@xemahq/ui-kernel 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/biome-host/define-web-biome.d.ts +3 -3
- package/dist/lib/biome-host/define-web-biome.d.ts.map +1 -1
- package/dist/lib/biome-host/define-web-biome.js +23 -2
- package/dist/lib/biome-host/define-web-biome.js.map +1 -1
- package/dist/lib/biome-host/frontend-biome.d.ts +4 -3
- package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
- package/dist/lib/biome-host/host-bridge.d.ts +2 -0
- package/dist/lib/biome-host/host-bridge.d.ts.map +1 -1
- package/dist/lib/biome-host/host-bridge.js.map +1 -1
- package/dist/lib/biome-host/i18n-hooks.d.ts +4 -0
- package/dist/lib/biome-host/i18n-hooks.d.ts.map +1 -0
- package/dist/lib/biome-host/i18n-hooks.js +22 -0
- package/dist/lib/biome-host/i18n-hooks.js.map +1 -0
- package/dist/lib/biome-host/i18n-port.d.ts +15 -0
- package/dist/lib/biome-host/i18n-port.d.ts.map +1 -0
- package/dist/lib/biome-host/i18n-port.js +29 -0
- package/dist/lib/biome-host/i18n-port.js.map +1 -0
- package/dist/lib/biome-host/index.d.ts +2 -1
- package/dist/lib/biome-host/index.d.ts.map +1 -1
- package/dist/lib/biome-host/index.js +2 -1
- package/dist/lib/biome-host/index.js.map +1 -1
- package/dist/lib/biome-host/realtime-port.d.ts +1 -1
- package/dist/lib/biome-host/realtime-port.d.ts.map +1 -1
- package/dist/session-kit/approvals/approval-model.d.ts.map +1 -1
- package/dist/session-kit/approvals/approval-model.js.map +1 -1
- package/dist/ui/branding/app-branding-provider.d.ts +17 -0
- package/dist/ui/branding/app-branding-provider.d.ts.map +1 -0
- package/dist/ui/branding/app-branding-provider.js +81 -0
- package/dist/ui/branding/app-branding-provider.js.map +1 -0
- package/dist/ui/branding/sanitize.d.ts +4 -0
- package/dist/ui/branding/sanitize.d.ts.map +1 -0
- package/dist/ui/branding/sanitize.js +47 -0
- package/dist/ui/branding/sanitize.js.map +1 -0
- package/dist/ui/breakpoints.d.ts +2 -0
- package/dist/ui/breakpoints.d.ts.map +1 -0
- package/dist/ui/breakpoints.js +5 -0
- package/dist/ui/breakpoints.js.map +1 -0
- package/dist/ui/charts/bar-chart.d.ts +7 -0
- package/dist/ui/charts/bar-chart.d.ts.map +1 -0
- package/dist/ui/charts/bar-chart.js +47 -0
- package/dist/ui/charts/bar-chart.js.map +1 -0
- package/dist/ui/charts/chart-frame.d.ts +25 -0
- package/dist/ui/charts/chart-frame.d.ts.map +1 -0
- package/dist/ui/charts/chart-frame.js +23 -0
- package/dist/ui/charts/chart-frame.js.map +1 -0
- package/dist/ui/charts/chart-types.d.ts +19 -0
- package/dist/ui/charts/chart-types.d.ts.map +1 -0
- package/dist/ui/charts/chart-types.js +3 -0
- package/dist/ui/charts/chart-types.js.map +1 -0
- package/dist/ui/charts/chart-utils.d.ts +22 -0
- package/dist/ui/charts/chart-utils.d.ts.map +1 -0
- package/dist/ui/charts/chart-utils.js +97 -0
- package/dist/ui/charts/chart-utils.js.map +1 -0
- package/dist/ui/charts/donut-chart.d.ts +7 -0
- package/dist/ui/charts/donut-chart.d.ts.map +1 -0
- package/dist/ui/charts/donut-chart.js +55 -0
- package/dist/ui/charts/donut-chart.js.map +1 -0
- package/dist/ui/charts/index.d.ts +8 -0
- package/dist/ui/charts/index.d.ts.map +1 -0
- package/dist/ui/charts/index.js +21 -0
- package/dist/ui/charts/index.js.map +1 -0
- package/dist/ui/charts/line-chart.d.ts +7 -0
- package/dist/ui/charts/line-chart.d.ts.map +1 -0
- package/dist/ui/charts/line-chart.js +70 -0
- package/dist/ui/charts/line-chart.js.map +1 -0
- package/dist/ui/charts/sparkline.d.ts +13 -0
- package/dist/ui/charts/sparkline.d.ts.map +1 -0
- package/dist/ui/charts/sparkline.js +39 -0
- package/dist/ui/charts/sparkline.js.map +1 -0
- package/dist/ui/hooks/use-is-mobile.d.ts +2 -0
- package/dist/ui/hooks/use-is-mobile.d.ts.map +1 -0
- package/dist/ui/hooks/use-is-mobile.js +19 -0
- package/dist/ui/hooks/use-is-mobile.js.map +1 -0
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +14 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/pdf/pdf-preview.d.ts +8 -0
- package/dist/ui/pdf/pdf-preview.d.ts.map +1 -0
- package/dist/ui/pdf/pdf-preview.js +34 -0
- package/dist/ui/pdf/pdf-preview.js.map +1 -0
- package/dist/ui/primitives/preview-device-frame.d.ts +9 -0
- package/dist/ui/primitives/preview-device-frame.d.ts.map +1 -0
- package/dist/ui/primitives/preview-device-frame.js +90 -0
- package/dist/ui/primitives/preview-device-frame.js.map +1 -0
- package/package.json +8 -7
- package/src/lib/biome-host/define-web-biome.test.js +93 -0
- package/src/lib/biome-host/define-web-biome.ts +76 -15
- package/src/lib/biome-host/frontend-biome.ts +47 -18
- package/src/lib/biome-host/host-bridge.ts +13 -0
- package/src/lib/biome-host/i18n-hooks.ts +50 -0
- package/src/lib/biome-host/i18n-port.test.ts +50 -0
- package/src/lib/biome-host/i18n-port.ts +96 -0
- package/src/lib/biome-host/index.ts +2 -1
- package/src/lib/biome-host/realtime-port.ts +9 -3
- package/src/lib/system-bus/enums.ts +1 -1
- package/src/lib/system-bus/index.ts +1 -1
- package/src/lib/system-bus/intents.ts +1 -1
- package/src/session-kit/approvals/ApprovalCard.tsx +1 -1
- package/src/session-kit/approvals/ApprovalsCenter.tsx +2 -2
- package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +1 -1
- package/src/session-kit/approvals/Obligation.tsx +1 -1
- package/src/session-kit/approvals/ScopedBody.tsx +1 -1
- package/src/session-kit/approvals/approval-model.ts +7 -8
- package/src/session-kit/approvals/index.ts +1 -1
- package/src/session-kit/approvals/obligation-display.ts +1 -1
- package/src/session-kit/approvals/risk-accent.ts +1 -1
- package/src/session-kit/combobox/Combobox.tsx +1 -1
- package/src/session-kit/display/MessageTurn.tsx +1 -1
- package/src/session-kit/display/TokenMeter.tsx +1 -1
- package/src/session-kit/index.ts +3 -3
- package/src/session-kit/lib/enums.ts +1 -1
- package/src/session-kit/lib/status-dot.ts +1 -1
- package/src/session-kit/primitives/RiskPill.tsx +1 -1
- package/src/ui/branding/app-branding-provider.tsx +146 -0
- package/src/ui/branding/sanitize.ts +77 -0
- package/src/ui/breakpoints.ts +14 -0
- package/src/ui/charts/bar-chart.tsx +135 -0
- package/src/ui/charts/chart-frame.tsx +112 -0
- package/src/ui/charts/chart-types.ts +52 -0
- package/src/ui/charts/chart-utils.ts +158 -0
- package/src/ui/charts/charts.test.tsx +204 -0
- package/src/ui/charts/donut-chart.tsx +111 -0
- package/src/ui/charts/index.ts +27 -0
- package/src/ui/charts/line-chart.tsx +180 -0
- package/src/ui/charts/sparkline.tsx +101 -0
- package/src/ui/hooks/use-is-mobile.ts +28 -0
- package/src/ui/index.ts +22 -0
- package/src/ui/pdf/pdf-preview.tsx +122 -0
- package/src/ui/primitives/preview-device-frame.tsx +205 -0
- package/dist/lib/biome-host/composition-validation.d.ts +0 -22
- package/dist/lib/biome-host/composition-validation.d.ts.map +0 -1
- package/dist/lib/biome-host/composition-validation.js +0 -127
- package/dist/lib/biome-host/composition-validation.js.map +0 -1
- package/dist/lib/biome-host/nav.d.ts +0 -17
- package/dist/lib/biome-host/nav.d.ts.map +0 -1
- package/dist/lib/biome-host/nav.js +0 -52
- package/dist/lib/biome-host/nav.js.map +0 -1
- package/dist/registry/lib/composition-validation-host.d.ts +0 -3
- package/dist/registry/lib/composition-validation-host.d.ts.map +0 -1
- package/dist/registry/lib/composition-validation-host.js +0 -10
- package/dist/registry/lib/composition-validation-host.js.map +0 -1
- package/src/lib/biome-host/nav.ts +0 -83
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Chart primitives — render proofs (Wave 9.2)
|
|
3
|
+
//
|
|
4
|
+
// Repo convention: `renderToStaticMarkup` (no @testing-library, effects DON'T
|
|
5
|
+
// run — so `useIsMobile` stays false and the desktop layout renders). We assert
|
|
6
|
+
// on the produced SVG markup: element counts, the fluid `viewBox` + width,
|
|
7
|
+
// the aria summary, the legend, and — critically — that no coordinate ever
|
|
8
|
+
// serializes as `NaN`.
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { BarChart } from './bar-chart';
|
|
14
|
+
import { DonutChart } from './donut-chart';
|
|
15
|
+
import { LineChart } from './line-chart';
|
|
16
|
+
import { Sparkline } from './sparkline';
|
|
17
|
+
|
|
18
|
+
const count = (markup: string, tag: string): number =>
|
|
19
|
+
markup.match(new RegExp(`<${tag}[\\s>]`, 'g'))?.length ?? 0;
|
|
20
|
+
|
|
21
|
+
describe('BarChart', () => {
|
|
22
|
+
const data = [
|
|
23
|
+
{ label: 'Q1', value: 120 },
|
|
24
|
+
{ label: 'Q2', value: 340 },
|
|
25
|
+
{ label: 'Q3', value: 210 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
it('renders one bar per datum, fluid SVG, legend and aria summary', () => {
|
|
29
|
+
const markup = renderToStaticMarkup(<BarChart title="Revenue" data={data} />);
|
|
30
|
+
expect(count(markup, 'rect')).toBe(3);
|
|
31
|
+
expect(markup).toContain('viewBox="0 0 640 360"');
|
|
32
|
+
expect(markup).toContain('width="100%"');
|
|
33
|
+
expect(markup).toContain('role="img"');
|
|
34
|
+
// aria summary carries the title + the values (data is never color-only).
|
|
35
|
+
expect(markup).toContain('aria-label="Revenue:');
|
|
36
|
+
expect(markup).toContain('Q2 340');
|
|
37
|
+
// legend beside the plot (desktop: effects don't run so useIsMobile=false).
|
|
38
|
+
expect(count(markup, 'li')).toBe(3);
|
|
39
|
+
expect(markup).not.toContain('NaN');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders an explicit empty state (no NaN) for an empty series', () => {
|
|
43
|
+
const markup = renderToStaticMarkup(<BarChart title="Revenue" data={[]} />);
|
|
44
|
+
expect(markup).toContain('role="img"');
|
|
45
|
+
expect(markup).toContain('aria-label="Revenue: No data"');
|
|
46
|
+
expect(count(markup, 'rect')).toBe(0);
|
|
47
|
+
expect(markup).not.toContain('NaN');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('skips a non-finite value instead of emitting NaN coordinates', () => {
|
|
51
|
+
const markup = renderToStaticMarkup(
|
|
52
|
+
<BarChart
|
|
53
|
+
title="Revenue"
|
|
54
|
+
data={[
|
|
55
|
+
{ label: 'Q1', value: 120 },
|
|
56
|
+
{ label: 'Q2', value: Number.NaN },
|
|
57
|
+
{ label: 'Q3', value: Number.POSITIVE_INFINITY },
|
|
58
|
+
]}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
// only the one finite bar survives
|
|
62
|
+
expect(count(markup, 'rect')).toBe(1);
|
|
63
|
+
expect(markup).not.toContain('NaN');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('honours a custom value formatter', () => {
|
|
67
|
+
const markup = renderToStaticMarkup(
|
|
68
|
+
<BarChart title="Revenue" data={data} formatValue={(v) => `$${v}`} />,
|
|
69
|
+
);
|
|
70
|
+
expect(markup).toContain('$340');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('LineChart', () => {
|
|
75
|
+
const series = [
|
|
76
|
+
{
|
|
77
|
+
name: 'Sessions',
|
|
78
|
+
points: [
|
|
79
|
+
{ label: 'Mon', value: 10 },
|
|
80
|
+
{ label: 'Tue', value: 30 },
|
|
81
|
+
{ label: 'Wed', value: 20 },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'Errors',
|
|
86
|
+
points: [
|
|
87
|
+
{ label: 'Mon', value: 2 },
|
|
88
|
+
{ label: 'Tue', value: 5 },
|
|
89
|
+
{ label: 'Wed', value: 1 },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
it('renders a polyline per series with point markers, fluid + accessible', () => {
|
|
95
|
+
const markup = renderToStaticMarkup(<LineChart title="Traffic" series={series} />);
|
|
96
|
+
expect(count(markup, 'polyline')).toBe(2);
|
|
97
|
+
expect(count(markup, 'circle')).toBe(6);
|
|
98
|
+
expect(markup).toContain('viewBox="0 0 640 360"');
|
|
99
|
+
expect(markup).toContain('width="100%"');
|
|
100
|
+
expect(markup).toContain('role="img"');
|
|
101
|
+
expect(markup).toContain('aria-label="Traffic:');
|
|
102
|
+
// two named series => two legend rows
|
|
103
|
+
expect(count(markup, 'li')).toBe(2);
|
|
104
|
+
expect(markup).not.toContain('NaN');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('breaks the line at a non-finite gap (no NaN, one fewer marker)', () => {
|
|
108
|
+
const markup = renderToStaticMarkup(
|
|
109
|
+
<LineChart
|
|
110
|
+
title="Traffic"
|
|
111
|
+
series={[
|
|
112
|
+
{
|
|
113
|
+
name: 'Sessions',
|
|
114
|
+
points: [
|
|
115
|
+
{ label: 'Mon', value: 10 },
|
|
116
|
+
{ label: 'Tue', value: Number.NaN },
|
|
117
|
+
{ label: 'Wed', value: 20 },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
]}
|
|
121
|
+
/>,
|
|
122
|
+
);
|
|
123
|
+
// gap splits the single line into two 1-point segments; markers only for finite points
|
|
124
|
+
expect(count(markup, 'circle')).toBe(2);
|
|
125
|
+
expect(markup).not.toContain('NaN');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('renders the empty state when no series has a finite point', () => {
|
|
129
|
+
const markup = renderToStaticMarkup(
|
|
130
|
+
<LineChart title="Traffic" series={[{ name: 'X', points: [] }]} />,
|
|
131
|
+
);
|
|
132
|
+
expect(markup).toContain('aria-label="Traffic: No data"');
|
|
133
|
+
expect(count(markup, 'polyline')).toBe(0);
|
|
134
|
+
expect(markup).not.toContain('NaN');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('Sparkline', () => {
|
|
139
|
+
it('renders a fluid trend polyline with an aria summary', () => {
|
|
140
|
+
const markup = renderToStaticMarkup(
|
|
141
|
+
<Sparkline title="Latency" values={[5, 8, 6, 9, 7]} />,
|
|
142
|
+
);
|
|
143
|
+
expect(count(markup, 'polyline')).toBe(1);
|
|
144
|
+
expect(markup).toContain('width="100%"');
|
|
145
|
+
expect(markup).toContain('viewBox="0 0 100 28"');
|
|
146
|
+
expect(markup).toContain('role="img"');
|
|
147
|
+
expect(markup).toContain('aria-label="Latency:');
|
|
148
|
+
expect(markup).not.toContain('NaN');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('renders an empty state for no finite values', () => {
|
|
152
|
+
const markup = renderToStaticMarkup(
|
|
153
|
+
<Sparkline title="Latency" values={[Number.NaN, Number.POSITIVE_INFINITY]} />,
|
|
154
|
+
);
|
|
155
|
+
expect(markup).toContain('aria-label="Latency: No data"');
|
|
156
|
+
expect(count(markup, 'polyline')).toBe(0);
|
|
157
|
+
expect(markup).not.toContain('NaN');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('DonutChart', () => {
|
|
162
|
+
const data = [
|
|
163
|
+
{ label: 'Passed', value: 70 },
|
|
164
|
+
{ label: 'Failed', value: 20 },
|
|
165
|
+
{ label: 'Skipped', value: 10 },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
it('renders one arc path per positive slice, with legend percentages', () => {
|
|
169
|
+
const markup = renderToStaticMarkup(<DonutChart title="Results" data={data} />);
|
|
170
|
+
expect(count(markup, 'path')).toBe(3);
|
|
171
|
+
expect(markup).toContain('viewBox="0 0 200 200"');
|
|
172
|
+
expect(markup).toContain('width="100%"');
|
|
173
|
+
expect(markup).toContain('role="img"');
|
|
174
|
+
expect(markup).toContain('aria-label="Results:');
|
|
175
|
+
// legend shows label + value + percent (never color-only)
|
|
176
|
+
expect(markup).toContain('70%');
|
|
177
|
+
expect(count(markup, 'li')).toBe(3);
|
|
178
|
+
expect(markup).not.toContain('NaN');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('skips negative / non-finite slices', () => {
|
|
182
|
+
const markup = renderToStaticMarkup(
|
|
183
|
+
<DonutChart
|
|
184
|
+
title="Results"
|
|
185
|
+
data={[
|
|
186
|
+
{ label: 'Passed', value: 70 },
|
|
187
|
+
{ label: 'Bad', value: -5 },
|
|
188
|
+
{ label: 'Worse', value: Number.NaN },
|
|
189
|
+
]}
|
|
190
|
+
/>,
|
|
191
|
+
);
|
|
192
|
+
expect(count(markup, 'path')).toBe(1);
|
|
193
|
+
expect(markup).not.toContain('NaN');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('renders the empty state when every value is zero', () => {
|
|
197
|
+
const markup = renderToStaticMarkup(
|
|
198
|
+
<DonutChart title="Results" data={[{ label: 'None', value: 0 }]} />,
|
|
199
|
+
);
|
|
200
|
+
expect(markup).toContain('aria-label="Results: No data"');
|
|
201
|
+
expect(count(markup, 'path')).toBe(0);
|
|
202
|
+
expect(markup).not.toContain('NaN');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DonutChart — proportional ring, pure SVG (Wave 9.2)
|
|
3
|
+
//
|
|
4
|
+
// Slices are built from finite, non-negative values (a slice needs a magnitude).
|
|
5
|
+
// If no finite positive value remains, an explicit empty state renders instead
|
|
6
|
+
// of a broken ring.
|
|
7
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
|
|
10
|
+
import { ChartEmpty, ChartFrame, type ChartLegendItem } from './chart-frame';
|
|
11
|
+
import type { ChartBaseProps, ChartDatum } from './chart-types';
|
|
12
|
+
import { buildAriaSummary, chartColorAt, defaultFormatValue } from './chart-utils';
|
|
13
|
+
|
|
14
|
+
const SIZE = 200;
|
|
15
|
+
const CX = SIZE / 2;
|
|
16
|
+
const CY = SIZE / 2;
|
|
17
|
+
const R_OUTER = 90;
|
|
18
|
+
const R_INNER = 56;
|
|
19
|
+
const FULL = Math.PI * 2;
|
|
20
|
+
|
|
21
|
+
export interface DonutChartProps extends ChartBaseProps {
|
|
22
|
+
/** The slices — one per labelled datum. Negative/non-finite values are skipped. */
|
|
23
|
+
readonly data: readonly ChartDatum[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Point on a circle at `angle` (radians, 0 = 12 o'clock, clockwise). */
|
|
27
|
+
function polar(radius: number, angle: number): [number, number] {
|
|
28
|
+
return [CX + radius * Math.sin(angle), CY - radius * Math.cos(angle)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Annulus-segment path from `start` to `end` (radians). */
|
|
32
|
+
function slicePath(start: number, end: number): string {
|
|
33
|
+
const [ox1, oy1] = polar(R_OUTER, start);
|
|
34
|
+
const [ox2, oy2] = polar(R_OUTER, end);
|
|
35
|
+
const [ix2, iy2] = polar(R_INNER, end);
|
|
36
|
+
const [ix1, iy1] = polar(R_INNER, start);
|
|
37
|
+
const largeArc = end - start > Math.PI ? 1 : 0;
|
|
38
|
+
return [
|
|
39
|
+
`M ${ox1} ${oy1}`,
|
|
40
|
+
`A ${R_OUTER} ${R_OUTER} 0 ${largeArc} 1 ${ox2} ${oy2}`,
|
|
41
|
+
`L ${ix2} ${iy2}`,
|
|
42
|
+
`A ${R_INNER} ${R_INNER} 0 ${largeArc} 0 ${ix1} ${iy1}`,
|
|
43
|
+
'Z',
|
|
44
|
+
].join(' ');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function DonutChart({
|
|
48
|
+
data,
|
|
49
|
+
title,
|
|
50
|
+
ariaLabel,
|
|
51
|
+
formatValue,
|
|
52
|
+
className,
|
|
53
|
+
emptyLabel = 'No data',
|
|
54
|
+
}: DonutChartProps): ReactNode {
|
|
55
|
+
const format = formatValue ?? defaultFormatValue;
|
|
56
|
+
const slices = data.filter((d) => Number.isFinite(d.value) && d.value > 0);
|
|
57
|
+
const total = slices.reduce((sum, d) => sum + d.value, 0);
|
|
58
|
+
|
|
59
|
+
if (slices.length === 0 || total <= 0) {
|
|
60
|
+
return <ChartEmpty title={title} label={emptyLabel} className={className} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const legend: ChartLegendItem[] = slices.map((d, i) => ({
|
|
64
|
+
label: d.label,
|
|
65
|
+
value: `${format(d.value)} (${Math.round((d.value / total) * 100)}%)`,
|
|
66
|
+
color: chartColorAt(i, d.color),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const summaryPoints = slices.map((d) => ({ label: d.label, value: d.value }));
|
|
70
|
+
const summary = ariaLabel ?? buildAriaSummary(title, summaryPoints, format, 'slice');
|
|
71
|
+
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
const arcs = slices.map((d, i) => {
|
|
74
|
+
const start = cursor;
|
|
75
|
+
// A lone full-circle slice can't be drawn as a single arc (start===end):
|
|
76
|
+
// nudge the end just shy of a full turn so the ring still closes visually.
|
|
77
|
+
const sweep = slices.length === 1 ? FULL - 1e-3 : (d.value / total) * FULL;
|
|
78
|
+
const end = start + sweep;
|
|
79
|
+
cursor = end;
|
|
80
|
+
return { path: slicePath(start, end), color: chartColorAt(i, d.color), key: `${d.label}-${i}` };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const plot = (
|
|
84
|
+
<svg
|
|
85
|
+
role="img"
|
|
86
|
+
aria-label={summary}
|
|
87
|
+
width="100%"
|
|
88
|
+
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
|
89
|
+
preserveAspectRatio="xMidYMid meet"
|
|
90
|
+
className="mx-auto block h-auto w-full max-w-[240px] text-ink-4"
|
|
91
|
+
>
|
|
92
|
+
{arcs.map((a) => (
|
|
93
|
+
<path key={a.key} d={a.path} fill={a.color} stroke="var(--card, transparent)" strokeWidth={1} />
|
|
94
|
+
))}
|
|
95
|
+
<text
|
|
96
|
+
x={CX}
|
|
97
|
+
y={CY}
|
|
98
|
+
textAnchor="middle"
|
|
99
|
+
dominantBaseline="middle"
|
|
100
|
+
fontSize={20}
|
|
101
|
+
fontWeight={600}
|
|
102
|
+
fill="currentColor"
|
|
103
|
+
className="text-ink"
|
|
104
|
+
>
|
|
105
|
+
{format(total)}
|
|
106
|
+
</text>
|
|
107
|
+
</svg>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return <ChartFrame plot={plot} legend={legend} className={className} />;
|
|
111
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Chart primitives (Wave 9.2) — pure-SVG, responsive-by-construction dashboard
|
|
3
|
+
// charts. The kernel ships NO chart library: a strict CSP blocks external
|
|
4
|
+
// scripts/fonts/images, so every chart is inline SVG built from React.
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
export type {
|
|
7
|
+
ChartDatum,
|
|
8
|
+
ChartLineSeries,
|
|
9
|
+
ChartValueFormatter,
|
|
10
|
+
ChartBaseProps,
|
|
11
|
+
} from './chart-types';
|
|
12
|
+
export {
|
|
13
|
+
CHART_PALETTE,
|
|
14
|
+
chartColorAt,
|
|
15
|
+
defaultFormatValue,
|
|
16
|
+
finitePoints,
|
|
17
|
+
niceScale,
|
|
18
|
+
buildAriaSummary,
|
|
19
|
+
type NiceScale,
|
|
20
|
+
type NiceScaleOptions,
|
|
21
|
+
type FiniteDatum,
|
|
22
|
+
} from './chart-utils';
|
|
23
|
+
export { ChartLegend, type ChartLegendItem } from './chart-frame';
|
|
24
|
+
export { BarChart, type BarChartProps } from './bar-chart';
|
|
25
|
+
export { LineChart, type LineChartProps } from './line-chart';
|
|
26
|
+
export { Sparkline, type SparklineProps } from './sparkline';
|
|
27
|
+
export { DonutChart, type DonutChartProps } from './donut-chart';
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// LineChart — one or more line series over shared x-labels, pure SVG (Wave 9.2)
|
|
3
|
+
//
|
|
4
|
+
// Non-finite points break the line (a new segment starts after the gap) rather
|
|
5
|
+
// than emitting a NaN coordinate.
|
|
6
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
import { ChartEmpty, ChartFrame, type ChartLegendItem } from './chart-frame';
|
|
10
|
+
import type { ChartBaseProps, ChartLineSeries } from './chart-types';
|
|
11
|
+
import {
|
|
12
|
+
buildAriaSummary,
|
|
13
|
+
chartColorAt,
|
|
14
|
+
defaultFormatValue,
|
|
15
|
+
niceScale,
|
|
16
|
+
} from './chart-utils';
|
|
17
|
+
|
|
18
|
+
const VIEW_W = 640;
|
|
19
|
+
const VIEW_H = 360;
|
|
20
|
+
const PAD = { top: 12, right: 12, bottom: 44, left: 48 } as const;
|
|
21
|
+
const PLOT_L = PAD.left;
|
|
22
|
+
const PLOT_R = VIEW_W - PAD.right;
|
|
23
|
+
const PLOT_T = PAD.top;
|
|
24
|
+
const PLOT_B = VIEW_H - PAD.bottom;
|
|
25
|
+
const PLOT_W = PLOT_R - PLOT_L;
|
|
26
|
+
const PLOT_H = PLOT_B - PLOT_T;
|
|
27
|
+
|
|
28
|
+
export interface LineChartProps extends ChartBaseProps {
|
|
29
|
+
/** One or more named line series. */
|
|
30
|
+
readonly series: readonly ChartLineSeries[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Break a series' points into finite segments (a gap ends the current run). */
|
|
34
|
+
function toSegments(
|
|
35
|
+
points: readonly { value: number }[],
|
|
36
|
+
xToPx: (i: number) => number,
|
|
37
|
+
yToPx: (v: number) => number,
|
|
38
|
+
): string[] {
|
|
39
|
+
const segments: string[] = [];
|
|
40
|
+
let current: string[] = [];
|
|
41
|
+
points.forEach((p, i) => {
|
|
42
|
+
if (Number.isFinite(p.value)) {
|
|
43
|
+
current.push(`${xToPx(i)},${yToPx(p.value)}`);
|
|
44
|
+
} else if (current.length > 0) {
|
|
45
|
+
segments.push(current.join(' '));
|
|
46
|
+
current = [];
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (current.length > 0) segments.push(current.join(' '));
|
|
50
|
+
return segments;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function LineChart({
|
|
54
|
+
series,
|
|
55
|
+
title,
|
|
56
|
+
ariaLabel,
|
|
57
|
+
formatValue,
|
|
58
|
+
className,
|
|
59
|
+
emptyLabel = 'No data',
|
|
60
|
+
}: LineChartProps): ReactNode {
|
|
61
|
+
const format = formatValue ?? defaultFormatValue;
|
|
62
|
+
|
|
63
|
+
// Every finite value across all series drives the y-domain and the summary.
|
|
64
|
+
const finiteValues: number[] = [];
|
|
65
|
+
let maxLen = 0;
|
|
66
|
+
for (const s of series) {
|
|
67
|
+
maxLen = Math.max(maxLen, s.points.length);
|
|
68
|
+
for (const p of s.points) if (Number.isFinite(p.value)) finiteValues.push(p.value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (finiteValues.length === 0) {
|
|
72
|
+
return <ChartEmpty title={title} label={emptyLabel} className={className} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const scale = niceScale(Math.min(...finiteValues), Math.max(...finiteValues), {
|
|
76
|
+
pinZero: false,
|
|
77
|
+
});
|
|
78
|
+
const span = scale.max - scale.min;
|
|
79
|
+
const yToPx = (v: number): number => PLOT_B - ((v - scale.min) / span) * PLOT_H;
|
|
80
|
+
const xToPx = (i: number): number =>
|
|
81
|
+
maxLen <= 1 ? PLOT_L + PLOT_W / 2 : PLOT_L + (i / (maxLen - 1)) * PLOT_W;
|
|
82
|
+
|
|
83
|
+
// x labels from the longest series (deterministic pick: first of that length).
|
|
84
|
+
const xLabelSource = series.find((s) => s.points.length === maxLen)?.points ?? [];
|
|
85
|
+
|
|
86
|
+
const legend: ChartLegendItem[] = series.map((s, i) => ({
|
|
87
|
+
label: s.name,
|
|
88
|
+
color: chartColorAt(i, s.color),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// Summarize the first series' points (or the union count) for the aria label.
|
|
92
|
+
const summaryPoints = series.flatMap((s) =>
|
|
93
|
+
s.points
|
|
94
|
+
.filter((p) => Number.isFinite(p.value))
|
|
95
|
+
.map((p) => ({ label: `${s.name} ${p.label}`, value: p.value })),
|
|
96
|
+
);
|
|
97
|
+
const summary = ariaLabel ?? buildAriaSummary(title, summaryPoints, format, 'point');
|
|
98
|
+
|
|
99
|
+
const plot = (
|
|
100
|
+
<svg
|
|
101
|
+
role="img"
|
|
102
|
+
aria-label={summary}
|
|
103
|
+
width="100%"
|
|
104
|
+
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
|
105
|
+
preserveAspectRatio="xMidYMid meet"
|
|
106
|
+
className="block h-auto w-full overflow-visible text-ink-4"
|
|
107
|
+
>
|
|
108
|
+
{/* y gridlines + tick labels */}
|
|
109
|
+
{scale.ticks.map((t) => {
|
|
110
|
+
const y = yToPx(t);
|
|
111
|
+
return (
|
|
112
|
+
<g key={`y-${t}`}>
|
|
113
|
+
<line
|
|
114
|
+
x1={PLOT_L}
|
|
115
|
+
x2={PLOT_R}
|
|
116
|
+
y1={y}
|
|
117
|
+
y2={y}
|
|
118
|
+
stroke="currentColor"
|
|
119
|
+
strokeOpacity={0.12}
|
|
120
|
+
/>
|
|
121
|
+
<text
|
|
122
|
+
x={PLOT_L - 8}
|
|
123
|
+
y={y}
|
|
124
|
+
textAnchor="end"
|
|
125
|
+
dominantBaseline="middle"
|
|
126
|
+
fontSize={12}
|
|
127
|
+
fill="currentColor"
|
|
128
|
+
>
|
|
129
|
+
{format(t)}
|
|
130
|
+
</text>
|
|
131
|
+
</g>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
|
|
135
|
+
{/* x labels */}
|
|
136
|
+
{xLabelSource.map((p, i) => (
|
|
137
|
+
<text
|
|
138
|
+
key={`x-${p.label}-${i}`}
|
|
139
|
+
x={xToPx(i)}
|
|
140
|
+
y={PLOT_B + 16}
|
|
141
|
+
textAnchor="middle"
|
|
142
|
+
fontSize={12}
|
|
143
|
+
fill="currentColor"
|
|
144
|
+
>
|
|
145
|
+
{p.label}
|
|
146
|
+
</text>
|
|
147
|
+
))}
|
|
148
|
+
|
|
149
|
+
{/* lines */}
|
|
150
|
+
{series.map((s, si) => {
|
|
151
|
+
const color = chartColorAt(si, s.color);
|
|
152
|
+
const segments = toSegments(s.points, xToPx, yToPx);
|
|
153
|
+
return (
|
|
154
|
+
<g key={`line-${s.name}-${si}`}>
|
|
155
|
+
{segments.map((pts, gi) => (
|
|
156
|
+
<polyline
|
|
157
|
+
key={`seg-${gi}`}
|
|
158
|
+
points={pts}
|
|
159
|
+
fill="none"
|
|
160
|
+
stroke={color}
|
|
161
|
+
strokeWidth={2}
|
|
162
|
+
strokeLinejoin="round"
|
|
163
|
+
strokeLinecap="round"
|
|
164
|
+
/>
|
|
165
|
+
))}
|
|
166
|
+
{s.points.map((p, i) =>
|
|
167
|
+
Number.isFinite(p.value) ? (
|
|
168
|
+
<circle key={`pt-${i}`} cx={xToPx(i)} cy={yToPx(p.value)} r={2.5} fill={color}>
|
|
169
|
+
<title>{`${s.name} — ${p.label}: ${format(p.value)}`}</title>
|
|
170
|
+
</circle>
|
|
171
|
+
) : null,
|
|
172
|
+
)}
|
|
173
|
+
</g>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</svg>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return <ChartFrame plot={plot} legend={legend} className={className} />;
|
|
180
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Sparkline — tiny inline trend line, pure SVG, no axes/legend (Wave 9.2)
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../cn';
|
|
7
|
+
|
|
8
|
+
import type { ChartValueFormatter } from './chart-types';
|
|
9
|
+
import { chartColorAt, defaultFormatValue, niceScale } from './chart-utils';
|
|
10
|
+
|
|
11
|
+
const VIEW_W = 100;
|
|
12
|
+
const VIEW_H = 28;
|
|
13
|
+
const PAD_Y = 3;
|
|
14
|
+
|
|
15
|
+
export interface SparklineProps {
|
|
16
|
+
/** The ordered values. Non-finite entries break the line rather than plotting NaN. */
|
|
17
|
+
readonly values: readonly number[];
|
|
18
|
+
/** Title — the spine of the `aria-label` summary. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly ariaLabel?: string;
|
|
21
|
+
readonly formatValue?: ChartValueFormatter;
|
|
22
|
+
/** Line color (CSS color / `currentColor` / `var(--…)`). Defaults to the brand palette slot 0. */
|
|
23
|
+
readonly color?: string;
|
|
24
|
+
readonly className?: string;
|
|
25
|
+
readonly emptyLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Sparkline({
|
|
29
|
+
values,
|
|
30
|
+
title,
|
|
31
|
+
ariaLabel,
|
|
32
|
+
formatValue,
|
|
33
|
+
color,
|
|
34
|
+
className,
|
|
35
|
+
emptyLabel = 'No data',
|
|
36
|
+
}: SparklineProps): ReactNode {
|
|
37
|
+
const format = formatValue ?? defaultFormatValue;
|
|
38
|
+
const finite = values.filter((v) => Number.isFinite(v));
|
|
39
|
+
|
|
40
|
+
if (finite.length === 0) {
|
|
41
|
+
return (
|
|
42
|
+
<span
|
|
43
|
+
role="img"
|
|
44
|
+
aria-label={`${title}: ${emptyLabel}`}
|
|
45
|
+
className={cn('inline-block text-body-2 text-ink-4', className)}
|
|
46
|
+
>
|
|
47
|
+
{emptyLabel}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const scale = niceScale(Math.min(...finite), Math.max(...finite), { pinZero: false });
|
|
53
|
+
const span = scale.max - scale.min;
|
|
54
|
+
const yToPx = (v: number): number =>
|
|
55
|
+
VIEW_H - PAD_Y - ((v - scale.min) / span) * (VIEW_H - 2 * PAD_Y);
|
|
56
|
+
const xToPx = (i: number): number =>
|
|
57
|
+
values.length <= 1 ? VIEW_W / 2 : (i / (values.length - 1)) * VIEW_W;
|
|
58
|
+
|
|
59
|
+
// Break into finite segments so a gap never yields a NaN coordinate.
|
|
60
|
+
const segments: string[] = [];
|
|
61
|
+
let current: string[] = [];
|
|
62
|
+
values.forEach((v, i) => {
|
|
63
|
+
if (Number.isFinite(v)) current.push(`${xToPx(i)},${yToPx(v)}`);
|
|
64
|
+
else if (current.length > 0) {
|
|
65
|
+
segments.push(current.join(' '));
|
|
66
|
+
current = [];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (current.length > 0) segments.push(current.join(' '));
|
|
70
|
+
|
|
71
|
+
const stroke = chartColorAt(0, color);
|
|
72
|
+
const first = finite[0];
|
|
73
|
+
const last = finite[finite.length - 1];
|
|
74
|
+
const summary =
|
|
75
|
+
ariaLabel ??
|
|
76
|
+
`${title}: trend of ${finite.length} points, from ${format(first)} to ${format(last)}.`;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<svg
|
|
80
|
+
role="img"
|
|
81
|
+
aria-label={summary}
|
|
82
|
+
width="100%"
|
|
83
|
+
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
|
84
|
+
preserveAspectRatio="none"
|
|
85
|
+
className={cn('block h-auto w-full overflow-visible', className)}
|
|
86
|
+
>
|
|
87
|
+
{segments.map((pts, i) => (
|
|
88
|
+
<polyline
|
|
89
|
+
key={`seg-${i}`}
|
|
90
|
+
points={pts}
|
|
91
|
+
fill="none"
|
|
92
|
+
stroke={stroke}
|
|
93
|
+
strokeWidth={1.5}
|
|
94
|
+
strokeLinejoin="round"
|
|
95
|
+
strokeLinecap="round"
|
|
96
|
+
vectorEffect="non-scaling-stroke"
|
|
97
|
+
/>
|
|
98
|
+
))}
|
|
99
|
+
</svg>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { MOBILE_BREAKPOINT } from '../breakpoints';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `true` when the viewport is narrower than the shared {@link MOBILE_BREAKPOINT}
|
|
7
|
+
* (Tailwind's `md`). Server-driven renderers use this to collapse tables to
|
|
8
|
+
* cards, reflow grids and go single-column WITHOUT the author declaring a
|
|
9
|
+
* breakpoint — responsiveness is a property of the primitive, not a checklist.
|
|
10
|
+
*
|
|
11
|
+
* Reads `window.innerWidth`, so it returns `false` until mounted on the client
|
|
12
|
+
* (SSR-safe: no layout thrash, no hydration mismatch beyond the first paint).
|
|
13
|
+
*/
|
|
14
|
+
export function useIsMobile(): boolean {
|
|
15
|
+
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
19
|
+
const onChange = (): void => {
|
|
20
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
21
|
+
};
|
|
22
|
+
mql.addEventListener('change', onChange);
|
|
23
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
24
|
+
return () => mql.removeEventListener('change', onChange);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return !!isMobile;
|
|
28
|
+
}
|