@ultimat3/ui 29.4.0 → 30.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 +491 -24
- package/CLAUDE.md +23 -16
- package/README.md +338 -33
- package/package.json +16 -5
- package/src/components/AppShell.module.scss +23 -7
- package/src/components/AppShell.tsx +9 -0
- package/src/components/BarChart.module.scss +26 -0
- package/src/components/BarChart.tsx +242 -93
- package/src/components/Board.module.scss +30 -0
- package/src/components/Board.tsx +47 -0
- package/src/components/BottomNav.module.scss +49 -0
- package/src/components/BottomNav.tsx +57 -0
- package/src/components/Calendar.module.scss +118 -0
- package/src/components/Calendar.tsx +246 -0
- package/src/components/ChartFrame.module.scss +40 -3
- package/src/components/ChartFrame.tsx +84 -28
- package/src/components/CodeBlock.module.scss +101 -0
- package/src/components/CodeBlock.tsx +76 -0
- package/src/components/CommandPalette.module.scss +25 -0
- package/src/components/CommandPalette.tsx +50 -22
- package/src/components/ConfirmDialog.module.scss +36 -0
- package/src/components/ConfirmDialog.tsx +188 -0
- package/src/components/DataTable.tsx +92 -33
- package/src/components/DatePicker.module.scss +37 -0
- package/src/components/DatePicker.tsx +134 -0
- package/src/components/DateRangePicker.module.scss +54 -0
- package/src/components/DateRangePicker.tsx +156 -0
- package/src/components/DescriptionList.module.scss +46 -0
- package/src/components/DescriptionList.tsx +38 -0
- package/src/components/DiffView.module.scss +124 -0
- package/src/components/DiffView.tsx +169 -0
- package/src/components/DonutChart.tsx +13 -4
- package/src/components/Drawer.module.scss +40 -0
- package/src/components/Drawer.tsx +56 -0
- package/src/components/Gauge.module.scss +8 -0
- package/src/components/Gauge.tsx +40 -13
- package/src/components/HeatStrip.module.scss +133 -0
- package/src/components/HeatStrip.tsx +64 -0
- package/src/components/JsonView.module.scss +64 -0
- package/src/components/JsonView.tsx +68 -0
- package/src/components/LineChart.tsx +45 -18
- package/src/components/LiveIndicator.module.scss +44 -0
- package/src/components/LiveIndicator.tsx +45 -0
- package/src/components/LogViewer.module.scss +102 -0
- package/src/components/LogViewer.tsx +123 -0
- package/src/components/Markdown.module.scss +87 -0
- package/src/components/Markdown.tsx +84 -0
- package/src/components/Meter.module.scss +7 -0
- package/src/components/Meter.tsx +16 -0
- package/src/components/NavList.module.scss +66 -0
- package/src/components/NavList.tsx +95 -0
- package/src/components/Pagination.module.scss +6 -2
- package/src/components/Pagination.tsx +57 -13
- package/src/components/Popover.tsx +12 -2
- package/src/components/PullToRefresh.module.scss +41 -0
- package/src/components/PullToRefresh.tsx +108 -0
- package/src/components/Radio.module.scss +51 -0
- package/src/components/Radio.tsx +12 -1
- package/src/components/SearchableSelect.module.scss +216 -0
- package/src/components/SearchableSelect.tsx +331 -0
- package/src/components/SearchableSelectChips.tsx +41 -0
- package/src/components/SearchableSelectNative.tsx +89 -0
- package/src/components/ShortcutSheet.module.scss +33 -0
- package/src/components/ShortcutSheet.tsx +67 -0
- package/src/components/Slider.module.scss +50 -0
- package/src/components/Slider.tsx +68 -0
- package/src/components/SortableList.module.scss +72 -0
- package/src/components/SortableList.tsx +42 -0
- package/src/components/Sparkline.module.scss +10 -2
- package/src/components/Sparkline.tsx +27 -10
- package/src/components/SplitPane.module.scss +39 -0
- package/src/components/SplitPane.tsx +96 -0
- package/src/components/StatTile.module.scss +29 -3
- package/src/components/StatTile.tsx +75 -16
- package/src/components/Stepper.module.scss +78 -0
- package/src/components/Stepper.tsx +73 -0
- package/src/components/Tabs.module.scss +13 -1
- package/src/components/Tabs.tsx +77 -1
- package/src/components/TerminalView.module.scss +68 -0
- package/src/components/TerminalView.tsx +97 -0
- package/src/components/TimeRangePicker.module.scss +85 -0
- package/src/components/TimeRangePicker.tsx +91 -0
- package/src/components/Timeline.module.scss +74 -0
- package/src/components/Timeline.tsx +60 -0
- package/src/components/Tree.module.scss +98 -0
- package/src/components/Tree.tsx +185 -0
- package/src/components/VirtualList.module.scss +60 -0
- package/src/components/VirtualList.tsx +251 -0
- package/src/components/app-shell-view.ts +4 -1
- package/src/components/bar-chart-view.ts +121 -39
- package/src/components/board-view.ts +25 -0
- package/src/components/bottom-nav-view.ts +18 -0
- package/src/components/calendar-view.ts +226 -0
- package/src/components/chart-checks.ts +59 -0
- package/src/components/chart-frame-view.ts +33 -2
- package/src/components/chart-reference-view.ts +41 -0
- package/src/components/command-palette-view.ts +47 -6
- package/src/components/confirm-dialog-view.ts +47 -0
- package/src/components/data-table-rows.ts +93 -0
- package/src/components/date-picker-view.ts +38 -0
- package/src/components/date-range-view.ts +46 -0
- package/src/components/diff-view-view.ts +85 -0
- package/src/components/drawer-gesture-view.ts +33 -0
- package/src/components/gauge-view.ts +29 -0
- package/src/components/heat-strip-view.ts +68 -0
- package/src/components/json-view-view.ts +54 -0
- package/src/components/line-chart-view.ts +87 -25
- package/src/components/log-viewer-view.ts +51 -0
- package/src/components/markdown-nodes.tsx +150 -0
- package/src/components/nav-list-view.ts +37 -0
- package/src/components/pull-refresh-view.ts +93 -0
- package/src/components/rank-options.ts +61 -0
- package/src/components/searchable-select-keys.ts +118 -0
- package/src/components/searchable-select-view.ts +187 -0
- package/src/components/slider-view.ts +47 -0
- package/src/components/sort-pointer.ts +141 -0
- package/src/components/sort-surface.tsx +231 -0
- package/src/components/sparkline-view.ts +74 -14
- package/src/components/split-pane-view.ts +69 -0
- package/src/components/stat-delta.ts +54 -11
- package/src/components/stepper-view.ts +38 -0
- package/src/components/thunk.ts +9 -0
- package/src/components/timeline-view.ts +29 -0
- package/src/components/tree-node.tsx +133 -0
- package/src/components/tree-view.ts +185 -0
- package/src/components/use-command-palette.ts +153 -0
- package/src/components/virtual-list-anchor.ts +35 -0
- package/src/components/virtual-list-observer.ts +58 -0
- package/src/components/virtual-list-view.ts +145 -0
- package/src/content/ansi.ts +144 -0
- package/src/content/heading-slug.ts +32 -0
- package/src/content/highlight.ts +129 -0
- package/src/content/languages/all.ts +21 -0
- package/src/content/languages/diff.ts +14 -0
- package/src/content/languages/html.ts +16 -0
- package/src/content/languages/json.ts +15 -0
- package/src/content/languages/shell.ts +20 -0
- package/src/content/languages/sql.ts +27 -0
- package/src/content/languages/ts.ts +26 -0
- package/src/content/languages/yaml.ts +18 -0
- package/src/content/line-diff.ts +191 -0
- package/src/content/markdown-inline.ts +368 -0
- package/src/content/markdown-parse.ts +267 -0
- package/src/content/markdown-tree.ts +58 -0
- package/src/content/unified-patch.ts +53 -0
- package/src/drag/pointer-drag.ts +185 -0
- package/src/drag/sort-controller.ts +196 -0
- package/src/drag/sort-view.ts +201 -0
- package/src/errors.ts +1 -1
- package/src/form/field-path.ts +7 -33
- package/src/form/form-binding.ts +5 -8
- package/src/form/form-issue.ts +30 -36
- package/src/form/form-values.ts +3 -2
- package/src/hotkeys-view.ts +150 -0
- package/src/i18n-keys.ts +7 -50
- package/src/index.ts +214 -15
- package/src/list-url.ts +123 -0
- package/src/live-window.ts +23 -0
- package/src/roving.ts +2 -0
- package/src/theme/inert-runtime.ts +4 -0
- package/src/theme/runtime-slot.ts +2 -2
- package/src/theme/solid-adapter.ts +11 -5
- package/src/time-range-url.ts +200 -0
- package/src/tokens/_mixins.scss +1 -1
- package/src/tokens/_touch.scss +30 -1
- package/src/tokens/contrast-pairs.ts +12 -0
- package/src/ui-keys.ts +126 -0
- package/src/use-hotkeys.ts +145 -0
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// The geometry of a BarChart, apart from its markup: where each bar sits, how tall it is, where
|
|
2
|
-
// the grid lines fall.
|
|
2
|
+
// zero and the grid lines fall. Signed: a negative bar hangs below a real zero line, `null` is no
|
|
3
|
+
// bar at all, and zero is a 2px stub on the line — three different marks for three facts.
|
|
4
|
+
|
|
5
|
+
import type { ChartSeries } from './chart-frame-view';
|
|
6
|
+
import { finiteOrNull } from './chart-frame-view';
|
|
3
7
|
|
|
4
8
|
export interface ChartPoint {
|
|
5
9
|
/** The bar's name — a day, a bucket, a label. Read by `<title>` and the axis. */
|
|
6
10
|
readonly key: string;
|
|
7
|
-
|
|
11
|
+
/** `null` is unknown: no bar, and the table says so. Never zero-fill a gap. */
|
|
12
|
+
readonly value: number | null;
|
|
8
13
|
/**
|
|
9
14
|
* A second series, stacked on `value` — failed on top of done. Absent or 0 draws nothing, so a
|
|
10
15
|
* one-series chart is exactly the chart it was.
|
|
11
16
|
*/
|
|
12
|
-
readonly secondary?: number;
|
|
17
|
+
readonly secondary?: number | undefined;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export interface BarRect {
|
|
@@ -30,57 +35,134 @@ export const BAR_CHART = { width: 600, top: 2, plot: 104, height: 108, gap: 3 }
|
|
|
30
35
|
/** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
|
|
31
36
|
export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
/** The shortest mark a known value draws: a zero is still visibly there, unlike a gap. */
|
|
39
|
+
const STUB = 2;
|
|
40
|
+
|
|
41
|
+
/** The value range the plot spans: always through zero, never empty, so it always divides. */
|
|
42
|
+
export interface BarExtent {
|
|
43
|
+
readonly min: number;
|
|
44
|
+
readonly max: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const positive = (value: number | null | undefined): number =>
|
|
48
|
+
Math.max(0, finiteOrNull(value) ?? 0);
|
|
49
|
+
|
|
50
|
+
/** Folds `also` (reference values) into a range, keeping it non-empty. */
|
|
51
|
+
function widened(min: number, max: number, also: readonly number[]): BarExtent {
|
|
52
|
+
const lo = Math.min(min, ...also);
|
|
53
|
+
const hi = Math.max(max, ...also);
|
|
54
|
+
return hi === lo ? { min: lo, max: lo + 1 } : { min: lo, max: hi };
|
|
55
|
+
}
|
|
35
56
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The lowest and highest point — a stack's whole height — always including zero, and any
|
|
59
|
+
* reference value in `also`, so a target above every bar is still on the plot.
|
|
60
|
+
*/
|
|
61
|
+
export function barExtent(points: readonly ChartPoint[], also: readonly number[] = []): BarExtent {
|
|
62
|
+
let min = 0;
|
|
63
|
+
let max = 0;
|
|
39
64
|
for (const point of points) {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
65
|
+
const value = finiteOrNull(point.value);
|
|
66
|
+
if (value !== null && value < 0) min = Math.min(min, value);
|
|
67
|
+
max = Math.max(max, positive(value) + positive(point.secondary));
|
|
68
|
+
}
|
|
69
|
+
return widened(min, max, also);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The same over an n-series chart's every value. */
|
|
73
|
+
export function seriesExtent(
|
|
74
|
+
series: readonly ChartSeries[],
|
|
75
|
+
also: readonly number[] = [],
|
|
76
|
+
): BarExtent {
|
|
77
|
+
let min = 0;
|
|
78
|
+
let max = 0;
|
|
79
|
+
for (const one of series) {
|
|
80
|
+
for (const raw of one.values) {
|
|
81
|
+
const value = finiteOrNull(raw);
|
|
82
|
+
if (value === null) continue;
|
|
83
|
+
min = Math.min(min, value);
|
|
84
|
+
max = Math.max(max, value);
|
|
85
|
+
}
|
|
42
86
|
}
|
|
43
|
-
return max;
|
|
87
|
+
return widened(min, max, also);
|
|
44
88
|
}
|
|
45
89
|
|
|
46
|
-
/** The y of
|
|
90
|
+
/** The y of `value` in the plot. */
|
|
91
|
+
export function valueY(value: number, extent: BarExtent): number {
|
|
92
|
+
return BAR_CHART.top + ((extent.max - value) / (extent.max - extent.min)) * BAR_CHART.plot;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The y of the zero line — the plot's floor for an all-positive chart. */
|
|
96
|
+
export function zeroY(extent: BarExtent): number {
|
|
97
|
+
return valueY(0, extent);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The y of a grid line at `step` of the plot height (1 = the top line). */
|
|
47
101
|
export function gridY(step: number): number {
|
|
48
102
|
return BAR_CHART.top + BAR_CHART.plot - step * BAR_CHART.plot;
|
|
49
103
|
}
|
|
50
104
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
105
|
+
/** `count` slots across the width: the gap gives way before the bars do, at most half a slot. */
|
|
106
|
+
function slots(count: number): { readonly width: number; readonly gap: number } {
|
|
107
|
+
const n = Math.max(count, 1);
|
|
108
|
+
const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / n / 2);
|
|
109
|
+
return { width: (BAR_CHART.width - gap * (n - 1)) / n, gap };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A bar from zero to `value` at `x`: up for a positive, down for a negative, a stub for zero. */
|
|
113
|
+
function barAt(x: number, width: number, value: number, extent: BarExtent): BarRect {
|
|
114
|
+
const zero = zeroY(extent);
|
|
115
|
+
const height = Math.max(STUB, Math.abs(valueY(value, extent) - zero));
|
|
116
|
+
return { x, y: value < 0 ? zero : zero - height, width, height };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One rect per point, oldest first; `null` where the value is unknown. */
|
|
120
|
+
export function barRects(
|
|
121
|
+
points: readonly ChartPoint[],
|
|
122
|
+
extent: BarExtent = barExtent(points),
|
|
123
|
+
): readonly (BarRect | null)[] {
|
|
124
|
+
const { width, gap } = slots(points.length);
|
|
60
125
|
return points.map((point, index) => {
|
|
61
|
-
const value =
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
x: index * (width + gap),
|
|
65
|
-
y: BAR_CHART.top + BAR_CHART.plot - height,
|
|
66
|
-
width,
|
|
67
|
-
height,
|
|
68
|
-
};
|
|
126
|
+
const value = finiteOrNull(point.value);
|
|
127
|
+
return value === null ? null : barAt(index * (width + gap), width, value, extent);
|
|
69
128
|
});
|
|
70
129
|
}
|
|
71
130
|
|
|
72
131
|
/**
|
|
73
|
-
* The second series' rect per point, stacked on that point's
|
|
74
|
-
* Same scale as `barRects`,
|
|
132
|
+
* The second series' rect per point, stacked on the positive part of that point's bar (or on the
|
|
133
|
+
* zero line); `null` where it is 0. Same scale as `barRects`, read against the same grid.
|
|
75
134
|
*/
|
|
76
|
-
export function secondaryRects(
|
|
77
|
-
|
|
78
|
-
|
|
135
|
+
export function secondaryRects(
|
|
136
|
+
points: readonly ChartPoint[],
|
|
137
|
+
extent: BarExtent = barExtent(points),
|
|
138
|
+
): readonly (BarRect | null)[] {
|
|
139
|
+
const { width, gap } = slots(points.length);
|
|
79
140
|
return points.map((point, index) => {
|
|
80
|
-
const value =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
return { x:
|
|
141
|
+
const value = positive(point.secondary);
|
|
142
|
+
if (value === 0) return null;
|
|
143
|
+
const base = valueY(positive(point.value), extent);
|
|
144
|
+
const top = valueY(positive(point.value) + value, extent);
|
|
145
|
+
return { x: index * (width + gap), y: top, width, height: base - top };
|
|
85
146
|
});
|
|
86
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* n series over shared keys, side by side within each key's slot — every bar signed on its own,
|
|
151
|
+
* so a negative in one series never hides another. `[series][key]`, `null` for a gap.
|
|
152
|
+
*/
|
|
153
|
+
export function groupedBarRects(
|
|
154
|
+
keyCount: number,
|
|
155
|
+
series: readonly ChartSeries[],
|
|
156
|
+
extent: BarExtent = seriesExtent(series),
|
|
157
|
+
): readonly (readonly (BarRect | null)[])[] {
|
|
158
|
+
const slot = slots(keyCount);
|
|
159
|
+
const inner = Math.min(1, slot.width / Math.max(series.length, 1) / 4);
|
|
160
|
+
const width = (slot.width - inner * (series.length - 1)) / Math.max(series.length, 1);
|
|
161
|
+
return series.map((one, s) =>
|
|
162
|
+
Array.from({ length: keyCount }, (_, k) => {
|
|
163
|
+
const value = finiteOrNull(one.values[k]);
|
|
164
|
+
const x = k * (slot.width + slot.gap) + s * (width + inner);
|
|
165
|
+
return value === null ? null : barAt(x, width, value, extent);
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A board's columns, checked: column ids unique (a move names one) and card keys unique across
|
|
2
|
+
// every column (a card is one node). Refused as X_UI_INVALID_VALUE naming the id.
|
|
3
|
+
|
|
4
|
+
import { type SortGroup, sortPositions } from '../drag/sort-view';
|
|
5
|
+
import { invalidValueError } from '../errors';
|
|
6
|
+
|
|
7
|
+
export interface BoardGroupInput<T> {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly items: readonly T[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function boardGroups<T, G extends BoardGroupInput<T>>(
|
|
14
|
+
groups: readonly G[],
|
|
15
|
+
itemKey: (item: T) => string,
|
|
16
|
+
): readonly G[] {
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
for (const group of groups) {
|
|
19
|
+
if (seen.has(group.id))
|
|
20
|
+
throw invalidValueError('Board', group.id, 'a column id unique on the board');
|
|
21
|
+
seen.add(group.id);
|
|
22
|
+
}
|
|
23
|
+
sortPositions(groups as readonly SortGroup<T>[], itemKey);
|
|
24
|
+
return groups;
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The bottom bar's one rule beyond `currentOf`: five destinations at most. A sixth no longer fits
|
|
2
|
+
// a 320px phone at a touch target each, and the thumb cannot reach a scroll it cannot see.
|
|
3
|
+
|
|
4
|
+
import { invalidValueError } from '../errors';
|
|
5
|
+
|
|
6
|
+
export const BOTTOM_NAV_MAX = 5;
|
|
7
|
+
|
|
8
|
+
/** `items` unchanged, or `X_UI_INVALID_VALUE` naming the count when there are more than five. */
|
|
9
|
+
export function bottomNavItems<T>(items: readonly T[]): readonly T[] {
|
|
10
|
+
if (items.length > BOTTOM_NAV_MAX) {
|
|
11
|
+
throw invalidValueError(
|
|
12
|
+
'BottomNav',
|
|
13
|
+
`${items.length} items`,
|
|
14
|
+
`at most ${BOTTOM_NAV_MAX} destinations — move the rest into AppShell's sidebar (NavList)`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return items;
|
|
18
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// A month as a grid of `PlainDate`s, and every rule a calendar keys on: week start from the
|
|
2
|
+
// locale, 6 rows always so the box never jumps, the arrow step (flipped by `dir`, skipping what is
|
|
3
|
+
// disabled), and the one day holding the tab stop. Pure, over `@ultimat3/time`'s plain dates.
|
|
4
|
+
|
|
5
|
+
import { cachedFormatter } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
addPlainDays,
|
|
8
|
+
addPlainMonths,
|
|
9
|
+
comparePlainDates,
|
|
10
|
+
isPlainDate,
|
|
11
|
+
type PlainDate,
|
|
12
|
+
plainDateOf,
|
|
13
|
+
plainDateParts,
|
|
14
|
+
plainDateToUtcInstant,
|
|
15
|
+
} from '@ultimat3/time';
|
|
16
|
+
import { invalidValueError } from '../errors';
|
|
17
|
+
|
|
18
|
+
/** ISO weekday: 1 is Monday, 7 is Sunday — `Intl.Locale#getWeekInfo().firstDay`'s numbering. */
|
|
19
|
+
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
20
|
+
|
|
21
|
+
export interface CalendarBounds {
|
|
22
|
+
readonly min?: PlainDate | undefined;
|
|
23
|
+
readonly max?: PlainDate | undefined;
|
|
24
|
+
readonly isDisabled?: ((date: PlainDate) => boolean) | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CalendarDay {
|
|
28
|
+
readonly date: PlainDate;
|
|
29
|
+
readonly day: number;
|
|
30
|
+
/** In the month shown; the others pad the grid to whole weeks. */
|
|
31
|
+
readonly inMonth: boolean;
|
|
32
|
+
readonly disabled: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WEEKS = 6;
|
|
36
|
+
/** How far an arrow keeps stepping over disabled days before it gives up and stays put. */
|
|
37
|
+
const MAX_SKIP = 366;
|
|
38
|
+
|
|
39
|
+
/** `value` as a `PlainDate`, or `X_UI_INVALID_VALUE` naming the prop. */
|
|
40
|
+
export function plainDateProp(prop: string, value: unknown): PlainDate {
|
|
41
|
+
if (!isPlainDate(value)) throw invalidValueError(prop, value, 'a YYYY-MM-DD calendar date');
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An optional date prop: absent or empty is no date, anything else must be a `PlainDate`. */
|
|
46
|
+
export const optionalDateProp = (prop: string, value: string | undefined): PlainDate | undefined =>
|
|
47
|
+
value === undefined || value === '' ? undefined : plainDateProp(prop, value);
|
|
48
|
+
|
|
49
|
+
/** `min` after `max` leaves no day to pick: refused, naming both. */
|
|
50
|
+
export function assertBounds(component: string, bounds: CalendarBounds): void {
|
|
51
|
+
const { min, max } = bounds;
|
|
52
|
+
if (min !== undefined) plainDateProp(`${component} min`, min);
|
|
53
|
+
if (max !== undefined) plainDateProp(`${component} max`, max);
|
|
54
|
+
if (min !== undefined && max !== undefined && comparePlainDates(min, max) > 0) {
|
|
55
|
+
throw invalidValueError(
|
|
56
|
+
`${component} min`,
|
|
57
|
+
`${min} after max ${max}`,
|
|
58
|
+
'a min on or before max',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface WeekInfoLocale {
|
|
64
|
+
getWeekInfo?: () => { readonly firstDay?: number };
|
|
65
|
+
readonly weekInfo?: { readonly firstDay?: number };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isWeekday = (value: unknown): value is Weekday =>
|
|
69
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 7;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The locale's first day of the week, from the platform (`en-US` Sunday, `de` Monday), so no table
|
|
73
|
+
* ships. `override` wins; an engine without `getWeekInfo` falls back to ISO Monday.
|
|
74
|
+
*/
|
|
75
|
+
export function weekStartOf(locale: string, override?: Weekday | undefined): Weekday {
|
|
76
|
+
if (override !== undefined) {
|
|
77
|
+
if (!isWeekday(override))
|
|
78
|
+
throw invalidValueError('Calendar weekStart', override, 'a weekday 1–7');
|
|
79
|
+
return override;
|
|
80
|
+
}
|
|
81
|
+
const info = new Intl.Locale(locale) as unknown as WeekInfoLocale;
|
|
82
|
+
const firstDay = (info.getWeekInfo?.() ?? info.weekInfo)?.firstDay;
|
|
83
|
+
return isWeekday(firstDay) ? firstDay : 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The ISO weekday of a plain date. */
|
|
87
|
+
export function weekdayOf(date: PlainDate): Weekday {
|
|
88
|
+
const sunday0 = plainDateToUtcInstant(date).getUTCDay();
|
|
89
|
+
return (sunday0 === 0 ? 7 : sunday0) as Weekday;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const firstOfMonth = (date: PlainDate): PlainDate => {
|
|
93
|
+
const { year, month } = plainDateParts(date);
|
|
94
|
+
return plainDateOf({ year, month, day: 1 });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function isDayDisabled(date: PlainDate, bounds: CalendarBounds): boolean {
|
|
98
|
+
if (bounds.min !== undefined && comparePlainDates(date, bounds.min) < 0) return true;
|
|
99
|
+
if (bounds.max !== undefined && comparePlainDates(date, bounds.max) > 0) return true;
|
|
100
|
+
return bounds.isDisabled?.(date) === true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Six weeks starting on `weekStart`, around `month`'s first day. Always 42 days. */
|
|
104
|
+
export function monthGrid(
|
|
105
|
+
month: PlainDate,
|
|
106
|
+
weekStart: Weekday,
|
|
107
|
+
bounds: CalendarBounds = {},
|
|
108
|
+
): readonly (readonly CalendarDay[])[] {
|
|
109
|
+
const first = firstOfMonth(month);
|
|
110
|
+
const lead = (weekdayOf(first) - weekStart + 7) % 7;
|
|
111
|
+
const start = addPlainDays(first, -lead);
|
|
112
|
+
const shown = plainDateParts(first).month;
|
|
113
|
+
return Array.from({ length: WEEKS }, (_, week) =>
|
|
114
|
+
Array.from({ length: 7 }, (_, weekday) => {
|
|
115
|
+
const date = addPlainDays(start, week * 7 + weekday);
|
|
116
|
+
const parts = plainDateParts(date);
|
|
117
|
+
return {
|
|
118
|
+
date,
|
|
119
|
+
day: parts.day,
|
|
120
|
+
inMonth: parts.month === shown,
|
|
121
|
+
disabled: isDayDisabled(date, bounds),
|
|
122
|
+
};
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface StepOptions extends CalendarBounds {
|
|
128
|
+
readonly dir: 'ltr' | 'rtl';
|
|
129
|
+
readonly weekStart: Weekday;
|
|
130
|
+
readonly shiftKey?: boolean | undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One key's move, ignoring what is disabled. `undefined` is a key the grid does not take. */
|
|
134
|
+
function rawStep(from: PlainDate, key: string, options: StepOptions): PlainDate | undefined {
|
|
135
|
+
const forward = options.dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
|
|
136
|
+
const back = options.dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
|
|
137
|
+
const offset = (weekdayOf(from) - options.weekStart + 7) % 7;
|
|
138
|
+
switch (key) {
|
|
139
|
+
case forward:
|
|
140
|
+
return addPlainDays(from, 1);
|
|
141
|
+
case back:
|
|
142
|
+
return addPlainDays(from, -1);
|
|
143
|
+
case 'ArrowDown':
|
|
144
|
+
return addPlainDays(from, 7);
|
|
145
|
+
case 'ArrowUp':
|
|
146
|
+
return addPlainDays(from, -7);
|
|
147
|
+
case 'Home':
|
|
148
|
+
return addPlainDays(from, -offset);
|
|
149
|
+
case 'End':
|
|
150
|
+
return addPlainDays(from, 6 - offset);
|
|
151
|
+
case 'PageDown':
|
|
152
|
+
return addPlainMonths(from, options.shiftKey === true ? 12 : 1);
|
|
153
|
+
case 'PageUp':
|
|
154
|
+
return addPlainMonths(from, options.shiftKey === true ? -12 : -1);
|
|
155
|
+
default:
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The day a key lands on. A disabled day is stepped over one day at a time in the direction the
|
|
162
|
+
* key moved, so the roving stop never lands on a day that refuses focus. Stays put (`undefined`)
|
|
163
|
+
* when nothing reachable is left that way.
|
|
164
|
+
*/
|
|
165
|
+
export function stepDay(from: PlainDate, key: string, options: StepOptions): PlainDate | undefined {
|
|
166
|
+
let target = rawStep(from, key, options);
|
|
167
|
+
if (target === undefined) return undefined;
|
|
168
|
+
const ahead = comparePlainDates(target, from) > 0;
|
|
169
|
+
for (let skipped = 0; skipped < MAX_SKIP; skipped += 1) {
|
|
170
|
+
if (target === from) return undefined;
|
|
171
|
+
if (!isDayDisabled(target, options)) return target;
|
|
172
|
+
const outOfRange =
|
|
173
|
+
(ahead && options.max !== undefined && comparePlainDates(target, options.max) > 0) ||
|
|
174
|
+
(!ahead && options.min !== undefined && comparePlainDates(target, options.min) < 0);
|
|
175
|
+
if (outOfRange) return undefined;
|
|
176
|
+
target = addPlainDays(target, ahead ? 1 : -1);
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The one day carrying `tabindex="0"`: the focused day, else the selected one, else the first
|
|
183
|
+
* enabled day of the month — each only when it is in the month shown and enabled. `undefined` when
|
|
184
|
+
* the whole month is disabled, and then nothing is tabbable, which is the correct answer.
|
|
185
|
+
*/
|
|
186
|
+
export function rovingDay(
|
|
187
|
+
grid: readonly (readonly CalendarDay[])[],
|
|
188
|
+
candidates: readonly (PlainDate | undefined)[],
|
|
189
|
+
): PlainDate | undefined {
|
|
190
|
+
const days = grid.flat().filter((day) => day.inMonth && !day.disabled);
|
|
191
|
+
for (const candidate of candidates) {
|
|
192
|
+
if (candidate !== undefined && days.some((day) => day.date === candidate)) return candidate;
|
|
193
|
+
}
|
|
194
|
+
return days[0]?.date;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
198
|
+
/** `timeZone: 'UTC'` on a plain date's UTC midnight: the date named, never shifted by a zone. */
|
|
199
|
+
const formatter = (locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat =>
|
|
200
|
+
cachedFormatter(
|
|
201
|
+
formatters,
|
|
202
|
+
`${locale}|${JSON.stringify(options)}`,
|
|
203
|
+
() => new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
export const monthLabel = (month: PlainDate, locale: string): string =>
|
|
207
|
+
formatter(locale, { month: 'long', year: 'numeric' }).format(plainDateToUtcInstant(month));
|
|
208
|
+
|
|
209
|
+
export const dayLabel = (date: PlainDate, locale: string): string =>
|
|
210
|
+
formatter(locale, { dateStyle: 'full' }).format(plainDateToUtcInstant(date));
|
|
211
|
+
|
|
212
|
+
/** Seven column headers from `weekStart`: the short name shown, the long one as `abbr`. */
|
|
213
|
+
export function weekdayLabels(
|
|
214
|
+
locale: string,
|
|
215
|
+
weekStart: Weekday,
|
|
216
|
+
): readonly { readonly short: string; readonly long: string }[] {
|
|
217
|
+
// 2024-01-01 is a Monday: ISO weekday `n` is that date plus `n - 1` days.
|
|
218
|
+
const monday = plainDateOf({ year: 2024, month: 1, day: 1 });
|
|
219
|
+
return Array.from({ length: 7 }, (_, index) => {
|
|
220
|
+
const date = plainDateToUtcInstant(addPlainDays(monday, (weekStart - 1 + index) % 7));
|
|
221
|
+
return {
|
|
222
|
+
short: formatter(locale, { weekday: 'narrow' }).format(date),
|
|
223
|
+
long: formatter(locale, { weekday: 'long' }).format(date),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The shapes a chart refuses rather than draws wrong: a series whose values do not line up with
|
|
2
|
+
// the keys, x positions that are not ascending, gauge thresholds out of order. A misaligned row
|
|
3
|
+
// drawn anyway puts a value under the wrong key — a chart that lies quietly — so each is coded.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { UI_ERROR_CODES, UiError } from '../errors';
|
|
7
|
+
import type { ChartSeries } from './chart-frame-view';
|
|
8
|
+
|
|
9
|
+
function refused(cause: string, fix: string): UiError {
|
|
10
|
+
return new UiError({ code: UI_ERROR_CODES.invalidValue, cause, fix });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Every series carries exactly one value per key; `null` is how a gap is said. */
|
|
14
|
+
export function checkSeries(
|
|
15
|
+
chart: string,
|
|
16
|
+
keys: readonly string[],
|
|
17
|
+
series: readonly ChartSeries[],
|
|
18
|
+
): void {
|
|
19
|
+
for (const one of series) {
|
|
20
|
+
if (one.values.length === keys.length) continue;
|
|
21
|
+
throw refused(
|
|
22
|
+
`<${chart}> series ${renderCauseValue(one.label)} has ${one.values.length} values for ${keys.length} keys`,
|
|
23
|
+
'values: keys.map((key) => byKey.get(key) ?? null) # one value per key, null for a gap — never a shorter row',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True x positions: one per key, finite, strictly ascending. */
|
|
29
|
+
export function checkAt(chart: string, keys: readonly string[], at: readonly number[]): void {
|
|
30
|
+
const fix =
|
|
31
|
+
'at: rows.map((row) => row.at.getTime()) # one finite number per key, oldest first, no repeats';
|
|
32
|
+
if (at.length !== keys.length) {
|
|
33
|
+
throw refused(`<${chart}> at has ${at.length} positions for ${keys.length} keys`, fix);
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i < at.length; i += 1) {
|
|
36
|
+
const value = at[i] as number;
|
|
37
|
+
if (!Number.isFinite(value) || (i > 0 && value <= (at[i - 1] as number))) {
|
|
38
|
+
throw refused(
|
|
39
|
+
`<${chart}> at[${i}] is ${renderCauseValue(value)}, which is not finite and above at[${i - 1}]`,
|
|
40
|
+
fix,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Thresholds ascend by `at`, so "the last one passed" is one answer. */
|
|
47
|
+
export function checkThresholds(
|
|
48
|
+
chart: string,
|
|
49
|
+
thresholds: readonly { readonly at: number }[],
|
|
50
|
+
): void {
|
|
51
|
+
for (let i = 0; i < thresholds.length; i += 1) {
|
|
52
|
+
const at = thresholds[i]?.at as number;
|
|
53
|
+
if (Number.isFinite(at) && (i === 0 || at > (thresholds[i - 1]?.at as number))) continue;
|
|
54
|
+
throw refused(
|
|
55
|
+
`<${chart}> thresholds[${i}].at is ${renderCauseValue(at)}, which is not finite and above the one before`,
|
|
56
|
+
"thresholds: [{ at: 70, tone: 'warning' }, { at: 90, tone: 'danger' }] # ascending by at, in the value's unit",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// no-colour reader get instead of the picture, and how a number becomes text — always in a locale
|
|
4
4
|
// the caller passed, never the runtime's default.
|
|
5
5
|
|
|
6
|
+
import type { Tone } from './variants';
|
|
7
|
+
|
|
6
8
|
/** The chart colour roles, `chart-1` … `chart-8` (`--color-chart-N`). */
|
|
7
9
|
export const CHART_SERIES_SLOTS = 8;
|
|
8
10
|
|
|
@@ -144,8 +146,13 @@ export function finiteOrNull(value: number | null | undefined): number | null {
|
|
|
144
146
|
export interface ChartSeries {
|
|
145
147
|
/** The series' name, already translated: the legend entry and the table's column head. */
|
|
146
148
|
readonly label: string;
|
|
147
|
-
/** One value per key, in key order; `null` is a gap in the line and
|
|
149
|
+
/** One value per key, in key order; `null` is a gap in the line and `ui.chart.none` in the table. */
|
|
148
150
|
readonly values: readonly (number | null)[];
|
|
151
|
+
/**
|
|
152
|
+
* A status colour in place of the slot's — an error rate in `danger`. The dash and marker stay,
|
|
153
|
+
* so the series is never told apart by colour alone.
|
|
154
|
+
*/
|
|
155
|
+
readonly tone?: Tone | undefined;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
export interface ChartTableRow {
|
|
@@ -166,6 +173,8 @@ export interface ChartTableInput {
|
|
|
166
173
|
readonly keys: readonly string[];
|
|
167
174
|
readonly series: readonly ChartSeries[];
|
|
168
175
|
readonly format: (value: number) => string;
|
|
176
|
+
/** What a gap's cell says, already translated (`ui.chart.none`) — an empty cell reads as nothing. */
|
|
177
|
+
readonly none: string;
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
/** The same numbers the picture draws, as rows a screen reader walks cell by cell. */
|
|
@@ -177,7 +186,7 @@ export function chartTable(input: ChartTableInput): ChartTable {
|
|
|
177
186
|
key,
|
|
178
187
|
cells: input.series.map((series) => {
|
|
179
188
|
const value = finiteOrNull(series.values[index]);
|
|
180
|
-
return value === null ?
|
|
189
|
+
return value === null ? input.none : input.format(value);
|
|
181
190
|
}),
|
|
182
191
|
})),
|
|
183
192
|
};
|
|
@@ -217,3 +226,25 @@ export function hitEdge(x: number, width: number): 'start' | 'middle' | 'end' {
|
|
|
217
226
|
if (share > 0.8) return 'end';
|
|
218
227
|
return 'middle';
|
|
219
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A point's stable identity across ticks: its series and its key, so a live window that drops the
|
|
232
|
+
* oldest key keeps every other point's node — and the focus on it. A repeated key gets `#n`, the
|
|
233
|
+
* first `n` no earlier id holds: a key may itself read `a#1`, and a duplicate id throws in
|
|
234
|
+
* `keyedRows`, taking the whole chart with it.
|
|
235
|
+
*/
|
|
236
|
+
export function hitIds(series: number, keys: readonly string[]): readonly string[] {
|
|
237
|
+
const seen = new Map<string, number>();
|
|
238
|
+
const used = new Set<string>();
|
|
239
|
+
return keys.map((key) => {
|
|
240
|
+
let n = seen.get(key) ?? 0;
|
|
241
|
+
let id = n === 0 ? `${series}:${key}` : `${series}:${key}#${n}`;
|
|
242
|
+
while (used.has(id)) {
|
|
243
|
+
n += 1;
|
|
244
|
+
id = `${series}:${key}#${n}`;
|
|
245
|
+
}
|
|
246
|
+
seen.set(key, n + 1);
|
|
247
|
+
used.add(id);
|
|
248
|
+
return id;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Reference lines — a target, a limit, last week's mean — as geometry every xy chart shares: a
|
|
2
|
+
// horizontal rule at a value, its label, and its tone. The value widens the chart's range, so a
|
|
3
|
+
// target above every bar is still on the plot rather than silently off it.
|
|
4
|
+
|
|
5
|
+
import { finiteOrNull } from './chart-frame-view';
|
|
6
|
+
import type { Tone } from './variants';
|
|
7
|
+
|
|
8
|
+
export interface ChartReference {
|
|
9
|
+
readonly value: number;
|
|
10
|
+
/** Already translated: "Target", "SLO 99.9%". Drawn beside the rule with its value. */
|
|
11
|
+
readonly label: string;
|
|
12
|
+
/** Default `neutral`. The rule is dashed in every tone, so it never reads as a series. */
|
|
13
|
+
readonly tone?: Tone | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReferenceLine {
|
|
17
|
+
readonly value: number;
|
|
18
|
+
readonly y: number;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly tone: Tone;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The finite values among `references` — what an extent folds in. */
|
|
24
|
+
export function referenceValues(references: readonly ChartReference[] | undefined): number[] {
|
|
25
|
+
return (references ?? []).flatMap((ref) => {
|
|
26
|
+
const value = finiteOrNull(ref.value);
|
|
27
|
+
return value === null ? [] : [value];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Each drawable reference at its y, by the chart's own scale; a non-finite value is dropped. */
|
|
32
|
+
export function referenceLines(
|
|
33
|
+
references: readonly ChartReference[] | undefined,
|
|
34
|
+
yOf: (value: number) => number,
|
|
35
|
+
): readonly ReferenceLine[] {
|
|
36
|
+
return (references ?? []).flatMap((ref) => {
|
|
37
|
+
const value = finiteOrNull(ref.value);
|
|
38
|
+
if (value === null) return [];
|
|
39
|
+
return [{ value, y: yOf(value), label: ref.label, tone: ref.tone ?? 'neutral' }];
|
|
40
|
+
});
|
|
41
|
+
}
|