@tangle-network/ui 11.41.0 → 11.43.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 +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -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/compliance-badge.d.ts +62 -0
- package/dist/primitives/compliance-badge.js +58 -0
- 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 +5 -1
- package/dist/primitives.js +5 -1
- package/package.json +2 -2
- package/src/lib/series.test.ts +172 -0
- package/src/lib/series.ts +174 -0
- package/src/presentation.ts +14 -0
- package/src/primitives/compliance-badge.stories.tsx +76 -0
- package/src/primitives/compliance-badge.test.tsx +104 -0
- package/src/primitives/compliance-badge.tsx +139 -0
- package/src/primitives/index.ts +28 -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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { chartPresentation } from "../presentation";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import {
|
|
5
|
+
normalizeSeries,
|
|
6
|
+
seriesAreaPath,
|
|
7
|
+
seriesExtent,
|
|
8
|
+
seriesLinePath,
|
|
9
|
+
seriesMean,
|
|
10
|
+
seriesScale,
|
|
11
|
+
type SeriesPointInput,
|
|
12
|
+
} from "../lib/series";
|
|
13
|
+
|
|
14
|
+
export interface ChartReferenceLine {
|
|
15
|
+
value: number;
|
|
16
|
+
label?: string;
|
|
17
|
+
/** Which reading the line applies to for a dual series. A secondary line
|
|
18
|
+
* on a single-reading series is not drawn. */
|
|
19
|
+
series?: "primary" | "secondary";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MetricChartProps = Omit<React.SVGAttributes<SVGSVGElement>, "points"> & {
|
|
23
|
+
points: SeriesPointInput[];
|
|
24
|
+
/** Accessible name; also rendered as the svg title. */
|
|
25
|
+
title: string;
|
|
26
|
+
/** Unit noun for the generated description ("bpm", "ms"). */
|
|
27
|
+
unit?: string;
|
|
28
|
+
/** Fraction digits for tick labels and the description. Defaults to a
|
|
29
|
+
* maximum of two. */
|
|
30
|
+
decimals?: number;
|
|
31
|
+
referenceLines?: ChartReferenceLine[];
|
|
32
|
+
/** Overrides the generated <desc> summary. */
|
|
33
|
+
description?: string;
|
|
34
|
+
formatValue?: (value: number) => string;
|
|
35
|
+
formatTime?: (at: number) => string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const defaultTimeLabel = (at: number) =>
|
|
39
|
+
new Date(at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
40
|
+
|
|
41
|
+
const defaultValueLabel =
|
|
42
|
+
(decimals?: number) =>
|
|
43
|
+
(value: number) =>
|
|
44
|
+
value.toLocaleString(
|
|
45
|
+
undefined,
|
|
46
|
+
decimals === undefined
|
|
47
|
+
? { maximumFractionDigits: 2 }
|
|
48
|
+
: { minimumFractionDigits: decimals, maximumFractionDigits: decimals },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A labelled line chart: y gridlines, x date ticks, optional dashed
|
|
53
|
+
* reference lines and a dotted secondary series for dual readings
|
|
54
|
+
* (`value2`). Hand-rolled SVG, viewBox-scaled, currentColor throughout.
|
|
55
|
+
*/
|
|
56
|
+
const MetricChart = React.forwardRef<SVGSVGElement, MetricChartProps>(
|
|
57
|
+
(
|
|
58
|
+
{
|
|
59
|
+
points,
|
|
60
|
+
title,
|
|
61
|
+
unit,
|
|
62
|
+
decimals,
|
|
63
|
+
referenceLines,
|
|
64
|
+
description,
|
|
65
|
+
formatValue,
|
|
66
|
+
formatTime,
|
|
67
|
+
className,
|
|
68
|
+
...props
|
|
69
|
+
},
|
|
70
|
+
ref,
|
|
71
|
+
) => {
|
|
72
|
+
const series = normalizeSeries(points);
|
|
73
|
+
const width = 680;
|
|
74
|
+
const height = 260;
|
|
75
|
+
const padX = 46;
|
|
76
|
+
const padY = 16;
|
|
77
|
+
const dual = series.some((point) => point.value2 !== undefined);
|
|
78
|
+
const lines = (referenceLines ?? []).filter(
|
|
79
|
+
(line) => line.series !== "secondary" || dual,
|
|
80
|
+
);
|
|
81
|
+
// Plot box runs right of the y-label gutter; x() is box-local, hence +padX.
|
|
82
|
+
const extent = seriesExtent(series, {
|
|
83
|
+
include: lines.map((line) => line.value),
|
|
84
|
+
});
|
|
85
|
+
const plot = seriesScale(extent, width - padX - 8, height, padY);
|
|
86
|
+
const xAt = (at: number) => padX + plot.x(at);
|
|
87
|
+
const yAt = plot.y;
|
|
88
|
+
const { lo, hi, tLo, tHi } = extent;
|
|
89
|
+
const primary = series.map((point) => ({
|
|
90
|
+
x: xAt(point.at),
|
|
91
|
+
y: yAt(point.value),
|
|
92
|
+
}));
|
|
93
|
+
const secondary = series
|
|
94
|
+
.filter((point) => point.value2 !== undefined)
|
|
95
|
+
.map((point) => ({ x: xAt(point.at), y: yAt(point.value2!) }));
|
|
96
|
+
const ticks = Array.from(
|
|
97
|
+
{ length: 4 },
|
|
98
|
+
(_, index) => lo + ((hi - lo) * (index + 1)) / 5,
|
|
99
|
+
);
|
|
100
|
+
const xTicks = [tLo, tLo + (tHi - tLo) / 2, tHi];
|
|
101
|
+
const lastPrimary = primary[primary.length - 1];
|
|
102
|
+
const valueLabel = formatValue ?? defaultValueLabel(decimals);
|
|
103
|
+
const timeLabel = formatTime ?? defaultTimeLabel;
|
|
104
|
+
const values = series.map((point) => point.value);
|
|
105
|
+
const describe =
|
|
106
|
+
description ??
|
|
107
|
+
`${title}: ${series.length} readings from ${timeLabel(tLo)} to ${timeLabel(tHi)}, ` +
|
|
108
|
+
`min ${valueLabel(series.length > 0 ? Math.min(...values) : 0)}, ` +
|
|
109
|
+
`max ${valueLabel(series.length > 0 ? Math.max(...values) : 0)}, ` +
|
|
110
|
+
`average ${valueLabel(seriesMean(values) ?? 0)}${unit ? ` ${unit}` : ""}.`;
|
|
111
|
+
return (
|
|
112
|
+
<svg
|
|
113
|
+
ref={ref}
|
|
114
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
115
|
+
role="img"
|
|
116
|
+
aria-label={title}
|
|
117
|
+
className={cn(chartPresentation.root, className)}
|
|
118
|
+
{...props}
|
|
119
|
+
>
|
|
120
|
+
<title>{title}</title>
|
|
121
|
+
<desc>{describe}</desc>
|
|
122
|
+
{series.length > 0 && (
|
|
123
|
+
<>
|
|
124
|
+
{ticks.map((tick) => (
|
|
125
|
+
<g key={tick} className={chartPresentation.axis}>
|
|
126
|
+
<line
|
|
127
|
+
x1={padX}
|
|
128
|
+
x2={width - 8}
|
|
129
|
+
y1={yAt(tick)}
|
|
130
|
+
y2={yAt(tick)}
|
|
131
|
+
stroke="currentColor"
|
|
132
|
+
strokeWidth="1"
|
|
133
|
+
className={chartPresentation.gridLine}
|
|
134
|
+
/>
|
|
135
|
+
<text
|
|
136
|
+
x={padX - 8}
|
|
137
|
+
y={yAt(tick) + 4}
|
|
138
|
+
textAnchor="end"
|
|
139
|
+
fontSize="11"
|
|
140
|
+
fill="currentColor"
|
|
141
|
+
>
|
|
142
|
+
{valueLabel(tick)}
|
|
143
|
+
</text>
|
|
144
|
+
</g>
|
|
145
|
+
))}
|
|
146
|
+
{xTicks.map((tick, index) => (
|
|
147
|
+
<text
|
|
148
|
+
key={tick}
|
|
149
|
+
className={chartPresentation.axis}
|
|
150
|
+
fontSize="11"
|
|
151
|
+
fill="currentColor"
|
|
152
|
+
x={xAt(tick)}
|
|
153
|
+
y={height - 4}
|
|
154
|
+
textAnchor={
|
|
155
|
+
index === 0
|
|
156
|
+
? "start"
|
|
157
|
+
: index === xTicks.length - 1
|
|
158
|
+
? "end"
|
|
159
|
+
: "middle"
|
|
160
|
+
}
|
|
161
|
+
>
|
|
162
|
+
{timeLabel(tick)}
|
|
163
|
+
</text>
|
|
164
|
+
))}
|
|
165
|
+
{lines.map((line, index) => (
|
|
166
|
+
<g key={line.label ?? index} className={chartPresentation.axis}>
|
|
167
|
+
<line
|
|
168
|
+
x1={padX}
|
|
169
|
+
x2={width - 8}
|
|
170
|
+
y1={yAt(line.value)}
|
|
171
|
+
y2={yAt(line.value)}
|
|
172
|
+
stroke="currentColor"
|
|
173
|
+
strokeWidth="1.25"
|
|
174
|
+
strokeDasharray="5 4"
|
|
175
|
+
className={chartPresentation.referenceLine}
|
|
176
|
+
/>
|
|
177
|
+
{line.label && (
|
|
178
|
+
<text
|
|
179
|
+
x={width - 12}
|
|
180
|
+
y={yAt(line.value) - 5}
|
|
181
|
+
textAnchor="end"
|
|
182
|
+
fontSize="11"
|
|
183
|
+
fill="currentColor"
|
|
184
|
+
>
|
|
185
|
+
{line.label}
|
|
186
|
+
</text>
|
|
187
|
+
)}
|
|
188
|
+
</g>
|
|
189
|
+
))}
|
|
190
|
+
{primary.length > 1 && (
|
|
191
|
+
<path
|
|
192
|
+
d={seriesAreaPath(primary, yAt(lo))}
|
|
193
|
+
fill="currentColor"
|
|
194
|
+
stroke="none"
|
|
195
|
+
className={cn(chartPresentation.primarySeries, chartPresentation.area)}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
<path
|
|
199
|
+
d={seriesLinePath(primary)}
|
|
200
|
+
fill="none"
|
|
201
|
+
stroke="currentColor"
|
|
202
|
+
strokeWidth="2"
|
|
203
|
+
strokeLinejoin="round"
|
|
204
|
+
strokeLinecap="round"
|
|
205
|
+
className={chartPresentation.primarySeries}
|
|
206
|
+
/>
|
|
207
|
+
{secondary.length > 0 && (
|
|
208
|
+
<path
|
|
209
|
+
d={seriesLinePath(secondary)}
|
|
210
|
+
fill="none"
|
|
211
|
+
stroke="currentColor"
|
|
212
|
+
strokeWidth="2"
|
|
213
|
+
strokeLinejoin="round"
|
|
214
|
+
strokeLinecap="round"
|
|
215
|
+
strokeDasharray="2 5"
|
|
216
|
+
className={chartPresentation.secondarySeries}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
{primary.length === 1 ? (
|
|
220
|
+
<circle
|
|
221
|
+
cx={primary[0]!.x}
|
|
222
|
+
cy={primary[0]!.y}
|
|
223
|
+
r="4"
|
|
224
|
+
fill="currentColor"
|
|
225
|
+
className={chartPresentation.primarySeries}
|
|
226
|
+
/>
|
|
227
|
+
) : (
|
|
228
|
+
lastPrimary && (
|
|
229
|
+
<circle
|
|
230
|
+
cx={lastPrimary.x}
|
|
231
|
+
cy={lastPrimary.y}
|
|
232
|
+
r="3.5"
|
|
233
|
+
fill="currentColor"
|
|
234
|
+
className={chartPresentation.primarySeries}
|
|
235
|
+
/>
|
|
236
|
+
)
|
|
237
|
+
)}
|
|
238
|
+
</>
|
|
239
|
+
)}
|
|
240
|
+
</svg>
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
MetricChart.displayName = "MetricChart";
|
|
245
|
+
|
|
246
|
+
export { MetricChart };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { SeriesSparkline } from "./series-sparkline";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Primitives/SeriesSparkline",
|
|
6
|
+
component: SeriesSparkline,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
} satisfies Meta<typeof SeriesSparkline>;
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
|
|
12
|
+
const DAY = 86_400_000;
|
|
13
|
+
const t0 = Date.parse("2026-09-22T07:00:00.000Z");
|
|
14
|
+
const readings = [63, 61, 64, 60, 62, 65, 61, 63, 59, 62, 60, 61];
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
args: {
|
|
18
|
+
label: "Resting heart rate, last twelve readings",
|
|
19
|
+
points: readings.map((value, index) => ({ at: t0 + index * DAY, value })),
|
|
20
|
+
},
|
|
21
|
+
render: (args) => (
|
|
22
|
+
<div className="w-40">
|
|
23
|
+
<SeriesSparkline {...args} />
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const SingleReading: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
label: "Weight, single reading",
|
|
31
|
+
points: [{ at: t0, value: 71.4 }],
|
|
32
|
+
},
|
|
33
|
+
render: (args) => (
|
|
34
|
+
<div className="w-40">
|
|
35
|
+
<SeriesSparkline {...args} />
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { SeriesSparkline } from "./series-sparkline";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
const points = [
|
|
9
|
+
{ at: "2026-10-19T07:00:00.000Z", value: 62 },
|
|
10
|
+
{ at: "2026-10-20T07:00:00.000Z", value: 60 },
|
|
11
|
+
{ at: "2026-10-21T07:00:00.000Z", value: 63 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("SeriesSparkline", () => {
|
|
15
|
+
it("renders an accessible svg with line, area and a last-point marker", () => {
|
|
16
|
+
const { container } = render(<SeriesSparkline points={points} label="Resting heart rate" />);
|
|
17
|
+
const svg = screen.getByRole("img", { name: "Resting heart rate" });
|
|
18
|
+
expect(svg.tagName).toBe("svg");
|
|
19
|
+
expect(svg.querySelector("title")!.textContent).toBe("Resting heart rate");
|
|
20
|
+
const paths = container.querySelectorAll("path");
|
|
21
|
+
expect(paths).toHaveLength(2);
|
|
22
|
+
expect(paths[0]!.getAttribute("d")).toMatch(/^M.*Z$/);
|
|
23
|
+
expect(paths[1]!.getAttribute("d")).toMatch(/^M/);
|
|
24
|
+
expect(paths[1]!.getAttribute("d")).not.toContain("Z");
|
|
25
|
+
expect(container.querySelectorAll("circle")).toHaveLength(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("keeps every stroke and fill on currentColor against token classes", () => {
|
|
29
|
+
const { container } = render(<SeriesSparkline points={points} label="Resting heart rate" />);
|
|
30
|
+
const svg = container.querySelector("svg")!;
|
|
31
|
+
expect(svg.getAttribute("class")).toContain("text-primary");
|
|
32
|
+
for (const element of Array.from(container.querySelectorAll("[stroke], [fill]"))) {
|
|
33
|
+
const paint = element.getAttribute("stroke") ?? element.getAttribute("fill");
|
|
34
|
+
expect(["currentColor", "none"]).toContain(paint);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("draws only the marker for a single reading and nothing for none", () => {
|
|
39
|
+
const { container, rerender } = render(
|
|
40
|
+
<SeriesSparkline points={[points[0]!]} label="Resting heart rate" />,
|
|
41
|
+
);
|
|
42
|
+
expect(container.querySelectorAll("path")).toHaveLength(0);
|
|
43
|
+
expect(container.querySelectorAll("circle")).toHaveLength(1);
|
|
44
|
+
rerender(<SeriesSparkline points={[]} label="Resting heart rate" />);
|
|
45
|
+
expect(container.querySelectorAll("circle")).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("preserves ref, caller attributes and caller classes", () => {
|
|
49
|
+
const ref = React.createRef<SVGSVGElement>();
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<SeriesSparkline ref={ref} points={points} label="HRV" data-testid="hrv" className="h-6" />,
|
|
52
|
+
);
|
|
53
|
+
expect(ref.current).toBe(container.querySelector("svg"));
|
|
54
|
+
expect(screen.getByTestId("hrv").getAttribute("class")).toContain("h-6");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { chartPresentation } from "../presentation";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import {
|
|
5
|
+
normalizeSeries,
|
|
6
|
+
seriesAreaPath,
|
|
7
|
+
seriesExtent,
|
|
8
|
+
seriesLinePath,
|
|
9
|
+
seriesScale,
|
|
10
|
+
type SeriesPointInput,
|
|
11
|
+
} from "../lib/series";
|
|
12
|
+
|
|
13
|
+
export type SeriesSparklineProps = Omit<React.SVGAttributes<SVGSVGElement>, "points"> & {
|
|
14
|
+
points: SeriesPointInput[];
|
|
15
|
+
/** Accessible name; also rendered as the svg title. */
|
|
16
|
+
label: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Inline trend glyph: viewBox-scaled, currentColor against `text-primary`. */
|
|
20
|
+
const SeriesSparkline = React.forwardRef<SVGSVGElement, SeriesSparklineProps>(
|
|
21
|
+
({ points, label, className, ...props }, ref) => {
|
|
22
|
+
const series = normalizeSeries(points);
|
|
23
|
+
const width = 120;
|
|
24
|
+
const height = 36;
|
|
25
|
+
const pad = 3;
|
|
26
|
+
const scale = seriesScale(seriesExtent(series), width, height, pad);
|
|
27
|
+
const placed = series.map((point) => ({
|
|
28
|
+
x: scale.x(point.at),
|
|
29
|
+
y: scale.y(point.value),
|
|
30
|
+
}));
|
|
31
|
+
const last = placed[placed.length - 1];
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
ref={ref}
|
|
35
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
36
|
+
preserveAspectRatio="none"
|
|
37
|
+
role="img"
|
|
38
|
+
aria-label={label}
|
|
39
|
+
className={cn(chartPresentation.sparkline, className)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
<title>{label}</title>
|
|
43
|
+
{placed.length > 1 && (
|
|
44
|
+
<>
|
|
45
|
+
<path
|
|
46
|
+
d={seriesAreaPath(placed, height - pad)}
|
|
47
|
+
fill="currentColor"
|
|
48
|
+
className={chartPresentation.area}
|
|
49
|
+
stroke="none"
|
|
50
|
+
/>
|
|
51
|
+
<path
|
|
52
|
+
d={seriesLinePath(placed)}
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
strokeWidth="1.5"
|
|
56
|
+
strokeLinejoin="round"
|
|
57
|
+
strokeLinecap="round"
|
|
58
|
+
vectorEffect="non-scaling-stroke"
|
|
59
|
+
/>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
{last && <circle cx={last.x} cy={last.y} r="2" fill="currentColor" />}
|
|
63
|
+
</svg>
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
SeriesSparkline.displayName = "SeriesSparkline";
|
|
68
|
+
|
|
69
|
+
export { SeriesSparkline };
|