@suzumiyaaoba/mdxr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2902 -0
- package/dist/components.d.mts +791 -0
- package/dist/components.mjs +2 -0
- package/dist/config-SI9IyFiC.mjs +184 -0
- package/dist/doc-context-CEqzMYKv.d.mts +568 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +4 -0
- package/dist/ui-GKFD0mx5.mjs +15986 -0
- package/package.json +131 -0
- package/skill/SKILL.md +49 -0
- package/skill/references/components/charts.md +172 -0
- package/skill/references/components/document.md +98 -0
- package/skill/references/components/forms.md +35 -0
- package/skill/references/components/investigation.md +198 -0
- package/skill/references/components/layout.md +68 -0
- package/skill/references/components/output.md +158 -0
- package/skill/references/components/planning.md +155 -0
- package/skill/references/components/reports.md +252 -0
- package/skill/references/components/shadcn.md +19 -0
- package/skill/references/components.md +186 -0
- package/skill/references/extending.md +48 -0
- package/src/ask-sheet.ts +40 -0
- package/src/assets/css.ts +392 -0
- package/src/assets/scripts.ts +78 -0
- package/src/catalog.ts +287 -0
- package/src/cli.ts +178 -0
- package/src/client/doc-events.ts +1108 -0
- package/src/client/entry.ts +27 -0
- package/src/client-js.ts +47 -0
- package/src/component-map.ts +47 -0
- package/src/components/ui/accordion.tsx +77 -0
- package/src/components/ui/alert-dialog.tsx +185 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +208 -0
- package/src/components/ui/avatar.tsx +106 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/breadcrumb.tsx +121 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +88 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +226 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/carousel.tsx +246 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/combobox.tsx +298 -0
- package/src/components/ui/command.tsx +193 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +4 -0
- package/src/components/ui/drawer.tsx +227 -0
- package/src/components/ui/dropdown-menu.tsx +269 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +237 -0
- package/src/components/ui/fieldset.tsx +32 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/hover-card.tsx +50 -0
- package/src/components/ui/input-group.tsx +159 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +202 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +284 -0
- package/src/components/ui/message-scroller.tsx +128 -0
- package/src/components/ui/message.tsx +91 -0
- package/src/components/ui/meter.tsx +80 -0
- package/src/components/ui/native-select.tsx +64 -0
- package/src/components/ui/navigation-menu.tsx +170 -0
- package/src/components/ui/pagination.tsx +133 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/questionnaire.tsx +328 -0
- package/src/components/ui/radio-group.tsx +35 -0
- package/src/components/ui/resizable.tsx +49 -0
- package/src/components/ui/scroll-area.tsx +50 -0
- package/src/components/ui/select.tsx +201 -0
- package/src/components/ui/separator.tsx +22 -0
- package/src/components/ui/sheet.tsx +135 -0
- package/src/components/ui/sidebar.tsx +730 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +51 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +113 -0
- package/src/components/ui/tabs.tsx +82 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toast.tsx +229 -0
- package/src/components/ui/toggle-group.tsx +87 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/components.ts +78 -0
- package/src/config.ts +56 -0
- package/src/define.ts +122 -0
- package/src/doc-context.ts +20 -0
- package/src/editor.ts +94 -0
- package/src/format-error.ts +78 -0
- package/src/guards.ts +84 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/html.ts +91 -0
- package/src/hydrate/export-index.ts +232 -0
- package/src/hydrate/import-scan.ts +169 -0
- package/src/hydrate/plugins.ts +118 -0
- package/src/hydrate/runtime-module.ts +145 -0
- package/src/hydrate-runtime.ts +67 -0
- package/src/hydrate.ts +147 -0
- package/src/index.ts +16 -0
- package/src/init.ts +57 -0
- package/src/langs.ts +148 -0
- package/src/lines.ts +53 -0
- package/src/load-user-module.ts +191 -0
- package/src/mdx.ts +248 -0
- package/src/paths.ts +36 -0
- package/src/rehype/shiki.ts +533 -0
- package/src/remark/alerts.ts +53 -0
- package/src/remark/ast.ts +96 -0
- package/src/remark/callouts.ts +25 -0
- package/src/remark/code-file.ts +85 -0
- package/src/remark/code-meta.ts +21 -0
- package/src/remark/directives.ts +170 -0
- package/src/remark/file-paths.ts +64 -0
- package/src/remark/headings.ts +131 -0
- package/src/remark/no-js.ts +40 -0
- package/src/render.ts +337 -0
- package/src/serve.ts +322 -0
- package/src/styles/globals.css +134 -0
- package/src/styles/shadcn.css +641 -0
- package/src/tailwind.ts +119 -0
- package/src/ui/approvals.tsx +76 -0
- package/src/ui/ask-question.tsx +386 -0
- package/src/ui/ask.tsx +206 -0
- package/src/ui/attrs.ts +51 -0
- package/src/ui/audit.tsx +139 -0
- package/src/ui/bar-chart.tsx +334 -0
- package/src/ui/benchmarks.tsx +143 -0
- package/src/ui/bits.tsx +537 -0
- package/src/ui/board.tsx +173 -0
- package/src/ui/bridge.tsx +207 -0
- package/src/ui/bumps.tsx +178 -0
- package/src/ui/callout.tsx +106 -0
- package/src/ui/changes.tsx +89 -0
- package/src/ui/chart-bits.tsx +52 -0
- package/src/ui/chart.ts +577 -0
- package/src/ui/checks.tsx +203 -0
- package/src/ui/child-index.tsx +44 -0
- package/src/ui/children.ts +43 -0
- package/src/ui/chips.tsx +49 -0
- package/src/ui/cmd.tsx +27 -0
- package/src/ui/columns.tsx +31 -0
- package/src/ui/comments.tsx +544 -0
- package/src/ui/compare.tsx +67 -0
- package/src/ui/decision.tsx +79 -0
- package/src/ui/deps.tsx +78 -0
- package/src/ui/details.tsx +43 -0
- package/src/ui/diff-parse.ts +307 -0
- package/src/ui/diff.tsx +458 -0
- package/src/ui/diffstat.tsx +58 -0
- package/src/ui/due.tsx +65 -0
- package/src/ui/effort.tsx +29 -0
- package/src/ui/endpoints.tsx +118 -0
- package/src/ui/envvars.tsx +96 -0
- package/src/ui/figure.tsx +37 -0
- package/src/ui/file-icon.ts +1020 -0
- package/src/ui/file-link.ts +32 -0
- package/src/ui/file-ref.tsx +39 -0
- package/src/ui/files.tsx +112 -0
- package/src/ui/findings.tsx +85 -0
- package/src/ui/flow.tsx +73 -0
- package/src/ui/funnel.tsx +121 -0
- package/src/ui/gantt.tsx +443 -0
- package/src/ui/gauges.tsx +135 -0
- package/src/ui/glossary.tsx +29 -0
- package/src/ui/graph-layout.ts +149 -0
- package/src/ui/graph-specs.tsx +102 -0
- package/src/ui/graph.tsx +278 -0
- package/src/ui/grid.tsx +119 -0
- package/src/ui/hypothesis.tsx +94 -0
- package/src/ui/icon.tsx +80 -0
- package/src/ui/incident.tsx +130 -0
- package/src/ui/index.ts +342 -0
- package/src/ui/ins-del.tsx +41 -0
- package/src/ui/json.tsx +190 -0
- package/src/ui/layout.ts +9 -0
- package/src/ui/line-chart.tsx +251 -0
- package/src/ui/matrix.tsx +208 -0
- package/src/ui/meta.tsx +73 -0
- package/src/ui/option.tsx +64 -0
- package/src/ui/owner.tsx +40 -0
- package/src/ui/packages.tsx +104 -0
- package/src/ui/pathway.tsx +96 -0
- package/src/ui/phase.tsx +41 -0
- package/src/ui/pie-chart.tsx +170 -0
- package/src/ui/plan.tsx +77 -0
- package/src/ui/pre.tsx +175 -0
- package/src/ui/priority.tsx +51 -0
- package/src/ui/props.tsx +77 -0
- package/src/ui/quadrant.tsx +172 -0
- package/src/ui/radar.tsx +200 -0
- package/src/ui/ref.tsx +160 -0
- package/src/ui/release.tsx +178 -0
- package/src/ui/req.tsx +42 -0
- package/src/ui/review.tsx +133 -0
- package/src/ui/risk.tsx +67 -0
- package/src/ui/sankey.tsx +287 -0
- package/src/ui/scatter.tsx +237 -0
- package/src/ui/schema.tsx +113 -0
- package/src/ui/score.tsx +105 -0
- package/src/ui/search.tsx +120 -0
- package/src/ui/series.tsx +38 -0
- package/src/ui/severity.tsx +69 -0
- package/src/ui/shadcn.tsx +216 -0
- package/src/ui/spark.tsx +86 -0
- package/src/ui/stack.tsx +57 -0
- package/src/ui/stats.tsx +63 -0
- package/src/ui/status-badge.tsx +66 -0
- package/src/ui/statuspage.tsx +238 -0
- package/src/ui/steps.tsx +74 -0
- package/src/ui/summary.tsx +41 -0
- package/src/ui/symbol-ref.tsx +73 -0
- package/src/ui/terminal.tsx +117 -0
- package/src/ui/tests.tsx +218 -0
- package/src/ui/timeline.tsx +63 -0
- package/src/ui/toc.tsx +56 -0
- package/src/ui/tones.ts +187 -0
- package/src/ui/trace.tsx +69 -0
- package/src/ui/tree.tsx +281 -0
- package/src/ui/treemap.tsx +128 -0
- package/src/ui/venn.tsx +258 -0
- package/src/ui/verdict.tsx +78 -0
- package/src/ui/waterfall.tsx +142 -0
package/src/ui/chart.ts
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { numOf } from "./attrs.js";
|
|
2
|
+
import { TONE, TONE_TEXT } from "./tones.js";
|
|
3
|
+
import type { Tone } from "./tones.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared chart machinery — series palette, document-attribute number parsing,
|
|
7
|
+
* "nice" axis scales, and pure SVG/layout geometry for the catalog's
|
|
8
|
+
* quantitative components (BarChart, LineChart, PieChart, Scatter, Radar,
|
|
9
|
+
* Funnel, Quadrant, Bridge, Treemap, Sankey, Venn). All layouts are computed
|
|
10
|
+
* at render time; no client JS is involved.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Tones usable as an explicit `tone=` override on data items. */
|
|
14
|
+
export const CHART_TONES = [
|
|
15
|
+
"sky",
|
|
16
|
+
"emerald",
|
|
17
|
+
"amber",
|
|
18
|
+
"violet",
|
|
19
|
+
"teal",
|
|
20
|
+
"indigo",
|
|
21
|
+
"orange",
|
|
22
|
+
"red",
|
|
23
|
+
] as const satisfies readonly Tone[];
|
|
24
|
+
|
|
25
|
+
export type ChartTone = (typeof CHART_TONES)[number];
|
|
26
|
+
|
|
27
|
+
/** `fill-{c}-500` — solid SVG fill per tone (literal classes for the scanner). */
|
|
28
|
+
const FILL: Record<Tone, string> = {
|
|
29
|
+
amber: "fill-amber-500",
|
|
30
|
+
emerald: "fill-emerald-500",
|
|
31
|
+
indigo: "fill-indigo-500",
|
|
32
|
+
neutral: "fill-neutral-400",
|
|
33
|
+
orange: "fill-orange-500",
|
|
34
|
+
red: "fill-red-500",
|
|
35
|
+
sky: "fill-sky-500",
|
|
36
|
+
teal: "fill-teal-500",
|
|
37
|
+
violet: "fill-violet-500",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** `fill-{c}-500/20` — soft SVG fill for areas, ribbons, venn circles. */
|
|
41
|
+
const SOFT_FILL: Record<Tone, string> = {
|
|
42
|
+
amber: "fill-amber-500/20",
|
|
43
|
+
emerald: "fill-emerald-500/20",
|
|
44
|
+
indigo: "fill-indigo-500/20",
|
|
45
|
+
neutral: "fill-neutral-400/20",
|
|
46
|
+
orange: "fill-orange-500/20",
|
|
47
|
+
red: "fill-red-500/20",
|
|
48
|
+
sky: "fill-sky-500/20",
|
|
49
|
+
teal: "fill-teal-500/20",
|
|
50
|
+
violet: "fill-violet-500/20",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** `stroke-{c}-500` — SVG stroke per tone. */
|
|
54
|
+
const STROKE: Record<Tone, string> = {
|
|
55
|
+
amber: "stroke-amber-500",
|
|
56
|
+
emerald: "stroke-emerald-500",
|
|
57
|
+
indigo: "stroke-indigo-500",
|
|
58
|
+
neutral: "stroke-neutral-400",
|
|
59
|
+
orange: "stroke-orange-500",
|
|
60
|
+
red: "stroke-red-500",
|
|
61
|
+
sky: "stroke-sky-500",
|
|
62
|
+
teal: "stroke-teal-500",
|
|
63
|
+
violet: "stroke-violet-500",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** `bg-{c}-500` — solid HTML fill for bars/dots. */
|
|
67
|
+
const BG: Record<Tone, string> = {
|
|
68
|
+
amber: "bg-amber-500",
|
|
69
|
+
emerald: "bg-emerald-500",
|
|
70
|
+
indigo: "bg-indigo-500",
|
|
71
|
+
neutral: "bg-neutral-400",
|
|
72
|
+
orange: "bg-orange-500",
|
|
73
|
+
red: "bg-red-500",
|
|
74
|
+
sky: "bg-sky-500",
|
|
75
|
+
teal: "bg-teal-500",
|
|
76
|
+
violet: "bg-violet-500",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** `bg-{c}-500/25` — translucent HTML fill for floating range segments. */
|
|
80
|
+
const SOFT_BG: Record<Tone, string> = {
|
|
81
|
+
amber: "bg-amber-500/25",
|
|
82
|
+
emerald: "bg-emerald-500/25",
|
|
83
|
+
indigo: "bg-indigo-500/25",
|
|
84
|
+
neutral: "bg-neutral-400/25",
|
|
85
|
+
orange: "bg-orange-500/25",
|
|
86
|
+
red: "bg-red-500/25",
|
|
87
|
+
sky: "bg-sky-500/25",
|
|
88
|
+
teal: "bg-teal-500/25",
|
|
89
|
+
violet: "bg-violet-500/25",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface SeriesStyle {
|
|
93
|
+
/** Solid HTML background (`bg-{c}-500`) — bars, dots. */
|
|
94
|
+
bg: string;
|
|
95
|
+
/** Solid SVG fill (`fill-{c}-500`). */
|
|
96
|
+
fill: string;
|
|
97
|
+
/** Translucent SVG fill (`fill-{c}-500/20`) — areas, ribbons. */
|
|
98
|
+
soft: string;
|
|
99
|
+
/** Translucent HTML background — range segments. */
|
|
100
|
+
softBg: string;
|
|
101
|
+
/** SVG stroke (`stroke-{c}-500`). */
|
|
102
|
+
stroke: string;
|
|
103
|
+
/** Text accent (`text-{c}-600 dark:text-{c}-400`). */
|
|
104
|
+
text: string;
|
|
105
|
+
/** Toned tile surface (soft bg + readable text) — treemap regions. */
|
|
106
|
+
tile: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const styleOf = (tone: Tone): SeriesStyle => ({
|
|
110
|
+
bg: BG[tone],
|
|
111
|
+
fill: FILL[tone],
|
|
112
|
+
soft: SOFT_FILL[tone],
|
|
113
|
+
softBg: SOFT_BG[tone],
|
|
114
|
+
stroke: STROKE[tone],
|
|
115
|
+
text: TONE_TEXT[tone],
|
|
116
|
+
tile: TONE[tone],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** Cyclic series palette — index `i` mod length picks a series color. */
|
|
120
|
+
export const SERIES: readonly SeriesStyle[] = CHART_TONES.map(styleOf);
|
|
121
|
+
|
|
122
|
+
export const seriesOf = (i: number): SeriesStyle =>
|
|
123
|
+
SERIES[((i % SERIES.length) + SERIES.length) % SERIES.length];
|
|
124
|
+
|
|
125
|
+
/** Style for an explicit `tone` prop; falls back to the cyclic palette. */
|
|
126
|
+
export const toneOf = (tone: string | undefined, i: number): SeriesStyle => {
|
|
127
|
+
const t = CHART_TONES.find((c) => c === tone);
|
|
128
|
+
return t === undefined ? seriesOf(i) : styleOf(t);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Comma/space-separated number list from a document attribute
|
|
133
|
+
* (`values="12, 8, 4"`). Non-numeric entries are dropped.
|
|
134
|
+
*/
|
|
135
|
+
export const numList = (x: unknown): number[] => {
|
|
136
|
+
if (typeof x === "number") {
|
|
137
|
+
return Number.isFinite(x) ? [x] : [];
|
|
138
|
+
}
|
|
139
|
+
if (typeof x !== "string") {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
return x
|
|
143
|
+
.split(/[,\s]+/u)
|
|
144
|
+
.map(numOf)
|
|
145
|
+
.filter((n): n is number => n !== undefined);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Comma-separated label list (`labels="Q1, Q2"`). Whitespace around each
|
|
150
|
+
* label is trimmed; empty entries are dropped.
|
|
151
|
+
*/
|
|
152
|
+
export const textList = (x: unknown): string[] =>
|
|
153
|
+
typeof x === "string"
|
|
154
|
+
? x
|
|
155
|
+
.split(",")
|
|
156
|
+
.map((s) => s.trim())
|
|
157
|
+
.filter((s) => s !== "")
|
|
158
|
+
: [];
|
|
159
|
+
|
|
160
|
+
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
|
161
|
+
|
|
162
|
+
/** Compact tick/label number — `1.2k`, `3M`, plain integers under 1000. */
|
|
163
|
+
export const fmtNum = (n: number): string => {
|
|
164
|
+
if (!Number.isFinite(n)) {
|
|
165
|
+
return "0";
|
|
166
|
+
}
|
|
167
|
+
const abs = Math.abs(n);
|
|
168
|
+
if (abs >= 1_000_000) {
|
|
169
|
+
return `${round2(n / 1_000_000)}M`;
|
|
170
|
+
}
|
|
171
|
+
if (abs >= 10_000) {
|
|
172
|
+
return `${Math.round(n / 1000)}k`;
|
|
173
|
+
}
|
|
174
|
+
if (abs >= 1000) {
|
|
175
|
+
return `${round2(n / 1000)}k`;
|
|
176
|
+
}
|
|
177
|
+
return `${round2(n)}`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const STEPS = [1, 2, 2.5, 5, 10] as const;
|
|
181
|
+
|
|
182
|
+
/** Round a span up to a 1/2/2.5/5×10^k step. */
|
|
183
|
+
const niceStep = (rough: number): number => {
|
|
184
|
+
const mag = 10 ** Math.floor(Math.log10(rough));
|
|
185
|
+
return STEPS.map((m) => m * mag).find((s) => s >= rough - 1e-9) ?? 10 * mag;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export interface AxisScale {
|
|
189
|
+
/** Scale top — `max` rounded up to a tick multiple. */
|
|
190
|
+
ceil: number;
|
|
191
|
+
/** Tick step (a "nice" 1/2/2.5/5×10^k value). */
|
|
192
|
+
step: number;
|
|
193
|
+
/** Tick values including `ceil` (0 excluded — the baseline marks it). */
|
|
194
|
+
ticks: number[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Zero-based "nice" scale covering [0, max]: the top is a round multiple of
|
|
199
|
+
* a 1/2/2.5/5 step and interior ticks land on step boundaries.
|
|
200
|
+
*/
|
|
201
|
+
export const niceScale = (max: number, count = 4): AxisScale => {
|
|
202
|
+
if (!(max > 0)) {
|
|
203
|
+
return { ceil: 1, step: 1, ticks: [1] };
|
|
204
|
+
}
|
|
205
|
+
const step = niceStep(max / count);
|
|
206
|
+
const ceil = Math.ceil(max / step - 1e-9) * step;
|
|
207
|
+
const ticks: number[] = [];
|
|
208
|
+
for (let t = step; t <= ceil + 1e-9; t += step) {
|
|
209
|
+
ticks.push(round2(t));
|
|
210
|
+
}
|
|
211
|
+
return { ceil, step, ticks };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export interface ExtentScale {
|
|
215
|
+
max: number;
|
|
216
|
+
min: number;
|
|
217
|
+
/** Tick values snapped to step boundaries within [min, max]. */
|
|
218
|
+
ticks: number[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* "Nice" scale covering an arbitrary [min, max] extent — bounds snap outward
|
|
223
|
+
* to step boundaries so ticks stay round. A zero-width extent expands ±1.
|
|
224
|
+
*/
|
|
225
|
+
export const niceBounds = (lo: number, hi: number, count = 4): ExtentScale => {
|
|
226
|
+
let min = lo;
|
|
227
|
+
let max = hi;
|
|
228
|
+
if (!(min <= max)) {
|
|
229
|
+
min = 0;
|
|
230
|
+
max = 1;
|
|
231
|
+
}
|
|
232
|
+
if (min === max) {
|
|
233
|
+
min -= 1;
|
|
234
|
+
max += 1;
|
|
235
|
+
}
|
|
236
|
+
const step = niceStep((max - min) / count);
|
|
237
|
+
const lo2 = Math.floor(min / step + 1e-9) * step;
|
|
238
|
+
const hi2 = Math.ceil(max / step - 1e-9) * step;
|
|
239
|
+
const ticks: number[] = [];
|
|
240
|
+
for (let t = lo2; t <= hi2 + 1e-9; t += step) {
|
|
241
|
+
ticks.push(round2(t));
|
|
242
|
+
}
|
|
243
|
+
return { max: hi2, min: lo2, ticks };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Point on a circle — angle in radians measured from +x, y grows downward. */
|
|
247
|
+
export const polar = (
|
|
248
|
+
cx: number,
|
|
249
|
+
cy: number,
|
|
250
|
+
r: number,
|
|
251
|
+
angle: number
|
|
252
|
+
): { x: number; y: number } => ({
|
|
253
|
+
x: cx + r * Math.cos(angle),
|
|
254
|
+
y: cy + r * Math.sin(angle),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const TWO_PI = Math.PI * 2;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* SVG path for a pie (r0 = 0) or annular sector between angles a0→a1
|
|
261
|
+
* (radians, +x = 0, clockwise as y grows). A full turn is split into two
|
|
262
|
+
* half arcs — a single `A` command cannot close on itself.
|
|
263
|
+
*/
|
|
264
|
+
export const sectorPath = (
|
|
265
|
+
cx: number,
|
|
266
|
+
cy: number,
|
|
267
|
+
r0: number,
|
|
268
|
+
r1: number,
|
|
269
|
+
a0: number,
|
|
270
|
+
a1: number
|
|
271
|
+
): string => {
|
|
272
|
+
const span = Math.min(a1 - a0, TWO_PI);
|
|
273
|
+
if (span <= 0 || r1 <= 0) {
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
const segs = span >= TWO_PI - 1e-6 ? 2 : 1;
|
|
277
|
+
const each = span / segs;
|
|
278
|
+
const parts: string[] = [];
|
|
279
|
+
for (let i = 0; i < segs; i += 1) {
|
|
280
|
+
const s = a0 + each * i;
|
|
281
|
+
const e = s + each;
|
|
282
|
+
const p0 = polar(cx, cy, r1, s);
|
|
283
|
+
const p1 = polar(cx, cy, r1, e);
|
|
284
|
+
const large = each > Math.PI ? 1 : 0;
|
|
285
|
+
if (r0 <= 0) {
|
|
286
|
+
parts.push(
|
|
287
|
+
`M${cx} ${cy}L${p0.x} ${p0.y}A${r1} ${r1} 0 ${large} 1 ${p1.x} ${p1.y}Z`
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
const q0 = polar(cx, cy, r0, e);
|
|
291
|
+
const q1 = polar(cx, cy, r0, s);
|
|
292
|
+
parts.push(
|
|
293
|
+
`M${p0.x} ${p0.y}A${r1} ${r1} 0 ${large} 1 ${p1.x} ${p1.y}L${q0.x} ${q0.y}A${r0} ${r0} 0 ${large} 0 ${q1.x} ${q1.y}Z`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return parts.join("");
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** Rounded coordinate for emitted SVG — trims float noise in `d` strings. */
|
|
301
|
+
const c = (n: number): number => Math.round(n * 100) / 100;
|
|
302
|
+
|
|
303
|
+
/** `M x0 y0 L x1 y1 …` polyline path from scaled points. */
|
|
304
|
+
export const linePath = (pts: readonly { x: number; y: number }[]): string =>
|
|
305
|
+
pts.map((p, i) => `${i === 0 ? "M" : "L"}${c(p.x)} ${c(p.y)}`).join("");
|
|
306
|
+
|
|
307
|
+
/** Closed polygon from `linePath` extended to a baseline (area fills). */
|
|
308
|
+
export const areaPath = (
|
|
309
|
+
pts: readonly { x: number; y: number }[],
|
|
310
|
+
baseY: number
|
|
311
|
+
): string => {
|
|
312
|
+
if (pts.length === 0) {
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
const [first] = pts;
|
|
316
|
+
const last = pts.at(-1);
|
|
317
|
+
return `${linePath(pts)}L${c(last?.x ?? 0)} ${c(baseY)}L${c(first?.x ?? 0)} ${c(baseY)}Z`;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export interface Rect {
|
|
321
|
+
h: number;
|
|
322
|
+
w: number;
|
|
323
|
+
x: number;
|
|
324
|
+
y: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Worst aspect ratio in a candidate row — `w` is the length of the side the
|
|
329
|
+
* row is laid along (Bruls et al. §4). Squarify closes a row when adding
|
|
330
|
+
* another item would raise this ratio.
|
|
331
|
+
*/
|
|
332
|
+
const worst = (row: readonly number[], w: number): number => {
|
|
333
|
+
const s = row.reduce((a, b) => a + b, 0);
|
|
334
|
+
if (s <= 0 || w <= 0) {
|
|
335
|
+
return Number.POSITIVE_INFINITY;
|
|
336
|
+
}
|
|
337
|
+
const s2 = s * s;
|
|
338
|
+
const w2 = w * w;
|
|
339
|
+
let m = 0;
|
|
340
|
+
for (const r of row) {
|
|
341
|
+
m = Math.max(m, (w2 * r) / s2, s2 / (w2 * r));
|
|
342
|
+
}
|
|
343
|
+
return m;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Squarified treemap layout (Bruls et al.) — returns one rect per input
|
|
348
|
+
* value in the same order, packed inside `rect`. Non-positive values are
|
|
349
|
+
* dropped (they get a zero rect).
|
|
350
|
+
*/
|
|
351
|
+
export const squarify = (values: readonly number[], rect: Rect): Rect[] => {
|
|
352
|
+
const pending = values
|
|
353
|
+
.map((v, i) => ({ i, v }))
|
|
354
|
+
.filter((e) => e.v > 0)
|
|
355
|
+
.toSorted((a, b) => b.v - a.v);
|
|
356
|
+
const placed = new Map<number, Rect>();
|
|
357
|
+
let rest = { ...rect };
|
|
358
|
+
let remaining = pending.reduce((a, e) => a + e.v, 0);
|
|
359
|
+
let row: { i: number; v: number }[] = [];
|
|
360
|
+
|
|
361
|
+
const closeRow = () => {
|
|
362
|
+
if (row.length === 0 || remaining <= 0) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const sum = row.reduce((a, e) => a + e.v, 0);
|
|
366
|
+
const frac = Math.min(1, sum / remaining);
|
|
367
|
+
// The row strip occupies `frac` of the remaining area along the short
|
|
368
|
+
// side: a vertical strip when wider than tall, horizontal otherwise.
|
|
369
|
+
const wide = rest.w >= rest.h;
|
|
370
|
+
const strip = wide
|
|
371
|
+
? { h: rest.h, w: rest.w * frac, x: rest.x, y: rest.y }
|
|
372
|
+
: { h: rest.h * frac, w: rest.w, x: rest.x, y: rest.y };
|
|
373
|
+
let offset = 0;
|
|
374
|
+
for (const e of row) {
|
|
375
|
+
const share = sum === 0 ? 0 : e.v / sum;
|
|
376
|
+
placed.set(
|
|
377
|
+
e.i,
|
|
378
|
+
wide
|
|
379
|
+
? { h: strip.h * share, w: strip.w, x: strip.x, y: strip.y + offset }
|
|
380
|
+
: { h: strip.h, w: strip.w * share, x: strip.x + offset, y: strip.y }
|
|
381
|
+
);
|
|
382
|
+
offset += wide ? strip.h * share : strip.w * share;
|
|
383
|
+
}
|
|
384
|
+
rest = wide
|
|
385
|
+
? {
|
|
386
|
+
h: rest.h,
|
|
387
|
+
w: rest.w * (1 - frac),
|
|
388
|
+
x: rest.x + strip.w,
|
|
389
|
+
y: rest.y,
|
|
390
|
+
}
|
|
391
|
+
: {
|
|
392
|
+
h: rest.h * (1 - frac),
|
|
393
|
+
w: rest.w,
|
|
394
|
+
x: rest.x,
|
|
395
|
+
y: rest.y + strip.h,
|
|
396
|
+
};
|
|
397
|
+
remaining -= sum;
|
|
398
|
+
row = [];
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
for (const item of pending) {
|
|
402
|
+
const side = Math.min(rest.w, rest.h);
|
|
403
|
+
const withItem = [...row, item];
|
|
404
|
+
if (
|
|
405
|
+
row.length > 0 &&
|
|
406
|
+
worst(
|
|
407
|
+
withItem.map((e) => e.v),
|
|
408
|
+
side
|
|
409
|
+
) >
|
|
410
|
+
worst(
|
|
411
|
+
row.map((e) => e.v),
|
|
412
|
+
side
|
|
413
|
+
)
|
|
414
|
+
) {
|
|
415
|
+
closeRow();
|
|
416
|
+
row = [item];
|
|
417
|
+
} else {
|
|
418
|
+
row = withItem;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
closeRow();
|
|
422
|
+
return values.map((_, i) => placed.get(i) ?? { h: 0, w: 0, x: 0, y: 0 });
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
export interface SankeySpec {
|
|
426
|
+
links: { from: string; to: string; value: number }[];
|
|
427
|
+
nodes: { id: string; stage?: number }[];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface SankeyLayout {
|
|
431
|
+
/** Pixels per unit — ribbon thickness is `value * k`. */
|
|
432
|
+
k: number;
|
|
433
|
+
/** Link endpoints in layout space (y offsets within source/target bars). */
|
|
434
|
+
links: { from: string; sy: number; to: string; ty: number; value: number }[];
|
|
435
|
+
nodes: { h: number; id: string; stage: number; value: number; y: number }[];
|
|
436
|
+
stages: number;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Every node id mentioned by a <Node> spec or a link endpoint. */
|
|
440
|
+
const sankeyIds = (spec: SankeySpec): Set<string> => {
|
|
441
|
+
const ids = new Set<string>();
|
|
442
|
+
for (const n of spec.nodes) {
|
|
443
|
+
ids.add(n.id);
|
|
444
|
+
}
|
|
445
|
+
for (const l of spec.links) {
|
|
446
|
+
ids.add(l.from);
|
|
447
|
+
ids.add(l.to);
|
|
448
|
+
}
|
|
449
|
+
return ids;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/** Longest-path layering: stage = 1 + max stage of any incoming node. */
|
|
453
|
+
const resolveStage = (
|
|
454
|
+
id: string,
|
|
455
|
+
trail: Set<string>,
|
|
456
|
+
incoming: Map<string, string[]>,
|
|
457
|
+
explicit: Map<string, number | undefined>,
|
|
458
|
+
stageOf: Map<string, number>
|
|
459
|
+
): number => {
|
|
460
|
+
const fixed = explicit.get(id);
|
|
461
|
+
if (fixed !== undefined) {
|
|
462
|
+
return fixed;
|
|
463
|
+
}
|
|
464
|
+
const memo = stageOf.get(id);
|
|
465
|
+
if (memo !== undefined) {
|
|
466
|
+
return memo;
|
|
467
|
+
}
|
|
468
|
+
if (trail.has(id)) {
|
|
469
|
+
// Cycle — break at the first repeated node.
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
trail.add(id);
|
|
473
|
+
const deps = incoming.get(id) ?? [];
|
|
474
|
+
const stage =
|
|
475
|
+
deps.length === 0
|
|
476
|
+
? 0
|
|
477
|
+
: Math.max(
|
|
478
|
+
...deps.map((d) =>
|
|
479
|
+
resolveStage(d, trail, incoming, explicit, stageOf)
|
|
480
|
+
)
|
|
481
|
+
) + 1;
|
|
482
|
+
trail.delete(id);
|
|
483
|
+
stageOf.set(id, stage);
|
|
484
|
+
return stage;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/** Node value = max(in-flow, out-flow). */
|
|
488
|
+
const sankeyValues = (links: SankeySpec["links"]): ((id: string) => number) => {
|
|
489
|
+
const flow = new Map<string, { in: number; out: number }>();
|
|
490
|
+
const add = (id: string, key: "in" | "out", v: number): void => {
|
|
491
|
+
const f = flow.get(id) ?? { in: 0, out: 0 };
|
|
492
|
+
f[key] += v;
|
|
493
|
+
flow.set(id, f);
|
|
494
|
+
};
|
|
495
|
+
for (const l of links) {
|
|
496
|
+
add(l.from, "out", l.value);
|
|
497
|
+
add(l.to, "in", l.value);
|
|
498
|
+
}
|
|
499
|
+
return (id) => {
|
|
500
|
+
const f = flow.get(id);
|
|
501
|
+
return f === undefined ? 0 : Math.max(f.in, f.out);
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Sankey layout: nodes are vertical bars stacked per stage column, one
|
|
507
|
+
* px-per-unit scale across the whole diagram (column totals equalize at the
|
|
508
|
+
* tallest column). Stage comes from `node.stage` or longest-path layering
|
|
509
|
+
* from the source nodes. Link `sy`/`ty` are offsets inside each bar where
|
|
510
|
+
* the ribbon attaches.
|
|
511
|
+
*/
|
|
512
|
+
export const layoutSankey = (
|
|
513
|
+
spec: SankeySpec,
|
|
514
|
+
height: number,
|
|
515
|
+
gap: number
|
|
516
|
+
): SankeyLayout => {
|
|
517
|
+
const explicit = new Map(spec.nodes.map((n) => [n.id, n.stage]));
|
|
518
|
+
const stageOf = new Map<string, number>();
|
|
519
|
+
const incoming = new Map<string, string[]>();
|
|
520
|
+
for (const l of spec.links) {
|
|
521
|
+
incoming.set(l.to, [...(incoming.get(l.to) ?? []), l.from]);
|
|
522
|
+
}
|
|
523
|
+
const nodes = [...sankeyIds(spec)].map((id) => ({
|
|
524
|
+
id,
|
|
525
|
+
stage: resolveStage(id, new Set(), incoming, explicit, stageOf),
|
|
526
|
+
}));
|
|
527
|
+
const stages = Math.max(0, ...nodes.map((n) => n.stage)) + 1;
|
|
528
|
+
const valueOf = sankeyValues(spec.links);
|
|
529
|
+
|
|
530
|
+
const columns: string[][] = Array.from({ length: stages }, () => []);
|
|
531
|
+
for (const n of nodes) {
|
|
532
|
+
columns[n.stage]?.push(n.id);
|
|
533
|
+
}
|
|
534
|
+
const maxRows = Math.max(1, ...columns.map((col) => col.length));
|
|
535
|
+
const maxTotal = Math.max(
|
|
536
|
+
0,
|
|
537
|
+
...columns.map((col) => col.reduce((a, id) => a + valueOf(id), 0))
|
|
538
|
+
);
|
|
539
|
+
const k =
|
|
540
|
+
maxTotal <= 0 ? 0 : (height - gap * Math.max(0, maxRows - 1)) / maxTotal;
|
|
541
|
+
|
|
542
|
+
const laid = new Map<string, { h: number; y: number }>();
|
|
543
|
+
for (const col of columns) {
|
|
544
|
+
let y = 0;
|
|
545
|
+
for (const id of col) {
|
|
546
|
+
const h = Math.max(2, valueOf(id) * k);
|
|
547
|
+
laid.set(id, { h, y });
|
|
548
|
+
y += h + gap;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Ribbon attach offsets: links leave a source bar top-down in link order,
|
|
553
|
+
// and arrive at target bars the same way — matching orders keep the
|
|
554
|
+
// crossing count low without a full barycenter pass.
|
|
555
|
+
const sourceUsed = new Map<string, number>();
|
|
556
|
+
const targetUsed = new Map<string, number>();
|
|
557
|
+
const links = spec.links.map((l) => {
|
|
558
|
+
const sOff = sourceUsed.get(l.from) ?? 0;
|
|
559
|
+
const tOff = targetUsed.get(l.to) ?? 0;
|
|
560
|
+
sourceUsed.set(l.from, sOff + l.value * k);
|
|
561
|
+
targetUsed.set(l.to, tOff + l.value * k);
|
|
562
|
+
return { from: l.from, sy: sOff, to: l.to, ty: tOff, value: l.value };
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
k,
|
|
567
|
+
links,
|
|
568
|
+
nodes: nodes.map((n) => ({
|
|
569
|
+
h: laid.get(n.id)?.h ?? 0,
|
|
570
|
+
id: n.id,
|
|
571
|
+
stage: n.stage,
|
|
572
|
+
value: valueOf(n.id),
|
|
573
|
+
y: laid.get(n.id)?.y ?? 0,
|
|
574
|
+
})),
|
|
575
|
+
stages,
|
|
576
|
+
};
|
|
577
|
+
};
|