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(
+