@siyahi/ui 0.1.9-next.24 → 0.1.9-next.25

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.
Files changed (45) hide show
  1. package/agents/examples/change-grid-money.tsx +36 -0
  2. package/agents/examples/change-grid.tsx +42 -0
  3. package/agents/examples/drawdown-chart.tsx +35 -0
  4. package/agents/examples/heatmap-calendar-signed.tsx +67 -0
  5. package/agents/examples/heatmap-status.tsx +6 -4
  6. package/agents/examples/histogram-chart.tsx +28 -0
  7. package/agents/examples/scatter-chart-quadrants.tsx +63 -0
  8. package/agents/examples/scatter-chart.tsx +75 -0
  9. package/agents/examples/slope-chart-market.tsx +33 -0
  10. package/agents/examples/slope-chart.tsx +26 -0
  11. package/agents/examples/waterfall-chart-stores.tsx +27 -0
  12. package/agents/examples/waterfall-chart.tsx +37 -0
  13. package/agents/manifest.json +160 -0
  14. package/dist/components/registry/heatmap/calendar-heatmap.d.ts +8 -2
  15. package/dist/components/registry/heatmap/calendar-heatmap.js +96 -18
  16. package/dist/components/registry/heatmap/status-heatmap.js +86 -47
  17. package/dist/components/siyahi/change-grid.d.ts +31 -0
  18. package/dist/components/siyahi/change-grid.js +74 -0
  19. package/dist/components/siyahi/drawdown-chart.d.ts +34 -0
  20. package/dist/components/siyahi/drawdown-chart.js +102 -0
  21. package/dist/components/siyahi/histogram-chart.d.ts +57 -0
  22. package/dist/components/siyahi/histogram-chart.js +134 -0
  23. package/dist/components/siyahi/scatter-chart.d.ts +59 -0
  24. package/dist/components/siyahi/scatter-chart.js +367 -0
  25. package/dist/components/siyahi/slope-chart.d.ts +35 -0
  26. package/dist/components/siyahi/slope-chart.js +159 -0
  27. package/dist/components/siyahi/treemap.js +3 -7
  28. package/dist/components/siyahi/waterfall-chart.d.ts +51 -0
  29. package/dist/components/siyahi/waterfall-chart.js +147 -0
  30. package/dist/index.d.ts +9 -2
  31. package/dist/index.js +7 -1
  32. package/dist/lib/chart-frame.d.ts +7 -2
  33. package/dist/lib/chart-frame.js +44 -10
  34. package/dist/lib/chart-series.d.ts +2 -3
  35. package/dist/lib/fit-text.d.ts +6 -0
  36. package/dist/lib/fit-text.js +11 -0
  37. package/dist/lib/heat.d.ts +7 -0
  38. package/dist/lib/heat.js +14 -0
  39. package/dist/lib/scatter.d.ts +98 -0
  40. package/dist/lib/scatter.js +218 -0
  41. package/dist/lib/treemap.d.ts +0 -5
  42. package/dist/lib/treemap.js +1 -16
  43. package/package.json +1 -1
  44. package/report.css +9 -0
  45. package/styles/siyahi.css +16 -0
@@ -0,0 +1,59 @@
1
+ import { ScatterQuadrants } from "../../lib/scatter.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/components/siyahi/scatter-chart.d.ts
4
+ export interface ScatterPoint {
5
+ name: string;
6
+ /** Across. On a log axis a point at or under zero is not drawn. */
7
+ x: number;
8
+ /** Up. `null` when unknown: the point is not drawn, so say on the page how many are missing. */
9
+ y: number | null;
10
+ /** Unique key when names repeat, such as one product sold in two regions. Defaults to `name`. */
11
+ key?: string;
12
+ /**
13
+ * A weight, such as a holding's value: the dot's area follows it. Give every point a size, or none. A size that is
14
+ * not a positive number draws the smallest dot and leaves the others' sizes as they are.
15
+ */
16
+ size?: number;
17
+ }
18
+ /** One tooltip row: a label and its figure, such as ["Sales", "₹9.8L"]. */
19
+ export type ScatterDetail = readonly [label: string, value: string];
20
+ export interface ScatterChartCardProps<T extends ScatterPoint> {
21
+ title: ReactNode;
22
+ description?: ReactNode;
23
+ /** A scope badge or a toggle, top right of the card. */
24
+ action?: ReactNode;
25
+ /** Most important first: earlier points take the labels. */
26
+ points: T[];
27
+ /** Height of the plot, in pixels. */
28
+ height: number;
29
+ /** What the x axis measures, under it, such as "Share of sales". */
30
+ xLabel: string;
31
+ /** What the y axis measures, beside it, such as "Growth on last year". */
32
+ yLabel: string;
33
+ /** "log" spreads values across powers of ten, for sizes that run from tiny to large. */
34
+ xScale?: "linear" | "log";
35
+ /** The x ticks and the tooltip's x figure. Defaults to the locale's numbers. */
36
+ xFormatter?: (value: number) => string;
37
+ /** The y ticks and the tooltip's y figure. Defaults to the locale's numbers. */
38
+ yFormatter?: (value: number) => string;
39
+ /** How many points get their name beside the dot, at most; a name with no free spot is left to the tooltip. */
40
+ labels?: number;
41
+ /** The tooltip's rows under the point's name, and what a screen reader hears. Defaults to the two axes' figures. */
42
+ details?: (point: T) => readonly ScatterDetail[];
43
+ /** The point that carries a ring, matched by key; pass back what onSelect gave. */
44
+ selected?: T | null;
45
+ /** Gets a tapped or keyed point, and null on a second tap, Escape or a tap on the plot. */
46
+ onSelect?: (point: T | null) => void;
47
+ /** Lines under the chart, such as a caption or how many points are not drawn. */
48
+ footer?: ReactNode;
49
+ /** Dashed lines at a value on each axis, splitting the plot into four, and the corners' names. */
50
+ quadrants?: ScatterQuadrants;
51
+ /** Names the size in the tooltip, such as "Value". Defaults to "Size". */
52
+ sizeLabel?: string;
53
+ /** The tooltip's size figure. Defaults to the locale's numbers. */
54
+ sizeFormatter?: (value: number) => string;
55
+ }
56
+ /** Two measures per item as dots, such as size against growth, with the leading names beside their dots. */
57
+ export declare function ScatterChartCard<T extends ScatterPoint>({ title, description, action, points, height, xLabel, yLabel, xScale, xFormatter, yFormatter, labels, details, selected, onSelect, footer, quadrants, sizeLabel, sizeFormatter }: ScatterChartCardProps<T>): import("react").JSX.Element;
58
+ //#endregion
59
+ export type { ScatterQuadrants };
@@ -0,0 +1,367 @@
1
+ import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card.js";
2
+ import { ChartContainer } from "../ui/chart.js";
3
+ import { useLocale } from "./siyahi-provider.js";
4
+ import { corners, dotSizes, linearTicks, logTicks, placeLabels, quadrantSplit } from "../../lib/scatter.js";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { useEffect, useMemo, useRef, useState } from "react";
7
+ import { CartesianGrid, DefaultZIndexes, ReferenceLine, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZIndexLayer, usePlotArea, useXAxisScale, useYAxisScale } from "recharts";
8
+ //#region src/components/siyahi/scatter-chart.tsx
9
+ const RADIUS = 5;
10
+ const EDGE = 1.5;
11
+ const SIZED_FILL = .3;
12
+ const RING = 3;
13
+ const NAME_BAND = 42;
14
+ const TICK = { className: "fill-muted-foreground font-mono" };
15
+ const AXIS_TITLE = "fill-muted-foreground text-xs";
16
+ const keyOf = (point) => point.key ?? point.name;
17
+ /** Two measures per item as dots, such as size against growth, with the leading names beside their dots. */
18
+ function ScatterChartCard({ title, description, action, points, height, xLabel, yLabel, xScale = "linear", xFormatter, yFormatter, labels = 12, details, selected = null, onSelect, footer, quadrants, sizeLabel = "Size", sizeFormatter }) {
19
+ const { numberLocale } = useLocale();
20
+ const plotRef = useRef(null);
21
+ const [focusedKey, setFocusedKey] = useState(null);
22
+ const keyed = useRef(null);
23
+ useEffect(() => {
24
+ const key = keyed.current;
25
+ if (key === null) return;
26
+ const frame = requestAnimationFrame(() => {
27
+ const dot = plotRef.current?.querySelector(`[data-key="${CSS.escape(key)}"]`);
28
+ if (dot && document.activeElement !== dot) dot.focus();
29
+ });
30
+ return () => cancelAnimationFrame(frame);
31
+ });
32
+ const onBlur = (event) => {
33
+ if (plotRef.current?.contains(event.relatedTarget)) return;
34
+ const left = event.target;
35
+ requestAnimationFrame(() => {
36
+ if (left.isConnected) keyed.current = null;
37
+ });
38
+ };
39
+ const formatX = useMemo(() => xFormatter ?? new Intl.NumberFormat(numberLocale).format, [xFormatter, numberLocale]);
40
+ const formatY = useMemo(() => yFormatter ?? new Intl.NumberFormat(numberLocale).format, [yFormatter, numberLocale]);
41
+ const formatSize = useMemo(() => sizeFormatter ?? new Intl.NumberFormat(numberLocale).format, [sizeFormatter, numberLocale]);
42
+ const drawn = useMemo(() => points.filter((p) => p.y !== null && Number.isFinite(p.y) && Number.isFinite(p.x) && (xScale === "linear" || p.x > 0)), [points, xScale]);
43
+ const order = useMemo(() => drawn.toSorted((a, b) => a.x - b.x || (a.y ?? 0) - (b.y ?? 0)), [drawn]);
44
+ const { radiusOf, sized } = dotSizes(drawn, RADIUS);
45
+ const painted = sized ? drawn.toSorted((a, b) => radiusOf(b) - radiusOf(a)) : drawn;
46
+ const split = quadrantSplit(quadrants, xScale);
47
+ const xs = [...drawn.map((p) => p.x), ...split ? [split.x] : []];
48
+ const ys = [...drawn.map((p) => p.y ?? 0), ...split ? [split.y] : []];
49
+ const reach = sized ? 23 : 0;
50
+ const names = split?.labels;
51
+ const band = (named) => named ? NAME_BAND + Math.max(reach, 6.5) : reach;
52
+ const xTicks = xScale === "log" ? logTicks(Math.min(...xs), Math.max(...xs)) : void 0;
53
+ const yTicks = linearTicks(Math.min(...ys), Math.max(...ys));
54
+ const crossesZero = ys.some((y) => y < 0) && ys.some((y) => y > 0);
55
+ const selectedKey = selected ? keyOf(selected) : null;
56
+ const rows = details ?? ((p) => [
57
+ [xLabel, formatX(p.x)],
58
+ [yLabel, p.y === null ? "—" : formatY(p.y)],
59
+ ...p.size === void 0 ? [] : [[sizeLabel, formatSize(p.size)]]
60
+ ]);
61
+ const first = order[0];
62
+ const focusKey = order.some((p) => keyOf(p) === focusedKey) ? focusedKey : selectedKey ?? (first ? keyOf(first) : null);
63
+ const onKeyDown = (event, point) => {
64
+ if (!onSelect) return;
65
+ const key = keyOf(point);
66
+ keyed.current = key;
67
+ const at = order.findIndex((p) => keyOf(p) === key);
68
+ const to = {
69
+ ArrowRight: at + 1,
70
+ ArrowUp: at + 1,
71
+ ArrowLeft: at - 1,
72
+ ArrowDown: at - 1,
73
+ Home: 0,
74
+ End: order.length - 1
75
+ }[event.key];
76
+ if (event.key === "Enter" || event.key === " ") {
77
+ event.preventDefault();
78
+ onSelect(key === selectedKey ? null : point);
79
+ } else if (event.key === "Escape") onSelect(null);
80
+ else if (to !== void 0) {
81
+ event.preventDefault();
82
+ const next = order[Math.max(0, Math.min(order.length - 1, to))];
83
+ if (!next) return;
84
+ keyed.current = keyOf(next);
85
+ setFocusedKey(keyOf(next));
86
+ }
87
+ };
88
+ return /* @__PURE__ */ jsxs(Card, { children: [
89
+ /* @__PURE__ */ jsxs(CardHeader, { children: [
90
+ /* @__PURE__ */ jsx(CardTitle, { children: title }),
91
+ description && /* @__PURE__ */ jsx(CardDescription, { children: description }),
92
+ action && /* @__PURE__ */ jsx(CardAction, { children: action })
93
+ ] }),
94
+ /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx("div", {
95
+ ref: plotRef,
96
+ "data-slot": "scatter-chart",
97
+ role: onSelect ? "group" : "img",
98
+ "aria-label": `${xLabel} against ${yLabel}`,
99
+ onClick: () => onSelect?.(null),
100
+ onBlur,
101
+ children: /* @__PURE__ */ jsx(ChartContainer, {
102
+ config: {},
103
+ className: "aspect-auto w-full",
104
+ style: { height },
105
+ children: /* @__PURE__ */ jsxs(ScatterChart, {
106
+ margin: {
107
+ top: 12,
108
+ right: 16,
109
+ left: 0
110
+ },
111
+ accessibilityLayer: false,
112
+ children: [
113
+ /* @__PURE__ */ jsx(CartesianGrid, { strokeDasharray: "3 3" }),
114
+ /* @__PURE__ */ jsx(XAxis, {
115
+ type: "number",
116
+ dataKey: "x",
117
+ scale: xScale,
118
+ domain: xTicks ? [xTicks[0] ?? "auto", xTicks.at(-1) ?? "auto"] : [(low) => Math.min(low, ...xs), (high) => Math.max(high, ...xs)],
119
+ ticks: xTicks,
120
+ niceTicks: xTicks ? void 0 : "snap125",
121
+ padding: reach > 0 ? {
122
+ left: reach,
123
+ right: reach
124
+ } : void 0,
125
+ tickLine: false,
126
+ axisLine: false,
127
+ tickMargin: 8,
128
+ tick: TICK,
129
+ tickFormatter: (value) => formatX(value),
130
+ height: 48,
131
+ label: {
132
+ value: xLabel,
133
+ position: "insideBottom",
134
+ className: AXIS_TITLE
135
+ }
136
+ }),
137
+ /* @__PURE__ */ jsx(YAxis, {
138
+ type: "number",
139
+ dataKey: "y",
140
+ domain: [yTicks[0] ?? "auto", yTicks.at(-1) ?? "auto"],
141
+ ticks: yTicks,
142
+ padding: {
143
+ top: band(Boolean(names?.topLeft || names?.topRight)),
144
+ bottom: band(Boolean(names?.bottomLeft || names?.bottomRight))
145
+ },
146
+ tickLine: false,
147
+ axisLine: false,
148
+ width: "auto",
149
+ tick: TICK,
150
+ tickFormatter: (value) => formatY(value),
151
+ label: {
152
+ value: yLabel,
153
+ angle: -90,
154
+ position: "insideLeft",
155
+ style: { textAnchor: "middle" },
156
+ className: AXIS_TITLE
157
+ }
158
+ }),
159
+ split ? /* @__PURE__ */ jsxs(Fragment, { children: [
160
+ /* @__PURE__ */ jsx(ReferenceLine, {
161
+ x: split.x,
162
+ stroke: "var(--muted-foreground)",
163
+ strokeDasharray: "4 4"
164
+ }),
165
+ /* @__PURE__ */ jsx(ReferenceLine, {
166
+ y: split.y,
167
+ stroke: "var(--muted-foreground)",
168
+ strokeDasharray: "4 4"
169
+ }),
170
+ /* @__PURE__ */ jsx(QuadrantNames, { quadrants: split })
171
+ ] }) : crossesZero && /* @__PURE__ */ jsx(ReferenceLine, {
172
+ y: 0,
173
+ stroke: "var(--border)"
174
+ }),
175
+ /* @__PURE__ */ jsx(Tooltip, {
176
+ cursor: false,
177
+ isAnimationActive: false,
178
+ content: /* @__PURE__ */ jsx(PointTooltip, { details: rows })
179
+ }),
180
+ /* @__PURE__ */ jsx(Scatter, {
181
+ data: painted,
182
+ fill: "var(--chart-1)",
183
+ isAnimationActive: false,
184
+ shape: /* @__PURE__ */ jsx(Dot, {
185
+ selectedKey,
186
+ focusKey: onSelect ? focusKey : null,
187
+ describe: (p) => [p.name, ...rows(p).map(([label, value]) => `${label} ${value}`)].join(", "),
188
+ onSelect,
189
+ onKeyDown,
190
+ radiusOf,
191
+ sized
192
+ })
193
+ }),
194
+ /* @__PURE__ */ jsx(PointLabels, {
195
+ points: drawn,
196
+ limit: labels,
197
+ radiusOf,
198
+ quadrants: split
199
+ })
200
+ ]
201
+ })
202
+ })
203
+ }) }),
204
+ footer && /* @__PURE__ */ jsx(CardFooter, {
205
+ className: "flex-col items-start gap-2 text-sm",
206
+ children: footer
207
+ })
208
+ ] });
209
+ }
210
+ /** The hovered point's name, then its rows. Recharts passes the hovered point in as payload. */
211
+ function PointTooltip({ active, payload, details }) {
212
+ const point = active ? payload?.[0]?.payload : void 0;
213
+ if (!point) return null;
214
+ return /* @__PURE__ */ jsxs("div", {
215
+ className: "grid min-w-32 gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
216
+ children: [/* @__PURE__ */ jsx("span", {
217
+ className: "font-medium",
218
+ children: point.name
219
+ }), details(point).map(([label, value]) => /* @__PURE__ */ jsxs("div", {
220
+ className: "flex justify-between gap-4",
221
+ children: [/* @__PURE__ */ jsx("span", {
222
+ className: "text-muted-foreground",
223
+ children: label
224
+ }), /* @__PURE__ */ jsx("span", {
225
+ className: "font-mono font-medium text-foreground tabular-nums",
226
+ children: value
227
+ })]
228
+ }, label))]
229
+ });
230
+ }
231
+ /** One point's dot. A ring marks the selected one; with onSelect it is a button Tab and the arrow keys reach. */
232
+ function Dot({ cx, cy, payload, index, "data-recharts-item-id": itemId, selectedKey, focusKey, describe, onSelect, onKeyDown, radiusOf, sized }) {
233
+ if (cx === void 0 || cy === void 0 || !payload) return null;
234
+ const key = keyOf(payload);
235
+ const isSelected = key === selectedKey;
236
+ const r = radiusOf(payload) + (isSelected ? 2 : 0);
237
+ const dot = /* @__PURE__ */ jsx("circle", {
238
+ cx,
239
+ cy,
240
+ r,
241
+ fill: "var(--chart-1)",
242
+ fillOpacity: sized ? SIZED_FILL : 1,
243
+ stroke: isSelected ? "var(--foreground)" : sized ? "var(--chart-1)" : "var(--card)",
244
+ strokeWidth: isSelected ? 2 : EDGE,
245
+ "data-slot": "scatter-point",
246
+ "data-key": key,
247
+ "data-selected": isSelected || void 0,
248
+ "data-recharts-item-id": itemId,
249
+ "data-recharts-item-index": index,
250
+ role: onSelect ? "button" : void 0,
251
+ tabIndex: onSelect ? key === focusKey ? 0 : -1 : void 0,
252
+ "aria-label": describe(payload),
253
+ "aria-pressed": onSelect ? isSelected : void 0,
254
+ className: onSelect ? "cursor-pointer outline-none focus-visible:stroke-ring focus-visible:stroke-2" : void 0,
255
+ onKeyDown: (event) => onKeyDown(event, payload),
256
+ onClick: (event) => {
257
+ if (!onSelect) return;
258
+ event.stopPropagation();
259
+ onSelect(isSelected ? null : payload);
260
+ }
261
+ });
262
+ if (!sized) return dot;
263
+ return /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("circle", {
264
+ cx,
265
+ cy,
266
+ r: r + EDGE / 2 + RING / 2,
267
+ fill: "none",
268
+ stroke: "var(--card)",
269
+ strokeWidth: RING
270
+ }), dot] });
271
+ }
272
+ /** A name's width in the labels' type, measured on a canvas once per font and name. */
273
+ const widths = /* @__PURE__ */ new Map();
274
+ let canvas;
275
+ /** The labels' type as the page sets it: text-xs is three quarters of the root size, which a reader may enlarge. */
276
+ function labelType() {
277
+ if (typeof document === "undefined") return {
278
+ size: 12,
279
+ measure: (text) => text.length * 7
280
+ };
281
+ const size = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) * .75;
282
+ const font = `500 ${size}px ${getComputedStyle(document.body).fontFamily}`;
283
+ canvas ??= document.createElement("canvas").getContext("2d");
284
+ const measure = (text) => {
285
+ const id = `${font}\n${text}`;
286
+ let width = widths.get(id);
287
+ if (width === void 0 && canvas) {
288
+ canvas.font = font;
289
+ width = canvas.measureText(text).width;
290
+ widths.set(id, width);
291
+ }
292
+ return width ?? text.length * size * .6;
293
+ };
294
+ return {
295
+ size,
296
+ measure
297
+ };
298
+ }
299
+ /** The leading points' names, placed from the axes' scales once the chart has laid them out. */
300
+ /** Where the quadrants' split lines cross, in pixels, once the axes are laid out. */
301
+ function useSplit(quadrants) {
302
+ const x = useXAxisScale();
303
+ const y = useYAxisScale();
304
+ return quadrants && x && y ? {
305
+ x: Number(x(quadrants.x)),
306
+ y: Number(y(quadrants.y))
307
+ } : null;
308
+ }
309
+ /** The quadrants' names, muted, in the plot's corners. */
310
+ function QuadrantNames({ quadrants }) {
311
+ const plot = usePlotArea();
312
+ const split = useSplit(quadrants);
313
+ if (!plot) return null;
314
+ return /* @__PURE__ */ jsx(ZIndexLayer, {
315
+ zIndex: DefaultZIndexes.label,
316
+ children: /* @__PURE__ */ jsx("g", {
317
+ "data-slot": "scatter-quadrant-names",
318
+ pointerEvents: "none",
319
+ children: corners(plot, quadrants.labels, split, labelType()).map(({ key, lines, x, y, line }) => /* @__PURE__ */ jsx("text", {
320
+ x,
321
+ y,
322
+ className: "fill-muted-foreground stroke-card text-xs font-medium",
323
+ style: { paintOrder: "stroke" },
324
+ strokeWidth: 3,
325
+ strokeLinejoin: "round",
326
+ children: lines.map((text, index) => /* @__PURE__ */ jsx("tspan", {
327
+ x,
328
+ dy: index === 0 ? 0 : line,
329
+ children: text
330
+ }, text))
331
+ }, key))
332
+ })
333
+ });
334
+ }
335
+ function PointLabels({ points, limit, radiusOf, quadrants }) {
336
+ const x = useXAxisScale();
337
+ const y = useYAxisScale();
338
+ const plot = usePlotArea();
339
+ const split = useSplit(quadrants);
340
+ if (!x || !y || !plot || limit <= 0) return null;
341
+ const type = labelType();
342
+ const candidates = points.map((p) => ({
343
+ key: keyOf(p),
344
+ text: p.name,
345
+ cx: Number(x(p.x)),
346
+ cy: Number(y(p.y)),
347
+ r: radiusOf(p) + EDGE
348
+ }));
349
+ return /* @__PURE__ */ jsx(ZIndexLayer, {
350
+ zIndex: DefaultZIndexes.label,
351
+ children: /* @__PURE__ */ jsx("g", {
352
+ "data-slot": "scatter-labels",
353
+ pointerEvents: "none",
354
+ children: placeLabels(candidates, plot, limit, type, corners(plot, quadrants?.labels, split, type).map(({ box }) => box)).map((label) => /* @__PURE__ */ jsx("text", {
355
+ x: label.x,
356
+ y: label.y,
357
+ className: "fill-foreground stroke-card text-xs font-medium",
358
+ style: { paintOrder: "stroke" },
359
+ strokeWidth: 3,
360
+ strokeLinejoin: "round",
361
+ children: label.text
362
+ }, label.key))
363
+ })
364
+ });
365
+ }
366
+ //#endregion
367
+ export { ScatterChartCard };
@@ -0,0 +1,35 @@
1
+ //#region src/components/siyahi/slope-chart.d.ts
2
+ export interface SlopeItem {
3
+ name: string;
4
+ /** The value on the left: before, or the comparison such as an index's weight. */
5
+ from: number;
6
+ /** The value on the right: after, or our own figure. */
7
+ to: number;
8
+ /** Unique key when names repeat. Defaults to `name`. */
9
+ key?: string;
10
+ }
11
+ export interface SlopeChartProps {
12
+ /** An item whose from or to is not a finite number is not drawn: say on the page how many are left out. */
13
+ items: readonly SlopeItem[];
14
+ /** Heads the left side, such as "Nifty 50" or "2025". */
15
+ fromLabel: string;
16
+ /** Heads the right side, such as "Portfolio" or "2026". */
17
+ toLabel: string;
18
+ /** Keys (or names) of the items to draw in the accent colour; the rest stay grey. */
19
+ highlighted?: readonly string[];
20
+ /** Writes each end's value; useFormat().pct by default. */
21
+ valueFormatter?: (value: number) => string;
22
+ className?: string;
23
+ }
24
+ /**
25
+ * Pushes labels apart so none sits closer than `gap` to the next, keeping their order and staying inside [min, max]:
26
+ * down from the top first, then back up from the bottom when the last one ran past it.
27
+ */
28
+ export declare function spread(ys: readonly number[], gap: number, min: number, max: number): number[];
29
+ /**
30
+ * Each item as a line from its value on the left to its value on the right, so rises, falls and crossings read at a
31
+ * glance: allocation against a target, a weight against an index's, or one year against the next. Lines are grey and
32
+ * the page picks the few to draw in the accent; names sit on the left, values at both ends, nudged apart when close.
33
+ */
34
+ export declare function SlopeChart({ items: given, fromLabel, toLabel, highlighted, valueFormatter, className }: SlopeChartProps): import("react").JSX.Element;
35
+ //#endregion
@@ -0,0 +1,159 @@
1
+ import { useFormat } from "../../lib/format.js";
2
+ import { useElementWidth } from "../../lib/use-element-width.js";
3
+ import { fit, textWidth } from "../../lib/fit-text.js";
4
+ import { cn } from "cn";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/components/siyahi/slope-chart.tsx
7
+ const ROW = 18;
8
+ const TOP = 32;
9
+ const BOTTOM = 8;
10
+ const GAP = 12;
11
+ const NONE = [];
12
+ /**
13
+ * Pushes labels apart so none sits closer than `gap` to the next, keeping their order and staying inside [min, max]:
14
+ * down from the top first, then back up from the bottom when the last one ran past it.
15
+ */
16
+ function spread(ys, gap, min, max) {
17
+ const order = ys.map((y, index) => ({
18
+ y,
19
+ index
20
+ })).toSorted((a, b) => a.y - b.y);
21
+ let floor = min;
22
+ for (const label of order) {
23
+ label.y = Math.max(label.y, floor);
24
+ floor = label.y + gap;
25
+ }
26
+ let ceiling = max;
27
+ for (const label of order.toReversed()) {
28
+ label.y = Math.min(label.y, ceiling);
29
+ ceiling = label.y - gap;
30
+ }
31
+ const placed = Array.from({ length: ys.length }, () => 0);
32
+ for (const { y, index } of order) placed[index] = y;
33
+ return placed;
34
+ }
35
+ /**
36
+ * Each item as a line from its value on the left to its value on the right, so rises, falls and crossings read at a
37
+ * glance: allocation against a target, a weight against an index's, or one year against the next. Lines are grey and
38
+ * the page picks the few to draw in the accent; names sit on the left, values at both ends, nudged apart when close.
39
+ */
40
+ function SlopeChart({ items: given, fromLabel, toLabel, highlighted = NONE, valueFormatter, className }) {
41
+ const [ref, width] = useElementWidth();
42
+ const { pct } = useFormat();
43
+ const format = valueFormatter ?? ((value) => pct(value));
44
+ const items = given.filter(({ from, to }) => Number.isFinite(from) && Number.isFinite(to));
45
+ const height = TOP + Math.max(items.length * ROW * 1.5, 180) + BOTTOM;
46
+ const values = items.flatMap(({ from, to }) => [from, to]);
47
+ const low = Math.min(...values);
48
+ const high = Math.max(...values);
49
+ const y = (value) => high === low ? (TOP + height - BOTTOM) / 2 : TOP + (high - value) / (high - low) * (height - TOP - BOTTOM - ROW) + ROW / 2;
50
+ const rightTexts = [toLabel, ...items.map(({ to }) => format(to))];
51
+ const column = Math.min(Math.ceil(Math.max(...rightTexts.map(textWidth)) * 1.1) + 8, Math.round(width * .4));
52
+ const left = Math.min(Math.max(Math.round(width * .45), width - column - 96), 240);
53
+ const right = width - column;
54
+ const lefts = spread(items.map(({ from }) => y(from)), ROW, 41, height - BOTTOM - ROW / 2);
55
+ const rights = spread(items.map(({ to }) => y(to)), ROW, 41, height - BOTTOM - ROW / 2);
56
+ const marked = new Set(highlighted);
57
+ const isMarked = (item) => marked.has(item.key ?? item.name);
58
+ const order = items.map((item, index) => ({
59
+ item,
60
+ index
61
+ })).toSorted((a, b) => Number(isMarked(a.item)) - Number(isMarked(b.item)));
62
+ return /* @__PURE__ */ jsxs("div", {
63
+ ref,
64
+ "data-slot": "slope-chart",
65
+ className: cn("w-full max-w-2xl", className),
66
+ style: { height },
67
+ children: [width > 0 && /* @__PURE__ */ jsxs("svg", {
68
+ width,
69
+ height,
70
+ "aria-hidden": "true",
71
+ className: "text-xs",
72
+ children: [
73
+ /* @__PURE__ */ jsx("text", {
74
+ x: left,
75
+ y: 14,
76
+ textAnchor: "end",
77
+ className: "fill-muted-foreground font-medium",
78
+ children: fit(fromLabel, left)
79
+ }),
80
+ /* @__PURE__ */ jsx("text", {
81
+ x: right,
82
+ y: 14,
83
+ className: "fill-muted-foreground font-medium",
84
+ children: fit(toLabel, column)
85
+ }),
86
+ [left, right].map((x) => /* @__PURE__ */ jsx("line", {
87
+ x1: x,
88
+ x2: x,
89
+ y1: 26,
90
+ y2: height - BOTTOM,
91
+ className: "stroke-border"
92
+ }, x)),
93
+ order.map(({ item, index }) => {
94
+ const strong = isMarked(item);
95
+ const ink = strong ? "var(--chart-1)" : "var(--muted-foreground)";
96
+ const a = y(item.from);
97
+ const b = y(item.to);
98
+ return /* @__PURE__ */ jsxs("g", {
99
+ "data-slot": "slope-chart-item",
100
+ "data-highlighted": strong || void 0,
101
+ children: [
102
+ /* @__PURE__ */ jsx("title", { children: `${item.name}: ${format(item.from)} to ${format(item.to)}` }),
103
+ strong && /* @__PURE__ */ jsx("line", {
104
+ x1: left + GAP,
105
+ y1: a,
106
+ x2: right - GAP,
107
+ y2: b,
108
+ stroke: "var(--card)",
109
+ strokeWidth: 6.5,
110
+ strokeLinecap: "round"
111
+ }),
112
+ /* @__PURE__ */ jsx("line", {
113
+ x1: left + GAP,
114
+ y1: a,
115
+ x2: right - GAP,
116
+ y2: b,
117
+ stroke: ink,
118
+ strokeWidth: strong ? 2.5 : 1.5
119
+ }),
120
+ [[left + GAP, a], [right - GAP, b]].map(([cx, cy]) => /* @__PURE__ */ jsx("circle", {
121
+ cx,
122
+ cy,
123
+ r: strong ? 4 : 3,
124
+ fill: ink,
125
+ stroke: strong ? "var(--card)" : void 0,
126
+ strokeWidth: strong ? 2 : void 0
127
+ }, cx)),
128
+ /* @__PURE__ */ jsxs("text", {
129
+ x: left - 4,
130
+ y: lefts[index],
131
+ dominantBaseline: "central",
132
+ textAnchor: "end",
133
+ className: cn("tabular-nums", strong ? "fill-foreground font-medium" : "fill-muted-foreground"),
134
+ children: [
135
+ fit(item.name, left - 8 - textWidth(` ${format(item.from)}`)),
136
+ " ",
137
+ format(item.from)
138
+ ]
139
+ }),
140
+ /* @__PURE__ */ jsx("text", {
141
+ x: right + 4,
142
+ y: rights[index],
143
+ dominantBaseline: "central",
144
+ className: cn("tabular-nums", strong ? "fill-foreground font-medium" : "fill-muted-foreground"),
145
+ children: format(item.to)
146
+ })
147
+ ]
148
+ }, item.key ?? item.name);
149
+ })
150
+ ]
151
+ }), /* @__PURE__ */ jsx("ul", {
152
+ className: "sr-only",
153
+ "aria-label": `${fromLabel} against ${toLabel}`,
154
+ children: items.map((item) => /* @__PURE__ */ jsx("li", { children: `${item.name}: ${fromLabel} ${format(item.from)}, ${toLabel} ${format(item.to)}` }, item.key ?? item.name))
155
+ })]
156
+ });
157
+ }
158
+ //#endregion
159
+ export { SlopeChart, spread };
@@ -1,6 +1,8 @@
1
1
  import { useFormat } from "../../lib/format.js";
2
2
  import { useElementWidth } from "../../lib/use-element-width.js";
3
- import { heat, heatFill, heatRange, layout } from "../../lib/treemap.js";
3
+ import { heat, heatFill, heatRange } from "../../lib/heat.js";
4
+ import { fit, textWidth } from "../../lib/fit-text.js";
5
+ import { layout } from "../../lib/treemap.js";
4
6
  import { cn } from "cn";
5
7
  import { jsx, jsxs } from "react/jsx-runtime";
6
8
  import { useEffect, useEffectEvent, useMemo, useState } from "react";
@@ -138,12 +140,6 @@ function Treemap({ items, height, range, onHover, selected, onSelect }) {
138
140
  })
139
141
  });
140
142
  }
141
- const CHAR_PX = 6.6;
142
- const textWidth = (text) => text.length * CHAR_PX;
143
- function fit(text, width) {
144
- const room = Math.floor(width / CHAR_PX);
145
- return text.length <= room ? text : `${text.slice(0, Math.max(room - 1, 1)).trimEnd()}…`;
146
- }
147
143
  const MIN_LABEL_WIDTH = 56;
148
144
  const MIN_LABEL_HEIGHT = 28;
149
145
  const TWO_LINE_HEIGHT = 44;