From bb87171309347458903779a955ffff27eb640995 Mon Sep 17 00:00:00 2001 From: daharoni Date: Wed, 8 Jul 2026 13:42:16 -0700 Subject: [PATCH] refactor(ui): shared uPlot axis/cursor/range helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme axis chrome ({ stroke: AXIS_TEXT, grid: { stroke: AXIS_GRID }, ticks: { stroke: AXIS_TICK } }) plus label styling, the integer-tick formatter, the cursor-sync config, and the degenerate-span range guard were copy-pasted into every chart across the apps. Collapse them into @calab/ui/chart: - chartAxis(overrides) / labeledAxis(label, overrides) — axis chrome + labels - integerTickValues / hiddenTickValues — reusable formatters - syncCursor(key) / staticCursor — cursor configs - safeRange(padFrac) — the zero-span guard (uPlot crashes on a [v, v] range) Migrate the live direct-SolidUplot charts to the helpers: AsymptoteTrends, KernelConvergence, KernelDisplay, HistogramCard, and the shared TracePanel. Behavior-preserving — no visual change (verified in-browser across all tabs). Dead components (drilldown/*, SubsetVariance — imported nowhere) are left for the cleanup pass rather than migrated. Co-Authored-By: Claude Fable 5 --- .../src/components/charts/AsymptoteTrends.tsx | 52 +++++---------- .../components/charts/KernelConvergence.tsx | 30 ++------- .../distributions/HistogramCard.tsx | 18 ++--- .../src/components/kernel/KernelDisplay.tsx | 24 ++----- packages/ui/src/chart/TracePanel.tsx | 22 ++----- packages/ui/src/chart/axis-helpers.ts | 66 +++++++++++++++++++ packages/ui/src/chart/index.ts | 9 +++ 7 files changed, 113 insertions(+), 108 deletions(-) create mode 100644 packages/ui/src/chart/axis-helpers.ts diff --git a/apps/cadecon/src/components/charts/AsymptoteTrends.tsx b/apps/cadecon/src/components/charts/AsymptoteTrends.tsx index 257ba5b..b1441bf 100644 --- a/apps/cadecon/src/components/charts/AsymptoteTrends.tsx +++ b/apps/cadecon/src/components/charts/AsymptoteTrends.tsx @@ -21,7 +21,15 @@ import { type KernelSnapshot, } from '../../lib/iteration-store.ts'; import { viewedIteration } from '../../lib/viz-store.ts'; -import { wheelZoomPlugin, AXIS_TEXT, AXIS_GRID, AXIS_TICK, METRIC_COLORS } from '@calab/ui/chart'; +import { + wheelZoomPlugin, + chartAxis, + labeledAxis, + integerTickValues, + syncCursor, + safeRange, + METRIC_COLORS, +} from '@calab/ui/chart'; import { convergenceMarkerPlugin } from '../../lib/chart/convergence-marker-plugin.ts'; import { viewedIterationPlugin } from '../../lib/chart/viewed-iteration-plugin.ts'; @@ -73,23 +81,11 @@ const PANELS: PanelDef[] = [ }, ]; -/** Y range that never degenerates: uPlot throws in drawAxesGrid on a zero span. */ -function yRange(_u: uPlot, dataMin: number, dataMax: number): [number, number] { - if (dataMin == null || dataMax == null || !isFinite(dataMin) || !isFinite(dataMax)) return [0, 1]; - if (dataMin === dataMax) { - const pad = Math.abs(dataMin) * 0.05 || 0.5; - return [dataMin - pad, dataMax + pad]; - } - const pad = (dataMax - dataMin) * 0.1; - return [dataMin - pad, dataMax + pad]; -} - -/** X range = exact iteration extent; pad only the single-point case (also avoids a zero span). */ -function xRange(_u: uPlot, dataMin: number, dataMax: number): [number, number] { - if (dataMin == null || dataMax == null || !isFinite(dataMin) || !isFinite(dataMax)) return [0, 1]; - if (dataMin === dataMax) return [dataMin - 0.5, dataMax + 0.5]; - return [dataMin, dataMax]; -} +// Degenerate-span guards (uPlot throws in drawAxesGrid on a zero span). Y pads +// 10%; X uses the exact iteration extent (the createEffect below drives the +// real x-scale — these are the fallbacks). +const yRange = safeRange(0.1); +const xRange = safeRange(0); /** One compact trend chart; reads convergence history reactively. */ function MiniTrend(props: { panel: PanelDef }): JSX.Element { @@ -158,22 +154,8 @@ function MiniTrend(props: { panel: PanelDef }): JSX.Element { ]; const axes: uPlot.Axis[] = [ - { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - size: 24, - values: (_u, splits) => (splits ?? []).map((v) => (Number.isInteger(v) ? String(v) : '')), - }, - { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - label: props.panel.unit, - labelSize: 10, - labelFont: '10px sans-serif', - size: 38, - }, + chartAxis({ size: 24, values: integerTickValues }), + labeledAxis(props.panel.unit, { size: 38 }), ]; const plugins = [ @@ -182,7 +164,7 @@ function MiniTrend(props: { panel: PanelDef }): JSX.Element { wheelZoomPlugin(), ]; - const cursor: uPlot.Cursor = { sync: { key: 'cadecon-asymptote', setSeries: true } }; + const cursor = syncCursor('cadecon-asymptote'); return (
diff --git a/apps/cadecon/src/components/charts/KernelConvergence.tsx b/apps/cadecon/src/components/charts/KernelConvergence.tsx index 78fcbec..7ae58fa 100644 --- a/apps/cadecon/src/components/charts/KernelConvergence.tsx +++ b/apps/cadecon/src/components/charts/KernelConvergence.tsx @@ -20,9 +20,9 @@ import { import { tauToShape } from '@calab/compute'; import { wheelZoomPlugin, - AXIS_TEXT, - AXIS_GRID, - AXIS_TICK, + labeledAxis, + integerTickValues, + syncCursor, METRIC_COLORS, withOpacity, } from '@calab/ui/chart'; @@ -287,24 +287,8 @@ export function KernelConvergence(): JSX.Element { }; const axes: uPlot.Axis[] = [ - { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - label: 'Iteration', - labelSize: 10, - labelFont: '10px sans-serif', - values: (_u: uPlot, splits: number[]) => - splits.map((v) => (Number.isInteger(v) ? String(v) : '')), - }, - { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - label: 'ms', - labelSize: 10, - labelFont: '10px sans-serif', - }, + labeledAxis('Iteration', { values: integerTickValues }), + labeledAxis('ms'), ]; const plugins = [ @@ -315,9 +299,7 @@ export function KernelConvergence(): JSX.Element { wheelZoomPlugin(), ]; - const cursor: uPlot.Cursor = { - sync: { key: 'cadecon-convergence', setSeries: true }, - }; + const cursor = syncCursor('cadecon-convergence'); return ( median(props.values())); const iqrVal = createMemo(() => iqr(props.values())); diff --git a/apps/cadecon/src/components/kernel/KernelDisplay.tsx b/apps/cadecon/src/components/kernel/KernelDisplay.tsx index 4882ed6..fa8eda1 100644 --- a/apps/cadecon/src/components/kernel/KernelDisplay.tsx +++ b/apps/cadecon/src/components/kernel/KernelDisplay.tsx @@ -37,9 +37,9 @@ import { GROUND_TRUTH_COLORS, withOpacity, wheelZoomPlugin, - AXIS_TEXT, - AXIS_GRID, - AXIS_TICK, + chartAxis, + labeledAxis, + syncCursor, kernelAnnotationsPlugin, type KernelAnnotations, } from '@calab/ui/chart'; @@ -230,28 +230,14 @@ export function KernelDisplay(): JSX.Element { return s; }); - const axes: uPlot.Axis[] = [ - { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - label: 'Time (ms)', - labelSize: 10, - labelFont: '10px sans-serif', - }, - { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - }, - ]; + const axes: uPlot.Axis[] = [labeledAxis('Time (ms)'), chartAxis()]; const scales: uPlot.Scales = { x: { time: false } }; const plugins = createMemo(() => [ wheelZoomPlugin(), kernelAnnotationsPlugin(() => annotations()), ]); - const cursor: uPlot.Cursor = { sync: { key: 'cadecon-kernel', setSeries: true } }; + const cursor = syncCursor('cadecon-kernel'); const tauRMs = () => formatTauMs(currentTauRise()); const tauDMs = () => formatTauMs(currentTauDecay()); diff --git a/packages/ui/src/chart/TracePanel.tsx b/packages/ui/src/chart/TracePanel.tsx index abf5a86..464ebb1 100644 --- a/packages/ui/src/chart/TracePanel.tsx +++ b/packages/ui/src/chart/TracePanel.tsx @@ -8,7 +8,8 @@ import { SolidUplot } from '@dschz/solid-uplot'; import type uPlot from 'uplot'; import 'uplot/dist/uPlot.min.css'; import './chart-theme.css'; -import { wheelZoomPlugin, AXIS_TEXT, AXIS_GRID, AXIS_TICK } from './index.ts'; +import { wheelZoomPlugin } from './index.ts'; +import { chartAxis, hiddenTickValues } from './axis-helpers.ts'; export interface TracePanelProps { /** uPlot AlignedData format: [x, y1, y2, ...] -- signal accessor for reactivity */ @@ -64,28 +65,17 @@ export function TracePanel(props: TracePanelProps) { // recreating the chart on every data update. `props.xLabel` is read at // mount; callers never swap it mid-chart-life. /* eslint-disable solid/reactivity */ - const xAxis: uPlot.Axis = { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, + const xAxis: uPlot.Axis = chartAxis({ values: formatTimeValues, ...(props.xLabel ? { label: props.xLabel, labelSize: 10, labelGap: 0, labelFont: '10px sans-serif', size: 30 } : {}), - }; + }); /* eslint-enable solid/reactivity */ - const yAxisBase: uPlot.Axis = { - stroke: AXIS_TEXT, - grid: { stroke: AXIS_GRID }, - ticks: { stroke: AXIS_TICK }, - }; + const yAxisBase: uPlot.Axis = chartAxis(); - const yAxisHidden: uPlot.Axis = { - ...yAxisBase, - values: (_u: uPlot, vals: number[]) => vals.map(() => ''), - size: 20, - }; + const yAxisHidden: uPlot.Axis = chartAxis({ values: hiddenTickValues, size: 20 }); const yAxis = () => (props.hideYValues ? yAxisHidden : yAxisBase); diff --git a/packages/ui/src/chart/axis-helpers.ts b/packages/ui/src/chart/axis-helpers.ts new file mode 100644 index 0000000..d6cbb99 --- /dev/null +++ b/packages/ui/src/chart/axis-helpers.ts @@ -0,0 +1,66 @@ +// Shared uPlot axis / cursor / scale-range helpers. The theme axis chrome +// (stroke + grid + ticks) and the degenerate-span guard were previously +// copy-pasted into every chart component across the apps; these collapse that +// duplication into one place. + +import type uPlot from 'uplot'; +import { AXIS_TEXT, AXIS_GRID, AXIS_TICK } from './theme-colors.ts'; + +const LABEL_FONT = '10px sans-serif'; + +/** Base axis chrome (theme stroke / grid / ticks), merged with `overrides`. */ +export function chartAxis(overrides: uPlot.Axis = {}): uPlot.Axis { + return { + stroke: AXIS_TEXT, + grid: { stroke: AXIS_GRID }, + ticks: { stroke: AXIS_TICK }, + ...overrides, + }; +} + +/** Axis chrome plus a consistently-styled axis label. */ +export function labeledAxis(label: string, overrides: uPlot.Axis = {}): uPlot.Axis { + return chartAxis({ label, labelSize: 10, labelFont: LABEL_FONT, ...overrides }); +} + +/** Axis `values` formatter that shows only integer splits (e.g. an iteration axis). */ +export function integerTickValues(_u: uPlot, splits: number[]): string[] { + return (splits ?? []).map((v) => (Number.isInteger(v) ? String(v) : '')); +} + +/** Axis `values` formatter that hides all tick labels (keeps gridlines). */ +export function hiddenTickValues(_u: uPlot, splits: number[]): string[] { + return (splits ?? []).map(() => ''); +} + +/** Cursor that syncs across charts sharing `key`; pass `drag: false` for static charts. */ +export function syncCursor(key: string, opts: { drag?: boolean } = {}): uPlot.Cursor { + const cursor: uPlot.Cursor = { sync: { key, setSeries: true } }; + if (opts.drag === false) cursor.drag = { x: false, y: false }; + return cursor; +} + +/** Cursor for a static (non-synced) chart with drag-zoom disabled. */ +export const staticCursor: uPlot.Cursor = { drag: { x: false, y: false } }; + +/** + * uPlot scale-range fn that never returns a zero span — a degenerate [v, v] + * range crashes uPlot's drawAxesGrid. Non-finite/absent bounds fall back to + * [0, 1]; an equal min/max is padded; otherwise the span is padded by + * `padFrac` (use 0 for an exact-extent axis). + */ +export function safeRange( + padFrac = 0.1, +): (u: uPlot, dataMin: number, dataMax: number) => [number, number] { + return (_u, dataMin, dataMax) => { + if (dataMin == null || dataMax == null || !isFinite(dataMin) || !isFinite(dataMax)) { + return [0, 1]; + } + if (dataMin === dataMax) { + const pad = Math.abs(dataMin) * 0.05 || 0.5; + return [dataMin - pad, dataMax + pad]; + } + const pad = (dataMax - dataMin) * padFrac; + return [dataMin - pad, dataMax + pad]; + }; +} diff --git a/packages/ui/src/chart/index.ts b/packages/ui/src/chart/index.ts index 3f62e79..e6891be 100644 --- a/packages/ui/src/chart/index.ts +++ b/packages/ui/src/chart/index.ts @@ -5,6 +5,15 @@ export { transientZonePlugin } from './transient-zone-plugin.ts'; export { AXIS_TEXT, AXIS_GRID, AXIS_TICK, getThemeColors } from './theme-colors.ts'; export { VIRIDIS_LUT, viridisRGB, viridisCss } from './colormap.ts'; export { niceTicks } from './chart-math.ts'; +export { + chartAxis, + labeledAxis, + integerTickValues, + hiddenTickValues, + syncCursor, + staticCursor, + safeRange, +} from './axis-helpers.ts'; export { OKABE_ITO, OKABE_ITO_CYCLE,