@surflane/ui 0.11.0 → 0.12.0
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/app-shell.d.ts +10 -0
- package/dist/app-shell.js +7 -1
- package/dist/bar-list.d.ts +31 -0
- package/dist/bar-list.js +25 -0
- package/dist/bottom-navigation.d.ts +6 -2
- package/dist/bottom-navigation.js +2 -2
- package/dist/chart.d.ts +56 -3
- package/dist/chart.js +266 -48
- package/dist/css/bar-list.css +9 -0
- package/dist/css/chart.css +2 -0
- package/dist/css/dashboard.css +20 -0
- package/dist/css/rules/alert-dialog.css +4 -0
- package/dist/css/rules/app-shell.css +10 -0
- package/dist/css/rules/bar-list.css +101 -0
- package/dist/css/rules/bottom-navigation.css +16 -0
- package/dist/css/rules/card.css +5 -0
- package/dist/css/rules/chart.css +74 -3
- package/dist/css/rules/dashboard.css +66 -0
- package/dist/css/rules/dialog.css +11 -0
- package/dist/css/rules/filter-bar.css +8 -1
- package/dist/css/rules/panel.css +9 -1
- package/dist/css/rules/select.css +25 -1
- package/dist/css/rules/sizing.css +10 -0
- package/dist/css/rules/stat.css +57 -0
- package/dist/css/rules/time-range-picker.css +14 -0
- package/dist/css/styles.css +3 -0
- package/dist/css/time-range-picker.css +14 -0
- package/dist/dashboard.d.ts +46 -0
- package/dist/dashboard.js +35 -0
- package/dist/icon.d.ts +10 -0
- package/dist/icon.js +11 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/select.d.ts +33 -6
- package/dist/select.js +94 -21
- package/dist/stat.d.ts +20 -0
- package/dist/stat.js +15 -1
- package/dist/time-range-picker.d.ts +42 -0
- package/dist/time-range-picker.js +69 -0
- package/package.json +18 -3
package/dist/chart.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { Area, Bar, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, useActiveTooltipCoordinate, useActiveTooltipLabel, useIsTooltipActive, usePlotArea, } from 'recharts';
|
|
4
|
+
import { Area, Bar, CartesianGrid, ComposedChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, useActiveTooltipCoordinate, useActiveTooltipLabel, useIsTooltipActive, usePlotArea, } from 'recharts';
|
|
5
5
|
import { createPortal } from 'react-dom';
|
|
6
6
|
import { Table } from './table.js';
|
|
7
7
|
import { Button } from './button.js';
|
|
8
|
+
import { Popover } from './popover.js';
|
|
8
9
|
import { cx } from './parts.js';
|
|
9
10
|
const chartLabels = {
|
|
10
11
|
missing: 'Not available',
|
|
@@ -20,6 +21,8 @@ const chartLabels = {
|
|
|
20
21
|
noRows: 'No rows.',
|
|
21
22
|
legend: (title) => `${title} series`,
|
|
22
23
|
series: (label) => `${label} series`,
|
|
24
|
+
moreSeries: (count) => `${count} more series`,
|
|
25
|
+
total: 'Total',
|
|
23
26
|
};
|
|
24
27
|
/** The round step at or above a raw step: 1, 2, 2.5 or 5 times a power of ten. */
|
|
25
28
|
function roundStep(raw) {
|
|
@@ -37,6 +40,83 @@ function axisStep(low, high) {
|
|
|
37
40
|
return step;
|
|
38
41
|
}
|
|
39
42
|
const dashes = [undefined, '7 4', '2 4', '10 3 2 3', '12 5', '4 3'];
|
|
43
|
+
/** A series' color: the caller's, or the palette's for its place. */
|
|
44
|
+
function seriesColor(index, palette, color) {
|
|
45
|
+
return (color ??
|
|
46
|
+
(palette === 'categorical'
|
|
47
|
+
? `var(--surf-chart-category-${(index % 12) + 1})`
|
|
48
|
+
: `var(--surf-chart-series-${(index % 3) + 1})`));
|
|
49
|
+
}
|
|
50
|
+
const toneColors = {
|
|
51
|
+
neutral: 'var(--surf-ink-muted)',
|
|
52
|
+
caution: 'var(--surf-status-caution-fg)',
|
|
53
|
+
danger: 'var(--surf-status-danger-fg)',
|
|
54
|
+
};
|
|
55
|
+
const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
|
|
56
|
+
const timeSteps = [
|
|
57
|
+
['minute', 1, MINUTE],
|
|
58
|
+
['minute', 5, 5 * MINUTE],
|
|
59
|
+
['minute', 15, 15 * MINUTE],
|
|
60
|
+
['minute', 30, 30 * MINUTE],
|
|
61
|
+
['hour', 1, HOUR],
|
|
62
|
+
['hour', 3, 3 * HOUR],
|
|
63
|
+
['hour', 6, 6 * HOUR],
|
|
64
|
+
['hour', 12, 12 * HOUR],
|
|
65
|
+
['day', 1, DAY],
|
|
66
|
+
['day', 2, 2 * DAY],
|
|
67
|
+
['day', 7, 7 * DAY],
|
|
68
|
+
['month', 1, 30 * DAY],
|
|
69
|
+
['month', 3, 91 * DAY],
|
|
70
|
+
['month', 6, 182 * DAY],
|
|
71
|
+
['year', 1, 365 * DAY],
|
|
72
|
+
];
|
|
73
|
+
/** Ticks on round local times, such as every 6 hours or the first of each month, at
|
|
74
|
+
* most six across the span. */
|
|
75
|
+
function timeTicks(min, max) {
|
|
76
|
+
const [unit, count] = timeSteps.find(([, , ms]) => (max - min) / ms <= 6) ??
|
|
77
|
+
timeSteps[timeSteps.length - 1];
|
|
78
|
+
const date = new Date(min);
|
|
79
|
+
date.setSeconds(0, 0);
|
|
80
|
+
if (unit === 'minute')
|
|
81
|
+
date.setMinutes(Math.floor(date.getMinutes() / count) * count);
|
|
82
|
+
else
|
|
83
|
+
date.setMinutes(0);
|
|
84
|
+
if (unit === 'hour')
|
|
85
|
+
date.setHours(Math.floor(date.getHours() / count) * count);
|
|
86
|
+
else if (unit !== 'minute')
|
|
87
|
+
date.setHours(0);
|
|
88
|
+
if (unit === 'month')
|
|
89
|
+
date.setMonth(Math.floor(date.getMonth() / count) * count, 1);
|
|
90
|
+
if (unit === 'year')
|
|
91
|
+
date.setMonth(0, 1);
|
|
92
|
+
const ticks = [];
|
|
93
|
+
while (date.getTime() <= max && ticks.length < 100) {
|
|
94
|
+
if (date.getTime() >= min)
|
|
95
|
+
ticks.push(date.getTime());
|
|
96
|
+
if (unit === 'minute')
|
|
97
|
+
date.setMinutes(date.getMinutes() + count);
|
|
98
|
+
else if (unit === 'hour')
|
|
99
|
+
date.setHours(date.getHours() + count);
|
|
100
|
+
else if (unit === 'day')
|
|
101
|
+
date.setDate(date.getDate() + count);
|
|
102
|
+
else if (unit === 'month')
|
|
103
|
+
date.setMonth(date.getMonth() + count);
|
|
104
|
+
else
|
|
105
|
+
date.setFullYear(date.getFullYear() + count);
|
|
106
|
+
}
|
|
107
|
+
return { ticks, unit };
|
|
108
|
+
}
|
|
109
|
+
/** Powers of ten that span low to high, with 2 and 5 between them across one decade. */
|
|
110
|
+
function logTicks(low, high) {
|
|
111
|
+
const bottom = Math.floor(Math.log10(low)), top = Math.max(Math.ceil(Math.log10(high)), bottom + 1);
|
|
112
|
+
const ticks = [];
|
|
113
|
+
for (let power = bottom; power <= top; power++)
|
|
114
|
+
for (const factor of top - bottom === 1 ? [1, 2, 5] : [1])
|
|
115
|
+
if (power < top || factor === 1)
|
|
116
|
+
ticks.push(Number((factor * 10 ** power).toPrecision(12)));
|
|
117
|
+
return ticks;
|
|
118
|
+
}
|
|
119
|
+
const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
40
120
|
/** True once the font of the tick labels has loaded. Recharts measures a label to keep
|
|
41
121
|
* it inside the chart and caches that width; measured in the fallback font, the last
|
|
42
122
|
* label overflows by a few pixels once the real font arrives. */
|
|
@@ -79,6 +159,15 @@ function Pattern({ id, index, color, }) {
|
|
|
79
159
|
function SeriesMark({ type, index, color, pattern, }) {
|
|
80
160
|
return (_jsx("svg", { className: 'surf-chart-mark', viewBox: '0 0 32 20', "aria-hidden": 'true', focusable: 'false', children: type === 'bar' ? (_jsxs(_Fragment, { children: [_jsx("defs", { children: _jsx(Pattern, { id: pattern, color: color, index: index }) }), _jsx("rect", { x: '4', y: '3', width: '24', height: '14', rx: '2', fill: `url(#${pattern})` })] })) : (_jsxs(_Fragment, { children: [_jsx("path", { d: 'M1 10h30', stroke: color, strokeWidth: '2', strokeDasharray: dashes[index % dashes.length] }), _jsx(Marker, { cx: 16, cy: 10, index: index, color: color })] })) }));
|
|
81
161
|
}
|
|
162
|
+
function ReferenceMark({ reference }) {
|
|
163
|
+
const color = toneColors[reference.tone ?? 'neutral'];
|
|
164
|
+
return (_jsx("svg", { className: 'surf-chart-mark', viewBox: '0 0 32 20', "aria-hidden": 'true', focusable: 'false', children: 'y' in reference ? (_jsx("path", { d: 'M1 10h30', stroke: color, strokeWidth: '2', strokeDasharray: '4 3' })) : (_jsx("rect", { x: '4', y: '4', width: '24', height: '12', rx: '2', fill: color, fillOpacity: '0.2' })) }));
|
|
165
|
+
}
|
|
166
|
+
/** A series' swatch as Chart's legend draws it, for a legend or key of your own. */
|
|
167
|
+
export function ChartSeriesMark({ type = 'line', index, palette = 'neutral', color, }) {
|
|
168
|
+
const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
169
|
+
return (_jsx(SeriesMark, { type: type, index: index, color: seriesColor(index, palette, color), pattern: `${id}-mark` }));
|
|
170
|
+
}
|
|
82
171
|
/** The active point's values beside it, for pointer and touch users. The readout
|
|
83
172
|
* below the plot announces the same values, so this copy stays out of the
|
|
84
173
|
* accessibility tree. It sits on the side of the point with more room and stays
|
|
@@ -95,22 +184,26 @@ function FloatingValues({ host, hidden, render, }) {
|
|
|
95
184
|
if (shown && box.current)
|
|
96
185
|
setWidth(box.current.offsetWidth);
|
|
97
186
|
}, [shown, label, plot?.width]);
|
|
98
|
-
|
|
187
|
+
const content = shown ? render(String(label)) : null;
|
|
188
|
+
// A break in sampling has no values to show.
|
|
189
|
+
if (!shown || !point || !plot || content === null)
|
|
99
190
|
return null;
|
|
100
191
|
// The gap between the point and the box, in px: the --surf-space-3 step.
|
|
101
192
|
const gap = 12;
|
|
102
193
|
const start = point.x > plot.x + plot.width / 2;
|
|
103
194
|
const left = Math.min(Math.max(start ? point.x - gap - width : point.x + gap, plot.x), plot.x + plot.width - width);
|
|
104
|
-
return createPortal(_jsx("div", { ref: box, className: 'surf-chart-tooltip', "aria-hidden": 'true', style: { left, top: plot.y, visibility: width ? undefined : 'hidden' }, children:
|
|
195
|
+
return createPortal(_jsx("div", { ref: box, className: 'surf-chart-tooltip', "aria-hidden": 'true', style: { left, top: plot.y, visibility: width ? undefined : 'hidden' }, children: content }), host);
|
|
105
196
|
}
|
|
106
197
|
/** Cartesian trends with a persistent, keyboard-readable tooltip region and a
|
|
107
198
|
* full data-table alternative. Missing values are gaps, never fabricated zeros. */
|
|
108
|
-
export const Chart = function Chart({ title, description, data, series, unit, xLabel, yLabel, type = 'line', height
|
|
199
|
+
export const Chart = function Chart({ title, description, data, series, unit, xLabel, yLabel, type = 'line', height, includeZero = true, palette = 'neutral', xScale = 'category', yScale = 'linear', curve = 'linear', stacked = false, references = [], density = 'default', legendPosition = 'top', legendLimit, locale = 'en-US', formatValue, labels, hiddenSeries, defaultHiddenSeries = [], onHiddenSeriesChange, className, ref, ...props }) {
|
|
109
200
|
const text = { ...chartLabels, ...labels };
|
|
110
201
|
const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
111
|
-
const colorFor = (index) => palette
|
|
112
|
-
|
|
113
|
-
|
|
202
|
+
const colorFor = (index) => seriesColor(index, palette, series[index]?.color);
|
|
203
|
+
const timeScale = xScale === 'time';
|
|
204
|
+
const logScale = yScale === 'log';
|
|
205
|
+
const stacks = stacked && type !== 'line';
|
|
206
|
+
const compact = density === 'compact';
|
|
114
207
|
const [internalHidden, setInternalHidden] = React.useState(defaultHiddenSeries);
|
|
115
208
|
const hidden = new Set(hiddenSeries ?? internalHidden);
|
|
116
209
|
const visible = series.filter((s) => !hidden.has(s.key));
|
|
@@ -118,6 +211,8 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
|
|
|
118
211
|
const fontsReady = useFontReady(host, data.map((d) => d.label).join(''));
|
|
119
212
|
const [floatHost, setFloatHost] = React.useState(null);
|
|
120
213
|
const [dismissed, setDismissed] = React.useState(false);
|
|
214
|
+
// The table renders once it is first opened, so a page of charts stays light.
|
|
215
|
+
const [tableOpened, setTableOpened] = React.useState(false);
|
|
121
216
|
const firstLegend = React.useRef(null);
|
|
122
217
|
const [restoreRequested, setRestoreRequested] = React.useState(false);
|
|
123
218
|
React.useLayoutEffect(() => {
|
|
@@ -128,13 +223,20 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
|
|
|
128
223
|
setRestoreRequested(false);
|
|
129
224
|
}, [restoreRequested, visible.length]);
|
|
130
225
|
const number = React.useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }), [locale]);
|
|
131
|
-
const
|
|
226
|
+
const compactNumber = React.useMemo(() => new Intl.NumberFormat(locale, {
|
|
132
227
|
notation: 'compact',
|
|
133
228
|
maximumFractionDigits: 1,
|
|
134
229
|
}), [locale]);
|
|
135
|
-
const
|
|
136
|
-
?
|
|
137
|
-
|
|
230
|
+
const formatNumber = (value) => isNumber(value)
|
|
231
|
+
? formatValue
|
|
232
|
+
? formatValue(value)
|
|
233
|
+
: number.format(value)
|
|
234
|
+
: null;
|
|
235
|
+
// The readout names the unit after each value; the table names it once, in the header.
|
|
236
|
+
const format = (value) => {
|
|
237
|
+
const formatted = formatNumber(value);
|
|
238
|
+
return formatted === null ? text.missing : `${formatted} ${unit}`;
|
|
239
|
+
};
|
|
138
240
|
const rows = React.useMemo(() => {
|
|
139
241
|
const ids = new Set(), keys = new Set();
|
|
140
242
|
for (const s of series) {
|
|
@@ -142,38 +244,130 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
|
|
|
142
244
|
throw new Error('Chart series keys must be non-empty and unique');
|
|
143
245
|
keys.add(s.key);
|
|
144
246
|
}
|
|
145
|
-
|
|
247
|
+
const result = data.map((row) => {
|
|
146
248
|
if (!row.id || ids.has(row.id))
|
|
147
249
|
throw new Error('Chart datum IDs must be non-empty and unique');
|
|
148
250
|
ids.add(row.id);
|
|
251
|
+
const time = row.time === undefined ? undefined : Number(row.time);
|
|
252
|
+
if (timeScale && !isNumber(time))
|
|
253
|
+
throw new Error('Chart rows need a time when xScale is "time"');
|
|
149
254
|
return {
|
|
150
255
|
...row,
|
|
256
|
+
time,
|
|
151
257
|
values: Object.fromEntries(series.map((s) => [
|
|
152
258
|
s.key,
|
|
153
|
-
|
|
154
|
-
Number.isFinite(row.values[s.key])
|
|
155
|
-
? row.values[s.key]
|
|
156
|
-
: null,
|
|
259
|
+
isNumber(row.values[s.key]) ? row.values[s.key] : null,
|
|
157
260
|
])),
|
|
158
261
|
};
|
|
159
262
|
});
|
|
160
|
-
|
|
263
|
+
return timeScale ? result.sort((a, b) => a.time - b.time) : result;
|
|
264
|
+
}, [data, series, timeScale]);
|
|
161
265
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
266
|
+
const byTime = new Map(rows.map((row) => [row.time, row]));
|
|
267
|
+
const rowFor = (label) => timeScale ? byTime.get(Number(label)) : byId.get(String(label));
|
|
268
|
+
// On a time axis, a wait of more than twice the usual interval is a break in
|
|
269
|
+
// sampling: an empty row there keeps the line from bridging it.
|
|
270
|
+
const interval = React.useMemo(() => {
|
|
271
|
+
if (!timeScale || rows.length < 3)
|
|
272
|
+
return 0;
|
|
273
|
+
const steps = rows
|
|
274
|
+
.slice(1)
|
|
275
|
+
.map((row, index) => row.time - rows[index].time)
|
|
276
|
+
.sort((a, b) => a - b);
|
|
277
|
+
return steps[Math.floor(steps.length / 2)];
|
|
278
|
+
}, [rows, timeScale]);
|
|
279
|
+
const plotRows = React.useMemo(() => {
|
|
280
|
+
if (!interval)
|
|
281
|
+
return rows;
|
|
282
|
+
const empty = Object.fromEntries(series.map((s) => [s.key, null]));
|
|
283
|
+
return rows.flatMap((row, index) => {
|
|
284
|
+
const next = rows[index + 1];
|
|
285
|
+
return next && next.time - row.time > interval * 2
|
|
286
|
+
? [
|
|
287
|
+
row,
|
|
288
|
+
{
|
|
289
|
+
...row,
|
|
290
|
+
id: `${row.id}-gap`,
|
|
291
|
+
time: row.time + interval,
|
|
292
|
+
values: empty,
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
: [row];
|
|
296
|
+
});
|
|
297
|
+
}, [rows, series, interval]);
|
|
298
|
+
const valueOf = (row, key) => {
|
|
299
|
+
const value = row.values[key];
|
|
300
|
+
return value === null || (logScale && value <= 0) ? null : value;
|
|
301
|
+
};
|
|
302
|
+
// The value axis spans the visible values (their sums when stacked) and the
|
|
303
|
+
// references, widened to round steps so its ticks read cleanly.
|
|
162
304
|
const values = rows
|
|
163
|
-
.flatMap((row) => visible.map((s) => row
|
|
164
|
-
.filter(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
(
|
|
176
|
-
|
|
305
|
+
.flatMap((row) => visible.map((s) => valueOf(row, s.key)))
|
|
306
|
+
.filter(isNumber);
|
|
307
|
+
const sums = stacks
|
|
308
|
+
? rows.flatMap((row) => {
|
|
309
|
+
const parts = visible.map((s) => valueOf(row, s.key)).filter(isNumber);
|
|
310
|
+
return [
|
|
311
|
+
parts.filter((v) => v > 0).reduce((a, b) => a + b, 0),
|
|
312
|
+
parts.filter((v) => v < 0).reduce((a, b) => a + b, 0),
|
|
313
|
+
];
|
|
314
|
+
})
|
|
315
|
+
: values;
|
|
316
|
+
const referenceValues = references
|
|
317
|
+
.flatMap((r) => ('y' in r ? [r.y] : [r.from, r.to]))
|
|
318
|
+
.filter((v) => isNumber(v) && (!logScale || v > 0));
|
|
319
|
+
const extent = [...sums, ...(values.length ? referenceValues : [])];
|
|
320
|
+
// Bars grow from zero, so they always keep it on a linear axis.
|
|
321
|
+
const keepZero = !logScale && (includeZero || type === 'bar');
|
|
322
|
+
const start = keepZero ? 0 : (extent[0] ?? 0);
|
|
323
|
+
const low = extent.reduce((a, b) => Math.min(a, b), start), high = extent.reduce((a, b) => Math.max(a, b), start);
|
|
324
|
+
let min = low, max = high, ticks;
|
|
325
|
+
if (logScale) {
|
|
326
|
+
ticks = logTicks(low > 0 ? low : 1, high > 0 ? high : 10);
|
|
327
|
+
min = ticks[0];
|
|
328
|
+
max = ticks[ticks.length - 1];
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
const step = axisStep(low, high);
|
|
332
|
+
if (step) {
|
|
333
|
+
min = Math.floor(low / step) * step;
|
|
334
|
+
max = Math.ceil(high / step) * step;
|
|
335
|
+
ticks = Array.from({ length: Math.round((max - min) / step) + 1 },
|
|
336
|
+
// Rounded, so float steps never print as 0.30000000000000004.
|
|
337
|
+
(_, index) => Number((min + index * step).toPrecision(12)));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const time = React.useMemo(() => {
|
|
341
|
+
if (!timeScale || !rows.length)
|
|
342
|
+
return undefined;
|
|
343
|
+
const first = rows[0].time, last = rows[rows.length - 1].time;
|
|
344
|
+
const { ticks: timeAxisTicks, unit: timeUnit } = timeTicks(first, last);
|
|
345
|
+
// Bars sit on their time, so half an interval on each side keeps the end ones whole.
|
|
346
|
+
const pad = type === 'bar' ? (interval || DAY) / 2 : 0;
|
|
347
|
+
const subDay = timeUnit === 'minute' || timeUnit === 'hour';
|
|
348
|
+
const tickFormat = new Intl.DateTimeFormat(locale, subDay
|
|
349
|
+
? { hour: 'numeric', minute: '2-digit' }
|
|
350
|
+
: timeUnit === 'day'
|
|
351
|
+
? { month: 'short', day: 'numeric' }
|
|
352
|
+
: timeUnit === 'month'
|
|
353
|
+
? { month: 'short', year: 'numeric' }
|
|
354
|
+
: { year: 'numeric' });
|
|
355
|
+
const dayFormat = new Intl.DateTimeFormat(locale, {
|
|
356
|
+
month: 'short',
|
|
357
|
+
day: 'numeric',
|
|
358
|
+
});
|
|
359
|
+
return {
|
|
360
|
+
domain: [first - pad, last + pad],
|
|
361
|
+
ticks: timeAxisTicks,
|
|
362
|
+
// Within a day the ticks name hours; one at midnight names the day instead.
|
|
363
|
+
format: (value) => {
|
|
364
|
+
const date = new Date(value);
|
|
365
|
+
return subDay && date.getHours() === 0 && date.getMinutes() === 0
|
|
366
|
+
? dayFormat.format(date)
|
|
367
|
+
: tickFormat.format(date);
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}, [rows, timeScale, type, interval, locale]);
|
|
177
371
|
const hasValues = values.length > 0;
|
|
178
372
|
const changeHidden = (next) => {
|
|
179
373
|
if (hiddenSeries === undefined)
|
|
@@ -183,14 +377,34 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
|
|
|
183
377
|
const toggle = (key) => changeHidden(series
|
|
184
378
|
.filter((s) => (s.key === key ? !hidden.has(key) : hidden.has(s.key)))
|
|
185
379
|
.map((s) => s.key));
|
|
186
|
-
const chartHeight =
|
|
187
|
-
|
|
380
|
+
const chartHeight = height !== undefined && Number.isFinite(height)
|
|
381
|
+
? Math.max(compact ? 120 : 200, height)
|
|
382
|
+
: compact
|
|
383
|
+
? 180
|
|
384
|
+
: 280;
|
|
385
|
+
const total = (row) => visible
|
|
386
|
+
.map((s) => row.values[s.key])
|
|
387
|
+
.filter(isNumber)
|
|
388
|
+
.reduce((a, b) => a + b, 0);
|
|
389
|
+
const referenceText = (r) => 'y' in r
|
|
390
|
+
? `${r.label} ${format(r.y)}`
|
|
391
|
+
: `${r.label} ${formatNumber(r.from) ?? text.missing}–${format(r.to)}`;
|
|
392
|
+
const legendButton = (s, index) => (_jsxs("button", { ref: index === 0 ? firstLegend : undefined, type: 'button', className: 'surf-chart-legend-button', "aria-label": text.series(s.label), "aria-pressed": !hidden.has(s.key), onClick: () => toggle(s.key), children: [_jsx(SeriesMark, { type: type, index: index, color: colorFor(index), pattern: `${id}-legend-${index}` }), _jsx("span", { children: s.label })] }));
|
|
393
|
+
const shown = legendLimit !== undefined && legendLimit < series.length
|
|
394
|
+
? series.slice(0, Math.max(0, legendLimit))
|
|
395
|
+
: series;
|
|
396
|
+
const folded = series.slice(shown.length);
|
|
397
|
+
const legend = (_jsxs("ul", { className: 'surf-chart-legend', "aria-label": text.legend(title), children: [shown.map((s, index) => (_jsx("li", { children: legendButton(s, index) }, s.key))), folded.length > 0 && (_jsx("li", { children: _jsxs(Popover.Root, { children: [_jsxs(Popover.Trigger, { className: 'surf-chart-legend-button surf-chart-legend-more', "aria-label": text.moreSeries(folded.length), children: ["+", folded.length] }), _jsx(Popover.Content, { size: 'small', children: _jsx("ul", { className: 'surf-chart-legend-list', children: folded.map((s, index) => (_jsx("li", { children: legendButton(s, shown.length + index) }, s.key))) }) })] }) })), references.map((reference, index) => (_jsxs("li", { className: 'surf-chart-legend-reference', children: [_jsx(ReferenceMark, { reference: reference }), _jsx("span", { children: referenceText(reference) })] }, index)))] }));
|
|
398
|
+
return (_jsxs("figure", { ...props, ref: ref, "data-density": density, className: cx('surf-chart', className), "aria-labelledby": `${id}-title`, children: [_jsxs("figcaption", { className: 'surf-chart-caption', children: [_jsx("strong", { id: `${id}-title`, children: title }), _jsx("p", { id: `${id}-summary`, children: description })] }), _jsxs("div", { className: 'surf-chart-frame', children: [_jsxs("div", { className: 'surf-chart-key', children: [_jsxs("p", { className: 'surf-chart-axis-label', children: [yLabel, " (", unit, ")"] }), legendPosition === 'top' && legend] }), hasValues ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: 'surf-chart-plot', onKeyDownCapture: (event) => {
|
|
188
399
|
if (event.key === 'Escape')
|
|
189
400
|
setDismissed(true);
|
|
190
401
|
else if (event.key === 'ArrowLeft' ||
|
|
191
402
|
event.key === 'ArrowRight')
|
|
192
403
|
setDismissed(false);
|
|
193
|
-
}, onMouseMoveCapture: () => setDismissed(false), onTouchStartCapture: () => setDismissed(false), onFocusCapture: () => setDismissed(false), children: [_jsx(ResponsiveContainer, { width: '100%', height: chartHeight, minWidth: 0, initialDimension: { width: 320, height: chartHeight }, children: _jsxs(ComposedChart, { data:
|
|
404
|
+
}, onMouseMoveCapture: () => setDismissed(false), onTouchStartCapture: () => setDismissed(false), onFocusCapture: () => setDismissed(false), children: [_jsx(ResponsiveContainer, { width: '100%', height: chartHeight, minWidth: 0, initialDimension: { width: 320, height: chartHeight }, children: _jsxs(ComposedChart, { data: plotRows, accessibilityLayer: true, role: 'application', "aria-label": title, "aria-describedby": `${id}-summary ${id}-keys`, margin: { top: 16, right: 20, bottom: 8, left: 0 }, baseValue: logScale ? min : undefined, children: [_jsx("defs", { children: series.map((s, index) => (_jsx(Pattern, { id: `${id}-pattern-${index}`, index: index, color: colorFor(index) }, s.key))) }), _jsx(CartesianGrid, { vertical: false, stroke: 'var(--surf-line)' }), references.map((reference, index) => 'y' in reference ? null : (_jsx(ReferenceArea, { y1: reference.from, y2: reference.to, fill: toneColors[reference.tone ?? 'neutral'], fillOpacity: 0.12, stroke: 'none' }, index))), time ? (_jsx(XAxis, { dataKey: 'time', type: 'number', scale: 'time', domain: time.domain, ticks: time.ticks, tickFormatter: (value) => time.format(Number(value)), tick: fontsReady && {
|
|
405
|
+
fill: 'var(--surf-ink-muted)',
|
|
406
|
+
fontSize: 12,
|
|
407
|
+
}, tickLine: false, axisLine: { stroke: 'var(--surf-line)' }, minTickGap: 24, interval: 'preserveStartEnd' })) : (_jsx(XAxis, { dataKey: 'id', tickFormatter: (value) => {
|
|
194
408
|
const label = byId.get(String(value))?.label ?? String(value);
|
|
195
409
|
// minTickGap already drops ticks that would overlap; the cap
|
|
196
410
|
// only keeps one very long label from crowding the axis.
|
|
@@ -200,9 +414,9 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
|
|
|
200
414
|
}, tick: fontsReady && {
|
|
201
415
|
fill: 'var(--surf-ink-muted)',
|
|
202
416
|
fontSize: 12,
|
|
203
|
-
}, tickLine: false, axisLine: { stroke: 'var(--surf-line)' }, minTickGap: 24, interval: 'preserveStartEnd' }), _jsx(YAxis, { width: 56, tickFormatter: (value) => formatValue
|
|
417
|
+
}, tickLine: false, axisLine: { stroke: 'var(--surf-line)' }, minTickGap: 24, interval: 'preserveStartEnd' })), _jsx(YAxis, { width: 56, scale: logScale ? 'log' : 'auto', tickFormatter: (value) => formatValue
|
|
204
418
|
? formatValue(Number(value))
|
|
205
|
-
:
|
|
419
|
+
: compactNumber.format(Number(value)), tick: { fill: 'var(--surf-ink-muted)', fontSize: 12 }, tickLine: false, axisLine: false, domain: [min, max === min ? max + 1 : max], ticks: ticks, allowDataOverflow: logScale }), !logScale && (_jsx(ReferenceLine, { y: 0, stroke: 'var(--surf-ink-muted)' })), host && (_jsx(Tooltip, { portal: host, isAnimationActive: false, active: dismissed ? false : undefined, filterNull: false, cursor: dismissed
|
|
206
420
|
? false
|
|
207
421
|
: {
|
|
208
422
|
stroke: 'var(--surf-control-border)',
|
|
@@ -213,29 +427,33 @@ export const Chart = function Chart({ title, description, data, series, unit, xL
|
|
|
213
427
|
pointerEvents: 'auto',
|
|
214
428
|
}, content: (tooltip) => {
|
|
215
429
|
const row = tooltip.active && !dismissed
|
|
216
|
-
?
|
|
430
|
+
? rowFor(tooltip.label)
|
|
217
431
|
: undefined;
|
|
218
|
-
return (_jsx("div", { className: 'surf-chart-readout', "data-chart-readout": row?.id, children: row ? (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }),
|
|
432
|
+
return (_jsx("div", { className: 'surf-chart-readout', "data-chart-readout": row?.id, children: row ? (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }), _jsxs("ul", { children: [visible.map((s) => (_jsxs("li", { children: [s.label, ": ", format(row.values[s.key])] }, s.key))), stacks && (_jsxs("li", { children: [text.total, ": ", format(total(row))] }))] })] })) : (_jsx("span", { children: text.inspectHint })) }));
|
|
219
433
|
} })), series.map((s, index) => {
|
|
220
434
|
const common = {
|
|
221
435
|
name: s.label,
|
|
222
|
-
dataKey: (row) => row
|
|
436
|
+
dataKey: (row) => valueOf(row, s.key),
|
|
223
437
|
hide: hidden.has(s.key),
|
|
224
438
|
isAnimationActive: false,
|
|
225
439
|
stroke: colorFor(index),
|
|
440
|
+
stackId: stacks ? 'total' : undefined,
|
|
226
441
|
};
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}), _jsx(FloatingValues, { host: floatHost, hidden: dismissed, render: (label) => {
|
|
233
|
-
const row =
|
|
442
|
+
const dot = rows.length > 40
|
|
443
|
+
? false
|
|
444
|
+
: (dot) => (_jsx(Marker, { cx: dot.cx, cy: dot.cy, color: colorFor(index), index: index }));
|
|
445
|
+
const shape = curve === 'step' ? 'stepAfter' : 'linear';
|
|
446
|
+
return type === 'bar' ? (_jsx(Bar, { ...common, fill: `url(#${id}-pattern-${index})`, maxBarSize: 48 }, s.key)) : type === 'area' ? (_jsx(Area, { ...common, type: shape, strokeWidth: 2, strokeDasharray: dashes[index % dashes.length], fill: `url(#${id}-pattern-${index})`, fillOpacity: stacks ? 0.32 : 0.12, connectNulls: false, dot: dot, activeDot: false }, s.key)) : (_jsx(Line, { ...common, type: shape, strokeWidth: 2, strokeDasharray: dashes[index % dashes.length], connectNulls: false, dot: dot, activeDot: false }, s.key));
|
|
447
|
+
}), references.map((reference, index) => 'y' in reference ? (_jsx(ReferenceLine, { y: reference.y, stroke: toneColors[reference.tone ?? 'neutral'], strokeWidth: 1.5, strokeDasharray: '4 3' }, index)) : null), _jsx(FloatingValues, { host: floatHost, hidden: dismissed, render: (label) => {
|
|
448
|
+
const row = rowFor(label);
|
|
234
449
|
if (!row)
|
|
235
450
|
return null;
|
|
236
|
-
return (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }),
|
|
451
|
+
return (_jsxs(_Fragment, { children: [_jsx("strong", { children: row.label }), _jsxs("ul", { children: [series.map((s, index) => hidden.has(s.key) ? null : (_jsxs("li", { children: [_jsx(SeriesMark, { type: type, index: index, color: colorFor(index), pattern: `${id}-float-${index}` }), _jsx("span", { children: s.label }), _jsx("span", { className: 'surf-chart-tooltip-value', children: format(row.values[s.key]) })] }, s.key))), stacks && (_jsxs("li", { className: 'surf-chart-tooltip-total', children: [_jsx("span", { children: text.total }), _jsx("span", { className: 'surf-chart-tooltip-value', children: format(total(row)) })] }))] })] }));
|
|
237
452
|
} })] }) }), _jsx("div", { className: 'surf-chart-float', ref: setFloatHost })] }), _jsx("p", { className: 'surf-chart-axis-label surf-chart-x-label', children: xLabel })] })) : (_jsx("div", { className: 'surf-chart-empty', role: 'status', children: visible.length === 0 && series.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { children: text.noSeries }), _jsx(Button, { variant: 'outline', onClick: () => {
|
|
238
453
|
setRestoreRequested(true);
|
|
239
454
|
changeHidden([]);
|
|
240
|
-
}, children: text.showAll })] })) : (_jsx("p", { children: text.empty })) }))] }), hasValues && (_jsx("p", { className: 'surf-chart-help', id: `${id}-keys`, children: text.keyboardHelp })), _jsx("div", { className: 'surf-chart-inspector', ref: setHost, role: 'status', "aria-live": 'polite', "aria-atomic": 'true', "aria-label": text.values(title), hidden: !hasValues }), _jsxs("details", { className: 'surf-chart-table',
|
|
455
|
+
}, children: text.showAll })] })) : (_jsx("p", { children: text.empty })) })), legendPosition === 'bottom' && (_jsx("div", { className: 'surf-chart-key', "data-position": 'bottom', children: legend }))] }), hasValues && (_jsx("p", { className: 'surf-chart-help', id: `${id}-keys`, children: text.keyboardHelp })), _jsx("div", { className: 'surf-chart-inspector', ref: setHost, role: 'status', "aria-live": 'polite', "aria-atomic": 'true', "aria-label": text.values(title), hidden: !hasValues }), _jsxs("details", { className: 'surf-chart-table', onToggle: (event) => {
|
|
456
|
+
if (event.currentTarget.open)
|
|
457
|
+
setTableOpened(true);
|
|
458
|
+
}, children: [_jsx("summary", { children: text.viewTable }), tableOpened && (_jsx(Table.Container, { label: text.table(title), orientation: 'both', scrollChain: 'contain', children: _jsxs(Table.Root, { children: [_jsx(Table.Caption, { children: text.tableCaption(unit) }), _jsx(Table.Head, { children: _jsxs(Table.Row, { children: [_jsx(Table.HeaderCell, { scope: 'col', children: xLabel }), series.map((s) => (_jsxs(Table.HeaderCell, { scope: 'col', numeric: true, children: [s.label, " (", unit, ")"] }, s.key)))] }) }), _jsx(Table.Body, { children: rows.length ? (rows.map((row) => (_jsxs(Table.Row, { children: [_jsx(Table.HeaderCell, { scope: 'row', children: row.label }), series.map((s) => (_jsx(Table.Cell, { numeric: true, children: formatNumber(row.values[s.key]) ?? text.missing }, s.key)))] }, row.id)))) : (_jsx(Table.Row, { children: _jsx(Table.Cell, { colSpan: series.length + 1, children: text.noRows }) })) })] }) }))] })] }));
|
|
241
459
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* Bar list, with the styles of what it renders. */
|
|
2
|
+
@layer surflane.tokens, surflane.themes, surflane.base, surflane.components,
|
|
3
|
+
surflane.sizes;
|
|
4
|
+
@import '@surflane/design-tokens/tokens.css';
|
|
5
|
+
@import '@surflane/css/presentation/base.css';
|
|
6
|
+
@import './rules/base.css';
|
|
7
|
+
@import './rules/sizing.css';
|
|
8
|
+
@import './rules/scroll-area.css';
|
|
9
|
+
@import './rules/bar-list.css';
|
package/dist/css/chart.css
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
@import '@surflane/css/presentation/base.css';
|
|
6
6
|
@import './rules/base.css';
|
|
7
7
|
@import './rules/sizing.css';
|
|
8
|
+
@import './rules/popup.css';
|
|
8
9
|
@import './rules/icon.css';
|
|
10
|
+
@import './rules/popover.css';
|
|
9
11
|
@import './rules/button.css';
|
|
10
12
|
@import './rules/scroll-area.css';
|
|
11
13
|
@import '@surflane/css/presentation/table.css';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Dashboard, with the styles of what it renders. */
|
|
2
|
+
@layer surflane.tokens, surflane.themes, surflane.base, surflane.components,
|
|
3
|
+
surflane.sizes;
|
|
4
|
+
@import '@surflane/design-tokens/tokens.css';
|
|
5
|
+
@import '@surflane/css/presentation/base.css';
|
|
6
|
+
@import './rules/base.css';
|
|
7
|
+
@import './rules/sizing.css';
|
|
8
|
+
@import './rules/popup.css';
|
|
9
|
+
@import './rules/icon.css';
|
|
10
|
+
@import './rules/popover.css';
|
|
11
|
+
@import './rules/button.css';
|
|
12
|
+
@import './rules/spinner.css';
|
|
13
|
+
@import './rules/skeleton.css';
|
|
14
|
+
@import '@surflane/css/foundations/layout.css';
|
|
15
|
+
@import './rules/layout.css';
|
|
16
|
+
@import '@surflane/css/presentation/card.css';
|
|
17
|
+
@import './rules/card.css';
|
|
18
|
+
@import '@surflane/css/presentation/inline-message.css';
|
|
19
|
+
@import './rules/inline-message.css';
|
|
20
|
+
@import './rules/dashboard.css';
|
|
@@ -211,6 +211,16 @@
|
|
|
211
211
|
border-radius: 0;
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
|
+
/* The menu trigger shows only where the sidebar hides, at the header's end. */
|
|
215
|
+
.surf-app-shell-menu-trigger.surf-button {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
@media (max-width: 760px) {
|
|
219
|
+
.surf-app-shell-menu-trigger.surf-button {
|
|
220
|
+
display: inline-flex;
|
|
221
|
+
margin-inline-start: auto;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
214
224
|
@media (max-width: 760px), (pointer: coarse) {
|
|
215
225
|
.surf-app-shell-link {
|
|
216
226
|
min-block-size: var(--surf-control-target-size);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* Bar list, with the styles of what it renders. */
|
|
2
|
+
@layer surflane.tokens, surflane.themes, surflane.base, surflane.components,
|
|
3
|
+
surflane.sizes;
|
|
4
|
+
|
|
5
|
+
@layer surflane.components {
|
|
6
|
+
/* Every row is one height, so a list that scrolls shows whole rows: a name line,
|
|
7
|
+
and a detail line when any row has one. */
|
|
8
|
+
.surf-bar-list {
|
|
9
|
+
--surf-bar-list-row: calc(
|
|
10
|
+
var(--surf-type-body-size) * var(--surf-type-body-leading) +
|
|
11
|
+
var(--surf-space-2)
|
|
12
|
+
);
|
|
13
|
+
min-inline-size: 0;
|
|
14
|
+
}
|
|
15
|
+
.surf-bar-list[data-detail] {
|
|
16
|
+
--surf-bar-list-row: calc(
|
|
17
|
+
var(--surf-type-body-size) * var(--surf-type-body-leading) +
|
|
18
|
+
var(--surf-type-caption-size) * var(--surf-type-caption-leading) +
|
|
19
|
+
var(--surf-space-2)
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
.surf-bar-list .surf-scroll-viewport {
|
|
23
|
+
max-block-size: calc(var(--surf-bar-list-rows) * var(--surf-bar-list-row));
|
|
24
|
+
}
|
|
25
|
+
/* Name, bar and value share one line, in columns every row shares, so the bars
|
|
26
|
+
start together and the values line up at the end. */
|
|
27
|
+
.surf-bar-list-items {
|
|
28
|
+
display: grid;
|
|
29
|
+
grid-template-columns: minmax(0, 2fr) minmax(var(--surf-space-12), 3fr) auto;
|
|
30
|
+
column-gap: var(--surf-space-3);
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding: 0;
|
|
33
|
+
list-style: none;
|
|
34
|
+
}
|
|
35
|
+
.surf-bar-list-item {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-column: 1 / -1;
|
|
38
|
+
grid-template-columns: subgrid;
|
|
39
|
+
align-items: center;
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
block-size: var(--surf-bar-list-row);
|
|
42
|
+
padding-block: var(--surf-space-1);
|
|
43
|
+
color: var(--surf-ink-body);
|
|
44
|
+
font: var(--surf-weight-regular)
|
|
45
|
+
var(--surf-type-body-size)/var(--surf-type-body-leading)
|
|
46
|
+
var(--surf-font-body);
|
|
47
|
+
}
|
|
48
|
+
.surf-bar-list-label {
|
|
49
|
+
display: grid;
|
|
50
|
+
min-inline-size: 0;
|
|
51
|
+
}
|
|
52
|
+
.surf-bar-list-name,
|
|
53
|
+
.surf-bar-list-detail {
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
text-overflow: ellipsis;
|
|
56
|
+
white-space: nowrap;
|
|
57
|
+
}
|
|
58
|
+
.surf-bar-list-name {
|
|
59
|
+
color: var(--surf-ink);
|
|
60
|
+
}
|
|
61
|
+
.surf-bar-list-detail {
|
|
62
|
+
color: var(--surf-ink-muted);
|
|
63
|
+
font-size: var(--surf-type-caption-size);
|
|
64
|
+
line-height: var(--surf-type-caption-leading);
|
|
65
|
+
}
|
|
66
|
+
/* The pair Progress and Slider use: the brand mark on the surface plane. */
|
|
67
|
+
.surf-bar-list-bar {
|
|
68
|
+
display: block;
|
|
69
|
+
block-size: var(--surf-space-2);
|
|
70
|
+
border-radius: var(--surf-radius-full);
|
|
71
|
+
background: var(--surf-surface);
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
}
|
|
74
|
+
.surf-bar-list-fill {
|
|
75
|
+
display: block;
|
|
76
|
+
block-size: 100%;
|
|
77
|
+
border-radius: inherit;
|
|
78
|
+
background: var(--surf-brand-mark);
|
|
79
|
+
}
|
|
80
|
+
.surf-bar-list-value {
|
|
81
|
+
color: var(--surf-ink);
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
text-align: end;
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
}
|
|
86
|
+
@media (forced-colors: active) {
|
|
87
|
+
.surf-bar-list-bar {
|
|
88
|
+
background: Canvas;
|
|
89
|
+
outline: 1px solid CanvasText;
|
|
90
|
+
}
|
|
91
|
+
.surf-bar-list-fill {
|
|
92
|
+
background: CanvasText;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
@layer surflane.sizes {
|
|
97
|
+
/* The bar is as thick as Meter's and Progress's at each size. */
|
|
98
|
+
:where([data-ui-size], [data-ui-size] *).surf-bar-list-bar {
|
|
99
|
+
block-size: var(--surf-ui-gauge);
|
|
100
|
+
}
|
|
101
|
+
}
|