@nanisoft/prism-ui 0.8.0 → 0.10.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,212 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from '../../lib/utils';
3
+ /**
4
+ * The canvas the graph draws into, in its own user units.
5
+ *
6
+ * A fixed canvas rather than a viewBox derived from the caller's numbers, for
7
+ * the reason `Diagram` gives: type inside a scaled SVG is sized in the SVG's
8
+ * own user units, so a viewBox derived from a caller's coordinates would make
9
+ * the label size a function of how large a number the caller happened to use.
10
+ */
11
+ const CANVAS_WIDTH = 640;
12
+ const CANVAS_HEIGHT = 360;
13
+ /**
14
+ * The band left empty around the fitted points, in user units.
15
+ *
16
+ * Wider than `Diagram`'s, and the extra is on the bottom rather than split. A
17
+ * pulse graph is read as a sequence running left to right, so the bottom band
18
+ * carries the rail, the lane marks and the stage names set under them, and all
19
+ * three are horizontal extents that a graph with more vertical spread would
20
+ * collide with. Reserving it once here is cheaper than every caller learning
21
+ * where not to put a node.
22
+ */
23
+ const PADDING = 56;
24
+ /** The node mark's radius. A circle, so no corner radius for the pack to move. */
25
+ const NODE_RADIUS = 6;
26
+ /**
27
+ * Type sizes, in user units rather than in `rem`.
28
+ *
29
+ * The authored type scale is in `rem` and cannot be used inside a viewBox, where
30
+ * a font size is a coordinate and not a length on the page. These are geometry,
31
+ * the one thing Tailwind stays authoritative for.
32
+ */
33
+ const NODE_NAME_SIZE = 13;
34
+ const NODE_NAME_OFFSET = 22;
35
+ const NODE_NOTE_SIZE = 10;
36
+ const NODE_NOTE_OFFSET = 35;
37
+ /**
38
+ * The rail the marker rides, in user units, measured from the bottom of the
39
+ * fitted area.
40
+ *
41
+ * It is a separate element from the edges because it is a different claim: the
42
+ * edges say what connects to what, and the rail says there is an order. A graph
43
+ * whose nodes are not in lanes has no rail, which is why this is drawn from the
44
+ * lane count rather than always being present.
45
+ */
46
+ const RAIL_OFFSET = 74;
47
+ const RAIL_THICKNESS = 1.5;
48
+ /**
49
+ * The caller's points, fitted into the canvas with their aspect ratio kept and
50
+ * the leftover shared out evenly.
51
+ *
52
+ * A caller whose points span one axis only, or who passes a single node, gets
53
+ * the other axis centred rather than a division by zero.
54
+ */
55
+ function fit(nodes) {
56
+ if (nodes.length === 0)
57
+ return () => ({ x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2 });
58
+ const xs = nodes.map((node) => node.x);
59
+ const ys = nodes.map((node) => node.y);
60
+ const minX = Math.min(...xs);
61
+ const minY = Math.min(...ys);
62
+ const spanX = Math.max(...xs) - minX;
63
+ const spanY = Math.max(...ys) - minY;
64
+ const roomX = CANVAS_WIDTH - PADDING * 2;
65
+ const roomY = CANVAS_HEIGHT - PADDING * 2;
66
+ const scale = Math.min(spanX === 0 ? Number.POSITIVE_INFINITY : roomX / spanX, spanY === 0 ? Number.POSITIVE_INFINITY : roomY / spanY);
67
+ const unit = Number.isFinite(scale) && scale > 0 ? scale : 1;
68
+ const offsetX = PADDING + (roomX - spanX * unit) / 2 - minX * unit;
69
+ const offsetY = PADDING + (roomY - spanY * unit) / 2 - minY * unit;
70
+ return (node) => ({ x: node.x * unit + offsetX, y: node.y * unit + offsetY });
71
+ }
72
+ /**
73
+ * The lanes present, in ascending order.
74
+ *
75
+ * A lane number is the caller's own indexing and is not required to start at
76
+ * zero or to be contiguous, so the set is derived and sorted rather than
77
+ * assumed. A caller that skipped a lane gets a rail with the lanes it named
78
+ * and no invented one between them, which is the only answer that does not
79
+ * draw a stage that is not there.
80
+ */
81
+ function lanesOf(nodes) {
82
+ return [...new Set(nodes.flatMap((node) => (node.lane === undefined ? [] : [node.lane])))].sort((a, b) => a - b);
83
+ }
84
+ /**
85
+ * A three-stop stagger for the ambient cycles.
86
+ *
87
+ * A figure of nodes that all breathe on the same beat is a heartbeat, and a
88
+ * heartbeat is decoration: it is the same signal everywhere and so it carries
89
+ * no information about this system. Offsetting by lane means the rhythm runs
90
+ * along the rail instead, which is a claim about the sequence. The classes are
91
+ * the three the stylesheet publishes, chosen by lane position rather than by
92
+ * anything about the node, so a caller cannot accidentally author an
93
+ * unsynchronised field.
94
+ */
95
+ function stagger(lane) {
96
+ return ['prism-ambient-delay-1', 'prism-ambient-delay-2', 'prism-ambient-delay-3'][Math.abs(lane) % 3];
97
+ }
98
+ /**
99
+ * A graph of named things, the relations between them, and a marker travelling
100
+ * the sequence.
101
+ *
102
+ * This is the Component the four NaniSoft product sites each lost when they
103
+ * moved off the retired line, rebuilt rather than recovered. Their old heroes
104
+ * drew the right thing, in the wrong way: a `<canvas>` with a
105
+ * `requestAnimationFrame` loop, its colours read out of the DOM once at mount,
106
+ * and numbers invented by a sine hash so the drawing would have something to
107
+ * show. Every part of that was a defect with a real cost, and this file is the
108
+ * argument for each of them in turn.
109
+ *
110
+ * **It is a server Component.** No `'use client'`, no hook, no effect, no
111
+ * context, no runtime. The motion is CSS on the server-rendered markup, so a
112
+ * consumer gets a running figure in a static export with zero bytes of
113
+ * JavaScript, and `check-client-budget.mjs` keeps it off the client roster. The
114
+ * old heroes needed a client runtime on a static-export site; this one needs
115
+ * nothing.
116
+ *
117
+ * **It is SVG, not a canvas, and that is the pack-boundary reason.** A canvas
118
+ * paints pixels it has already resolved, and a `data-pack` boundary is an
119
+ * attribute on an ancestor, so a resolved value does not move when the pack
120
+ * beneath it does. Every stroke and fill below names a semantic token as a
121
+ * utility, so a scoped pack boundary above the drawing restyles it through the
122
+ * cascade exactly as it restyles a heading. This is why `check-vector-ink.mjs`
123
+ * scans this file.
124
+ *
125
+ * **Nothing is hidden and nothing waits.** No rule sets `opacity: 0`; the
126
+ * figure is complete and fully legible at first paint; and the animation only
127
+ * moves things that are already visible. A reader with scripting off, a slow
128
+ * connection, a print stylesheet, or a crawler sees the whole drawing. The
129
+ * reduced-motion block in the stylesheet is one `animation: none`, and because
130
+ * every element's resting state is its full form, that reader gets the same
131
+ * figure, still. This is the `hidden-state` gate's requirement met by the
132
+ * design rather than by an exception, which is why a consumer needs no
133
+ * exception to enable it.
134
+ *
135
+ * **The motion is the token scale's.** Every `prism-ambient-*` class reads its
136
+ * length from `var(--ambient-*)`; nothing in this file states a duration, an
137
+ * easing, or a keyframe name in a shorthand. The one distance this file does set
138
+ * is a geometry length in user units, because the keyframe that consumes it
139
+ * translates by a distance and that distance is a property of this drawing's
140
+ * coordinates rather than a timing.
141
+ *
142
+ * **What it will not do.** It does not invent data. The old canvas filled itself
143
+ * with a sine hash, and every site that shipped it inherited a chart of numbers
144
+ * nobody collected; a reader who screenshotted one and read the axis got a lie
145
+ * with a real typeface on it. Here, a node is a node a caller named, an edge is
146
+ * an edge a caller declared, and an empty graph is an empty graph. The motion
147
+ * says a system is running. It does not say what the numbers were.
148
+ *
149
+ * **The rail.** Nodes carrying a `lane` are stages of one sequence, and the
150
+ * graph draws a rail under them with a marker travelling it, so the order is
151
+ * visible as an order rather than inferred from left-to-right spacing. Nodes
152
+ * with no lane are a field: no rail, no marker, and every node present from the
153
+ * first frame. A graph may have both, and the rail then describes only the lanes
154
+ * that exist.
155
+ *
156
+ * **Accessibility.** `role="img"` with the `label` the caller passes, the same
157
+ * contract `Diagram` holds. Everything inside a `role="img"` is presentational,
158
+ * so node names and notes are drawn for sighted readers and the surrounding
159
+ * sentence is what a screen reader gets. Pass `decorative` when that sentence
160
+ * already says what the drawing shows.
161
+ *
162
+ * Positions are the caller's. The Component fits and draws, and it does not lay
163
+ * out a graph, so it cannot know that two labels will not collide. A relation
164
+ * naming a node that is not in `nodes` is dropped and counted on the element.
165
+ */
166
+ function PulseGraph({ nodes, relations = [], label, decorative = false, className, }) {
167
+ const place = fit(nodes);
168
+ const byId = new Map(nodes.map((node) => [node.id, node]));
169
+ const lanes = lanesOf(nodes);
170
+ const drawn = relations.flatMap((relation) => {
171
+ const from = byId.get(relation.from);
172
+ const to = byId.get(relation.to);
173
+ return from && to ? [{ relation, from: place(from), to: place(to) }] : [];
174
+ });
175
+ /**
176
+ * The rail's two ends, taken from the lanes that exist rather than from the
177
+ * canvas. A rail as wide as the canvas regardless of where the nodes are would
178
+ * say the sequence continues past its first and last stage, which is a claim
179
+ * about a caller's data that the caller did not make.
180
+ */
181
+ const railNodes = nodes.filter((node) => node.lane !== undefined);
182
+ const railAt = railNodes.length
183
+ ? {
184
+ x0: Math.min(...railNodes.map((node) => place(node).x)) - NODE_RADIUS - 10,
185
+ x1: Math.max(...railNodes.map((node) => place(node).x)) + NODE_RADIUS + 10,
186
+ }
187
+ : null;
188
+ const railY = CANVAS_HEIGHT - RAIL_OFFSET;
189
+ return (_jsxs("svg", { "data-slot": "pulse-graph", "data-unresolved-relations": relations.length - drawn.length, "data-lanes": lanes.length, viewBox: `0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`, xmlns: "http://www.w3.org/2000/svg", role: decorative ? undefined : 'img', "aria-label": decorative ? undefined : label, "aria-hidden": decorative || undefined, className: cn('h-auto w-full', className), children: [railAt ? (_jsxs("g", { "data-slot": "pulse-graph-rail", children: [_jsx("line", { "data-slot": "pulse-graph-rail-line", x1: railAt.x0, y1: railY, x2: railAt.x1, y2: railY, strokeWidth: RAIL_THICKNESS, className: "stroke-border" }), _jsx("g", { "data-slot": "pulse-graph-marker", className: "prism-ambient-travel prism-ambient-delay-1", style: { '--ambient-travel-distance': `${railAt.x1 - railAt.x0}px` }, children: _jsx("rect", { x: railAt.x0 - 2.5, y: railY - 2.5, width: 5, height: 5, rx: 2.5, className: "fill-brand-ink" }) })] })) : null, drawn.map(({ relation, from, to }) => (_jsxs("g", { "data-slot": "pulse-graph-relation", "data-relation": `${relation.from}-${relation.to}`, "data-carries": relation.carries || undefined, children: [_jsx("line", { x1: from.x, y1: from.y, x2: to.x, y2: to.y, strokeWidth: 1, strokeDasharray: relation.indirect ? '4 4' : undefined, className: "stroke-muted-foreground/50 fill-none" }), relation.carries
190
+ ? (() => {
191
+ const dx = to.x - from.x;
192
+ const dy = to.y - from.y;
193
+ const length = Math.hypot(dx, dy);
194
+ if (length === 0)
195
+ return null;
196
+ // The head sits back from the target mark so it never overlaps
197
+ // it, and is scaled to the line's own length so a short edge and
198
+ // a long one get the same size head rather than a stretched one.
199
+ const inset = NODE_RADIUS + 4;
200
+ const ux = dx / length;
201
+ const uy = dy / length;
202
+ const hx = to.x - ux * inset;
203
+ const hy = to.y - uy * inset;
204
+ const head = 5;
205
+ return (_jsx("path", { "data-slot": "pulse-graph-flow", d: `M ${hx - uy * head} ${hy + ux * head} L ${hx} ${hy} L ${hx + uy * head} ${hy - ux * head}`, strokeWidth: 1.5, fill: "none", className: "stroke-brand-ink" }));
206
+ })()
207
+ : null] }, `${relation.from}-${relation.to}`))), nodes.map((node) => {
208
+ const at = place(node);
209
+ return (_jsxs("g", { "data-slot": "pulse-graph-node", "data-node": node.id, "data-emphasis": node.emphasis || undefined, "data-lane": node.lane, children: [node.emphasis ? (_jsx("circle", { "data-slot": "pulse-graph-halo", cx: at.x, cy: at.y, r: NODE_RADIUS, strokeWidth: 0, className: cn('fill-brand-ink/25', 'prism-ambient-pulse', stagger(node.lane ?? 0)) })) : null, _jsx("circle", { cx: at.x, cy: at.y, r: NODE_RADIUS, strokeWidth: node.emphasis ? 2 : 1, className: cn(node.emphasis ? 'fill-accent stroke-brand-ink' : 'fill-card stroke-border') }), _jsx("text", { x: at.x, y: at.y + NODE_NAME_OFFSET, fontSize: NODE_NAME_SIZE, textAnchor: "middle", className: "fill-foreground font-mono", children: node.name }), node.note ? (_jsx("text", { x: at.x, y: at.y + NODE_NOTE_OFFSET, fontSize: NODE_NOTE_SIZE, textAnchor: "middle", className: "fill-muted-foreground font-mono", children: node.note })) : null] }, node.id));
210
+ })] }));
211
+ }
212
+ export { PulseGraph };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * One column in a PulseSeries.
3
+ *
4
+ * `value` is a magnitude in the caller's own units and the Component scales it
5
+ * against the tallest column, so a caller passes the numbers it has rather than
6
+ * percentages of a maximum it had to compute first. The reason is honesty: a
7
+ * caller that pre-normalises has two chances to get the axis wrong, and a
8
+ * mislabelled axis is a lie with a typeface on it.
9
+ *
10
+ * `emphasis` marks the column the reader is meant to land on, and it is the one
11
+ * difference a column may carry. The emphasised column takes the brand ink and
12
+ * takes a label; every other column takes muted ink and takes none. Two
13
+ * emphasised columns are a caller's mistake rather than a second level, because
14
+ * a figure with two focal points has none.
15
+ */
16
+ export type PulseBar = {
17
+ /** A key unique within the series. It is carried on the markup as `data-column`. */
18
+ id: string;
19
+ /** The column's height, in the caller's own units. */
20
+ value: number;
21
+ /**
22
+ * The name printed under the column. Required on the emphasised column and
23
+ * optional elsewhere, because a label under every column of a wide series is
24
+ * the axis the caller would have drawn anyway, and this Component leaves room
25
+ * for it rather than making the caller draw one behind it.
26
+ */
27
+ label?: string;
28
+ /** Marks the column the drawing is about. See the Brand Ink Rule. */
29
+ emphasis?: boolean;
30
+ };
31
+ /**
32
+ * What both arms of `PulseSeriesProps` carry.
33
+ *
34
+ * The name is required unless the drawing is decorative, and it is forbidden
35
+ * when it is. That is a union rather than `label?: string` for the reason
36
+ * `Diagram` states: only a union lets the type system see the exception, because
37
+ * an optional label on one arm can only be optional on both.
38
+ */
39
+ type PulseSeriesFigure = {
40
+ /**
41
+ * Hides the drawing from assistive technology and drops the name. Use it
42
+ * when the surrounding sentence already says what the series shows, and
43
+ * nothing inside the hidden tree is named either.
44
+ */
45
+ decorative: true;
46
+ label?: never;
47
+ } | {
48
+ /** The name of the drawing, and the only thing a screen reader reads from it. */
49
+ label: string;
50
+ decorative?: false;
51
+ };
52
+ export type PulseSeriesProps = PulseSeriesFigure & {
53
+ /**
54
+ * The columns drawn, left to right in the order given. At least one: a series
55
+ * with nothing in it is a mistake in the caller's data rather than a state
56
+ * worth an empty message.
57
+ */
58
+ bars: PulseBar[];
59
+ /**
60
+ * The line the columns rise from: the zero of the caller's units, named so a
61
+ * reader is told what the columns are counted from rather than left to infer
62
+ * it from a shape. Optional, and omitted rather than defaulted, because a
63
+ * default here is a claim about the caller's units.
64
+ */
65
+ baseline?: string;
66
+ /**
67
+ * A reticle crossing the series once per cycle, marking which column is being
68
+ * read at this instant. This is the figure's only motion beyond the columns'
69
+ * own rise, and it is the part that makes a static bar chart read as a live
70
+ * view rather than a report.
71
+ */
72
+ scan?: boolean;
73
+ /** Layout only, exactly as on every Component. */
74
+ className?: string;
75
+ };
76
+ /**
77
+ * A series of columns, a reticle crossing them, and a baseline they rise from.
78
+ *
79
+ * This is the instrument the retired line drew on a canvas, rebuilt. Its
80
+ * predecessor read open interest out of a sine hash, so the drawing was of
81
+ * numbers that had never been collected, and a reader who screenshotted it and
82
+ * read the axis got a fabrication set in a real typeface. That defect is not
83
+ * repeated here and is not repeatable here: the columns are the caller's
84
+ * numbers, the tallest one sets the scale, and a caller with no numbers passes
85
+ * no bars.
86
+ *
87
+ * **A server Component with no runtime.** The rise and the scan are CSS on
88
+ * server-rendered markup, so a live view costs zero bytes of JavaScript and
89
+ * works in a static export. See `PulseGraph` for the full argument; the short
90
+ * form is that the old instrument needed a client runtime, a `requestAnimationFrame`
91
+ * loop and a colour read at mount, and this needs none of the three.
92
+ *
93
+ * **The columns are visible from the first frame.** The rise animation
94
+ * interpolates from the baseline, so at 0% the column has no height and the
95
+ * reader is looking at a baseline with labels on it. That is the one moment this
96
+ * figure is less informative than its resting state, and it lasts one cycle
97
+ * rather than persisting, it never hides a column permanently, and under
98
+ * `prefers-reduced-motion` it is not run at all. The figure is not gated on a
99
+ * script, an intersection or a scroll position, so a reader with scripting off
100
+ * and a print stylesheet both get every column at full height.
101
+ *
102
+ * **The scan is the honest part.** A reticle crossing the series says a column
103
+ * is being read at this instant, and it is drawn at a real position on the
104
+ * scale rather than sweeping an empty field: a sweep with nothing under it is
105
+ * atmosphere, and atmosphere was what the old heroes had too much of. Under
106
+ * reduced motion the scan is off and the columns stand, which is the correct
107
+ * still frame for a chart.
108
+ *
109
+ * **The emphasis is the caller's.** One column may be emphasised, and it is the
110
+ * one that takes the brand ink and the label. The reticle does not choose it,
111
+ * because the reticle is a claim about time and the emphasis is a claim about
112
+ * importance, and a component that conflated the two would be moving the
113
+ * reader's attention on a schedule.
114
+ *
115
+ * **Accessibility.** `role="img"` and the caller's `label`, the same contract
116
+ * the rest of the drawing set holds. The labels under columns are inside a
117
+ * `role="img"` and so are presentational; the surrounding sentence is what a
118
+ * screen reader gets. Pass `decorative` when it already says what the drawing
119
+ * shows.
120
+ */
121
+ declare function PulseSeries({ bars, baseline, scan, label, decorative, className, }: PulseSeriesProps): import("react").JSX.Element;
122
+ export { PulseSeries };
123
+ //# sourceMappingURL=pulse-series.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pulse-series.d.ts","sourceRoot":"","sources":["../../../src/components/ui/pulse-series.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,oFAAoF;IACpF,EAAE,EAAE,MAAM,CAAA;IACV,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED;;;;;;;GAOG;AACH,KAAK,iBAAiB,GAClB;IACE;;;;OAIG;IACH,UAAU,EAAE,IAAI,CAAA;IAChB,KAAK,CAAC,EAAE,KAAK,CAAA;CACd,GACD;IACE,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,KAAK,CAAA;CACnB,CAAA;AAEL,MAAM,MAAM,gBAAgB,GAAG,iBAAiB,GAAG;IACjD;;;;OAIG;IACD,IAAI,EAAE,QAAQ,EAAE,CAAA;IAChB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAoCH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,iBAAS,WAAW,CAAC,EACnB,IAAI,EACJ,QAAQ,EACR,IAAY,EACZ,KAAK,EACL,UAAkB,EAClB,SAAS,GACV,EAAE,gBAAgB,+BAsIlB;AAED,OAAO,EAAE,WAAW,EAAE,CAAA"}
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from '../../lib/utils';
3
+ /**
4
+ * The canvas the series draws into, in its own user units.
5
+ *
6
+ * Shorter than `PulseGraph`'s and for the reason a bar chart is not a graph: a
7
+ * series reads its height against its baseline, so a canvas with more vertical
8
+ * room than it needs pushes the baseline up and makes every column look
9
+ * shorter than it is.
10
+ */
11
+ const CANVAS_WIDTH = 640;
12
+ const CANVAS_HEIGHT = 300;
13
+ /**
14
+ * The band left empty around the columns, in user units.
15
+ *
16
+ * The bottom band carries the baseline and the labels, so it is generous, and
17
+ * the top band is small because a column that reaches the top of the canvas has
18
+ * no headroom to breathe in and reads as clipped rather than as tall.
19
+ */
20
+ const PADDING = 52;
21
+ const PADDING_TOP = 28;
22
+ /** The widest a column is drawn, in user units, before the series narrows them. */
23
+ const MAX_BAR_WIDTH = 18;
24
+ /** The gap between two columns, as a fraction of a column's own width. */
25
+ const BAR_GAP = 0.45;
26
+ /**
27
+ * Type sizes, in user units rather than in `rem`, for the reason every figure in
28
+ * this package states: a font size inside a viewBox is a coordinate.
29
+ */
30
+ const LABEL_SIZE = 11;
31
+ const BASELINE_SIZE = 10;
32
+ /**
33
+ * A series of columns, a reticle crossing them, and a baseline they rise from.
34
+ *
35
+ * This is the instrument the retired line drew on a canvas, rebuilt. Its
36
+ * predecessor read open interest out of a sine hash, so the drawing was of
37
+ * numbers that had never been collected, and a reader who screenshotted it and
38
+ * read the axis got a fabrication set in a real typeface. That defect is not
39
+ * repeated here and is not repeatable here: the columns are the caller's
40
+ * numbers, the tallest one sets the scale, and a caller with no numbers passes
41
+ * no bars.
42
+ *
43
+ * **A server Component with no runtime.** The rise and the scan are CSS on
44
+ * server-rendered markup, so a live view costs zero bytes of JavaScript and
45
+ * works in a static export. See `PulseGraph` for the full argument; the short
46
+ * form is that the old instrument needed a client runtime, a `requestAnimationFrame`
47
+ * loop and a colour read at mount, and this needs none of the three.
48
+ *
49
+ * **The columns are visible from the first frame.** The rise animation
50
+ * interpolates from the baseline, so at 0% the column has no height and the
51
+ * reader is looking at a baseline with labels on it. That is the one moment this
52
+ * figure is less informative than its resting state, and it lasts one cycle
53
+ * rather than persisting, it never hides a column permanently, and under
54
+ * `prefers-reduced-motion` it is not run at all. The figure is not gated on a
55
+ * script, an intersection or a scroll position, so a reader with scripting off
56
+ * and a print stylesheet both get every column at full height.
57
+ *
58
+ * **The scan is the honest part.** A reticle crossing the series says a column
59
+ * is being read at this instant, and it is drawn at a real position on the
60
+ * scale rather than sweeping an empty field: a sweep with nothing under it is
61
+ * atmosphere, and atmosphere was what the old heroes had too much of. Under
62
+ * reduced motion the scan is off and the columns stand, which is the correct
63
+ * still frame for a chart.
64
+ *
65
+ * **The emphasis is the caller's.** One column may be emphasised, and it is the
66
+ * one that takes the brand ink and the label. The reticle does not choose it,
67
+ * because the reticle is a claim about time and the emphasis is a claim about
68
+ * importance, and a component that conflated the two would be moving the
69
+ * reader's attention on a schedule.
70
+ *
71
+ * **Accessibility.** `role="img"` and the caller's `label`, the same contract
72
+ * the rest of the drawing set holds. The labels under columns are inside a
73
+ * `role="img"` and so are presentational; the surrounding sentence is what a
74
+ * screen reader gets. Pass `decorative` when it already says what the drawing
75
+ * shows.
76
+ */
77
+ function PulseSeries({ bars, baseline, scan = false, label, decorative = false, className, }) {
78
+ const roomX = CANVAS_WIDTH - PADDING * 2;
79
+ const roomY = CANVAS_HEIGHT - PADDING - PADDING_TOP;
80
+ const baselineY = PADDING + roomY;
81
+ const tallest = Math.max(...bars.map((bar) => bar.value), 0);
82
+ const slot = bars.length ? roomX / bars.length : roomX;
83
+ const barWidth = Math.max(2, Math.min(MAX_BAR_WIDTH, slot * (1 / (1 + BAR_GAP))));
84
+ return (_jsxs("svg", { "data-slot": "pulse-series", "data-columns": bars.length, "data-scan": scan || undefined, viewBox: `0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`, xmlns: "http://www.w3.org/2000/svg", role: decorative ? undefined : 'img', "aria-label": decorative ? undefined : label, "aria-hidden": decorative || undefined, className: cn('h-auto w-full', className), children: [scan ? (_jsx("g", { "data-slot": "pulse-series-scan", "aria-hidden": true, children: _jsxs("g", { className: "prism-ambient-scan", style: { '--ambient-scan-distance': `${roomX + slot}px` }, children: [_jsx("rect", { "data-slot": "pulse-series-scan-wash", x: PADDING - slot, y: PADDING_TOP - 12, width: slot, height: roomY + 12, className: "fill-brand-ink/10" }), _jsx("line", { "data-slot": "pulse-series-scan-line", x1: 0, y1: PADDING_TOP - 12, x2: 0, y2: baselineY, strokeWidth: 1, className: "stroke-brand-ink/60" })] }) })) : null, bars.map((bar, index) => {
85
+ const centre = PADDING + slot * (index + 0.5);
86
+ // A column of value zero is drawn as a hairline rather than as nothing,
87
+ // because a gap in a series is a missing reading and a missing reading
88
+ // that looks identical to a value of zero is a data claim the drawing
89
+ // cannot support.
90
+ const height = tallest === 0 ? 1 : Math.max(1, (bar.value / tallest) * roomY);
91
+ return (_jsxs("g", { "data-slot": "pulse-series-column", "data-column": bar.id, "data-emphasis": bar.emphasis || undefined, children: [_jsx("rect", { x: centre - barWidth / 2, y: baselineY - height, width: barWidth, height: height, rx: Math.min(2, barWidth / 2), className: cn('origin-bottom', 'prism-ambient-rise', bar.emphasis ? 'fill-brand-ink' : 'fill-muted-foreground/35', `prism-ambient-delay-${(index % 3) + 1}`) }), bar.label ? (_jsx("text", { x: centre, y: baselineY + 18, fontSize: LABEL_SIZE, textAnchor: "middle", className: cn('font-mono', bar.emphasis ? 'fill-foreground' : 'fill-muted-foreground'), children: bar.label })) : null] }, bar.id));
92
+ }), _jsxs("g", { "data-slot": "pulse-series-baseline", children: [_jsx("line", { x1: PADDING - 12, y1: baselineY, x2: CANVAS_WIDTH - PADDING + 12, y2: baselineY, strokeWidth: 1, className: "stroke-border" }), baseline ? (_jsx("text", { x: PADDING - 12, y: baselineY - 8, fontSize: BASELINE_SIZE, textAnchor: "start", className: "fill-muted-foreground font-mono", children: baseline })) : null] })] }));
93
+ }
94
+ export { PulseSeries };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * How many marks a field draws, and how they are arranged.
3
+ *
4
+ * A field is atmosphere, and atmosphere is the one thing in this system that is
5
+ * allowed to be a number rather than data. The count is a prop rather than a
6
+ * constant because a hero panel beside a two-column hero and a full-bleed
7
+ * background behind a title want very different densities, and a fixed count
8
+ * would have one of them too sparse or the other too busy.
9
+ */
10
+ export type SignalFieldProps = {
11
+ /**
12
+ * How many marks the field draws. Read as a suggestion and clamped to a small
13
+ * range, because a field is a texture and a texture with four hundred marks is
14
+ * a chart that lost its labels.
15
+ */
16
+ count?: number;
17
+ /**
18
+ * A mark the drawing is about, given as a fraction of the field. The field
19
+ * centres the emphasis and dims the rest, which is what makes a field read as
20
+ * a system with a focus rather than as scattered dots.
21
+ */
22
+ emphasisAt?: number;
23
+ /** The name of the drawing, or `decorative`. See `PulseGraphProps`. */
24
+ label?: string;
25
+ decorative?: boolean;
26
+ /**
27
+ * Whether the marks drift. Off by default: a field behind a reading passage
28
+ * is the worst place for movement a reader did not ask for, and the drift is
29
+ * the first thing a reader with vestibular sensitivity will ask to have gone.
30
+ */
31
+ drift?: boolean;
32
+ /** Layout only, exactly as on every Component. */
33
+ className?: string;
34
+ };
35
+ /**
36
+ * A field of marks: the atmosphere a figure is drawn over, and the one thing in
37
+ * this system that is allowed to be nothing more than texture.
38
+ *
39
+ * The retired line drew this as a canvas behind the hero type, and the defect
40
+ * was never the idea: it was that the canvas was opaque, animated continuously,
41
+ * and had to be redrawn by a `requestAnimationFrame` loop to keep a decorative
42
+ * sweep moving. This is the same idea with the three costs removed. It is SVG,
43
+ * so it scales, prints and costs no runtime; it is `aria-hidden` by default,
44
+ * because a field of dots is not information and a screen reader being made to
45
+ * read one is a cost with no reader attached; and its movement is off unless a
46
+ * caller asks for it.
47
+ *
48
+ * **It is atmosphere, and it is labelled as such.** The reason it exists is that
49
+ * a hero with a hard flat ground behind it reads as a document, and a hero with
50
+ * a field behind it reads as a product with a surface. Everything else about it
51
+ * is restraint: no mark is emphasised by default, no two marks are the same
52
+ * colour, and nothing in the field moves unless `drift` is set. It is the
53
+ * weakest mark in the system and the easiest one to overdo, and the props are
54
+ * shaped to make the restrained version the easy one.
55
+ *
56
+ * **It never carries the meaning.** A field is `aria-hidden` unless a caller
57
+ * deliberately names it, and the default is that it names nothing, because a
58
+ * caller reaching for this has atmosphere and not data. When a figure needs to
59
+ * say what it is showing, that figure is `PulseGraph` or `PulseSeries`, both of
60
+ * which require a name and both of which a screen reader can be given.
61
+ *
62
+ * **It is a server Component.** No hook, no client runtime, nothing to hydrate.
63
+ */
64
+ declare function SignalField({ count, emphasisAt, label, decorative, drift, className, }: SignalFieldProps): import("react").JSX.Element;
65
+ export { SignalField };
66
+ //# sourceMappingURL=signal-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signal-field.d.ts","sourceRoot":"","sources":["../../../src/components/ui/signal-field.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAwCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,iBAAS,WAAW,CAAC,EACnB,KAAU,EACV,UAAU,EACV,KAAK,EACL,UAAiB,EACjB,KAAa,EACb,SAAS,GACV,EAAE,gBAAgB,+BA0ClB;AAED,OAAO,EAAE,WAAW,EAAE,CAAA"}
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from '../../lib/utils';
3
+ /**
4
+ * The canvas the field draws into, in its own user units.
5
+ *
6
+ * Deliberately oversized relative to the panel it usually fills, because a field
7
+ * that ends where the panel ends is a grid and a grid reads as content. The
8
+ * marks are placed on a low-discrepancy walk rather than at random, so no two
9
+ * fields ever look alike and none of them looks hand-scattered, while remaining
10
+ * reproducible between server render and client hydration. A field that changed
11
+ * on every render would be a hydration mismatch, which is a correctness bug
12
+ * dressed as variety.
13
+ */
14
+ const CANVAS_WIDTH = 1200;
15
+ const CANVAS_HEIGHT = 700;
16
+ /** The largest a mark is drawn, in user units. */
17
+ const MAX_MARK = 3;
18
+ /**
19
+ * The marks, placed by a golden-angle walk.
20
+ *
21
+ * A golden angle is the one arrangement that looks uncorrelated while being
22
+ * fully determined by its index, which is what this needs: reproducible
23
+ * without a seed, and evenly spread without a grid. The modulus keeps every
24
+ * mark inside the canvas without a rejection loop, so the count is honoured
25
+ * exactly rather than approximately.
26
+ */
27
+ function marks(count) {
28
+ const golden = 2.399963229728653;
29
+ return Array.from({ length: count }, (_, index) => {
30
+ const t = (index + 0.5) / count;
31
+ return {
32
+ x: ((index * golden) % 1) * CANVAS_WIDTH,
33
+ y: t * CANVAS_HEIGHT,
34
+ r: MAX_MARK * (0.5 + ((index * 7919) % 5) / 10),
35
+ };
36
+ });
37
+ }
38
+ /**
39
+ * A field of marks: the atmosphere a figure is drawn over, and the one thing in
40
+ * this system that is allowed to be nothing more than texture.
41
+ *
42
+ * The retired line drew this as a canvas behind the hero type, and the defect
43
+ * was never the idea: it was that the canvas was opaque, animated continuously,
44
+ * and had to be redrawn by a `requestAnimationFrame` loop to keep a decorative
45
+ * sweep moving. This is the same idea with the three costs removed. It is SVG,
46
+ * so it scales, prints and costs no runtime; it is `aria-hidden` by default,
47
+ * because a field of dots is not information and a screen reader being made to
48
+ * read one is a cost with no reader attached; and its movement is off unless a
49
+ * caller asks for it.
50
+ *
51
+ * **It is atmosphere, and it is labelled as such.** The reason it exists is that
52
+ * a hero with a hard flat ground behind it reads as a document, and a hero with
53
+ * a field behind it reads as a product with a surface. Everything else about it
54
+ * is restraint: no mark is emphasised by default, no two marks are the same
55
+ * colour, and nothing in the field moves unless `drift` is set. It is the
56
+ * weakest mark in the system and the easiest one to overdo, and the props are
57
+ * shaped to make the restrained version the easy one.
58
+ *
59
+ * **It never carries the meaning.** A field is `aria-hidden` unless a caller
60
+ * deliberately names it, and the default is that it names nothing, because a
61
+ * caller reaching for this has atmosphere and not data. When a figure needs to
62
+ * say what it is showing, that figure is `PulseGraph` or `PulseSeries`, both of
63
+ * which require a name and both of which a screen reader can be given.
64
+ *
65
+ * **It is a server Component.** No hook, no client runtime, nothing to hydrate.
66
+ */
67
+ function SignalField({ count = 28, emphasisAt, label, decorative = true, drift = false, className, }) {
68
+ const placed = marks(Math.max(4, Math.min(120, Math.round(count))));
69
+ const emphasised = emphasisAt === undefined ? -1 : Math.round(emphasisAt * (placed.length - 1));
70
+ return (_jsx("svg", { "data-slot": "signal-field", "data-marks": placed.length, viewBox: `0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`, xmlns: "http://www.w3.org/2000/svg", role: decorative ? undefined : 'img', "aria-label": decorative ? undefined : label, "aria-hidden": decorative || undefined, preserveAspectRatio: "xMidYMid slice", className: cn('h-full w-full', className), children: placed.map((mark, index) => (_jsx("circle", { "data-slot": "signal-field-mark", "data-emphasis": index === emphasised || undefined, cx: mark.x, cy: mark.y, r: index === emphasised ? mark.r * 1.9 : mark.r, strokeWidth: 0, className: cn(index === emphasised ? 'fill-brand-ink/45' : 'fill-foreground/12', drift && index % 3 === 0 ? 'prism-ambient-drift' : undefined, drift && index % 3 === 1 ? 'prism-ambient-delay-1' : undefined, drift && index % 3 === 2 ? 'prism-ambient-delay-2' : undefined), style: drift
71
+ ? {
72
+ '--ambient-drift-x': `${(index % 2 === 0 ? 1 : -1) * (0.4 + (index % 5) * 0.15)}rem`,
73
+ '--ambient-drift-y': `${(index % 3 === 0 ? 1 : -1) * 0.35}rem`,
74
+ }
75
+ : undefined }))) }));
76
+ }
77
+ export { SignalField };