From cedb9057e69d3931c31939a6b33925c636c7290d Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Wed, 4 Nov 2026 16:05:20 +0100 Subject: [PATCH 1/5] fix: preserve export fonts and prevent axis label overlap - Preserve SVG root attributes during serialization - Share axis geometry between rendering and margin calculations - Adjust 1D and 2D spacing for configured font sizes --- src/component/1d/HorizontalAxis1D.tsx | 9 +- src/component/2d/DirectAxis2D.tsx | 16 ++- src/component/2d/IndirectAxis2D.tsx | 57 ++++------ src/component/2d/utilities/getIndirectAxis.ts | 41 +++++++ .../2d/utilities/getRightAxisMargin.ts | 47 ++++++++ src/component/context/ChartContext.tsx | 60 +++++++++-- src/component/elements/D3Axis.tsx | 60 +++-------- .../elements/export/RenderDetector.tsx | 27 +++-- src/component/hooks/useTextMetrics.ts | 85 +++++++-------- src/component/hooks/use_axis_unit.ts | 61 ++--------- src/component/utility/export.ts | 18 +++- src/component/utility/getAxisLayout.ts | 102 ++++++++++++++++++ src/component/utility/getVerticalAxisTicks.ts | 37 +++++++ 13 files changed, 402 insertions(+), 218 deletions(-) create mode 100644 src/component/2d/utilities/getIndirectAxis.ts create mode 100644 src/component/2d/utilities/getRightAxisMargin.ts create mode 100644 src/component/utility/getAxisLayout.ts create mode 100644 src/component/utility/getVerticalAxisTicks.ts diff --git a/src/component/1d/HorizontalAxis1D.tsx b/src/component/1d/HorizontalAxis1D.tsx index 4d8086cc4..690f592ac 100644 --- a/src/component/1d/HorizontalAxis1D.tsx +++ b/src/component/1d/HorizontalAxis1D.tsx @@ -13,6 +13,7 @@ import { } from '../hooks/use_axis_unit.ts'; import { useGridline1DConfig } from '../hooks/use_gridlines_config.ts'; import { useTicksConfig } from '../hooks/use_ticks_config.ts'; +import { getAxisLayout } from '../utility/getAxisLayout.ts'; import { useIsInset } from './inset/InsetProvider.js'; @@ -35,7 +36,9 @@ export function HorizontalAxis1D() { 'horizontal', refAxis, ); - const { labelStyle } = useTicksConfig(); + const axis = useTicksConfig(); + const { labelStyle } = axis; + const { labelText } = getAxisLayout(axis); const gridConfig = useGridline1DConfig(); if (!width || !height) { @@ -65,9 +68,7 @@ export function HorizontalAxis1D() { {unitLabel} diff --git a/src/component/2d/DirectAxis2D.tsx b/src/component/2d/DirectAxis2D.tsx index 3acf61cd1..f29ecbdb8 100644 --- a/src/component/2d/DirectAxis2D.tsx +++ b/src/component/2d/DirectAxis2D.tsx @@ -10,6 +10,7 @@ import { useCheckExportStatus } from '../hooks/useViewportSize.tsx'; import { axisUnitToLabel, useDirectAxisUnit } from '../hooks/use_axis_unit.ts'; import { useGridline2DConfig } from '../hooks/use_gridlines_config.ts'; import { useTicksConfig } from '../hooks/use_ticks_config.ts'; +import { getAxisLayout } from '../utility/getAxisLayout.ts'; import { useScale2DX } from './utilities/scale.js'; @@ -63,7 +64,7 @@ function DirectAxis2D(props: DirectAxis2DProps) { primaryGridProps={gridConfig.primary.lineStyle} secondaryGridProps={gridConfig.secondary.lineStyle} > - + ); } @@ -95,16 +96,11 @@ interface UnitLabelProps { } function UnitLabel(props: UnitLabelProps) { const { width, children } = props; - const { labelStyle } = useTicksConfig(); + const axis = useTicksConfig(); + const { labelStyle } = axis; + const { labelText } = getAxisLayout(axis); return ( - + {children} ); diff --git a/src/component/2d/IndirectAxis2D.tsx b/src/component/2d/IndirectAxis2D.tsx index 009eeeaa2..d120b3872 100644 --- a/src/component/2d/IndirectAxis2D.tsx +++ b/src/component/2d/IndirectAxis2D.tsx @@ -1,5 +1,4 @@ -import { memo, useRef } from 'react'; -import { useLinearPrimaryTicks } from 'react-d3-utils'; +import { memo, useMemo } from 'react'; import { SVGStyledText } from 'react-science/ui'; import { useIsInset } from '../1d/inset/InsetProvider.tsx'; @@ -7,7 +6,6 @@ import { AxisUnitPicker } from '../1d-2d/components/axis_unit_picker.tsx'; import { useChartData } from '../context/ChartContext.js'; import { D3Axis } from '../elements/D3Axis.js'; import { useActiveNucleusTab } from '../hooks/useActiveNucleusTab.js'; -import { useTextMetrics } from '../hooks/useTextMetrics.js'; import { useCheckExportStatus } from '../hooks/useViewportSize.tsx'; import { axisUnitToLabel, @@ -15,10 +13,12 @@ import { } from '../hooks/use_axis_unit.ts'; import { useGridline2DConfig } from '../hooks/use_gridlines_config.ts'; import { useTicksConfig } from '../hooks/use_ticks_config.ts'; +import { getAxisLayout } from '../utility/getAxisLayout.ts'; +import { getVerticalAxisTicks } from '../utility/getVerticalAxisTicks.ts'; import { useScale2DY } from './utilities/scale.js'; -const defaultMargin = { right: 50, top: 0, bottom: 0, left: 0 }; +const defaultMargin = { right: 0, top: 0, bottom: 0, left: 0 }; interface IndirectAxis2DProps { margin?: { @@ -46,12 +46,11 @@ function IndirectAxis2D(props: IndirectAxis2DProps) { const isInset = useIsInset(); const isExportingProcessStart = useCheckExportStatus(); - const refAxis = useRef(null); - - const { ticks, scale: ticksScale } = useLinearPrimaryTicks( - scaleY, - 'vertical', - refAxis, + const axisStyle = useTicksConfig(); + const { tickFontSize, rightLabelInset } = getAxisLayout(axisStyle); + const { ticks, scale: ticksScale } = useMemo( + () => getVerticalAxisTicks(scaleY, tickFontSize), + [scaleY, tickFontSize], ); const gridConfig = useGridline2DConfig(); @@ -61,8 +60,7 @@ function IndirectAxis2D(props: IndirectAxis2DProps) { return ( - + - - - - {children} - - + + {children} + ); } diff --git a/src/component/2d/utilities/getIndirectAxis.ts b/src/component/2d/utilities/getIndirectAxis.ts new file mode 100644 index 000000000..e3113c00c --- /dev/null +++ b/src/component/2d/utilities/getIndirectAxis.ts @@ -0,0 +1,41 @@ +import type { Spectrum } from '@zakodium/nmrium-core'; +import { + axisUnits2DFid, + axisUnits2DFt, + defaultAxisUnit2DFid, + defaultAxisUnit2DFt, + isSpectrum2D, +} from '@zakodium/nmrium-core'; +import { assertIn } from '@zakodium/utils'; +import { scaleLinear } from 'd3-scale'; + +import type { State } from '../../reducer/Reducer.ts'; + +// Resolves the indirect-axis unit and converts its domain to that unit. +export function getIndirectAxis( + state: Pick, + spectrum: Spectrum | undefined, +) { + if (!spectrum || !isSpectrum2D(spectrum)) return undefined; + + const nucleus = state.view.spectra.activeTab; + const mode: 'ft' | 'fid' = spectrum.info.isFt ? 'ft' : 'fid'; + const unit = + state.view.units2D[nucleus]?.indirect[mode] ?? + (mode === 'ft' ? defaultAxisUnit2DFt : defaultAxisUnit2DFid); + const allowedUnits = mode === 'ft' ? axisUnits2DFt : axisUnits2DFid; + assertIn(unit, allowedUnits); + let domain = state.yDomain; + + if (unit === 'pt') { + const ppmToPoint = scaleLinear(state.originDomain.yDomain, [ + 0, + spectrum.info.spectrumSize[1], + ]); + domain = domain.map((value) => ppmToPoint(value)); + } else if (unit === 'hz') { + domain = domain.map((value) => value * spectrum.info.originFrequency[1]); + } + + return { nucleus, mode, unit, allowedUnits, domain } as const; +} diff --git a/src/component/2d/utilities/getRightAxisMargin.ts b/src/component/2d/utilities/getRightAxisMargin.ts new file mode 100644 index 000000000..39f078437 --- /dev/null +++ b/src/component/2d/utilities/getRightAxisMargin.ts @@ -0,0 +1,47 @@ +import { scaleLinear } from 'd3-scale'; + +import { getViewSpectra } from '../../../data/get_view_spectra.ts'; +import type { State } from '../../reducer/Reducer.ts'; +import { getActiveSpectrum } from '../../reducer/helper/getActiveSpectrum.ts'; +import type { AxisStyles } from '../../utility/getAxisLayout.ts'; +import { getAxisLayout } from '../../utility/getAxisLayout.ts'; +import { getVerticalAxisTicks } from '../../utility/getVerticalAxisTicks.ts'; + +import { getIndirectAxis } from './getIndirectAxis.ts'; + +interface TextMetrics { + getTextWidth: (text: string) => number; + getTextHeight: () => number; +} + +// Reserves room for the widest right-axis tick and the vertical unit label. +export function getRightAxisMargin( + state: State, + axis: AxisStyles, + metrics: TextMetrics, +) { + // Use the same unit conversion and tick generation as the right axis. + const activeSpectrum = getActiveSpectrum(state); + const spectrum = getViewSpectra(state).find( + (spectrum) => spectrum.id === activeSpectrum?.id, + ); + const indirectAxis = getIndirectAxis(state, spectrum); + const [, indirectNucleus] = state.view.spectra.activeTab.split(',', 2); + const matchNucleus = /^[0-9]+[A-Z][a-z]?$/.test(indirectNucleus); + const scale = scaleLinear( + matchNucleus ? (indirectAxis?.domain ?? state.yDomain) : state.yDomain, + [state.margin.top, state.height - state.margin.bottom], + ); + const { tickFontSize } = getAxisLayout(axis); + const { ticks } = getVerticalAxisTicks(scale, tickFontSize); + + // Reserve space for the widest tick number and the vertical unit label. + const tickLabelWidth = Math.max( + 0, + ...ticks.map(({ label }) => metrics.getTextWidth(label)), + ); + return getAxisLayout(axis, { + tickLabelWidth, + labelHeight: metrics.getTextHeight(), + }).right; +} diff --git a/src/component/context/ChartContext.tsx b/src/component/context/ChartContext.tsx index ffe793bde..5dd718075 100644 --- a/src/component/context/ChartContext.tsx +++ b/src/component/context/ChartContext.tsx @@ -1,9 +1,14 @@ import { produce } from 'immer'; -import { createContext, use } from 'react'; +import { createContext, use, useMemo } from 'react'; +import { useIsInset } from '../1d/inset/InsetProvider.js'; +import { getRightAxisMargin } from '../2d/utilities/getRightAxisMargin.ts'; +import { useTextMetrics } from '../hooks/useTextMetrics.ts'; import { useViewportSize } from '../hooks/useViewportSize.js'; +import { useTicksConfig } from '../hooks/use_ticks_config.ts'; import type { State } from '../reducer/Reducer.js'; import { initialState } from '../reducer/Reducer.js'; +import { getAxisLayout } from '../utility/getAxisLayout.ts'; export const ChartDataContext = createContext(initialState); @@ -11,17 +16,50 @@ export function useChartData() { const data = use(ChartDataContext); const viewportSize = useViewportSize(); + const axis = useTicksConfig(); + const isInset = useIsInset(); + const layout = getAxisLayout(axis); + const { getTextWidth } = useTextMetrics({ + ...axis.primaryTicks.textStyle, + fontSize: layout.tickFontSize, + }); + const { getTextHeight } = useTextMetrics({ + ...axis.labelStyle, + fontSize: axis.labelStyle.fontSize ?? layout.fontSize, + }); - if (!viewportSize) return data; + return useMemo(() => { + return produce(data, (draft) => { + // 1. Apply export, print, or inset dimensions before calculating spacing. + if (viewportSize) { + const { width, height, margin, xDomain, yDomain } = viewportSize; + draft.width = width; + draft.height = height; + draft.margin = margin ?? draft.margin; + draft.xDomain = xDomain ?? draft.xDomain; + draft.yDomain = yDomain ?? draft.yDomain; + } - const { width, height, margin, xDomain, yDomain } = viewportSize; - const updatedData = produce(data, (draft) => { - draft.width = width; - draft.height = height; - draft.margin = margin ?? draft.margin; - draft.xDomain = xDomain ?? draft.xDomain; - draft.yDomain = yDomain ?? draft.yDomain; - }); + // 2. Keep inset spacing unchanged; reserve bottom space for other axes. + if (isInset) return; + draft.margin.bottom = Math.max(draft.margin.bottom, layout.bottom); - return updatedData; + // 3. In 2D, leave room for tick numbers and the vertical right-axis unit. + if (draft.displayerMode === '2D') { + const right = getRightAxisMargin(draft, axis, { + getTextWidth, + getTextHeight, + }); + draft.margin.right = Math.max(draft.margin.right, right); + } + }); + }, [ + axis, + data, + getTextHeight, + getTextWidth, + isInset, + layout.bottom, + viewportSize, + ]); } diff --git a/src/component/elements/D3Axis.tsx b/src/component/elements/D3Axis.tsx index a1f11c93e..458f09acd 100644 --- a/src/component/elements/D3Axis.tsx +++ b/src/component/elements/D3Axis.tsx @@ -1,15 +1,12 @@ import type { Ref, SVGAttributes } from 'react'; import type { UseLinearPrimaryTicksResult } from 'react-d3-utils'; -import type { - SVGStyledLineProps, - SVGStyledLineUserConfig, - SVGStyledTextProps, -} from 'react-science/ui'; +import type { SVGStyledLineUserConfig } from 'react-science/ui'; import { SVGStyledLine, SVGStyledText } from 'react-science/ui'; import { useTicksConfig } from '../hooks/use_ticks_config.ts'; +import type { AxisPosition } from '../utility/getAxisLayout.ts'; +import { getAxisLayout } from '../utility/getAxisLayout.ts'; -type AxisPosition = 'top' | 'bottom' | 'left' | 'right'; interface BaseD3AxisProps { tickLength?: number; axisPosition: AxisPosition; @@ -60,8 +57,10 @@ function getTickTransform(position: number, isVertical: boolean) { } function BaseLine(props: BaseLineProps) { - const { axisPosition, tickLength = 6, scale } = props; - const { primaryTicks } = useTicksConfig(); + const { axisPosition, scale } = props; + const axis = useTicksConfig(); + const { primaryTicks } = axis; + const { tickLength } = getAxisLayout(axis, props); const [x1, x2] = scale.range(); const sign = ['left', 'top'].includes(axisPosition) ? -1 : 1; @@ -84,41 +83,14 @@ function BaseLine(props: BaseLineProps) { } function Tickets(props: TicketsProps) { - const { ticks, tickLength = 6, axisPosition } = props; - const { primaryTicks, secondaryTicks } = useTicksConfig(); + const { ticks, axisPosition } = props; + const axis = useTicksConfig(); + const { primaryTicks, secondaryTicks } = axis; + const { tickText, primaryLine, secondaryLine } = getAxisLayout(axis, props); if (!Array.isArray(ticks) || ticks.length === 0) return null; const isVertical = isVerticalAxis(axisPosition); - const positionSign = - axisPosition === 'left' || axisPosition === 'top' ? -1 : 1; - - const positionTextConfig: SVGStyledTextProps = isVertical - ? { - x: positionSign * (tickLength + 3), - dy: '0.32em', - textAnchor: positionSign === -1 ? 'end' : 'start', - } - : { - y: positionSign * (tickLength + 3), - dy: positionSign === -1 ? '0em' : '0.71em', - textAnchor: 'middle', - }; - - const positionPrimaryLineConfig: SVGStyledLineProps = isVertical - ? { x1: positionSign * tickLength, x2: 0 } - : { y1: positionSign * tickLength, y2: 0 }; - - const secondaryTickLength = tickLength / 2; - const positionSecondaryLineConfig: SVGStyledLineProps = isVertical - ? { - x1: positionSign * secondaryTickLength, - x2: 0, - } - : { - y1: positionSign * secondaryTickLength, - y2: 0, - }; const secondaryTickPositions = secondaryTicks.enabled && ticks.length > 1 ? getSecondaryTickPositions(ticks) @@ -132,7 +104,7 @@ function Tickets(props: TicketsProps) { @@ -142,7 +114,7 @@ function Tickets(props: TicketsProps) { fill="black" data-testid="axis-primary-tick-label" {...primaryTicks.textStyle} - {...positionTextConfig} + {...tickText} > {label} @@ -155,7 +127,7 @@ function Tickets(props: TicketsProps) { data-testid="axis-secondary-tick-line" key={`secondary-${secondaryTickPosition}`} transform={getTickTransform(secondaryTickPosition, isVertical)} - {...positionSecondaryLineConfig} + {...secondaryLine} stroke="black" {...secondaryTicks.tickStyle} /> @@ -243,11 +215,13 @@ export function D3Axis(props: D3AxisProps) { } = props; const isVertical = isVerticalAxis(axisPosition); + const axis = useTicksConfig(); + const { fontSize } = getAxisLayout(axis, { axisPosition, tickLength }); return ( diff --git a/src/component/elements/export/RenderDetector.tsx b/src/component/elements/export/RenderDetector.tsx index 3a897aa98..72453ef68 100644 --- a/src/component/elements/export/RenderDetector.tsx +++ b/src/component/elements/export/RenderDetector.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, ReactNode } from 'react'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; interface RenderDetectorProps { onRender: () => void; @@ -9,21 +9,30 @@ interface RenderDetectorProps { export function RenderDetector(props: RenderDetectorProps) { const { onRender, style, children } = props; + const ref = useRef(null); useEffect(() => { - const handleRenderComplete = () => { - // eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout - setTimeout(() => { - onRender(); - }, 250); - }; + const element = ref.current; + if (!element) return; + let active = true; + let animationFrameId = 0; - const animationFrameId = requestAnimationFrame(handleRenderComplete); + void element.ownerDocument.fonts.ready.then(() => { + if (!active) return; + animationFrameId = requestAnimationFrame(() => { + onRender(); + }); + }); return () => { + active = false; cancelAnimationFrame(animationFrameId); }; }, [onRender]); - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/src/component/hooks/useTextMetrics.ts b/src/component/hooks/useTextMetrics.ts index 9fd32b9d9..ce36faf78 100644 --- a/src/component/hooks/useTextMetrics.ts +++ b/src/component/hooks/useTextMetrics.ts @@ -19,60 +19,55 @@ export function useTextMetrics(options: UseTextMetricsOptions = {}) { fontWeight = 'normal', debugCanvasWidth, } = options; - const canvas = useCanvas(debugCanvasWidth); const size = typeof fontSize === 'number' ? `${fontSize}px` : fontSize; + const font = `${fontStyle} ${fontWeight} ${size} Arial`; - // TODO: ctx should probably be in a ref. It is wrong to mutate it during rendering. - const ctx = canvas.getContext('2d'); - if (ctx) ctx.font = `${fontStyle} ${fontWeight} ${size} Arial`; + const { context, getTextWidth, getTextHeight } = useMemo(() => { + // Configure a new context rather than mutate one used by a previous render. + const canvas = document.createElement('canvas'); + if (debugCanvasWidth) { + canvas.width = debugCanvasWidth; + canvas.height = 450; + } + const context = canvas.getContext('2d'); + if (!context) throw new Error('Could not create text metrics canvas'); + context.font = font; + + const metrics = context.measureText('M'); + const height = + metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + + return { + context, + getTextWidth: (text: string) => + Math.round(context.measureText(text).width), + getTextHeight: () => height, + }; + }, [debugCanvasWidth, font]); - function getTextWidth(text: string): number { - return measureTextWidth(ctx, text); - } + useEffect(() => { + if (!debugCanvasWidth) return; + + const { canvas } = context; + Object.assign(canvas.style, { + position: 'absolute', + bottom: '0', + left: '0', + zIndex: '100', + opacity: '50%', + background: 'white', + }); + document.body.append(canvas); + return () => canvas.remove(); + }, [context, debugCanvasWidth]); return { getTextWidth, + getTextHeight, /** * @deprecated Use only for debug mode */ // eslint-disable-next-line @typescript-eslint/no-deprecated - ctx, + ctx: context, }; } - -function useCanvas(width: number | undefined) { - const canvas = useMemo(() => { - const canvas = document.createElement('canvas'); - if (width) { - canvas.width = width; - canvas.height = 450; - } - canvas.style.position = 'absolute'; - canvas.style.bottom = '0'; - canvas.style.left = '0'; - canvas.style.zIndex = '100'; - canvas.style.opacity = '50%'; - canvas.style.background = 'white'; - return canvas; - }, [width]); - - useEffect(() => { - if (!width) return; - - document.body.append(canvas); - - return () => { - canvas.remove(); - }; - }, [canvas, width]); - - return canvas; -} - -function measureTextWidth( - context: CanvasRenderingContext2D | null, - text: string, -) { - if (!context) return 0; - return Math.round(context.measureText(text).width); -} diff --git a/src/component/hooks/use_axis_unit.ts b/src/component/hooks/use_axis_unit.ts index 6d3f21541..f533aac40 100644 --- a/src/component/hooks/use_axis_unit.ts +++ b/src/component/hooks/use_axis_unit.ts @@ -23,7 +23,8 @@ import { scaleLinear } from 'd3-scale'; import { useCallback, useMemo } from 'react'; import { match } from 'ts-pattern'; -import { useScale2DX, useScale2DY } from '../2d/utilities/scale.ts'; +import { getIndirectAxis } from '../2d/utilities/getIndirectAxis.ts'; +import { useScale2DX } from '../2d/utilities/scale.ts'; import { useChartData } from '../context/ChartContext.tsx'; import { useDispatch } from '../context/DispatchContext.tsx'; import { useScaleChecked } from '../context/ScaleContext.tsx'; @@ -208,60 +209,14 @@ export function useDirectAxisUnit() { } export function useIndirectAxisUnit() { - const { - originDomain: { yDomain: originYDomain }, - } = useChartData(); - const { nucleus, units } = useAxisUnit2D(); + const state = useChartData(); const spectrum = useSpectrum(); const dispatch = useDispatch(); - const scaleY = useScale2DY(); return useMemo(() => { - if (!spectrum) return; - if (!isSpectrum2D(spectrum)) return; - - const mode: keyof Nucleus2DUnit['direct'] = spectrum.info.isFt - ? 'ft' - : 'fid'; - const unit: AxisUnit2DFid | AxisUnit2DFt = units.indirect[mode]; - const allowedUnits: AxisUnit2DFid[] | AxisUnit2DFt[] = - mode === 'ft' ? axisUnits2DFt : axisUnits2DFid; - - // spectrum.info.spectrumSize = [nbColumns, nbRows]; - // in nmrium-core formatSpectrum2D - const indirectAxisIndex = 1; - function getPtDomain() { - assert(isSpectrum2D(spectrum)); - const originPtDomain = [0, spectrum.info.spectrumSize[indirectAxisIndex]]; - const ppmToPoint = scaleLinear(originYDomain, originPtDomain); - - return scaleY.domain().map((v) => ppmToPoint(v)); - } - - const domain = match(mode) - .with('fid', () => - match(unit) - .with('s', () => undefined) - .with('pt', getPtDomain) - .with('hz', 'ppm', (unit) => { - assertUnreachable(unit as never); - }) - .exhaustive(), - ) - .with('ft', () => - match(unit) - .with('ppm', () => undefined) - .with('pt', getPtDomain) - .with('hz', () => { - const frequency = spectrum.info.originFrequency[indirectAxisIndex]; - return scaleY.domain().map((v) => v * frequency); - }) - .with('s', (unit) => { - assertUnreachable(unit as never); - }) - .exhaustive(), - ) - .exhaustive(); + const axis = getIndirectAxis(state, spectrum); + if (!axis) return undefined; + const { nucleus, mode } = axis; function setUnit(unit: AxisUnit) { match(mode) @@ -282,8 +237,8 @@ export function useIndirectAxisUnit() { .exhaustive(); } - return { mode, unit, allowedUnits, setUnit, domain }; - }, [dispatch, nucleus, originYDomain, scaleY, spectrum, units.indirect]); + return { ...axis, setUnit }; + }, [dispatch, state, spectrum]); } const defaultUnit1D: Nucleus1DUnit = { diff --git a/src/component/utility/export.ts b/src/component/utility/export.ts index a5732257d..230b7f5bc 100644 --- a/src/component/utility/export.ts +++ b/src/component/utility/export.ts @@ -442,11 +442,19 @@ function getBlob(targetElementID: string, options: GetBlobOptions): BlobObject { element.remove(); } - const head = ``; - const style = ` - -`; - const svg = `${head + style + _svg.getHTML()}`; + _svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + _svg.setAttribute('version', '1.1'); + _svg.setAttribute('viewBox', viewBox); + _svg.classList.add('nmr-svg'); + if (css?.styles) { + const style = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'style', + ); + style.textContent = css.styles; + _svg.prepend(style); + } + const svg = new XMLSerializer().serializeToString(_svg); const blob = new Blob([svg], { type: 'image/svg+xml' }); diff --git a/src/component/utility/getAxisLayout.ts b/src/component/utility/getAxisLayout.ts new file mode 100644 index 000000000..74e88f337 --- /dev/null +++ b/src/component/utility/getAxisLayout.ts @@ -0,0 +1,102 @@ +import type { TextStyle } from '@zakodium/nmrium-core'; +import type { SVGStyledLineProps, SVGStyledTextProps } from 'react-science/ui'; + +import { MARGIN } from '../reducer/core/Constants.ts'; + +export type AxisPosition = 'top' | 'bottom' | 'left' | 'right'; + +export interface AxisStyles { + labelStyle: TextStyle; + primaryTicks: { textStyle: TextStyle }; +} + +interface AxisLayoutOptions { + axisPosition?: AxisPosition; + // Fallback when no font size is set in the axis text styles. + defaultFontSize?: number; + tickLength?: number; + tickPadding?: number; + tickLabelWidth?: number; + labelHeight?: number; +} + +// Calculates shared rendering positions and the space required around an axis. +export function getAxisLayout( + axis: AxisStyles, + options: AxisLayoutOptions = {}, +) { + const { + axisPosition = 'bottom', + defaultFontSize = 10, + tickLength = 6, + tickPadding = 3, + tickLabelWidth = 0, + } = options; + const tickFontSize = axis.primaryTicks.textStyle.fontSize ?? defaultFontSize; + const labelFontSize = axis.labelStyle.fontSize ?? defaultFontSize; + const isVertical = axisPosition === 'left' || axisPosition === 'right'; + const sign = axisPosition === 'left' || axisPosition === 'top' ? -1 : 1; + const tickOffset = tickLength + tickPadding; + + const tickText: SVGStyledTextProps = isVertical + ? { + x: sign * tickOffset, + dy: '0.32em', + textAnchor: sign === -1 ? 'end' : 'start', + } + : { + y: sign * tickOffset, + dy: sign === -1 ? '0em' : '0.71em', + textAnchor: 'middle', + }; + const primaryLine: SVGStyledLineProps = isVertical + ? { x1: sign * tickLength, x2: 0 } + : { y1: sign * tickLength, y2: 0 }; + const secondaryLine: SVGStyledLineProps = isVertical + ? { x1: (sign * tickLength) / 2, x2: 0 } + : { y1: (sign * tickLength) / 2, y2: 0 }; + const labelBaselineOffset = 0.7; + const labelY = Math.max( + 30, + tickOffset + + tickFontSize + + tickPadding + + labelFontSize * (1 - labelBaselineOffset), + ); + const labelText: SVGStyledTextProps = { + y: labelY, + dy: `${labelBaselineOffset}em`, + textAnchor: 'end', + }; + const bottom = Math.max( + MARGIN['1D'].bottom, + Math.ceil(labelY + labelFontSize + tickPadding), + ); + const rightLabelInset = tickPadding; + const rightLabelText: SVGStyledTextProps = { + transform: 'rotate(-90)', + dominantBaseline: 'text-after-edge', + textAnchor: 'end', + }; + const right = Math.ceil( + tickOffset + + tickLabelWidth + + tickPadding + + (options.labelHeight ?? labelFontSize) + + tickPadding, + ); + + return { + fontSize: defaultFontSize, + tickFontSize, + tickLength, + tickText, + primaryLine, + secondaryLine, + labelText, + rightLabelText, + rightLabelInset, + right, + bottom, + }; +} diff --git a/src/component/utility/getVerticalAxisTicks.ts b/src/component/utility/getVerticalAxisTicks.ts new file mode 100644 index 000000000..8e1beebaf --- /dev/null +++ b/src/component/utility/getVerticalAxisTicks.ts @@ -0,0 +1,37 @@ +import type { ScaleContinuousNumeric } from 'd3-scale'; +import type { UseLinearPrimaryTicksResult } from 'react-d3-utils'; + +// Selects rounded tick values with enough vertical space for their labels. +export function getVerticalAxisTicks( + scale: ScaleContinuousNumeric, + fontSize: number, +): UseLinearPrimaryTicksResult { + const range = scale.range(); + const axisLength = Math.abs(range[0] - range[1]); + const minSpace = 8; + const requiredSpacing = fontSize + minSpace; + const tickNumber = Math.max( + 1, + Math.min(10, Math.floor(axisLength / requiredSpacing)), + ); + const values = scale.ticks(tickNumber); + + // D3's count is approximate; keep evenly spaced ticks that actually fit. + const spacing = + values.length > 1 ? Math.abs(scale(values[1]) - scale(values[0])) : 0; + const stride = + spacing > 0 + ? Math.max(1, Math.ceil(requiredSpacing / spacing)) + : Math.max(1, values.length); + + return { + scale, + ticks: values + .filter((_, index) => index % stride === 0) + .map((value) => ({ + value, + label: String(value), + position: scale(value), + })), + }; +} From 5bd52f86926ca2965ab95d2b12522836f9a77d89 Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Fri, 6 Nov 2026 11:00:59 +0100 Subject: [PATCH 2/5] fix: constrain brushing to plot bounds --- src/component/1d-2d/tools/BrushXY.tsx | 55 ++++++------------- src/component/1d/BrushTracker1D.tsx | 11 ++++ src/component/EventsTrackers/BrushTracker.tsx | 49 ++++++++++++++++- .../EventsTrackers/brush_tracker.types.ts | 1 + 4 files changed, 76 insertions(+), 40 deletions(-) diff --git a/src/component/1d-2d/tools/BrushXY.tsx b/src/component/1d-2d/tools/BrushXY.tsx index 99f809c4b..86956a25b 100644 --- a/src/component/1d-2d/tools/BrushXY.tsx +++ b/src/component/1d-2d/tools/BrushXY.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; import { isSpectrum1D, isSpectrum2D } from '@zakodium/nmrium-core'; +import clamp from 'lodash/clamp.js'; import type { CSSProperties } from 'react'; import { useScale2DX } from '../../2d/utilities/scale.js'; @@ -81,11 +82,6 @@ interface BrushCoordinates { endX?: number; endY?: number; } -const defaultDimensionBorder: BrushCoordinates = { - startX: 0, - startY: 0, -}; - interface BrushXYProps { axis: BrushAxis; dimensionBorder?: BrushCoordinates; @@ -99,7 +95,7 @@ interface BrushXYProps { export default function BrushXY(props: BrushXYProps) { const { axis = 'XY', - dimensionBorder = defaultDimensionBorder, + dimensionBorder, width: widthProps, height: heightProps, margin: externalMargin, @@ -118,42 +114,27 @@ export default function BrushXY(props: BrushXYProps) { const { step, mouseButton } = brushTracker; let { startX, endX, startY, endY } = brushTracker; const { isPrimary } = useKeyModifiers(); + const finalWidth = widthProps ?? width - margin.left - margin.right; + const finalHeight = heightProps ?? height - margin.top - margin.bottom; + const minX = dimensionBorder?.startX ?? margin.left; + const minY = dimensionBorder?.startY ?? margin.top; + const maxX = dimensionBorder?.endX ?? minX + finalWidth; + const maxY = dimensionBorder?.endY ?? minY + finalHeight; if ( !isBrushAllowed(selectedTool, isPrimary) || step !== 'brushing' || mouseButton !== 'main' || - !dimensionBorder || - (dimensionBorder.startX && startX < dimensionBorder.startX) || - (dimensionBorder.startY && startY < dimensionBorder.startY) || - ((dimensionBorder.endX && Math.sign(endX - startX) === 1 - ? endX > dimensionBorder.endX - : endX < dimensionBorder.startX) && - (dimensionBorder.endX && - dimensionBorder.endY && - Math.sign(endY - startY) === 1 - ? endY > dimensionBorder.endY - : endY < dimensionBorder.startY)) + startX < minX || + startX > maxX || + startY < minY || + startY > maxY ) { return null; } - const finalWidth = widthProps || width - margin.left - margin.right; - const finalHeight = heightProps || height - margin.top - margin.bottom; - - endX = - dimensionBorder.endX && endX > dimensionBorder.endX - ? dimensionBorder.endX - : dimensionBorder.startX && endX < dimensionBorder.startX - ? dimensionBorder.startX - : endX; - - endY = - dimensionBorder.endY && endY > dimensionBorder.endY - ? dimensionBorder.endY - : dimensionBorder.startY && endY < dimensionBorder.startY - ? dimensionBorder.startY - : endY; + endX = clamp(endX, minX, maxX); + endY = clamp(endY, minY, maxY); const brush = detectBrushing( { startX, startY, endX, endY }, { @@ -182,10 +163,10 @@ export default function BrushXY(props: BrushXYProps) { if (scaleX === 0 || scaleY === 0) return; - startX = axis === 'Y' ? margin.left : brush.startX || margin.left; - startY = axis === 'X' ? margin.top : brush.startY || margin.top; - endX = axis === 'Y' ? finalWidth : brush.endX || finalWidth; - endY = axis === 'X' ? finalHeight : brush.endY || finalHeight; + startX = axis === 'Y' || brush.type === 'Y' ? minX : brush.startX; + startY = axis === 'X' || brush.type === 'X' ? minY : brush.startY; + endX = axis === 'Y' || brush.type === 'Y' ? maxX : brush.endX; + endY = axis === 'X' || brush.type === 'X' ? maxY : brush.endY; const invScaleX = 1 / scaleX; const invScaleY = 1 / scaleY; diff --git a/src/component/1d/BrushTracker1D.tsx b/src/component/1d/BrushTracker1D.tsx index 3a0d4cb11..1451ab5d9 100644 --- a/src/component/1d/BrushTracker1D.tsx +++ b/src/component/1d/BrushTracker1D.tsx @@ -74,8 +74,17 @@ export function BrushTracker1D({ children }: Required) { view, width, height, + margin, } = state; const activeTab = view.spectra.activeTab; + const brushBounds = [ + { + startX: margin.left, + startY: margin.top, + endX: width - margin.right, + endY: height - margin.bottom, + }, + ]; const isClickDebounced = clickDebounceTools.has(selectedTool); const brushStartRef = useRef(null); const spectrum = useSpectrum(); @@ -499,6 +508,7 @@ export function BrushTracker1D({ children }: Required) { if (inset) { return ( ) { return ( <> + position.x >= startX && + position.x <= endX && + position.y >= startY && + position.y <= endY, + ); + if (brushBounds && !boundary) { + return; + } + //check that the right or left mouse button pressed if ([0, 2].includes(event.button)) { if (noPropagation) { @@ -253,8 +287,11 @@ export function BrushTracker(options: BrushTrackerProps) { payload: { screenX, screenY, - clientX, - clientY, + ...clampClientPosition( + { clientX, clientY }, + boundary, + boundingRectRef.current, + ), }, }); } @@ -283,7 +320,13 @@ export function BrushTracker(options: BrushTrackerProps) { return false; }, - [clickTriggerMode, handleClickWithDebounce, noPropagation, onZoom], + [ + brushBounds, + clickTriggerMode, + handleClickWithDebounce, + noPropagation, + onZoom, + ], ); const handleMouseWheel = useCallback( diff --git a/src/component/EventsTrackers/brush_tracker.types.ts b/src/component/EventsTrackers/brush_tracker.types.ts index a44e6ea43..71a22e7cd 100644 --- a/src/component/EventsTrackers/brush_tracker.types.ts +++ b/src/component/EventsTrackers/brush_tracker.types.ts @@ -64,6 +64,7 @@ export interface BrushTrackerProps { onDoubleClick?: OnDoubleClick; onClick?: OnClick; noPropagation?: boolean; + brushBounds?: BrushCoordination[]; brushDetectionOptions?: BaseDetectBrushingOptions; clickTriggerMode?: 'native' | 'debounced'; } From 9262ad5e914fbc6157df3eb0a877418a59771908 Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Fri, 6 Nov 2026 11:01:48 +0100 Subject: [PATCH 3/5] fix: disable 2d interactions in empty trace regions --- src/component/2d/BrushTracker2D.tsx | 10 +++++--- src/component/2d/utilities/DimensionLayout.ts | 25 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/component/2d/BrushTracker2D.tsx b/src/component/2d/BrushTracker2D.tsx index b78d5aed2..e9ddee9b5 100644 --- a/src/component/2d/BrushTracker2D.tsx +++ b/src/component/2d/BrushTracker2D.tsx @@ -21,9 +21,10 @@ import { useMapKeyModifiers } from '../context/KeyModifierContext.js'; import { usePreferences } from '../context/PreferencesContext.js'; import { options } from '../toolbar/ToolTypes.js'; +import { useTracesSpectra } from './useTracesSpectra.js'; import type { Layout } from './utilities/DimensionLayout.js'; import { - get2DDimensionLayout, + getInteractive2DDimensionLayout, getLayoutID, } from './utilities/DimensionLayout.js'; import { useScale2DX, useScale2DY } from './utilities/scale.js'; @@ -70,7 +71,8 @@ export function BrushTracker2D({ children }: Required) { const dispatch = useDispatch(); const brushStartRef = useRef<{ x: number; y: number } | null>(null); const isPrimaryKeyActivated = useMapKeyModifiers(); - const DIMENSION = get2DDimensionLayout(state); + const traces = useTracesSpectra(); + const DIMENSION = getInteractive2DDimensionLayout(state, traces); const convertToPPM = usePixelToPPMConverter(); const { current: { @@ -229,6 +231,7 @@ export function BrushTracker2D({ children }: Required) { const mouseClick: OnClick = useCallback( (event) => { const { x, y, shiftKey } = event; + if (!getLayoutID(DIMENSION, { startX: x, startY: y })) return; if (shiftKey) { switch (selectedTool) { @@ -251,11 +254,12 @@ export function BrushTracker2D({ children }: Required) { } } }, - [selectedTool, dispatch], + [DIMENSION, selectedTool, dispatch], ); return ( { + const dimension = get2DDimensionLayout(params); + return { + MAIN: dimension.MAIN, + ...(traces.x && { TOP: dimension.TOP }), + ...(traces.y && { LEFT: dimension.LEFT }), + }; +} + interface StartEndProps { startX: number; endX?: number; @@ -56,18 +69,20 @@ interface StartEndProps { endY?: number; } -type DimensionType = Record>; +type DimensionType = Partial>>; export function getLayoutID( dimension: DimensionType, brushData: StartEndProps, ): Layout | null { for (const key of Object.keys(dimension) as Layout[]) { + const bounds = dimension[key]; if ( - brushData.startX >= dimension[key].startX && - brushData.startX <= dimension[key].endX && - brushData.startY >= dimension[key].startY && - brushData.startY <= dimension[key].endY + bounds && + brushData.startX >= bounds.startX && + brushData.startX <= bounds.endX && + brushData.startY >= bounds.startY && + brushData.startY <= bounds.endY ) { return key; } From 62ec446707e8bcb98426cc0dc3c39b7ab9c841c8 Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Fri, 6 Nov 2026 11:03:11 +0100 Subject: [PATCH 4/5] fix: respect crosshair bounds while preserving trace behavior --- .../1d-2d/tools/CrossLinePointer.tsx | 57 ++++++++++--------- src/component/1d-2d/tools/CursorToolIcon.tsx | 16 +++++- src/component/2d/Viewer2D.tsx | 22 ++++++- 3 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/component/1d-2d/tools/CrossLinePointer.tsx b/src/component/1d-2d/tools/CrossLinePointer.tsx index 4b7f5a1ed..b88ec1fdd 100644 --- a/src/component/1d-2d/tools/CrossLinePointer.tsx +++ b/src/component/1d-2d/tools/CrossLinePointer.tsx @@ -45,26 +45,23 @@ interface DimensionBorder { endY?: number; } -const defaultDimensionBorder: DimensionBorder = { - startX: 0, - startY: 0, -}; - interface CrossLinePointerProps { axis?: BrushAxis; dimensionBorder?: DimensionBorder; width?: number; height?: number; margin?: Margin; + fullView?: boolean; } function CrossLinePointer(props: CrossLinePointerProps) { const { axis = 'XY', - dimensionBorder = defaultDimensionBorder, + dimensionBorder, width: externalWidth, height: externalHeight, margin: externalMargin, + fullView = false, } = props; const { @@ -77,15 +74,12 @@ function CrossLinePointer(props: CrossLinePointerProps) { const brushState = useBrushTracker(); const margin = externalMargin ?? innerMargin; - const finalWidth = externalWidth || width - margin.left - margin.right; - const finalHeight = externalHeight || height - margin.top - margin.bottom; - - const { - startX, - startY, - endX = finalWidth, - endY = finalHeight, - } = dimensionBorder; + const finalWidth = externalWidth ?? width - margin.left - margin.right; + const finalHeight = externalHeight ?? height - margin.top - margin.bottom; + const startX = dimensionBorder?.startX ?? margin.left; + const startY = dimensionBorder?.startY ?? margin.top; + const endX = dimensionBorder?.endX ?? startX + finalWidth; + const endY = dimensionBorder?.endY ?? startY + finalHeight; const visibility = toolsVisibility[selectedTool]; @@ -109,32 +103,41 @@ function CrossLinePointer(props: CrossLinePointerProps) { return null; } + const left = fullView ? 0 : startX; + const top = fullView ? 0 : startY; + return (
- + {(axis === 'X' || axis === 'XY') && ( )} @@ -142,7 +145,7 @@ function CrossLinePointer(props: CrossLinePointerProps) { diff --git a/src/component/1d-2d/tools/CursorToolIcon.tsx b/src/component/1d-2d/tools/CursorToolIcon.tsx index aab135c42..56f2f053d 100644 --- a/src/component/1d-2d/tools/CursorToolIcon.tsx +++ b/src/component/1d-2d/tools/CursorToolIcon.tsx @@ -3,6 +3,11 @@ import { createElement } from 'react'; import { TbRulerMeasure } from 'react-icons/tb'; import { match } from 'ts-pattern'; +import { useTracesSpectra } from '../../2d/useTracesSpectra.js'; +import { + getInteractive2DDimensionLayout, + getLayoutID, +} from '../../2d/utilities/DimensionLayout.js'; import { useMouseTracker } from '../../EventsTrackers/MouseTracker.js'; import { useChartData } from '../../context/ChartContext.js'; import { useCore } from '../../context/CoreContext.tsx'; @@ -70,21 +75,30 @@ export function CursorToolIcon({ offset = 25, size = 25, }: CursorHeaderProps) { + const state = useChartData(); const { width, height, margin, toolOptions: { selectedTool }, processingOperators: { selected: selectedProcessingTool }, - } = useChartData(); + displayerMode, + } = state; const core = useCore(); const isExperimental = useCheckExperimentalFeature(); const mousePosition = useMouseTracker(); + const traces = useTracesSpectra(); const { isPrimary, shiftKey } = useKeyModifiers(); if ( !mousePosition || !width || !height || + (displayerMode === '2D' + ? !getLayoutID(getInteractive2DDimensionLayout(state, traces), { + startX: mousePosition.x, + startY: mousePosition.y, + }) + : mousePosition.x < margin.left || mousePosition.y < margin.top) || mousePosition.x > width - margin.right || mousePosition.y > height - margin.bottom ) { diff --git a/src/component/2d/Viewer2D.tsx b/src/component/2d/Viewer2D.tsx index c097f319e..aa9f55877 100644 --- a/src/component/2d/Viewer2D.tsx +++ b/src/component/2d/Viewer2D.tsx @@ -63,9 +63,25 @@ function Viewer2D(props: Viewer2DProps) { )} - - - + {spectrumData.x && ( + + )} + {spectrumData.y && ( + + )} + <> From efb96e91378b7ad78b34feae92730ca6b31b4926 Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Fri, 6 Nov 2026 11:14:59 +0100 Subject: [PATCH 5/5] fix: respect fixed brush axes for 2d traces --- src/component/1d-2d/tools/BrushXY.tsx | 1 + src/component/EventsTrackers/BrushTracker.tsx | 16 +++++++++++----- .../EventsTrackers/brush_tracker.types.ts | 4 ++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/component/1d-2d/tools/BrushXY.tsx b/src/component/1d-2d/tools/BrushXY.tsx index 86956a25b..00d6fbf70 100644 --- a/src/component/1d-2d/tools/BrushXY.tsx +++ b/src/component/1d-2d/tools/BrushXY.tsx @@ -141,6 +141,7 @@ export default function BrushXY(props: BrushXYProps) { width: finalWidth, height: finalHeight, ...brushDetectionOptions, + axis, }, ); diff --git a/src/component/EventsTrackers/BrushTracker.tsx b/src/component/EventsTrackers/BrushTracker.tsx index ae092d7c2..e442f14fc 100644 --- a/src/component/EventsTrackers/BrushTracker.tsx +++ b/src/component/EventsTrackers/BrushTracker.tsx @@ -508,7 +508,13 @@ export function detectBrushing( coordination: BrushCoordination, options: DetectBrushingOptions, ): DetectBrushingResult { - const { width, height, thresholdFormat, thresholdAxis = 'both' } = options; + const { + width, + height, + thresholdFormat, + thresholdAxis = 'both', + axis = 'XY', + } = options; const { startX: x1, endX: x2, startY: y1, endY: y2 } = coordination; const startX = Math.min(x1, x2); const endX = Math.max(x1, x2); @@ -542,7 +548,7 @@ export function detectBrushing( xThreshold, yThreshold, }; - if (thresholdAxis === 'y') { + if (axis === 'XY' && thresholdAxis === 'y') { if (yDiff >= yThreshold && yThreshold > 0) { return { type: 'XY', @@ -567,7 +573,7 @@ export function detectBrushing( ...common, }; } - if (thresholdAxis === 'x') { + if (axis === 'XY' && thresholdAxis === 'x') { if (xDiff >= xThreshold && xThreshold > 0) { return { type: 'XY', @@ -593,7 +599,7 @@ export function detectBrushing( }; } - if (yDiff < yThreshold) { + if (axis === 'X' || (axis === 'XY' && yDiff < yThreshold)) { return { type: 'X', startX, @@ -606,7 +612,7 @@ export function detectBrushing( }; } - if (xDiff < xThreshold) { + if (axis === 'Y' || xDiff < xThreshold) { return { type: 'Y', startX: 0, diff --git a/src/component/EventsTrackers/brush_tracker.types.ts b/src/component/EventsTrackers/brush_tracker.types.ts index 71a22e7cd..f7132052e 100644 --- a/src/component/EventsTrackers/brush_tracker.types.ts +++ b/src/component/EventsTrackers/brush_tracker.types.ts @@ -71,6 +71,10 @@ export interface BrushTrackerProps { export type BrushAxis = 'X' | 'Y' | 'XY'; export type DetectBrushingOptions = { + /** + * Constrains trace brushes to one axis (XY enables threshold-based detection.) + */ + axis?: BrushAxis; thresholdAxis?: BrushDetectionThresholdAxis; } & (DetectBrushingThreshold | DetectBrushingThresholdSize); export interface DetectBrushingResult extends BrushCoordination {