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,