@siyahi/ui 0.1.9-next.3 → 0.1.9-next.30
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/DESIGN.md +170 -0
- package/README.md +94 -177
- package/THIRD_PARTY_NOTICES.md +11 -11
- package/agents/examples/accordion.tsx +39 -0
- package/agents/examples/alert.tsx +24 -0
- package/agents/examples/app-sidebar.tsx +40 -0
- package/agents/examples/avatar.tsx +24 -0
- package/agents/examples/badge.tsx +26 -0
- package/agents/examples/bar-list-labels.tsx +22 -0
- package/agents/examples/bar-list-sides.tsx +40 -0
- package/agents/examples/bar-list.tsx +24 -0
- package/agents/examples/breadcrumb.tsx +28 -0
- package/agents/examples/button-states.tsx +23 -0
- package/agents/examples/button.tsx +21 -0
- package/agents/examples/calendar.tsx +11 -0
- package/agents/examples/callout.tsx +33 -0
- package/agents/examples/card.tsx +36 -0
- package/agents/examples/change-bar.tsx +104 -0
- package/agents/examples/change-grid-money.tsx +36 -0
- package/agents/examples/change-grid.tsx +42 -0
- package/agents/examples/chart-area-log.tsx +45 -0
- package/agents/examples/chart-area.tsx +39 -0
- package/agents/examples/chart-bar-flows.tsx +32 -0
- package/agents/examples/chart-bar-hourly.tsx +28 -0
- package/agents/examples/chart-bar-losses.tsx +29 -0
- package/agents/examples/chart-bar-teams.tsx +72 -0
- package/agents/examples/chart-bar-years.tsx +40 -0
- package/agents/examples/chart-bar.tsx +56 -0
- package/agents/examples/chart-category-ticks.tsx +50 -0
- package/agents/examples/chart-line-events.tsx +47 -0
- package/agents/examples/chart-line-forecast.tsx +36 -0
- package/agents/examples/chart-line-log.tsx +39 -0
- package/agents/examples/chart-line-quiet.tsx +114 -0
- package/agents/examples/chart-line-timeline.tsx +41 -0
- package/agents/examples/chart-line.tsx +57 -0
- package/agents/examples/chart-panels.tsx +94 -0
- package/agents/examples/code-block.tsx +40 -0
- package/agents/examples/collapsible.tsx +18 -0
- package/agents/examples/command.tsx +29 -0
- package/agents/examples/context-menu.tsx +17 -0
- package/agents/examples/copy-button.tsx +8 -0
- package/agents/examples/data-bar.tsx +108 -0
- package/agents/examples/data-table-compact.tsx +96 -0
- package/agents/examples/data-table-filters.tsx +103 -0
- package/agents/examples/data-table.tsx +99 -0
- package/agents/examples/delta.tsx +18 -0
- package/agents/examples/dialog.tsx +28 -0
- package/agents/examples/distribution-bar-budget.tsx +30 -0
- package/agents/examples/distribution-bar-tug.tsx +74 -0
- package/agents/examples/drawdown-chart.tsx +35 -0
- package/agents/examples/dropdown-menu.tsx +31 -0
- package/agents/examples/field.tsx +108 -0
- package/agents/examples/formatters.tsx +42 -0
- package/agents/examples/heatmap-calendar-signed.tsx +67 -0
- package/agents/examples/heatmap-calendar.tsx +62 -0
- package/agents/examples/heatmap-status.tsx +61 -0
- package/agents/examples/heatmap-weekday.tsx +72 -0
- package/agents/examples/histogram-chart.tsx +28 -0
- package/agents/examples/input-states.tsx +23 -0
- package/agents/examples/input.tsx +17 -0
- package/agents/examples/local-time.tsx +14 -0
- package/agents/examples/metric-strip.tsx +46 -0
- package/agents/examples/overview-dashboard.tsx +180 -0
- package/agents/examples/page-hero.tsx +38 -0
- package/agents/examples/page-section.tsx +52 -0
- package/agents/examples/popover.tsx +23 -0
- package/agents/examples/price-chart.tsx +32 -0
- package/agents/examples/progress-tone.tsx +29 -0
- package/agents/examples/progress.tsx +10 -0
- package/agents/examples/range-position-table.tsx +101 -0
- package/agents/examples/range-position.tsx +55 -0
- package/agents/examples/scatter-chart-quadrants.tsx +63 -0
- package/agents/examples/scatter-chart-scales.tsx +46 -0
- package/agents/examples/scatter-chart.tsx +78 -0
- package/agents/examples/scroll-area.tsx +22 -0
- package/agents/examples/section-cards-select.tsx +28 -0
- package/agents/examples/section-cards-trend.tsx +32 -0
- package/agents/examples/section-cards.tsx +48 -0
- package/agents/examples/select.tsx +27 -0
- package/agents/examples/separator.tsx +13 -0
- package/agents/examples/sheet.tsx +15 -0
- package/agents/examples/skeleton.tsx +11 -0
- package/agents/examples/slider.tsx +5 -0
- package/agents/examples/slope-chart-market.tsx +33 -0
- package/agents/examples/slope-chart.tsx +26 -0
- package/agents/examples/stale-blocks.tsx +56 -0
- package/agents/examples/surfaces.tsx +116 -0
- package/agents/examples/table.tsx +60 -0
- package/agents/examples/tabs.tsx +16 -0
- package/agents/examples/target-rows.tsx +74 -0
- package/agents/examples/toggle-group.tsx +12 -0
- package/agents/examples/toggle.tsx +11 -0
- package/agents/examples/tooltip.tsx +13 -0
- package/agents/examples/treemap.tsx +93 -0
- package/agents/examples/waterfall-chart-stores.tsx +27 -0
- package/agents/examples/waterfall-chart.tsx +37 -0
- package/agents/manifest.json +1391 -0
- package/agents/report-rules.json +7 -0
- package/dist/components/registry/charts/area-chart-card.d.ts +7 -3
- package/dist/components/registry/charts/area-chart-card.js +56 -10
- package/dist/components/registry/charts/bar-chart-card.d.ts +1 -1
- package/dist/components/registry/charts/bar-chart-card.js +20 -22
- package/dist/components/registry/charts/line-chart-card.d.ts +3 -3
- package/dist/components/registry/charts/line-chart-card.js +44 -9
- package/dist/components/registry/dashboard-01/section-cards.d.ts +6 -0
- package/dist/components/registry/dashboard-01/section-cards.js +51 -35
- package/dist/components/registry/dashboardcn/bar-list.js +17 -10
- package/dist/components/registry/dashboardcn/distribution-bar.js +6 -4
- package/dist/components/registry/dashboardcn/kpi-row-card.d.ts +29 -0
- package/dist/components/registry/dashboardcn/kpi-row-card.js +81 -0
- package/dist/components/registry/dashboardcn/sparkline.d.ts +22 -0
- package/dist/components/registry/dashboardcn/sparkline.js +117 -0
- package/dist/components/registry/heatmap/calendar-heatmap.d.ts +11 -3
- package/dist/components/registry/heatmap/calendar-heatmap.js +164 -45
- package/dist/components/registry/heatmap/status-heatmap.d.ts +5 -3
- package/dist/components/registry/heatmap/status-heatmap.js +115 -54
- package/dist/components/registry/heatmap/weekday-heatmap.d.ts +9 -3
- package/dist/components/registry/heatmap/weekday-heatmap.js +83 -53
- package/dist/components/registry/niko-table/core/data-table-context.d.ts +18 -1
- package/dist/components/registry/niko-table/core/data-table-context.js +7 -1
- package/dist/components/registry/niko-table/core/data-table-grouped-row.js +3 -1
- package/dist/components/registry/niko-table/core/data-table-root.js +39 -4
- package/dist/components/registry/niko-table/core/data-table-structure.js +17 -12
- package/dist/components/registry/niko-table/core/data-table.d.ts +2 -2
- package/dist/components/registry/niko-table/core/data-table.js +5 -1
- package/dist/components/registry/niko-table/filters/table-faceted-filter.js +5 -5
- package/dist/components/registry/niko-table/filters/table-search-filter.js +1 -1
- package/dist/components/registry/niko-table/lib/flex-columns.d.ts +1 -1
- package/dist/components/registry/niko-table/lib/flex-columns.js +3 -3
- package/dist/components/registry/niko-table/lib/styles.d.ts +1 -1
- package/dist/components/registry/niko-table/lib/styles.js +3 -3
- package/dist/components/registry/niko-table/types/index.d.ts +9 -0
- package/dist/components/registry/niko-table/ui/table.js +2 -2
- package/dist/components/registry/sidebar-07/app-sidebar.d.ts +1 -1
- package/dist/components/registry/sidebar-07/app-sidebar.js +3 -1
- package/dist/components/registry/sidebar-07/nav-main.d.ts +2 -1
- package/dist/components/registry/sidebar-07/nav-main.js +16 -3
- package/dist/components/siyahi/callout.d.ts +17 -0
- package/dist/components/siyahi/callout.js +34 -0
- package/dist/components/siyahi/change-bar.d.ts +21 -0
- package/dist/components/siyahi/change-bar.js +35 -0
- package/dist/components/siyahi/change-grid.d.ts +31 -0
- package/dist/components/siyahi/change-grid.js +74 -0
- package/dist/components/siyahi/data-bar.d.ts +19 -0
- package/dist/components/siyahi/data-bar.js +29 -0
- package/dist/components/siyahi/data-table-compact-columns-toggle.d.ts +4 -0
- package/dist/components/siyahi/data-table-compact-columns-toggle.js +19 -0
- package/dist/components/siyahi/data-table-footnote.d.ts +12 -0
- package/dist/components/siyahi/data-table-footnote.js +35 -0
- package/dist/components/siyahi/default-sidebar-open.d.ts +8 -0
- package/dist/components/siyahi/default-sidebar-open.js +13 -0
- package/dist/components/siyahi/delta.d.ts +5 -1
- package/dist/components/siyahi/delta.js +14 -9
- package/dist/components/siyahi/drawdown-chart.d.ts +34 -0
- package/dist/components/siyahi/drawdown-chart.js +102 -0
- package/dist/components/siyahi/histogram-chart.d.ts +57 -0
- package/dist/components/siyahi/histogram-chart.js +134 -0
- package/dist/components/siyahi/page-hero.d.ts +23 -0
- package/dist/components/siyahi/page-hero.js +43 -0
- package/dist/components/siyahi/page-section.d.ts +4 -1
- package/dist/components/siyahi/page-section.js +6 -3
- package/dist/components/siyahi/page-toc.d.ts +20 -0
- package/dist/components/siyahi/page-toc.js +76 -0
- package/dist/components/siyahi/panel-grid.d.ts +24 -0
- package/dist/components/siyahi/panel-grid.js +37 -0
- package/dist/components/siyahi/quote.d.ts +12 -0
- package/dist/components/siyahi/quote.js +23 -0
- package/dist/components/siyahi/range-position.d.ts +24 -0
- package/dist/components/siyahi/range-position.js +105 -0
- package/dist/components/siyahi/scale-toggle.d.ts +32 -0
- package/dist/components/siyahi/scale-toggle.js +61 -0
- package/dist/components/siyahi/scatter-chart.d.ts +69 -0
- package/dist/components/siyahi/scatter-chart.js +395 -0
- package/dist/components/siyahi/siyahi-provider.d.ts +15 -2
- package/dist/components/siyahi/siyahi-provider.js +17 -4
- package/dist/components/siyahi/slope-chart.d.ts +35 -0
- package/dist/components/siyahi/slope-chart.js +159 -0
- package/dist/components/siyahi/stale-note.d.ts +9 -0
- package/dist/components/siyahi/stale-note.js +12 -0
- package/dist/components/siyahi/status-alert.d.ts +15 -0
- package/dist/components/siyahi/status-alert.js +29 -0
- package/dist/components/siyahi/status-badge.d.ts +15 -0
- package/dist/components/siyahi/status-badge.js +24 -0
- package/dist/components/siyahi/target-rows.d.ts +34 -0
- package/dist/components/siyahi/target-rows.js +97 -0
- package/dist/components/siyahi/tone-progress.d.ts +1 -1
- package/dist/components/siyahi/tone-progress.js +1 -1
- package/dist/components/siyahi/treemap.d.ts +8 -5
- package/dist/components/siyahi/treemap.js +80 -34
- package/dist/components/siyahi/waterfall-chart.d.ts +51 -0
- package/dist/components/siyahi/waterfall-chart.js +147 -0
- package/dist/components/ui/accordion.js +5 -5
- package/dist/components/ui/alert.js +2 -2
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/breadcrumb.js +2 -2
- package/dist/components/ui/button.js +10 -10
- package/dist/components/ui/calendar.js +1 -1
- package/dist/components/ui/card.js +4 -4
- package/dist/components/ui/chart.js +1 -1
- package/dist/components/ui/code-block.js +13 -12
- package/dist/components/ui/command.js +7 -6
- package/dist/components/ui/context-menu.js +7 -7
- package/dist/components/ui/dialog.js +5 -5
- package/dist/components/ui/dropdown-menu.js +8 -8
- package/dist/components/ui/field.d.ts +26 -0
- package/dist/components/ui/field.js +109 -0
- package/dist/components/ui/input-group.js +11 -11
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/label.d.ts +5 -0
- package/dist/components/ui/label.js +13 -0
- package/dist/components/ui/popover.js +3 -3
- package/dist/components/ui/progress.js +1 -1
- package/dist/components/ui/select.js +6 -6
- package/dist/components/ui/sheet.js +5 -5
- package/dist/components/ui/sidebar.js +17 -18
- package/dist/components/ui/skeleton.js +1 -1
- package/dist/components/ui/slider.js +2 -2
- package/dist/components/ui/table.js +2 -2
- package/dist/components/ui/tabs.js +2 -2
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/toggle-group.js +2 -2
- package/dist/components/ui/toggle.js +4 -4
- package/dist/components/ui/tooltip.js +2 -2
- package/dist/index.d.ts +35 -8
- package/dist/index.js +30 -4
- package/dist/lib/chart-frame.d.ts +51 -7
- package/dist/lib/chart-frame.js +390 -133
- package/dist/lib/chart-marks.d.ts +83 -0
- package/dist/lib/chart-marks.js +216 -0
- package/dist/lib/chart-series.d.ts +42 -10
- package/dist/lib/compact-columns.d.ts +19 -0
- package/dist/lib/compact-columns.js +55 -0
- package/dist/lib/fit-text.d.ts +6 -0
- package/dist/lib/fit-text.js +11 -0
- package/dist/lib/format.d.ts +8 -3
- package/dist/lib/format.js +55 -17
- package/dist/lib/heat.d.ts +7 -0
- package/dist/lib/heat.js +14 -0
- package/dist/lib/heatmap-navigation.d.ts +8 -0
- package/dist/lib/heatmap-navigation.js +49 -0
- package/dist/lib/scatter.d.ts +88 -0
- package/dist/lib/scatter.js +179 -0
- package/dist/lib/ticks.d.ts +27 -0
- package/dist/lib/ticks.js +67 -0
- package/dist/lib/tone.d.ts +36 -0
- package/dist/lib/tone.js +35 -0
- package/dist/lib/treemap.d.ts +0 -5
- package/dist/lib/treemap.js +1 -11
- package/dist/lib/use-element-width.d.ts +5 -0
- package/dist/lib/use-element-width.js +15 -0
- package/package.json +5 -1
- package/registry.lock.json +16 -0
- package/report.css +63 -47
- package/styles/siyahi.css +708 -55
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card.js";
|
|
2
|
+
import { ChartContainer } from "../ui/chart.js";
|
|
3
|
+
import { LOG_BLOCKED, useAxisScale } from "./scale-toggle.js";
|
|
4
|
+
import { useLocale } from "./siyahi-provider.js";
|
|
5
|
+
import { fitsLog, ticksFor } from "../../lib/ticks.js";
|
|
6
|
+
import { corners, dotSizes, placeLabels, quadrantSplit } from "../../lib/scatter.js";
|
|
7
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { CartesianGrid, DefaultZIndexes, ReferenceLine, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZIndexLayer, usePlotArea, useXAxisScale, useYAxisScale } from "recharts";
|
|
10
|
+
//#region src/components/siyahi/scatter-chart.tsx
|
|
11
|
+
const RADIUS = 5;
|
|
12
|
+
const EDGE = 1.5;
|
|
13
|
+
const SIZED_FILL = .3;
|
|
14
|
+
const RING = 3;
|
|
15
|
+
const NAME_BAND = 42;
|
|
16
|
+
const TICK = { className: "fill-muted-foreground font-mono" };
|
|
17
|
+
const AXIS_TITLE = "fill-muted-foreground text-xs";
|
|
18
|
+
const keyOf = (point) => point.key ?? point.name;
|
|
19
|
+
const MONO_WIDTH = 7.2;
|
|
20
|
+
/** Two measures per item as dots, such as size against growth, with the leading names beside their dots. */
|
|
21
|
+
function ScatterChartCard({ title, description, action, points, height, xLabel, yLabel, xScale = "linear", xScaleToggle = false, yScale = "linear", yScaleToggle = false, xFormatter, yFormatter, labels = 12, details, selected = null, onSelect, footer, quadrants, sizeLabel = "Size", sizeFormatter }) {
|
|
22
|
+
const { numberLocale } = useLocale();
|
|
23
|
+
const plotRef = useRef(null);
|
|
24
|
+
const [focusedKey, setFocusedKey] = useState(null);
|
|
25
|
+
const keyed = useRef(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const key = keyed.current;
|
|
28
|
+
if (key === null) return;
|
|
29
|
+
const frame = requestAnimationFrame(() => {
|
|
30
|
+
const dot = plotRef.current?.querySelector(`[data-key="${CSS.escape(key)}"]`);
|
|
31
|
+
if (dot && document.activeElement !== dot) dot.focus();
|
|
32
|
+
});
|
|
33
|
+
return () => cancelAnimationFrame(frame);
|
|
34
|
+
});
|
|
35
|
+
const onBlur = (event) => {
|
|
36
|
+
if (plotRef.current?.contains(event.relatedTarget)) return;
|
|
37
|
+
const left = event.target;
|
|
38
|
+
requestAnimationFrame(() => {
|
|
39
|
+
if (left.isConnected) keyed.current = null;
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const formatX = useMemo(() => xFormatter ?? new Intl.NumberFormat(numberLocale).format, [xFormatter, numberLocale]);
|
|
43
|
+
const formatY = useMemo(() => yFormatter ?? new Intl.NumberFormat(numberLocale).format, [yFormatter, numberLocale]);
|
|
44
|
+
const formatSize = useMemo(() => sizeFormatter ?? new Intl.NumberFormat(numberLocale).format, [sizeFormatter, numberLocale]);
|
|
45
|
+
const drawn = useMemo(() => points.filter((p) => p.y !== null && Number.isFinite(p.y) && Number.isFinite(p.x)), [points]);
|
|
46
|
+
const both = xScaleToggle && yScaleToggle;
|
|
47
|
+
const across = useAxisScale({
|
|
48
|
+
initial: xScale,
|
|
49
|
+
toggle: xScaleToggle,
|
|
50
|
+
label: `${xLabel} scale`,
|
|
51
|
+
caption: both ? xLabel : void 0,
|
|
52
|
+
logBlocked: fitsLog(drawn.map((p) => p.x)) ? void 0 : LOG_BLOCKED
|
|
53
|
+
});
|
|
54
|
+
const up = useAxisScale({
|
|
55
|
+
initial: yScale,
|
|
56
|
+
toggle: yScaleToggle,
|
|
57
|
+
label: `${yLabel} scale`,
|
|
58
|
+
caption: both ? yLabel : void 0,
|
|
59
|
+
logBlocked: fitsLog(drawn.map((p) => p.y ?? 0)) ? void 0 : LOG_BLOCKED
|
|
60
|
+
});
|
|
61
|
+
const order = useMemo(() => drawn.toSorted((a, b) => a.x - b.x || (a.y ?? 0) - (b.y ?? 0)), [drawn]);
|
|
62
|
+
const { radiusOf, sized } = dotSizes(drawn, RADIUS);
|
|
63
|
+
const painted = sized ? drawn.toSorted((a, b) => radiusOf(b) - radiusOf(a)) : drawn;
|
|
64
|
+
const split = quadrantSplit(quadrants, across.scale, up.scale);
|
|
65
|
+
const xs = [...drawn.map((p) => p.x), ...split ? [split.x] : []];
|
|
66
|
+
const ys = [...drawn.map((p) => p.y ?? 0), ...split ? [split.y] : []];
|
|
67
|
+
const reach = sized ? 23 : 0;
|
|
68
|
+
const names = split?.labels;
|
|
69
|
+
const band = (named) => named ? NAME_BAND + Math.max(reach, 6.5) : reach;
|
|
70
|
+
const xTicks = ticksFor(across.scale, Math.min(...xs), Math.max(...xs));
|
|
71
|
+
const yTicks = ticksFor(up.scale, Math.min(...ys), Math.max(...ys));
|
|
72
|
+
const lastX = xTicks.at(-1);
|
|
73
|
+
const endRoom = Math.max(16, lastX === void 0 ? 0 : Math.ceil(formatX(lastX).length * MONO_WIDTH / 2) + 4 - reach);
|
|
74
|
+
const crossesZero = ys.some((y) => y < 0) && ys.some((y) => y > 0);
|
|
75
|
+
const selectedKey = selected ? keyOf(selected) : null;
|
|
76
|
+
const rows = details ?? ((p) => [
|
|
77
|
+
[xLabel, formatX(p.x)],
|
|
78
|
+
[yLabel, p.y === null ? "—" : formatY(p.y)],
|
|
79
|
+
...p.size === void 0 ? [] : [[sizeLabel, formatSize(p.size)]]
|
|
80
|
+
]);
|
|
81
|
+
const first = order[0];
|
|
82
|
+
const focusKey = order.some((p) => keyOf(p) === focusedKey) ? focusedKey : selectedKey ?? (first ? keyOf(first) : null);
|
|
83
|
+
const onKeyDown = (event, point) => {
|
|
84
|
+
if (!onSelect) return;
|
|
85
|
+
const key = keyOf(point);
|
|
86
|
+
keyed.current = key;
|
|
87
|
+
const at = order.findIndex((p) => keyOf(p) === key);
|
|
88
|
+
const to = {
|
|
89
|
+
ArrowRight: at + 1,
|
|
90
|
+
ArrowUp: at + 1,
|
|
91
|
+
ArrowLeft: at - 1,
|
|
92
|
+
ArrowDown: at - 1,
|
|
93
|
+
Home: 0,
|
|
94
|
+
End: order.length - 1
|
|
95
|
+
}[event.key];
|
|
96
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
onSelect(key === selectedKey ? null : point);
|
|
99
|
+
} else if (event.key === "Escape") onSelect(null);
|
|
100
|
+
else if (to !== void 0) {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
const next = order[Math.max(0, Math.min(order.length - 1, to))];
|
|
103
|
+
if (!next) return;
|
|
104
|
+
keyed.current = keyOf(next);
|
|
105
|
+
setFocusedKey(keyOf(next));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return /* @__PURE__ */ jsxs(Card, { children: [
|
|
109
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
110
|
+
/* @__PURE__ */ jsx(CardTitle, { children: title }),
|
|
111
|
+
description && /* @__PURE__ */ jsx(CardDescription, { children: description }),
|
|
112
|
+
(action || across.buttons || up.buttons) && /* @__PURE__ */ jsxs(CardAction, {
|
|
113
|
+
className: "flex flex-wrap items-center gap-x-3 gap-y-2",
|
|
114
|
+
children: [
|
|
115
|
+
action,
|
|
116
|
+
across.buttons,
|
|
117
|
+
up.buttons
|
|
118
|
+
]
|
|
119
|
+
})
|
|
120
|
+
] }),
|
|
121
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx("div", {
|
|
122
|
+
ref: plotRef,
|
|
123
|
+
"data-slot": "scatter-chart",
|
|
124
|
+
role: onSelect ? "group" : "img",
|
|
125
|
+
"aria-label": `${xLabel} against ${yLabel}`,
|
|
126
|
+
onClick: () => onSelect?.(null),
|
|
127
|
+
onBlur,
|
|
128
|
+
children: /* @__PURE__ */ jsx(ChartContainer, {
|
|
129
|
+
config: {},
|
|
130
|
+
className: "aspect-auto w-full",
|
|
131
|
+
style: { height },
|
|
132
|
+
children: /* @__PURE__ */ jsxs(ScatterChart, {
|
|
133
|
+
margin: {
|
|
134
|
+
top: 12,
|
|
135
|
+
right: endRoom,
|
|
136
|
+
left: 0
|
|
137
|
+
},
|
|
138
|
+
accessibilityLayer: false,
|
|
139
|
+
children: [
|
|
140
|
+
/* @__PURE__ */ jsx(CartesianGrid, { strokeDasharray: "3 3" }),
|
|
141
|
+
/* @__PURE__ */ jsx(XAxis, {
|
|
142
|
+
type: "number",
|
|
143
|
+
dataKey: "x",
|
|
144
|
+
scale: across.scale,
|
|
145
|
+
domain: xTicks.length > 0 ? [xTicks[0] ?? "auto", xTicks.at(-1) ?? "auto"] : [(low) => Math.min(low, ...xs), (high) => Math.max(high, ...xs)],
|
|
146
|
+
ticks: xTicks.length > 0 ? xTicks : void 0,
|
|
147
|
+
niceTicks: xTicks.length > 0 ? void 0 : "snap125",
|
|
148
|
+
padding: reach > 0 ? {
|
|
149
|
+
left: reach,
|
|
150
|
+
right: reach
|
|
151
|
+
} : void 0,
|
|
152
|
+
tickLine: false,
|
|
153
|
+
axisLine: false,
|
|
154
|
+
tickMargin: 8,
|
|
155
|
+
tick: TICK,
|
|
156
|
+
tickFormatter: (value) => formatX(value),
|
|
157
|
+
height: 48,
|
|
158
|
+
label: {
|
|
159
|
+
value: xLabel,
|
|
160
|
+
position: "insideBottom",
|
|
161
|
+
className: AXIS_TITLE
|
|
162
|
+
}
|
|
163
|
+
}),
|
|
164
|
+
/* @__PURE__ */ jsx(YAxis, {
|
|
165
|
+
type: "number",
|
|
166
|
+
dataKey: "y",
|
|
167
|
+
scale: up.scale,
|
|
168
|
+
domain: [yTicks[0] ?? "auto", yTicks.at(-1) ?? "auto"],
|
|
169
|
+
ticks: yTicks,
|
|
170
|
+
padding: {
|
|
171
|
+
top: band(Boolean(names?.topLeft || names?.topRight)),
|
|
172
|
+
bottom: band(Boolean(names?.bottomLeft || names?.bottomRight))
|
|
173
|
+
},
|
|
174
|
+
tickLine: false,
|
|
175
|
+
axisLine: false,
|
|
176
|
+
width: "auto",
|
|
177
|
+
tick: TICK,
|
|
178
|
+
tickFormatter: (value) => formatY(value),
|
|
179
|
+
label: {
|
|
180
|
+
value: yLabel,
|
|
181
|
+
angle: -90,
|
|
182
|
+
position: "insideLeft",
|
|
183
|
+
style: { textAnchor: "middle" },
|
|
184
|
+
className: AXIS_TITLE
|
|
185
|
+
}
|
|
186
|
+
}),
|
|
187
|
+
split ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
188
|
+
/* @__PURE__ */ jsx(ReferenceLine, {
|
|
189
|
+
x: split.x,
|
|
190
|
+
stroke: "var(--muted-foreground)",
|
|
191
|
+
strokeDasharray: "4 4"
|
|
192
|
+
}),
|
|
193
|
+
/* @__PURE__ */ jsx(ReferenceLine, {
|
|
194
|
+
y: split.y,
|
|
195
|
+
stroke: "var(--muted-foreground)",
|
|
196
|
+
strokeDasharray: "4 4"
|
|
197
|
+
}),
|
|
198
|
+
/* @__PURE__ */ jsx(QuadrantNames, { quadrants: split })
|
|
199
|
+
] }) : crossesZero && /* @__PURE__ */ jsx(ReferenceLine, {
|
|
200
|
+
y: 0,
|
|
201
|
+
stroke: "var(--border)"
|
|
202
|
+
}),
|
|
203
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
204
|
+
cursor: false,
|
|
205
|
+
isAnimationActive: false,
|
|
206
|
+
content: /* @__PURE__ */ jsx(PointTooltip, { details: rows })
|
|
207
|
+
}),
|
|
208
|
+
/* @__PURE__ */ jsx(Scatter, {
|
|
209
|
+
data: painted,
|
|
210
|
+
fill: "var(--chart-1)",
|
|
211
|
+
isAnimationActive: false,
|
|
212
|
+
shape: /* @__PURE__ */ jsx(Dot, {
|
|
213
|
+
selectedKey,
|
|
214
|
+
focusKey: onSelect ? focusKey : null,
|
|
215
|
+
describe: (p) => [p.name, ...rows(p).map(([label, value]) => `${label} ${value}`)].join(", "),
|
|
216
|
+
onSelect,
|
|
217
|
+
onKeyDown,
|
|
218
|
+
radiusOf,
|
|
219
|
+
sized
|
|
220
|
+
})
|
|
221
|
+
}),
|
|
222
|
+
/* @__PURE__ */ jsx(PointLabels, {
|
|
223
|
+
points: drawn,
|
|
224
|
+
limit: labels,
|
|
225
|
+
radiusOf,
|
|
226
|
+
quadrants: split
|
|
227
|
+
})
|
|
228
|
+
]
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
}) }),
|
|
232
|
+
footer && /* @__PURE__ */ jsx(CardFooter, {
|
|
233
|
+
className: "flex-col items-start gap-2 text-sm",
|
|
234
|
+
children: footer
|
|
235
|
+
})
|
|
236
|
+
] });
|
|
237
|
+
}
|
|
238
|
+
/** The hovered point's name, then its rows. Recharts passes the hovered point in as payload. */
|
|
239
|
+
function PointTooltip({ active, payload, details }) {
|
|
240
|
+
const point = active ? payload?.[0]?.payload : void 0;
|
|
241
|
+
if (!point) return null;
|
|
242
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
243
|
+
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",
|
|
244
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
245
|
+
className: "font-medium",
|
|
246
|
+
children: point.name
|
|
247
|
+
}), details(point).map(([label, value]) => /* @__PURE__ */ jsxs("div", {
|
|
248
|
+
className: "flex justify-between gap-4",
|
|
249
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
250
|
+
className: "text-muted-foreground",
|
|
251
|
+
children: label
|
|
252
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
253
|
+
className: "font-mono font-medium text-foreground tabular-nums",
|
|
254
|
+
children: value
|
|
255
|
+
})]
|
|
256
|
+
}, label))]
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** One point's dot. A ring marks the selected one; with onSelect it is a button Tab and the arrow keys reach. */
|
|
260
|
+
function Dot({ cx, cy, payload, index, "data-recharts-item-id": itemId, selectedKey, focusKey, describe, onSelect, onKeyDown, radiusOf, sized }) {
|
|
261
|
+
if (cx === void 0 || cy === void 0 || !payload) return null;
|
|
262
|
+
const key = keyOf(payload);
|
|
263
|
+
const isSelected = key === selectedKey;
|
|
264
|
+
const r = radiusOf(payload) + (isSelected ? 2 : 0);
|
|
265
|
+
const dot = /* @__PURE__ */ jsx("circle", {
|
|
266
|
+
cx,
|
|
267
|
+
cy,
|
|
268
|
+
r,
|
|
269
|
+
fill: "var(--chart-1)",
|
|
270
|
+
fillOpacity: sized ? SIZED_FILL : 1,
|
|
271
|
+
stroke: isSelected ? "var(--foreground)" : sized ? "var(--chart-1)" : "var(--card)",
|
|
272
|
+
strokeWidth: isSelected ? 2 : EDGE,
|
|
273
|
+
"data-slot": "scatter-point",
|
|
274
|
+
"data-key": key,
|
|
275
|
+
"data-selected": isSelected || void 0,
|
|
276
|
+
"data-recharts-item-id": itemId,
|
|
277
|
+
"data-recharts-item-index": index,
|
|
278
|
+
role: onSelect ? "button" : void 0,
|
|
279
|
+
tabIndex: onSelect ? key === focusKey ? 0 : -1 : void 0,
|
|
280
|
+
"aria-label": describe(payload),
|
|
281
|
+
"aria-pressed": onSelect ? isSelected : void 0,
|
|
282
|
+
className: onSelect ? "cursor-pointer" : void 0,
|
|
283
|
+
onKeyDown: (event) => onKeyDown(event, payload),
|
|
284
|
+
onClick: (event) => {
|
|
285
|
+
if (!onSelect) return;
|
|
286
|
+
event.stopPropagation();
|
|
287
|
+
onSelect(isSelected ? null : payload);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
if (!sized) return dot;
|
|
291
|
+
return /* @__PURE__ */ jsxs("g", { children: [/* @__PURE__ */ jsx("circle", {
|
|
292
|
+
cx,
|
|
293
|
+
cy,
|
|
294
|
+
r: r + EDGE / 2 + RING / 2,
|
|
295
|
+
fill: "none",
|
|
296
|
+
stroke: "var(--card)",
|
|
297
|
+
strokeWidth: RING
|
|
298
|
+
}), dot] });
|
|
299
|
+
}
|
|
300
|
+
/** A name's width in the labels' type, measured on a canvas once per font and name. */
|
|
301
|
+
const widths = /* @__PURE__ */ new Map();
|
|
302
|
+
let canvas;
|
|
303
|
+
/** The labels' type as the page sets it: text-xs is three quarters of the root size, which a reader may enlarge. */
|
|
304
|
+
function labelType() {
|
|
305
|
+
if (typeof document === "undefined") return {
|
|
306
|
+
size: 12,
|
|
307
|
+
measure: (text) => text.length * 7
|
|
308
|
+
};
|
|
309
|
+
const size = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) * .75;
|
|
310
|
+
const font = `500 ${size}px ${getComputedStyle(document.body).fontFamily}`;
|
|
311
|
+
canvas ??= document.createElement("canvas").getContext("2d");
|
|
312
|
+
const measure = (text) => {
|
|
313
|
+
const id = `${font}\n${text}`;
|
|
314
|
+
let width = widths.get(id);
|
|
315
|
+
if (width === void 0 && canvas) {
|
|
316
|
+
canvas.font = font;
|
|
317
|
+
width = canvas.measureText(text).width;
|
|
318
|
+
widths.set(id, width);
|
|
319
|
+
}
|
|
320
|
+
return width ?? text.length * size * .6;
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
size,
|
|
324
|
+
measure
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** The leading points' names, placed from the axes' scales once the chart has laid them out. */
|
|
328
|
+
/** Where the quadrants' split lines cross, in pixels, once the axes are laid out. */
|
|
329
|
+
function useSplit(quadrants) {
|
|
330
|
+
const x = useXAxisScale();
|
|
331
|
+
const y = useYAxisScale();
|
|
332
|
+
return quadrants && x && y ? {
|
|
333
|
+
x: Number(x(quadrants.x)),
|
|
334
|
+
y: Number(y(quadrants.y))
|
|
335
|
+
} : null;
|
|
336
|
+
}
|
|
337
|
+
/** The quadrants' names, muted, in the plot's corners. */
|
|
338
|
+
function QuadrantNames({ quadrants }) {
|
|
339
|
+
const plot = usePlotArea();
|
|
340
|
+
const split = useSplit(quadrants);
|
|
341
|
+
if (!plot) return null;
|
|
342
|
+
return /* @__PURE__ */ jsx(ZIndexLayer, {
|
|
343
|
+
zIndex: DefaultZIndexes.label,
|
|
344
|
+
children: /* @__PURE__ */ jsx("g", {
|
|
345
|
+
"data-slot": "scatter-quadrant-names",
|
|
346
|
+
pointerEvents: "none",
|
|
347
|
+
children: corners(plot, quadrants.labels, split, labelType()).map(({ key, lines, x, y, line }) => /* @__PURE__ */ jsx("text", {
|
|
348
|
+
x,
|
|
349
|
+
y,
|
|
350
|
+
className: "fill-muted-foreground stroke-card text-xs font-medium",
|
|
351
|
+
style: { paintOrder: "stroke" },
|
|
352
|
+
strokeWidth: 3,
|
|
353
|
+
strokeLinejoin: "round",
|
|
354
|
+
children: lines.map((text, index) => /* @__PURE__ */ jsx("tspan", {
|
|
355
|
+
x,
|
|
356
|
+
dy: index === 0 ? 0 : line,
|
|
357
|
+
children: text
|
|
358
|
+
}, text))
|
|
359
|
+
}, key))
|
|
360
|
+
})
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function PointLabels({ points, limit, radiusOf, quadrants }) {
|
|
364
|
+
const x = useXAxisScale();
|
|
365
|
+
const y = useYAxisScale();
|
|
366
|
+
const plot = usePlotArea();
|
|
367
|
+
const split = useSplit(quadrants);
|
|
368
|
+
if (!x || !y || !plot || limit <= 0) return null;
|
|
369
|
+
const type = labelType();
|
|
370
|
+
const candidates = points.map((p) => ({
|
|
371
|
+
key: keyOf(p),
|
|
372
|
+
text: p.name,
|
|
373
|
+
cx: Number(x(p.x)),
|
|
374
|
+
cy: Number(y(p.y)),
|
|
375
|
+
r: radiusOf(p) + EDGE
|
|
376
|
+
}));
|
|
377
|
+
return /* @__PURE__ */ jsx(ZIndexLayer, {
|
|
378
|
+
zIndex: DefaultZIndexes.label,
|
|
379
|
+
children: /* @__PURE__ */ jsx("g", {
|
|
380
|
+
"data-slot": "scatter-labels",
|
|
381
|
+
pointerEvents: "none",
|
|
382
|
+
children: placeLabels(candidates, plot, limit, type, corners(plot, quadrants?.labels, split, type).map(({ box }) => box)).map((label) => /* @__PURE__ */ jsx("text", {
|
|
383
|
+
x: label.x,
|
|
384
|
+
y: label.y,
|
|
385
|
+
className: "fill-foreground stroke-card text-xs font-medium",
|
|
386
|
+
style: { paintOrder: "stroke" },
|
|
387
|
+
strokeWidth: 3,
|
|
388
|
+
strokeLinejoin: "round",
|
|
389
|
+
children: label.text
|
|
390
|
+
}, label.key))
|
|
391
|
+
})
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
export { ScatterChartCard };
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { SiyahiLocale } from "../../lib/locale.js";
|
|
2
2
|
import { ReactNode } from "react";
|
|
3
3
|
//#region src/components/siyahi/siyahi-provider.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* How a data block sits on the page. "ruled", the default: blocks filled with the card colour and parted by hairlines,
|
|
6
|
+
* with a rule above and below each group and none on its sides. "card": a filled square card in a hairline ring, 8px
|
|
7
|
+
* from the next, for a page of separate blocks. "open": no fill and no lines, the block's content level with the
|
|
8
|
+
* page's text.
|
|
9
|
+
*/
|
|
10
|
+
export type Surface = "ruled" | "card" | "open";
|
|
4
11
|
export interface SiyahiProviderProps {
|
|
5
12
|
/** Any setting left out keeps its default: en-IN, INR, Asia/Kolkata. */
|
|
6
13
|
locale?: Partial<SiyahiLocale>;
|
|
14
|
+
/**
|
|
15
|
+
* Set it on a nested provider for one part of a page. Left out, a provider keeps the surface around it, so a
|
|
16
|
+
* provider that only changes the locale changes nothing else; the outermost default is "ruled".
|
|
17
|
+
*/
|
|
18
|
+
surface?: Surface;
|
|
7
19
|
children: ReactNode;
|
|
8
20
|
}
|
|
9
|
-
/** Wrap the app once. Carries the locale and the tooltip provider shadcn asks for. */
|
|
10
|
-
export declare function SiyahiProvider({ locale, children }: SiyahiProviderProps): import("react").JSX.Element;
|
|
21
|
+
/** Wrap the app once. Carries the locale, the surface and the tooltip provider shadcn asks for. */
|
|
22
|
+
export declare function SiyahiProvider({ locale, surface, children }: SiyahiProviderProps): import("react").JSX.Element;
|
|
11
23
|
export declare function useLocale(): SiyahiLocale;
|
|
24
|
+
export declare function useSurface(): Surface;
|
|
12
25
|
//#endregion
|
|
@@ -4,8 +4,9 @@ import { jsx } from "react/jsx-runtime";
|
|
|
4
4
|
import { createContext, use, useMemo } from "react";
|
|
5
5
|
//#region src/components/siyahi/siyahi-provider.tsx
|
|
6
6
|
const LocaleContext = createContext(defaultLocale);
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const SurfaceContext = createContext("ruled");
|
|
8
|
+
/** Wrap the app once. Carries the locale, the surface and the tooltip provider shadcn asks for. */
|
|
9
|
+
function SiyahiProvider({ locale, surface, children }) {
|
|
9
10
|
const { numberLocale, currency, timeZone } = {
|
|
10
11
|
...defaultLocale,
|
|
11
12
|
...locale
|
|
@@ -19,13 +20,25 @@ function SiyahiProvider({ locale, children }) {
|
|
|
19
20
|
currency,
|
|
20
21
|
timeZone
|
|
21
22
|
]);
|
|
23
|
+
const around = use(SurfaceContext);
|
|
22
24
|
return /* @__PURE__ */ jsx(LocaleContext, {
|
|
23
25
|
value,
|
|
24
|
-
children: /* @__PURE__ */ jsx(
|
|
26
|
+
children: /* @__PURE__ */ jsx(SurfaceContext, {
|
|
27
|
+
value: surface ?? around,
|
|
28
|
+
children: /* @__PURE__ */ jsx(TooltipProvider, { children: surface ? /* @__PURE__ */ jsx("div", {
|
|
29
|
+
"data-slot": "siyahi-provider",
|
|
30
|
+
"data-surface": surface,
|
|
31
|
+
className: "contents",
|
|
32
|
+
children
|
|
33
|
+
}) : children })
|
|
34
|
+
})
|
|
25
35
|
});
|
|
26
36
|
}
|
|
27
37
|
function useLocale() {
|
|
28
38
|
return use(LocaleContext);
|
|
29
39
|
}
|
|
40
|
+
function useSurface() {
|
|
41
|
+
return use(SurfaceContext);
|
|
42
|
+
}
|
|
30
43
|
//#endregion
|
|
31
|
-
export { SiyahiProvider, useLocale };
|
|
44
|
+
export { SiyahiProvider, useLocale, useSurface };
|
|
@@ -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 { useElementWidth } from "../../lib/use-element-width.js";
|
|
2
|
+
import { useFormat } from "../../lib/format.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 };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/components/siyahi/stale-note.d.ts
|
|
3
|
+
export interface StaleNoteProps {
|
|
4
|
+
/** Since when the data is old, as the page writes it: "3 Nov". */
|
|
5
|
+
since: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
/** A small boxed mono capital note that a block's data is old: "Stale · 3 Nov". The block's own card greys out. */
|
|
8
|
+
export declare function StaleNote({ since }: StaleNoteProps): import("react").JSX.Element;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsxs } from "react/jsx-runtime";
|
|
2
|
+
//#region src/components/siyahi/stale-note.tsx
|
|
3
|
+
/** A small boxed mono capital note that a block's data is old: "Stale · 3 Nov". The block's own card greys out. */
|
|
4
|
+
function StaleNote({ since }) {
|
|
5
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
6
|
+
"data-slot": "stale-note",
|
|
7
|
+
className: "w-fit border px-1.5 py-px font-mono text-2xs tracking-wider whitespace-nowrap text-muted-foreground uppercase",
|
|
8
|
+
children: ["Stale · ", since]
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { StaleNote };
|