@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.
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/kit/ChartFrame.d.ts +86 -0
- package/dist/kit/ChartFrame.d.ts.map +1 -0
- package/dist/kit/ChartFrame.js +176 -0
- package/dist/kit/ChartFrame.js.map +1 -0
- package/dist/kit/ChartTypes.d.ts +163 -0
- package/dist/kit/ChartTypes.d.ts.map +1 -0
- package/dist/kit/ChartTypes.js +327 -0
- package/dist/kit/ChartTypes.js.map +1 -0
- package/dist/kit/Charts.d.ts +173 -65
- package/dist/kit/Charts.d.ts.map +1 -1
- package/dist/kit/Charts.js +667 -308
- package/dist/kit/Charts.js.map +1 -1
- package/dist/kit/Sparkline.d.ts +8 -1
- package/dist/kit/Sparkline.d.ts.map +1 -1
- package/dist/kit/Sparkline.js +6 -4
- package/dist/kit/Sparkline.js.map +1 -1
- package/dist/kit/chartScale.d.ts +79 -0
- package/dist/kit/chartScale.d.ts.map +1 -0
- package/dist/kit/chartScale.js +214 -0
- package/dist/kit/chartScale.js.map +1 -0
- package/dist/theme/tokens.d.ts +16 -0
- package/dist/theme/tokens.d.ts.map +1 -1
- package/dist/theme/tokens.js +49 -0
- package/dist/theme/tokens.js.map +1 -1
- package/package.json +1 -1
package/dist/kit/Charts.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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:
|
|
89
|
+
border-radius: 8px;
|
|
134
90
|
box-shadow: ${v.elev};
|
|
135
91
|
padding: 7px 9px;
|
|
136
92
|
font-size: 11px;
|
|
137
|
-
color: ${v.
|
|
138
|
-
white-space: nowrap;
|
|
93
|
+
color: ${v.muted};
|
|
139
94
|
z-index: ${v.zPop};
|
|
140
|
-
.t { color: ${v.faint}; margin-bottom: 4px; }
|
|
141
|
-
.
|
|
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:
|
|
145
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
162
|
-
|
|
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:
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 [
|
|
154
|
+
const [hover, setHover] = useState(null);
|
|
194
155
|
const [drag, setDrag] = useState(null);
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 =
|
|
251
|
-
return
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
}
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
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))
|
|
268
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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)
|
|
334
|
-
*
|
|
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
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|