diff --git a/dist/app.js b/dist/app.js index d66e1117..13b67274 100644 --- a/dist/app.js +++ b/dist/app.js @@ -10,6 +10,12 @@ Object.defineProperty(exports, "FN", { return _fn.default; } }); +Object.defineProperty(exports, "LIST_HOST_HOOK_CLASS", { + enumerable: true, + get: function get() { + return _list_graph.LIST_HOST_HOOK_CLASS; + } +}); exports.store = exports.SpectraEditor = void 0; var _react = _interopRequireDefault(require("react")); var _reactRedux = require("react-redux"); @@ -22,6 +28,7 @@ var _index = _interopRequireDefault(require("./reducers/index")); var _index2 = _interopRequireDefault(require("./sagas/index")); var _layer_init = _interopRequireDefault(require("./layer_init")); var _fn = _interopRequireDefault(require("./fn")); +var _list_graph = require("./constants/list_graph"); var _jsxRuntime = require("react/jsx-runtime"); /* eslint-disable react/function-component-definition, react/require-default-props */ diff --git a/dist/components/cmd_bar/common.js b/dist/components/cmd_bar/common.js index b851c116..1359642d 100644 --- a/dist/components/cmd_bar/common.js +++ b/dist/components/cmd_bar/common.js @@ -42,6 +42,13 @@ MuButton.displayName = 'MuButton'; const commonStyle = exports.commonStyle = { card: { margin: '0 0 5px 52px', + // The outlined selects below (Submit, Write Peaks, Write Intensity, Decimal) are + // compressed to `selectInput.height = 30`, well under MUI's outlined geometry, so + // their shrunk InputLabel floats to about -9px - outside this box. Without the + // padding it only rendered because nothing above clipped; a host that bounds the + // editor with `overflow: hidden` truncates the labels. Reserve the room here so it + // holds for any host. + paddingTop: 10, border: '1px solid white', borderRadius: 4 }, diff --git a/dist/components/cmd_bar/index.js b/dist/components/cmd_bar/index.js index e82cf604..38578d49 100644 --- a/dist/components/cmd_bar/index.js +++ b/dist/components/cmd_bar/index.js @@ -26,6 +26,7 @@ var _r08_change_axes = _interopRequireDefault(require("./r08_change_axes")); var _r09_detector = _interopRequireDefault(require("./r09_detector")); var _r10_cv_density = _interopRequireDefault(require("./r10_cv_density")); var _format = _interopRequireDefault(require("../../helpers/format")); +var _list_graph = require("../../constants/list_graph"); var _jsxRuntime = require("react/jsx-runtime"); /* eslint-disable prefer-object-spread, function-paren-newline, react/function-component-definition, react/require-default-props */ @@ -95,7 +96,7 @@ const CmdBar = ({ }); if (prependLcMsToolbar) { return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { - className: `${classes.card} ${classes.cardFlex}`, + className: `${_list_graph.LIST_HOST_HOOK_CLASS.CMD_BAR} ${classes.card} ${classes.cardFlex}`, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", { className: classes.lcMsToolbarLeft, children: prependLcMsToolbar @@ -109,7 +110,7 @@ const CmdBar = ({ }); } return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { - className: classes.card, + className: `${_list_graph.LIST_HOST_HOOK_CLASS.CMD_BAR} ${classes.card}`, children: [hideMainEditTools ? null : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, { children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_viewer.default, { editorOnly: editorOnly diff --git a/dist/components/d3_line_rect/index.js b/dist/components/d3_line_rect/index.js index 8e598035..5b300b6b 100644 --- a/dist/components/d3_line_rect/index.js +++ b/dist/components/d3_line_rect/index.js @@ -4,7 +4,7 @@ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefau Object.defineProperty(exports, "__esModule", { value: true }); -exports.isLcmsMsPageLoading = exports.default = void 0; +exports.sameSizes = exports.measurePane = exports.isLcmsMsPageLoading = exports.default = exports.SIZE_EPSILON = void 0; var _react = _interopRequireDefault(require("react")); var _reactRedux = require("react-redux"); var _redux = require("redux"); @@ -40,9 +40,43 @@ var _extractEntityLCMS = require("../../helpers/extractEntityLCMS"); var _jsxRuntime = require("react/jsx-runtime"); /* eslint-disable no-mixed-operators, prefer-object-spread, react/function-component-definition */ +// Fallback viewBox, used only until the panes can be measured (and in jsdom, where +// clientWidth/clientHeight are 0). const W = Math.round(window.innerWidth * 0.90 * 9 / 12); // ROI const H = Math.round(window.innerHeight * 0.90 * 0.8 / 3); // ROI +// Below this, the drawable area net of the focus classes' margins (l:60 r:5 t:5 b:40) +// stops being meaningful; clamp rather than let a scale range invert. +const MIN_PANE_W = 240; +const MIN_PANE_H = 96; + +// Each chart svg carries `preserveAspectRatio="xMinYMin meet"`, so it is the viewBox +// aspect - not the container - that decides how much of the pane the drawing fills. With +// a viewBox derived once from `window.innerWidth` at module load, a pane proportionally +// wider than that ratio scales the drawing down to its height and leaves the surplus +// width empty on the right, which is what a viewport wider than FHD produces. Measuring +// the pane and matching the viewBox to it removes the letterboxing in both directions. +const measurePane = node => { + if (!node) return null; + const { + clientWidth, + clientHeight + } = node; + if (!clientWidth || !clientHeight) return null; + return { + width: Math.max(Math.round(clientWidth), MIN_PANE_W), + height: Math.max(Math.round(clientHeight), MIN_PANE_H) + }; +}; +exports.measurePane = measurePane; +// A pane whose height is content-derived (any host that does not bound us - the +// standalone demo included) takes its height from the svg, whose height comes back from +// the viewBox we are about to set. Re-measuring integer client boxes across that round +// trip can differ by a pixel without anything really having moved, so require a real +// change before paying for a remount. +const SIZE_EPSILON = exports.SIZE_EPSILON = 2; +const sameSizes = (a, b) => Boolean(a) && Boolean(b) && ['line', 'multi', 'rect'].every(k => Math.abs(a[k].width - b[k].width) <= SIZE_EPSILON && Math.abs(a[k].height - b[k].height) <= SIZE_EPSILON); +exports.sameSizes = sameSizes; const toSeed = (xValues = [], yValues = []) => { const maxLength = Math.min(xValues.length, yValues.length); const seed = new Array(maxLength); @@ -77,9 +111,38 @@ const isLcmsMsPageLoading = (mzEntities = [], hplcMsSt = {}) => { exports.isLcmsMsPageLoading = isLcmsMsPageLoading; const styles = () => Object.assign({}, { lcMsStackRoot: { - margin: '0 0 5px 52px' + margin: '0 0 5px 52px', + // This is the only place the editor mounts three chart containers stacked in one + // pane instead of a single one. A host stylesheet that stretches a single chart + // with `.d3Line { height: 100% }` would otherwise make each of the three as tall + // as this whole pane and push the TIC and m/z graphs out of a bounded, clipped + // container. As a flex column they share the pane instead, so the stack fits + // whether or not the host ships such a rule. + display: 'flex', + flexDirection: 'column', + // Load-bearing under a host that makes this node a flex item (chemotion_ELN's + // `.MuiGrid-grid-xs-9 { display: flex; flex-direction: column }` does), where the + // initial `min-height: auto` would otherwise resolve to the stack's content size + // and defeat the shrink below. + minHeight: 0, + // All three panes need the SAME flex-basis or the deficit is shared in proportion + // to basis and one of them is squeezed to a fraction of a third. `height: 100%` + // rather than `flex-basis: 0`, because a host stylesheet loads after this JSS and + // chemotion_ELN already puts `height: 100%` on the two bare mounts with a higher + // specificity than anything reachable from here - a basis of 0 would lose that + // contest on `.d3Line`/`.d3Multi`, apply to the m/z panel alone, and collapse it to + // zero. Restating the same declaration agrees with such a host and supplies it for + // one that bounds our height without styling the mounts itself. Against an + // unbounded parent it computes to `auto`, i.e. the content height, as before. + '& > .d3Line, & > .d3Multi': { + flex: '1 1 auto', + minHeight: 0, + height: '100%' + } }, lcMsToolbarRow: { + // Never absorb the shrink the chart panes above negotiate. + flexShrink: 0, display: 'flex', flexWrap: 'wrap', alignItems: 'center', @@ -106,7 +169,12 @@ const styles = () => Object.assign({}, { flex: '0 1 auto' }, lcMsGraphPanel: { - position: 'relative' + position: 'relative', + // Wraps the m/z chart plus its loading overlay, so this - not `.d3Rect` - is the + // flex item beside the two bare mounts, and takes the same basis as they do. + flex: '1 1 auto', + minHeight: 0, + height: '100%' }, lcMsLoadingOverlay: { position: 'absolute', @@ -252,46 +320,21 @@ const ticSelect = (classes, hplcMsSt, handleTicChanged) => { class ViewerLineRect extends _react.default.Component { constructor(props) { super(props); - const { - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - ticEntities, - uvvisEntities, - uiSt - } = props; this.rootKlassLine = `.${_list_graph.LIST_ROOT_SVG_GRAPH.LINE}`; - this.lineFocus = new _line_focus.default({ - W, - H, - uvvisEntities, - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - graphIndex: 0, - uiSt - }); this.rootKlassMulti = `.${_list_graph.LIST_ROOT_SVG_GRAPH.MULTI}`; - this.multiFocus = new _multi_focus.default({ - W, - H, - ticEntities, - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - graphIndex: 1, - uiSt - }); this.rootKlassRect = `.${_list_graph.LIST_ROOT_SVG_GRAPH.RECT}`; - this.rectFocus = new _rect_focus.default({ - W, - H, - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - graphIndex: 2, - uiSt - }); + this.stackRef = /*#__PURE__*/_react.default.createRef(); + this.lineRef = /*#__PURE__*/_react.default.createRef(); + this.multiRef = /*#__PURE__*/_react.default.createRef(); + this.rectRef = /*#__PURE__*/_react.default.createRef(); + this.resizeObserver = null; + this.resizeFrame = null; + this.currentSizes = null; + + // Nothing is mounted yet, so this resolves to the fallback; componentDidMount + // re-measures and rebuilds against the real panes. + this.createFocuses(this.resolvePaneSizes()); + this.handleResize = this.handleResize.bind(this); this.extractSubView = this.extractSubView.bind(this); this.notifyHostOnSubViewerChange = this.notifyHostOnSubViewerChange.bind(this); this.extractUvvisView = this.extractUvvisView.bind(this); @@ -299,80 +342,8 @@ class ViewerLineRect extends _react.default.Component { this.handleUvvisRedo = this.handleUvvisRedo.bind(this); } componentDidMount() { - const { - curveSt, - feature, - ticEntities, - hplcMsSt, - tTrEndPts, - layoutSt, - isUiAddIntgSt, - isUiNoBrushSt, - integrationSt, - isHidden, - resetAllAct, - uiSt, - editPeakSt - } = this.props; - (0, _draw.drawDestroy)(this.rootKlassMulti); - (0, _draw.drawDestroy)(this.rootKlassLine); - (0, _draw.drawDestroy)(this.rootKlassRect); - resetAllAct(feature); - const { - zoom - } = uiSt; - const { - sweepExtent - } = zoom; - const uvvisViewFeature = this.extractUvvisView(); - let uvvisSeed = []; - if (uvvisViewFeature?.data?.[0]) { - const currentData = uvvisViewFeature.data[0]; - const { - x, - y - } = currentData; - uvvisSeed = toSeed(x, y); - } - (0, _draw.drawMain)(this.rootKlassLine, W, H, _list_graph.LIST_BRUSH_SVG_GRAPH.LINE); - this.lineFocus.create({ - filterSeed: uvvisSeed, - filterPeak: [], - tTrEndPts, - layoutSt, - isUiNoBrushSt: true, - sweepExtentSt: sweepExtent[0], - integrationSt, - isUiAddIntgSt, - editPeakSt, - hplcMsSt - }); - (0, _draw.drawLabel)(this.rootKlassLine, null, 'Minutes', 'Intensity'); - (0, _draw.drawDisplay)(this.rootKlassLine, false); - (0, _draw.drawMain)(this.rootKlassMulti, W, H, _list_graph.LIST_BRUSH_SVG_GRAPH.MULTI); - this.multiFocus.create({ - ticEntities, - curveSt, - hplcMsSt, - tTrEndPts, - layoutSt, - sweepExtentSt: sweepExtent[1], - isUiAddIntgSt, - isUiNoBrushSt - }); - (0, _draw.drawLabel)(this.rootKlassMulti, null, 'Minutes', 'Intensity'); - (0, _draw.drawDisplay)(this.rootKlassMulti, isHidden); - (0, _draw.drawMain)(this.rootKlassRect, W, H, _list_graph.LIST_BRUSH_SVG_GRAPH.RECT); - this.rectFocus.create({ - filterSeed: [], - filterPeak: [], - tTrEndPts, - layoutSt, - isUiNoBrushSt: true, - sweepExtentSt: sweepExtent[2] - }); - (0, _draw.drawLabel)(this.rootKlassRect, null, 'm/z', 'Intensity'); - (0, _draw.drawDisplay)(this.rootKlassRect, false); + this.setupResizeObserver(); + this.mountCharts(this.resolvePaneSizes(), true); } componentDidUpdate(prevProps) { const { @@ -399,7 +370,7 @@ class ViewerLineRect extends _react.default.Component { if (uvvisViewFeature?.data?.[0]) { const hasLineSvg = !!document.querySelector(`${this.rootKlassLine} .${_list_graph.LIST_BRUSH_SVG_GRAPH.LINE}`); if (!hasLineSvg) { - (0, _draw.drawMain)(this.rootKlassLine, W, H, _list_graph.LIST_BRUSH_SVG_GRAPH.LINE); + (0, _draw.drawMain)(this.rootKlassLine, this.currentSizes.line.width, this.currentSizes.line.height, _list_graph.LIST_BRUSH_SVG_GRAPH.LINE); } const currentData = uvvisViewFeature.data[0]; const { @@ -428,7 +399,7 @@ class ViewerLineRect extends _react.default.Component { if (this.multiFocus) { const hasMultiSvg = !!document.querySelector(`${this.rootKlassMulti} .${_list_graph.LIST_BRUSH_SVG_GRAPH.MULTI}`); if (!hasMultiSvg) { - (0, _draw.drawMain)(this.rootKlassMulti, W, H, _list_graph.LIST_BRUSH_SVG_GRAPH.MULTI); + (0, _draw.drawMain)(this.rootKlassMulti, this.currentSizes.multi.width, this.currentSizes.multi.height, _list_graph.LIST_BRUSH_SVG_GRAPH.MULTI); } this.multiFocus.update({ curveSt, @@ -463,7 +434,7 @@ class ViewerLineRect extends _react.default.Component { if (subViewFeature) { const hasRectSvg = !!document.querySelector(`${this.rootKlassRect} .${_list_graph.LIST_BRUSH_SVG_GRAPH.RECT}`); if (!hasRectSvg) { - (0, _draw.drawMain)(this.rootKlassRect, W, H, _list_graph.LIST_BRUSH_SVG_GRAPH.RECT); + (0, _draw.drawMain)(this.rootKlassRect, this.currentSizes.rect.width, this.currentSizes.rect.height, _list_graph.LIST_BRUSH_SVG_GRAPH.RECT); } const { threshold @@ -496,10 +467,33 @@ class ViewerLineRect extends _react.default.Component { } } componentWillUnmount() { + this.teardownResizeObserver(); (0, _draw.drawDestroy)(this.rootKlassLine); (0, _draw.drawDestroy)(this.rootKlassMulti); (0, _draw.drawDestroy)(this.rootKlassRect); } + + // Redraw only when a pane actually changed size. Against an unbounded host the measured + // size is the one the current viewBox already produces, so this settles after the first + // pass instead of feeding itself. + handleResize() { + // Never mutate layout synchronously inside a ResizeObserver callback. The remount + // resizes the subtree being observed, and the browser abandons the delivery pass with + // "ResizeObserver loop completed with undelivered notifications" - which surfaces as + // an uncaught application error, not just a console warning. Deferring to the next + // frame lets the observer finish before anything moves. + // + // d3_multi remounts synchronously and gets away with it because its resize path is + // gated to Cyclic Voltammetry, whose container height is fixed by CSS and so cannot + // be fed back into by a redraw. This stack has no such guarantee. + if (this.resizeFrame != null) return; + this.resizeFrame = window.requestAnimationFrame(() => { + this.resizeFrame = null; + const sizes = this.resolvePaneSizes(); + if (sameSizes(sizes, this.currentSizes)) return; + this.mountCharts(sizes, false); + }); + } handleUvvisUndo() { const { uvvisUndoAct @@ -512,6 +506,176 @@ class ViewerLineRect extends _react.default.Component { } = this.props; uvvisRedoAct(); } + setupResizeObserver() { + if (typeof ResizeObserver === 'undefined') return; + if (!this.stackRef.current || this.resizeObserver) return; + this.resizeObserver = new ResizeObserver(this.handleResize); + this.resizeObserver.observe(this.stackRef.current); + } + + // Measure every pane, so a stack whose three panes differ in height (a host that has + // not equalised them) still gets a correct viewBox each. + resolvePaneSizes() { + const fallback = { + width: W, + height: H + }; + return { + line: measurePane(this.lineRef?.current) || fallback, + multi: measurePane(this.multiRef?.current) || fallback, + rect: measurePane(this.rectRef?.current) || fallback + }; + } + createFocuses(sizes) { + const { + clickUiTargetAct, + selectUiSweepAct, + scrollUiWheelAct, + ticEntities, + uvvisEntities, + uiSt + } = this.props; + const shared = { + clickUiTargetAct, + selectUiSweepAct, + scrollUiWheelAct, + uiSt + }; + this.lineFocus = new _line_focus.default({ + W: sizes.line.width, + H: sizes.line.height, + uvvisEntities, + graphIndex: 0, + ...shared + }); + this.multiFocus = new _multi_focus.default({ + W: sizes.multi.width, + H: sizes.multi.height, + ticEntities, + graphIndex: 1, + ...shared + }); + this.rectFocus = new _rect_focus.default({ + W: sizes.rect.width, + H: sizes.rect.height, + graphIndex: 2, + ...shared + }); + } + teardownResizeObserver() { + if (this.resizeFrame != null) { + window.cancelAnimationFrame(this.resizeFrame); + this.resizeFrame = null; + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + } + + // The whole draw sequence, parameterised by pane size so a resize can re-run it. Only + // the first run resets redux (`shouldReset`); a resize must not discard the user's zoom, + // threshold or selection. + mountCharts(sizes, shouldReset = false) { + const { + curveSt, + feature, + ticEntities, + hplcMsSt, + tTrEndPts, + layoutSt, + isUiAddIntgSt, + isUiNoBrushSt, + integrationSt, + isHidden, + resetAllAct, + uiSt, + editPeakSt + } = this.props; + this.currentSizes = sizes; + (0, _draw.drawDestroy)(this.rootKlassMulti); + (0, _draw.drawDestroy)(this.rootKlassLine); + (0, _draw.drawDestroy)(this.rootKlassRect); + if (shouldReset) { + resetAllAct(feature); + } + this.createFocuses(sizes); + const { + zoom + } = uiSt; + const { + sweepExtent + } = zoom; + const uvvisViewFeature = this.extractUvvisView(); + let uvvisSeed = []; + if (uvvisViewFeature?.data?.[0]) { + const currentData = uvvisViewFeature.data[0]; + const { + x, + y + } = currentData; + uvvisSeed = toSeed(x, y); + } + (0, _draw.drawMain)(this.rootKlassLine, sizes.line.width, sizes.line.height, _list_graph.LIST_BRUSH_SVG_GRAPH.LINE); + this.lineFocus.create({ + filterSeed: uvvisSeed, + filterPeak: [], + tTrEndPts, + layoutSt, + isUiNoBrushSt: true, + sweepExtentSt: sweepExtent[0], + integrationSt, + isUiAddIntgSt, + editPeakSt, + hplcMsSt + }); + (0, _draw.drawLabel)(this.rootKlassLine, null, 'Minutes', 'Intensity'); + (0, _draw.drawDisplay)(this.rootKlassLine, false); + const multiSize = sizes.multi; + (0, _draw.drawMain)(this.rootKlassMulti, multiSize.width, multiSize.height, _list_graph.LIST_BRUSH_SVG_GRAPH.MULTI); + this.multiFocus.create({ + ticEntities, + curveSt, + hplcMsSt, + tTrEndPts, + layoutSt, + sweepExtentSt: sweepExtent[1], + isUiAddIntgSt, + isUiNoBrushSt + }); + (0, _draw.drawLabel)(this.rootKlassMulti, null, 'Minutes', 'Intensity'); + (0, _draw.drawDisplay)(this.rootKlassMulti, isHidden); + + // Seed the m/z pane with the scan that is currently selected rather than with an + // empty series. On first mount there is none and this stays [] as before - but a + // resize remount happens long after componentDidUpdate has drawn a scan here, and + // recreating the pane empty would silently wipe it with nothing to redraw it. + const subViewFeature = this.extractSubView(); + let subSeed = []; + let subTrEndPts = tTrEndPts; + let subLabel = null; + if (subViewFeature?.data?.[0]) { + const { + x, + y + } = subViewFeature.data[0]; + subSeed = toSeed(x, y); + subTrEndPts = (0, _chem.convertThresEndPts)(subViewFeature, hplcMsSt?.threshold?.value); + const pageValue = (0, _pageValue.parseFeaturePageValue)(subViewFeature); + subLabel = Number.isFinite(pageValue) ? pageValue : subViewFeature?.pageValue ?? subViewFeature?.page ?? null; + } + (0, _draw.drawMain)(this.rootKlassRect, sizes.rect.width, sizes.rect.height, _list_graph.LIST_BRUSH_SVG_GRAPH.RECT); + this.rectFocus.create({ + filterSeed: subSeed, + filterPeak: [], + tTrEndPts: subTrEndPts, + layoutSt, + isUiNoBrushSt: true, + sweepExtentSt: sweepExtent[2] + }); + (0, _draw.drawLabel)(this.rootKlassRect, subLabel != null ? `${subLabel} min` : null, 'm/z', 'Intensity'); + (0, _draw.drawDisplay)(this.rootKlassRect, false); + } extractUvvisView() { const { uvvisEntities, @@ -667,7 +831,8 @@ class ViewerLineRect extends _react.default.Component { selectWavelengthAct(event); }; return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { - className: classes.lcMsStackRoot, + className: `${_list_graph.LIST_HOST_HOOK_CLASS.LCMS_STACK} ${classes.lcMsStackRoot}`, + ref: this.stackRef, children: [omitUvvisToolbarRow ? null : /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: classes.lcMsToolbarRow, children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { @@ -720,7 +885,8 @@ class ViewerLineRect extends _react.default.Component { className: classes.lcMsToolbarRight })] }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: _list_graph.LIST_ROOT_SVG_GRAPH.LINE + className: _list_graph.LIST_ROOT_SVG_GRAPH.LINE, + ref: this.lineRef }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: classes.lcMsToolbarRow, children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { @@ -738,23 +904,24 @@ class ViewerLineRect extends _react.default.Component { className: classes.lcMsToolbarRight })] }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: _list_graph.LIST_ROOT_SVG_GRAPH.MULTI + className: _list_graph.LIST_ROOT_SVG_GRAPH.MULTI, + ref: this.multiRef }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: classes.lcMsToolbarRow, - children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", { + children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: classes.lcMsToolbarLeft, - children: zoomView(classes, 2, uiSt, zoomInAct) - }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: classes.lcMsToolbarRight, - children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_r03_threshold.default, { + children: [zoomView(classes, 2, uiSt, zoomInAct), /*#__PURE__*/(0, _jsxRuntime.jsx)(_r03_threshold.default, { feature: resolvedFeature, hasEdit: hasEdit - }) + })] + }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { + className: classes.lcMsToolbarRight })] }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { - className: classes.lcMsGraphPanel, + className: `${_list_graph.LIST_HOST_HOOK_CLASS.LCMS_GRAPH_PANEL} ${classes.lcMsGraphPanel}`, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: _list_graph.LIST_ROOT_SVG_GRAPH.RECT + className: _list_graph.LIST_ROOT_SVG_GRAPH.RECT, + ref: this.rectRef }), isMsLoading ? /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { className: classes.lcMsLoadingOverlay, "data-testid": "lcms-ms-loading", diff --git a/dist/components/hplc_viewer.js b/dist/components/hplc_viewer.js index a23fb588..e3234e94 100644 --- a/dist/components/hplc_viewer.js +++ b/dist/components/hplc_viewer.js @@ -16,6 +16,7 @@ var _index2 = _interopRequireDefault(require("./cmd_bar/index")); var _index3 = _interopRequireDefault(require("./d3_line_rect/index")); var _lc_ms_uv_tools_bar = _interopRequireDefault(require("./lc_ms_uv_tools_bar")); var _extractEntityLCMS = require("../helpers/extractEntityLCMS"); +var _list_graph = require("../constants/list_graph"); var _jsxRuntime = require("react/jsx-runtime"); /* eslint-disable react/default-props-match-prop-types, react/require-default-props, react/no-unused-prop-types, react/jsx-boolean-value, @@ -87,7 +88,7 @@ class HPLCViewer extends _react.default.Component { hideMainEditTools: true, prependLcMsToolbar: /*#__PURE__*/(0, _jsxRuntime.jsx)(_lc_ms_uv_tools_bar.default, {}) }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: "react-spectrum-editor", + className: _list_graph.LIST_HOST_HOOK_CLASS.EDITOR_ROOT, children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_material.Grid, { container: true, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_material.Grid, { diff --git a/dist/components/multi_jcamps_viewer.js b/dist/components/multi_jcamps_viewer.js index 0cd897f7..df3467ae 100644 --- a/dist/components/multi_jcamps_viewer.js +++ b/dist/components/multi_jcamps_viewer.js @@ -20,6 +20,7 @@ var _curve = require("../actions/curve"); var _cyclic_voltammetry = require("../actions/cyclic_voltammetry"); var _list_layout = require("../constants/list_layout"); var _format = _interopRequireDefault(require("../helpers/format")); +var _list_graph = require("../constants/list_graph"); var _jsxRuntime = require("react/jsx-runtime"); /* eslint-disable react/default-props-match-prop-types, react/require-default-props, react/no-unused-prop-types, react/jsx-boolean-value, @@ -37,7 +38,10 @@ const styles = () => ({ fontSize: '14px' }, cvEditor: { - height: 'calc(90vh - 220px)', + // 230, not 220: `commonStyle.card` reserves 10px above the toolbar for the + // outlined selects' floating labels, and this constant is the budget left + // over after it. Keep the two in step. + height: 'calc(90vh - 230px)', display: 'flex', flexDirection: 'column', minHeight: 0, @@ -130,7 +134,7 @@ class MultiJcampsViewer extends _react.default.Component { editorOnly: editorOnly, hideThreshold: !_format.default.isNmrLayout(layoutSt) }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: (0, _classnames.default)('react-spectrum-editor', isCyclicVolta && classes.cvEditor), + className: (0, _classnames.default)(_list_graph.LIST_HOST_HOOK_CLASS.EDITOR_ROOT, isCyclicVolta && classes.cvEditor), children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_Grid.default, { container: true, className: isCyclicVolta ? classes.cvTopRow : undefined, diff --git a/dist/components/panel/index.js b/dist/components/panel/index.js index 80bc732a..bf10fc9d 100644 --- a/dist/components/panel/index.js +++ b/dist/components/panel/index.js @@ -34,9 +34,22 @@ const theme = (0, _styles.createTheme)({ }); const styles = () => ({ panels: { - maxHeight: 'calc(90vh - 220px)', + // `display: table` silently defeated both declarations below it: CSS leaves the + // effect of `max-height` on a table box undefined, and `overflow` does not make one + // a scroll container. So this panel never capped and never scrolled - it grew to the + // full height of its accordions (measured at 1512px for five expanded panels against + // a 748px column) and simply overflowed whatever contained it. That went unnoticed + // while the host let the page grow; a host that bounds the editor and clips it + // instead shows the panel truncated at the bottom with no way to scroll to the rest. + display: 'block', + // Take the height from a bounded parent when there is one; `height: 100%` against an + // unbounded parent computes to `auto`, so the viewport-derived cap below still + // applies in a host that does not constrain us (and now actually works). + height: '100%', + minHeight: 0, + // 230, not 220 - see the matching constant in multi_jcamps_viewer.js. + maxHeight: 'calc(90vh - 230px)', // ROI - display: 'table', overflowX: 'hidden', overflowY: 'auto', margin: '5px 0 0 0', diff --git a/dist/constants/list_graph.js b/dist/constants/list_graph.js index 24d49820..4e2e91cc 100644 --- a/dist/constants/list_graph.js +++ b/dist/constants/list_graph.js @@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); -exports.LIST_ROOT_SVG_GRAPH = exports.LIST_BRUSH_SVG_GRAPH = void 0; +exports.LIST_ROOT_SVG_GRAPH = exports.LIST_HOST_HOOK_CLASS = exports.LIST_BRUSH_SVG_GRAPH = void 0; const LIST_ROOT_SVG_GRAPH = exports.LIST_ROOT_SVG_GRAPH = { LINE: 'd3Line', RECT: 'd3Rect', @@ -13,4 +13,24 @@ const LIST_BRUSH_SVG_GRAPH = exports.LIST_BRUSH_SVG_GRAPH = { LINE: 'd3Svg', RECT: 'd3SvgRect', MULTI: 'd3SvgMulti' +}; + +// Stable, non-JSS class names a host application (chemotion_ELN) styles against. The +// classes withStyles generates around these nodes are opaque (`jss8 jss4`) and change +// between builds, so a host stylesheet has nothing else to target. Treat these as part +// of the public DOM contract: do not rename them without a host-side change. +// +// All entries are `rse-` prefixed. These land in a host's global, non-modular stylesheet +// alongside its own classes, so an unprefixed generic name like `lcms-stack` would be one +// collision away from a host's own LC/MS markup - and the contract above makes such a name +// expensive to change afterwards. +// +// EDITOR_ROOT is the oldest of these and the one hosts already target; it is listed here +// so it is covered by the same contract as the rest, rather than living on as a bare +// string literal in four components. +const LIST_HOST_HOOK_CLASS = exports.LIST_HOST_HOOK_CLASS = { + EDITOR_ROOT: 'react-spectrum-editor', + CMD_BAR: 'rse-cmd-bar', + LCMS_STACK: 'rse-lcms-stack', + LCMS_GRAPH_PANEL: 'rse-lcms-graph-panel' }; \ No newline at end of file diff --git a/dist/layer_prism.js b/dist/layer_prism.js index 07650926..30b3b6f2 100644 --- a/dist/layer_prism.js +++ b/dist/layer_prism.js @@ -16,6 +16,7 @@ var _index2 = _interopRequireDefault(require("./components/cmd_bar/index")); var _layer_content = _interopRequireDefault(require("./layer_content")); var _list_ui = require("./constants/list_ui"); var _extractParams = require("./helpers/extractParams"); +var _list_graph = require("./constants/list_graph"); var _jsxRuntime = require("react/jsx-runtime"); /* eslint-disable prefer-object-spread, default-param-last, react/function-component-definition, react/require-default-props @@ -74,7 +75,7 @@ const LayerPrism = ({ operations: operations, editorOnly: editorOnly }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: "react-spectrum-editor", + className: _list_graph.LIST_HOST_HOOK_CLASS.EDITOR_ROOT, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_Grid.default, { container: true, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_Grid.default, { @@ -103,7 +104,7 @@ const LayerPrism = ({ operations: operations, editorOnly: editorOnly }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { - className: "react-spectrum-editor", + className: _list_graph.LIST_HOST_HOOK_CLASS.EDITOR_ROOT, children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_Grid.default, { container: true, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_Grid.default, { diff --git a/docs/architecture/frontend-architecture.md b/docs/architecture/frontend-architecture.md index e1340faa..2f87d15d 100644 --- a/docs/architecture/frontend-architecture.md +++ b/docs/architecture/frontend-architecture.md @@ -208,6 +208,32 @@ The following table maps each host contract to runtime code inside the editor. `entity` must include `layout`, `spectra`, and `features` in the shape produced by `FN.ExtractJcamp` (`{ spectra, features, layout }`, plus layout-specific fields). `GetComparisons` transforms comparison entities in `jcamp` state for IR, HPLC UV/VIS, and XRD overlays. +### DOM hooks for host stylesheets + +`withStyles` generates opaque class names (`jss8 jss4`) that change between builds, so a host +stylesheet cannot target the editor's own containers. `LIST_HOST_HOOK_CLASS` +(`src/constants/list_graph.js`) adds stable class names alongside them. They are part of the +public DOM contract — renaming one is a breaking change for the host. + +| Class | Node | Why a host needs it | +|---|---|---| +| `react-spectrum-editor` | editor root, below `CmdBar` (`hplc_viewer.js`, `layer_prism.js`, `multi_jcamps_viewer.js`) | The node every host stylesheet already bounds and clips. Unprefixed for history: it predates this contract and hosts target it today. | +| `rse-cmd-bar` | `CmdBar` card root (`src/components/cmd_bar/index.js`) — the whole toolbar card, not one row | The toolbar's outlined selects are compressed to 30px, so their shrunk `InputLabel` floats above its own box. A host that bounds the editor must not clip this card. | +| `rse-lcms-stack` | LC/MS stack root (`src/components/d3_line_rect/index.js`) | The one place the editor mounts three chart containers (`.d3Line`, `.d3Multi`, `.d3Rect`) stacked in a single pane rather than one. A blanket `.d3Line { height: 100% }` rule written for the single-chart layouts triples this stack's height. | +| `rse-lcms-graph-panel` | m/z pane wrapper inside the stack | Wraps `.d3Rect` plus the loading overlay, so it — not `.d3Rect` — is the flex item beside the other two charts. | + +New hooks are `rse-` prefixed: they land in a host's global, non-modular stylesheet next to +its own classes, so a generic name would be one collision away from the host's own markup — +and the no-rename rule above makes that expensive to undo. `react-spectrum-editor` keeps its +historical name because hosts already depend on it. + +The chart mount classes themselves (`d3Line` / `d3Multi` / `d3Rect` and the inner +`d3Svg` / `d3SvgMulti` / `d3SvgRect`) are already stable (`LIST_ROOT_SVG_GRAPH`, +`LIST_BRUSH_SVG_GRAPH`). Each chart is drawn as a `viewBox` with +`preserveAspectRatio="xMinYMin meet"` (`src/components/common/draw.js`) sized from +`window.inner*` at mount, with the LC/MS height already divided by three — so forcing +`height: 100%` on a mount letterboxes the chart rather than enlarging it. + ## Runtime Synchronization Patterns Runtime synchronization is distributed across `LayerInit`, reducers, sagas, and D3 viewers. The editor relies on action propagation rather than a single central controller. diff --git a/src/__tests__/units/components/d3_line_rect.test.js b/src/__tests__/units/components/d3_line_rect.test.js index eda46908..3cb2e464 100644 --- a/src/__tests__/units/components/d3_line_rect.test.js +++ b/src/__tests__/units/components/d3_line_rect.test.js @@ -1,4 +1,4 @@ -import { isLcmsMsPageLoading } from '../../../components/d3_line_rect/index'; +import { isLcmsMsPageLoading, measurePane, sameSizes } from '../../../components/d3_line_rect/index'; import { pickTicIndex } from '../../../components/d3_line_rect/multi_focus'; import RectFocus from '../../../components/d3_line_rect/rect_focus'; @@ -83,3 +83,69 @@ describe('RectFocus.drawBar with an empty threshold-endpoint list (B7)', () => { expect(() => rf.drawBar()).not.toThrow(); }); }); + +// The letterboxing this guards against is a layout effect jsdom cannot observe (it reports +// clientWidth/clientHeight as 0), so what is testable here is the measurement logic and its +// fallback. The claim that the panes actually fill their width rests on browser +// measurement, recorded in the commit message. +describe('measurePane (LC/MS pane sizing)', () => { + it('returns null for a missing node, so callers fall back to the fixed viewBox', () => { + expect(measurePane(null)).toBeNull(); + expect(measurePane(undefined)).toBeNull(); + }); + + it('returns null for an unlaid-out node rather than a degenerate 0x0 viewBox', () => { + // This is the jsdom case, and also a pane measured before first paint. + expect(measurePane({ clientWidth: 0, clientHeight: 0 })).toBeNull(); + expect(measurePane({ clientWidth: 800, clientHeight: 0 })).toBeNull(); + }); + + it('measures a laid-out pane', () => { + expect(measurePane({ clientWidth: 1296, clientHeight: 197 })) + .toEqual({ width: 1296, height: 197 }); + }); + + it('rounds sub-pixel box metrics', () => { + expect(measurePane({ clientWidth: 1295.6, clientHeight: 196.4 })) + .toEqual({ width: 1296, height: 196 }); + }); + + it('clamps below the focus classes own margins, where a scale range would invert', () => { + // margins are l:60 r:5 t:5 b:40, so an unclamped 40x20 pane yields a negative + // drawable width and height. + expect(measurePane({ clientWidth: 40, clientHeight: 20 })) + .toEqual({ width: 240, height: 96 }); + }); +}); + +describe('sameSizes (resize guard)', () => { + const sizes = (w, h) => ({ + line: { width: w, height: h }, + multi: { width: w, height: h }, + rect: { width: w, height: h }, + }); + + it('treats a null previous size as different, so the first measure always mounts', () => { + expect(sameSizes(sizes(100, 50), null)).toBe(false); + }); + + it('is true for identical sizes, which is what stops a resize feedback loop', () => { + expect(sameSizes(sizes(1296, 197), sizes(1296, 197))).toBe(true); + }); + + it('detects a real change in any single pane', () => { + const a = sizes(1296, 197); + const b = sizes(1296, 197); + b.rect = { width: 1296, height: 260 }; + expect(sameSizes(a, b)).toBe(false); + }); + + it('absorbs a one-pixel difference, which is round-trip noise rather than a resize', () => { + // A content-height pane takes its height from the svg, whose height comes back from + // the viewBox this measurement sets. Treating a 1px integer-rounding difference as a + // resize would remount forever. + const a = sizes(1296, 197); + const b = sizes(1297, 198); + expect(sameSizes(a, b)).toBe(true); + }); +}); diff --git a/src/__tests__/units/components/host_dom_hooks.test.js b/src/__tests__/units/components/host_dom_hooks.test.js new file mode 100644 index 00000000..afc93320 --- /dev/null +++ b/src/__tests__/units/components/host_dom_hooks.test.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Provider } from 'react-redux'; +import { createTheme } from '@mui/material'; +import { ThemeProvider } from '@mui/styles'; + +import { store } from '../../../app'; +import CmdBar from '../../../components/cmd_bar/index'; +import ViewerLineRect from '../../../components/d3_line_rect/index'; +import { LIST_HOST_HOOK_CLASS } from '../../../constants/list_graph'; + +// chemotion_ELN styles the editor from its own stylesheet and can only target class names +// that survive a build - withStyles emits opaque `jss8 jss4` ones. These hooks are +// therefore a published contract: a rename silently breaks the host's layout (the LC/MS +// three-chart stack collapsing to its first pane, the toolbar's floating select labels +// getting clipped), with nothing failing here. +describe('host DOM hooks', () => { + // d3-tip attaches to the chart svg on mount and calls SVG geometry APIs jsdom does not + // implement. `beforeAll` runs before the first test of the whole describe, not just the + // chart one, and nothing restores the prototype afterwards - so treat stubbed SVG + // geometry as in force for every test in this file. + beforeAll(() => { + const proto = window.SVGSVGElement.prototype; + proto.createSVGPoint = proto.createSVGPoint || (() => ({ + x: 0, + y: 0, + matrixTransform: () => ({ x: 0, y: 0 }), + })); + proto.getScreenCTM = proto.getScreenCTM || (() => ({ + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, + inverse: () => ({ + a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, + }), + })); + }); + + // Asserted per entry rather than with toEqual on the whole object: renaming one of these + // is the breaking change worth gating, while adding a fourth hook is not. + it.each([ + ['EDITOR_ROOT', 'react-spectrum-editor'], + ['CMD_BAR', 'rse-cmd-bar'], + ['LCMS_STACK', 'rse-lcms-stack'], + ['LCMS_GRAPH_PANEL', 'rse-lcms-graph-panel'], + ])('pins the published class name for %s', (key, className) => { + expect(LIST_HOST_HOOK_CLASS[key]).toEqual(className); + }); + + // The editor's own store, so every slice these connected components select from is + // present without hand-rolling a fixture of the whole state tree. Unlike the rest of the + // component suite (which uses redux-mock-store), this one boots the real store because + // ViewerLineRect selects from six slices; the trade-off is that it is shared mutable + // state, so the assertions below are on rendered DOM only, never on store contents. + const theme = createTheme(); + const withStore = (ui) => render( + + {ui} + , + ); + + it('marks the CmdBar root', () => { + const { container } = withStore( + , + ); + expect(container.querySelector(`.${LIST_HOST_HOOK_CLASS.CMD_BAR}`)).toBeInTheDocument(); + }); + + it('marks the LC/MS stack root and the m/z pane', () => { + const { container } = withStore( + , + ); + const stack = container.querySelector(`.${LIST_HOST_HOOK_CLASS.LCMS_STACK}`); + expect(stack).toBeInTheDocument(); + // The three chart mounts the host's height rules act on must all be inside the stack, + // otherwise a host rule scoped to the stack misses one of them. + expect(stack.querySelector('.d3Line')).toBeInTheDocument(); + expect(stack.querySelector('.d3Multi')).toBeInTheDocument(); + const panel = stack.querySelector(`.${LIST_HOST_HOOK_CLASS.LCMS_GRAPH_PANEL}`); + expect(panel).toBeInTheDocument(); + expect(panel.querySelector('.d3Rect')).toBeInTheDocument(); + }); +}); diff --git a/src/app.js b/src/app.js index 8017f46e..f6e86d97 100644 --- a/src/app.js +++ b/src/app.js @@ -13,6 +13,7 @@ import reducers from './reducers/index'; import sagas from './sagas/index'; import LayerInit from './layer_init'; import FN from './fn'; +import { LIST_HOST_HOOK_CLASS } from './constants/list_graph'; // - - - store & middleware - - - const sagaMiddleware = createSagaMiddleware(); @@ -118,4 +119,9 @@ SpectraEditor.defaultProps = { export { SpectraEditor, FN, store, + // Published DOM hook class names - see docs/architecture/frontend-architecture.md. + // Exported here so a host can reference them programmatically instead of deep-importing + // dist/constants/list_graph or hard-coding the literals, which is the coupling the + // constant exists to remove. + LIST_HOST_HOOK_CLASS, }; diff --git a/src/components/cmd_bar/common.js b/src/components/cmd_bar/common.js index c2819cfe..b94778cb 100644 --- a/src/components/cmd_bar/common.js +++ b/src/components/cmd_bar/common.js @@ -32,6 +32,13 @@ MuButton.displayName = 'MuButton'; const commonStyle = { card: { margin: '0 0 5px 52px', + // The outlined selects below (Submit, Write Peaks, Write Intensity, Decimal) are + // compressed to `selectInput.height = 30`, well under MUI's outlined geometry, so + // their shrunk InputLabel floats to about -9px - outside this box. Without the + // padding it only rendered because nothing above clipped; a host that bounds the + // editor with `overflow: hidden` truncates the labels. Reserve the room here so it + // holds for any host. + paddingTop: 10, border: '1px solid white', borderRadius: 4, }, diff --git a/src/components/cmd_bar/index.js b/src/components/cmd_bar/index.js index 450c109d..e8311550 100644 --- a/src/components/cmd_bar/index.js +++ b/src/components/cmd_bar/index.js @@ -23,6 +23,7 @@ import ChangeAxes from './r08_change_axes'; import Detector from './r09_detector'; import CvDensityControls from './r10_cv_density'; import Format from '../../helpers/format'; +import { LIST_HOST_HOOK_CLASS } from '../../constants/list_graph'; const styles = () => ( Object.assign( @@ -95,7 +96,7 @@ const CmdBar = ({ if (prependLcMsToolbar) { return ( -
+
{ prependLcMsToolbar }
@@ -109,7 +110,7 @@ const CmdBar = ({ } return ( -
+
{ hideMainEditTools ? null : ( <> diff --git a/src/components/d3_line_rect/index.js b/src/components/d3_line_rect/index.js index 94c562f0..978ea80a 100644 --- a/src/components/d3_line_rect/index.js +++ b/src/components/d3_line_rect/index.js @@ -37,16 +37,54 @@ import { import { LIST_UI_SWEEP_TYPE, LIST_NON_BRUSH_TYPES } from '../../constants/list_ui'; import renderWavelengthSelect from '../../features/lc-ms/ui/wavelengthSelect'; import { parseFeaturePageValue as parsePageValue } from '../../features/lc-ms/parsing/pageValue'; -import { LIST_ROOT_SVG_GRAPH, LIST_BRUSH_SVG_GRAPH } from '../../constants/list_graph'; +import { + LIST_ROOT_SVG_GRAPH, LIST_BRUSH_SVG_GRAPH, LIST_HOST_HOOK_CLASS, +} from '../../constants/list_graph'; import PeakGroup from '../cmd_bar/08_peak_group'; import Threshold from '../cmd_bar/r03_threshold'; import Integration from '../cmd_bar/04_integration'; import Peak from '../cmd_bar/03_peak'; import { getLcMsInfo } from '../../helpers/extractEntityLCMS'; +// Fallback viewBox, used only until the panes can be measured (and in jsdom, where +// clientWidth/clientHeight are 0). const W = Math.round(window.innerWidth * 0.90 * 9 / 12); // ROI const H = Math.round(window.innerHeight * 0.90 * 0.8 / 3); // ROI +// Below this, the drawable area net of the focus classes' margins (l:60 r:5 t:5 b:40) +// stops being meaningful; clamp rather than let a scale range invert. +const MIN_PANE_W = 240; +const MIN_PANE_H = 96; + +// Each chart svg carries `preserveAspectRatio="xMinYMin meet"`, so it is the viewBox +// aspect - not the container - that decides how much of the pane the drawing fills. With +// a viewBox derived once from `window.innerWidth` at module load, a pane proportionally +// wider than that ratio scales the drawing down to its height and leaves the surplus +// width empty on the right, which is what a viewport wider than FHD produces. Measuring +// the pane and matching the viewBox to it removes the letterboxing in both directions. +export const measurePane = (node) => { + if (!node) return null; + const { clientWidth, clientHeight } = node; + if (!clientWidth || !clientHeight) return null; + return { + width: Math.max(Math.round(clientWidth), MIN_PANE_W), + height: Math.max(Math.round(clientHeight), MIN_PANE_H), + }; +}; + +export // A pane whose height is content-derived (any host that does not bound us - the +// standalone demo included) takes its height from the svg, whose height comes back from +// the viewBox we are about to set. Re-measuring integer client boxes across that round +// trip can differ by a pixel without anything really having moved, so require a real +// change before paying for a remount. +const SIZE_EPSILON = 2; + +export const sameSizes = (a, b) => Boolean(a) && Boolean(b) + && ['line', 'multi', 'rect'].every((k) => ( + Math.abs(a[k].width - b[k].width) <= SIZE_EPSILON + && Math.abs(a[k].height - b[k].height) <= SIZE_EPSILON + )); + const toSeed = (xValues = [], yValues = []) => { const maxLength = Math.min(xValues.length, yValues.length); const seed = new Array(maxLength); @@ -89,8 +127,37 @@ const styles = () => ( { lcMsStackRoot: { margin: '0 0 5px 52px', + // This is the only place the editor mounts three chart containers stacked in one + // pane instead of a single one. A host stylesheet that stretches a single chart + // with `.d3Line { height: 100% }` would otherwise make each of the three as tall + // as this whole pane and push the TIC and m/z graphs out of a bounded, clipped + // container. As a flex column they share the pane instead, so the stack fits + // whether or not the host ships such a rule. + display: 'flex', + flexDirection: 'column', + // Load-bearing under a host that makes this node a flex item (chemotion_ELN's + // `.MuiGrid-grid-xs-9 { display: flex; flex-direction: column }` does), where the + // initial `min-height: auto` would otherwise resolve to the stack's content size + // and defeat the shrink below. + minHeight: 0, + // All three panes need the SAME flex-basis or the deficit is shared in proportion + // to basis and one of them is squeezed to a fraction of a third. `height: 100%` + // rather than `flex-basis: 0`, because a host stylesheet loads after this JSS and + // chemotion_ELN already puts `height: 100%` on the two bare mounts with a higher + // specificity than anything reachable from here - a basis of 0 would lose that + // contest on `.d3Line`/`.d3Multi`, apply to the m/z panel alone, and collapse it to + // zero. Restating the same declaration agrees with such a host and supplies it for + // one that bounds our height without styling the mounts itself. Against an + // unbounded parent it computes to `auto`, i.e. the content height, as before. + '& > .d3Line, & > .d3Multi': { + flex: '1 1 auto', + minHeight: 0, + height: '100%', + }, }, lcMsToolbarRow: { + // Never absorb the shrink the chart panes above negotiate. + flexShrink: 0, display: 'flex', flexWrap: 'wrap', alignItems: 'center', @@ -118,6 +185,11 @@ const styles = () => ( }, lcMsGraphPanel: { position: 'relative', + // Wraps the m/z chart plus its loading overlay, so this - not `.d3Rect` - is the + // flex item beside the two bare mounts, and takes the same basis as they do. + flex: '1 1 auto', + minHeight: 0, + height: '100%', }, lcMsLoadingOverlay: { position: 'absolute', @@ -263,44 +335,23 @@ class ViewerLineRect extends React.Component { constructor(props) { super(props); - const { - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - ticEntities, - uvvisEntities, - uiSt, - } = props; - this.rootKlassLine = `.${LIST_ROOT_SVG_GRAPH.LINE}`; - this.lineFocus = new LineFocus({ - W, - H, - uvvisEntities, - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - graphIndex: 0, - uiSt, - }); - this.rootKlassMulti = `.${LIST_ROOT_SVG_GRAPH.MULTI}`; - this.multiFocus = new MultiFocus({ - W, - H, - ticEntities, - clickUiTargetAct, - selectUiSweepAct, - scrollUiWheelAct, - graphIndex: 1, - uiSt, - }); - this.rootKlassRect = `.${LIST_ROOT_SVG_GRAPH.RECT}`; - this.rectFocus = new RectFocus({ - W, H, clickUiTargetAct, selectUiSweepAct, scrollUiWheelAct, graphIndex: 2, uiSt, - }); + this.stackRef = React.createRef(); + this.lineRef = React.createRef(); + this.multiRef = React.createRef(); + this.rectRef = React.createRef(); + this.resizeObserver = null; + this.resizeFrame = null; + this.currentSizes = null; + + // Nothing is mounted yet, so this resolves to the fallback; componentDidMount + // re-measures and rebuilds against the real panes. + this.createFocuses(this.resolvePaneSizes()); + + this.handleResize = this.handleResize.bind(this); this.extractSubView = this.extractSubView.bind(this); this.notifyHostOnSubViewerChange = this.notifyHostOnSubViewerChange.bind(this); this.extractUvvisView = this.extractUvvisView.bind(this); @@ -309,71 +360,8 @@ class ViewerLineRect extends React.Component { } componentDidMount() { - const { - curveSt, feature, ticEntities, hplcMsSt, - tTrEndPts, layoutSt, - isUiAddIntgSt, isUiNoBrushSt, - integrationSt, - isHidden, - resetAllAct, uiSt, - editPeakSt, - } = this.props; - drawDestroy(this.rootKlassMulti); - drawDestroy(this.rootKlassLine); - drawDestroy(this.rootKlassRect); - resetAllAct(feature); - - const { zoom } = uiSt; - const { sweepExtent } = zoom; - - const uvvisViewFeature = this.extractUvvisView(); - let uvvisSeed = []; - if (uvvisViewFeature?.data?.[0]) { - const currentData = uvvisViewFeature.data[0]; - const { x, y } = currentData; - uvvisSeed = toSeed(x, y); - } - drawMain(this.rootKlassLine, W, H, LIST_BRUSH_SVG_GRAPH.LINE); - this.lineFocus.create({ - filterSeed: uvvisSeed, - filterPeak: [], - tTrEndPts, - layoutSt, - isUiNoBrushSt: true, - sweepExtentSt: sweepExtent[0], - integrationSt, - isUiAddIntgSt, - editPeakSt, - hplcMsSt, - }); - drawLabel(this.rootKlassLine, null, 'Minutes', 'Intensity'); - drawDisplay(this.rootKlassLine, false); - - drawMain(this.rootKlassMulti, W, H, LIST_BRUSH_SVG_GRAPH.MULTI); - this.multiFocus.create({ - ticEntities, - curveSt, - hplcMsSt, - tTrEndPts, - layoutSt, - sweepExtentSt: sweepExtent[1], - isUiAddIntgSt, - isUiNoBrushSt, - }); - drawLabel(this.rootKlassMulti, null, 'Minutes', 'Intensity'); - drawDisplay(this.rootKlassMulti, isHidden); - - drawMain(this.rootKlassRect, W, H, LIST_BRUSH_SVG_GRAPH.RECT); - this.rectFocus.create({ - filterSeed: [], - filterPeak: [], - tTrEndPts, - layoutSt, - isUiNoBrushSt: true, - sweepExtentSt: sweepExtent[2], - }); - drawLabel(this.rootKlassRect, null, 'm/z', 'Intensity'); - drawDisplay(this.rootKlassRect, false); + this.setupResizeObserver(); + this.mountCharts(this.resolvePaneSizes(), true); } componentDidUpdate(prevProps) { @@ -394,7 +382,12 @@ class ViewerLineRect extends React.Component { `${this.rootKlassLine} .${LIST_BRUSH_SVG_GRAPH.LINE}`, ); if (!hasLineSvg) { - drawMain(this.rootKlassLine, W, H, LIST_BRUSH_SVG_GRAPH.LINE); + drawMain( + this.rootKlassLine, + this.currentSizes.line.width, + this.currentSizes.line.height, + LIST_BRUSH_SVG_GRAPH.LINE, + ); } const currentData = uvvisViewFeature.data[0]; const { x, y } = currentData; @@ -423,7 +416,12 @@ class ViewerLineRect extends React.Component { `${this.rootKlassMulti} .${LIST_BRUSH_SVG_GRAPH.MULTI}`, ); if (!hasMultiSvg) { - drawMain(this.rootKlassMulti, W, H, LIST_BRUSH_SVG_GRAPH.MULTI); + drawMain( + this.rootKlassMulti, + this.currentSizes.multi.width, + this.currentSizes.multi.height, + LIST_BRUSH_SVG_GRAPH.MULTI, + ); } this.multiFocus.update({ curveSt, @@ -459,7 +457,12 @@ class ViewerLineRect extends React.Component { `${this.rootKlassRect} .${LIST_BRUSH_SVG_GRAPH.RECT}`, ); if (!hasRectSvg) { - drawMain(this.rootKlassRect, W, H, LIST_BRUSH_SVG_GRAPH.RECT); + drawMain( + this.rootKlassRect, + this.currentSizes.rect.width, + this.currentSizes.rect.height, + LIST_BRUSH_SVG_GRAPH.RECT, + ); } const { threshold } = hplcMsSt; const curTrEndPts = convertThresEndPts(subViewFeature, threshold.value); @@ -493,11 +496,34 @@ class ViewerLineRect extends React.Component { } componentWillUnmount() { + this.teardownResizeObserver(); drawDestroy(this.rootKlassLine); drawDestroy(this.rootKlassMulti); drawDestroy(this.rootKlassRect); } + // Redraw only when a pane actually changed size. Against an unbounded host the measured + // size is the one the current viewBox already produces, so this settles after the first + // pass instead of feeding itself. + handleResize() { + // Never mutate layout synchronously inside a ResizeObserver callback. The remount + // resizes the subtree being observed, and the browser abandons the delivery pass with + // "ResizeObserver loop completed with undelivered notifications" - which surfaces as + // an uncaught application error, not just a console warning. Deferring to the next + // frame lets the observer finish before anything moves. + // + // d3_multi remounts synchronously and gets away with it because its resize path is + // gated to Cyclic Voltammetry, whose container height is fixed by CSS and so cannot + // be fed back into by a redraw. This stack has no such guarantee. + if (this.resizeFrame != null) return; + this.resizeFrame = window.requestAnimationFrame(() => { + this.resizeFrame = null; + const sizes = this.resolvePaneSizes(); + if (sameSizes(sizes, this.currentSizes)) return; + this.mountCharts(sizes, false); + }); + } + handleUvvisUndo() { const { uvvisUndoAct } = this.props; uvvisUndoAct(); @@ -508,6 +534,164 @@ class ViewerLineRect extends React.Component { uvvisRedoAct(); } + setupResizeObserver() { + if (typeof ResizeObserver === 'undefined') return; + if (!this.stackRef.current || this.resizeObserver) return; + this.resizeObserver = new ResizeObserver(this.handleResize); + this.resizeObserver.observe(this.stackRef.current); + } + + // Measure every pane, so a stack whose three panes differ in height (a host that has + // not equalised them) still gets a correct viewBox each. + resolvePaneSizes() { + const fallback = { width: W, height: H }; + return { + line: measurePane(this.lineRef?.current) || fallback, + multi: measurePane(this.multiRef?.current) || fallback, + rect: measurePane(this.rectRef?.current) || fallback, + }; + } + + createFocuses(sizes) { + const { + clickUiTargetAct, selectUiSweepAct, scrollUiWheelAct, + ticEntities, uvvisEntities, uiSt, + } = this.props; + const shared = { + clickUiTargetAct, selectUiSweepAct, scrollUiWheelAct, uiSt, + }; + + this.lineFocus = new LineFocus({ + W: sizes.line.width, + H: sizes.line.height, + uvvisEntities, + graphIndex: 0, + ...shared, + }); + this.multiFocus = new MultiFocus({ + W: sizes.multi.width, + H: sizes.multi.height, + ticEntities, + graphIndex: 1, + ...shared, + }); + this.rectFocus = new RectFocus({ + W: sizes.rect.width, + H: sizes.rect.height, + graphIndex: 2, + ...shared, + }); + } + + teardownResizeObserver() { + if (this.resizeFrame != null) { + window.cancelAnimationFrame(this.resizeFrame); + this.resizeFrame = null; + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + } + + // The whole draw sequence, parameterised by pane size so a resize can re-run it. Only + // the first run resets redux (`shouldReset`); a resize must not discard the user's zoom, + // threshold or selection. + mountCharts(sizes, shouldReset = false) { + const { + curveSt, feature, ticEntities, hplcMsSt, + tTrEndPts, layoutSt, + isUiAddIntgSt, isUiNoBrushSt, + integrationSt, + isHidden, + resetAllAct, uiSt, + editPeakSt, + } = this.props; + this.currentSizes = sizes; + drawDestroy(this.rootKlassMulti); + drawDestroy(this.rootKlassLine); + drawDestroy(this.rootKlassRect); + if (shouldReset) { + resetAllAct(feature); + } + this.createFocuses(sizes); + + const { zoom } = uiSt; + const { sweepExtent } = zoom; + + const uvvisViewFeature = this.extractUvvisView(); + let uvvisSeed = []; + if (uvvisViewFeature?.data?.[0]) { + const currentData = uvvisViewFeature.data[0]; + const { x, y } = currentData; + uvvisSeed = toSeed(x, y); + } + drawMain(this.rootKlassLine, sizes.line.width, sizes.line.height, LIST_BRUSH_SVG_GRAPH.LINE); + this.lineFocus.create({ + filterSeed: uvvisSeed, + filterPeak: [], + tTrEndPts, + layoutSt, + isUiNoBrushSt: true, + sweepExtentSt: sweepExtent[0], + integrationSt, + isUiAddIntgSt, + editPeakSt, + hplcMsSt, + }); + drawLabel(this.rootKlassLine, null, 'Minutes', 'Intensity'); + drawDisplay(this.rootKlassLine, false); + + const multiSize = sizes.multi; + drawMain(this.rootKlassMulti, multiSize.width, multiSize.height, LIST_BRUSH_SVG_GRAPH.MULTI); + this.multiFocus.create({ + ticEntities, + curveSt, + hplcMsSt, + tTrEndPts, + layoutSt, + sweepExtentSt: sweepExtent[1], + isUiAddIntgSt, + isUiNoBrushSt, + }); + drawLabel(this.rootKlassMulti, null, 'Minutes', 'Intensity'); + drawDisplay(this.rootKlassMulti, isHidden); + + // Seed the m/z pane with the scan that is currently selected rather than with an + // empty series. On first mount there is none and this stays [] as before - but a + // resize remount happens long after componentDidUpdate has drawn a scan here, and + // recreating the pane empty would silently wipe it with nothing to redraw it. + const subViewFeature = this.extractSubView(); + let subSeed = []; + let subTrEndPts = tTrEndPts; + let subLabel = null; + if (subViewFeature?.data?.[0]) { + const { x, y } = subViewFeature.data[0]; + subSeed = toSeed(x, y); + subTrEndPts = convertThresEndPts(subViewFeature, hplcMsSt?.threshold?.value); + const pageValue = parsePageValue(subViewFeature); + subLabel = Number.isFinite(pageValue) + ? pageValue + : (subViewFeature?.pageValue ?? subViewFeature?.page ?? null); + } + drawMain(this.rootKlassRect, sizes.rect.width, sizes.rect.height, LIST_BRUSH_SVG_GRAPH.RECT); + this.rectFocus.create({ + filterSeed: subSeed, + filterPeak: [], + tTrEndPts: subTrEndPts, + layoutSt, + isUiNoBrushSt: true, + sweepExtentSt: sweepExtent[2], + }); + drawLabel( + this.rootKlassRect, + subLabel != null ? `${subLabel} min` : null, + 'm/z', + 'Intensity', + ); + drawDisplay(this.rootKlassRect, false); + } + extractUvvisView() { const { uvvisEntities, hplcMsSt } = this.props; if (!uvvisEntities || !uvvisEntities[0]) { @@ -660,7 +844,10 @@ class ViewerLineRect extends React.Component { selectWavelengthAct(event); }; return ( -
+
{ omitUvvisToolbarRow ? null : (
@@ -707,7 +894,7 @@ class ViewerLineRect extends React.Component {
) } -
+
{ @@ -722,19 +909,18 @@ class ViewerLineRect extends React.Component {
-
+
{ zoomView(classes, 2, uiSt, zoomInAct) } -
-
+
-
-
+
+
{ isMsLoading ? (
({ root: { @@ -60,7 +61,7 @@ class HPLCViewer extends React.Component { // eslint-disable-line hideMainEditTools={true} prependLcMsToolbar={} /> -
+
({ root: { @@ -34,7 +35,10 @@ const styles = () => ({ fontSize: '14px', }, cvEditor: { - height: 'calc(90vh - 220px)', + // 230, not 220: `commonStyle.card` reserves 10px above the toolbar for the + // outlined selects' floating labels, and this constant is the budget left + // over after it. Keep the two in step. + height: 'calc(90vh - 230px)', display: 'flex', flexDirection: 'column', minHeight: 0, @@ -111,7 +115,12 @@ class MultiJcampsViewer extends React.Component { // eslint-disable-line editorOnly={editorOnly} hideThreshold={!Format.isNmrLayout(layoutSt)} /> -
+
diff --git a/src/components/panel/index.js b/src/components/panel/index.js index 7461dc40..c8db31f8 100644 --- a/src/components/panel/index.js +++ b/src/components/panel/index.js @@ -32,8 +32,21 @@ const theme = createTheme({ const styles = () => ({ panels: { - maxHeight: 'calc(90vh - 220px)', // ROI - display: 'table', + // `display: table` silently defeated both declarations below it: CSS leaves the + // effect of `max-height` on a table box undefined, and `overflow` does not make one + // a scroll container. So this panel never capped and never scrolled - it grew to the + // full height of its accordions (measured at 1512px for five expanded panels against + // a 748px column) and simply overflowed whatever contained it. That went unnoticed + // while the host let the page grow; a host that bounds the editor and clips it + // instead shows the panel truncated at the bottom with no way to scroll to the rest. + display: 'block', + // Take the height from a bounded parent when there is one; `height: 100%` against an + // unbounded parent computes to `auto`, so the viewport-derived cap below still + // applies in a host that does not constrain us (and now actually works). + height: '100%', + minHeight: 0, + // 230, not 220 - see the matching constant in multi_jcamps_viewer.js. + maxHeight: 'calc(90vh - 230px)', // ROI overflowX: 'hidden', overflowY: 'auto', margin: '5px 0 0 0', diff --git a/src/constants/list_graph.js b/src/constants/list_graph.js index 7ea69bb8..de32ca9b 100644 --- a/src/constants/list_graph.js +++ b/src/constants/list_graph.js @@ -10,6 +10,26 @@ const LIST_BRUSH_SVG_GRAPH = { MULTI: 'd3SvgMulti', }; +// Stable, non-JSS class names a host application (chemotion_ELN) styles against. The +// classes withStyles generates around these nodes are opaque (`jss8 jss4`) and change +// between builds, so a host stylesheet has nothing else to target. Treat these as part +// of the public DOM contract: do not rename them without a host-side change. +// +// All entries are `rse-` prefixed. These land in a host's global, non-modular stylesheet +// alongside its own classes, so an unprefixed generic name like `lcms-stack` would be one +// collision away from a host's own LC/MS markup - and the contract above makes such a name +// expensive to change afterwards. +// +// EDITOR_ROOT is the oldest of these and the one hosts already target; it is listed here +// so it is covered by the same contract as the rest, rather than living on as a bare +// string literal in four components. +const LIST_HOST_HOOK_CLASS = { + EDITOR_ROOT: 'react-spectrum-editor', + CMD_BAR: 'rse-cmd-bar', + LCMS_STACK: 'rse-lcms-stack', + LCMS_GRAPH_PANEL: 'rse-lcms-graph-panel', +}; + export { - LIST_ROOT_SVG_GRAPH, LIST_BRUSH_SVG_GRAPH, + LIST_ROOT_SVG_GRAPH, LIST_BRUSH_SVG_GRAPH, LIST_HOST_HOOK_CLASS, }; diff --git a/src/layer_prism.js b/src/layer_prism.js index e3146577..fc043c1a 100644 --- a/src/layer_prism.js +++ b/src/layer_prism.js @@ -14,6 +14,7 @@ import CmdBar from './components/cmd_bar/index'; import LayerContent from './layer_content'; import { LIST_UI_VIEWER_TYPE } from './constants/list_ui'; import { extractParams } from './helpers/extractParams'; +import { LIST_HOST_HOOK_CLASS } from './constants/list_graph'; const styles = () => ({ }); @@ -55,7 +56,7 @@ const LayerPrism = ({ operations={operations} editorOnly={editorOnly} /> -
+
-
+