portal-mojo 0.2.12 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/charts.md CHANGED
@@ -69,6 +69,55 @@ Legend entries toggle series; hover shows the crosshair index tooltip;
69
69
  ticks are "nice"-rounded; container width is measured (no viewBox
70
70
  smearing).
71
71
 
72
+ Sparse histories can opt into numeric X positions without changing categorical
73
+ callers:
74
+
75
+ ```tsx
76
+ <SeriesChart
77
+ labels={['10:01', '10:02', '10:03', '10:12', '10:13']}
78
+ xValues={[60, 120, 180, 720, 780]}
79
+ xDomain={[0, 900]}
80
+ maxGap={180}
81
+ datasets={[{ label: 'CPU', data: [12, 24, null, 40, 32] }]}
82
+ minValue={0} maxValue={100}
83
+ ariaLabel="Kiosk CPU history"
84
+ valueFormatter={(value) => `${value}%`}
85
+ onInspect={(index) => { /* selected original sample index, or null */ }}
86
+ />
87
+ ```
88
+
89
+ - `Dataset.data` accepts `(number | null | undefined)[]`. Null, undefined,
90
+ `NaN`, and infinities are unavailable: no zero substitution, connecting
91
+ line, area fill across the gap, bar, or inspection dot. Tooltips say
92
+ **Unavailable**; incomplete stacked totals do too. A one-point run always
93
+ has a dot, even above 40 labels or with routine `showDots={false}`.
94
+ - `xValues?: number[]` aligns with the original labels/data indices. Supply
95
+ ordered timestamps (seconds or milliseconds both work); labels are display
96
+ strings only. Numeric spacing uses actual values. A missing/nonfinite X
97
+ breaks the run and cannot be inspected. Reversed X movement breaks the run.
98
+ With no `xValues`, the existing evenly spaced category centers remain.
99
+ - `xDomain?: [number, number]` fixes the visible window, including empty time
100
+ before/after the samples. Out-of-window samples are omitted from drawing,
101
+ inspection, and automatic Y scaling. Omit it to fit the finite X values;
102
+ a single distinct X is centered. Invalid/reversed domains fall back to fit.
103
+ - `maxGap?: number` breaks line/area runs when the distance between consecutive
104
+ numeric X values exceeds the limit, in the **same units** as `xValues`.
105
+ It does not synthesize timestamps or missing buckets. Omit it for no extra
106
+ distance threshold. Negative/nonfinite limits are ignored.
107
+ - `minValue` and `maxValue` set exact finite Y bounds; omitted ends are
108
+ automatically scaled. Geometry is clipped at the plot bounds. A reversed
109
+ pair falls back to automatic bounds. Percentage callers can use `0`/`100`.
110
+ - `ariaLabel` names the focusable SVG. Left/Right inspect adjacent original
111
+ samples; Home/End inspect the first/last visible timestamp; Escape and blur
112
+ clear inspection. A live accessible readout announces the label and each
113
+ visible series value. Pointer movement and touch use that same index and
114
+ `onInspect(index: number | null)` callback. Touch selection stays after lift;
115
+ pointer cancellation clears it. Changing series, values, labels, X positions,
116
+ or domain clears inspection; equivalent new arrays do not.
117
+
118
+ `npm run verify:series-chart` mounts the real component to cover these
119
+ contracts alongside categorical stacked/grouped bars and container resize.
120
+
72
121
  ## MetricsChart (the django-mojo metrics widget)
73
122
 
74
123
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -3,13 +3,14 @@
3
3
  // (stacked:'auto' → true for bar), grouped opt-out, legend, gridlines,
4
4
  // nice Y ticks, thinned X labels, and Chart.js-style "index mode"
5
5
  // crosshair tooltips (snap to nearest column, ghost dots, multi-row card).
6
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
6
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
7
7
 
8
8
  export type ChartType = 'line' | 'bar' | 'area';
9
9
 
10
10
  export interface Dataset {
11
11
  label: string;
12
- data: number[];
12
+ /** Null, undefined and nonfinite numbers are missing samples, never zero. */
13
+ data: (number | null | undefined)[];
13
14
  color?: string;
14
15
  }
15
16
 
@@ -30,6 +31,18 @@ export interface SeriesChartProps {
30
31
  smoothing?: number; // 0 = straight segments
31
32
  showDots?: boolean;
32
33
  emptyText?: string;
34
+ /** Numeric positions (e.g. epoch seconds), aligned with labels and data. */
35
+ xValues?: number[];
36
+ /** Visible numeric X window, in the same units as xValues. */
37
+ xDomain?: [number, number];
38
+ /** Split line/area runs when consecutive numeric X values exceed this gap. */
39
+ maxGap?: number;
40
+ /** Exact Y bounds; omitted bounds are computed from visible data. */
41
+ minValue?: number;
42
+ maxValue?: number;
43
+ ariaLabel?: string;
44
+ /** Shared pointer/touch/keyboard inspection; null means inspection ended. */
45
+ onInspect?: (index: number | null) => void;
33
46
  }
34
47
 
35
48
  export const CHART_PALETTE = [
@@ -38,6 +51,7 @@ export const CHART_PALETTE = [
38
51
  ];
39
52
 
40
53
  const PAD = { top: 10, right: 14, bottom: 26, left: 48 };
54
+ const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
41
55
 
42
56
  function niceNum(range: number, round: boolean): number {
43
57
  const exp = Math.floor(Math.log10(range || 1));
@@ -53,6 +67,9 @@ function niceScale(min: number, max: number, ticks: number) {
53
67
  if (max === min) max = min + 1;
54
68
  const range = niceNum(max - min, false);
55
69
  const step = niceNum(range / Math.max(1, ticks - 1), true);
70
+ // Extreme but finite feeds must not turn tick generation into an endless
71
+ // loop or emit nonfinite SVG coordinates.
72
+ if (!Number.isFinite(step) || step <= 0) return { min, max, step: max / 2 - min / 2 || 1 };
56
73
  return {
57
74
  min: Math.floor(min / step) * step,
58
75
  max: Math.ceil(max / step) * step,
@@ -90,7 +107,7 @@ export function SeriesChart({
90
107
  datasets: datasetsProp,
91
108
  chartType = 'line',
92
109
  stacked = 'auto',
93
- height = 260,
110
+ height: heightProp = 260,
94
111
  showLegend = true,
95
112
  showGrid = true,
96
113
  gridLines = 5,
@@ -100,7 +117,15 @@ export function SeriesChart({
100
117
  smoothing = 0.3,
101
118
  showDots = true,
102
119
  emptyText = 'No data for this range',
120
+ xValues,
121
+ xDomain,
122
+ maxGap,
123
+ minValue,
124
+ maxValue,
125
+ ariaLabel = 'Series chart',
126
+ onInspect,
103
127
  }: SeriesChartProps) {
128
+ const height = isFiniteNumber(heightProp) && heightProp > 0 ? heightProp : 260;
104
129
  // Malformed feed data must degrade to the empty state, not take down the
105
130
  // route (a live-backend shape drift did exactly that once) — warn + empty,
106
131
  // per the unknown-value rule.
@@ -115,6 +140,9 @@ export function SeriesChart({
115
140
  const [width, setWidth] = useState(600);
116
141
  const [hover, setHover] = useState<number | null>(null);
117
142
  const [visible, setVisible] = useState<Set<string>>(() => new Set());
143
+ const id = useId();
144
+ const tipRef = useRef<HTMLDivElement>(null);
145
+ const [tipWidth, setTipWidth] = useState(180);
118
146
 
119
147
  // Measure the container — real pixel geometry keeps text unscaled
120
148
  // (a viewBox + preserveAspectRatio="none" would smear the labels).
@@ -123,7 +151,7 @@ export function SeriesChart({
123
151
  if (!node) return;
124
152
  const ro = new ResizeObserver(([entry]) => {
125
153
  const w = entry?.contentRect.width ?? 0;
126
- if (w > 0) setWidth(w);
154
+ if (isFiniteNumber(w) && w > 0) setWidth(w);
127
155
  });
128
156
  ro.observe(node);
129
157
  return () => ro.disconnect();
@@ -139,6 +167,38 @@ export function SeriesChart({
139
167
 
140
168
  const plotW = Math.max(40, width - PAD.left - PAD.right);
141
169
  const plotH = Math.max(40, height - PAD.top - PAD.bottom);
170
+ const n = labels.length;
171
+ const colW = n > 0 ? plotW / n : plotW;
172
+ const xPositions = useMemo(() => {
173
+ if (!xValues) return labels.map((_, i) => PAD.left + colW * (i + 0.5));
174
+ const values = xValues.slice(0, n).filter(isFiniteNumber);
175
+ let min = values.reduce((a, b) => Math.min(a, b), Infinity);
176
+ let max = values.reduce((a, b) => Math.max(a, b), -Infinity);
177
+ if (xDomain && isFiniteNumber(xDomain[0]) && isFiniteNumber(xDomain[1]) && xDomain[1] > xDomain[0]) {
178
+ [min, max] = xDomain;
179
+ }
180
+ return labels.map((_, i) => {
181
+ const x = xValues[i];
182
+ if (!isFiniteNumber(x) || x < min || x > max) return null;
183
+ // Half-scaled differences also work for finite domains spanning
184
+ // more than Number.MAX_VALUE, without overflowing the subtraction.
185
+ const fraction = max === min ? 0.5 : (x / 2 - min / 2) / (max / 2 - min / 2);
186
+ return Number.isFinite(fraction) ? PAD.left + fraction * plotW : null;
187
+ });
188
+ }, [labels, xValues, xDomain, n, colW, plotW]);
189
+ const inspectable = xPositions.flatMap((x, i) => x == null ? [] : [i]);
190
+ const active = hover != null && xPositions[hover] != null ? hover : null;
191
+ const lastInspection = useRef<number | null | undefined>(undefined);
192
+ // Compare content, not array identity: callers often construct props during
193
+ // render, including renders caused by onInspect itself.
194
+ const inspectionKey = JSON.stringify([seriesKey, labels, xValues, xDomain, datasets.map((ds) => ds.data)]);
195
+ useEffect(() => { setHover(null); }, [inspectionKey]);
196
+ useEffect(() => {
197
+ if (onInspect && lastInspection.current !== active) {
198
+ lastInspection.current = active;
199
+ onInspect(active);
200
+ }
201
+ }, [active, onInspect]);
142
202
 
143
203
  const scale = useMemo(() => {
144
204
  let max = 0;
@@ -148,7 +208,8 @@ export function SeriesChart({
148
208
  let pos = 0;
149
209
  let neg = 0;
150
210
  for (const ds of shown) {
151
- const v = ds.data[i] ?? 0;
211
+ const v = ds.data[i];
212
+ if (xPositions[i] == null || !isFiniteNumber(v)) continue;
152
213
  if (v >= 0) pos += v; else neg += v;
153
214
  }
154
215
  max = Math.max(max, pos);
@@ -156,28 +217,47 @@ export function SeriesChart({
156
217
  }
157
218
  } else {
158
219
  for (const ds of shown) {
159
- for (const v of ds.data) {
160
- max = Math.max(max, v ?? 0);
161
- min = Math.min(min, v ?? 0);
220
+ for (let i = 0; i < n; i++) {
221
+ const v = ds.data[i];
222
+ if (xPositions[i] == null || !isFiniteNumber(v)) continue;
223
+ max = Math.max(max, v);
224
+ min = Math.min(min, v);
162
225
  }
163
226
  }
164
227
  }
165
- return niceScale(min, max, gridLines);
166
- }, [labels.length, shown, isStacked, gridLines]);
228
+ if (!Number.isFinite(min)) min = -Number.MAX_VALUE;
229
+ if (!Number.isFinite(max)) max = Number.MAX_VALUE;
230
+ const count = isFiniteNumber(gridLines) ? Math.max(2, Math.min(100, Math.floor(gridLines))) : 5;
231
+ const nice = niceScale(min, max, count);
232
+ let lo = isFiniteNumber(minValue) ? minValue : nice.min;
233
+ let hi = isFiniteNumber(maxValue) ? maxValue : nice.max;
234
+ if (hi <= lo) {
235
+ if (isFiniteNumber(minValue) && !isFiniteNumber(maxValue)) hi = lo + Math.max(1, Math.abs(lo) * 0.1);
236
+ else if (isFiniteNumber(maxValue) && !isFiniteNumber(minValue)) lo = hi - Math.max(1, Math.abs(hi) * 0.1);
237
+ else { lo = nice.min; hi = nice.max; }
238
+ }
239
+ const boundedStep = niceNum((hi / 2 - lo / 2) / (count - 1), true) * 2;
240
+ return { min: lo, max: hi, step: minValue != null || maxValue != null ? boundedStep : nice.step };
241
+ }, [n, shown, isStacked, gridLines, xPositions, minValue, maxValue]);
167
242
 
168
243
  const yOf = useCallback(
169
- (v: number) => PAD.top + plotH - ((v - scale.min) / (scale.max - scale.min || 1)) * plotH,
170
- [plotH, scale],
244
+ (v: number) => {
245
+ const y = PAD.top + plotH - ((v / 2 - scale.min / 2) / (scale.max / 2 - scale.min / 2 || 1)) * plotH;
246
+ return Number.isFinite(y) ? y : v > scale.max ? -plotH : plotH + height;
247
+ },
248
+ [plotH, scale, height],
171
249
  );
172
250
 
173
- const n = labels.length;
174
- const colW = n > 0 ? plotW / n : plotW;
175
- // Line/area sample at column centers; bars fill the column.
176
- const xCenter = useCallback((i: number) => PAD.left + colW * (i + 0.5), [colW]);
251
+ const xCenter = (i: number) => xPositions[i]!;
177
252
 
178
253
  const ticks = useMemo(() => {
179
254
  const out: number[] = [];
180
- for (let v = scale.min; v <= scale.max + scale.step / 2; v += scale.step) out.push(v);
255
+ for (let i = 0; i < 200; i++) {
256
+ const v = scale.min + i * scale.step;
257
+ if (!isFiniteNumber(v) || v > scale.max) break;
258
+ if (out.at(-1) !== v) out.push(v);
259
+ }
260
+ if (out.at(-1) !== scale.max) out.push(scale.max);
181
261
  return out;
182
262
  }, [scale]);
183
263
 
@@ -186,14 +266,37 @@ export function SeriesChart({
186
266
 
187
267
  const colorOf = (ds: Dataset, i: number) => ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length]!;
188
268
 
189
- const onMove = (e: React.MouseEvent<SVGRectElement>) => {
269
+ const onMove = (e: React.PointerEvent<SVGRectElement>) => {
190
270
  const rect = e.currentTarget.getBoundingClientRect();
191
- const x = e.clientX - rect.left;
192
- const idx = Math.min(n - 1, Math.max(0, Math.floor(x / (colW || 1))));
193
- setHover(idx);
271
+ const x = PAD.left + (e.clientX - rect.left) * (plotW / (rect.width || plotW));
272
+ let nearest: number | null = null;
273
+ for (const i of inspectable) {
274
+ if (nearest == null || Math.abs(xCenter(i) - x) < Math.abs(xCenter(nearest) - x)) nearest = i;
275
+ }
276
+ setHover(nearest);
277
+ };
278
+
279
+ const onKeyDown = (e: React.KeyboardEvent<SVGSVGElement>) => {
280
+ const current = active == null ? -1 : inspectable.indexOf(active);
281
+ let next: number | null;
282
+ if (e.key === 'Home') next = inspectable[0] ?? null;
283
+ else if (e.key === 'End') next = inspectable.at(-1) ?? null;
284
+ else if (e.key === 'ArrowRight') next = inspectable[Math.min(inspectable.length - 1, current + 1)] ?? null;
285
+ else if (e.key === 'ArrowLeft') next = inspectable[Math.max(0, current - 1)] ?? null;
286
+ else if (e.key === 'Escape') next = null;
287
+ else return;
288
+ e.preventDefault();
289
+ setHover(next);
194
290
  };
195
291
 
196
- const hasData = n > 0 && datasets.length > 0;
292
+ const formatSample = (v: unknown) => isFiniteNumber(v) ? fmt(v) : 'Unavailable';
293
+ const readout = active == null ? '' : `${labels[active]}. ${shown.map((ds) => `${ds.label}: ${formatSample(ds.data[active])}`).join('; ')}`;
294
+ useLayoutEffect(() => {
295
+ const measured = tipRef.current?.getBoundingClientRect().width;
296
+ if (measured && Number.isFinite(measured)) setTipWidth(measured);
297
+ }, [readout, width]);
298
+
299
+ const hasData = n > 0 && datasets.length > 0 && inspectable.length > 0;
197
300
 
198
301
  return (
199
302
  <div className="chart-wrap" ref={wrapRef}>
@@ -203,8 +306,10 @@ export function SeriesChart({
203
306
  const off = visible.has(ds.label);
204
307
  return (
205
308
  <button
309
+ type="button"
206
310
  key={ds.label}
207
311
  className={`legend-item${off ? ' legend-off' : ''}`}
312
+ aria-pressed={!off}
208
313
  onClick={() => setVisible((prev) => {
209
314
  const next = new Set(prev);
210
315
  if (next.has(ds.label)) next.delete(ds.label); else next.add(ds.label);
@@ -224,7 +329,14 @@ export function SeriesChart({
224
329
  <div className="chart-empty" style={{ height }}>{emptyText}</div>
225
330
  ) : (
226
331
  <div className="chart-svg-area" style={{ height }}>
227
- <svg width={width} height={height} className="series-svg" role="img">
332
+ <svg width={width} height={height} className="series-svg" role="img"
333
+ tabIndex={0} aria-label={ariaLabel} aria-describedby={`${id}-help ${id}-value`}
334
+ onKeyDown={onKeyDown} onBlur={() => setHover(null)}>
335
+ <defs>
336
+ <clipPath id={`${id}-plot`}>
337
+ <rect x={PAD.left - 4} y={PAD.top} width={plotW + 8} height={plotH} />
338
+ </clipPath>
339
+ </defs>
228
340
  {/* gridlines + Y ticks */}
229
341
  {showGrid && ticks.map((t) => (
230
342
  <line
@@ -242,30 +354,38 @@ export function SeriesChart({
242
354
 
243
355
  {/* bars */}
244
356
  {chartType === 'bar' && labels.map((_, i) => {
245
- const inner = colW * 0.72;
357
+ if (xPositions[i] == null) return null;
358
+ const previous = xPositions[i - 1];
359
+ const next = xPositions[i + 1];
360
+ const slot = xValues ? Math.min(colW,
361
+ previous == null ? colW : Math.abs(xCenter(i) - previous),
362
+ next == null ? colW : Math.abs(next - xCenter(i))) : colW;
363
+ const inner = slot * 0.72;
246
364
  const groupN = Math.max(1, shown.length);
247
365
  let posAcc = 0;
248
366
  let negAcc = 0;
249
367
  return (
250
- <g key={`bar${i}`}>
368
+ <g key={`bar${i}`} clipPath={`url(#${id}-plot)`}>
251
369
  {shown.map((ds, di) => {
252
- const v = ds.data[i] ?? 0;
370
+ const v = ds.data[i];
371
+ if (!isFiniteNumber(v)) return null;
253
372
  const color = colorOf(ds, datasets.indexOf(ds));
254
373
  let x: number;
255
374
  let w: number;
256
375
  let y: number;
257
376
  let h: number;
258
377
  if (isStacked) {
259
- x = PAD.left + colW * i + (colW - inner) / 2;
378
+ x = xCenter(i) - inner / 2;
260
379
  w = inner;
261
380
  const base = v >= 0 ? posAcc : negAcc;
262
381
  const top = base + v;
382
+ if (!Number.isFinite(top)) return null;
263
383
  y = yOf(Math.max(base, top));
264
384
  h = Math.abs(yOf(top) - yOf(base));
265
385
  if (v >= 0) posAcc = top; else negAcc = top;
266
386
  } else {
267
387
  w = inner / groupN;
268
- x = PAD.left + colW * i + (colW - inner) / 2 + w * di;
388
+ x = xCenter(i) - inner / 2 + w * di;
269
389
  const zero = yOf(Math.max(0, scale.min));
270
390
  y = Math.min(zero, yOf(v));
271
391
  h = Math.abs(yOf(v) - zero);
@@ -276,7 +396,7 @@ export function SeriesChart({
276
396
  x={x} y={y}
277
397
  width={Math.max(1, w - 1)} height={Math.max(0, h)}
278
398
  fill={color}
279
- className={`bar${hover === i ? ' bar-hot' : ''}`}
399
+ className={`bar${active === i ? ' bar-hot' : ''}`}
280
400
  />
281
401
  );
282
402
  })}
@@ -287,41 +407,62 @@ export function SeriesChart({
287
407
  {/* area fills + lines */}
288
408
  {chartType !== 'bar' && shown.map((ds) => {
289
409
  const color = colorOf(ds, datasets.indexOf(ds));
290
- const pts = ds.data.slice(0, n).map((v, i) => [xCenter(i), yOf(v ?? 0)] as [number, number]);
291
- if (!pts.length) return null;
292
- const path = smoothPath(pts, smoothing);
410
+ const runs: { point: [number, number]; index: number }[][] = [];
411
+ let run: (typeof runs)[number] = [];
412
+ for (let i = 0; i < n; i++) {
413
+ const value = ds.data[i];
414
+ const previous = run.at(-1);
415
+ const gap = xValues && previous ? xValues[i]! - xValues[previous.index]! : 0;
416
+ if (xPositions[i] == null || !isFiniteNumber(value)) {
417
+ run = [];
418
+ continue;
419
+ }
420
+ if (previous && (gap < 0 || (isFiniteNumber(maxGap) && maxGap >= 0 && gap > maxGap))) run = [];
421
+ if (!run.length) runs.push(run);
422
+ run.push({ point: [xCenter(i), yOf(value)], index: i });
423
+ }
293
424
  const zeroY = yOf(Math.max(0, scale.min));
294
425
  return (
295
- <g key={ds.label}>
296
- {chartType === 'area' && (
297
- <path
298
- d={`${path}L${pts[pts.length - 1]![0]},${zeroY}L${pts[0]![0]},${zeroY}Z`}
299
- fill={color}
300
- opacity={0.16}
301
- />
302
- )}
303
- <path d={path} fill="none" stroke={color} strokeWidth={2} className="series-line" />
304
- {showDots && n <= 40 && pts.map(([x, y], i) => (
305
- <circle key={i} cx={x} cy={y} r={hover === i ? 4 : 2.5} fill={color} className="series-dot" />
306
- ))}
426
+ <g key={ds.label} clipPath={`url(#${id}-plot)`}>
427
+ {runs.map((segment) => {
428
+ const pts = segment.map((p) => p.point);
429
+ const path = smoothPath(pts, isFiniteNumber(smoothing) ? smoothing : 0);
430
+ return (
431
+ <g key={segment[0]!.index}>
432
+ {chartType === 'area' && pts.length > 1 && (
433
+ <path
434
+ d={`${path}L${pts[pts.length - 1]![0]},${zeroY}L${pts[0]![0]},${zeroY}Z`}
435
+ fill={color}
436
+ opacity={0.16}
437
+ />
438
+ )}
439
+ {pts.length > 1 && <path d={path} fill="none" stroke={color} strokeWidth={2} className="series-line" />}
440
+ {(pts.length === 1 || (showDots && n <= 40)) && segment.map(({ point: [x, y], index }) => (
441
+ <circle key={index} cx={x} cy={y} r={active === index ? 4 : 2.5} fill={color} className="series-dot" />
442
+ ))}
443
+ </g>
444
+ );
445
+ })}
307
446
  </g>
308
447
  );
309
448
  })}
310
449
 
311
450
  {/* crosshair */}
312
- {hover != null && (
451
+ {active != null && (
313
452
  <line
314
- x1={xCenter(hover)} x2={xCenter(hover)}
453
+ x1={xCenter(active)} x2={xCenter(active)}
315
454
  y1={PAD.top} y2={PAD.top + plotH}
316
455
  className="crosshair"
317
456
  />
318
457
  )}
319
- {hover != null && chartType !== 'bar' && shown.map((ds) => {
458
+ {active != null && chartType !== 'bar' && shown.map((ds) => {
459
+ const value = ds.data[active];
460
+ if (!isFiniteNumber(value) || value < scale.min || value > scale.max) return null;
320
461
  const color = colorOf(ds, datasets.indexOf(ds));
321
462
  return (
322
463
  <circle
323
464
  key={`h${ds.label}`}
324
- cx={xCenter(hover)} cy={yOf(ds.data[hover] ?? 0)}
465
+ cx={xCenter(active)} cy={yOf(value)} className="series-inspect-dot"
325
466
  r={4} fill={color} stroke="var(--surface)" strokeWidth={1.5}
326
467
  />
327
468
  );
@@ -329,7 +470,7 @@ export function SeriesChart({
329
470
 
330
471
  {/* X labels */}
331
472
  {showXLabels && labels.map((label, i) => (
332
- i % labelStride === 0 ? (
473
+ i % labelStride === 0 && xPositions[i] != null ? (
333
474
  <text key={`xl${i}`} x={xCenter(i)} y={height - 8} className="axis-label" textAnchor="middle">
334
475
  {label}
335
476
  </text>
@@ -340,37 +481,46 @@ export function SeriesChart({
340
481
  <rect
341
482
  x={PAD.left} y={PAD.top} width={plotW} height={plotH}
342
483
  fill="transparent"
343
- onMouseMove={onMove}
344
- onMouseLeave={() => setHover(null)}
484
+ style={{ touchAction: 'pan-y' }}
485
+ onPointerMove={onMove}
486
+ onPointerDown={onMove}
487
+ onPointerLeave={(e) => { if (e.pointerType !== 'touch') setHover(null); }}
488
+ onPointerCancel={() => setHover(null)}
345
489
  />
346
490
  </svg>
347
491
 
348
- {hover != null && (
492
+ {active != null && (
349
493
  <div
350
- className="chart-tip"
494
+ className="chart-tip" ref={tipRef} aria-hidden="true"
351
495
  style={{
352
- left: Math.min(Math.max(xCenter(hover), 90), width - 90),
496
+ left: Math.max(8, Math.min(xCenter(active) - tipWidth / 2, width - tipWidth - 8)),
353
497
  top: PAD.top,
498
+ transform: 'none', maxWidth: 'calc(100% - 16px)', minWidth: 0,
499
+ width: 'max-content', boxSizing: 'border-box', overflowWrap: 'anywhere',
354
500
  }}
355
501
  >
356
- <div className="chart-tip-head">{labels[hover]}</div>
502
+ <div className="chart-tip-head">{labels[active]}</div>
357
503
  {shown.map((ds) => (
358
504
  <div key={ds.label} className="chart-tip-row">
359
505
  <span className="legend-swatch" style={{ background: colorOf(ds, datasets.indexOf(ds)) }} />
360
506
  <span className="chart-tip-label">{ds.label}</span>
361
- <span className="chart-tip-val">{fmt(ds.data[hover] ?? 0)}</span>
507
+ <span className="chart-tip-val">{formatSample(ds.data[active])}</span>
362
508
  </div>
363
509
  ))}
364
510
  {isStacked && shown.length > 1 && (
365
511
  <div className="chart-tip-row chart-tip-total">
366
512
  <span className="chart-tip-label">Total</span>
367
513
  <span className="chart-tip-val">
368
- {fmt(shown.reduce((sum, ds) => sum + (ds.data[hover!] ?? 0), 0))}
514
+ {shown.every((ds) => isFiniteNumber(ds.data[active]))
515
+ ? formatSample(shown.reduce((sum, ds) => sum + (ds.data[active] as number), 0))
516
+ : 'Unavailable'}
369
517
  </span>
370
518
  </div>
371
519
  )}
372
520
  </div>
373
521
  )}
522
+ <span id={`${id}-help`} className="chart-accessible">Use Left and Right arrows to inspect samples, Home or End for the first or last, and Escape to clear.</span>
523
+ <span id={`${id}-value`} className="chart-accessible" role="status" aria-live="polite" aria-atomic="true">{readout}</span>
374
524
  </div>
375
525
  )}
376
526
  </div>
@@ -459,6 +459,8 @@ button { font: inherit; color: inherit; }
459
459
  .legend-swatch { width: 9px; height: 9px; border-radius: 3px; flex: none; }
460
460
  .chart-svg-area { position: relative; }
461
461
  .series-svg { display: block; overflow: visible; }
462
+ .series-svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 3px; }
463
+ .chart-accessible { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
462
464
  .grid-line { stroke: var(--line); stroke-width: 1; }
463
465
  .grid-zero { stroke: var(--mute); stroke-opacity: 0.5; }
464
466
  .axis-label { fill: var(--mute); font-size: 10.5px; font-family: ui-monospace, "SF Mono", Menlo, monospace; }