@tangle-network/ui 11.41.0 → 11.42.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/CHANGELOG.md +6 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/lib/series.d.ts +74 -0
- package/dist/lib/series.js +100 -0
- package/dist/presentation.d.ts +13 -0
- package/dist/presentation.js +14 -1
- package/dist/primitives/metric-chart.d.ts +47 -0
- package/dist/primitives/metric-chart.js +155 -0
- package/dist/primitives/series-sparkline.d.ts +16 -0
- package/dist/primitives/series-sparkline.js +53 -0
- package/dist/primitives.d.ts +4 -1
- package/dist/primitives.js +4 -1
- package/package.json +1 -1
- package/src/lib/series.test.ts +172 -0
- package/src/lib/series.ts +174 -0
- package/src/presentation.ts +14 -0
- package/src/primitives/index.ts +21 -0
- package/src/primitives/metric-chart.stories.tsx +55 -0
- package/src/primitives/metric-chart.test.tsx +118 -0
- package/src/primitives/metric-chart.tsx +246 -0
- package/src/primitives/series-sparkline.stories.tsx +38 -0
- package/src/primitives/series-sparkline.test.tsx +56 -0
- package/src/primitives/series-sparkline.tsx +69 -0
package/dist/primitives.js
CHANGED
|
@@ -56,6 +56,9 @@ import { HoverCard, HoverCardContent, HoverCardDescription, HoverCardHeader, Hov
|
|
|
56
56
|
import { Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./primitives/breadcrumb.js";
|
|
57
57
|
import { IconTile, initialsOf } from "./primitives/icon-tile.js";
|
|
58
58
|
import { Metric, MetricStrip } from "./primitives/metric-strip.js";
|
|
59
|
+
import { normalizeSeries, periodDelta, seriesAreaPath, seriesExtent, seriesLinePath, seriesMean, seriesScale } from "./lib/series.js";
|
|
60
|
+
import { SeriesSparkline } from "./primitives/series-sparkline.js";
|
|
61
|
+
import { MetricChart } from "./primitives/metric-chart.js";
|
|
59
62
|
import { FilterField, Toolbar } from "./primitives/toolbar.js";
|
|
60
63
|
import { Logo, TangleKnot } from "@tangle-network/brand";
|
|
61
|
-
export { Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, Avatar, AvatarFallback, AvatarImage, Badge, Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, CATEGORY_TONES, Calendar, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, DatePicker, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, Field, FilterField, Heading, HelpText, HoverCard, HoverCardContent, HoverCardDescription, HoverCardHeader, HoverCardTitle, HoverCardTrigger, IconTile, InlineCode, Input, Label, Logo, Metric, MetricStrip, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, RadioGroup, RadioGroupItem, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, SettingsList, SettingsListItem, SettingsRow, SettingsSection, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, TimeSelect, ToastContainer, ToastProvider, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, Wizard, WizardStep, badgeVariants, buttonVariants, fieldMessageId, formatCalendarDate, initialsOf, localToday, parseCalendarDate, toneFor, useTheme, useToast };
|
|
64
|
+
export { Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, Avatar, AvatarFallback, AvatarImage, Badge, Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, CATEGORY_TONES, Calendar, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, DatePicker, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, Field, FilterField, Heading, HelpText, HoverCard, HoverCardContent, HoverCardDescription, HoverCardHeader, HoverCardTitle, HoverCardTrigger, IconTile, InlineCode, Input, Label, Logo, Metric, MetricChart, MetricStrip, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, RadioGroup, RadioGroupItem, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, SeriesSparkline, SettingsList, SettingsListItem, SettingsRow, SettingsSection, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, TimeSelect, ToastContainer, ToastProvider, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, Wizard, WizardStep, badgeVariants, buttonVariants, fieldMessageId, formatCalendarDate, initialsOf, localToday, normalizeSeries, parseCalendarDate, periodDelta, seriesAreaPath, seriesExtent, seriesLinePath, seriesMean, seriesScale, toneFor, useTheme, useToast };
|
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
normalizeSeries,
|
|
4
|
+
periodDelta,
|
|
5
|
+
seriesAreaPath,
|
|
6
|
+
seriesExtent,
|
|
7
|
+
seriesLinePath,
|
|
8
|
+
seriesMean,
|
|
9
|
+
seriesScale,
|
|
10
|
+
type SeriesPoint,
|
|
11
|
+
} from "./series";
|
|
12
|
+
|
|
13
|
+
const DAY = 86_400_000;
|
|
14
|
+
|
|
15
|
+
describe("normalizeSeries", () => {
|
|
16
|
+
it("parses epoch, ISO and Date times, drops non-finite readings and sorts", () => {
|
|
17
|
+
const points = normalizeSeries([
|
|
18
|
+
{ at: "2026-10-21T07:00:00.000Z", value: 63 },
|
|
19
|
+
{ at: 1_000, value: 61 },
|
|
20
|
+
{ at: new Date("2026-10-20T07:00:00.000Z"), value: 62, value2: 40 },
|
|
21
|
+
{ at: "not a date", value: 99 },
|
|
22
|
+
{ at: 2_000, value: Number.NaN },
|
|
23
|
+
{ at: 3_000, value: 65, value2: Number.POSITIVE_INFINITY },
|
|
24
|
+
]);
|
|
25
|
+
expect(points.map((point) => point.value)).toEqual([61, 65, 62, 63]);
|
|
26
|
+
expect(points[0]!.at).toBe(1_000);
|
|
27
|
+
// A non-finite second reading drops value2, not the point.
|
|
28
|
+
expect(points[1]!.value2).toBeUndefined();
|
|
29
|
+
expect(points[2]!.value2).toBe(40);
|
|
30
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
31
|
+
expect(points[i]!.at).toBeGreaterThanOrEqual(points[i - 1]!.at);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns an empty series for empty input", () => {
|
|
36
|
+
expect(normalizeSeries([])).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("seriesExtent", () => {
|
|
41
|
+
const points: SeriesPoint[] = [
|
|
42
|
+
{ at: 10 * DAY, value: 60 },
|
|
43
|
+
{ at: 20 * DAY, value: 80 },
|
|
44
|
+
{ at: 30 * DAY, value: 70, value2: 45 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
it("spans both readings with twelve percent padding by default", () => {
|
|
48
|
+
const extent = seriesExtent(points);
|
|
49
|
+
const span = 80 - 45;
|
|
50
|
+
expect(extent.lo).toBeCloseTo(45 - span * 0.12);
|
|
51
|
+
expect(extent.hi).toBeCloseTo(80 + span * 0.12);
|
|
52
|
+
expect(extent.tLo).toBe(10 * DAY);
|
|
53
|
+
expect(extent.tHi).toBe(30 * DAY);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("widens the domain to cover included reference values", () => {
|
|
57
|
+
const extent = seriesExtent(points, { include: [95, 30] });
|
|
58
|
+
expect(extent.hi).toBeGreaterThanOrEqual(95);
|
|
59
|
+
expect(extent.lo).toBeLessThanOrEqual(30);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("honours an explicit padding of zero", () => {
|
|
63
|
+
const extent = seriesExtent(points, { padding: 0 });
|
|
64
|
+
expect(extent.lo).toBe(45);
|
|
65
|
+
expect(extent.hi).toBe(80);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("opens up a degenerate value span so a flat series still centres", () => {
|
|
69
|
+
const extent = seriesExtent([
|
|
70
|
+
{ at: DAY, value: 50 },
|
|
71
|
+
{ at: 2 * DAY, value: 50 },
|
|
72
|
+
]);
|
|
73
|
+
expect(extent.lo).toBeLessThan(50);
|
|
74
|
+
expect(extent.hi).toBeGreaterThan(50);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("opens up a degenerate time span for a single reading", () => {
|
|
78
|
+
const extent = seriesExtent([{ at: DAY, value: 50 }]);
|
|
79
|
+
expect(extent.tHi - extent.tLo).toBe(24 * 3_600_000);
|
|
80
|
+
expect((extent.tLo + extent.tHi) / 2).toBe(DAY);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns a usable default extent for an empty series", () => {
|
|
84
|
+
const extent = seriesExtent([]);
|
|
85
|
+
expect(extent.lo).toBeLessThan(extent.hi);
|
|
86
|
+
expect(extent.tLo).toBeLessThan(extent.tHi);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("seriesScale", () => {
|
|
91
|
+
it("maps extent corners to the padded box corners", () => {
|
|
92
|
+
const extent = { lo: 0, hi: 10, tLo: 0, tHi: 100 };
|
|
93
|
+
const scale = seriesScale(extent, 200, 100, 10);
|
|
94
|
+
expect(scale.x(0)).toBe(10);
|
|
95
|
+
expect(scale.x(100)).toBe(190);
|
|
96
|
+
expect(scale.y(0)).toBe(90);
|
|
97
|
+
expect(scale.y(10)).toBe(10);
|
|
98
|
+
expect(scale.x(50)).toBeCloseTo(100);
|
|
99
|
+
expect(scale.y(5)).toBeCloseTo(50);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("seriesMean", () => {
|
|
104
|
+
it("averages finite values and ignores non-finite ones", () => {
|
|
105
|
+
expect(seriesMean([2, 4, 6])).toBe(4);
|
|
106
|
+
expect(seriesMean([2, Number.NaN, 4])).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null when there is nothing to average", () => {
|
|
110
|
+
expect(seriesMean([])).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("periodDelta", () => {
|
|
115
|
+
const latest = 60 * DAY;
|
|
116
|
+
const points: SeriesPoint[] = [
|
|
117
|
+
{ at: latest - 45 * DAY, value: 80 }, // prior window
|
|
118
|
+
{ at: latest - 40 * DAY, value: 60 }, // prior window
|
|
119
|
+
{ at: latest - 10 * DAY, value: 50 }, // recent window
|
|
120
|
+
{ at: latest, value: 70 }, // recent window
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
it("measures both windows back from the latest measurement, not the wall clock", () => {
|
|
124
|
+
const result = periodDelta(points, 30 * DAY)!;
|
|
125
|
+
expect(result.current).toBe(60);
|
|
126
|
+
expect(result.previous).toBe(70);
|
|
127
|
+
expect(result.delta).toBe(-10);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reports a null delta when the prior window has no data", () => {
|
|
131
|
+
const result = periodDelta(points.slice(2), 30 * DAY)!;
|
|
132
|
+
expect(result.current).toBe(60);
|
|
133
|
+
expect(result.previous).toBeNull();
|
|
134
|
+
expect(result.delta).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("excludes readings exactly on the window boundary from the recent window", () => {
|
|
138
|
+
const boundary: SeriesPoint[] = [
|
|
139
|
+
{ at: latest - 30 * DAY, value: 100 },
|
|
140
|
+
{ at: latest, value: 50 },
|
|
141
|
+
];
|
|
142
|
+
const result = periodDelta(boundary, 30 * DAY)!;
|
|
143
|
+
expect(result.current).toBe(50);
|
|
144
|
+
expect(result.previous).toBe(100);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns null for an empty series", () => {
|
|
148
|
+
expect(periodDelta([], 30 * DAY)).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("seriesLinePath / seriesAreaPath", () => {
|
|
153
|
+
const placed = [
|
|
154
|
+
{ x: 0, y: 10 },
|
|
155
|
+
{ x: 5, y: 0 },
|
|
156
|
+
{ x: 10, y: 10 },
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
it("builds a move/line path", () => {
|
|
160
|
+
expect(seriesLinePath(placed)).toBe("M0.00,10.00 L5.00,0.00 L10.00,10.00");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("closes the area path along the baseline", () => {
|
|
164
|
+
expect(seriesAreaPath(placed, 12)).toBe(
|
|
165
|
+
"M0.00,10.00 L5.00,0.00 L10.00,10.00L10.00,12L0.00,12Z",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("draws no area for fewer than two points", () => {
|
|
170
|
+
expect(seriesAreaPath([{ x: 0, y: 0 }], 12)).toBe("");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/lib/series.ts — pure time-series math shared by the chart primitives.
|
|
3
|
+
* No React, no DOM: unit-tested directly. All periods are relative to the
|
|
4
|
+
* LATEST measurement in the series, never to the wall clock, so a seeded
|
|
5
|
+
* demo or a backfilled import never goes stale.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A point as callers supply it: `at` accepts epoch ms, ISO text or a Date. */
|
|
9
|
+
export interface SeriesPointInput {
|
|
10
|
+
at: number | string | Date;
|
|
11
|
+
value: number;
|
|
12
|
+
/** Second reading for dual series (diastolic next to systolic). */
|
|
13
|
+
value2?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A normalized point: finite numbers, epoch-ms time, ascending order. */
|
|
17
|
+
export interface SeriesPoint {
|
|
18
|
+
at: number;
|
|
19
|
+
value: number;
|
|
20
|
+
value2?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const asTime = (at: number | string | Date): number | null => {
|
|
24
|
+
const time =
|
|
25
|
+
at instanceof Date ? at.getTime() : typeof at === "number" ? at : Date.parse(at);
|
|
26
|
+
return Number.isFinite(time) ? time : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Drop non-finite readings and sort by measurement time. */
|
|
30
|
+
export function normalizeSeries(
|
|
31
|
+
points: readonly SeriesPointInput[],
|
|
32
|
+
): SeriesPoint[] {
|
|
33
|
+
const normalized: SeriesPoint[] = [];
|
|
34
|
+
for (const point of points) {
|
|
35
|
+
const at = asTime(point.at);
|
|
36
|
+
if (at === null || !Number.isFinite(point.value)) continue;
|
|
37
|
+
const { value2 } = point;
|
|
38
|
+
normalized.push(
|
|
39
|
+
value2 !== undefined && Number.isFinite(value2)
|
|
40
|
+
? { at, value: point.value, value2 }
|
|
41
|
+
: { at, value: point.value },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return normalized.sort((a, b) => a.at - b.at);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SeriesExtent {
|
|
48
|
+
/** Padded value domain. */
|
|
49
|
+
lo: number;
|
|
50
|
+
hi: number;
|
|
51
|
+
/** Time domain, never degenerate. */
|
|
52
|
+
tLo: number;
|
|
53
|
+
tHi: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TWELVE_HOURS_MS = 12 * 3_600_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Value and time extents across both readings of every point. The value
|
|
60
|
+
* domain is padded by `padding` (fraction of the span, default 0.12) and
|
|
61
|
+
* widened to cover `include`d values (reference lines). Degenerate spans
|
|
62
|
+
* open up so a flat or single-reading series still centres.
|
|
63
|
+
*/
|
|
64
|
+
export function seriesExtent(
|
|
65
|
+
points: readonly SeriesPoint[],
|
|
66
|
+
options: { padding?: number; include?: readonly number[] } = {},
|
|
67
|
+
): SeriesExtent {
|
|
68
|
+
const values = [
|
|
69
|
+
...points.flatMap((point) =>
|
|
70
|
+
point.value2 === undefined ? [point.value] : [point.value, point.value2]
|
|
71
|
+
),
|
|
72
|
+
...(options.include ?? []),
|
|
73
|
+
];
|
|
74
|
+
let lo = values.length > 0 ? Math.min(...values) : 0;
|
|
75
|
+
let hi = values.length > 0 ? Math.max(...values) : 1;
|
|
76
|
+
if (lo === hi) {
|
|
77
|
+
lo -= 1;
|
|
78
|
+
hi += 1;
|
|
79
|
+
}
|
|
80
|
+
const pad = (hi - lo) * (options.padding ?? 0.12);
|
|
81
|
+
lo -= pad;
|
|
82
|
+
hi += pad;
|
|
83
|
+
let tLo = points.length > 0 ? points[0]!.at : 0;
|
|
84
|
+
let tHi = points.length > 0 ? points[points.length - 1]!.at : 1;
|
|
85
|
+
if (tLo === tHi) {
|
|
86
|
+
tLo -= TWELVE_HOURS_MS;
|
|
87
|
+
tHi += TWELVE_HOURS_MS;
|
|
88
|
+
}
|
|
89
|
+
return { lo, hi, tLo, tHi };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SeriesScale {
|
|
93
|
+
x: (at: number) => number;
|
|
94
|
+
y: (value: number) => number;
|
|
95
|
+
extent: SeriesExtent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Project an extent into a `width` × `height` box with `pad` inset. */
|
|
99
|
+
export function seriesScale(
|
|
100
|
+
extent: SeriesExtent,
|
|
101
|
+
width: number,
|
|
102
|
+
height: number,
|
|
103
|
+
pad: number,
|
|
104
|
+
): SeriesScale {
|
|
105
|
+
const { lo, hi, tLo, tHi } = extent;
|
|
106
|
+
return {
|
|
107
|
+
extent,
|
|
108
|
+
x: (at) => pad + ((at - tLo) / (tHi - tLo)) * (width - 2 * pad),
|
|
109
|
+
y: (value) => height - pad - ((value - lo) / (hi - lo)) * (height - 2 * pad),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PeriodDelta {
|
|
114
|
+
/** Mean of the latest `windowMs` of data, ending at the last measurement. */
|
|
115
|
+
current: number;
|
|
116
|
+
/** Mean of the window before that, when such data exists. */
|
|
117
|
+
previous: number | null;
|
|
118
|
+
/** `current - previous`, or null without a populated prior window. */
|
|
119
|
+
delta: number | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Mean of finite values, or null when there is nothing to average. */
|
|
123
|
+
export function seriesMean(values: readonly number[]): number | null {
|
|
124
|
+
const finite = values.filter((value) => Number.isFinite(value));
|
|
125
|
+
if (finite.length === 0) return null;
|
|
126
|
+
return finite.reduce((sum, value) => sum + value, 0) / finite.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compare the latest `windowMs` of primary readings against the window
|
|
131
|
+
* before it. Both windows end at the LATEST measurement, never the wall
|
|
132
|
+
* clock. Returns null for an empty series.
|
|
133
|
+
*/
|
|
134
|
+
export function periodDelta(
|
|
135
|
+
points: readonly SeriesPoint[],
|
|
136
|
+
windowMs: number,
|
|
137
|
+
): PeriodDelta | null {
|
|
138
|
+
if (points.length === 0) return null;
|
|
139
|
+
const latest = points[points.length - 1]!.at;
|
|
140
|
+
const recentStart = latest - windowMs;
|
|
141
|
+
const previousStart = latest - 2 * windowMs;
|
|
142
|
+
const recent = points
|
|
143
|
+
.filter((point) => point.at > recentStart)
|
|
144
|
+
.map((point) => point.value);
|
|
145
|
+
const previous = points
|
|
146
|
+
.filter((point) => point.at > previousStart && point.at <= recentStart)
|
|
147
|
+
.map((point) => point.value);
|
|
148
|
+
const current = seriesMean(recent)!;
|
|
149
|
+
const previousMean = seriesMean(previous);
|
|
150
|
+
return {
|
|
151
|
+
current,
|
|
152
|
+
previous: previousMean,
|
|
153
|
+
delta: previousMean !== null ? current - previousMean : null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Polyline path data for already-projected points. */
|
|
158
|
+
export function seriesLinePath(points: ReadonlyArray<{ x: number; y: number }>): string {
|
|
159
|
+
return points
|
|
160
|
+
.map(
|
|
161
|
+
(point, index) =>
|
|
162
|
+
`${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`,
|
|
163
|
+
)
|
|
164
|
+
.join(" ");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Closed area path down to `baseline`, empty for fewer than two points. */
|
|
168
|
+
export function seriesAreaPath(
|
|
169
|
+
points: ReadonlyArray<{ x: number; y: number }>,
|
|
170
|
+
baseline: number,
|
|
171
|
+
): string {
|
|
172
|
+
if (points.length < 2) return "";
|
|
173
|
+
return `${seriesLinePath(points)}L${points[points.length - 1]!.x.toFixed(2)},${baseline}L${points[0]!.x.toFixed(2)},${baseline}Z`;
|
|
174
|
+
}
|
package/src/presentation.ts
CHANGED
|
@@ -116,6 +116,20 @@ export const metricLayouts = {
|
|
|
116
116
|
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(6n+1))]:border-l",
|
|
117
117
|
} as const;
|
|
118
118
|
|
|
119
|
+
/** Hand-rolled SVG charts: every stroke and fill is currentColor against a
|
|
120
|
+
* token class, so ink stays theme-aware with no hardcoded hues. Charts draw
|
|
121
|
+
* lines and text only; the grouping plane (border, surface) is caller-owned. */
|
|
122
|
+
export const chartPresentation = {
|
|
123
|
+
sparkline: "h-9 w-full text-primary",
|
|
124
|
+
root: "h-auto w-full",
|
|
125
|
+
axis: "text-muted-foreground",
|
|
126
|
+
gridLine: "opacity-20",
|
|
127
|
+
referenceLine: "opacity-60",
|
|
128
|
+
primarySeries: "text-primary",
|
|
129
|
+
secondarySeries: "text-muted-foreground",
|
|
130
|
+
area: "opacity-10",
|
|
131
|
+
} as const;
|
|
132
|
+
|
|
119
133
|
export const dialogPresentation = {
|
|
120
134
|
overlay: "fixed inset-0 z-50 bg-black/40",
|
|
121
135
|
transition: surfaceMotion,
|
package/src/primitives/index.ts
CHANGED
|
@@ -248,5 +248,26 @@ export type { CategoryTone, Tone, ToneClasses } from "./tone";
|
|
|
248
248
|
export { MetricStrip, Metric } from "./metric-strip";
|
|
249
249
|
export type { MetricStripProps, MetricProps } from "./metric-strip";
|
|
250
250
|
|
|
251
|
+
export { SeriesSparkline } from "./series-sparkline";
|
|
252
|
+
export type { SeriesSparklineProps } from "./series-sparkline";
|
|
253
|
+
export { MetricChart } from "./metric-chart";
|
|
254
|
+
export type { ChartReferenceLine, MetricChartProps } from "./metric-chart";
|
|
255
|
+
export {
|
|
256
|
+
normalizeSeries,
|
|
257
|
+
periodDelta,
|
|
258
|
+
seriesAreaPath,
|
|
259
|
+
seriesExtent,
|
|
260
|
+
seriesLinePath,
|
|
261
|
+
seriesMean,
|
|
262
|
+
seriesScale,
|
|
263
|
+
} from "../lib/series";
|
|
264
|
+
export type {
|
|
265
|
+
PeriodDelta,
|
|
266
|
+
SeriesExtent,
|
|
267
|
+
SeriesPoint,
|
|
268
|
+
SeriesPointInput,
|
|
269
|
+
SeriesScale,
|
|
270
|
+
} from "../lib/series";
|
|
271
|
+
|
|
251
272
|
export { Toolbar, FilterField } from "./toolbar";
|
|
252
273
|
export type { ToolbarProps, FilterFieldProps } from "./toolbar";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MetricChart } from "./metric-chart";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Primitives/MetricChart",
|
|
6
|
+
component: MetricChart,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
} satisfies Meta<typeof MetricChart>;
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
|
|
12
|
+
const DAY = 86_400_000;
|
|
13
|
+
const t0 = Date.parse("2026-08-24T07:00:00.000Z");
|
|
14
|
+
|
|
15
|
+
const bloodPressure = Array.from({ length: 12 }, (_, index) => ({
|
|
16
|
+
at: t0 + index * 5 * DAY,
|
|
17
|
+
value: 118 + ((index * 7) % 9),
|
|
18
|
+
value2: 74 + ((index * 5) % 8),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const steps = Array.from({ length: 12 }, (_, index) => ({
|
|
22
|
+
at: t0 + index * 5 * DAY,
|
|
23
|
+
value: 6_200 + ((index * 1_937) % 4_800),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
export const DualSeries: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
title: "Blood pressure",
|
|
29
|
+
unit: "mmHg",
|
|
30
|
+
decimals: 0,
|
|
31
|
+
points: bloodPressure,
|
|
32
|
+
referenceLines: [
|
|
33
|
+
{ value: 120, label: "120 systolic" },
|
|
34
|
+
{ value: 80, label: "80 diastolic", series: "secondary" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const SingleSeries: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
title: "Steps",
|
|
42
|
+
unit: "steps",
|
|
43
|
+
decimals: 0,
|
|
44
|
+
points: steps,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const SingleReading: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
title: "Weight",
|
|
51
|
+
unit: "kg",
|
|
52
|
+
decimals: 1,
|
|
53
|
+
points: [{ at: t0, value: 71.4 }],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { MetricChart } from "./metric-chart";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
const DAY = 86_400_000;
|
|
9
|
+
const t0 = Date.parse("2026-09-01T07:00:00.000Z");
|
|
10
|
+
const points = Array.from({ length: 10 }, (_, index) => ({
|
|
11
|
+
at: t0 + index * 5 * DAY,
|
|
12
|
+
value: 118 + (index % 3) * 4,
|
|
13
|
+
value2: 76 + (index % 2) * 4,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("MetricChart", () => {
|
|
17
|
+
it("renders an accessible svg with a generated summary description", () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<MetricChart points={points} title="Blood pressure" unit="mmHg" decimals={0} />,
|
|
20
|
+
);
|
|
21
|
+
const svg = screen.getByRole("img", { name: "Blood pressure" });
|
|
22
|
+
expect(svg.querySelector("title")!.textContent).toBe("Blood pressure");
|
|
23
|
+
const desc = svg.querySelector("desc")!.textContent!;
|
|
24
|
+
expect(desc).toContain("10 readings");
|
|
25
|
+
expect(desc).toContain("min 118");
|
|
26
|
+
expect(desc).toContain("max 126");
|
|
27
|
+
expect(desc).toContain("mmHg");
|
|
28
|
+
// Primary line, area and dotted secondary series.
|
|
29
|
+
expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(3);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("honours a caller-provided description", () => {
|
|
33
|
+
const { container } = render(
|
|
34
|
+
<MetricChart points={points} title="Blood pressure" description="Custom summary." />,
|
|
35
|
+
);
|
|
36
|
+
expect(container.querySelector("desc")!.textContent).toBe("Custom summary.");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("draws gridlines, date ticks and labelled reference lines on muted ink", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<MetricChart
|
|
42
|
+
points={points}
|
|
43
|
+
title="Blood pressure"
|
|
44
|
+
referenceLines={[
|
|
45
|
+
{ value: 120, label: "120 systolic" },
|
|
46
|
+
{ value: 80, label: "80 diastolic", series: "secondary" },
|
|
47
|
+
]}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
expect(screen.getByText("120 systolic")).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText("80 diastolic")).toBeInTheDocument();
|
|
52
|
+
// Four interior y ticks plus three x date ticks.
|
|
53
|
+
expect(screen.getByText("Sep 1")).toBeInTheDocument();
|
|
54
|
+
const dashed = Array.from(container.querySelectorAll("line")).filter(
|
|
55
|
+
(line) => line.getAttribute("stroke-dasharray") === "5 4",
|
|
56
|
+
);
|
|
57
|
+
expect(dashed).toHaveLength(2);
|
|
58
|
+
for (const line of dashed) expect(line.closest("g")!.getAttribute("class")).toContain("text-muted-foreground");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("drops secondary reference lines when the series has no second reading", () => {
|
|
62
|
+
const single = points.map(({ at, value }) => ({ at, value }));
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<MetricChart
|
|
65
|
+
points={single}
|
|
66
|
+
title="Heart rate"
|
|
67
|
+
referenceLines={[{ value: 80, label: "80 diastolic", series: "secondary" }]}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
expect(screen.queryByText("80 diastolic")).toBeNull();
|
|
71
|
+
expect(
|
|
72
|
+
Array.from(container.querySelectorAll("line")).filter(
|
|
73
|
+
(line) => line.getAttribute("stroke-dasharray") === "5 4",
|
|
74
|
+
),
|
|
75
|
+
).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("marks a single reading and renders axes only when data exists", () => {
|
|
79
|
+
const { container, rerender } = render(
|
|
80
|
+
<MetricChart points={[points[0]!]} title="Blood pressure" />,
|
|
81
|
+
);
|
|
82
|
+
expect(container.querySelectorAll("circle")).toHaveLength(1);
|
|
83
|
+
rerender(<MetricChart points={[]} title="Blood pressure" />);
|
|
84
|
+
expect(container.querySelectorAll("line")).toHaveLength(0);
|
|
85
|
+
expect(container.querySelectorAll("path")).toHaveLength(0);
|
|
86
|
+
expect(screen.getByRole("img", { name: "Blood pressure" })).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps every stroke and fill on currentColor", () => {
|
|
90
|
+
const { container } = render(<MetricChart points={points} title="Blood pressure" />);
|
|
91
|
+
for (const element of Array.from(container.querySelectorAll("[stroke], [fill]"))) {
|
|
92
|
+
const paint = element.getAttribute("stroke") ?? element.getAttribute("fill");
|
|
93
|
+
expect(["currentColor", "none"]).toContain(paint);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("supports caller value and time formatting", () => {
|
|
98
|
+
render(
|
|
99
|
+
<MetricChart
|
|
100
|
+
points={points}
|
|
101
|
+
title="Blood pressure"
|
|
102
|
+
formatValue={(value) => `${value.toFixed(0)}!`}
|
|
103
|
+
formatTime={() => "sometime"}
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getAllByText("sometime")).toHaveLength(3);
|
|
107
|
+
expect(screen.getAllByText(/!$/).length).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("preserves ref, caller attributes and caller classes", () => {
|
|
111
|
+
const ref = React.createRef<SVGSVGElement>();
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<MetricChart ref={ref} points={points} title="Blood pressure" data-testid="bp" className="max-w-md" />,
|
|
114
|
+
);
|
|
115
|
+
expect(ref.current).toBe(container.querySelector("svg"));
|
|
116
|
+
expect(screen.getByTestId("bp").getAttribute("class")).toContain("max-w-md");
|
|
117
|
+
});
|
|
118
|
+
});
|