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.
@@ -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
- const plotW = Math.max(40, width - PAD.left - PAD.right);
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 xPositions = useMemo(() => {
173
- if (!xValues) return labels.map((_, i) => PAD.left + colW * (i + 0.5));
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) ? PAD.left + fraction * plotW : null;
233
+ return Number.isFinite(fraction) ? left + fraction * plotW : null;
187
234
  });
188
- }, [labels, xValues, xDomain, n, colW, plotW]);
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
- // Thin X labels to whatever fits — ~54px per label.
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 = PAD.left + (e.clientX - rect.left) * (plotW / (rect.width || plotW));
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
- const hasData = n > 0 && datasets.length > 0 && inspectable.length > 0;
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={PAD.left - 4} y={PAD.top} width={plotW + 8} height={plotH} />
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={PAD.left} x2={PAD.left + plotW}
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 && ticks.map((t) => (
350
- <text key={`yl${t}`} x={PAD.left - 8} y={yOf(t)} className="axis-label" textAnchor="end" dominantBaseline="middle">
351
- {fmt(t)}
352
- </text>
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 && labels.map((label, i) => (
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={PAD.left} y={PAD.top} width={plotW} height={plotH}
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, stats[0]!.count)}</div>
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
- {cols.map((c) => <td key={c} className="num">{fmt(pick(s, c))}</td>)}
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: 'md' });
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) => toNumber(d.data?.[i]))] as const);
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 as number)}</td>)}
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
  }
@@ -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
+ }