@ultimat3/ui 23.0.0 → 25.0.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/CATALOG.md +119 -15
- package/CLAUDE.md +17 -8
- package/README.md +119 -33
- package/package.json +6 -6
- package/src/a11y.ts +27 -13
- package/src/catalog/build-catalog.ts +2 -2
- package/src/components/Accordion.tsx +8 -2
- package/src/components/AppShell.module.scss +105 -4
- package/src/components/AppShell.tsx +48 -10
- package/src/components/BarChart.module.scss +47 -9
- package/src/components/BarChart.tsx +152 -72
- package/src/components/Card.tsx +52 -16
- package/src/components/ChartFrame.module.scss +188 -0
- package/src/components/ChartFrame.tsx +155 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +161 -51
- package/src/components/Divider.tsx +0 -2
- package/src/components/DonutChart.module.scss +73 -0
- package/src/components/DonutChart.tsx +154 -0
- package/src/components/Dropzone.module.scss +3 -1
- package/src/components/Dropzone.tsx +17 -8
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.tsx +17 -4
- package/src/components/Gauge.module.scss +48 -0
- package/src/components/Gauge.tsx +67 -0
- package/src/components/Grid.tsx +34 -13
- package/src/components/Image.tsx +2 -2
- package/src/components/InlineBar.module.scss +23 -0
- package/src/components/InlineBar.tsx +31 -0
- package/src/components/LineChart.module.scss +147 -0
- package/src/components/LineChart.tsx +169 -0
- package/src/components/Menu.tsx +9 -5
- package/src/components/Meter.tsx +3 -3
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +14 -7
- package/src/components/Section.tsx +45 -26
- package/src/components/Stack.tsx +59 -18
- package/src/components/Table.module.scss +30 -0
- package/src/components/Table.tsx +14 -3
- package/src/components/Tabs.module.scss +6 -0
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +11 -6
- package/src/components/chart-frame-view.ts +219 -0
- package/src/components/chart-ticks-view.ts +173 -0
- package/src/components/data-table-view.ts +68 -0
- package/src/components/donut-chart-view.ts +120 -0
- package/src/components/file-input-view.ts +32 -13
- package/src/components/gauge-view.ts +43 -0
- package/src/components/heading-node.tsx +62 -0
- package/src/components/image-source.ts +3 -3
- package/src/components/line-chart-view.ts +153 -0
- package/src/components/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/money-view.ts +2 -7
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +32 -14
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +109 -24
- package/src/index.ts +47 -15
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +104 -96
- package/src/theme/context.ts +1 -1
- package/src/theme/preset-scifi.ts +99 -0
- package/src/theme/preset-shape.ts +18 -0
- package/src/theme/presets.ts +15 -0
- package/src/theme/shadow-value.ts +39 -0
- package/src/theme/theme.ts +36 -11
- package/src/toast/toast-state.ts +0 -0
- package/src/toast/toast-store.ts +1 -1
- package/src/toast/use-toasts.ts +1 -1
- package/src/tokens/_colors.scss +20 -0
- package/src/tokens/_effects.scss +59 -0
- package/src/tokens/_index.scss +2 -0
- package/src/tokens/_mixins.scss +6 -4
- package/src/tokens/_shadow.scss +16 -0
- package/src/tokens/_touch.scss +39 -0
- package/src/tokens/_typography.scss +14 -0
- package/src/tokens/color-tokens.ts +133 -0
- package/src/tokens/colour-vision.ts +106 -0
- package/src/tokens/contrast-pairs.ts +6 -1
- package/src/tokens/contrast.ts +1 -1
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +41 -87
- package/src/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
- package/src/theme/inline-script.ts +0 -49
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// AppShell's
|
|
2
|
-
// from the same place the <main>
|
|
3
|
-
//
|
|
1
|
+
// AppShell's rules, kept away from the markup: the ids the skip link and the menu button point at
|
|
2
|
+
// are derived from the same place the <main> and the <nav> get them, and which landmarks the frame
|
|
3
|
+
// emits in which order. All are silent failures when wrong — a skip link or a menu to nowhere.
|
|
4
4
|
|
|
5
5
|
export type ShellLandmark = 'banner' | 'navigation' | 'main' | 'contentinfo';
|
|
6
6
|
|
|
@@ -8,6 +8,8 @@ export interface ShellIds {
|
|
|
8
8
|
readonly mainId: string;
|
|
9
9
|
/** Always `#` + `mainId`. One derivation, so the link and the target cannot drift. */
|
|
10
10
|
readonly skipHref: string;
|
|
11
|
+
/** The sidebar `<nav>`, which the menu button's `popovertarget` names below `md`. */
|
|
12
|
+
readonly navId: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface ShellSlots {
|
|
@@ -18,7 +20,7 @@ export interface ShellSlots {
|
|
|
18
20
|
|
|
19
21
|
export function shellIds(base: string): ShellIds {
|
|
20
22
|
const mainId = `${base}-main`;
|
|
21
|
-
return { mainId, skipHref: `#${mainId}` };
|
|
23
|
+
return { mainId, skipHref: `#${mainId}`, navId: `${base}-nav` };
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -20,11 +20,12 @@ export interface BarRect {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* 600 ×
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* 600 × 108, nearly 6:1, not 3:1: at dashboard width a taller box pushed the table — the thing
|
|
24
|
+
* people came for — below the fold. `plot` is where bars live; `top` and the matching strip under
|
|
25
|
+
* it only keep the outer grid hairlines off the edge. The axis labels are HTML OUTSIDE the svg
|
|
26
|
+
* (issue 494) — inside a viewBox they scaled with it — so the box spends no strip on them.
|
|
26
27
|
*/
|
|
27
|
-
export const BAR_CHART = { width: 600, top:
|
|
28
|
+
export const BAR_CHART = { width: 600, top: 2, plot: 104, height: 108, gap: 3 } as const;
|
|
28
29
|
|
|
29
30
|
/** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
|
|
30
31
|
export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
|
|
@@ -50,13 +51,17 @@ export function gridY(step: number): number {
|
|
|
50
51
|
/** One rect per point, oldest first. At least 2px tall so a zero still draws a visible bar. */
|
|
51
52
|
export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
|
|
52
53
|
const count = Math.max(points.length, 1);
|
|
53
|
-
|
|
54
|
+
// The gap gives way before the bars do: at most half of each bar's slot, so past the 201 points
|
|
55
|
+
// where 3px gaps alone fill the width, every bar is still a positive rect and the row still ends
|
|
56
|
+
// at the right edge.
|
|
57
|
+
const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / count / 2);
|
|
58
|
+
const width = (BAR_CHART.width - gap * (count - 1)) / count;
|
|
54
59
|
const max = maxOf(points);
|
|
55
60
|
return points.map((point, index) => {
|
|
56
61
|
const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
|
|
57
62
|
const height = Math.max(2, (value / max) * BAR_CHART.plot);
|
|
58
63
|
return {
|
|
59
|
-
x: index * (width +
|
|
64
|
+
x: index * (width + gap),
|
|
60
65
|
y: BAR_CHART.top + BAR_CHART.plot - height,
|
|
61
66
|
width,
|
|
62
67
|
height,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// What every chart shares apart from its geometry: which colour slot, dash and marker a series
|
|
2
|
+
// gets (so no series is told apart by colour alone), the data table a screen reader and a
|
|
3
|
+
// no-colour reader get instead of the picture, and how a number becomes text — always in a locale
|
|
4
|
+
// the caller passed, never the runtime's default.
|
|
5
|
+
|
|
6
|
+
/** The chart colour roles, `chart-1` … `chart-8` (`--color-chart-N`). */
|
|
7
|
+
export const CHART_SERIES_SLOTS = 8;
|
|
8
|
+
|
|
9
|
+
export type MarkerShape =
|
|
10
|
+
| 'circle'
|
|
11
|
+
| 'square'
|
|
12
|
+
| 'triangle'
|
|
13
|
+
| 'diamond'
|
|
14
|
+
| 'plus'
|
|
15
|
+
| 'triangle-down'
|
|
16
|
+
| 'cross'
|
|
17
|
+
| 'star';
|
|
18
|
+
|
|
19
|
+
const MARKERS: readonly MarkerShape[] = [
|
|
20
|
+
'circle',
|
|
21
|
+
'square',
|
|
22
|
+
'triangle',
|
|
23
|
+
'diamond',
|
|
24
|
+
'plus',
|
|
25
|
+
'triangle-down',
|
|
26
|
+
'cross',
|
|
27
|
+
'star',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Five dash patterns against eight slots and markers: 5 and 8 share no factor, so the whole look
|
|
32
|
+
* of a series (slot, dash, marker) first repeats at the 41st — a ninth series reuses a colour,
|
|
33
|
+
* never a line. Units are screen px: every chart stroke is `non-scaling-stroke`.
|
|
34
|
+
*/
|
|
35
|
+
const DASHES: readonly (string | undefined)[] = [undefined, '6 4', '2 3', '10 3 2 3', '1 4'];
|
|
36
|
+
|
|
37
|
+
export interface SeriesStyle {
|
|
38
|
+
/** 1-based: the `N` of `--color-chart-N`, and the `data-series` a sheet keys the colour on. */
|
|
39
|
+
readonly slot: number;
|
|
40
|
+
/** `stroke-dasharray`; `undefined` is a solid line. */
|
|
41
|
+
readonly dash: string | undefined;
|
|
42
|
+
readonly marker: MarkerShape;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const indexOf = (index: number): number =>
|
|
46
|
+
Number.isFinite(index) && index > 0 ? Math.floor(index) : 0;
|
|
47
|
+
|
|
48
|
+
/** The look of the `index`th series (0-based). */
|
|
49
|
+
export function seriesStyle(index: number): SeriesStyle {
|
|
50
|
+
const i = indexOf(index);
|
|
51
|
+
return {
|
|
52
|
+
slot: (i % CHART_SERIES_SLOTS) + 1,
|
|
53
|
+
dash: DASHES[i % DASHES.length],
|
|
54
|
+
marker: MARKERS[i % MARKERS.length] ?? 'circle',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A coordinate as SVG text: two decimals at most, no trailing zeros, never `-0`. */
|
|
59
|
+
export function svgNumber(value: number): string {
|
|
60
|
+
const text = value.toFixed(2).replace(/\.?0+$/, '');
|
|
61
|
+
return text === '-0' ? '0' : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A closed polygon through `points` (unit offsets scaled by `r` around `x, y`). */
|
|
65
|
+
function polygon(x: number, y: number, r: number, points: readonly (readonly [number, number])[]) {
|
|
66
|
+
const [head, ...rest] = points.map(
|
|
67
|
+
([dx, dy]) => `${svgNumber(x + dx * r)},${svgNumber(y + dy * r)}`,
|
|
68
|
+
);
|
|
69
|
+
return `M${head} ${rest.map((p) => `L${p}`).join(' ')} Z`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ring = (count: number, radius: number, phase = 0): [number, number][] =>
|
|
73
|
+
Array.from({ length: count }, (_, i) => {
|
|
74
|
+
const angle = phase + (i * 2 * Math.PI) / count;
|
|
75
|
+
return [Math.sin(angle) * radius, -Math.cos(angle) * radius];
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Polygons only, so a marker is one path a sheet fills — a 12-gon IS a circle at 4px. */
|
|
79
|
+
const SHAPES: Record<MarkerShape, readonly (readonly [number, number])[]> = {
|
|
80
|
+
circle: ring(12, 1),
|
|
81
|
+
square: [
|
|
82
|
+
[-0.85, -0.85],
|
|
83
|
+
[0.85, -0.85],
|
|
84
|
+
[0.85, 0.85],
|
|
85
|
+
[-0.85, 0.85],
|
|
86
|
+
],
|
|
87
|
+
triangle: [
|
|
88
|
+
[0, -1],
|
|
89
|
+
[1, 0.85],
|
|
90
|
+
[-1, 0.85],
|
|
91
|
+
],
|
|
92
|
+
diamond: [
|
|
93
|
+
[0, -1],
|
|
94
|
+
[1, 0],
|
|
95
|
+
[0, 1],
|
|
96
|
+
[-1, 0],
|
|
97
|
+
],
|
|
98
|
+
plus: [
|
|
99
|
+
[-0.33, -1],
|
|
100
|
+
[0.33, -1],
|
|
101
|
+
[0.33, -0.33],
|
|
102
|
+
[1, -0.33],
|
|
103
|
+
[1, 0.33],
|
|
104
|
+
[0.33, 0.33],
|
|
105
|
+
[0.33, 1],
|
|
106
|
+
[-0.33, 1],
|
|
107
|
+
[-0.33, 0.33],
|
|
108
|
+
[-1, 0.33],
|
|
109
|
+
[-1, -0.33],
|
|
110
|
+
[-0.33, -0.33],
|
|
111
|
+
],
|
|
112
|
+
'triangle-down': [
|
|
113
|
+
[-1, -0.85],
|
|
114
|
+
[1, -0.85],
|
|
115
|
+
[0, 1],
|
|
116
|
+
],
|
|
117
|
+
cross: [
|
|
118
|
+
[-1, -0.6],
|
|
119
|
+
[-0.6, -1],
|
|
120
|
+
[0, -0.4],
|
|
121
|
+
[0.6, -1],
|
|
122
|
+
[1, -0.6],
|
|
123
|
+
[0.4, 0],
|
|
124
|
+
[1, 0.6],
|
|
125
|
+
[0.6, 1],
|
|
126
|
+
[0, 0.4],
|
|
127
|
+
[-0.6, 1],
|
|
128
|
+
[-1, 0.6],
|
|
129
|
+
[-0.4, 0],
|
|
130
|
+
],
|
|
131
|
+
star: ring(10, 1).map(([dx, dy], i) => (i % 2 === 0 ? [dx, dy] : [dx * 0.45, dy * 0.45])),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The `d` of a marker of `shape` centred on `x, y`, inside a box of half-width `r`. */
|
|
135
|
+
export function markerPath(shape: MarkerShape, x: number, y: number, r: number): string {
|
|
136
|
+
return polygon(x, y, r, SHAPES[shape]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A value a chart can place, or `null` for a gap: missing, NaN and ±∞ are never drawn as 0. */
|
|
140
|
+
export function finiteOrNull(value: number | null | undefined): number | null {
|
|
141
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ChartSeries {
|
|
145
|
+
/** The series' name, already translated: the legend entry and the table's column head. */
|
|
146
|
+
readonly label: string;
|
|
147
|
+
/** One value per key, in key order; `null` is a gap in the line and an empty table cell. */
|
|
148
|
+
readonly values: readonly (number | null)[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ChartTableRow {
|
|
152
|
+
readonly key: string;
|
|
153
|
+
readonly cells: readonly string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ChartTable {
|
|
157
|
+
readonly caption: string;
|
|
158
|
+
/** The corner cell (the key column's head, `''` when unnamed), then one head per series. */
|
|
159
|
+
readonly head: readonly string[];
|
|
160
|
+
readonly rows: readonly ChartTableRow[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ChartTableInput {
|
|
164
|
+
readonly caption: string;
|
|
165
|
+
readonly keyLabel: string | undefined;
|
|
166
|
+
readonly keys: readonly string[];
|
|
167
|
+
readonly series: readonly ChartSeries[];
|
|
168
|
+
readonly format: (value: number) => string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The same numbers the picture draws, as rows a screen reader walks cell by cell. */
|
|
172
|
+
export function chartTable(input: ChartTableInput): ChartTable {
|
|
173
|
+
return {
|
|
174
|
+
caption: input.caption,
|
|
175
|
+
head: [input.keyLabel ?? '', ...input.series.map((series) => series.label)],
|
|
176
|
+
rows: input.keys.map((key, index) => ({
|
|
177
|
+
key,
|
|
178
|
+
cells: input.series.map((series) => {
|
|
179
|
+
const value = finiteOrNull(series.values[index]);
|
|
180
|
+
return value === null ? '' : input.format(value);
|
|
181
|
+
}),
|
|
182
|
+
})),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `Intl.NumberFormat` in the locale passed — the page's, read by the component from `useUi()`. */
|
|
187
|
+
export function chartNumberFormat(
|
|
188
|
+
locale: string,
|
|
189
|
+
maxFractionDigits = 2,
|
|
190
|
+
): (value: number) => string {
|
|
191
|
+
const format = new Intl.NumberFormat(locale, { maximumFractionDigits: maxFractionDigits });
|
|
192
|
+
return (value) => format.format(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A point's accessible name: key, series (when there is more than one thing to name), value. */
|
|
196
|
+
export function pointLabel(key: string, series: string | undefined, value: string): string {
|
|
197
|
+
return series === undefined ? `${key}: ${value}` : `${key}, ${series}: ${value}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `part` of `whole` as a CSS percentage for a `--x`/`--y` custom property, clamped to the box. */
|
|
201
|
+
export function cssPercent(part: number, whole: number): string {
|
|
202
|
+
if (!(whole > 0) || !Number.isFinite(part)) return '0%';
|
|
203
|
+
const share = Math.min(1, Math.max(0, part / whole));
|
|
204
|
+
return `${svgNumber(share * 100)}%`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A share (0..1) as a percentage in the locale passed: a donut's legend, a gauge's readout. */
|
|
208
|
+
export function chartPercentFormat(locale: string): (share: number) => string {
|
|
209
|
+
const format = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 });
|
|
210
|
+
return (share) => format.format(share);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Which way a point's readout opens: inward from the outer fifth of the plot, centred elsewhere. */
|
|
214
|
+
export function hitEdge(x: number, width: number): 'start' | 'middle' | 'end' {
|
|
215
|
+
const share = width > 0 ? x / width : 0.5;
|
|
216
|
+
if (share < 0.2) return 'start';
|
|
217
|
+
if (share > 0.8) return 'end';
|
|
218
|
+
return 'middle';
|
|
219
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Axis arithmetic shared by every chart with a value axis: a "nice" scale (steps of 1, 2 or 5 ×
|
|
2
|
+
// 10ⁿ, the only steps a reader can count in their head) and which category keys get a label.
|
|
3
|
+
// Pure, so the server render and an island draw the same axis, and testable with no renderer.
|
|
4
|
+
|
|
5
|
+
export interface NiceScale {
|
|
6
|
+
/** The axis' lowest tick — at or below the data's minimum. */
|
|
7
|
+
readonly min: number;
|
|
8
|
+
/** The axis' highest tick — at or above the data's maximum. */
|
|
9
|
+
readonly max: number;
|
|
10
|
+
readonly step: number;
|
|
11
|
+
/** `min`, `min + step`, … `max`, as exact decimals. */
|
|
12
|
+
readonly ticks: readonly number[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MANTISSAS = [1, 2, 5] as const;
|
|
16
|
+
|
|
17
|
+
/** Rounds away binary noise: `0.1 + 0.2` is drawn as `0.3`, never `0.30000000000000004`. */
|
|
18
|
+
const exact = (value: number): number => Number(value.toPrecision(12));
|
|
19
|
+
|
|
20
|
+
/** The largest step of the 1-2-5 ladder at or below `raw`; 1 for a raw step that is not one. */
|
|
21
|
+
export function niceStep(raw: number): number {
|
|
22
|
+
if (!Number.isFinite(raw) || raw <= 0) return 1;
|
|
23
|
+
const exp = Math.floor(Math.log10(raw));
|
|
24
|
+
const power = 10 ** exp;
|
|
25
|
+
const fraction = raw / power;
|
|
26
|
+
let mantissa = 1;
|
|
27
|
+
for (const candidate of MANTISSAS) if (candidate <= fraction * (1 + 1e-9)) mantissa = candidate;
|
|
28
|
+
return exact(mantissa * power);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The next rung of the ladder above `step`: 1 → 2 → 5 → 10 → 20. */
|
|
32
|
+
function nextStep(step: number): number {
|
|
33
|
+
const exp = Math.floor(Math.log10(step));
|
|
34
|
+
const mantissa = Math.round(step / 10 ** exp);
|
|
35
|
+
if (mantissa === 1) return exact(2 * 10 ** exp);
|
|
36
|
+
if (mantissa === 2) return exact(5 * 10 ** exp);
|
|
37
|
+
return exact(10 ** (exp + 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fraction digits a tick at `step` needs: 0 for 5 or 500, 1 for 0.5, 2 for 0.02. */
|
|
41
|
+
export function tickDecimals(step: number): number {
|
|
42
|
+
if (!Number.isFinite(step) || step <= 0) return 0;
|
|
43
|
+
return Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A flat range has no height to divide: open it by a tenth of its size (or by 1 at zero). */
|
|
47
|
+
function bounds(lo: number, hi: number): readonly [number, number] {
|
|
48
|
+
const a = Number.isFinite(lo) ? lo : 0;
|
|
49
|
+
const b = Number.isFinite(hi) ? hi : a;
|
|
50
|
+
const [low, high] = a <= b ? [a, b] : [b, a];
|
|
51
|
+
if (high > low) return [low, high];
|
|
52
|
+
if (low === 0) return [0, 1];
|
|
53
|
+
const open = Math.abs(low) * 0.1;
|
|
54
|
+
// Clamped, because a flat range at ±MAX_VALUE opens past float64 into ±Infinity.
|
|
55
|
+
return [Math.max(-Number.MAX_VALUE, low - open), Math.min(Number.MAX_VALUE, high + open)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface NiceTicksOptions {
|
|
59
|
+
/**
|
|
60
|
+
* The data is whole numbers — a count. Steps are then at least 1, so no tick falls between two
|
|
61
|
+
* values the data can take: "0.4 signups" labels nothing, and it pushed the max off the labels.
|
|
62
|
+
*/
|
|
63
|
+
readonly integer?: boolean | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The densest 1-2-5 labelling of `lo..hi` with at most `maxTicks` ticks (read as at least 2):
|
|
68
|
+
* start at the step that would fit exactly and climb the ladder until the count fits.
|
|
69
|
+
*/
|
|
70
|
+
export function niceTicks(
|
|
71
|
+
lo: number,
|
|
72
|
+
hi: number,
|
|
73
|
+
maxTicks = 6,
|
|
74
|
+
options: NiceTicksOptions = {},
|
|
75
|
+
): NiceScale {
|
|
76
|
+
const cap = Math.max(2, Math.floor(maxTicks));
|
|
77
|
+
const [low, high] = bounds(lo, hi);
|
|
78
|
+
const fitted = niceStep((high - low) / (cap - 1));
|
|
79
|
+
// Every rung of the ladder from 1 up is a whole number, so flooring the START at 1 is enough.
|
|
80
|
+
let step = options.integer === true ? Math.max(1, fitted) : fitted;
|
|
81
|
+
for (let rung = 0; rung < MAX_RUNGS; rung += 1) {
|
|
82
|
+
const min = exact(Math.floor(low / step + 1e-9) * step);
|
|
83
|
+
const max = exact(Math.ceil(high / step - 1e-9) * step);
|
|
84
|
+
const count = Math.round((max - min) / step) + 1;
|
|
85
|
+
// Past float64's edge the ladder stops being one: `ceil(hi / step) * step` overflows, the
|
|
86
|
+
// next rung IS `Infinity`, and `NaN <= cap` is false forever — a LineChart SSR that never
|
|
87
|
+
// returned. An underflowing step collapses the range to one tick instead. Either way the
|
|
88
|
+
// data's own two ends are the only honest axis left.
|
|
89
|
+
if (!(step > 0 && Number.isFinite(step) && Number.isFinite(min) && Number.isFinite(max))) break;
|
|
90
|
+
if (count < 2) break;
|
|
91
|
+
if (count <= cap) {
|
|
92
|
+
const ticks = Array.from({ length: count }, (_, i) => exact(min + i * step));
|
|
93
|
+
return { min, max, step, ticks };
|
|
94
|
+
}
|
|
95
|
+
step = nextStep(step);
|
|
96
|
+
}
|
|
97
|
+
return edgeScale(low, high);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Rungs from the smallest subnormal step to the largest finite one: ~632 decades × 3. The loop
|
|
102
|
+
* already exits at the first non-finite rung; the bound makes termination a fact, not an argument.
|
|
103
|
+
*/
|
|
104
|
+
const MAX_RUNGS = 2_048;
|
|
105
|
+
|
|
106
|
+
/** The fallback axis: just the data's two ends, which `bounds` has already made finite. */
|
|
107
|
+
function edgeScale(low: number, high: number): NiceScale {
|
|
108
|
+
const span = high - low;
|
|
109
|
+
return {
|
|
110
|
+
min: low,
|
|
111
|
+
max: high,
|
|
112
|
+
step: Number.isFinite(span) && span > 0 ? span : Number.MAX_VALUE,
|
|
113
|
+
ticks: [low, high],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Which of `count` category keys carry a label, at most `max` of them: an even stride counted
|
|
119
|
+
* back from the NEWEST key, which is always labelled — on a time axis it is the one people read.
|
|
120
|
+
* The stride is at least `(count - 1) / (max - 1)` keys, which is what lets a label be capped at
|
|
121
|
+
* a fixed share of the axis and never touch its neighbour.
|
|
122
|
+
*/
|
|
123
|
+
export function axisLabelIndices(count: number, max: number): readonly number[] {
|
|
124
|
+
if (count <= 0) return [];
|
|
125
|
+
const cap = Math.max(2, Math.floor(max));
|
|
126
|
+
if (count <= cap) return Array.from({ length: count }, (_, i) => i);
|
|
127
|
+
const stride = Math.ceil((count - 1) / (cap - 1));
|
|
128
|
+
const out: number[] = [];
|
|
129
|
+
for (let index = count - 1; index >= 0; index -= stride) out.unshift(index);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Category labels a chart under its container breakpoint shows — a 390px phone. */
|
|
134
|
+
export const NARROW_LABELS = 3;
|
|
135
|
+
/** And at or above it. Each label is capped at ⅔ of the narrowest stride, so neither overlaps. */
|
|
136
|
+
export const WIDE_LABELS = 6;
|
|
137
|
+
|
|
138
|
+
export type LabelEdge = 'start' | 'middle' | 'end';
|
|
139
|
+
|
|
140
|
+
export interface CategoryLabel {
|
|
141
|
+
readonly index: number;
|
|
142
|
+
/** Shown below the container breakpoint (`data-narrow`). */
|
|
143
|
+
readonly narrow: boolean;
|
|
144
|
+
/** Shown at or above it (`data-wide`). */
|
|
145
|
+
readonly wide: boolean;
|
|
146
|
+
/** The oldest key aligns to the start edge, the newest to the end, so neither leaves the box. */
|
|
147
|
+
readonly edge: LabelEdge;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every key that either width labels, oldest first, each flagged with the width that shows it.
|
|
152
|
+
* The sheet hides by flag, so one server render serves the phone and the desktop with no JS.
|
|
153
|
+
*/
|
|
154
|
+
export function categoryLabels(count: number): readonly CategoryLabel[] {
|
|
155
|
+
const narrow = new Set(axisLabelIndices(count, NARROW_LABELS));
|
|
156
|
+
const wide = new Set(axisLabelIndices(count, WIDE_LABELS));
|
|
157
|
+
const indices = [...new Set([...narrow, ...wide])].sort((a, b) => a - b);
|
|
158
|
+
return indices.map((index) => ({
|
|
159
|
+
index,
|
|
160
|
+
narrow: narrow.has(index),
|
|
161
|
+
wide: wide.has(index),
|
|
162
|
+
edge: count === 1 ? 'middle' : index === 0 ? 'start' : index === count - 1 ? 'end' : 'middle',
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which of `count` value-axis labels a narrow chart hides: every other one counted down from the
|
|
168
|
+
* TOP, once there are more than three — the highest tick is the one that bounds the data, so it is
|
|
169
|
+
* never the one dropped. Hidden labels keep their grid line; only the text goes.
|
|
170
|
+
*/
|
|
171
|
+
export function minorTicks(count: number): readonly boolean[] {
|
|
172
|
+
return Array.from({ length: count }, (_, i) => count > 3 && (count - 1 - i) % 2 === 1);
|
|
173
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Where each DataTable value is visible, by the table's own width. A column's `priority` hides its
|
|
2
|
+
// cell in a narrow table; the row's "more" disclosure shows the same value for exactly the widths
|
|
3
|
+
// the cell is gone, so no value is ever on screen twice and none is ever nowhere.
|
|
4
|
+
|
|
5
|
+
import { invalidValueError } from '../errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The roles every DataTable element re-declares. Redundant on a table laid out as a table, and
|
|
9
|
+
* deliberately so: below `sm` the card view changes `display` on the table, its row groups, rows
|
|
10
|
+
* and cells, and WebKit drops the implicit table semantics of an element whose `display` changed —
|
|
11
|
+
* a screen reader then meets loose text with no row, no column and no header. The explicit role is
|
|
12
|
+
* the documented remedy, and ONE constant is the one place the redundancy is argued, rather than a
|
|
13
|
+
* lint suppression on every element.
|
|
14
|
+
*/
|
|
15
|
+
export const TABLE_ROLES = {
|
|
16
|
+
rowgroup: 'rowgroup',
|
|
17
|
+
row: 'row',
|
|
18
|
+
columnheader: 'columnheader',
|
|
19
|
+
cell: 'cell',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
/** 1 is always a cell; 2 is a cell from `md` up; 3 from `lg` up. Widths are the TABLE's. */
|
|
23
|
+
export const COLUMN_PRIORITIES = [1, 2, 3] as const;
|
|
24
|
+
export type ColumnPriority = (typeof COLUMN_PRIORITIES)[number];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `data-priority` / `data-more` values the stylesheet's width rules select on. Attributes, not
|
|
28
|
+
* classes: they are state the markup carries, and a test can read them where a CSS-module class
|
|
29
|
+
* resolves to nothing under `bun test`.
|
|
30
|
+
*/
|
|
31
|
+
export type PriorityAttr = '2' | '3';
|
|
32
|
+
/** The rung the disclosure shows below — the widest range any of its columns is hidden over. */
|
|
33
|
+
export type MoreUntil = 'md' | 'lg';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The priority a column declared, refused when it is not one: the type stops a TypeScript caller,
|
|
37
|
+
* and nothing stops a column list parsed from config — a `priority: 4` used to be a column shown
|
|
38
|
+
* at every width with no word that it was not what was asked for.
|
|
39
|
+
*/
|
|
40
|
+
export function columnPriority(priority: unknown): ColumnPriority {
|
|
41
|
+
if (priority === undefined) return 1;
|
|
42
|
+
if (priority === 1 || priority === 2 || priority === 3) return priority;
|
|
43
|
+
throw invalidValueError('DataTable', priority, 'a column priority of 1, 2 or 3');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a cell (and its header) carries; priority 1 carries nothing and is never hidden. */
|
|
47
|
+
export function priorityAttr(priority: ColumnPriority): PriorityAttr | undefined {
|
|
48
|
+
return priority === 1 ? undefined : `${priority}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MoreColumn<C> {
|
|
52
|
+
/** The columns whose values the disclosure may hold, in declaration order. */
|
|
53
|
+
readonly columns: readonly C[];
|
|
54
|
+
readonly until: MoreUntil;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The trailing "more" column, or `undefined` when every column is priority 1 — a table that
|
|
59
|
+
* hides nothing gets no extra column, no extra header and no extra bytes.
|
|
60
|
+
*/
|
|
61
|
+
export function moreColumn<C extends { readonly priority?: unknown }>(
|
|
62
|
+
columns: readonly C[],
|
|
63
|
+
): MoreColumn<C> | undefined {
|
|
64
|
+
const hidden = columns.filter((column) => columnPriority(column.priority) > 1);
|
|
65
|
+
if (hidden.length === 0) return undefined;
|
|
66
|
+
const widest = Math.max(...hidden.map((column) => columnPriority(column.priority)));
|
|
67
|
+
return { columns: hidden, until: widest === 3 ? 'lg' : 'md' };
|
|
68
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The arc maths of a DonutChart (and a Gauge's track): where each segment starts and ends, the
|
|
2
|
+
// annular-sector path that draws it, and where its marker sits. Angles are radians from twelve
|
|
3
|
+
// o'clock, clockwise — the way a reader goes round a clock face. Pure, so two renders agree.
|
|
4
|
+
|
|
5
|
+
import { svgNumber } from './chart-frame-view';
|
|
6
|
+
|
|
7
|
+
/** A 200 × 200 box; the ring is 32 units wide, which leaves room for a centre label inside. */
|
|
8
|
+
export const DONUT = { size: 200, centre: 100, outer: 96, inner: 64, marker: 6 } as const;
|
|
9
|
+
|
|
10
|
+
const TURN = Math.PI * 2;
|
|
11
|
+
/** The gap between two neighbours, so a segment boundary reads without relying on colour. */
|
|
12
|
+
const GAP = 0.03;
|
|
13
|
+
/** The least sweep a non-zero segment is drawn with: a 0.1% share is still a visible sliver. */
|
|
14
|
+
const MIN_SWEEP = 0.012;
|
|
15
|
+
|
|
16
|
+
export interface CirclePoint {
|
|
17
|
+
readonly x: number;
|
|
18
|
+
readonly y: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pointOnCircle(cx: number, cy: number, r: number, angle: number): CirclePoint {
|
|
22
|
+
return { x: cx + r * Math.sin(angle), y: cy - r * Math.cos(angle) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const at = (p: CirclePoint): string => `${svgNumber(p.x)},${svgNumber(p.y)}`;
|
|
26
|
+
|
|
27
|
+
function sector(cx: number, cy: number, outer: number, inner: number, a0: number, a1: number) {
|
|
28
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
29
|
+
const o0 = pointOnCircle(cx, cy, outer, a0);
|
|
30
|
+
const o1 = pointOnCircle(cx, cy, outer, a1);
|
|
31
|
+
const i1 = pointOnCircle(cx, cy, inner, a1);
|
|
32
|
+
const i0 = pointOnCircle(cx, cy, inner, a0);
|
|
33
|
+
return (
|
|
34
|
+
`M${at(o0)} A${outer},${outer} 0 ${large} 1 ${at(o1)} ` +
|
|
35
|
+
`L${at(i1)} A${inner},${inner} 0 ${large} 0 ${at(i0)} Z`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The ring between radii `inner` and `outer` from angle `a0` to `a1`. A sweep of a whole turn is
|
|
41
|
+
* drawn as two halves: an SVG arc from a point back to itself has no direction and draws nothing.
|
|
42
|
+
*/
|
|
43
|
+
export function annularSector(
|
|
44
|
+
cx: number,
|
|
45
|
+
cy: number,
|
|
46
|
+
outer: number,
|
|
47
|
+
inner: number,
|
|
48
|
+
a0: number,
|
|
49
|
+
a1: number,
|
|
50
|
+
): string {
|
|
51
|
+
if (!(a1 > a0)) return '';
|
|
52
|
+
if (a1 - a0 >= TURN - 1e-9) {
|
|
53
|
+
const half = a0 + Math.PI;
|
|
54
|
+
return `${sector(cx, cy, outer, inner, a0, half)} ${sector(cx, cy, outer, inner, half, a0 + TURN)}`;
|
|
55
|
+
}
|
|
56
|
+
return sector(cx, cy, outer, inner, a0, a1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DonutArc {
|
|
60
|
+
/** This value's part of the positive total; 0 for a value that is not drawn. */
|
|
61
|
+
readonly share: number;
|
|
62
|
+
/** The segment's slot on the ring, before the gap is taken off. */
|
|
63
|
+
readonly start: number;
|
|
64
|
+
readonly end: number;
|
|
65
|
+
/** What is actually drawn: the slot less half a gap at each end. */
|
|
66
|
+
readonly drawnStart: number;
|
|
67
|
+
readonly drawnEnd: number;
|
|
68
|
+
readonly path: string;
|
|
69
|
+
/** Mid-ring, mid-segment: where a focusable segment's hit sits. `null` for a share of 0. */
|
|
70
|
+
readonly anchor: CirclePoint | null;
|
|
71
|
+
/** The anchor again, or `null` where the segment is too narrow to hold a marker. */
|
|
72
|
+
readonly marker: CirclePoint | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const positive = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0);
|
|
76
|
+
|
|
77
|
+
/** One arc per value, in order from twelve o'clock; a non-positive value is listed, not drawn. */
|
|
78
|
+
export function donutArcs(values: readonly number[]): readonly DonutArc[] {
|
|
79
|
+
const { centre, outer, inner, marker } = DONUT;
|
|
80
|
+
const total = values.reduce((sum, value) => sum + positive(value), 0);
|
|
81
|
+
const drawn = values.filter((value) => positive(value) > 0).length;
|
|
82
|
+
const gap = drawn > 1 ? GAP : 0;
|
|
83
|
+
const mid = (outer + inner) / 2;
|
|
84
|
+
let cursor = 0;
|
|
85
|
+
return values.map((value): DonutArc => {
|
|
86
|
+
const share = total > 0 ? positive(value) / total : 0;
|
|
87
|
+
const start = cursor;
|
|
88
|
+
const end = cursor + share * TURN;
|
|
89
|
+
cursor = end;
|
|
90
|
+
if (share === 0) {
|
|
91
|
+
return {
|
|
92
|
+
share,
|
|
93
|
+
start,
|
|
94
|
+
end,
|
|
95
|
+
drawnStart: start,
|
|
96
|
+
drawnEnd: start,
|
|
97
|
+
path: '',
|
|
98
|
+
anchor: null,
|
|
99
|
+
marker: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const centreAngle = (start + end) / 2;
|
|
103
|
+
const sweep = Math.max(MIN_SWEEP, end - start - gap);
|
|
104
|
+
const drawnStart = gap === 0 ? start : centreAngle - sweep / 2;
|
|
105
|
+
const drawnEnd = gap === 0 ? end : centreAngle + sweep / 2;
|
|
106
|
+
// A marker needs about three of its own widths of arc, or it spills onto the neighbours.
|
|
107
|
+
const fits = sweep * mid >= marker * 3;
|
|
108
|
+
const anchor = pointOnCircle(centre, centre, mid, centreAngle);
|
|
109
|
+
return {
|
|
110
|
+
share,
|
|
111
|
+
start,
|
|
112
|
+
end,
|
|
113
|
+
drawnStart,
|
|
114
|
+
drawnEnd,
|
|
115
|
+
path: annularSector(centre, centre, outer, inner, drawnStart, drawnEnd),
|
|
116
|
+
anchor,
|
|
117
|
+
marker: fits ? anchor : null,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|