portal-mojo 0.2.13 → 0.2.14
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/admin-modals.md +6 -0
- package/docs/charts.md +103 -5
- package/package.json +1 -1
- package/src/charts/MetricsChart.tsx +29 -331
- package/src/charts/MetricsChartView.tsx +280 -0
- package/src/charts/SeriesChart.tsx +99 -18
- package/src/charts/chart-dialogs.tsx +13 -7
- package/src/charts/index.ts +2 -0
- package/src/charts/metrics-window.ts +57 -0
- package/src/charts/stats.ts +8 -6
- package/src/styles/components/charts-c2.css +15 -0
- package/src/ui/modal.tsx +6 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Shared MetricsChart presentation. The owner supplies every payload and request.
|
|
2
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
|
3
|
+
import { DateRangePicker } from '../ui/date/DateRangePicker';
|
|
4
|
+
import { dayPresets, type PresetEntry } from '../ui/date/PresetRail';
|
|
5
|
+
import { formatByPrecision } from '../ui/date/fns';
|
|
6
|
+
import { modal } from '../ui/modal';
|
|
7
|
+
import { showSeriesData, showSeriesStats } from './chart-dialogs';
|
|
8
|
+
import { SeriesChart, type ChartType, type SeriesChartProps } from './SeriesChart';
|
|
9
|
+
import { ymdRangeToEpochSeconds } from './stats';
|
|
10
|
+
import { GRANULARITIES, QUICK_RANGES, metricsDateLabel, metricsGranularities, metricsQuickWindow, type MetricsChartWindow, type MetricsQuickRange } from './metrics-window';
|
|
11
|
+
|
|
12
|
+
const TYPES: { value: ChartType; icon: string; title: string }[] = [
|
|
13
|
+
{ value: 'line', icon: 'bi-graph-up', title: 'Line' },
|
|
14
|
+
{ value: 'bar', icon: 'bi-bar-chart-fill', title: 'Bar (stacked)' },
|
|
15
|
+
{ value: 'area', icon: 'bi-graph-up-arrow', title: 'Area' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export interface MetricsChartViewProps extends SeriesChartProps {
|
|
19
|
+
title: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
/** Replace the plot with an owner-rendered report; status and toolbar remain shared. */
|
|
22
|
+
content?: ReactNode;
|
|
23
|
+
window: MetricsChartWindow;
|
|
24
|
+
onWindowChange: (window: MetricsChartWindow) => void;
|
|
25
|
+
/** Refresh never fetches or changes the window itself; the owner does both. */
|
|
26
|
+
onRefresh?: () => void;
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
isFetching?: boolean;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
defaultType?: ChartType;
|
|
33
|
+
onTypeChange?: (type: ChartType) => void;
|
|
34
|
+
allowedRanges?: readonly MetricsQuickRange[];
|
|
35
|
+
allowedGranularities?: readonly string[];
|
|
36
|
+
/** Exact supported history bounds, epoch seconds (including partial days). */
|
|
37
|
+
minDate?: number;
|
|
38
|
+
maxDate?: number;
|
|
39
|
+
/** Abort blanks this view and removes its open dialogs from the shared stack. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
showGranularity?: boolean;
|
|
42
|
+
showDateRange?: boolean;
|
|
43
|
+
showTypeSwitch?: boolean;
|
|
44
|
+
showRefresh?: boolean;
|
|
45
|
+
showStats?: boolean;
|
|
46
|
+
showDataTable?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function RangeDialog({ initial, minDate, maxDate, onDone }: {
|
|
50
|
+
initial: { start: string; end: string };
|
|
51
|
+
minDate?: number; maxDate?: number;
|
|
52
|
+
onDone: (range: { dt_start: number; dt_end: number } | null) => void;
|
|
53
|
+
}) {
|
|
54
|
+
const [range, setRange] = useState(initial);
|
|
55
|
+
const min = minDate == null ? undefined : metricsDateLabel(minDate);
|
|
56
|
+
const max = maxDate == null ? undefined : metricsDateLabel(maxDate);
|
|
57
|
+
const epochs = ymdRangeToEpochSeconds(range.start, range.end);
|
|
58
|
+
const dt_start = epochs ? Math.max(epochs.dtStart, minDate ?? -Infinity) : NaN;
|
|
59
|
+
const dt_end = epochs ? Math.min(epochs.dtEnd, maxDate ?? Infinity) : NaN;
|
|
60
|
+
const complete = !!epochs && dt_start <= dt_end && (!min || range.start >= min) && (!max || range.end <= max);
|
|
61
|
+
// Picker presets do not enforce min/max. Offer only supported calendar days,
|
|
62
|
+
// then clamp partial first/last days to exact epoch bounds on Apply.
|
|
63
|
+
const presets: PresetEntry[] = dayPresets().filter((preset) => {
|
|
64
|
+
if (preset.divider) return minDate == null && maxDate == null;
|
|
65
|
+
const r = preset.range();
|
|
66
|
+
const start = formatByPrecision(r.start, 'day');
|
|
67
|
+
const end = formatByPrecision(r.end, 'day');
|
|
68
|
+
return (!min || start >= min) && (!max || end <= max);
|
|
69
|
+
});
|
|
70
|
+
return <div className="modal-pad chart-range-dialog">
|
|
71
|
+
<h2 className="modal-title">Custom date range</h2>
|
|
72
|
+
<DateRangePicker inline months={1} presets={presets} min={min} max={max}
|
|
73
|
+
start={range.start} end={range.end} onChange={(e) => setRange({ start: e.start, end: e.end })} />
|
|
74
|
+
<div className="modal-actions">
|
|
75
|
+
<button className="btn" onClick={() => onDone(null)}>Cancel</button>
|
|
76
|
+
<button className="btn btn-primary" disabled={!complete} onClick={() => onDone({ dt_start, dt_end })}>Apply</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function MetricsChartView({
|
|
82
|
+
title, className = '', content, window: selectedWindow, onWindowChange, onRefresh, onRetry = onRefresh,
|
|
83
|
+
loading = false, isFetching = false, error, disabled = false,
|
|
84
|
+
chartType: controlledType, defaultType = 'line', onTypeChange,
|
|
85
|
+
allowedRanges = QUICK_RANGES, allowedGranularities, minDate, maxDate, signal,
|
|
86
|
+
showGranularity = true, showDateRange = true, showTypeSwitch = true,
|
|
87
|
+
showRefresh = true, showStats = true, showDataTable = true,
|
|
88
|
+
height = 280, labels, datasets, valueFormatter, ...seriesProps
|
|
89
|
+
}: MetricsChartViewProps) {
|
|
90
|
+
const [localType, setLocalType] = useState(defaultType);
|
|
91
|
+
const chartType = controlledType ?? localType;
|
|
92
|
+
const setChartType = (next: ChartType) => { if (controlledType == null) setLocalType(next); onTypeChange?.(next); };
|
|
93
|
+
const aborted = useSyncExternalStore(
|
|
94
|
+
(changed) => { signal?.addEventListener('abort', changed); return () => signal?.removeEventListener('abort', changed); },
|
|
95
|
+
() => signal?.aborted ?? false,
|
|
96
|
+
() => signal?.aborted ?? false,
|
|
97
|
+
);
|
|
98
|
+
const owner = useRef<AbortController | null>(null);
|
|
99
|
+
// Compare content rather than array identity: inspection often re-renders
|
|
100
|
+
// parents with equivalent arrays. Expired/replaced data must close snapshots.
|
|
101
|
+
const dialogIdentity = JSON.stringify([title, selectedWindow, labels, datasets,
|
|
102
|
+
seriesProps.xValues, seriesProps.xDomain, loading, !!error, disabled, minDate, maxDate, allowedGranularities, allowedRanges]);
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
owner.current = controller;
|
|
106
|
+
const abort = () => controller.abort();
|
|
107
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
108
|
+
if (signal?.aborted) abort();
|
|
109
|
+
return () => { signal?.removeEventListener('abort', abort); controller.abort(); };
|
|
110
|
+
}, [signal, dialogIdentity]);
|
|
111
|
+
const available = metricsGranularities(selectedWindow, allowedGranularities);
|
|
112
|
+
const configured = GRANULARITIES.filter((g) => !allowedGranularities || allowedGranularities.includes(g.value));
|
|
113
|
+
const effective = selectedWindow.granularity;
|
|
114
|
+
const active = () => !disabled && !signal?.aborted && !!owner.current && !owner.current.signal.aborted;
|
|
115
|
+
const setWindow = (next: MetricsChartWindow) => {
|
|
116
|
+
const supported = metricsGranularities(next, allowedGranularities);
|
|
117
|
+
if (!active() || !supported.length) return;
|
|
118
|
+
onWindowChange({ ...next, granularity: supported.includes(next.granularity) ? next.granularity : supported.at(-1)! });
|
|
119
|
+
};
|
|
120
|
+
const quickWindow = (r: MetricsQuickRange) => metricsQuickWindow(r, effective, Math.min(Date.now(), (maxDate ?? Infinity) * 1000));
|
|
121
|
+
const ranges = allowedRanges.filter((r) => Number.isFinite(r.durationMs) && r.durationMs > 0 && quickWindow(r).dt_start >= (minDate ?? -Infinity));
|
|
122
|
+
const pickRange = (next: MetricsQuickRange) => setWindow(quickWindow(next));
|
|
123
|
+
const setGranularity = (granularity: string) => setWindow({ ...selectedWindow, granularity });
|
|
124
|
+
const openCustomRange = async () => {
|
|
125
|
+
if (!active()) return;
|
|
126
|
+
const dialogSignal = owner.current!.signal;
|
|
127
|
+
const initial = selectedWindow.range ? { start: '', end: '' } : { start: metricsDateLabel(selectedWindow.dt_start), end: metricsDateLabel(selectedWindow.dt_end) };
|
|
128
|
+
const picked = await modal.open<{ dt_start: number; dt_end: number } | null>((close) => (
|
|
129
|
+
<RangeDialog initial={initial} minDate={minDate} maxDate={maxDate} onDone={close} />
|
|
130
|
+
), { size: 'md', signal: dialogSignal });
|
|
131
|
+
if (!picked || dialogSignal.aborted) return;
|
|
132
|
+
setWindow({ ...picked, granularity: effective });
|
|
133
|
+
};
|
|
134
|
+
const refresh = () => { if (active()) onRefresh?.(); };
|
|
135
|
+
const openDialog = (stats: boolean) => {
|
|
136
|
+
if (!active() || loading || error) return;
|
|
137
|
+
const { xValues, xDomain } = seriesProps;
|
|
138
|
+
const bounded = xDomain && Number.isFinite(xDomain[0]) && Number.isFinite(xDomain[1]) && xDomain[1] > xDomain[0];
|
|
139
|
+
const indices = labels.flatMap((_, i) => !xValues || (Number.isFinite(xValues[i]) && (!bounded || (xValues[i]! >= xDomain[0] && xValues[i]! <= xDomain[1]))) ? [i] : []);
|
|
140
|
+
void (stats ? showSeriesStats : showSeriesData)({
|
|
141
|
+
title: stats ? `${title} — Stats` : title,
|
|
142
|
+
labels: indices.map((i) => labels[i]!),
|
|
143
|
+
datasets: datasets.map((d) => ({ ...d, data: indices.map((i) => d.data[i]) })),
|
|
144
|
+
granularity: showGranularity ? effective : undefined, formatter: valueFormatter, signal: owner.current!.signal,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
const openStats = () => openDialog(true);
|
|
148
|
+
const openData = () => openDialog(false);
|
|
149
|
+
const customActive = !selectedWindow.range;
|
|
150
|
+
const range = { start: metricsDateLabel(selectedWindow.dt_start), end: metricsDateLabel(selectedWindow.dt_end) };
|
|
151
|
+
const customTitle = customActive ? `Custom range: ${range.start} – ${range.end}` : 'Custom range…';
|
|
152
|
+
if (aborted) return null;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className={`panel panel-pad chart-panel ${className}`.trim()}>
|
|
156
|
+
<div className="chart-head">
|
|
157
|
+
<div>
|
|
158
|
+
<div className="eyebrow">Metrics</div>
|
|
159
|
+
<h3 className="panel-subtitle">
|
|
160
|
+
{title}
|
|
161
|
+
{customActive && <span className="chart-range-chip">{range.start} – {range.end}</span>}
|
|
162
|
+
</h3>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="chart-controls">
|
|
165
|
+
{showGranularity && (
|
|
166
|
+
<>
|
|
167
|
+
<div className="seg seg-xs gran-toggle" role="group" aria-label="Granularity">
|
|
168
|
+
{configured.map((g) => {
|
|
169
|
+
const enabled = available.includes(g.value);
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
key={g.value}
|
|
173
|
+
className={`seg-btn${effective === g.value ? ' seg-active' : ''}`}
|
|
174
|
+
disabled={disabled || !enabled}
|
|
175
|
+
title={enabled ? g.label : `${g.label} — not available for this range`}
|
|
176
|
+
onClick={() => setGranularity(g.value)}
|
|
177
|
+
>
|
|
178
|
+
{g.short}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
<select
|
|
184
|
+
className="input input-compact gran-select"
|
|
185
|
+
value={effective}
|
|
186
|
+
disabled={disabled || available.length === 0}
|
|
187
|
+
onChange={(e) => setGranularity(e.target.value)}
|
|
188
|
+
aria-label="Granularity"
|
|
189
|
+
>
|
|
190
|
+
{GRANULARITIES.filter((g) => available.includes(g.value)).map((g) => (
|
|
191
|
+
<option key={g.value} value={g.value}>{g.label}</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{showDateRange && (
|
|
198
|
+
<div className="seg seg-xs" role="group" aria-label="Date range">
|
|
199
|
+
{ranges.map((r) => (
|
|
200
|
+
<button
|
|
201
|
+
key={r.value}
|
|
202
|
+
className={`seg-btn${selectedWindow.range === r.value ? ' seg-active' : ''}`}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
onClick={() => pickRange(r)}
|
|
205
|
+
>
|
|
206
|
+
{r.label}
|
|
207
|
+
</button>
|
|
208
|
+
))}
|
|
209
|
+
<button
|
|
210
|
+
className={`seg-btn${customActive ? ' seg-active' : ''}`}
|
|
211
|
+
title={customTitle}
|
|
212
|
+
aria-label="Custom date range"
|
|
213
|
+
disabled={disabled || available.length === 0}
|
|
214
|
+
onClick={() => void openCustomRange()}
|
|
215
|
+
>
|
|
216
|
+
<i className="bi bi-calendar-range" />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{showTypeSwitch && (
|
|
222
|
+
<div className="seg seg-xs" role="group" aria-label="Chart type">
|
|
223
|
+
{TYPES.map((t) => (
|
|
224
|
+
<button
|
|
225
|
+
key={t.value}
|
|
226
|
+
className={`seg-btn${chartType === t.value ? ' seg-active' : ''}`}
|
|
227
|
+
title={t.title}
|
|
228
|
+
aria-label={t.title}
|
|
229
|
+
disabled={disabled}
|
|
230
|
+
onClick={() => setChartType(t.value)}
|
|
231
|
+
>
|
|
232
|
+
<i className={`bi ${t.icon}`} />
|
|
233
|
+
</button>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{showStats && (
|
|
239
|
+
<button className="btn-icon" title="Stats" aria-label="Stats" disabled={disabled || loading || !!error} onClick={openStats}>
|
|
240
|
+
<i className="bi bi-info-circle" />
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
{showDataTable && (
|
|
244
|
+
<button className="btn-icon" title="View data" aria-label="View data" disabled={disabled || loading || !!error} onClick={openData}>
|
|
245
|
+
<i className="bi bi-table" />
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
{showRefresh && (
|
|
249
|
+
<button className="btn-icon" title="Refresh" aria-label="Refresh" disabled={disabled || !onRefresh || isFetching} onClick={refresh}>
|
|
250
|
+
<i className={`bi bi-arrow-repeat${isFetching ? ' spin' : ''}`} />
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="chart-body" style={{ minHeight: height }}>
|
|
257
|
+
{loading ? (
|
|
258
|
+
<div className="chart-overlay"><span className="skel skel-block" /></div>
|
|
259
|
+
) : !!error ? (
|
|
260
|
+
<div className="chart-overlay">
|
|
261
|
+
<div className="chart-error">
|
|
262
|
+
<i className="bi bi-exclamation-triangle" />
|
|
263
|
+
<span>{error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to load metrics'}</span>
|
|
264
|
+
<button className="btn btn-compact" disabled={disabled || !onRetry} onClick={() => { if (active()) onRetry?.(); }}>Retry</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
) : content !== undefined ? content : (
|
|
268
|
+
<SeriesChart
|
|
269
|
+
{...seriesProps}
|
|
270
|
+
labels={labels}
|
|
271
|
+
datasets={datasets}
|
|
272
|
+
chartType={chartType}
|
|
273
|
+
height={height}
|
|
274
|
+
valueFormatter={valueFormatter}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
@@ -35,6 +35,8 @@ export interface SeriesChartProps {
|
|
|
35
35
|
xValues?: number[];
|
|
36
36
|
/** Visible numeric X window, in the same units as xValues. */
|
|
37
37
|
xDomain?: [number, number];
|
|
38
|
+
/** Numeric X axis ticks only; inspection keeps the original full labels. */
|
|
39
|
+
xValueFormatter?: (value: number) => string;
|
|
38
40
|
/** Split line/area runs when consecutive numeric X values exceed this gap. */
|
|
39
41
|
maxGap?: number;
|
|
40
42
|
/** Exact Y bounds; omitted bounds are computed from visible data. */
|
|
@@ -83,6 +85,39 @@ function defaultFormat(v: number): string {
|
|
|
83
85
|
return Number.isInteger(v) ? String(v) : v.toFixed(1);
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
// Axis labels use 10.5px monospace. Budget conservatively, then clip each
|
|
89
|
+
// label to its own slot so custom fonts/long strings cannot overlap a neighbour.
|
|
90
|
+
const axisTextWidth = (text: string) => Array.from(text).reduce((width, char) => width + (char.codePointAt(0)! < 128 ? 7 : 11), 0);
|
|
91
|
+
function fitAxisLabel(text: string, width: number): string {
|
|
92
|
+
if (axisTextWidth(text) <= width) return text;
|
|
93
|
+
let shortened = '';
|
|
94
|
+
for (const char of text) {
|
|
95
|
+
if (axisTextWidth(shortened + char + '…') > width) break;
|
|
96
|
+
shortened += char;
|
|
97
|
+
}
|
|
98
|
+
return `${shortened}…`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function numericAxisTicks(domain: [number, number], plotW: number, left: number, format: (value: number) => string) {
|
|
102
|
+
const [min, max] = domain;
|
|
103
|
+
const make = (count: number) => Array.from({ length: count }, (_, index) => {
|
|
104
|
+
const fraction = count === 1 ? 0.5 : index / (count - 1);
|
|
105
|
+
// Weighted interpolation avoids overflowing max - min on extreme feeds.
|
|
106
|
+
const value = index === 0 ? min : index === count - 1 ? max : min * (1 - fraction) + max * fraction;
|
|
107
|
+
const x = left + fraction * plotW;
|
|
108
|
+
const step = count === 1 ? plotW : plotW / (count - 1);
|
|
109
|
+
const start = index === 0 ? left : x - step / 2 + 6;
|
|
110
|
+
const end = index === count - 1 ? left + plotW : x + step / 2 - 6;
|
|
111
|
+
const full = format(value);
|
|
112
|
+
return { value, x, start, width: Math.max(1, end - start), full,
|
|
113
|
+
anchor: count === 1 ? 'middle' as const : index === 0 ? 'start' as const : index === count - 1 ? 'end' as const : 'middle' as const };
|
|
114
|
+
});
|
|
115
|
+
let count = min === max ? 1 : Math.max(2, Math.min(6, Math.floor(plotW / 64) + 1));
|
|
116
|
+
let ticks = make(count);
|
|
117
|
+
while (count > 2 && ticks.some((tick) => axisTextWidth(tick.full) > tick.width)) ticks = make(--count);
|
|
118
|
+
return ticks.map((tick) => ({ ...tick, text: fitAxisLabel(tick.full, tick.width) }));
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
/** Catmull-Rom → cubic bezier, matching web-mojo's `smoothing` option. */
|
|
87
122
|
function smoothPath(pts: [number, number][], tension: number): string {
|
|
88
123
|
if (pts.length < 2) return '';
|
|
@@ -119,6 +154,7 @@ export function SeriesChart({
|
|
|
119
154
|
emptyText = 'No data for this range',
|
|
120
155
|
xValues,
|
|
121
156
|
xDomain,
|
|
157
|
+
xValueFormatter,
|
|
122
158
|
maxGap,
|
|
123
159
|
minValue,
|
|
124
160
|
maxValue,
|
|
@@ -143,6 +179,8 @@ export function SeriesChart({
|
|
|
143
179
|
const id = useId();
|
|
144
180
|
const tipRef = useRef<HTMLDivElement>(null);
|
|
145
181
|
const [tipWidth, setTipWidth] = useState(180);
|
|
182
|
+
const yLabelsRef = useRef<SVGGElement>(null);
|
|
183
|
+
const [yLabelWidth, setYLabelWidth] = useState(0);
|
|
146
184
|
|
|
147
185
|
// Measure the container — real pixel geometry keeps text unscaled
|
|
148
186
|
// (a viewBox + preserveAspectRatio="none" would smear the labels).
|
|
@@ -165,28 +203,38 @@ export function SeriesChart({
|
|
|
165
203
|
const shown = datasets.filter((d) => !visible.has(d.label));
|
|
166
204
|
const isStacked = stacked === 'auto' ? chartType === 'bar' : !!stacked;
|
|
167
205
|
|
|
168
|
-
|
|
206
|
+
// Keep most of the canvas available to data, even for verbose formatters.
|
|
207
|
+
const maxLeft = Math.min(160, width * 0.4, Math.max(0, width - PAD.right - 40));
|
|
208
|
+
const left = Math.min(maxLeft, Math.max(PAD.left, showYLabels ? Math.ceil(yLabelWidth) + 16 : 0));
|
|
209
|
+
const plotW = Math.max(1, width - left - PAD.right);
|
|
169
210
|
const plotH = Math.max(40, height - PAD.top - PAD.bottom);
|
|
170
211
|
const n = labels.length;
|
|
171
212
|
const colW = n > 0 ? plotW / n : plotW;
|
|
172
|
-
const
|
|
173
|
-
if (!xValues) return
|
|
213
|
+
const numericDomain = useMemo<[number, number] | null>(() => {
|
|
214
|
+
if (!xValues) return null;
|
|
174
215
|
const values = xValues.slice(0, n).filter(isFiniteNumber);
|
|
175
216
|
let min = values.reduce((a, b) => Math.min(a, b), Infinity);
|
|
176
217
|
let max = values.reduce((a, b) => Math.max(a, b), -Infinity);
|
|
177
218
|
if (xDomain && isFiniteNumber(xDomain[0]) && isFiniteNumber(xDomain[1]) && xDomain[1] > xDomain[0]) {
|
|
178
219
|
[min, max] = xDomain;
|
|
179
220
|
}
|
|
221
|
+
return isFiniteNumber(min) && isFiniteNumber(max) ? [min, max] : null;
|
|
222
|
+
}, [xValues, xDomain, n]);
|
|
223
|
+
const xPositions = useMemo(() => {
|
|
224
|
+
if (!xValues) return labels.map((_, i) => left + colW * (i + 0.5));
|
|
225
|
+
if (!numericDomain) return labels.map(() => null);
|
|
226
|
+
const [min, max] = numericDomain;
|
|
180
227
|
return labels.map((_, i) => {
|
|
181
228
|
const x = xValues[i];
|
|
182
229
|
if (!isFiniteNumber(x) || x < min || x > max) return null;
|
|
183
230
|
// Half-scaled differences also work for finite domains spanning
|
|
184
231
|
// more than Number.MAX_VALUE, without overflowing the subtraction.
|
|
185
232
|
const fraction = max === min ? 0.5 : (x / 2 - min / 2) / (max / 2 - min / 2);
|
|
186
|
-
return Number.isFinite(fraction) ?
|
|
233
|
+
return Number.isFinite(fraction) ? left + fraction * plotW : null;
|
|
187
234
|
});
|
|
188
|
-
}, [labels, xValues,
|
|
235
|
+
}, [labels, xValues, numericDomain, colW, plotW, left]);
|
|
189
236
|
const inspectable = xPositions.flatMap((x, i) => x == null ? [] : [i]);
|
|
237
|
+
const hasData = n > 0 && datasets.length > 0 && inspectable.length > 0;
|
|
190
238
|
const active = hover != null && xPositions[hover] != null ? hover : null;
|
|
191
239
|
const lastInspection = useRef<number | null | undefined>(undefined);
|
|
192
240
|
// Compare content, not array identity: callers often construct props during
|
|
@@ -261,14 +309,20 @@ export function SeriesChart({
|
|
|
261
309
|
return out;
|
|
262
310
|
}, [scale]);
|
|
263
311
|
|
|
264
|
-
//
|
|
312
|
+
// Numeric ticks describe the whole scale, independent of sample clusters.
|
|
313
|
+
const xTicks = numericDomain ? numericAxisTicks(numericDomain, plotW, left, xValueFormatter ?? String) : null;
|
|
314
|
+
const yLabels = showYLabels && hasData ? ticks.map((tick) => fmt(tick)) : [];
|
|
315
|
+
const yLabelsKey = JSON.stringify(yLabels);
|
|
316
|
+
const yLabelSpace = Math.max(0, left - 16);
|
|
317
|
+
const fittedYLabels = yLabels.map((label) => yLabelWidth > yLabelSpace ? fitAxisLabel(label, yLabelSpace) : label);
|
|
318
|
+
// Categorical callers keep their existing label thinning.
|
|
265
319
|
const labelStride = Math.max(1, Math.ceil((n * 54) / Math.max(plotW, 1)));
|
|
266
320
|
|
|
267
321
|
const colorOf = (ds: Dataset, i: number) => ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length]!;
|
|
268
322
|
|
|
269
323
|
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
|
|
270
324
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
271
|
-
const x =
|
|
325
|
+
const x = left + (e.clientX - rect.left) * (plotW / (rect.width || plotW));
|
|
272
326
|
let nearest: number | null = null;
|
|
273
327
|
for (const i of inspectable) {
|
|
274
328
|
if (nearest == null || Math.abs(xCenter(i) - x) < Math.abs(xCenter(nearest) - x)) nearest = i;
|
|
@@ -296,7 +350,15 @@ export function SeriesChart({
|
|
|
296
350
|
if (measured && Number.isFinite(measured)) setTipWidth(measured);
|
|
297
351
|
}, [readout, width]);
|
|
298
352
|
|
|
299
|
-
|
|
353
|
+
// Always measure full, hidden labels, never their abbreviated display text:
|
|
354
|
+
// clipping must not shrink the next measurement and oscillate the layout.
|
|
355
|
+
useLayoutEffect(() => {
|
|
356
|
+
const sizes = [...(yLabelsRef.current?.querySelectorAll('text') ?? [])].map((node) => {
|
|
357
|
+
const measured = node.getComputedTextLength?.();
|
|
358
|
+
return isFiniteNumber(measured) && measured > 0 ? measured : axisTextWidth(node.textContent ?? '');
|
|
359
|
+
});
|
|
360
|
+
setYLabelWidth(Math.max(0, ...sizes));
|
|
361
|
+
}, [yLabelsKey, showYLabels, hasData, width]);
|
|
300
362
|
|
|
301
363
|
return (
|
|
302
364
|
<div className="chart-wrap" ref={wrapRef}>
|
|
@@ -334,23 +396,37 @@ export function SeriesChart({
|
|
|
334
396
|
onKeyDown={onKeyDown} onBlur={() => setHover(null)}>
|
|
335
397
|
<defs>
|
|
336
398
|
<clipPath id={`${id}-plot`}>
|
|
337
|
-
<rect x={
|
|
399
|
+
<rect x={left - 4} y={PAD.top} width={plotW + 8} height={plotH} />
|
|
400
|
+
</clipPath>
|
|
401
|
+
<clipPath id={`${id}-y-labels`}>
|
|
402
|
+
<rect x={8} y={0} width={yLabelSpace} height={height} />
|
|
338
403
|
</clipPath>
|
|
404
|
+
{showXLabels && xTicks?.map((tick, i) => (
|
|
405
|
+
<clipPath key={i} id={`${id}-x-label-${i}`}>
|
|
406
|
+
<rect x={tick.start} y={height - PAD.bottom} width={tick.width} height={PAD.bottom} />
|
|
407
|
+
</clipPath>
|
|
408
|
+
))}
|
|
339
409
|
</defs>
|
|
340
410
|
{/* gridlines + Y ticks */}
|
|
341
411
|
{showGrid && ticks.map((t) => (
|
|
342
412
|
<line
|
|
343
413
|
key={`g${t}`}
|
|
344
|
-
x1={
|
|
414
|
+
x1={left} x2={left + plotW}
|
|
345
415
|
y1={yOf(t)} y2={yOf(t)}
|
|
346
416
|
className={t === 0 && scale.min < 0 ? 'grid-line grid-zero' : 'grid-line'}
|
|
347
417
|
/>
|
|
348
418
|
))}
|
|
349
|
-
{showYLabels &&
|
|
350
|
-
<
|
|
351
|
-
{
|
|
352
|
-
</
|
|
353
|
-
|
|
419
|
+
{showYLabels && <>
|
|
420
|
+
<g ref={yLabelsRef} visibility="hidden" aria-hidden="true">
|
|
421
|
+
{yLabels.map((label, i) => <text key={i} className="axis-label">{label}</text>)}
|
|
422
|
+
</g>
|
|
423
|
+
<g clipPath={`url(#${id}-y-labels)`}>{ticks.map((t, i) => (
|
|
424
|
+
<text key={`yl${t}`} x={left - 8} y={yOf(t)} className="axis-label" textAnchor="end" dominantBaseline="middle" aria-label={yLabels[i]}>
|
|
425
|
+
{fittedYLabels[i] !== yLabels[i] && <title>{yLabels[i]}</title>}
|
|
426
|
+
<tspan>{fittedYLabels[i]}</tspan>
|
|
427
|
+
</text>
|
|
428
|
+
))}</g>
|
|
429
|
+
</>}
|
|
354
430
|
|
|
355
431
|
{/* bars */}
|
|
356
432
|
{chartType === 'bar' && labels.map((_, i) => {
|
|
@@ -469,17 +545,22 @@ export function SeriesChart({
|
|
|
469
545
|
})}
|
|
470
546
|
|
|
471
547
|
{/* X labels */}
|
|
472
|
-
{showXLabels &&
|
|
548
|
+
{showXLabels && (xTicks ? xTicks.map((tick, i) => (
|
|
549
|
+
<text key={`xt${i}`} x={tick.x} y={height - 8} className="axis-label series-x-label"
|
|
550
|
+
textAnchor={tick.anchor} clipPath={`url(#${id}-x-label-${i})`} aria-label={tick.full}>
|
|
551
|
+
<title>{tick.full}</title><tspan>{tick.text}</tspan>
|
|
552
|
+
</text>
|
|
553
|
+
)) : labels.map((label, i) => (
|
|
473
554
|
i % labelStride === 0 && xPositions[i] != null ? (
|
|
474
555
|
<text key={`xl${i}`} x={xCenter(i)} y={height - 8} className="axis-label" textAnchor="middle">
|
|
475
556
|
{label}
|
|
476
557
|
</text>
|
|
477
558
|
) : null
|
|
478
|
-
))}
|
|
559
|
+
)))}
|
|
479
560
|
|
|
480
561
|
{/* hit area — one overlay drives index-mode hover */}
|
|
481
562
|
<rect
|
|
482
|
-
x={
|
|
563
|
+
x={left} y={PAD.top} width={plotW} height={plotH}
|
|
483
564
|
fill="transparent"
|
|
484
565
|
style={{ touchAction: 'pan-y' }}
|
|
485
566
|
onPointerMove={onMove}
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
computeSeriesStats,
|
|
11
11
|
csvFilename,
|
|
12
12
|
defaultStatFormat,
|
|
13
|
-
toNumber,
|
|
14
13
|
type SeriesLike,
|
|
15
14
|
} from './stats';
|
|
16
15
|
|
|
@@ -23,6 +22,8 @@ export interface ChartDialogOptions {
|
|
|
23
22
|
granularity?: string;
|
|
24
23
|
/** Value formatter; default = int/2dp locale formatting. */
|
|
25
24
|
formatter?: (n: number) => string;
|
|
25
|
+
/** Owner disposal removes this dialog and its captured data. */
|
|
26
|
+
signal?: AbortSignal;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function pointsLine(granularity: string | undefined, points: number): string {
|
|
@@ -44,6 +45,7 @@ function EmptyBody({ onClose }: { onClose: () => void }) {
|
|
|
44
45
|
|
|
45
46
|
/** Per-series Latest / Min / Max / Avg / Median / Sum summary dialog. */
|
|
46
47
|
export function showSeriesStats(opts: ChartDialogOptions): Promise<unknown> {
|
|
48
|
+
if (opts.signal?.aborted) return Promise.resolve(null);
|
|
47
49
|
const stats = computeSeriesStats(opts.datasets);
|
|
48
50
|
const fmt = opts.formatter ?? defaultStatFormat;
|
|
49
51
|
const cols = ['Latest', 'Min', 'Max', 'Avg', 'Median', 'Sum'] as const;
|
|
@@ -58,12 +60,13 @@ export function showSeriesStats(opts: ChartDialogOptions): Promise<unknown> {
|
|
|
58
60
|
<EmptyBody onClose={() => close(null)} />
|
|
59
61
|
) : (
|
|
60
62
|
<>
|
|
61
|
-
<div className="chart-dialog-sub">{pointsLine(opts.granularity,
|
|
63
|
+
<div className="chart-dialog-sub">{pointsLine(opts.granularity, opts.labels.length)}</div>
|
|
62
64
|
<div className="chart-dialog-scroll">
|
|
63
65
|
<table className="chart-stats-table">
|
|
64
66
|
<thead>
|
|
65
67
|
<tr>
|
|
66
68
|
<th>Series</th>
|
|
69
|
+
<th className="num">Samples</th>
|
|
67
70
|
{cols.map((c) => <th key={c} className="num">{c}</th>)}
|
|
68
71
|
</tr>
|
|
69
72
|
</thead>
|
|
@@ -71,7 +74,8 @@ export function showSeriesStats(opts: ChartDialogOptions): Promise<unknown> {
|
|
|
71
74
|
{stats.map((s) => (
|
|
72
75
|
<tr key={s.label}>
|
|
73
76
|
<td>{s.label}</td>
|
|
74
|
-
|
|
77
|
+
<td className="num">{s.count}</td>
|
|
78
|
+
{cols.map((c) => <td key={c} className="num">{Number.isFinite(pick(s, c)) ? fmt(pick(s, c)) : 'Unavailable'}</td>)}
|
|
75
79
|
</tr>
|
|
76
80
|
))}
|
|
77
81
|
</tbody>
|
|
@@ -83,7 +87,7 @@ export function showSeriesStats(opts: ChartDialogOptions): Promise<unknown> {
|
|
|
83
87
|
</>
|
|
84
88
|
)}
|
|
85
89
|
</div>
|
|
86
|
-
), { size: '
|
|
90
|
+
), { size: 'lg', signal: opts.signal });
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/** Trigger a text-file download (the CSV button). */
|
|
@@ -101,13 +105,15 @@ function downloadText(text: string, filename: string, mime: string): void {
|
|
|
101
105
|
|
|
102
106
|
/** The raw series as a table: one row per label, one column per series, + CSV. */
|
|
103
107
|
export function showSeriesData(opts: ChartDialogOptions): Promise<unknown> {
|
|
108
|
+
if (opts.signal?.aborted) return Promise.resolve(null);
|
|
104
109
|
const { labels, datasets } = opts;
|
|
105
110
|
const hasData = labels.length > 0 && datasets.length > 0;
|
|
106
111
|
const fmt = opts.formatter ?? defaultStatFormat;
|
|
107
112
|
const headers = ['Label', ...datasets.map((d) => d.label ?? '')];
|
|
108
|
-
const rows = labels.map((label, i) => [label, ...datasets.map((d) =>
|
|
113
|
+
const rows = labels.map((label, i) => [label, ...datasets.map((d) => typeof d.data?.[i] === 'number' && Number.isFinite(d.data[i]) ? d.data[i] : null)] as const);
|
|
109
114
|
|
|
110
115
|
const downloadCsv = () => {
|
|
116
|
+
if (opts.signal?.aborted) return;
|
|
111
117
|
downloadText(buildCsv(headers, rows), csvFilename(opts.title), 'text/csv;charset=utf-8');
|
|
112
118
|
};
|
|
113
119
|
|
|
@@ -130,7 +136,7 @@ export function showSeriesData(opts: ChartDialogOptions): Promise<unknown> {
|
|
|
130
136
|
{rows.map((row, ri) => (
|
|
131
137
|
<tr key={ri}>
|
|
132
138
|
<td>{row[0]}</td>
|
|
133
|
-
{row.slice(1).map((v, ci) => <td key={ci} className="num">{fmt(v
|
|
139
|
+
{row.slice(1).map((v, ci) => <td key={ci} className="num">{typeof v === 'number' && Number.isFinite(v) ? fmt(v) : 'Unavailable'}</td>)}
|
|
134
140
|
</tr>
|
|
135
141
|
))}
|
|
136
142
|
</tbody>
|
|
@@ -145,5 +151,5 @@ export function showSeriesData(opts: ChartDialogOptions): Promise<unknown> {
|
|
|
145
151
|
</>
|
|
146
152
|
)}
|
|
147
153
|
</div>
|
|
148
|
-
), { size: 'lg' });
|
|
154
|
+
), { size: 'lg', signal: opts.signal });
|
|
149
155
|
}
|
package/src/charts/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// and the exportChartPng SVG→PNG serializer.
|
|
8
8
|
export * from './SeriesChart';
|
|
9
9
|
export * from './MetricsChart';
|
|
10
|
+
export * from './MetricsChartView';
|
|
11
|
+
export * from './metrics-window';
|
|
10
12
|
export * from './MiniChart';
|
|
11
13
|
export * from './MetricsMiniWidget';
|
|
12
14
|
export * from './KPITile';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { granularitiesForSpanMs, QUICK_RANGE_MS } from './stats';
|
|
2
|
+
|
|
3
|
+
export interface Granularity { value: string; label: string; short: string }
|
|
4
|
+
/** All bounds are epoch seconds. Absence of range denotes a custom window. */
|
|
5
|
+
export interface MetricsChartWindow {
|
|
6
|
+
dt_start: number;
|
|
7
|
+
dt_end: number;
|
|
8
|
+
granularity: string;
|
|
9
|
+
range?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface MetricsQuickRange { value: string; label: string; durationMs: number }
|
|
12
|
+
|
|
13
|
+
export const GRANULARITIES: Granularity[] = [
|
|
14
|
+
{ value: 'minutes', label: 'Minutes', short: 'MIN' },
|
|
15
|
+
{ value: 'hours', label: 'Hours', short: 'HR' },
|
|
16
|
+
{ value: 'days', label: 'Days', short: 'DAY' },
|
|
17
|
+
{ value: 'weeks', label: 'Weeks', short: 'WK' },
|
|
18
|
+
{ value: 'months', label: 'Months', short: 'MO' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const QUICK_RANGES: MetricsQuickRange[] = [
|
|
22
|
+
{ value: '1h', label: '1H', durationMs: QUICK_RANGE_MS['1h']! },
|
|
23
|
+
{ value: '24h', label: '24H', durationMs: QUICK_RANGE_MS['24h']! },
|
|
24
|
+
{ value: '7d', label: '7D', durationMs: QUICK_RANGE_MS['7d']! },
|
|
25
|
+
{ value: '30d', label: '30D', durationMs: QUICK_RANGE_MS['30d']! },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Granularities that make sense for a range — mirrors the backend's buckets. */
|
|
29
|
+
const RANGE_GRANULARITY: Record<string, string[]> = {
|
|
30
|
+
'1h': ['minutes'],
|
|
31
|
+
'24h': ['minutes', 'hours'],
|
|
32
|
+
'7d': ['hours', 'days'],
|
|
33
|
+
'30d': ['days', 'weeks', 'months'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Range gating narrows the source capability list; an explicit empty list stays empty. */
|
|
37
|
+
export function metricsGranularities(window: MetricsChartWindow, configured?: readonly string[]): string[] {
|
|
38
|
+
const supported = GRANULARITIES.map((g) => g.value).filter((g) => !configured || configured.includes(g));
|
|
39
|
+
const fit = window.range && RANGE_GRANULARITY[window.range]
|
|
40
|
+
|| granularitiesForSpanMs((window.dt_end - window.dt_start) * 1000);
|
|
41
|
+
const available = supported.filter((g) => fit.includes(g));
|
|
42
|
+
return available.length ? available : supported;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function metricsQuickWindow(range: string | MetricsQuickRange, granularity = 'hours', nowMs = Date.now()): MetricsChartWindow {
|
|
46
|
+
let preset = typeof range === 'string' ? QUICK_RANGES.find((r) => r.value === range) : range;
|
|
47
|
+
if (!preset || !Number.isFinite(preset.durationMs) || preset.durationMs <= 0) {
|
|
48
|
+
console.warn('MetricsChart: invalid quick range — falling back to 24h');
|
|
49
|
+
preset = QUICK_RANGES[1]!;
|
|
50
|
+
}
|
|
51
|
+
return { range: preset.value, granularity, dt_start: Math.floor((nowMs - preset.durationMs) / 1000), dt_end: Math.floor(nowMs / 1000) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function metricsDateLabel(seconds: number): string {
|
|
55
|
+
const d = new Date(seconds * 1000);
|
|
56
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
57
|
+
}
|