From b12329a028b01a211122d05ae8ddeb642e046141 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 02:35:18 +1300 Subject: [PATCH 01/14] New version. --- package.json | 2 +- src/renderer/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index acff2371..684e6b9b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/opencor/webapp.git" }, "type": "module", - "version": "1.20261023.0", + "version": "1.20261023.1", "engines": { "bun": ">=1.2.0" }, diff --git a/src/renderer/package.json b/src/renderer/package.json index c7a982d3..19c35353 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -42,7 +42,7 @@ }, "./style.css": "./dist/opencor.css" }, - "version": "1.20261023.0", + "version": "1.20261023.1", "libopencorVersion": "1.20261020.0", "scripts": { "build": "vite build && bun scripts/generate.version.ts", From d8fd4be33a7ae46a8490789016bb9fb329b6d3f2 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 02:50:17 +1300 Subject: [PATCH 02/14] Graph Panel widget: use OpenCOR's font and muted tick labels --- .../components/widgets/GraphPanelWidget.vue | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 6913445a..9df72ff0 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -4,8 +4,7 @@
@@ -183,13 +182,13 @@ const emit = defineEmits<{ }>(); // Some constants (in CSS pixels, where relevant). -// Note: we want to look and behave like Plotly, so most of these constants are either Plotly's defaults (see -// https://plotly.com/javascript/reference/) or what we used to give Plotly. +// Note: we want to behave like Plotly, so many of these constants are either Plotly's defaults (see +// https://plotly.com/javascript/reference/) or what we used to give Plotly. However, we want to look more modern, +// so we use OpenCOR's font (rather than Plotly's) with font sizes of our own. -const FONT_FAMILY = '"Open Sans", verdana, arial, sans-serif'; // Like Plotly, the font family of our tick labels and titles. -const TICK_FONT_SIZE = 10; // Like Plotly, the font size of our tick labels. -const TITLE_FONT_SIZE = 14; // Like Plotly, the font size of our axis titles. -const LEGEND_FONT_SIZE = 12; // Like Plotly, the font size of our legend items. +const TICK_FONT_SIZE = 11; // Font size of our tick labels. +const TITLE_FONT_SIZE = 13; // Font size of our axis titles. +const LEGEND_FONT_SIZE = 12; // Font size of our legend items. const LEGEND_LEFT_MARGIN = 8; // Margin between our plotting area and our legend. const LEGEND_RIGHT_MARGIN = 8; // Margin between our legend (or last X tick label) and the right edge of our container. const DEFAULT_LEGEND_RANK = 1000; // Like Plotly's default legend rank. @@ -303,6 +302,7 @@ let trackedMargins: IGraphPanelMargins | undefined; interface IThemeColors { background: string; text: string; + tickLabel: string; grid: string; minorGrid: string; zeroLine: string; @@ -317,6 +317,7 @@ const themeColors = vue.computed(() => { return { background: 'var(--p-content-background)', text: 'var(--p-text-color)', + tickLabel: 'var(--p-text-muted-color)', grid: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-700)', minorGrid: useLightMode ? 'var(--p-surface-100)' : 'var(--p-surface-800)', zeroLine: useLightMode ? 'var(--p-surface-400)' : 'var(--p-surface-500)', @@ -976,7 +977,7 @@ const svgDocument = (scale: number = 1): ISvgDocument => { ); res.push( - `${svgTickLabels.join('')}` + `${svgTickLabels.join('')}` ); // Our axis titles and legend, which we render on top of our plot. @@ -2521,7 +2522,7 @@ const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => { let res = 0; for (const tick of ticks) { - res = Math.max(res, textWidth(formatNumber(tick, format), tickFont)); + res = Math.max(res, textWidth(formatNumber(tick, format), tickFont())); } return res; @@ -2663,8 +2664,8 @@ const drawTickLabels = (u: uPlot): void => { context.save(); - context.font = `${Math.round(TICK_FONT_SIZE * area.pxRatio)}px ${FONT_FAMILY}`; - context.fillStyle = colors.cssColor(themeColors.value.text); + context.font = `${Math.round(TICK_FONT_SIZE * area.pxRatio)}px ${fontFamily}`; + context.fillStyle = colors.cssColor(themeColors.value.tickLabel); context.textBaseline = 'alphabetic'; for (const tickLabel of tickLabels(u, area)) { @@ -2679,7 +2680,14 @@ const drawTickLabels = (u: uPlot): void => { // Text measuring, which we use to make sure that our X tick labels don't overlap, to size our Y axis, right margin, and // hover label, and to position the text in our exports. -const tickFont = `${TICK_FONT_SIZE}px ${FONT_FAMILY}`; +let fontFamily = 'sans-serif'; + +const tickFont = (): string => { + // Return the font of our tick labels, i.e. OpenCOR's font (see createPlot()) at TICK_FONT_SIZE pixels. + + return `${TICK_FONT_SIZE}px ${fontFamily}`; +}; + let measureContext: CanvasRenderingContext2D | null | undefined; const textWidth = (text: string, font: string): number => { @@ -2731,7 +2739,7 @@ const yAxisSize = (): number => { let maxWidth = 0; for (const value of tickValues.y) { - maxWidth = Math.max(maxWidth, textWidth(value, tickFont)); + maxWidth = Math.max(maxWidth, textWidth(value, tickFont())); } naturalYAxisSize = Math.ceil(maxWidth + Y_AXIS_EXTRA_SIZE); @@ -2763,7 +2771,7 @@ const rightMargin = (): number => { const lastXValue = tickValues.x[tickValues.x.length - 1]; const legendSize = props.margins ? Math.max(legendRightMargin(), props.margins.right) : legendRightMargin(); - const lastXTickLabelSize = lastXValue ? 0.5 * textWidth(lastXValue, tickFont) + LEGEND_RIGHT_MARGIN : 0; + const lastXTickLabelSize = lastXValue ? 0.5 * textWidth(lastXValue, tickFont()) + LEGEND_RIGHT_MARGIN : 0; return Math.ceil(Math.max(legendSize, lastXTickLabelSize)); }; @@ -3606,6 +3614,13 @@ const createPlot = (width: number, height: number): void => { plot?.destroy(); + // Use OpenCOR's font, i.e. the one that we inherit, for what gets drawn on uPlot's canvas (and measured using + // textWidth()), so that it is consistent with our axis titles and legend (and the rest of OpenCOR). + + if (mainDivRef.value) { + fontFamily = getComputedStyle(mainDivRef.value).fontFamily; + } + // Create a series for each trace, using the trace's color for the stroke, our linear path builder, and our markers // path builder (for traces with markers). @@ -3634,8 +3649,8 @@ const createPlot = (width: number, height: number): void => { const axis: uPlot.Axis = { stroke: colors.cssColor(themeColors.value.text), - font: tickFont, - labelFont: `${TITLE_FONT_SIZE}px ${FONT_FAMILY}`, + font: tickFont(), + labelFont: `${TITLE_FONT_SIZE}px ${fontFamily}`, labelGap: 0, space: 1, ticks: { From 14a5f44182518a58e057e1271705073b08a095a7 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 09:44:35 +1300 Subject: [PATCH 03/14] Graph Panel widget: removed minor grid lines and lightened the grid and zero lines. --- .../components/widgets/GraphPanelWidget.vue | 85 +++++-------------- 1 file changed, 21 insertions(+), 64 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 9df72ff0..1f2a714b 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -202,9 +202,8 @@ const X_AXIS_TITLE_SIZE = 17; // Size of our X axis title. const Y_AXIS_TITLE_SIZE = 17; // Size of our Y axis title. const Y_AXIS_TITLE_GAP = 1.5; // Gap between our Y axis title and (the left edge of) our Y axis. const Y_AXIS_EXTRA_SIZE = 5; // Extra size of our Y axis, on top of the width of its tick labels. -const MAJOR_TICK_MIN_X_SPACING = 80; // Target spacing between two consecutive major ticks along the X axis. -const MAJOR_TICK_MIN_Y_SPACING = 40; // Target spacing between two consecutive major ticks along the Y axis. -const NB_OF_MINOR_TICKS = 5; // Number of minor tick spacings per major tick spacing (like Plotly). +const TICK_MIN_X_SPACING = 80; // Target spacing between two consecutive ticks along the X axis. +const TICK_MIN_Y_SPACING = 40; // Target spacing between two consecutive ticks along the Y axis. const TICK_LABEL_MIN_SPACING = 10; // Minimum spacing between two consecutive tick labels. const MAX_NB_OF_TICKS = 1000; // A safeguard against an axis needing an unreasonable number of ticks. const LINE_WIDTH = 2; // Like Plotly, the width of our traces' lines (in CSS pixels). @@ -304,7 +303,6 @@ interface IThemeColors { text: string; tickLabel: string; grid: string; - minorGrid: string; zeroLine: string; zoomMask: string; zoomCornersFill: string; @@ -318,9 +316,8 @@ const themeColors = vue.computed(() => { background: 'var(--p-content-background)', text: 'var(--p-text-color)', tickLabel: 'var(--p-text-muted-color)', - grid: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-700)', - minorGrid: useLightMode ? 'var(--p-surface-100)' : 'var(--p-surface-800)', - zeroLine: useLightMode ? 'var(--p-surface-400)' : 'var(--p-surface-500)', + grid: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-800)', + zeroLine: useLightMode ? 'var(--p-surface-300)' : 'var(--p-surface-600)', zoomMask: `color-mix(in srgb, ${useLightMode ? 'var(--p-surface-400)' : 'var(--p-surface-500)'} 50%, transparent)`, zoomCornersFill: useLightMode ? 'var(--p-surface-800)' : 'var(--p-surface-100)', zoomCornersStroke: useLightMode ? 'var(--p-surface-200)' : 'var(--p-surface-700)' @@ -912,10 +909,9 @@ const svgDocument = (scale: number = 1): ISvgDocument => { `` ]; - // Our minor and major grid lines, and our zero lines, which get drawn in that order on uPlot's canvas (see - // drawMinorGrid() and drawZeroLines()). - // Note: uPlot draws all our major grid lines, including one that is at the right (or bottom) edge of our plotting - // area, i.e. that is just outside of it once aligned (if our line width is an odd number of pixels). + // Our grid lines and zero lines, which get drawn in that order on uPlot's canvas (see drawZeroLines()). + // Note: uPlot draws all our grid lines, including one that is at the right (or bottom) edge of our plotting area, + // i.e. that is just outside of it once aligned (if our line width is an odd number of pixels). const [xAxis, yAxis] = u.axes as unknown as IAxisInternals[]; const xSplits = xAxis?._splits ?? []; @@ -929,11 +925,7 @@ const svgDocument = (scale: number = 1): ISvgDocument => { return svgPathElement(path, `stroke="${colors.cssColor(color)}" stroke-width="${lineWidth(scale)}" fill="none"`); }; - res.push( - lines(themeColors.value.minorGrid, axisTicks.x?.minorTicks ?? [], axisTicks.y?.minorTicks ?? []), - lines(themeColors.value.grid, xSplits, ySplits, false), - lines(themeColors.value.zeroLine, [0], [0]) - ); + res.push(lines(themeColors.value.grid, xSplits, ySplits, false), lines(themeColors.value.zeroLine, [0], [0])); // Our visible traces and their markers, if any, which uPlot draws in that order, trace by trace. @@ -2428,14 +2420,13 @@ const formatNumber = (value: number, format: INumberFormat): string => { return isNegative ? MINUS_SIGN + res : res; }; -// Ticks, like Plotly, i.e. major ticks that are multiples of a "nice" spacing (which depends on the length of our axis) -// and minor ticks (only used for our minor grid lines) that split a major tick spacing in four or five. Unlike Plotly, -// though, we use fewer major ticks if their labels would otherwise overlap (Plotly rotates its X tick labels instead -// and lets its Y tick labels overlap). +// Ticks, like Plotly, i.e. ticks that are multiples of a "nice" spacing (which depends on the length of our axis). +// Unlike Plotly, though, we use fewer ticks if their labels would otherwise overlap (Plotly rotates its X tick labels +// instead and lets its Y tick labels overlap), and we don't have minor ticks (and therefore minor grid lines), so that +// our grid is lighter. interface IAxisTicks { format: INumberFormat; - minorTicks: number[]; } const axisTicks: Partial> = {}; @@ -2468,21 +2459,15 @@ const ticksBetween = (min: number, max: number, dtick: number): number[] => { return res; }; -const isMultiple = (bigger: number, smaller: number): boolean => { - // Return whether the given bigger number is (almost) a multiple of the given smaller number. - - return Math.abs(((bigger / smaller + 0.5) % 1) - 0.5) < 0.001; -}; - const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => { - // Return a function that computes the splits (i.e. the positions of our major ticks) for the given axis. + // Return a function that computes the splits (i.e. the positions of our ticks) for the given axis. return (u: uPlot, _axisIndex: number, min: number, max: number): number[] => { - // Major ticks, like Plotly, i.e. we aim for one tick every 80 (X axis) or 40 (Y axis) pixels with at least four and - // at most nine tick spacings. + // Like Plotly, we aim for one tick every 80 (X axis) or 40 (Y axis) pixels with at least four and at most nine tick + // spacings. const length = (axisKey === 'x' ? u.bbox.width : u.bbox.height) / (dependencies._uPlot.pxRatio as number); - const minSpacing = axisKey === 'x' ? MAJOR_TICK_MIN_X_SPACING : MAJOR_TICK_MIN_Y_SPACING; + const minSpacing = axisKey === 'x' ? TICK_MIN_X_SPACING : TICK_MIN_Y_SPACING; const nbOfTicks = Math.min(Math.max(length / minSpacing, 4), 9) + 1; const range = Math.abs(max - min); const maxEnd = Math.max(Math.abs(min), Math.abs(max)); @@ -2497,8 +2482,7 @@ const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => { format: { tickRound: 0, exponent: 0 - }, - minorTicks: [] + } }; return []; @@ -2534,26 +2518,13 @@ const axisSplits = (axisKey: AxisKey): uPlot.Axis.Splits => { format = numberFormat(dtick, maxEnd); } - // Minor ticks, like Plotly, i.e. we aim for five minor tick spacings per major tick spacing, making sure that our - // major tick spacing is a multiple of our minor tick spacing. - - let minorDtick = roundDTick((0.99 * dtick) / NB_OF_MINOR_TICKS); - - if (!isMultiple(dtick, minorDtick)) { - minorDtick = Math.abs(dtick / minorDtick - 2.5) < 0.001 * 2.5 ? 0.5 * dtick : dtick; - } - - // Keep track of the format of our tick labels and of our minor ticks (excluding those that are also major ticks). + // Keep track of the format of our tick labels. axisTicks[axisKey] = { - format, - minorTicks: - minorDtick < dtick - ? ticksBetween(min, max, minorDtick).filter((tick: number) => !isMultiple(Math.abs(tick) || dtick, dtick)) - : [] + format }; - // Return our major ticks. + // Return our ticks. return ticks; }; @@ -2953,24 +2924,11 @@ const drawLines = (u: uPlot, color: string, xValues: number[], yValues: number[] const drawZeroLines = (u: uPlot): void => { // Like Plotly, draw our zero lines. - // Note: this gets called after uPlot draws our (major) grid lines and before it draws our traces. + // Note: this gets called after uPlot draws our grid lines and before it draws our traces. drawLines(u, colors.cssColor(themeColors.value.zeroLine), [0], [0]); }; -const drawMinorGrid = (u: uPlot): void => { - // Draw our minor grid lines. - // Note: this gets called before uPlot draws our (major) grid lines, so that the latter are drawn on top of the - // former. - - drawLines( - u, - colors.cssColor(themeColors.value.minorGrid), - axisTicks.x?.minorTicks ?? [], - axisTicks.y?.minorTicks ?? [] - ); -}; - // Traces and markers. const linearPath = ( @@ -3749,7 +3707,6 @@ const createPlot = (width: number, height: number): void => { ], setCursor: [queueHoverUpdate], drawAxes: [drawZeroLines, drawTickLabels], - drawClear: [drawMinorGrid], setSize: [ (u: uPlot) => { // Make sure that our plotting area is at least one pixel wide and high (e.g., if our container is narrower From be837efbea7f4d9d1dc937d8117c06bf0d6ee0e5 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 10:46:56 +1300 Subject: [PATCH 04/14] Graph Panel widget: replace the zoom box's mask and corners with a primary-tinted selection. --- .../components/widgets/GraphPanelWidget.vue | 95 ++++++++----------- 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 1f2a714b..7bfed6c5 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -14,11 +14,13 @@ :width="plotRect.width" :height="plotRect.height" > - - - - - - {{ hoverLabel.text }} - - -
+ +
+
+ + {{ hover.x }} +
+
+ + + {{ row.value }} +
+
+{{ hover.otherRows }} more
+
+
@@ -223,14 +211,11 @@ const ZOOM_FACTOR = 0.5; // Factor by which our ranges get scaled when zooming i const DRAGGER_SIZE = 20; // Like Plotly, the thickness of our axis (and corner) draggers. const AXIS_DRAGGER_OFFSET = 0.5; // Like Plotly, the distance between our plotting area and our axis draggers. const HIGHLIGHT_FADED_ALPHA = 0.2; // Opacity of our traces that are not highlighted when one of them is. -const HOVER_FONT = 'Arial, sans-serif'; // Like Plotly, the font of our hover label. -const HOVER_FONT_SIZE = 13; // Like Plotly, the font size of our hover label. -const HOVER_LABEL_HEIGHT = 21; // Like Plotly, the height of our hover label (in CSS pixels). -const HOVER_ARROW_SIZE = 6; // Like Plotly, the size of the arrow of our hover label (in CSS pixels). -const HOVER_TEXT_PADDING = 3; // Like Plotly, the horizontal padding of our hover label's text (in CSS pixels). -const HOVER_NAME_LENGTH = 15; // Like Plotly, the maximum length of a trace name in our hover label. const HOVER_DISTANCE = 20; // Like Plotly, the maximum distance between the mouse pointer and a point to hover it. const HOVER_LINE_RADIUS = 0.5; // Like Plotly, the "radius" of a point without a marker when hovering it. +const HOVER_TOOLTIP_OFFSET = 16; // Distance between the mouse pointer and our hover tooltip. +const HOVER_TOOLTIP_MARGIN = 4; // Minimum distance between our hover tooltip and the edges of the window. +const HOVER_TOOLTIP_MAX_ROWS = 12; // Maximum number of traces listed in our hover tooltip. // Resize handling. @@ -283,6 +268,7 @@ const legendRef = vue.shallowRef(null); const xAxisTitleRef = vue.shallowRef(null); const yAxisTitleRef = vue.shallowRef(null); const rangeEditorRef = vue.shallowRef(null); +const hoverTooltipRef = vue.shallowRef(null); const theme = vueCommon.useTheme(); const contextMenuRef = vue.ref | null>(null); const appendTarget = vueCommon.useAppendTarget(rootRef); @@ -465,7 +451,7 @@ const legendItems = vue.computed(() => { .sort((item1: ILegendItem, item2: ILegendItem) => item1.legendRank - item2.legendRank || item1.index - item2.index); }); -// Our plotting area, which we use to position our axis titles, axis draggers, zoom box, hover label, and legend. +// Our plotting area, which we use to position our axis titles, axis draggers, zoom box, hover tooltip, and legend. interface IPlotRect { left: number; @@ -691,6 +677,10 @@ const onContextMenu = (event: MouseEvent): void => { event.preventDefault(); + // Hide our hover tooltip, if any, so that it doesn't get in the way of our context menu. + + hover.value = null; + // Keep track of where our context menu was requested, so that we can zoom in/out around that position (see zoomIn() // and zoomOut()). // Note: we only consider the position along an axis if it is within our plotting area along that axis (e.g., not if @@ -731,7 +721,7 @@ const onContextMenu = (event: MouseEvent): void => { contextMenuRef.value?.show(event); }; -// Copy and export, like Plotly, i.e. an image of our plot and of our legend (but not of our hover label or zoom box). +// Copy and export, like Plotly, i.e. an image of our plot and of our legend (but not of our hover tooltip or zoom box). // Note: we build an SVG document from what we currently show, which we either export as is or rasterise. For our axis // titles and legend, we use where the browser renders their text, so that any HTML they contain (e.g., // subscripts and superscripts) gets rendered as it is in our panel. @@ -2347,7 +2337,7 @@ const stopDrag = (): void => { } drag = undefined; - hoverLabel.value = null; + hover.value = null; dragCursor.value = null; zoomBox.value = null; }; @@ -2717,8 +2707,8 @@ const drawTickLabels = (u: uPlot): void => { context.restore(); }; -// Text measuring, which we use to make sure that our X tick labels don't overlap, to size our Y axis, right margin, and -// hover label, and to position the text in our exports. +// Text measuring, which we use to make sure that our X tick labels don't overlap, to size our Y axis and right margin, +// and to position the text in our exports. let fontFamily = 'sans-serif'; @@ -3286,24 +3276,27 @@ const setPlotSize = (u: uPlot, width: number, height: number): void => { }); }; -// Hover functionality, like Plotly (with its default 'closest' hover mode), i.e. a label for the point that is closest -// to the mouse pointer (within HOVER_DISTANCE pixels) with its coordinates (and the name of its trace if we have -// several traces). +// Hover functionality, i.e. a tooltip that lists the value of each of our visible traces at the X value that is under +// the mouse pointer (like Plotly's 'x unified' hover mode). +// Note: a trace which X values are not sorted (e.g., a phase plot) doesn't have one value per X value, so we use its +// point that is closest to the mouse pointer instead (like Plotly's 'closest' hover mode). -interface IHoverLabel { - x: number; - y: number; - anchor: 'start' | 'end'; - path: string; - text: string; - textX: number; +interface IHoverRow { + seriesIndex: number; name: string; - nameX: number; + value: string; color: string; - contrastColor: string; } -const hoverLabel = vue.shallowRef(null); +interface IHover { + clientX: number; + clientY: number; + x: string; + rows: IHoverRow[]; + otherRows: number; +} + +const hover = vue.shallowRef(null); let hoverRafId: number | undefined; let sortedXValues: boolean[] = []; @@ -3327,129 +3320,98 @@ const lowerBound = (values: math.FloatArray, value: number, end: number): number return low; }; -const relativeLuminance = (color: string): number | undefined => { - // Return the (WCAG) relative luminance of the given (hexadecimal) colour, if valid. - - const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim()); - - if (!match?.[1]) { - return undefined; - } - - const hex = - match[1].length === 3 ? [...match[1]].map((character: string) => character + character).join('') : match[1]; - const [red, green, blue] = [0, 2, 4].map((index: number) => { - const value = Number.parseInt(hex.slice(index, index + 2), 16) / 255; - - return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; - }) as [number, number, number]; - - return 0.2126 * red + 0.7152 * green + 0.0722 * blue; -}; +const formatHoverValue = (value: number): string => { + // Like Plotly, format a value in our hover tooltip using the value itself as the tick spacing and adding four digits + // of precision. -const contrastRatio = (luminance1: number, luminance2: number): number => { - // Return the (WCAG) contrast ratio between two colours of the given relative luminances. + const absValue = Math.abs(value) || 1; + const format = numberFormat(absValue, absValue); - return (Math.max(luminance1, luminance2) + 0.05) / (Math.min(luminance1, luminance2) + 0.05); + return formatNumber(value, { + ...format, + tickRound: format.tickRound + 4 + }); }; -const contrastColor = (color: string): string => { - // Like Plotly (see its Color.contrast()), return Plotly's background colour (i.e. white) for a colour that is dark, - // i.e. that contrasts more with Plotly's background colour than with its default line colour, and Plotly's default - // line colour otherwise. - - const PLOTLY_BACKGROUND = '#fff'; - const PLOTLY_DEFAULT_LINE = '#444'; +const hoveredIndex = ( + xValues: math.FloatArray, + yValues: math.FloatArray, + sorted: boolean, + left: number, + top: number, + radius: number, + area: IPathArea +): number => { + // Return the index of the point of the given trace that gets hovered, if any, i.e. the point which X value is the + // closest to the mouse pointer (within HOVER_DISTANCE pixels) if its X values are sorted, or its point that is the + // closest to the mouse pointer (within HOVER_DISTANCE pixels) otherwise. + // Note: like Plotly (see its scatter hoverPoints() and Fx.getClosest()), the distance between the mouse pointer and a + // point is reduced by the "radius" of the point (i.e. the radius of its marker, if any, or HOVER_LINE_RADIUS + // pixels otherwise), with points within that radius being at a distance of zero. Also, in case of a tie, the + // last point wins, so that a point that is drawn on top of another one gets hovered. - const luminance = relativeLuminance(color); + const { width, height, xMin, xMax, yMin, yMax } = area; + const xFactor = width / (xMax - xMin); + const yFactor = height / (yMax - yMin); + const nbOfPoints = Math.min(xValues.length, yValues.length); + let res = -1; + let closestDistance = HOVER_DISTANCE; - if (luminance === undefined) { - return PLOTLY_BACKGROUND; - } + if (sorted) { + // Look at the points on either side of the X value that is under the mouse pointer (using a binary search), + // skipping any point that doesn't have finite X and Y values. - const backgroundLuminance = relativeLuminance(PLOTLY_BACKGROUND) as number; - const defaultLineLuminance = relativeLuminance(PLOTLY_DEFAULT_LINE) as number; + const index = lowerBound(xValues, xMin + left / xFactor, nbOfPoints); - return contrastRatio(luminance, backgroundLuminance) > contrastRatio(luminance, defaultLineLuminance) - ? PLOTLY_BACKGROUND - : PLOTLY_DEFAULT_LINE; -}; + for (const i of [index - 1, index]) { + if (i < 0 || i >= nbOfPoints) { + continue; + } -const hoverName = (name: string): string => { - // Like Plotly (see its svgTextUtils.plainText()), return the given (HTML) trace name with only some basic tags (which - // don't count towards its length) and truncated to HOVER_NAME_LENGTH characters (with an ellipsis), if needed. + const x = xValues[i] as number; + const y = yValues[i] as number; - const HOVER_NAME_TAGS = ['br', 'sub', 'sup', 'b', 'i', 'em', 's', 'u']; + if (Number.isFinite(x) && Number.isFinite(y)) { + const distance = Math.max(Math.abs((x - xMin) * xFactor - left) - radius, 0); - const ellipsis = '...'; - const res: string[] = []; - let previousTag = ''; - let length = 0; + if (distance <= closestDistance) { + res = i; + closestDistance = distance; + } + } + } - for (const part of name.split(/(<[^<>]*>)/)) { - // Keep any part that is an HTML tag that we support, but without its attributes (since our name gets rendered using - // v-html), and skip any other HTML tag. + return res; + } - const tagMatch = /<(\/?)([^ >]*)(\s+(.*))?>/i.exec(part); - const tag = tagMatch?.[2]?.toLowerCase(); + // Look at all the points of our trace. - if (tag) { - if (HOVER_NAME_TAGS.includes(tag)) { - res.push(`<${tagMatch?.[1] ?? ''}${tag}>`); + for (let i = 0; i < nbOfPoints; ++i) { + // Skip our point if its horizontal distance (less its radius) is greater than the distance of the closest point so + // far (or than HOVER_DISTANCE pixels if we haven't found one yet), in which case it can't be as close to the mouse + // pointer. + // Note: we negate our comparison so that a point with a NaN X value gets skipped. - previousTag = tag; - } + const dx = ((xValues[i] as number) - xMin) * xFactor - left; + if (!(Math.abs(dx) - radius <= closestDistance)) { continue; } - // Make sure that our name doesn't exceed HOVER_NAME_LENGTH characters, truncating it if needed. - - if (length + part.length < HOVER_NAME_LENGTH) { - res.push(part); - - length += part.length; - } else if (length < HOVER_NAME_LENGTH) { - const remainingLength = HOVER_NAME_LENGTH - length; + // Keep track of our point if it is at least as close to the mouse pointer as the closest point so far (so that, in + // case of a tie, the last point wins). + // Note: a point with a NaN Y value gets skipped since its distance is NaN. - if ( - previousTag && - (previousTag !== 'br' || remainingLength <= ellipsis.length || part.length <= ellipsis.length) - ) { - res.pop(); - } - - res.push(part.slice(0, Math.max(0, remainingLength - ellipsis.length)) + ellipsis); + const dy = height - ((yValues[i] as number) - yMin) * yFactor - top; + const distance = Math.max(Math.sqrt(dx * dx + dy * dy) - radius, 0); - break; + if (distance <= closestDistance) { + res = i; + closestDistance = distance; } - - // Our part is some text (rather than an HTML tag), so reset our previous tag. - - previousTag = ''; } - return res.join(''); -}; - -const formatHoverValue = (value: number): string => { - // Like Plotly, format a value in our hover label using the value itself as the tick spacing and adding four digits of - // precision. - - const absValue = Math.abs(value) || 1; - const format = numberFormat(absValue, absValue); - - return formatNumber(value, { - ...format, - tickRound: format.tickRound + 4 - }); -}; - -const plainText = (html: string): string => { - // Return the given HTML (e.g., a trace name) as plain text. - // Note: the document created by DOMParser is inert, i.e. no script gets executed and no resource gets loaded. - - return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''; + return res; }; const updateHover = (): void => { @@ -3457,42 +3419,26 @@ const updateHover = (): void => { const u = plot; const rect = plotRect.value; + const mainDiv = mainDivRef.value; const left = u?.cursor.left; const top = u?.cursor.top; - const xMin = u?.scales.x?.min; - const xMax = u?.scales.x?.max; - const yMin = u?.scales.y?.min; - const yMax = u?.scales.y?.max; + const area = u ? pathArea(u) : undefined; - if ( - !u || - !rect || - drag || - left == null || - top == null || - left < 0 || - top < 0 || - xMin == null || - xMax == null || - yMin == null || - yMax == null - ) { - hoverLabel.value = null; + if (!u || !rect || !mainDiv || !area || drag || left == null || top == null || left < 0 || top < 0) { + hover.value = null; return; } - // Find the point that is closest to the mouse pointer (within HOVER_DISTANCE pixels), if any. - // Note: like Plotly (see its scatter hoverPoints() and Fx.getClosest()), the distance between the mouse pointer and a - // point is reduced by the "radius" of the point (i.e. the radius of its marker, if any, or HOVER_LINE_RADIUS - // pixels otherwise), with points within that radius being at a distance of zero. Also, in case of a tie, the - // last point wins, so that a point that is drawn on top of another one gets hovered. + // Determine the point of each of our visible traces that gets hovered, if any, and the X value of the one that is the + // closest to the mouse pointer (horizontally), which is the X value that we show in our hover tooltip. + // Note: we use our plotting area in CSS pixels (since the position of the mouse pointer is in CSS pixels). - const xFactor = rect.width / (xMax - xMin); - const yFactor = rect.height / (yMax - yMin); - let closestDistance = HOVER_DISTANCE; - let closestSeriesIndex = -1; - let closestIndex = -1; + const cssArea: IPathArea = { ...area, ...rect, pxRatio: 1 }; + const xFactor = cssArea.width / (cssArea.xMax - cssArea.xMin); + const hoveredIndices = new Map(); + let xValue = Number.NaN; + let xDistance = Number.POSITIVE_INFINITY; for (let seriesIndex = 1; seriesIndex < u.series.length; ++seriesIndex) { // Make sure that our series is visible and that it has data. @@ -3503,93 +3449,96 @@ const updateHover = (): void => { continue; } - // Determine the points that we need to look at, i.e. all of them unless our X values are sorted, in which case we - // only look at the points that are within HOVER_DISTANCE pixels (plus their radius) horizontally (using a binary - // search). + // Determine the point of our series that gets hovered, if any. const radius = seriesHasMarkers(u, seriesIndex) ? 0.5 * MARKER_SIZE : HOVER_LINE_RADIUS; - const maxDx = HOVER_DISTANCE + radius; - const nbOfPoints = Math.min(xValues.length, yValues.length); - let start = 0; - let end = nbOfPoints; - - if (sortedXValues[seriesIndex - 1]) { - start = lowerBound(xValues, xMin + (left - maxDx) / xFactor, nbOfPoints); - end = lowerBound(xValues, xMin + (left + maxDx) / xFactor, nbOfPoints); + const index = hoveredIndex(xValues, yValues, !!sortedXValues[seriesIndex - 1], left, top, radius, cssArea); + + if (index !== -1) { + const x = xValues[index] as number; + const distance = Math.abs((x - cssArea.xMin) * xFactor - left); + + hoveredIndices.set(seriesIndex, index); + + if (distance < xDistance) { + xValue = x; + xDistance = distance; + } } + } - // Find the point that is closest to the mouse pointer (within HOVER_DISTANCE pixels) among the points that we are - // looking at. + // Make sure that some points get hovered, and hide our hover tooltip if none do. - for (let i = start; i < end; ++i) { - // Skip our point if its horizontal distance (less its radius) is greater than the distance of the closest point - // so far (or than HOVER_DISTANCE pixels if we haven't found one yet), in which case it can't be as close to the - // mouse pointer. - // Note: we negate our comparison so that a point with a NaN X value gets skipped. + if (!hoveredIndices.size) { + hover.value = null; - const dx = ((xValues[i] as number) - xMin) * xFactor - left; + return; + } - if (!(Math.abs(dx) - radius <= closestDistance)) { - continue; - } + // Build our hover tooltip, listing our traces in the order of our legend. - // Keep track of our point if it is at least as close to the mouse pointer as the closest point so far (so that, - // in case of a tie, the last point wins). - // Note: a point with a NaN Y value gets skipped since its distance is NaN. + const rows: IHoverRow[] = []; - const dy = rect.height - ((yValues[i] as number) - yMin) * yFactor - top; - const distance = Math.max(Math.sqrt(dx * dx + dy * dy) - radius, 0); + for (const legendItem of legendItems.value) { + const seriesIndex = legendItem.index + 1; + const index = hoveredIndices.get(seriesIndex); + const yValues = seriesData(u, seriesIndex)?.[1]; - if (distance <= closestDistance) { - closestDistance = distance; - closestSeriesIndex = seriesIndex; - closestIndex = i; - } + if (index === undefined || !yValues) { + continue; } + + rows.push({ + seriesIndex, + name: legendItem.name, + value: formatHoverValue(yValues[index] as number), + color: legendItem.color + }); } - // Make sure that we found a point that is close enough to the mouse pointer, and hide our hover label if we didn't. + // Show our hover tooltip at the mouse pointer (in CSS pixels). + + const mainDivRect = mainDiv.getBoundingClientRect(); + + hover.value = { + clientX: mainDivRect.left + rect.left + left, + clientY: mainDivRect.top + rect.top + top, + x: formatHoverValue(xValue), + rows: rows.slice(0, HOVER_TOOLTIP_MAX_ROWS), + otherRows: Math.max(0, rows.length - HOVER_TOOLTIP_MAX_ROWS) + }; + + // Position our hover tooltip once it has been rendered (since its position depends on its size). + + void vue.nextTick().then(placeHoverTooltip); +}; - const closestTrace = props.data.traces[closestSeriesIndex - 1]; - const [closestXValues, closestYValues] = seriesData(u, closestSeriesIndex) ?? []; +const placeHoverTooltip = (): void => { + // Make sure that our hover tooltip has been rendered and that we have a mouse pointer. - if (!closestTrace || !closestXValues || !closestYValues) { - hoverLabel.value = null; + const tooltip = hoverTooltipRef.value; + const currentHover = hover.value; + if (!tooltip || !currentHover) { return; } - // Build our label, which is to the right of our point unless there isn't enough room for it, in which case it is to - // the left of our point. - - const hoverFont = `${HOVER_FONT_SIZE}px ${HOVER_FONT}`; - const xValue = closestXValues[closestIndex] as number; - const yValue = closestYValues[closestIndex] as number; - const text = `(${formatHoverValue(xValue)}, ${formatHoverValue(yValue)})`; - const name = props.data.traces.length > 1 ? hoverName(closestTrace.name) : ''; - const boxWidth = textWidth(text, hoverFont) + 2 * HOVER_TEXT_PADDING; - const nameWidth = name ? textWidth(plainText(name), hoverFont) + 2 * HOVER_TEXT_PADDING : 0; - // Note: our name may contain some subscripts/superscripts, which are smaller, so nameWidth may overestimate the width - // of our name, but that's fine since we only use it to decide whether our label should be to the left of our - // point. - const x = rect.left + (xValue - xMin) * xFactor; - const y = rect.top + rect.height - (yValue - yMin) * yFactor; - const anchor = x + HOVER_ARROW_SIZE + boxWidth + nameWidth <= (mainDivRef.value?.clientWidth || 0) ? 'start' : 'end'; - const sign = anchor === 'start' ? 1 : -1; - const halfHeight = 0.5 * HOVER_LABEL_HEIGHT; - - hoverLabel.value = { - x, - y, - anchor, - path: `M0,0L${sign * HOVER_ARROW_SIZE},${HOVER_ARROW_SIZE}v${halfHeight - HOVER_ARROW_SIZE}h${sign * boxWidth}v${-HOVER_LABEL_HEIGHT}H${sign * HOVER_ARROW_SIZE}V${-HOVER_ARROW_SIZE}Z`, - text, - textX: sign * (HOVER_ARROW_SIZE + HOVER_TEXT_PADDING), - name, - nameX: x + sign * (HOVER_ARROW_SIZE + boxWidth), - color: closestTrace.color, - contrastColor: contrastColor(closestTrace.color) - }; + // Place our hover tooltip to the right of the mouse pointer or, if there isn't enough room for it, to its left, and + // vertically centred on it, while keeping it within the window. + + const { width, height } = tooltip.getBoundingClientRect(); + const rightLeft = currentHover.clientX + HOVER_TOOLTIP_OFFSET; + const left = + rightLeft + width + HOVER_TOOLTIP_MARGIN <= window.innerWidth + ? rightLeft + : currentHover.clientX - HOVER_TOOLTIP_OFFSET - width; + const top = Math.min( + Math.max(currentHover.clientY - 0.5 * height, HOVER_TOOLTIP_MARGIN), + window.innerHeight - height - HOVER_TOOLTIP_MARGIN + ); + + tooltip.style.left = `${Math.max(left, HOVER_TOOLTIP_MARGIN)}px`; + tooltip.style.top = `${top}px`; }; const queueHoverUpdate = (): void => { @@ -3767,10 +3716,10 @@ const createPlot = (width: number, height: number): void => { updatePlotRect(u); updateMarginsAsync(); - // Update our hover label, if any, since our plot may have changed (e.g., our data got updated or we zoomed - // in/out). + // Update our hover tooltip, if any, since our plot may have changed (e.g., our data got updated or we + // zoomed in/out). - if (hoverLabel.value) { + if (hover.value) { queueHoverUpdate(); } } @@ -4074,8 +4023,8 @@ vue.watch( .axis-title :deep(sub), .axis-title :deep(sup), - .hover-name :deep(sub), - .hover-name :deep(sup), + .hover-tooltip :deep(sub), + .hover-tooltip :deep(sup), .legend :deep(sub), .legend :deep(sup) { position: static; @@ -4083,13 +4032,13 @@ vue.watch( } .axis-title :deep(sub), - .hover-name :deep(sub), + .hover-tooltip :deep(sub), .legend :deep(sub) { vertical-align: sub; } .axis-title :deep(sup), - .hover-name :deep(sup), + .hover-tooltip :deep(sup), .legend :deep(sup) { vertical-align: super; } @@ -4127,14 +4076,69 @@ vue.watch( outline: none; } - .hover-name { - position: absolute; - z-index: 3; + /** + * Note: our hover tooltip is shown above our panels (so that it doesn't get clipped by short panels), but below + * PrimeVue's overlays (e.g., our context menu), which z-index is at least 1000. + */ + + .hover-tooltip { + position: fixed; + z-index: 999; + max-width: 320px; + padding: 6px 10px; + border: 1px solid var(--p-overlay-popover-border-color); + border-radius: var(--p-overlay-popover-border-radius); + background-color: var(--p-overlay-popover-background); + box-shadow: var(--p-overlay-popover-shadow); + color: var(--p-overlay-popover-color); + font-size: 12px; + line-height: 18px; white-space: nowrap; pointer-events: none; user-select: none; } + .hover-tooltip-header { + display: flex; + gap: 12px; + margin-bottom: 2px; + color: var(--p-text-muted-color); + } + + .hover-tooltip-row { + display: flex; + gap: 8px; + align-items: center; + } + + .hover-tooltip-swatch { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + } + + .hover-tooltip-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .hover-tooltip-value { + margin-left: auto; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .hover-tooltip-header .hover-tooltip-value { + color: var(--p-overlay-popover-color); + } + + .hover-tooltip-other-rows { + color: var(--p-text-muted-color); + } + .legend { position: absolute; z-index: 2; From e47d17330bb5120f8039bcab77e2012e74cc0f1a Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 12:45:08 +1300 Subject: [PATCH 08/14] Graph Panel widget: add a hover guide line and highlight the hovered points. --- .../components/widgets/GraphPanelWidget.vue | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index fe0ca290..37372b58 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -69,6 +69,20 @@ v-html="data.yAxisTitle" /> + + + +
(() => { @@ -311,7 +327,8 @@ const themeColors = vue.computed(() => { zeroLine: useLightMode ? 'var(--p-surface-300)' : 'var(--p-surface-600)', zoomFill: 'color-mix(in srgb, var(--p-primary-color) 12%, transparent)', zoomStroke: 'var(--p-primary-color)', - hiddenTrace: useLightMode ? 'var(--p-surface-300)' : 'var(--p-surface-600)' + hiddenTrace: useLightMode ? 'var(--p-surface-300)' : 'var(--p-surface-600)', + hoverGuide: 'color-mix(in srgb, var(--p-text-muted-color) 60%, transparent)' }; }); @@ -451,7 +468,8 @@ const legendItems = vue.computed(() => { .sort((item1: ILegendItem, item2: ILegendItem) => item1.legendRank - item2.legendRank || item1.index - item2.index); }); -// Our plotting area, which we use to position our axis titles, axis draggers, zoom box, hover tooltip, and legend. +// Our plotting area, which we use to position our axis titles, axis draggers, zoom box, hover guide line, hover points, +// hover tooltip, and legend. interface IPlotRect { left: number; @@ -3277,10 +3295,18 @@ const setPlotSize = (u: uPlot, width: number, height: number): void => { }; // Hover functionality, i.e. a tooltip that lists the value of each of our visible traces at the X value that is under -// the mouse pointer (like Plotly's 'x unified' hover mode). +// the mouse pointer (like Plotly's 'x unified' hover mode), with a vertical guide line at that X value and the +// corresponding points of our traces highlighted. // Note: a trace which X values are not sorted (e.g., a phase plot) doesn't have one value per X value, so we use its // point that is closest to the mouse pointer instead (like Plotly's 'closest' hover mode). +interface IHoverPoint { + seriesIndex: number; + x: number; + y: number; + color: string; +} + interface IHoverRow { seriesIndex: number; name: string; @@ -3291,6 +3317,9 @@ interface IHoverRow { interface IHover { clientX: number; clientY: number; + guideX: number; + guideWidth: number; + points: IHoverPoint[]; x: string; rows: IHoverRow[]; otherRows: number; @@ -3436,6 +3465,7 @@ const updateHover = (): void => { const cssArea: IPathArea = { ...area, ...rect, pxRatio: 1 }; const xFactor = cssArea.width / (cssArea.xMax - cssArea.xMin); + const yFactor = cssArea.height / (cssArea.yMax - cssArea.yMin); const hoveredIndices = new Map(); let xValue = Number.NaN; let xDistance = Number.POSITIVE_INFINITY; @@ -3475,19 +3505,28 @@ const updateHover = (): void => { return; } - // Build our hover tooltip, listing our traces in the order of our legend. + // Build our hover tooltip, listing our traces in the order of our legend, and highlight the hovered points that are + // within our plotting area. + const points: IHoverPoint[] = []; const rows: IHoverRow[] = []; for (const legendItem of legendItems.value) { const seriesIndex = legendItem.index + 1; const index = hoveredIndices.get(seriesIndex); - const yValues = seriesData(u, seriesIndex)?.[1]; + const [xValues, yValues] = seriesData(u, seriesIndex) ?? []; - if (index === undefined || !yValues) { + if (index === undefined || !xValues || !yValues) { continue; } + const x = ((xValues[index] as number) - cssArea.xMin) * xFactor; + const y = cssArea.height - ((yValues[index] as number) - cssArea.yMin) * yFactor; + + if (x >= 0 && x <= cssArea.width && y >= 0 && y <= cssArea.height) { + points.push({ seriesIndex, x, y, color: legendItem.color }); + } + rows.push({ seriesIndex, name: legendItem.name, @@ -3496,13 +3535,20 @@ const updateHover = (): void => { }); } - // Show our hover tooltip at the mouse pointer (in CSS pixels). + // Show our hover tooltip at the mouse pointer (in CSS pixels) and our guide line at our X value. + // Note: like our grid and zero lines (see linesPath()), our guide line is snapped to whole device pixels and is a + // whole number of device pixels wide (see lineWidth()), so that it is crisp whatever our pixel ratio. const mainDivRect = mainDiv.getBoundingClientRect(); + const pxRatio = dependencies._uPlot.pxRatio as number; + const guideWidth = lineWidth(pxRatio); hover.value = { clientX: mainDivRect.left + rect.left + left, clientY: mainDivRect.top + rect.top + top, + guideX: (Math.round((xValue - cssArea.xMin) * xFactor * pxRatio) + 0.5 * (guideWidth % 2)) / pxRatio, + guideWidth: guideWidth / pxRatio, + points, x: formatHoverValue(xValue), rows: rows.slice(0, HOVER_TOOLTIP_MAX_ROWS), otherRows: Math.max(0, rows.length - HOVER_TOOLTIP_MAX_ROWS) From b355fa891150951ee5b03ecc95c07b4d04e303d1 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 12:56:11 +1300 Subject: [PATCH 09/14] Graph Panel widget: extract `naturalRange()` from `scaleRange()`. --- .../components/widgets/GraphPanelWidget.vue | 99 ++++++++++--------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 37372b58..b6fdd90d 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -1437,74 +1437,83 @@ let zoomTitles: string | undefined; // along that axis gets reset) or when our axis titles change (i.e. when we are plotting something else, see // updatePlot()). -const scaleRange = (axisKey: AxisKey): uPlot.Range.Function => { - return (u: uPlot, dataMin: number | null, dataMax: number | null): Range => { - // If the user zoomed into a range for the axis, then we use it. +const naturalRange = (u: uPlot, axisKey: AxisKey, dataMin: number | null, dataMax: number | null): Range => { + // Return the natural range of the given axis, i.e. the given range for the axis, if any, or the autorange of the + // given data range otherwise (or of our data if no data range is given). - const zoomRange = zoomRanges[axisKey]; + // If a (finite) range was given for the axis, then we use it. - if (zoomRange) { - return [...zoomRange]; + const range = axisKey === 'x' ? props.data.xAxisRange : props.data.yAxisRange; + + if (range && Number.isFinite(range[0]) && Number.isFinite(range[1])) { + // Make sure that the given range is in increasing order (e.g., if our ending point is before our starting point) + // and has a non-zero width (e.g., if our starting and ending points are the same) since uPlot can handle neither. + // Note: like Plotly, we widen a zero-width range by 1 or 1ppm, whichever is bigger. + + const min = Math.min(range[0], range[1]); + const max = Math.max(range[0], range[1]); + + if (min === max) { + const delta = Math.max(1, 1e-6 * Math.abs(min)); + + return [min - delta, max + delta]; } - // If a (finite) range was given for the axis, then we use it. + return [min, max]; + } + + // No range was given, so we use the range of our data. + // Note: uPlot's range of our data may not be finite, e.g., if a trace starts with a NaN value (uPlot then never + // moves away from it) or has an infinite value. In that case, we determine the range of our data ourselves, + // ignoring non-finite values. - const range = axisKey === 'x' ? props.data.xAxisRange : props.data.yAxisRange; + let min = dataMin; + let max = dataMax; - if (range && Number.isFinite(range[0]) && Number.isFinite(range[1])) { - // Make sure that the given range is in increasing order (e.g., if our ending point is before our starting point) - // and has a non-zero width (e.g., if our starting and ending points are the same) since uPlot can handle neither. - // Note: like Plotly, we widen a zero-width range by 1 or 1ppm, whichever is bigger. + if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max)) { + const finiteRange = finiteDataRange(u, axisKey); - const min = Math.min(range[0], range[1]); - const max = Math.max(range[0], range[1]); + if (!finiteRange) { + // No (finite) data, so if some of our traces are hidden (e.g., the user hid all of them using our legend), then + // we keep our last autorange (so that the user sees the same view when showing some traces again). Otherwise, + // we use Plotly's default range. - if (min === max) { - const delta = Math.max(1, 1e-6 * Math.abs(min)); + const lastAutoRange = lastAutoRanges[axisKey]; - return [min - delta, max + delta]; + if (lastAutoRange && u.series.some((series: uPlot.Series, index: number) => index > 0 && !series.show)) { + return [...lastAutoRange]; } - return [min, max]; + return axisKey === 'x' ? [...DEFAULT_X_RANGE] : [...DEFAULT_Y_RANGE]; } - // No range was given, so we use the range of our data. - // Note: uPlot's range of our data may not be finite, e.g., if a trace starts with a NaN value (uPlot then never - // moves away from it) or has an infinite value. In that case, we determine the range of our data ourselves, - // ignoring non-finite values. + [min, max] = finiteRange; + } - let min = dataMin; - let max = dataMax; + // Autorange our axis and keep track of it. + // Note: we keep track of it here rather than rely on uPlot's scales since our plot may get recreated (e.g., when the + // theme changes) while all our traces are hidden. - if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max)) { - const finiteRange = finiteDataRange(u, axisKey); + const res = autoRange(u, axisKey, min, max); - if (!finiteRange) { - // No (finite) data, so if some of our traces are hidden (e.g., the user hid all of them using our legend), then - // we keep our last autorange (so that the user sees the same view when showing some traces again). Otherwise, - // we use Plotly's default range. + lastAutoRanges[axisKey] = res; - const lastAutoRange = lastAutoRanges[axisKey]; + return [...res]; +}; - if (lastAutoRange && u.series.some((series: uPlot.Series, index: number) => index > 0 && !series.show)) { - return [...lastAutoRange]; - } +const scaleRange = (axisKey: AxisKey): uPlot.Range.Function => { + return (u: uPlot, dataMin: number | null, dataMax: number | null): Range => { + // If the user zoomed into a range for the axis, then we use it. - return axisKey === 'x' ? [...DEFAULT_X_RANGE] : [...DEFAULT_Y_RANGE]; - } + const zoomRange = zoomRanges[axisKey]; - [min, max] = finiteRange; + if (zoomRange) { + return [...zoomRange]; } - // Autorange our axis and keep track of it. - // Note: we keep track of it here rather than rely on uPlot's scales since our plot may get recreated (e.g., when the - // theme changes) while all our traces are hidden. - - const res = autoRange(u, axisKey, min, max); - - lastAutoRanges[axisKey] = res; + // Use the natural range of the axis. - return [...res]; + return naturalRange(u, axisKey, dataMin, dataMax); }; }; From 70a2bb7d6444309c99b3e5d1deaf97a4127b8208 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 13:17:52 +1300 Subject: [PATCH 10/14] Graph Panel widget: animate zooming in/out, resetting the zoom, and using a zoom box, the mouse wheel, or the range editor. --- .../components/widgets/GraphPanelWidget.vue | 244 ++++++++++++++++-- 1 file changed, 226 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index b6fdd90d..694df95e 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -222,6 +222,8 @@ const DEFAULT_Y_RANGE: [number, number] = [-1, 4]; // Like Plotly, the default Y const MIN_DRAG = 8; // Like Plotly, the minimum distance that we must drag for it not to be a click (e.g., to zoom). const MIN_ZOOM = 20; // Like Plotly, the distance below which a drag is considered to be along one axis only. const ZOOM_FACTOR = 0.5; // Factor by which our ranges get scaled when zooming in (or its inverse when zooming out). +const ZOOM_ANIMATION_DURATION = 250; // Duration of our zoom animations (in milliseconds). +const WHEEL_ZOOM_ANIMATION_DURATION = 100; // Duration of our wheel zoom animations (in milliseconds). const DRAGGER_SIZE = 20; // Like Plotly, the thickness of our axis (and corner) draggers. const AXIS_DRAGGER_OFFSET = 0.5; // Like Plotly, the distance between our plotting area and our axis draggers. const HIGHLIGHT_FADED_ALPHA = 0.2; // Opacity of our traces that are not highlighted when one of them is. @@ -1503,6 +1505,15 @@ const naturalRange = (u: uPlot, axisKey: AxisKey, dataMin: number | null, dataMa const scaleRange = (axisKey: AxisKey): uPlot.Range.Function => { return (u: uPlot, dataMin: number | null, dataMax: number | null): Range => { + // If the axis is being animated, then we use its animated range (e.g., if our data gets updated while we are + // animating a zoom, see animateScales()). + + const animatedRange = animatedRanges[axisKey]; + + if (animatedRange) { + return [...animatedRange]; + } + // If the user zoomed into a range for the axis, then we use it. const zoomRange = zoomRanges[axisKey]; @@ -1555,9 +1566,184 @@ const setScales = (ranges: Partial>): void => { }); }; -const zoomTo = (ranges: Partial>): void => { - // Zoom into the given ranges, but only if they are all valid (e.g., not if we zoomed in so much that the two ends of - // a range are the same number). +// Zoom animations, i.e. our ranges smoothly go from their current values to their new values (or their natural values +// if they get reset) rather than jump to them. +// Note #1: while animating, our range function uses our animated ranges (see scaleRange()), so that an update of our +// data (e.g., while a simulation is running) doesn't interfere with our animation. +// Note #2: an update of our data may, however, change the natural range of an axis that gets reset, so we update its +// target whenever our data changes (see stepRangeAnimation()), so that it ends up at its natural range rather +// than jump to it once we are done animating. +// Note #3: we don't animate if the user prefers reduced motion. + +interface IRangeAnimation { + from: Partial>; + to: Partial>; + final: Partial>; + data: uPlot.AlignedData; + start: number; + duration: number; +} + +let rangeAnimation: IRangeAnimation | undefined; +let rangeAnimationRafId: number | undefined; +let animatedRanges: Partial> = {}; + +const targetRange = (axisKey: AxisKey): Range | undefined => { + // Return the range that the given axis has or will have once we are done animating it. + + return rangeAnimation?.to[axisKey] ?? currentRange(axisKey); +}; + +const animateScales = (final: Partial>, duration: number): void => { + // Animate the scales of our plot from their current ranges to the given ones (with a null range meaning that the + // corresponding axis gets autoranged, see setScales()) over the given duration, carrying on with the axes that we may + // already be animating. + + // Make sure that we have a plot to animate. + + const u = plot; + + if (!u) { + return; + } + + // Merge our final ranges with the final ranges of our current animation, if any, so that we don't lose any axes that + // we are already animating. + + const mergedFinal = { ...rangeAnimation?.final, ...final }; + + if (duration <= 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + stopRangeAnimation(); + setScales(mergedFinal); + + return; + } + + // Determine the ranges that we are animating from and to, using our current ranges as the starting point for our + // animation (which, if we are already animating, are our current animated ranges, so that we carry on smoothly), and + // our final ranges as the ending point for our animation (with a null range meaning that the corresponding axis gets + // autoranged, see setScales()). + + const from: Partial> = {}; + const to: Partial> = {}; + + for (const axisKey of Object.keys(mergedFinal) as AxisKey[]) { + const range = currentRange(axisKey); + + if (range) { + from[axisKey] = range; + to[axisKey] = mergedFinal[axisKey] ?? naturalRange(u, axisKey, null, null); + } + } + + // Start our animation or, if we are already animating, carry on with it using our new ranges. + + rangeAnimation = { + from, + to, + final: mergedFinal, + data: u.data, + start: performance.now(), + duration + }; + + rangeAnimationRafId ??= requestAnimationFrame(stepRangeAnimation); +}; + +const stepRangeAnimation = (now: number): void => { + // Move our ranges one step closer to their target, using an ease-out (cubic) curve, or finish our animation if we are + // done. + + rangeAnimationRafId = undefined; + + // Make sure that we are animating. + + const animation = rangeAnimation; + + if (!animation) { + return; + } + + // Determine how far we are through our animation, clamping to 0% if we are before its start, and finish our animation + // if we have reached its end. + + const progress = Math.max(0, (now - animation.start) / animation.duration); + + if (progress >= 1) { + finishRangeAnimation(); + + return; + } + + // Update the target of our axes that get reset if our data has changed since we last determined it (e.g., while a + // simulation is running). + // Note: uPlot replaces its data whenever it is given new data (see updatePlot()), so we can just compare references. + + const u = plot; + + if (u && u.data !== animation.data) { + animation.data = u.data; + + for (const axisKey of Object.keys(animation.to) as AxisKey[]) { + if (animation.final[axisKey] === null) { + animation.to[axisKey] = naturalRange(u, axisKey, null, null); + } + } + } + + // Move our ranges one step closer to their target. + + const easedProgress = 1 - (1 - progress) ** 3; + + animatedRanges = {}; + + for (const axisKey of Object.keys(animation.to) as AxisKey[]) { + const from = animation.from[axisKey]; + const to = animation.to[axisKey]; + + if (from && to) { + animatedRanges[axisKey] = [ + from[0] + easedProgress * (to[0] - from[0]), + from[1] + easedProgress * (to[1] - from[1]) + ]; + } + } + + // Update our scales to our new ranges. + + setScales(animatedRanges); + + rangeAnimationRafId = requestAnimationFrame(stepRangeAnimation); +}; + +const stopRangeAnimation = (): void => { + // Stop our animation, if any, leaving our ranges as they currently are. + + if (rangeAnimationRafId !== undefined) { + cancelAnimationFrame(rangeAnimationRafId); + + rangeAnimationRafId = undefined; + } + + rangeAnimation = undefined; + animatedRanges = {}; +}; + +const finishRangeAnimation = (): void => { + // Finish our animation, if any, i.e. set our ranges to their final values. + + const animation = rangeAnimation; + + stopRangeAnimation(); + + if (animation) { + setScales(animation.final); + } +}; + +const zoomTo = (ranges: Partial>, duration: number = 0): void => { + // Zoom into the given ranges, animating our zoom over the given duration, but only if they are all valid (e.g., not + // if we zoomed in so much that the two ends of a range are the same number). for (const range of Object.values(ranges)) { if (!Number.isFinite(range[0]) || !Number.isFinite(range[1]) || range[0] >= range[1]) { @@ -1567,26 +1753,40 @@ const zoomTo = (ranges: Partial>): void => { zoomRanges = { ...zoomRanges, ...ranges }; - setScales(ranges); + if (duration > 0) { + animateScales(ranges, duration); + } else { + finishRangeAnimation(); + setScales(ranges); + } }; -const zoomBy = (factor: number, axisKeys: AxisKey[], centerFractions: Partial>): void => { +const zoomBy = ( + factor: number, + axisKeys: AxisKey[], + centerFractions: Partial>, + duration: number +): void => { // Scale the range of the given axes by the given factor around the given fractions of their range (or their middle - // for an axis without a fraction). + // for an axis without a fraction), animating our zoom over the given duration. + // Note: we scale the range that an axis will have once we are done animating it (rather than its current range), so + // that zooming several times in a row (e.g., using the mouse wheel) is not affected by our animation. However, + // we zoom around what is currently under the mouse pointer. const ranges: Partial> = {}; for (const axisKey of axisKeys) { const range = currentRange(axisKey); + const target = targetRange(axisKey); - if (range) { + if (range && target) { const center = range[0] + (range[1] - range[0]) * (centerFractions[axisKey] ?? 0.5); - ranges[axisKey] = [center + factor * (range[0] - center), center + factor * (range[1] - center)]; + ranges[axisKey] = [center + factor * (target[0] - center), center + factor * (target[1] - center)]; } } - zoomTo(ranges); + zoomTo(ranges, duration); }; let contextMenuCenterFractions: Partial> = {}; @@ -1594,17 +1794,18 @@ let contextMenuCenterFractions: Partial> = {}; const zoomIn = (): void => { // Zoom in around where our context menu was requested (see onContextMenu()). - zoomBy(ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions); + zoomBy(ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions, ZOOM_ANIMATION_DURATION); }; const zoomOut = (): void => { // Zoom out around where our context menu was requested (see onContextMenu()). - zoomBy(1 / ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions); + zoomBy(1 / ZOOM_FACTOR, ['x', 'y'], contextMenuCenterFractions, ZOOM_ANIMATION_DURATION); }; const resetZoom = (axisKeys: AxisKey[] = ['x', 'y']): void => { - // Like Plotly, reset the zoom of the given axes, i.e. go back to the given ranges, if any, or autorange the axes. + // Like Plotly, reset the zoom of the given axes, i.e. go back to the given ranges, if any, or autorange the axes, and + // animate our reset. const ranges: Partial> = {}; @@ -1614,7 +1815,7 @@ const resetZoom = (axisKeys: AxisKey[] = ['x', 'y']): void => { ranges[axisKey] = null; } - setScales(ranges); + animateScales(ranges, ZOOM_ANIMATION_DURATION); }; // Mouse interaction, like with Plotly: @@ -1821,6 +2022,10 @@ const onDragStart = (event: MouseEvent, type: DragType): void => { applyWheelZoom(); } + // Finish our zoom animation, if any, so that our drag is based on our final ranges. + + finishRangeAnimation(); + // Start dragging, but only with the main mouse button and if our plot has been laid out. const rect = plotRect.value; @@ -2056,10 +2261,10 @@ const onDragEnd = (event: MouseEvent): void => { ]; } - // Zoom into the given ranges, but only if they are all valid (e.g., not if we zoomed in so much that the two ends of - // a range are the same number). + // Zoom into the given ranges (animating our zoom), but only if they are all valid (e.g., not if we zoomed in so much + // that the two ends of a range are the same number). - zoomTo(ranges); + zoomTo(ranges, ZOOM_ANIMATION_DURATION); }; const onDragKeyDown = (event: KeyboardEvent): void => { @@ -2255,7 +2460,7 @@ const closeRangeEditor = (apply: boolean): void => { newRange[editor.end] = value; - zoomTo({ [editor.axisKey]: newRange }); + zoomTo({ [editor.axisKey]: newRange }, ZOOM_ANIMATION_DURATION); }; const onRangeEditorKeyDown = (event: KeyboardEvent): void => { @@ -2289,7 +2494,7 @@ const applyWheelZoom = (): void => { pendingWheelZoom = undefined; - zoomBy(factor, axisKeys, centerFractions); + zoomBy(factor, axisKeys, centerFractions, WHEEL_ZOOM_ANIMATION_DURATION); }; const onWheel = (event: WheelEvent, type: DragType): void => { @@ -3864,6 +4069,8 @@ const updatePlot = (): void => { if (titles !== zoomTitles) { zoomTitles = titles; zoomRanges = {}; + + stopRangeAnimation(); } // Create or update our plot. @@ -3988,6 +4195,7 @@ vue.onBeforeUnmount(() => { } stopDrag(); + stopRangeAnimation(); closeRangeEditor(false); From afda7d70f38cddf89519e9f2f53e092f6da2681b Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 14:28:01 +1300 Subject: [PATCH 11/14] Graph Panel widget: animate the axes that are not zoomed when showing or hiding a trace. --- .../components/widgets/GraphPanelWidget.vue | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 694df95e..43c6fda0 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -541,7 +541,8 @@ const syncLegendWidth = (): void => { const setTraceVisibilities = (visibilities: boolean[]): void => { // Set the visibility of our traces, both in our legend and in our plot. - // Note: uPlot autoranges our axes again when the visibility of a trace changes. + // Note: uPlot autoranges our axes again when the visibility of a trace changes, in which case we animate our axes + // that are not zoomed from their current range to their new (natural) range (see animateScales()). traceVisibilities.value = visibilities; @@ -558,17 +559,53 @@ const setTraceVisibilities = (visibilities: boolean[]): void => { return; } - currentPlot.batch(() => { - visibilities.forEach((visible: boolean, index: number) => { - const plotSeries = currentPlot.series[index + 1]; + // Determine the traces which visibility has changed in our plot, if any, and leave if there are none (so that we don't + // animate our axes for nothing). - if (plotSeries && plotSeries.show !== visible) { - currentPlot.setSeries(index + 1, { - show: visible - }); - } - }); + const seriesIndices: number[] = []; + + visibilities.forEach((visible: boolean, index: number) => { + const plotSeries = currentPlot.series[index + 1]; + + if (plotSeries && plotSeries.show !== visible) { + seriesIndices.push(index + 1); + } }); + + if (!seriesIndices.length) { + return; + } + + // Keep our axes that are not zoomed at their current range while updating the visibility of our traces (by making + // them use their current range as their animated range, see scaleRange()), so that we can then animate them to their + // new range. + + const axisKeys = (['x', 'y'] as AxisKey[]).filter((axisKey: AxisKey) => !zoomRanges[axisKey]); + + for (const axisKey of axisKeys) { + const range = currentRange(axisKey); + + if (range) { + animatedRanges[axisKey] = range; + } + } + + currentPlot.batch(() => { + for (const seriesIndex of seriesIndices) { + currentPlot.setSeries(seriesIndex, { + show: visibilities[seriesIndex - 1] + }); + } + }); + + // Animate our axes that are not zoomed to their new range. + + if (axisKeys.length) { + animateScales( + Object.fromEntries(axisKeys.map((axisKey: AxisKey) => [axisKey, null])) as Partial>, + ZOOM_ANIMATION_DURATION + ); + } }; interface ILegendToggle { From ed326375a097961496e7b11ba365ce1024d2962f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 14:40:40 +1300 Subject: [PATCH 12/14] Graph Panel widget: put our CSS in alphabetical order. --- .../components/widgets/GraphPanelWidget.vue | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 43c6fda0..2e81989a 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -4357,25 +4357,6 @@ vue.watch( } } - /** - * Note: we use the font size that Plotly means to use for its range editor, i.e. 12px (Plotly gets its unit wrong, so - * it actually uses the font size of the page, i.e. 16px), with the same line height (relative to its font size) - * as Plotly's range editor. - */ - - .range-editor { - position: absolute; - z-index: 4; - box-sizing: border-box; - margin: -0.5px 0 0 -1px; - padding: 0; - font-family: Arial, sans-serif; - font-size: 12px; - line-height: 1.5; - white-space: nowrap; - outline: none; - } - /** * Note: our hover tooltip is shown above our panels (so that it doesn't get clipped by short panels), but below * PrimeVue's overlays (e.g., our context menu), which z-index is at least 1000. @@ -4405,6 +4386,21 @@ vue.watch( color: var(--p-text-muted-color); } + .hover-tooltip-header .hover-tooltip-value { + color: var(--p-overlay-popover-color); + } + + .hover-tooltip-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .hover-tooltip-other-rows { + color: var(--p-text-muted-color); + } + .hover-tooltip-row { display: flex; gap: 8px; @@ -4418,27 +4414,12 @@ vue.watch( border-radius: 50%; } - .hover-tooltip-name { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - } - .hover-tooltip-value { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; } - .hover-tooltip-header .hover-tooltip-value { - color: var(--p-overlay-popover-color); - } - - .hover-tooltip-other-rows { - color: var(--p-text-muted-color); - } - .legend { position: absolute; z-index: 2; @@ -4488,6 +4469,25 @@ vue.watch( border-radius: 1.5px; } + /** + * Note: we use the font size that Plotly means to use for its range editor, i.e. 12px (Plotly gets its unit wrong, so + * it actually uses the font size of the page, i.e. 16px), with the same line height (relative to its font size) + * as Plotly's range editor. + */ + + .range-editor { + position: absolute; + z-index: 4; + box-sizing: border-box; + margin: -0.5px 0 0 -1px; + padding: 0; + font-family: Arial, sans-serif; + font-size: 12px; + line-height: 1.5; + white-space: nowrap; + outline: none; + } + :deep(.u-over) { cursor: crosshair; } From 0d8f528b63ac0d7b99433fcf9be4e0d716cde85f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 15:24:57 +1300 Subject: [PATCH 13/14] Graph Panel widget: corrected/refined some comments. --- .../components/widgets/GraphPanelWidget.vue | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 2e81989a..0eb69605 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -214,9 +214,9 @@ const TICK_LABEL_MIN_SPACING = 10; // Minimum spacing between two consecutive ti const MAX_NB_OF_TICKS = 1000; // A safeguard against an axis needing an unreasonable number of ticks. const LINE_WIDTH = 2; // Like Plotly, the width of our traces' lines (in CSS pixels). const MARKER_SIZE = 6; // Like Plotly, the size of our markers (in CSS pixels). -const MARKER_PADDING = Math.max(MARKER_SIZE / 1.6, 3); // Autorange padding for markers. -const MIN_NB_OF_POINTS_WITHOUT_MARKERS = 20; // Like in Plotly, traces with fewer than 20 points also have markers. -const AUTORANGE_PADDING = 0.05; // As a fraction of the length of an axis. +const MARKER_PADDING = Math.max(MARKER_SIZE / 1.6, 3); // Autorange padding for markers (in CSS pixels). +const MIN_NB_OF_POINTS_WITHOUT_MARKERS = 20; // Like Plotly, traces with fewer than 20 points also have markers. +const AUTORANGE_PADDING = 0.05; // Autorange padding (as a fraction of the length of an axis). const DEFAULT_X_RANGE: [number, number] = [-1, 6]; // Like Plotly, the default X range. const DEFAULT_Y_RANGE: [number, number] = [-1, 4]; // Like Plotly, the default Y range. const MIN_DRAG = 8; // Like Plotly, the minimum distance that we must drag for it not to be a click (e.g., to zoom). @@ -229,7 +229,7 @@ const AXIS_DRAGGER_OFFSET = 0.5; // Like Plotly, the distance between our plotti const HIGHLIGHT_FADED_ALPHA = 0.2; // Opacity of our traces that are not highlighted when one of them is. const HOVER_DISTANCE = 20; // Like Plotly, the maximum distance between the mouse pointer and a point to hover it. const HOVER_LINE_RADIUS = 0.5; // Like Plotly, the "radius" of a point without a marker when hovering it. -const HOVER_POINT_RADIUS = 4; // Radius of the highlighted points of our hover tooltip. +const HOVER_POINT_RADIUS = 4; // Radius of the points that we highlight when hovering our traces. const HOVER_TOOLTIP_OFFSET = 16; // Distance between the mouse pointer and our hover tooltip. const HOVER_TOOLTIP_MARGIN = 4; // Minimum distance between our hover tooltip and the edges of the window. const HOVER_TOOLTIP_MAX_ROWS = 12; // Maximum number of traces listed in our hover tooltip. @@ -358,7 +358,8 @@ const traceVisibilities = vue.shallowRef([]); // (e.g., by clicking on its legend item) and stop highlighting it if it gets hidden or removed (e.g., when // runs get tracked in interactive mode, in which case its legend item may be removed without us being told // that it is no longer hovered). -// Note #2: this must come before our watcher of our traces since it runs immediately and calls highlightTrace(). +// Note #2: this must come before the watcher of our traces (below) since that watcher runs immediately and calls +// highlightTrace(). let hoveredTrace: number | null = null; let highlightedTrace: number | null = null; @@ -526,7 +527,8 @@ const measureLegendWidth = (): number => { }; const syncLegendWidth = (): void => { - // Get our plot to recompute its layout if the width of our legend has changed since it is part of our right margin. + // Get our plot to recompute its layout if the width of our legend has changed since our legend is part of our right + // margin. const newLegendWidth = measureLegendWidth(); @@ -778,7 +780,8 @@ const onContextMenu = (event: MouseEvent): void => { contextMenuRef.value?.show(event); }; -// Copy and export, like Plotly, i.e. an image of our plot and of our legend (but not of our hover tooltip or zoom box). +// Copy and export, like Plotly, i.e. an image of our plot and of our legend (but not of what we show when hovering or +// zooming, i.e. our hover tooltip, guide line, and highlighted points, and our zoom box). // Note: we build an SVG document from what we currently show, which we either export as is or rasterise. For our axis // titles and legend, we use where the browser renders their text, so that any HTML they contain (e.g., // subscripts and superscripts) gets rendered as it is in our panel. @@ -1318,8 +1321,8 @@ const exportToCsv = async (): Promise => { console.warn('OpenCOR: failed to export to CSV:', common.formatError(error)); } finally { - // Hide the progress message. If the export succeeded then delay briefly so that the user can see that we reached - // 100%. Otherwise, hide immediately to avoid blocking the UI unnecessarily. + // Hide the progress message. If the export succeeded, then delay briefly so that the user can see that we reached + // 100%. Otherwise, hide it immediately to avoid blocking the UI unnecessarily. if (successfulExport) { await common.sleep(LONG_DELAY); @@ -1418,7 +1421,7 @@ const autoRange = (u: uPlot, axisKey: AxisKey, min: number, max: number): Range // Note: the minimum and maximum extrema of a trace with markers are also within the range of our traces, but they get // padded more, so we consider them separately. - // Make sure that our range is not zero-width, since uPlot can't handle it. + // Make sure that our range is not zero-width since uPlot can't handle it. if (min === max) { return [min - 1, max + 1]; @@ -1580,8 +1583,8 @@ const setScales = (ranges: Partial>): void => { // Set the scales of our plot to the given ranges with a null range meaning that the corresponding axis gets // autoranged (see scaleRange()). // Note #1: uPlot calls our range function when the min/max of a scale is null, hence the cast. - // Note #2: in mode 2, uPlot only invalidates the paths of our traces when our Y scale changes, but it also does so - // whenever our X scale is set, so our traces always get redrawn. + // Note #2: in mode 2, uPlot invalidates the paths of our traces when our Y scale changes and whenever our X scale is + // set, so our traces always get redrawn. const currentPlot = plot; @@ -1990,7 +1993,8 @@ const plotPosition = ( }; const dZoom = (fraction: number): number => { - // Like Plotly, the (non-linear) scaling of a range when moving one of its ends by the given fraction of its length. + // Like Plotly, return the (non-linear) scaling of a range when moving one of its ends by the given fraction of its + // length. return 1 - (fraction >= 0 ? Math.min(fraction, 0.9) : 1 / (1 / Math.max(fraction, -0.3) + 3.222)); }; @@ -2542,7 +2546,7 @@ const onWheel = (event: WheelEvent, type: DragType): void => { return; } - // Prevent the default behavior of the mouse wheel, which is to scroll the page. + // Prevent the default behaviour of the mouse wheel, which is to scroll the page. event.preventDefault(); @@ -2905,7 +2909,7 @@ interface ITickLabel { } const tickLabels = (u: uPlot, area: IPathArea): ITickLabel[] => { - // Return our tick labels and where to draw them (in pixels of the given area), i.e., like Plotly, with their + // Return our tick labels and where to draw them (in pixels of the given area), i.e. like Plotly, with their // alphabetic baseline X_TICK_LABEL_BASELINE pixels below our plotting area for our X axis and, for our Y axis, // Y_TICK_LABEL_GAP pixels to the left of our plotting area and with their alphabetic baseline Y_TICK_LABEL_BASELINE // pixels below their tick. @@ -3811,7 +3815,7 @@ const updateHover = (): void => { }; const placeHoverTooltip = (): void => { - // Make sure that our hover tooltip has been rendered and that we have a mouse pointer. + // Make sure that our hover tooltip has been rendered and that we are still hovering. const tooltip = hoverTooltipRef.value; const currentHover = hover.value; @@ -3866,7 +3870,7 @@ const isSorted = (values: math.FloatArray): boolean => { }; const plotData = (): uPlot.AlignedData => { - // The data to be plotted. + // Return the data to be plotted. // Note #1: we also keep track of whether the X values of our traces are sorted (see updateHover()). // Note #2: uPlot needs at least one series, hence we use an empty one if we have no traces. @@ -3894,7 +3898,7 @@ const createPlot = (width: number, height: number): void => { fontFamily = getComputedStyle(mainDivRef.value).fontFamily; } - // Create a series for each trace, using the trace's color for the stroke, our linear path builder, and our markers + // Create a series for each trace, using the trace's colour for the stroke, our linear path builder, and our markers // path builder (for traces with markers). const series: uPlot.Series[] = [{}]; @@ -3910,7 +3914,7 @@ const createPlot = (width: number, height: number): void => { show: (u: uPlot, seriesIndex: number) => seriesHasMarkers(u, seriesIndex), paths: markerPaths, size: MARKER_SIZE, - width: 0 // Note: this means that our markers are filled using the trace's color. + width: 0 // Note: this means that our markers are filled using the trace's colour. }, facets: [ { scale: 'x', auto: true }, @@ -4217,7 +4221,8 @@ vue.onBeforeUnmount(() => { isUnmounted = true; - // Stop zooming, hovering, and dragging. + // Stop zooming (including any zoom animation), hovering, and dragging, and close our range editor (discarding its + // value). if (wheelRafId !== undefined) { cancelAnimationFrame(wheelRafId); From f56d711f51e45bec97e31486114163ac8da58fb8 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 23 Oct 2026 15:50:08 +1300 Subject: [PATCH 14/14] Graph Panel widget: use OpenCOR's font for the range editor. --- src/renderer/src/components/widgets/GraphPanelWidget.vue | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 0eb69605..a2265e38 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -4475,9 +4475,9 @@ vue.watch( } /** - * Note: we use the font size that Plotly means to use for its range editor, i.e. 12px (Plotly gets its unit wrong, so - * it actually uses the font size of the page, i.e. 16px), with the same line height (relative to its font size) - * as Plotly's range editor. + * Note: we use OpenCOR's font (which we inherit, like our axis titles and legend), but with the font size that Plotly + * means to use for its range editor, i.e. 12px (Plotly gets its unit wrong, so it actually uses the font size + * of the page, i.e. 16px), and with the same line height (relative to its font size) as Plotly's range editor. */ .range-editor { @@ -4486,7 +4486,6 @@ vue.watch( box-sizing: border-box; margin: -0.5px 0 0 -1px; padding: 0; - font-family: Arial, sans-serif; font-size: 12px; line-height: 1.5; white-space: nowrap;