@tangle-network/ui 11.40.2 → 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.
@@ -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 };