@xeonr/platform-ui 1.9.0 → 1.10.1

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.
@@ -1,90 +1,20 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import styled from "styled-components";
4
4
  import { v } from "../theme/tokens.js";
5
5
  import { fmtCount } from "./Facet.js";
6
- // Three charts in raw SVG, themed with the tokens so they sit in the same visual
7
- // system as everything else. No charting library: the need is "n series over a
8
- // shared time axis", "a stacked histogram" and "a stacked area", and a dependency for that costs
9
- // more in bundle size and theming friction than the code it saves.
10
- const HOUR = 3_600_000;
11
- const DAY = 24 * HOUR;
12
- const hhmm = (t) => new Date(t).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
13
- const dayMonth = (t) => new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" });
14
- const dayMonthTime = (t) => `${dayMonth(t)} ${hhmm(t)}`;
15
- const monthYear = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", year: "numeric" });
16
- /**
17
- * How an axis end reads for a series covering `span` ms: a few hours read
18
- * 14:05, a few days 16 Oct 14:00, a few months 16 Oct, anything longer
19
- * Oct 2026 (CON-73). Every axis was HH:MM before, so twelve monthly points
20
- * labelled both ends "00:00". Local time, as the charts always were; a series
21
- * bucketed in some other zone passes `formatTime`.
22
- */
23
- export function timeTick(span) {
24
- if (span < 20 * HOUR)
25
- return hhmm;
26
- if (span <= 3 * DAY)
27
- return dayMonthTime;
28
- if (span <= 180 * DAY)
29
- return dayMonth;
30
- return monthYear;
31
- }
32
- /** How one bucket or sample reads in a tooltip, from the step between them. */
33
- function timeDetail(step) {
34
- if (step >= 28 * DAY)
35
- return monthYear;
36
- if (step >= DAY)
37
- return dayMonth;
38
- return dayMonthTime;
39
- }
40
- const HistWrap = styled.div `padding: 12px 14px 8px;`;
41
- const HistSvg = styled.svg `display: block; width: 100%; height: 84px;`;
42
- const Axis = styled.div `
43
- display: flex; justify-content: space-between;
44
- color: ${v.faint}; font-size: 11px; margin-top: 4px; font-variant-numeric: tabular-nums;
45
- `;
46
- const Note = styled.div `color: ${v.faint}; font-size: 12px; padding: 22px 0; text-align: center;`;
47
- const TONE_FILL = {
48
- brand: v.brand, ok: v.success, warn: v.warning, crit: v.danger, info: v.info, mut: v.faint,
49
- };
50
- const Peak = styled.div `
51
- color: ${v.faint}; font-size: 11px; margin-bottom: 4px; font-variant-numeric: tabular-nums;
52
- `;
53
- /**
54
- * Histogram: a stacked bar per bucket over time, so the shape of what you are
55
- * looking at is visible before you read a single row. `tones` maps a part
56
- * name to its tone (e.g. error → crit) so a spike reads without a legend.
57
- *
58
- * `formatValue` says what a number is ("2.4 GB"); given one, the tallest bar's
59
- * value is shown above the chart as its scale, and each bar's tooltip breaks
60
- * its total into parts. `formatTime` overrides how the axis and tooltips read
61
- * a bucket's start, which otherwise follows the span in local time.
62
- */
63
- export function Histogram({ buckets, tones = {}, height, label = "Activity over time", empty = "No activity in range", formatValue, formatTime }) {
64
- const sorted = useMemo(() => [...buckets].sort((a, b) => a.t - b.t), [buckets]);
65
- if (sorted.length === 0)
66
- return _jsx(HistWrap, { children: _jsx(Note, { children: empty }) });
67
- const totals = sorted.map((b) => b.parts.reduce((s, p) => s + p.value, 0));
68
- const max = Math.max(...totals, 1);
69
- const w = 100 / sorted.length;
70
- const first = sorted[0].t;
71
- const last = sorted[sorted.length - 1].t;
72
- const tick = formatTime ?? timeTick(last - first);
73
- const detail = formatTime ?? timeDetail(sorted.length > 1 ? sorted[1].t - first : 0);
74
- const fmt = formatValue ?? fmtCount;
75
- const peak = Math.max(...totals, 0);
76
- return (_jsxs(HistWrap, { children: [formatValue ? _jsx(Peak, { children: peak > 0 ? `${formatValue(peak)} peak` : "\u00a0" }) : null, _jsx(HistSvg, { viewBox: "0 0 100 40", preserveAspectRatio: "none", role: "img", "aria-label": label, style: height ? { height } : undefined, children: sorted.map((b, i) => {
77
- let y = 40;
78
- const parts = formatValue && b.parts.length > 1
79
- ? ` (${b.parts.map((p) => `${p.name} ${formatValue(p.value)}`).join(", ")})`
80
- : "";
81
- return (_jsxs("g", { children: [_jsx("title", { children: `${detail(b.t)} — ${fmt(totals[i] ?? 0)}${parts}` }), b.parts.map((p) => {
82
- const h = (p.value / max) * 40;
83
- y -= h;
84
- return _jsx("rect", { x: i * w + w * 0.12, y: y, width: w * 0.76, height: h, fill: TONE_FILL[tones[p.name] ?? "brand"] }, p.name);
85
- })] }, b.t));
86
- }) }), _jsxs(Axis, { children: [_jsx("span", { children: tick(first) }), _jsx("span", { children: tick(last) })] })] }));
87
- }
6
+ import { LegendChips, LegendNote } from "./ChartFrame.js";
7
+ import { CHART_SIZES, TONE_COLOUR, niceTicks, rampToken, spaced, stepLabel, timeDetail, timeTick, timeTicks, useChartSize, } from "./chartScale.js";
8
+ // The time charts in raw SVG, themed with the tokens so they sit in the same
9
+ // visual system as everything else. No charting library: the need is "n series
10
+ // over a shared time axis", lined, stacked or barred, with markers, thresholds,
11
+ // bands and a compare, and a dependency for that costs more in bundle size and
12
+ // theming friction than the code it saves.
13
+ //
14
+ // One plot draws all of them (`TimePlot` below), so a line, a stacked area and
15
+ // a bar chart share the axis rules of board 32, the crosshair, the keyboard,
16
+ // the legend and drag-to-zoom.
17
+ export { timeTick };
88
18
  function scaleBytes(n) {
89
19
  const units = ["B", "KB", "MB", "GB", "TB", "PB"];
90
20
  let val = n;
@@ -95,6 +25,11 @@ function scaleBytes(n) {
95
25
  }
96
26
  return `${i === 0 || Math.abs(val) >= 100 ? Math.round(val) : Math.abs(val) >= 10 ? val.toFixed(1) : val.toFixed(2)} ${units[i]}`;
97
27
  }
28
+ function tickValue(x) {
29
+ if (Math.abs(x) >= 1000)
30
+ return fmtCount(Math.round(x));
31
+ return Number.isInteger(x) ? String(x) : String(Number(x.toPrecision(3)));
32
+ }
98
33
  /** Reads a number as its unit. `compact` is the axis form (shorter, whole numbers for percent); the default is the tooltip's. */
99
34
  export function formatUnit(value, unit, compact = false) {
100
35
  switch (unit) {
@@ -104,168 +39,684 @@ export function formatUnit(value, unit, compact = false) {
104
39
  default: return compact ? tickValue(value) : Math.abs(value) >= 100 || Number.isInteger(value) ? fmtCount(Math.round(value)) : value.toFixed(1);
105
40
  }
106
41
  }
107
- const Wrap = styled.div `padding: 14px;`;
108
- const Plot = styled.div `position: relative;`;
109
- const Svg = styled.svg `display: block; width: 100%; height: 220px; cursor: crosshair; user-select: none;`;
110
- const Legend = styled.div `display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; font-size: 12px; color: ${v.muted};`;
111
- // Series colours come from the theme's accent ramp rather than a chart palette.
112
- const STROKES = [v.brand, v.success, v.warning, v.info, v.danger];
113
- // A legend entry is a control: clicking isolates or restores a series, which is
114
- // the only way to read a chart split into a dozen sessions.
115
- const Key = styled.button `
116
- display: inline-flex; align-items: center; gap: 6px;
117
- font: inherit; font-size: 12px;
118
- color: ${(p) => (p.$off ? v.faint : v.muted)};
119
- opacity: ${(p) => (p.$off ? 0.55 : 1)};
120
- &::before { content: ""; width: 9px; height: 3px; border-radius: 2px; background: ${(p) => (p.$off ? v.faint : p.$c)}; ${(p) => (p.$dash ? "height: 0; border-top: 2px dashed currentColor; background: none; border-color: " + (p.$off ? v.faint : p.$c) + ";" : "")} }
121
- &:hover { color: ${v.text}; }
42
+ /** The change from `was` to `now` as the tooltip writes it: "+72 %", "−9 %", "→" when flat. */
43
+ export function formatDelta(now, was) {
44
+ if (!Number.isFinite(now) || !Number.isFinite(was))
45
+ return "";
46
+ if (was === 0)
47
+ return now === 0 ? "→" : "new";
48
+ const pct = ((now - was) / Math.abs(was)) * 100;
49
+ if (Math.abs(pct) < 1)
50
+ return "→";
51
+ return `${pct > 0 ? "+" : "−"}${Math.abs(pct) >= 10 ? Math.round(Math.abs(pct)) : Math.abs(pct).toFixed(1)} %`;
52
+ }
53
+ const syncBus = new Map();
54
+ function publish(key, t, from) {
55
+ if (!key)
56
+ return;
57
+ for (const fn of syncBus.get(key) ?? [])
58
+ fn(t, from);
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Styles
62
+ // ---------------------------------------------------------------------------
63
+ const Wrap = styled.div `min-width: 0;`;
64
+ const Plot = styled.div `position: relative; min-width: 0;`;
65
+ const PlotSvg = styled.svg `
66
+ display: block;
67
+ width: 100%;
68
+ overflow: visible;
69
+ cursor: ${(p) => (p.$interactive ? "crosshair" : "default")};
70
+ user-select: none;
71
+ touch-action: pan-y;
72
+ &:focus { outline: none; }
73
+ &:focus-visible { outline: 2px solid ${v.brand55}; outline-offset: 2px; border-radius: 4px; }
74
+ text { font-family: ${v.mono}; font-variant-numeric: tabular-nums; }
122
75
  `;
123
- const ChartHint = styled.div `margin-top: 8px; font-size: 11px; color: ${v.faint};`;
76
+ const Note = styled.div `
77
+ height: ${(p) => p.$h}px; display: grid; place-items: center;
78
+ color: ${v.faint}; font-size: 12px; text-align: center;
79
+ `;
80
+ const ChartHint = styled.div `margin-top: 4px; font-size: 11px; color: ${v.faint};`;
124
81
  const Tip = styled.div `
125
82
  position: absolute;
126
83
  top: 6px;
127
- left: ${(p) => p.$left}%;
128
- transform: translateX(${(p) => (p.$flip ? "-100%" : "0")});
129
- margin-left: ${(p) => (p.$flip ? "-10px" : "10px")};
130
84
  pointer-events: none;
85
+ min-width: 170px;
86
+ max-width: 340px;
131
87
  background: ${v.surface2};
132
88
  border: 1px solid ${v.line2};
133
- border-radius: ${v.rCard};
89
+ border-radius: 8px;
134
90
  box-shadow: ${v.elev};
135
91
  padding: 7px 9px;
136
92
  font-size: 11px;
137
- color: ${v.text};
138
- white-space: nowrap;
93
+ color: ${v.muted};
139
94
  z-index: ${v.zPop};
140
- .t { color: ${v.faint}; margin-bottom: 4px; }
141
- .val { margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; }
95
+ .t { color: ${v.faint}; margin-bottom: 4px; font-family: ${v.mono}; white-space: nowrap; }
96
+ .more { color: ${v.faint}; margin-top: 2px; }
142
97
  `;
143
98
  const TipRow = styled.div `
144
- display: flex; align-items: center; gap: 6px;
145
- &::before { content: ""; width: 8px; height: 3px; border-radius: 2px; background: ${(p) => p.$c}; }
99
+ display: grid;
100
+ grid-template-columns: 12px minmax(0, 1fr) auto auto;
101
+ align-items: center;
102
+ column-gap: 7px;
103
+ line-height: 1.7;
104
+ font-family: ${v.mono};
105
+ color: ${(p) => (p.$focus ? v.text : v.muted)};
106
+ .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: ${(p) => (p.$focus || p.$strong ? 600 : 400)}; }
107
+ .val { color: ${v.text}; font-variant-numeric: tabular-nums; text-align: right; font-weight: ${(p) => (p.$strong ? 600 : 400)}; padding-left: 10px; }
108
+ .delta { color: ${v.faint}; font-variant-numeric: tabular-nums; min-width: 40px; text-align: right; }
109
+ .delta.up { color: ${v.chartCrit}; }
110
+ .delta.down { color: ${v.chartOk}; }
146
111
  `;
147
- const W = 600;
148
- const H = 200;
149
- const PAD_L = 58;
150
- const PAD_B = 18;
151
- const PAD_T = 8;
152
- function tickValue(x) {
153
- if (Math.abs(x) >= 1000)
154
- return fmtCount(Math.round(x));
155
- return Number.isInteger(x) ? String(x) : x.toFixed(2);
156
- }
157
- /** Splits a series into the unbroken runs of reported samples; a missing one ends the run. */
158
- function lineRuns(points) {
112
+ const TipSwatch = styled.i `
113
+ width: 10px; height: ${(p) => (p.$dashed ? 0 : 3)}px; border-radius: 2px;
114
+ background: ${(p) => (p.$dashed ? "none" : p.$c)};
115
+ border-top: ${(p) => (p.$dashed ? `2px dashed ${p.$c}` : "0")};
116
+ `;
117
+ const MAX_TIP_ROWS = 12;
118
+ /** Splits aligned values into the unbroken runs of reported samples; a missing one ends the run. */
119
+ function runsOf(xs, ys) {
159
120
  const runs = [];
160
121
  let run = [];
161
- for (const p of points) {
162
- if (p.v == null || !Number.isFinite(p.v)) {
122
+ for (let i = 0; i < xs.length; i++) {
123
+ const val = ys[i];
124
+ if (val == null || !Number.isFinite(val)) {
163
125
  if (run.length)
164
126
  runs.push(run);
165
127
  run = [];
166
128
  }
167
129
  else
168
- run.push({ t: p.t, v: p.v });
130
+ run.push({ t: xs[i], v: val });
169
131
  }
170
132
  if (run.length)
171
133
  runs.push(run);
172
134
  return runs;
173
135
  }
174
- /**
175
- * LineChart: n series over a shared time axis. Axes to read a value off, a
176
- * crosshair to compare series at one instant, a legend that mutes lines, and
177
- * a drag that selects a window (`onZoom`) — the natural gesture once a spike
178
- * is visible.
179
- *
180
- * A point whose `v` is `null`/`undefined` is a sample that was not reported
181
- * (an agent too old for the metric, a node that was down): the line breaks
182
- * there rather than dipping to zero, which is a real measurement.
183
- *
184
- * `unit` reads the value axis and the crosshair as percent, bytes, bps or
185
- * count; `formatValue` overrides it ("38 ms"), `area` fills under the lines and
186
- * `max` pins the top of the axis.
187
-
188
- * `formatTime` the time axis, which otherwise follows the span in local time.
189
- */
190
- export function LineChart({ series, onZoom, label = "Series", empty = "No samples in range", formatValue, formatTime, unit, area, max }) {
191
- const svg = useRef(null);
136
+ const markerColour = (m) => m.colour ?? (m.tone ? TONE_COLOUR[m.tone] : m.kind === "alert" ? v.chartCrit : m.kind === "note" ? v.faint : v.chartDeploy);
137
+ const thresholdWord = (t) => t.label ?? (t.tone === "warn" ? "warn" : t.tone === "ok" ? "ok" : t.tone === "info" ? "target" : "alert");
138
+ function TimePlot(props) {
139
+ const { series, kind, label, empty, formatValue, formatTime, unit, area, stacked, max, min, log, domain, onZoom, onPoint, onReset, onThresholdDrag, syncKey, legend, legendExtra, total, bandOpacity, neutral, hint, testId, } = props;
140
+ const size = useChartSize(props.size);
141
+ const spec = CHART_SIZES[size];
142
+ const H = props.height ?? spec.height;
143
+ const axes = spec.yTicks > 0;
144
+ // A sparkline has no room for annotations: it is the shape, nothing else.
145
+ const markers = axes ? props.markers : undefined;
146
+ const thresholds = axes ? props.thresholds : undefined;
147
+ const bands = axes ? props.bands : undefined;
148
+ const uid = useId().replace(/:/g, "");
149
+ const me = useRef(Symbol("chart"));
150
+ const wrapRef = useRef(null);
151
+ const svgRef = useRef(null);
152
+ const [width, setWidth] = useState(600);
192
153
  const [hidden, setHidden] = useState(new Set());
193
- const [hoverX, setHoverX] = useState(null);
154
+ const [hover, setHover] = useState(null);
194
155
  const [drag, setDrag] = useState(null);
195
- const shown = useMemo(() => series.filter((s) => !hidden.has(s.name)), [series, hidden]);
196
- // Measured series take the ramp in order; a reference series sits outside it.
156
+ const down = useRef(null);
157
+ const dragRule = useRef(null);
158
+ // The plot is drawn at its real width so its text stays crisp; where there is
159
+ // no ResizeObserver (a server render, a test DOM) it keeps the default.
160
+ const hasData = series.some((s) => s.points.length > 0);
161
+ useLayoutEffect(() => {
162
+ const el = wrapRef.current;
163
+ if (!el || typeof ResizeObserver === "undefined")
164
+ return;
165
+ const measure = () => { const w = el.getBoundingClientRect().width; if (w > 0)
166
+ setWidth(Math.max(120, Math.round(w))); };
167
+ measure();
168
+ const ro = new ResizeObserver(measure);
169
+ ro.observe(el);
170
+ return () => ro.disconnect();
171
+ }, [hasData]);
172
+ // Measured series take the ramp in order; a reference, a compare and a
173
+ // neutral series sit outside it. A compare takes its twin's colour.
197
174
  const colours = useMemo(() => {
175
+ const byName = new Map();
198
176
  let n = 0;
199
- return series.map((s) => s.tone ? TONE_FILL[s.tone] : s.reference ? v.faint : STROKES[n++ % STROKES.length]);
177
+ const out = series.map((s) => {
178
+ if (s.tone)
179
+ return TONE_COLOUR[s.tone];
180
+ if (s.colour)
181
+ return s.colour;
182
+ if (s.reference || neutral?.(s.name))
183
+ return v.faint;
184
+ if (s.compareOf)
185
+ return "";
186
+ return rampToken(n++);
187
+ });
188
+ series.forEach((s, i) => byName.set(s.name, out[i]));
189
+ return series.map((s, i) => out[i] || byName.get(s.compareOf ?? "") || v.faint);
190
+ }, [series, neutral]);
191
+ const names = series.map((s) => s.name).join("\u0000");
192
+ // Only names still on the chart count: a new group-by starts with all shown.
193
+ const muted = useMemo(() => {
194
+ const present = new Set(names.split("\u0000"));
195
+ return new Set([...hidden].filter((n) => present.has(n)));
196
+ }, [hidden, names]);
197
+ const isShown = (s) => !muted.has(s.name) && !(s.compareOf && muted.has(s.compareOf));
198
+ // Every series on the union of their timestamps.
199
+ const aligned = useMemo(() => {
200
+ const xs = [...new Set(series.flatMap((s) => s.points.map((p) => p.t)))].sort((a, b) => a - b);
201
+ const index = new Map(xs.map((x, i) => [x, i]));
202
+ const cols = series.map((s) => {
203
+ const col = new Array(xs.length).fill(null);
204
+ for (const p of s.points) {
205
+ const i = index.get(p.t);
206
+ if (i !== undefined && p.v != null && Number.isFinite(p.v))
207
+ col[i] = p.v;
208
+ }
209
+ return col;
210
+ });
211
+ let step = Infinity;
212
+ for (let i = 1; i < xs.length; i++)
213
+ step = Math.min(step, xs[i] - xs[i - 1]);
214
+ return { xs, cols, step: Number.isFinite(step) ? step : 60_000 };
200
215
  }, [series]);
201
- const measured = series.filter((s) => !s.reference).length;
202
- const points = shown.flatMap((s) => s.points).filter((p) => p.v != null && Number.isFinite(p.v));
203
- const all = series.flatMap((s) => s.points.map((p) => p.t));
204
- if (all.length === 0)
205
- return _jsx(Wrap, { children: _jsx(Note, { children: empty }) });
206
- // The time axis spans every series, so hiding one does not move the x axis
207
- // under the reader; only the value axis rescales to what is shown.
208
- const tMin = Math.min(...all);
209
- const tMax = Math.max(...all);
210
- const vMax = max ?? Math.max(...points.map((p) => p.v), 0);
211
- const vMin = Math.min(...points.map((p) => p.v), 0);
212
- const tSpan = tMax - tMin || 1;
213
- const vSpan = vMax - vMin || 1;
214
- const x = (t) => PAD_L + ((t - tMin) / tSpan) * (W - PAD_L);
215
- const y = (val) => (H - PAD_B) - ((val - vMin) / vSpan) * (H - PAD_B - PAD_T);
216
- const frac = (t) => (x(t) - PAD_L) / (W - PAD_L);
217
- const timeAt = (fraction) => tMin + fraction * tSpan;
218
- const gridValues = [vMin, vMin + vSpan / 2, vMax];
216
+ const { xs, cols, step } = aligned;
217
+ // Sync: another chart's crosshair, at the nearest bucket here.
218
+ useEffect(() => {
219
+ if (!syncKey)
220
+ return;
221
+ const set = syncBus.get(syncKey) ?? new Set();
222
+ syncBus.set(syncKey, set);
223
+ const fn = (t, from) => {
224
+ if (from === me.current)
225
+ return;
226
+ if (t === null) {
227
+ setHover((h) => (h && !h.own ? null : h));
228
+ return;
229
+ }
230
+ let best = -1;
231
+ for (let i = 0; i < xs.length; i++)
232
+ if (best < 0 || Math.abs(xs[i] - t) < Math.abs(xs[best] - t))
233
+ best = i;
234
+ if (best >= 0 && Math.abs(xs[best] - t) <= step)
235
+ setHover({ idx: best, py: null, own: false });
236
+ else
237
+ setHover((h) => (h && !h.own ? null : h));
238
+ };
239
+ set.add(fn);
240
+ return () => { set.delete(fn); };
241
+ }, [syncKey, xs, step]);
242
+ if (xs.length === 0)
243
+ return _jsx(Wrap, { ref: wrapRef, "data-testid": testId, children: _jsx(Note, { "$h": H, children: empty }) });
244
+ const stack = kind === "bars" || !!stacked;
245
+ const stackable = (s) => stack && !s.reference && !s.overlay && !s.compareOf;
246
+ // Stacked parts: each part's lower and upper edge at every bucket.
247
+ const lower = series.map(() => []);
248
+ const upper = series.map(() => []);
249
+ const totals = xs.map(() => 0);
250
+ for (let i = 0; i < xs.length; i++) {
251
+ let base = 0;
252
+ series.forEach((s, k) => {
253
+ if (!stackable(s) || !isShown(s))
254
+ return;
255
+ const val = cols[k][i];
256
+ lower[k][i] = base;
257
+ if (val == null) {
258
+ upper[k][i] = kind === "bars" ? null : base;
259
+ return;
260
+ }
261
+ base += val;
262
+ upper[k][i] = base;
263
+ });
264
+ totals[i] = base;
265
+ }
266
+ // The value axis fits what is shown, the thresholds and zero.
267
+ const shownVals = [];
268
+ series.forEach((s, k) => {
269
+ if (!isShown(s))
270
+ return;
271
+ if (stackable(s))
272
+ for (const u of upper[k]) {
273
+ if (u != null)
274
+ shownVals.push(u);
275
+ }
276
+ else
277
+ for (const c of cols[k]) {
278
+ if (c != null)
279
+ shownVals.push(c);
280
+ }
281
+ });
282
+ for (const t of thresholds ?? [])
283
+ if (Number.isFinite(t.value))
284
+ shownVals.push(t.value * 1.05);
285
+ let dataMax = shownVals.length ? Math.max(...shownVals) : 1;
286
+ let dataMin = shownVals.length ? Math.min(...shownVals, 0) : 0;
287
+ if (min !== undefined)
288
+ dataMin = min;
289
+ if (max !== undefined)
290
+ dataMax = max;
291
+ let yTicks;
292
+ let yLo;
293
+ let yHi;
294
+ const tickCount = props.height !== undefined && props.height < 120 ? 2 : Math.max(2, spec.yTicks);
295
+ if (log) {
296
+ const pos = shownVals.filter((n) => n > 0);
297
+ const lo = min !== undefined && min > 0 ? min : pos.length ? Math.min(...pos) : 1;
298
+ const hi = Math.max(dataMax, lo * 10);
299
+ const a = Math.floor(Math.log10(lo));
300
+ const b = Math.ceil(Math.log10(hi));
301
+ yLo = 10 ** a;
302
+ yHi = 10 ** b;
303
+ yTicks = [];
304
+ const every = Math.max(1, Math.ceil((b - a) / Math.max(1, tickCount - 1)));
305
+ for (let e = a; e <= b; e += every)
306
+ yTicks.push(10 ** e);
307
+ }
308
+ else {
309
+ const nice = niceTicks(dataMin, dataMax, tickCount);
310
+ yLo = min ?? nice.min;
311
+ yHi = max ?? nice.max;
312
+ if (yHi <= yLo)
313
+ yHi = yLo + 1;
314
+ yTicks = nice.ticks.filter((t) => t >= yLo - 1e-9 && t <= yHi + 1e-9);
315
+ }
219
316
  const fmtTip = formatValue ?? (unit ? (n) => formatUnit(n, unit) : tickValue);
220
- const fmtV = formatValue ?? (unit ? (n) => formatUnit(n, unit, true) : tickValue);
221
- const tick = formatTime ?? timeTick(tMax - tMin);
222
- const fractionOf = (clientX) => {
223
- const rect = svg.current?.getBoundingClientRect();
224
- if (!rect)
317
+ const fmtAxis = formatValue ?? (unit ? (n) => formatUnit(n, unit, true) : tickValue);
318
+ const from = domain?.from ?? xs[0];
319
+ const to = domain?.to ?? (kind === "bars" ? xs[xs.length - 1] + step : xs[xs.length - 1]);
320
+ const tSpan = Math.max(1, to - from);
321
+ const yLabels = axes ? yTicks.map((t) => fmtAxis(t)) : [];
322
+ const padL = axes ? Math.max(28, Math.ceil(Math.max(...yLabels.map((s) => s.length)) * 6.2) + 10) : 0;
323
+ const padR = axes ? 10 : size === "s" ? 3 : 0;
324
+ const markerLabels = spec.markerLabels && (markers?.length ?? 0) > 0;
325
+ const padT = axes ? (markerLabels ? 18 : 8) : 3;
326
+ const padB = axes ? 20 : 3;
327
+ const plotW = Math.max(10, width - padL - padR);
328
+ const plotH = Math.max(10, H - padT - padB);
329
+ const bottom = padT + plotH;
330
+ const x = (t) => padL + ((t - from) / tSpan) * plotW;
331
+ const y = log
332
+ ? (val) => bottom - ((Math.log10(Math.max(val, yLo)) - Math.log10(yLo)) / (Math.log10(yHi) - Math.log10(yLo))) * plotH
333
+ : (val) => bottom - ((Math.min(Math.max(val, yLo), yHi) - yLo) / (yHi - yLo)) * plotH;
334
+ const baseY = y(log ? yLo : Math.max(yLo, Math.min(0, yHi)));
335
+ const f = (n) => n.toFixed(1);
336
+ const measured = series.filter((s) => !s.reference && !s.compareOf);
337
+ const fillSingle = !stack && area && (size === "s" || size === "m" || props.height !== undefined) && measured.length === 1;
338
+ // Pointer → the nearest bucket.
339
+ const idxAt = (clientX) => {
340
+ const rect = svgRef.current?.getBoundingClientRect();
341
+ if (!rect || rect.width === 0)
342
+ return null;
343
+ const px = ((clientX - rect.left) / rect.width) * width;
344
+ const t = from + ((px - padL) / plotW) * tSpan;
345
+ let best = 0;
346
+ for (let i = 1; i < xs.length; i++) {
347
+ const centre = kind === "bars" ? xs[i] + step / 2 : xs[i];
348
+ const bestCentre = kind === "bars" ? xs[best] + step / 2 : xs[best];
349
+ if (Math.abs(centre - t) < Math.abs(bestCentre - t))
350
+ best = i;
351
+ }
352
+ return best;
353
+ };
354
+ const fracAt = (clientX) => {
355
+ const rect = svgRef.current?.getBoundingClientRect();
356
+ if (!rect || rect.width === 0)
225
357
  return null;
226
- const plotLeft = rect.left + (PAD_L / W) * rect.width;
227
- const plotWidth = rect.width * (1 - PAD_L / W);
228
- return Math.min(1, Math.max(0, (clientX - plotLeft) / plotWidth));
358
+ const px = ((clientX - rect.left) / rect.width) * width;
359
+ return Math.min(1, Math.max(0, (px - padL) / plotW));
229
360
  };
230
- // The nearest sample per series to the hovered instant.
231
- const hovered = hoverX === null ? null : (() => {
232
- const t = timeAt(hoverX);
233
- const rows = shown.map((s) => {
234
- let best = null;
235
- for (const p of s.points)
236
- if (!best || Math.abs(p.t - t) < Math.abs(best.t - t))
237
- best = p;
238
- return best ? { name: s.name, c: colours[series.indexOf(s)], v: best.v, t: best.t } : null;
239
- }).filter((r) => r !== null);
240
- return rows.length ? { t, rows } : null;
241
- })();
242
- const toggle = (name) => setHidden((prev) => {
361
+ const pyAt = (clientY) => {
362
+ const rect = svgRef.current?.getBoundingClientRect();
363
+ if (!rect || rect.height === 0)
364
+ return null;
365
+ return ((clientY - rect.top) / rect.height) * H;
366
+ };
367
+ /** The series nearest the pointer at a bucket. */
368
+ const nearest = (idx, py) => {
369
+ let best = null;
370
+ let dist = Infinity;
371
+ series.forEach((s, k) => {
372
+ if (!isShown(s))
373
+ return;
374
+ if (stackable(s)) {
375
+ const lo = lower[k][idx];
376
+ const hi = upper[k][idx];
377
+ if (lo == null || hi == null)
378
+ return;
379
+ if (py !== null && py <= y(lo) && py >= y(hi)) {
380
+ best = k;
381
+ dist = -1;
382
+ return;
383
+ }
384
+ const d = py === null ? Infinity : Math.min(Math.abs(py - y(lo)), Math.abs(py - y(hi)));
385
+ if (d < dist) {
386
+ dist = d;
387
+ best = k;
388
+ }
389
+ return;
390
+ }
391
+ const val = cols[k][idx];
392
+ if (val == null || s.reference)
393
+ return;
394
+ const d = py === null ? Infinity : Math.abs(py - y(val));
395
+ if (dist >= 0 && d < dist) {
396
+ dist = d;
397
+ best = k;
398
+ }
399
+ });
400
+ return py === null ? null : best;
401
+ };
402
+ const setHoverIdx = (h) => {
403
+ setHover(h);
404
+ publish(syncKey, h ? xs[h.idx] : null, me.current);
405
+ };
406
+ const pointAt = (idx, py) => {
407
+ const k = nearest(idx, py);
408
+ const val = k !== null ? cols[k][idx] : null;
409
+ return { t: xs[idx], step, series: k !== null ? series[k].name : undefined, value: val ?? undefined };
410
+ };
411
+ const onPointerMove = (e) => {
412
+ if (dragRule.current !== null && onThresholdDrag) {
413
+ const py = pyAt(e.clientY);
414
+ if (py === null)
415
+ return;
416
+ const clamped = Math.min(bottom, Math.max(padT, py));
417
+ const val = log
418
+ ? 10 ** (Math.log10(yLo) + ((bottom - clamped) / plotH) * (Math.log10(yHi) - Math.log10(yLo)))
419
+ : yLo + ((bottom - clamped) / plotH) * (yHi - yLo);
420
+ onThresholdDrag(dragRule.current, val);
421
+ return;
422
+ }
423
+ const idx = idxAt(e.clientX);
424
+ if (idx === null)
425
+ return;
426
+ setHoverIdx({ idx, py: pyAt(e.clientY), own: true });
427
+ if (drag) {
428
+ const fr = fracAt(e.clientX);
429
+ if (fr !== null)
430
+ setDrag({ from: drag.from, to: fr });
431
+ }
432
+ };
433
+ const onPointerDown = (e) => {
434
+ if (e.button !== 0)
435
+ return;
436
+ down.current = { x: e.clientX, y: e.clientY };
437
+ if (onZoom) {
438
+ const fr = fracAt(e.clientX);
439
+ if (fr !== null)
440
+ setDrag({ from: fr, to: fr });
441
+ }
442
+ };
443
+ const onPointerUp = (e) => {
444
+ if (dragRule.current !== null) {
445
+ dragRule.current = null;
446
+ return;
447
+ }
448
+ const start = down.current;
449
+ down.current = null;
450
+ if (drag && onZoom && Math.abs(drag.to - drag.from) * plotW > 4) {
451
+ const a = from + Math.min(drag.from, drag.to) * tSpan;
452
+ const b = from + Math.max(drag.from, drag.to) * tSpan;
453
+ setDrag(null);
454
+ onZoom(new Date(a), new Date(b));
455
+ return;
456
+ }
457
+ setDrag(null);
458
+ if (onPoint && start && Math.abs(e.clientX - start.x) <= 3) {
459
+ const idx = idxAt(e.clientX);
460
+ if (idx !== null)
461
+ onPoint(pointAt(idx, pyAt(e.clientY)), { clientX: e.clientX, clientY: e.clientY });
462
+ }
463
+ };
464
+ const onContextMenu = (e) => {
465
+ if (!onPoint)
466
+ return;
467
+ e.preventDefault();
468
+ const idx = idxAt(e.clientX);
469
+ if (idx !== null)
470
+ onPoint(pointAt(idx, pyAt(e.clientY)), { clientX: e.clientX, clientY: e.clientY });
471
+ };
472
+ const onKey = (e) => {
473
+ const last = xs.length - 1;
474
+ const cur = hover?.idx ?? last;
475
+ let next = cur;
476
+ if (e.key === "ArrowLeft")
477
+ next = Math.max(0, cur - 1);
478
+ else if (e.key === "ArrowRight")
479
+ next = Math.min(last, cur + 1);
480
+ else if (e.key === "Home")
481
+ next = 0;
482
+ else if (e.key === "End")
483
+ next = last;
484
+ else if (e.key === "Escape")
485
+ next = null;
486
+ else if (e.key === "Enter" && onPoint && hover) {
487
+ const rect = svgRef.current?.getBoundingClientRect();
488
+ onPoint(pointAt(hover.idx, null), { clientX: (rect?.left ?? 0) + (x(xs[hover.idx]) / width) * (rect?.width ?? width), clientY: rect?.top ?? 0 });
489
+ return;
490
+ }
491
+ else
492
+ return;
493
+ e.preventDefault();
494
+ setHoverIdx(next === null ? null : { idx: next, py: null, own: true });
495
+ };
496
+ const toggle = (name, solo) => setHidden(() => {
497
+ const prev = muted;
498
+ const measuredNames = measured.map((s) => s.name);
499
+ if (solo) {
500
+ const soloed = measuredNames.every((n) => n === name ? !prev.has(n) : prev.has(n));
501
+ return soloed ? new Set() : new Set(measuredNames.filter((n) => n !== name));
502
+ }
243
503
  const next = new Set(prev);
244
504
  if (next.has(name))
245
505
  next.delete(name);
246
506
  else
247
507
  next.add(name);
248
508
  // Hiding every measured series leaves a chart with no axis to read; treat
249
- // the last one as "show all again". References do not count.
250
- const off = series.filter((s) => !s.reference && next.has(s.name)).length;
251
- return measured > 0 && off === measured ? new Set() : next;
509
+ // the last one as "show all again". References and compares do not count.
510
+ const off = measuredNames.filter((n) => next.has(n)).length;
511
+ return measuredNames.length > 0 && off === measuredNames.length ? new Set() : next;
252
512
  });
253
- return (_jsxs(Wrap, { children: [_jsxs(Plot, { children: [_jsxs(Svg, { ref: svg, viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", role: "img", "aria-label": label, onMouseMove: (e) => { const f = fractionOf(e.clientX); if (f === null)
254
- return; setHoverX(f); setDrag((d) => (d ? { ...d, to: f } : null)); }, onMouseLeave: () => { setHoverX(null); setDrag(null); }, onMouseDown: (e) => { if (!onZoom)
255
- return; const f = fractionOf(e.clientX); if (f !== null)
256
- setDrag({ from: f, to: f }); }, onMouseUp: () => {
257
- if (drag && onZoom)
258
- onZoom(new Date(timeAt(Math.min(drag.from, drag.to))), new Date(timeAt(Math.max(drag.from, drag.to))));
513
+ // Time ticks, thinned so two labels never touch.
514
+ const xTickMarks = axes
515
+ ? timeTicks(from, to, Math.min(spec.xTicks, Math.max(2, Math.floor(plotW / 70))), formatTime)
516
+ : [];
517
+ const tipTime = formatTime ?? timeDetail(step);
518
+ // The hovered bucket's rows: every shown series worst-first (the stacked
519
+ // charts top-first under a total), a compare read as its twin's delta.
520
+ const tip = (() => {
521
+ if (!hover || drag || !hover.own)
522
+ return null;
523
+ const idx = hover.idx;
524
+ const focus = nearest(idx, hover.py);
525
+ const twins = new Map();
526
+ series.forEach((s, k) => { if (s.compareOf && isShown(s))
527
+ twins.set(s.compareOf, k); });
528
+ const present = new Set(series.map((s) => s.name));
529
+ const rows = series
530
+ .map((s, k) => ({ s, k, val: cols[k][idx] ?? null }))
531
+ .filter((r) => isShown(r.s) && !(r.s.compareOf && present.has(r.s.compareOf)));
532
+ if (stack)
533
+ rows.reverse();
534
+ else
535
+ rows.sort((a, b) => (a.s.reference ? 1 : 0) - (b.s.reference ? 1 : 0) || (b.val ?? -Infinity) - (a.val ?? -Infinity));
536
+ if (!rows.length)
537
+ return null;
538
+ const shownRows = rows.slice(0, MAX_TIP_ROWS);
539
+ const left = x(xs[idx] + (kind === "bars" ? step / 2 : 0));
540
+ const flip = left > width * 0.6;
541
+ const cmpName = [...twins.values()].map((k) => series[k].name)[0];
542
+ const vs = cmpName && cmpName.includes(" · ") ? ` · vs ${cmpName.split(" · ").pop()}` : "";
543
+ return (_jsxs(Tip, { style: flip ? { right: width - left + 12 } : { left: left + 12 }, "data-testid": testId ? `${testId}-tooltip` : undefined, children: [_jsxs("div", { className: "t", children: [tipTime(xs[idx]), step > 0 && xs.length > 1 ? ` · ${stepLabel(step)} bucket` : "", vs] }), total && stack ? (_jsxs(TipRow, { "$strong": true, children: [_jsx("span", {}), _jsx("span", { className: "n", children: "Total" }), _jsx("span", { className: "val", children: fmtTip(totals[idx] ?? 0) }), _jsx("span", {})] })) : null, shownRows.map((r) => {
544
+ const twin = twins.get(r.s.name);
545
+ const was = twin !== undefined ? cols[twin][idx] : null;
546
+ const delta = r.val != null && was != null ? formatDelta(r.val, was) : "";
547
+ const dir = delta.startsWith("+") ? "up" : delta.startsWith("−") ? "down" : "";
548
+ return (_jsxs(TipRow, { "$focus": focus === r.k && series.length > 1, children: [_jsx(TipSwatch, { "$c": colours[r.k], "$dashed": r.s.reference || r.s.dashed || !!r.s.compareOf }), _jsx("span", { className: "n", children: r.s.name }), _jsx("span", { className: "val", children: r.val == null ? "—" : fmtTip(r.val) }), _jsx("span", { className: `delta ${dir}`, children: delta })] }, r.s.name));
549
+ }), rows.length > shownRows.length ? _jsxs("div", { className: "more", children: ["+", rows.length - shownRows.length, " more"] }) : null] }));
550
+ })();
551
+ // Marker labels at rest (L/XL) skip one that would touch the last kept.
552
+ const markerX = (markers ?? []).map((m) => x(m.at));
553
+ const markerKeep = spaced(markerX, 46);
554
+ const hoverX = hover && !drag ? x(xs[hover.idx] + (kind === "bars" ? step / 2 : 0)) : null;
555
+ const barW = Math.max(1, (step / tSpan) * plotW);
556
+ const barInset = barW > 4 ? Math.min(2, barW * 0.12) : 0;
557
+ const lastValue = (k) => {
558
+ const c = cols[k];
559
+ for (let i = c.length - 1; i >= 0; i--)
560
+ if (c[i] != null)
561
+ return c[i];
562
+ return null;
563
+ };
564
+ const showLegend = (legend ?? spec.legend) && size !== "s";
565
+ const legendItems = series.map((s, k) => {
566
+ const last = lastValue(k);
567
+ return {
568
+ key: s.name, name: s.name, colour: colours[k], value: last == null ? undefined : fmtTip(last),
569
+ dashed: s.reference || s.dashed || !!s.compareOf, faded: !!s.compareOf, off: !isShown(s),
570
+ };
571
+ });
572
+ const thresholdNotes = (thresholds ?? []).filter((t) => Number.isFinite(t.value));
573
+ return (_jsxs(Wrap, { "data-testid": testId, children: [_jsxs(Plot, { ref: wrapRef, children: [_jsxs(PlotSvg, { ref: svgRef, width: "100%", height: H, viewBox: `0 0 ${width} ${H}`, preserveAspectRatio: "none", role: "img", "aria-label": `${label}. Arrow keys step through the buckets.`, tabIndex: size === "s" ? -1 : 0, "$interactive": size !== "s", onPointerMove: onPointerMove, onPointerDown: onPointerDown, onPointerUp: onPointerUp, onPointerLeave: () => { if (dragRule.current === null) {
574
+ setHoverIdx(null);
259
575
  setDrag(null);
260
- }, children: [gridValues.map((g) => (_jsxs("g", { children: [_jsx("line", { x1: PAD_L, x2: W, y1: y(g), y2: y(g), stroke: v.line, strokeWidth: 1, vectorEffect: "non-scaling-stroke" }), _jsx("text", { x: PAD_L - 6, y: y(g) + 3, textAnchor: "end", fontSize: 9, fill: v.faint, children: fmtV(g) })] }, g))), _jsx("text", { x: PAD_L, y: H - 4, fontSize: 9, fill: v.faint, children: tick(tMin) }), _jsx("text", { x: W, y: H - 4, fontSize: 9, fill: v.faint, textAnchor: "end", children: tick(tMax) }), shown.map((s) => {
261
- const i = series.indexOf(s);
262
- const colour = colours[i];
263
- const runs = lineRuns(s.points);
264
- return (_jsxs("g", { children: [area && !s.reference ? runs.filter((r) => r.length > 1).map((r, j) => (_jsx("path", { d: `M ${x(r[0].t)} ${y(0)} ${r.map((p) => `L ${x(p.t)} ${y(p.v)}`).join(" ")} L ${x(r[r.length - 1].t)} ${y(0)} Z`, fill: colour, fillOpacity: 0.14, stroke: "none" }, j))) : null, runs.map((r, j) => r.length > 1
265
- ? _jsx("path", { d: r.map((p, k) => `${k === 0 ? "M" : "L"} ${x(p.t)} ${y(p.v)}`).join(" "), fill: "none", stroke: colour, strokeWidth: 1.5, vectorEffect: "non-scaling-stroke", ...(s.reference ? { strokeDasharray: "5 4" } : {}) }, j)
576
+ } }, onContextMenu: onContextMenu, onDoubleClick: onReset ? (e) => { e.preventDefault(); onReset(); } : undefined, onKeyDown: onKey, onFocus: () => { if (!hover && size !== "s")
577
+ setHoverIdx({ idx: xs.length - 1, py: null, own: true }); }, onBlur: () => setHoverIdx(null), children: [bands?.some((b) => b.hatch) ? (_jsx("defs", { children: _jsx("pattern", { id: `hatch-${uid}`, width: "6", height: "6", patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)", children: _jsx("line", { x1: "0", y1: "0", x2: "0", y2: "6", stroke: v.line2, strokeWidth: 2 }) }) })) : null, (bands ?? []).map((b, i) => {
578
+ const a = Math.max(padL, x(b.from));
579
+ const z = Math.min(padL + plotW, x(b.to));
580
+ if (z <= a)
581
+ return null;
582
+ return (_jsx("rect", { x: a, y: padT, width: Math.max(1, z - a), height: plotH, fill: b.hatch ? `url(#hatch-${uid})` : b.colour ?? TONE_COLOUR[b.tone ?? "brand"], ...(b.hatch || b.colour ? {} : { opacity: 0.07 }), children: b.label ? _jsx("title", { children: b.label }) : null }, `b${i}`));
583
+ }), axes ? yTicks.map((t, i) => (_jsxs("g", { children: [Math.abs(y(t) - bottom) > 0.5 ? _jsx("line", { x1: padL, x2: padL + plotW, y1: f(y(t)), y2: f(y(t)), stroke: v.line, strokeWidth: 1 }) : null, _jsx("text", { x: padL - 6, y: y(t) + 3.5, textAnchor: "end", fontSize: 10, fill: v.faint, children: yLabels[i] })] }, `y${t}`))) : null, axes ? _jsx("line", { x1: padL, x2: padL + plotW, y1: bottom, y2: bottom, stroke: v.line2, strokeWidth: 1 }) : null, xTickMarks.map((tk, i) => {
584
+ const px = x(tk.t);
585
+ if (px < padL - 1 || px > padL + plotW + 1)
586
+ return null;
587
+ const anchor = px - padL < 20 ? "start" : padL + plotW - px < 20 ? "end" : "middle";
588
+ return (_jsx("text", { x: px, y: H - 5, fontSize: 10, fill: v.faint, opacity: tk.dim ? 0.6 : 1, textAnchor: anchor, children: tk.label }, `x${i}`));
589
+ }), kind === "bars" ? (_jsx("g", { children: series.map((s, k) => {
590
+ if (!stackable(s) || !isShown(s))
591
+ return null;
592
+ return (_jsx("g", { fill: colours[k], children: xs.map((t, i) => {
593
+ const lo = lower[k][i];
594
+ const hi = upper[k][i];
595
+ if (lo == null || hi == null || hi === lo)
596
+ return null;
597
+ const top = y(hi);
598
+ const h = Math.max(0.5, y(lo) - top);
599
+ return _jsx("rect", { x: f(x(t) + barInset), y: f(top), width: f(Math.max(0.5, barW - barInset * 2)), height: f(h), opacity: hover && hover.idx !== i && hover.own ? 0.7 : 1 }, t);
600
+ }) }, s.name));
601
+ }) })) : null, stack && kind === "line" ? series.map((s, k) => {
602
+ if (!stackable(s) || !isShown(s))
603
+ return null;
604
+ const up = upper[k];
605
+ const lo = lower[k];
606
+ const top = xs.map((t, i) => `${f(x(t))} ${f(y(up[i] ?? 0))}`);
607
+ const bot = xs.map((t, i) => `${f(x(t))} ${f(y(lo[i] ?? 0))}`).reverse();
608
+ return (_jsxs("g", { children: [_jsx("path", { d: `M ${top.join(" L ")} L ${bot.join(" L ")} Z`, fill: colours[k], fillOpacity: bandOpacity ?? 0.35, stroke: "none" }), _jsx("path", { d: `M ${top.join(" L ")}`, fill: "none", stroke: bandOpacity && bandOpacity > 0.6 ? v.surface : colours[k], strokeWidth: bandOpacity && bandOpacity > 0.6 ? 1.2 : 1.8, strokeLinejoin: "round", strokeLinecap: "round" })] }, s.name));
609
+ }) : null, series.map((s, k) => {
610
+ if (!isShown(s) || stackable(s))
611
+ return null;
612
+ const runs = runsOf(xs, cols[k]);
613
+ const colour = colours[k];
614
+ const dashed = s.reference || s.dashed || !!s.compareOf;
615
+ const xOff = kind === "bars" ? step / 2 : 0;
616
+ return (_jsxs("g", { opacity: s.compareOf ? 0.45 : 1, children: [fillSingle && !s.reference && !s.compareOf ? runs.filter((r) => r.length > 1).map((r, j) => (_jsx("path", { d: `M ${f(x(r[0].t + xOff))} ${f(baseY)} ${r.map((p) => `L ${f(x(p.t + xOff))} ${f(y(p.v))}`).join(" ")} L ${f(x(r[r.length - 1].t + xOff))} ${f(baseY)} Z`, fill: colour, fillOpacity: 0.12, stroke: "none" }, `a${j}`))) : null, runs.map((r, j) => r.length > 1
617
+ ? _jsx("path", { d: r.map((p, q) => `${q === 0 ? "M" : "L"} ${f(x(p.t + xOff))} ${f(y(p.v))}`).join(" "), fill: "none", stroke: colour, strokeWidth: size === "s" ? 1.5 : 1.8, strokeLinejoin: "round", strokeLinecap: "round", ...(dashed ? { strokeDasharray: "5 4" } : {}) }, j)
266
618
  // a lone sample between two gaps has no line to draw; it is a dot
267
- : _jsx("circle", { cx: x(r[0].t), cy: y(r[0].v), r: 1.8, fill: colour }, j))] }, s.name));
268
- }), drag && Math.abs(drag.to - drag.from) > 0.002 ? (_jsx("rect", { x: PAD_L + Math.min(drag.from, drag.to) * (W - PAD_L), width: Math.abs(drag.to - drag.from) * (W - PAD_L), y: PAD_T, height: H - PAD_B - PAD_T, fill: v.brand12, stroke: v.brand55, strokeWidth: 1, vectorEffect: "non-scaling-stroke" })) : null, hoverX !== null && !drag ? (_jsx("line", { x1: PAD_L + hoverX * (W - PAD_L), x2: PAD_L + hoverX * (W - PAD_L), y1: PAD_T, y2: H - PAD_B, stroke: v.line2, strokeWidth: 1, vectorEffect: "non-scaling-stroke" })) : null, hovered && !drag ? hovered.rows.map((r) => (r.v == null ? null : _jsx("circle", { cx: x(r.t), cy: y(r.v), r: 2.5, fill: r.c }, r.name))) : null] }), hovered && !drag ? (_jsxs(Tip, { "$left": frac(hovered.t) * 100, "$flip": frac(hovered.t) > 0.6, children: [_jsx("div", { className: "t", children: new Date(hovered.t).toLocaleString() }), hovered.rows.map((r) => _jsxs(TipRow, { "$c": r.c, children: [r.name, _jsx("span", { className: "val", children: r.v == null ? "—" : fmtTip(r.v) })] }, r.name))] })) : null] }), _jsx(Legend, { children: series.map((s, i) => (_jsx(Key, { type: "button", "$c": colours[i], "$dash": s.reference, "$off": hidden.has(s.name), onClick: () => toggle(s.name), title: hidden.has(s.name) ? "Show this series" : "Hide this series", children: s.name }, s.name))) }), onZoom ? _jsx(ChartHint, { children: "Drag across the chart to zoom into a window." }) : null] }));
619
+ : _jsx("circle", { cx: f(x(r[0].t + xOff)), cy: f(y(r[0].v)), r: 1.8, fill: colour }, j)), size === "s" && !s.compareOf && !s.reference && runs.length ? (() => {
620
+ const lastRun = runs[runs.length - 1];
621
+ const p = lastRun[lastRun.length - 1];
622
+ return _jsx("circle", { cx: f(x(p.t + xOff)), cy: f(y(p.v)), r: 2.2, fill: colour });
623
+ })() : null] }, s.name));
624
+ }), (thresholds ?? []).map((t, i) => {
625
+ if (!Number.isFinite(t.value))
626
+ return null;
627
+ const ty = y(t.value);
628
+ const colour = TONE_COLOUR[t.tone ?? "crit"];
629
+ // Two rules close together: the lower one's label goes under its line.
630
+ const crowded = (thresholds ?? []).some((o, j) => j !== i && Number.isFinite(o.value) && y(o.value) < ty && ty - y(o.value) < 13);
631
+ const labelY = crowded ? ty + 11 : ty - 4;
632
+ return (_jsxs("g", { children: [_jsx("line", { x1: padL, x2: padL + plotW, y1: f(ty), y2: f(ty), stroke: colour, strokeWidth: 1, strokeDasharray: "4 3" }), axes ? (_jsx("text", { x: padL + plotW - 2, y: labelY, textAnchor: "end", fontSize: 10, fill: colour, children: `${thresholdWord(t)} · ${fmtAxis(t.value)}` })) : null, onThresholdDrag ? (_jsx("rect", { x: padL, y: ty - 5, width: plotW, height: 10, fill: "transparent", style: { cursor: "ns-resize" }, role: "slider", tabIndex: 0, "aria-label": `${thresholdWord(t)} threshold`, "aria-valuenow": t.value, "aria-valuetext": fmtTip(t.value), "data-testid": testId ? `${testId}-drag-${thresholdWord(t)}` : undefined, onPointerDown: (e) => { e.stopPropagation(); dragRule.current = i; (e.currentTarget.ownerSVGElement ?? e.currentTarget).setPointerCapture?.(e.pointerId); }, onKeyDown: (e) => {
633
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown")
634
+ return;
635
+ e.preventDefault();
636
+ e.stopPropagation();
637
+ const d = Math.abs(t.value) > 0 ? Math.abs(t.value) * 0.05 : 0.01;
638
+ onThresholdDrag(i, t.value + (e.key === "ArrowUp" ? d : -d));
639
+ } })) : null] }, `t${i}`));
640
+ }), (markers ?? []).map((m, i) => {
641
+ const mx = markerX[i];
642
+ if (mx < padL - 0.5 || mx > padL + plotW + 0.5)
643
+ return null;
644
+ const colour = markerColour(m);
645
+ const near = hoverX !== null && Math.abs(hoverX - mx) < 8;
646
+ const labelled = (markerLabels && markerKeep[i]) || near;
647
+ return (_jsxs("g", { "data-marker": m.kind ?? "deploy", children: [_jsx("title", { children: m.label }), _jsx("line", { x1: f(mx), x2: f(mx), y1: padT, y2: bottom, stroke: colour, strokeWidth: 1, strokeDasharray: "2 3", opacity: 0.8 }), m.kind === "alert"
648
+ ? _jsx("rect", { x: mx - 1, y: bottom - 6, width: 2, height: 7, fill: colour })
649
+ : m.kind === "note"
650
+ ? _jsx("circle", { cx: mx, cy: bottom, r: 2.6, fill: colour })
651
+ : _jsx("polygon", { points: `${f(mx)},${f(bottom - 6)} ${f(mx - 4.5)},${f(bottom + 1.5)} ${f(mx + 4.5)},${f(bottom + 1.5)}`, fill: colour }), labelled && axes ? (_jsx("text", { x: mx, y: padT - 5, textAnchor: mx > padL + plotW - 30 ? "end" : mx < padL + 30 ? "start" : "middle", fontSize: 10, fill: colour, children: m.label })) : null] }, `m${i}`));
652
+ }), drag && Math.abs(drag.to - drag.from) * plotW > 1 ? (_jsx("rect", { x: padL + Math.min(drag.from, drag.to) * plotW, width: Math.abs(drag.to - drag.from) * plotW, y: padT, height: plotH, fill: v.brand12, stroke: v.brand55, strokeWidth: 1 })) : null, hoverX !== null && size !== "s" ? (_jsx("line", { x1: f(hoverX), x2: f(hoverX), y1: padT, y2: bottom, stroke: v.line2, strokeWidth: 1, strokeDasharray: hover?.own ? undefined : "3 3" })) : null, hover && !drag && kind === "line" && size !== "s" ? series.map((s, k) => {
653
+ if (!isShown(s))
654
+ return null;
655
+ const val = stackable(s) ? upper[k][hover.idx] : cols[k][hover.idx];
656
+ if (val == null)
657
+ return null;
658
+ return _jsx("circle", { cx: f(x(xs[hover.idx])), cy: f(y(val)), r: 3, fill: colours[k], stroke: v.surface, strokeWidth: 1.5, opacity: s.compareOf ? 0.6 : 1 }, `h${k}`);
659
+ }) : null] }), tip] }), showLegend && (series.length > 1 || thresholdNotes.length > 0 || legendExtra || legend) ? (_jsx(LegendChips, { items: legendItems, onToggle: toggle, notes: _jsxs(_Fragment, { children: [thresholdNotes.length ? (_jsx(LegendNote, { colour: TONE_COLOUR[thresholdNotes[0].tone ?? "crit"], dashed: true, children: thresholdNotes.map((t) => `${thresholdWord(t)} ${fmtTip(t.value)}`).join(" · ") })) : null, legendExtra] }) })) : null, hint !== undefined ? hint : onZoom && size !== "s" && size !== "m" ? _jsx(ChartHint, { children: "Drag across the chart to zoom into a window." }) : null] }));
660
+ }
661
+ /**
662
+ * LineChart: n series over a shared time axis — the default timeseries, up to
663
+ * eight lines before the legend collapses.
664
+ *
665
+ * The axes follow board 32: 3–5 nice value ticks in the unit, the bottom line
666
+ * the only solid rule, hairlines above it, no vertical grid; 5–7 time ticks on
667
+ * a round step with a date only at a midnight crossing. Lines are 1.8 px and
668
+ * round-joined, with no dots at rest; a point whose `v` is `null`/`undefined`
669
+ * was not reported and breaks the line rather than dipping to zero.
670
+ *
671
+ * Hovering (or ← → once focused) draws a crosshair with a dot per series and
672
+ * one tooltip listing every series worst-first with its value and, for a
673
+ * series with a `compareOf` twin, the delta. The legend's chips carry each
674
+ * series' last value: click mutes, shift-click solos. Drag selects a window
675
+ * (`onZoom`); a click is a point (`onPoint`).
676
+ *
677
+ * `markers` (deploys, alert transitions, notes), `thresholds` (dashed, labelled
678
+ * at the right end) and `bands` (a firing window, a hatched "not in yet") are
679
+ * first-class. `size` sets the height and what shows; inside a `ChartFrame` it
680
+ * follows the frame's.
681
+ */
682
+ export function LineChart({ label = "Series", empty = "No samples in range", ...rest }) {
683
+ return _jsx(TimePlot, { kind: "line", label: label, empty: empty, ...rest });
684
+ }
685
+ /**
686
+ * BarChart: counts per bucket, stacked by part (status class, error type) over
687
+ * time. Each series is a part, stacked in series order; a series with
688
+ * `overlay` is drawn as a line over the stack (a shifted volume), and a
689
+ * `compareOf` twin is a dashed line too. Everything else — axes, markers,
690
+ * thresholds, the tooltip, the legend — is LineChart's.
691
+ */
692
+ export function BarChart({ label = "Counts over time", empty = "No activity in range", ...rest }) {
693
+ return _jsx(TimePlot, { kind: "bars", label: label, empty: empty, total: true, ...rest });
694
+ }
695
+ /**
696
+ * Histogram: a stacked bar per bucket over time — BarChart's time-bucketed
697
+ * form, taking buckets of parts rather than series. `tones` maps a part name
698
+ * to its tone (e.g. error → crit) so a spike reads without a legend; untoned
699
+ * parts take the ramp.
700
+ *
701
+ * `formatValue` says what a number is ("2.4 GB") on the axis and in the
702
+ * tooltip, which breaks each bar's total into parts. `formatTime` overrides
703
+ * how the axis and tooltips read a bucket's start, which otherwise follows the
704
+ * span in local time.
705
+ */
706
+ export function Histogram({ buckets, tones = {}, height, size, label = "Activity over time", empty = "No activity in range", formatValue, formatTime, onZoom, markers }) {
707
+ const series = useMemo(() => {
708
+ const order = [];
709
+ for (const b of buckets)
710
+ for (const p of b.parts)
711
+ if (!order.includes(p.name))
712
+ order.push(p.name);
713
+ return order.map((name) => ({
714
+ name,
715
+ tone: tones[name],
716
+ points: buckets.map((b) => ({ t: b.t, v: b.parts.find((p) => p.name === name)?.value ?? 0 })),
717
+ }));
718
+ }, [buckets, tones]);
719
+ return (_jsx(TimePlot, { kind: "bars", series: series, label: label, empty: empty, total: true, formatValue: formatValue ?? fmtCount, formatTime: formatTime, height: height, size: size, onZoom: onZoom, markers: markers, legend: series.length > 1 && (height === undefined || height >= 120) }));
269
720
  }
270
721
  /**
271
722
  * Lines the series up on the union of their timestamps. A series with no point
@@ -285,116 +736,24 @@ export function stackSeries(series) {
285
736
  return { t, values, total: values.reduce((a, b) => a + b, 0) };
286
737
  });
287
738
  }
288
- // Categorical hues in series order: a band keeps its colour wherever the
289
- // series sits in a list, and a series named "Other" is the neutral one.
290
- const BANDS = [v.cat1, v.cat2, v.cat3, v.cat4, v.cat5, v.cat6, v.cat7, v.cat8];
291
- const bandColour = (name, i) => (name === "Other" ? v.faint : BANDS[i % BANDS.length]);
292
- const AreaPlot = styled.svg `
293
- display: block; width: 100%; height: ${(p) => p.$h}px; cursor: crosshair; user-select: none;
294
- &:focus-visible { outline: 2px solid ${v.ring}; outline-offset: 2px; }
295
- `;
296
- const AreaTip = styled.div `
297
- position: absolute; top: 6px; min-width: 190px; pointer-events: none;
298
- background: ${v.surface2}; border: 1px solid ${v.line2}; border-radius: ${v.rCard}; box-shadow: ${v.elev};
299
- padding: 7px 9px; font-size: 11px; color: ${v.muted}; z-index: ${v.zPop};
300
- .t { color: ${v.faint}; margin-bottom: 4px; }
301
- `;
302
- const AreaTipRow = styled.div `
303
- display: flex; align-items: center; gap: 6px; line-height: 1.7;
304
- ${(p) => (p.$c ? `&::before { content: ""; width: 8px; height: 3px; border-radius: 2px; background: ${p.$c}; }` : "")}
305
- .val { margin-left: auto; padding-left: 12px; color: ${v.text}; font-variant-numeric: tabular-nums; font-weight: ${(p) => (p.$strong ? 600 : 400)}; }
306
- `;
307
- const AreaLegend = styled.ul `
308
- display: flex; flex-wrap: wrap; gap: 4px 14px; list-style: none; margin: 8px 0 0; padding: 0 0 0 44px;
309
- font-size: 12px; color: ${v.muted};
310
- li { display: inline-flex; align-items: center; gap: 6px; }
311
- i { width: 10px; height: 10px; border-radius: 2px; }
312
- `;
313
- const AREA_M = { l: 44, r: 12, t: 10, b: 24 };
314
- /** The smallest of 1, 2, 2.5, 5 × 10ⁿ that is at least `raw`. */
315
- function niceStep(raw) {
316
- if (!(raw > 0))
317
- return 1;
318
- const mag = 10 ** Math.floor(Math.log10(raw));
319
- for (const m of [1, 2, 2.5, 5, 10])
320
- if (m * mag >= raw)
321
- return m * mag;
322
- return 10 * mag;
323
- }
739
+ const isOther = (name) => name === "Other";
324
740
  /**
325
741
  * StackedAreaChart: n series stacked over a shared time axis, so the whole and
326
742
  * each part's share of it read together (the cells in conformance by domain,
327
- * requests by route). Series are ChartSeries as LineChart takes them, lined up
328
- * on the union of their timestamps (`stackSeries`); bands take the categorical
329
- * hues in order and the legend names them (a series called "Other" is the
330
- * neutral one; fold a long tail into it rather than drawing a ninth hue).
743
+ * requests by version). Series are lined up on the union of their timestamps
744
+ * (`stackSeries`), holding a series' last value; bands take the ramp in order
745
+ * and the legend names them (a series called "Other" is the neutral one; fold
746
+ * a long tail into it rather than drawing a ninth hue).
331
747
  *
332
748
  * Hovering, or the arrow keys once it has focus, reads every band and the total
333
- * at one instant. `max` caps the value axis (100 for percentages); otherwise
334
- * it fits the tallest stack. `formatValue` and `formatTime` read as in
335
- * LineChart. Show the values in a table too: the chart is not the only way in.
749
+ * at one instant. `max` caps the value axis (100 for percentages). Show the
750
+ * values in a table too: the chart is not the only way in.
336
751
  */
337
- export function StackedAreaChart({ series, max, height = 300, label = "Stacked series", empty = "No samples in range", formatValue, formatTime }) {
338
- const ref = useRef(null);
339
- const [width, setWidth] = useState(600);
340
- const [at, setAt] = useState(null);
341
- const moments = useMemo(() => stackSeries(series), [series]);
342
- const drawn = moments.length > 0;
343
- // The plot is drawn at its real width so its text stays crisp; where there is no
344
- // ResizeObserver (a server render, a test DOM) it keeps the default.
345
- useEffect(() => {
346
- const el = ref.current;
347
- if (!el || typeof ResizeObserver === "undefined")
348
- return;
349
- const ro = new ResizeObserver(() => setWidth(Math.max(280, el.getBoundingClientRect().width)));
350
- ro.observe(el);
351
- return () => ro.disconnect();
352
- }, [drawn]);
353
- if (!drawn)
354
- return _jsx(Wrap, { children: _jsx(Note, { children: empty }) });
355
- const t0 = moments[0].t;
356
- const t1 = moments[moments.length - 1].t;
357
- const span = Math.max(1, t1 - t0);
358
- const tallest = Math.max(...moments.map((m) => m.total), 0);
359
- const step = niceStep((Math.max(tallest * 1.1, 1)) / 4);
360
- const top = Math.min(max ?? Infinity, Math.ceil((Math.max(tallest * 1.1, 1)) / step) * step);
361
- const gridValues = [];
362
- for (let g = 0; g <= top + 1e-9; g += step)
363
- gridValues.push(Math.round(g * 100) / 100);
364
- if (gridValues[gridValues.length - 1] !== top)
365
- gridValues.push(top);
366
- const innerW = width - AREA_M.l - AREA_M.r;
367
- const x = (t) => AREA_M.l + ((t - t0) / span) * innerW;
368
- const y = (val) => height - AREA_M.b - (Math.min(val, top) / top) * (height - AREA_M.t - AREA_M.b);
369
- const cum = moments.map((m) => m.values.reduce((acc, val) => [...acc, (acc[acc.length - 1] ?? 0) + val], []));
370
- const area = (i) => {
371
- const upper = moments.map((m, k) => `${x(m.t)},${y(cum[k][i])}`);
372
- const lower = moments.map((m, k) => `${x(m.t)},${y(i === 0 ? 0 : cum[k][i - 1])}`).reverse();
373
- return `M${upper.join("L")}L${lower.join("L")}Z`;
374
- };
375
- const fmtV = formatValue ?? tickValue;
376
- const tick = formatTime ?? timeTick(span);
377
- const detail = formatTime ?? ((t) => new Date(t).toLocaleString());
378
- const nearest = (t) => {
379
- let best = 0;
380
- for (let i = 1; i < moments.length; i++)
381
- if (Math.abs(moments[i].t - t) < Math.abs(moments[best].t - t))
382
- best = i;
383
- return best;
384
- };
385
- const fromPointer = (clientX) => {
386
- const rect = ref.current?.getBoundingClientRect();
387
- if (rect)
388
- setAt(nearest(t0 + ((clientX - rect.left - AREA_M.l) / Math.max(1, innerW)) * span));
389
- };
390
- const onKey = (e) => {
391
- if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
392
- return;
393
- e.preventDefault();
394
- setAt((cur) => Math.min(moments.length - 1, Math.max(0, (cur ?? moments.length - 1) + (e.key === "ArrowRight" ? 1 : -1))));
395
- };
396
- const xTicks = Array.from({ length: 5 }, (_, k) => t0 + (span * k) / 4);
397
- const m = at === null ? null : moments[Math.min(at, moments.length - 1)];
398
- return (_jsxs(Wrap, { children: [_jsxs(Plot, { children: [_jsxs(AreaPlot, { ref: ref, "$h": height, viewBox: `0 0 ${width} ${height}`, role: "img", tabIndex: 0, "aria-label": `${label}. Arrow keys step through the points.`, onPointerMove: (e) => fromPointer(e.clientX), onPointerLeave: () => setAt(null), onKeyDown: onKey, onFocus: () => setAt((cur) => cur ?? moments.length - 1), onBlur: () => setAt(null), children: [gridValues.map((g) => (_jsxs("g", { children: [_jsx("line", { x1: AREA_M.l, x2: width - AREA_M.r, y1: y(g), y2: y(g), stroke: v.line, strokeWidth: 1 }), _jsx("text", { x: AREA_M.l - 8, y: y(g), textAnchor: "end", dominantBaseline: "middle", fontSize: 11, fill: v.faint, children: fmtV(g) })] }, g))), xTicks.map((tk, k) => (_jsx("text", { x: x(tk), y: height - 6, fontSize: 11, fill: v.faint, textAnchor: k === 0 ? "start" : k === xTicks.length - 1 ? "end" : "middle", children: tick(tk) }, tk))), series.map((s, i) => (_jsx("path", { d: area(i), fill: bandColour(s.name, i), fillOpacity: m ? 0.92 : 0.85, stroke: v.surface, strokeWidth: 1.5, strokeLinejoin: "round" }, s.name))), m ? (_jsxs("g", { children: [_jsx("line", { x1: x(m.t), x2: x(m.t), y1: AREA_M.t, y2: height - AREA_M.b, stroke: v.text, strokeOpacity: 0.5, strokeWidth: 1 }), _jsx("circle", { cx: x(m.t), cy: y(m.total), r: 4, fill: v.text, stroke: v.surface, strokeWidth: 2 })] })) : null] }), m ? (_jsxs(AreaTip, { style: x(m.t) > width / 2 ? { right: width - x(m.t) + 12 } : { left: x(m.t) + 12 }, children: [_jsx("div", { className: "t", children: detail(m.t) }), _jsxs(AreaTipRow, { "$strong": true, children: ["Total", _jsx("span", { className: "val", children: fmtV(m.total) })] }), [...series.keys()].reverse().map((i) => (_jsxs(AreaTipRow, { "$c": bandColour(series[i].name, i), children: [series[i].name, _jsx("span", { className: "val", children: fmtV(m.values[i]) })] }, series[i].name)))] })) : null] }), series.length > 1 ? (_jsx(AreaLegend, { "aria-label": "Series", children: series.map((s, i) => _jsxs("li", { children: [_jsx("i", { style: { background: bandColour(s.name, i) } }), s.name] }, s.name)) })) : null] }));
752
+ export function StackedAreaChart({ series, max, height, size, label = "Stacked series", empty = "No samples in range", formatValue, formatTime, markers, onZoom }) {
753
+ const lines = useMemo(() => {
754
+ const moments = stackSeries(series);
755
+ return series.map((s, i) => ({ name: s.name, points: moments.map((m) => ({ t: m.t, v: m.values[i] })) }));
756
+ }, [series]);
757
+ return (_jsx(TimePlot, { kind: "line", stacked: true, total: true, series: lines, max: max, height: height, size: size ?? (height === undefined ? "l" : undefined), label: label, empty: empty, formatValue: formatValue, formatTime: formatTime, bandOpacity: 0.85, neutral: isOther, markers: markers, onZoom: onZoom, legend: series.length > 1 }));
399
758
  }
400
759
  //# sourceMappingURL=Charts.js.map