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 +49 -0
- package/package.json +1 -1
- package/src/charts/SeriesChart.tsx +207 -57
- package/src/styles/core.css +2 -0
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.
|
|
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
|
-
|
|
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]
|
|
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 (
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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) =>
|
|
170
|
-
|
|
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
|
|
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
|
|
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.
|
|
269
|
+
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
|
|
190
270
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
191
|
-
const x = e.clientX - rect.left;
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
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 =
|
|
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 =
|
|
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${
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
{
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
{
|
|
451
|
+
{active != null && (
|
|
313
452
|
<line
|
|
314
|
-
x1={xCenter(
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
{
|
|
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.
|
|
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[
|
|
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">{
|
|
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
|
-
{
|
|
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>
|
package/src/styles/core.css
CHANGED
|
@@ -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; }
|