@tristan2828/ui-foundation 4.50.1 → 4.51.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/conventions/docs/design-language.md +66 -20
- package/dist/components/app/line-chart.d.ts +10 -1
- package/dist/components/app/line-chart.js +15 -6
- package/dist/components/app/multi-line-chart.d.ts +34 -0
- package/dist/components/app/multi-line-chart.js +75 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/line-chart-layout.d.ts +13 -0
- package/dist/lib/line-chart-layout.js +24 -0
- package/package.json +1 -1
|
@@ -3,24 +3,25 @@
|
|
|
3
3
|
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
|
-
- [Semantic tones](#semantic-tones) · lines
|
|
7
|
-
- [The three badge styles](#the-three-badge-styles) · lines
|
|
8
|
-
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines
|
|
9
|
-
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines
|
|
10
|
-
- [Logos and brand marks](#logos-and-brand-marks) · lines
|
|
11
|
-
- [Categorical colour](#categorical-colour) · lines
|
|
12
|
-
- [Typography](#typography) · lines
|
|
13
|
-
- [Written text (notes)](#written-text-notes) · lines
|
|
14
|
-
- [Density](#density) · lines
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [
|
|
20
|
-
- [A list as
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
6
|
+
- [Semantic tones](#semantic-tones) · lines 34–62
|
|
7
|
+
- [The three badge styles](#the-three-badge-styles) · lines 64–81
|
|
8
|
+
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 83–112
|
|
9
|
+
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 114–127
|
|
10
|
+
- [Logos and brand marks](#logos-and-brand-marks) · lines 129–173
|
|
11
|
+
- [Categorical colour](#categorical-colour) · lines 175–245
|
|
12
|
+
- [Typography](#typography) · lines 247–338
|
|
13
|
+
- [Written text (notes)](#written-text-notes) · lines 278–338
|
|
14
|
+
- [Density](#density) · lines 340–397
|
|
15
|
+
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 399–513
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 515–584
|
|
17
|
+
- [Notices](#notices) · lines 586–614
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 616–694
|
|
19
|
+
- [Several values over time](#several-values-over-time) · lines 657–694
|
|
20
|
+
- [A list as a board](#a-list-as-a-board) · lines 696–747
|
|
21
|
+
- [A list as cards](#a-list-as-cards) · lines 749–795
|
|
22
|
+
- [Key numbers on a record](#key-numbers-on-a-record) · lines 797–819
|
|
23
|
+
- [Picking at random](#picking-at-random) · lines 821–852
|
|
24
|
+
- [Filling in what's missing](#filling-in-whats-missing) · lines 854–904
|
|
24
25
|
|
|
25
26
|
<!-- /index -->
|
|
26
27
|
|
|
@@ -644,8 +645,53 @@ or `'last'`) with its value.
|
|
|
644
645
|
named by `label`, with a summary (first, last, lowest, highest, the
|
|
645
646
|
target) and a table of every point. Give it the same `formatY` and
|
|
646
647
|
`formatX` the page uses for those values elsewhere.
|
|
647
|
-
-
|
|
648
|
-
|
|
648
|
+
- **A projection** (4.51): `projection={{ label: 'Projected', points }}`
|
|
649
|
+
carries the line on past its last point, dashed in the same colour, for
|
|
650
|
+
where a value may go (a balance at a rate of return), never for what was
|
|
651
|
+
measured. Its points come after the last of `points`; the mark stays on
|
|
652
|
+
`points`, and a screen reader gets the projection's end in the summary
|
|
653
|
+
and its rows in the table, each named by `label`.
|
|
654
|
+
- One series and a few dozen points. Zoom or tooltips are a request for
|
|
655
|
+
the foundation, not a hand-drawn chart in the app.
|
|
656
|
+
|
|
657
|
+
### Several values over time
|
|
658
|
+
|
|
659
|
+
`MultiLineChart` (4.51) puts several series on one plot, sharing one x
|
|
660
|
+
and one y range: a net worth beside its assets and what's owed, or a
|
|
661
|
+
balance's history beside low, middle and high projections.
|
|
662
|
+
|
|
663
|
+
```tsx
|
|
664
|
+
<MultiLineChart
|
|
665
|
+
label="Net worth over time"
|
|
666
|
+
series={[
|
|
667
|
+
{ label: 'Assets', tone: 'success', points: assets },
|
|
668
|
+
{ label: 'Owed', tone: 'destructive', points: owed },
|
|
669
|
+
{ label: 'Net worth', points: netWorth },
|
|
670
|
+
]}
|
|
671
|
+
target={{ y: 1_500_000, label: 'Target' }}
|
|
672
|
+
formatY={usd}
|
|
673
|
+
formatX={month}
|
|
674
|
+
isLoading={isLoading}
|
|
675
|
+
/>
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
- **Tones, not colours:** each series takes a text tone (`info`, the
|
|
679
|
+
default, `success`, `warning`, `destructive` or `muted`), each clearing
|
|
680
|
+
3:1 on a card in both themes. Pick by meaning where there is one (what's
|
|
681
|
+
owed in `destructive`); otherwise keep the series that matters most in
|
|
682
|
+
`info` and the rest `muted`.
|
|
683
|
+
- **`dashed` is for what wasn't measured:** a projection or a plan. A
|
|
684
|
+
projection that starts at the last measured point should repeat that
|
|
685
|
+
point as its first, so the lines meet.
|
|
686
|
+
- **A legend under the plot** names each series with its latest value,
|
|
687
|
+
so colour never carries the meaning alone. The target is a dashed
|
|
688
|
+
`success-text` line, as on `LineChart`.
|
|
689
|
+
- **It owns its states** as `LineChart` does: a skeleton, one quiet line
|
|
690
|
+
when no series has a point, a dot for a series of one point.
|
|
691
|
+
- **A screen reader gets a summary** (each series from first to last
|
|
692
|
+
value, and the target) and a table with a column per series.
|
|
693
|
+
- Up to four or five series. More than that, or bars and stacked areas,
|
|
694
|
+
are a request for the foundation.
|
|
649
695
|
|
|
650
696
|
## A list as a board
|
|
651
697
|
|
|
@@ -10,6 +10,15 @@ export type LineChartProps = {
|
|
|
10
10
|
y: number;
|
|
11
11
|
label: string;
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* Where the line may go next, drawn dashed on from its last point:
|
|
15
|
+
* `{ label: 'Projected', points }`, oldest first, each after the last of
|
|
16
|
+
* `points`. The mark is only ever on `points`.
|
|
17
|
+
*/
|
|
18
|
+
projection?: {
|
|
19
|
+
label: string;
|
|
20
|
+
points: readonly LineChartPoint[];
|
|
21
|
+
};
|
|
13
22
|
/** Which point to mark with a dot and its value: the lowest, the highest or the latest. */
|
|
14
23
|
mark?: LineChartMark;
|
|
15
24
|
/** A value as shown: `usd`. Defaults to the number as the browser writes it. */
|
|
@@ -23,4 +32,4 @@ export type LineChartProps = {
|
|
|
23
32
|
emptyMessage?: string;
|
|
24
33
|
className?: string;
|
|
25
34
|
};
|
|
26
|
-
export declare function LineChart({ label, points, target, mark, formatY, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
|
|
35
|
+
export declare function LineChart({ label, points, target, projection, mark, formatY, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
|
|
@@ -13,7 +13,12 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
13
13
|
//
|
|
14
14
|
// The picture is for the eye: a screen reader gets the figure's name,
|
|
15
15
|
// a summary (first, last, lowest, highest, the target) and a table of
|
|
16
|
-
// every point, all visually hidden.
|
|
16
|
+
// every point, all visually hidden.
|
|
17
|
+
//
|
|
18
|
+
// A `projection` (4.51) carries the line on past its last point, dashed in
|
|
19
|
+
// the same colour: where a balance may go, not where it went. It shares
|
|
20
|
+
// the plot's x and y ranges, so the target and the projection meet where
|
|
21
|
+
// they would. The four states: a skeleton at
|
|
17
22
|
// `height` while loading, one quiet line with no points, a dot (no line)
|
|
18
23
|
// for one point, and the chart.
|
|
19
24
|
import { cn } from 'cn';
|
|
@@ -29,18 +34,21 @@ const PLATE = 'rounded-sm bg-card px-1';
|
|
|
29
34
|
function besides(x) {
|
|
30
35
|
return x > 0.75 ? '-translate-x-full -ml-1.5' : 'ml-1.5';
|
|
31
36
|
}
|
|
32
|
-
export function LineChart({ label, points, target, mark, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
|
|
37
|
+
export function LineChart({ label, points, target, projection, mark, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
|
|
33
38
|
if (isLoading) {
|
|
34
39
|
return (_jsxs("div", { "data-slot": "line-chart", "data-state": "loading", "aria-busy": "true", className: cn('min-w-0', className), children: [_jsxs("span", { className: "sr-only", children: ["Loading ", label] }), _jsx(Skeleton, { style: { height }, className: "w-full" })] }));
|
|
35
40
|
}
|
|
36
41
|
if (points.length === 0) {
|
|
37
42
|
return (_jsx("figure", { "data-slot": "line-chart", "data-state": "empty", "aria-label": label, className: cn('min-w-0', className), children: _jsx("p", { style: { height }, className: "flex items-center justify-center rounded-md border border-dashed border-border type-body text-muted-foreground", children: emptyMessage }) }));
|
|
38
43
|
}
|
|
39
|
-
const
|
|
44
|
+
const ahead = projection?.points ?? [];
|
|
45
|
+
const layout = { ...chartLayout([...points, ...ahead], target?.y), markIndex: chartLayout(points, undefined, mark).markIndex };
|
|
46
|
+
const projected = ahead.length > 0 ? layout.places.slice(points.length - 1) : [];
|
|
47
|
+
const lastX = ahead.length > 0 ? ahead[ahead.length - 1].x : points[points.length - 1].x;
|
|
40
48
|
const values = points.map((point) => point.y);
|
|
41
49
|
const lowest = values.indexOf(Math.min(...values));
|
|
42
50
|
const highest = values.indexOf(Math.max(...values));
|
|
43
|
-
const line = layout.places.map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
|
|
51
|
+
const line = layout.places.slice(0, points.length).map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
|
|
44
52
|
const marked = layout.markIndex === undefined ? undefined : layout.places[layout.markIndex];
|
|
45
53
|
const single = points.length === 1 ? layout.places[0] : undefined;
|
|
46
54
|
const dot = marked ?? single;
|
|
@@ -49,11 +57,12 @@ export function LineChart({ label, points, target, mark, formatY = defaultY, for
|
|
|
49
57
|
points.length === 1 ? `One point: ${describe(0)}` : `From ${describe(0)} to ${describe(points.length - 1)}`,
|
|
50
58
|
points.length > 2 && `lowest ${describe(lowest)}`,
|
|
51
59
|
points.length > 2 && `highest ${describe(highest)}`,
|
|
60
|
+
projection && ahead.length > 0 && `${projection.label} ${formatY(ahead[ahead.length - 1].y)} on ${formatX(lastX)}`,
|
|
52
61
|
target && `${target.label} ${formatY(target.y)}`,
|
|
53
62
|
]
|
|
54
63
|
.filter(Boolean)
|
|
55
64
|
.join('; ');
|
|
56
|
-
return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
|
|
65
|
+
return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), projected.length > 1 && (_jsx("svg", { "data-slot": "line-chart-projection", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: projected.map((place) => `${place.x * 100},${place.y * 100}`).join(' '), fill: "none", strokeWidth: 2, strokeDasharray: "6 4", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
|
|
57
66
|
// Under the lowest point, where the line isn't; over any other.
|
|
58
|
-
mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(points[0].x) }), points.length > 1 && _jsx("span", { children: formatX(
|
|
67
|
+
mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(points[0].x) }), (points.length > 1 || ahead.length > 0) && _jsx("span", { children: formatX(lastX) })] }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", children: "Value" })] }) }), _jsxs("tbody", { children: [points.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsx("td", { children: formatY(point.y) })] }, index))), ahead.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsxs("td", { children: [formatY(point.y), " (", projection.label, ")"] })] }, `ahead-${index}`)))] })] })] }));
|
|
59
68
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type LineChartPoint } from '../../lib/line-chart-layout.js';
|
|
2
|
+
export type ChartTone = 'info' | 'success' | 'warning' | 'destructive' | 'muted';
|
|
3
|
+
export type ChartSeries = {
|
|
4
|
+
/** The series' name in the legend and the table: "Net worth". */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Its values, oldest first. */
|
|
7
|
+
points: readonly LineChartPoint[];
|
|
8
|
+
/** Its colour, a text tone that clears 3:1 on a card. Defaults to `info`. */
|
|
9
|
+
tone?: ChartTone;
|
|
10
|
+
/** Dashed: a projection or a plan, not something measured. */
|
|
11
|
+
dashed?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type MultiLineChartProps = {
|
|
14
|
+
/** The figure's name: "Net worth over time". */
|
|
15
|
+
label: string;
|
|
16
|
+
/** The series, drawn in this order (the last on top) and listed in the legend. */
|
|
17
|
+
series: readonly ChartSeries[];
|
|
18
|
+
/** A dashed line at a value to reach: `{ y: 1_500_000, label: 'Target' }`, in `success-text`. */
|
|
19
|
+
target?: {
|
|
20
|
+
y: number;
|
|
21
|
+
label: string;
|
|
22
|
+
};
|
|
23
|
+
/** A value as shown: `usd`. Defaults to the number as the browser writes it. */
|
|
24
|
+
formatY?: (y: number) => string;
|
|
25
|
+
/** A point's x as shown: `formatPlainDate`. Defaults to x as given. */
|
|
26
|
+
formatX?: (x: LineChartPoint['x']) => string;
|
|
27
|
+
/** The plot's height in px; the chart fills its container's width. */
|
|
28
|
+
height?: number;
|
|
29
|
+
isLoading?: boolean;
|
|
30
|
+
/** What shows when no series has a point. */
|
|
31
|
+
emptyMessage?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function MultiLineChart({ label, series, target, formatY, formatX, height, isLoading, emptyMessage, className, }: MultiLineChartProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Several values over time on one plot (4.51): a net worth beside its
|
|
3
|
+
// assets and debts, or a balance's history beside low, middle and high
|
|
4
|
+
// projections. LineChart's sibling, plain SVG the same way: each series is
|
|
5
|
+
// its own <svg> drawing in currentColor in a text tone, so the package's
|
|
6
|
+
// glyph check measures it like any icon, and a dashed series (a
|
|
7
|
+
// projection, a plan) reads apart from a solid one in either theme. A
|
|
8
|
+
// legend under the plot names each series with its latest value, so the
|
|
9
|
+
// colours never carry the meaning alone.
|
|
10
|
+
//
|
|
11
|
+
// The picture is for the eye: a screen reader gets the figure's name, a
|
|
12
|
+
// summary (each series from its first to its last value, the target) and
|
|
13
|
+
// a table with a column per series, all visually hidden. The four states:
|
|
14
|
+
// a skeleton at `height` while loading, one quiet line when no series has
|
|
15
|
+
// a point, a dot for a series of one point, and the chart.
|
|
16
|
+
import { cn } from 'cn';
|
|
17
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
18
|
+
import { seriesLayout } from '../../lib/line-chart-layout.js';
|
|
19
|
+
const TONE = {
|
|
20
|
+
info: 'text-info-text',
|
|
21
|
+
success: 'text-success-text',
|
|
22
|
+
warning: 'text-warning-text',
|
|
23
|
+
destructive: 'text-destructive-text',
|
|
24
|
+
muted: 'text-muted-foreground',
|
|
25
|
+
};
|
|
26
|
+
const DOT = {
|
|
27
|
+
info: 'bg-info-text',
|
|
28
|
+
success: 'bg-success-text',
|
|
29
|
+
warning: 'bg-warning-text',
|
|
30
|
+
destructive: 'bg-destructive-text',
|
|
31
|
+
muted: 'bg-muted-foreground',
|
|
32
|
+
};
|
|
33
|
+
const defaultY = (y) => y.toLocaleString();
|
|
34
|
+
const defaultX = (x) => String(x);
|
|
35
|
+
const percent = (fraction) => `${fraction * 100}%`;
|
|
36
|
+
const DASH = '6 4';
|
|
37
|
+
export function MultiLineChart({ label, series, target, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
|
|
38
|
+
if (isLoading) {
|
|
39
|
+
return (_jsxs("div", { "data-slot": "multi-line-chart", "data-state": "loading", "aria-busy": "true", className: cn('min-w-0', className), children: [_jsxs("span", { className: "sr-only", children: ["Loading ", label] }), _jsx(Skeleton, { style: { height }, className: "w-full" })] }));
|
|
40
|
+
}
|
|
41
|
+
const drawn = series.filter((one) => one.points.length > 0);
|
|
42
|
+
if (drawn.length === 0) {
|
|
43
|
+
return (_jsx("figure", { "data-slot": "multi-line-chart", "data-state": "empty", "aria-label": label, className: cn('min-w-0', className), children: _jsx("p", { style: { height }, className: "flex items-center justify-center rounded-md border border-dashed border-border type-body text-muted-foreground", children: emptyMessage }) }));
|
|
44
|
+
}
|
|
45
|
+
const layout = seriesLayout(drawn.map((one) => one.points), target?.y);
|
|
46
|
+
// The x range's two ends, by where they sit on the plot.
|
|
47
|
+
const ends = drawn.flatMap((one, s) => one.points.map((point, p) => ({ x: point.x, at: layout.places[s][p].x })));
|
|
48
|
+
const left = ends.reduce((a, b) => (b.at < a.at ? b : a));
|
|
49
|
+
const right = ends.reduce((a, b) => (b.at > a.at ? b : a));
|
|
50
|
+
// Every x once, in order along the plot, for the table's rows.
|
|
51
|
+
const rows = [...new Map(ends.sort((a, b) => a.at - b.at).map((end) => [String(end.x), end.x])).values()];
|
|
52
|
+
const summary = [
|
|
53
|
+
...drawn.map((one) => {
|
|
54
|
+
const first = one.points[0];
|
|
55
|
+
const last = one.points[one.points.length - 1];
|
|
56
|
+
return one.points.length === 1
|
|
57
|
+
? `${one.label}: ${formatY(first.y)} on ${formatX(first.x)}`
|
|
58
|
+
: `${one.label} from ${formatY(first.y)} on ${formatX(first.x)} to ${formatY(last.y)} on ${formatX(last.x)}`;
|
|
59
|
+
}),
|
|
60
|
+
target && `${target.label} ${formatY(target.y)}`,
|
|
61
|
+
]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join('; ');
|
|
64
|
+
return (_jsxs("figure", { "data-slot": "multi-line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: DASH, vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: "absolute right-0 -translate-y-full rounded-sm bg-card px-1 type-caption text-success-text tabular-nums", style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), drawn.map((one, s) => {
|
|
65
|
+
const tone = one.tone ?? 'info';
|
|
66
|
+
const places = layout.places[s];
|
|
67
|
+
if (places.length === 1) {
|
|
68
|
+
return (_jsx("span", { "data-slot": "multi-line-chart-dot", className: cn('absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-card', DOT[tone]), style: { left: percent(places[0].x), top: percent(places[0].y) } }, s));
|
|
69
|
+
}
|
|
70
|
+
return (_jsx("svg", { "data-slot": "multi-line-chart-series", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: cn('absolute inset-0 size-full overflow-visible', TONE[tone]), children: _jsx("polyline", { points: places.map((place) => `${place.x * 100},${place.y * 100}`).join(' '), fill: "none", strokeWidth: 2, strokeDasharray: one.dashed ? DASH : undefined, strokeLinejoin: "round", strokeLinecap: one.dashed ? undefined : 'round', vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }, s));
|
|
71
|
+
})] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(left.x) }), right.at > left.at && _jsx("span", { children: formatX(right.x) })] }), _jsx("ul", { "aria-hidden": "true", "data-slot": "multi-line-chart-legend", className: "flex flex-wrap gap-x-4 gap-y-1 type-caption text-foreground", children: drawn.map((one, s) => (_jsxs("li", { className: "flex items-center gap-1.5", children: [_jsx("svg", { viewBox: "0 0 16 4", className: cn('h-1 w-4 shrink-0', TONE[one.tone ?? 'info']), children: _jsx("line", { x1: "0", x2: "16", y1: "2", y2: "2", strokeWidth: 2, strokeDasharray: one.dashed ? '4 2' : undefined, className: "stroke-current" }) }), _jsx("span", { children: one.label }), _jsx("span", { className: "font-medium tabular-nums", children: formatY(one.points[one.points.length - 1].y) })] }, s))) }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), drawn.map((one, s) => (_jsx("th", { scope: "col", children: one.label }, s)))] }) }), _jsx("tbody", { children: rows.map((x) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(x) }), drawn.map((one, s) => {
|
|
72
|
+
const point = one.points.find((p) => String(p.x) === String(x));
|
|
73
|
+
return _jsx("td", { children: point ? formatY(point.y) : '' }, s);
|
|
74
|
+
})] }, String(x)))) })] })] }));
|
|
75
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export { Notice, type NoticeProps, type NoticeTone } from './components/app/noti
|
|
|
37
37
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
38
38
|
export { ProgressRing, type ProgressRingProps } from './components/app/progress-ring.js';
|
|
39
39
|
export { LineChart, type LineChartMark, type LineChartPoint, type LineChartProps, } from './components/app/line-chart.js';
|
|
40
|
+
export { MultiLineChart, type ChartSeries, type ChartTone, type MultiLineChartProps, } from './components/app/multi-line-chart.js';
|
|
40
41
|
export { StepBar, type StepBarProps } from './components/app/step-bar.js';
|
|
41
42
|
export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
|
|
42
43
|
export { DateField, type DateFieldProps } from './components/app/date-field.js';
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,7 @@ export { Notice } from './components/app/notice.js';
|
|
|
40
40
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
41
41
|
export { ProgressRing } from './components/app/progress-ring.js';
|
|
42
42
|
export { LineChart, } from './components/app/line-chart.js';
|
|
43
|
+
export { MultiLineChart, } from './components/app/multi-line-chart.js';
|
|
43
44
|
export { StepBar } from './components/app/step-bar.js';
|
|
44
45
|
export { RatingStar } from './components/app/rating-star.js';
|
|
45
46
|
export { DateField } from './components/app/date-field.js';
|
|
@@ -21,3 +21,16 @@ export type ChartLayout = {
|
|
|
21
21
|
markIndex?: number;
|
|
22
22
|
};
|
|
23
23
|
export declare function chartLayout(points: readonly LineChartPoint[], target?: number, mark?: LineChartMark): ChartLayout;
|
|
24
|
+
export type SeriesLayout = {
|
|
25
|
+
/** Each series' places, in the order given, each in its points' order. */
|
|
26
|
+
places: {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
}[][];
|
|
30
|
+
domain: {
|
|
31
|
+
min: number;
|
|
32
|
+
max: number;
|
|
33
|
+
};
|
|
34
|
+
targetY?: number;
|
|
35
|
+
};
|
|
36
|
+
export declare function seriesLayout(series: readonly (readonly LineChartPoint[])[], target?: number): SeriesLayout;
|
|
@@ -42,3 +42,27 @@ export function chartLayout(points, target, mark) {
|
|
|
42
42
|
markIndex,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
// Several series on one plot (MultiLineChart): one y range over all their
|
|
46
|
+
// values and the target, and one x range over all their points, so a
|
|
47
|
+
// series that starts later starts further right. When an x isn't a time
|
|
48
|
+
// or a number, each series' points are evenly spaced over the longest's.
|
|
49
|
+
export function seriesLayout(series, target) {
|
|
50
|
+
const shared = chartLayout(series.flat(), target);
|
|
51
|
+
const { min, max } = shared.domain;
|
|
52
|
+
const height = (y) => (max - y) / (max - min);
|
|
53
|
+
const times = series.flat().map((point) => along(point.x));
|
|
54
|
+
const timed = times.every(Number.isFinite) && new Set(times).size > 1;
|
|
55
|
+
const first = Math.min(...times);
|
|
56
|
+
const span = Math.max(...times) - first;
|
|
57
|
+
const longest = Math.max(...series.map((points) => points.length - 1));
|
|
58
|
+
const across = (x, index) => {
|
|
59
|
+
if (timed)
|
|
60
|
+
return (along(x) - first) / span;
|
|
61
|
+
return longest === 0 ? 0.5 : index / longest;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
places: series.map((points) => points.map((point, index) => ({ x: across(point.x, index), y: height(point.y) }))),
|
|
65
|
+
domain: shared.domain,
|
|
66
|
+
targetY: shared.targetY,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.51.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|