From 15e914909557ddbae5e1c2f488116da3684e7533 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 21 Jun 2026 16:02:10 +0200 Subject: [PATCH] feat(chart): split multi-magnitude tracks across two y-axes Tracks whose peak values span more than an order of magnitude now plot on a second right-hand axis, so a small track is no longer flattened against the baseline by a large one. Also labels the legend's time column "Time" (was uPlot's default "Value") and gives the toggleable track rows a pointer cursor so the built-in hide/show is discoverable. --- src/components/SimChart.vue | 75 +++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/src/components/SimChart.vue b/src/components/SimChart.vue index 794c61c..f8b5d4c 100644 --- a/src/components/SimChart.vue +++ b/src/components/SimChart.vue @@ -76,8 +76,54 @@ function themeColor(name: string, fallback: string): string { return value || fallback } +/** + * Peak |value| of a track as an order of magnitude (log10), or null when the + * track is empty or flat-zero — there is no magnitude to compare. + */ +function magnitude(track: (number | null)[]): number | null { + let peak = 0 + for (const v of track) { + if (v !== null && Number.isFinite(v)) { + const a = Math.abs(v) + if (a > peak) peak = a + } + } + return peak > 0 ? Math.log10(peak) : null +} + +// Split tracks onto a second y-axis once they span more than this many orders of +// magnitude: below it a single shared axis reads fine; above it the smaller track +// is flattened against the baseline by the larger one. +const SPLIT_DECADES = 1 + +/** + * Assign each series a y-scale: "y" (left axis) or "y2" (right axis). Tracks are + * split at their widest magnitude gap when that gap exceeds SPLIT_DECADES, with the + * smaller-magnitude tracks moving to "y2"; otherwise every track stays on "y". + * Flat-zero tracks have no magnitude, so they stay on the primary axis. + */ +function scaleKeys(): ("y" | "y2")[] { + const tracks = props.data.slice(1) as (number | null)[][] + const mags = tracks.map(magnitude) + const known = mags.filter((m): m is number => m !== null).sort((a, b) => a - b) + if (known.length < 2) return tracks.map(() => "y") + // The widest gap between adjacent magnitudes is the natural cut between "big" and + // "small" tracks; its midpoint is the threshold each track is measured against. + let gap = 0 + let cut = 0 + for (let i = 1; i < known.length; i++) { + if (known[i] - known[i - 1] > gap) { + gap = known[i] - known[i - 1] + cut = (known[i] + known[i - 1]) / 2 + } + } + if (gap < SPLIT_DECADES) return tracks.map(() => "y") + return mags.map((m) => (m !== null && m < cut ? "y2" : "y")) +} + function trackSig(): string { - return props.series.map((s) => `${s.label}|${s.stroke}`).join(",") + const scales = scaleKeys() + return props.series.map((s, i) => `${s.label}|${s.stroke}|${scales[i]}`).join(",") } function options(width: number): uPlot.Options { @@ -92,6 +138,8 @@ function options(width: number): uPlot.Options { ticks: { stroke: themeColor("--color-base-300", "#e5e7eb"), width: 1 }, font: `11px ${family}`, } + const scales = scaleKeys() + const split = scales.includes("y2") return { width, height: props.height, @@ -101,10 +149,13 @@ function options(width: number): uPlot.Options { cursor: { y: false, drag: { x: false, y: false } }, scales: { x: { time: false } }, series: [ - {}, + // x is simulation time, so the live legend's first column reads "Time" + // rather than uPlot's default "Value". + { label: "Time" }, ...props.series.map( - (s): uPlot.Series => ({ + (s, i): uPlot.Series => ({ label: s.label, + scale: scales[i], stroke: s.stroke, width: 2, points: { show: false }, @@ -112,7 +163,15 @@ function options(width: number): uPlot.Options { }), ), ], - axes: [axis, { ...axis, size: 52 }], + // When tracks span orders of magnitude the small ones move to a right-hand + // axis ("y2"); its grid is suppressed so the two grids don't clash. + axes: split + ? [ + axis, + { ...axis, size: 52 }, + { ...axis, scale: "y2", side: 1, size: 52, grid: { show: false } }, + ] + : [axis, { ...axis, size: 52 }], } } @@ -176,4 +235,12 @@ watch(() => props.marker, syncMarker) :deep(.u-legend .u-value) { font-variant-numeric: tabular-nums; } +/* Each track row toggles its line on click (⌘/Ctrl-click isolates it); the first + row is the Time readout and has no such handler. */ +:deep(.u-legend .u-series) { + cursor: pointer; +} +:deep(.u-legend .u-series:first-child) { + cursor: default; +}