@karimsa/mdxserve 0.0.0-stage → 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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,875 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { z } from "zod";
3
+ import { Icon } from "../ui/Icon";
4
+ import { IconButton } from "../ui/IconButton";
5
+ import { ExpandModal } from "../ui/ExpandModal";
6
+ import Dropdown from "./Dropdown";
7
+ import {
8
+ binValues,
9
+ countTicks,
10
+ edgeTickIndices,
11
+ fitLabel,
12
+ formatBinEdge,
13
+ labelColumnWidth,
14
+ niceMax,
15
+ toNumber,
16
+ } from "./chart-data";
17
+
18
+ const SERIES_COLORS = [
19
+ "var(--chart-1)",
20
+ "var(--chart-2)",
21
+ "var(--chart-3)",
22
+ "var(--chart-4)",
23
+ "var(--chart-5)",
24
+ "var(--chart-6)",
25
+ ];
26
+
27
+ export interface ChartDatum {
28
+ label: string;
29
+ [series: string]: string | number;
30
+ }
31
+
32
+ // A chart datum is `{ label, <series>: number }` with series keys chosen by
33
+ // the author (`series` below lists which keys to plot) — z.record covers the
34
+ // open-ended numeric keys while `label` stays required and typed.
35
+ const chartDatumSchema = z
36
+ .record(z.string(), z.union([z.string(), z.number()]))
37
+ .and(z.object({ label: z.string() }));
38
+
39
+ export const chartProps = z.object({
40
+ type: z
41
+ .enum(["bar", "line", "area", "histogram"])
42
+ .default("bar")
43
+ .describe("Chart shape. `histogram` bins `values` on the client and ignores `data`/`series`."),
44
+ orientation: z
45
+ .enum(["vertical", "horizontal"])
46
+ .default("vertical")
47
+ .describe(
48
+ "Bar layout direction; ignored for `line`/`area`/`histogram`. Readers can flip it from the chart header for their own view only.",
49
+ ),
50
+ data: z
51
+ .array(chartDatumSchema)
52
+ .default([])
53
+ .describe("One object per x position: `{ label, <series>: number }`."),
54
+ series: z
55
+ .array(z.string())
56
+ .optional()
57
+ .describe('Keys of `data` entries to plot. Defaults to `["value"]`.'),
58
+ values: z
59
+ .array(z.number())
60
+ .default([])
61
+ .describe('Raw numbers to bin for `type="histogram"`; ignored otherwise.'),
62
+ bins: z
63
+ .number()
64
+ .int()
65
+ .positive()
66
+ .optional()
67
+ .describe("Histogram bin count. Defaults to a Sturges estimate, capped at 20."),
68
+ height: z.number().default(220).describe("SVG height in px."),
69
+ filename: z.string().optional().describe("Header label; defaults to the chart type."),
70
+ caption: z.string().optional().describe("Caption shown below the chart on a hairline rule."),
71
+ xLabel: z
72
+ .string()
73
+ .optional()
74
+ .describe(
75
+ "Caption for the horizontal axis, rendered as uppercase mono — the categories on a vertical bar, line or area chart; the values on a horizontal bar chart or a histogram.",
76
+ ),
77
+ yLabel: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ "Caption for the vertical axis, rendered as uppercase mono — the values on a vertical bar, line or area chart; the categories on a horizontal bar chart; the count on a histogram.",
82
+ ),
83
+ unit: z
84
+ .string()
85
+ .optional()
86
+ .describe(
87
+ 'Suffix appended to axis tick and tooltip values, e.g. "ms" or "%". On a histogram it labels the value axis (x); y is a count.',
88
+ ),
89
+ legend: z
90
+ .boolean()
91
+ .default(true)
92
+ .describe("Show the series legend in the header when there's more than one series."),
93
+ });
94
+
95
+ export type ChartProps = z.infer<typeof chartProps>;
96
+ export type Orientation = ChartProps["orientation"];
97
+
98
+ interface PlotProps {
99
+ type: ChartProps["type"];
100
+ data: ChartDatum[];
101
+ /** Series keys to plot, already defaulted. */
102
+ keys: string[];
103
+ unit?: string;
104
+ orientation: Orientation;
105
+ values: number[];
106
+ bins?: number;
107
+ /** Logical (viewBox) size. Inline charts fix the width at 640 and stretch to
108
+ the card; the expanded view passes the measured panel size so labels stay
109
+ at their native pixel size instead of scaling up with the viewBox. */
110
+ width: number;
111
+ height: number;
112
+ /** Roomier padding and type for the full-screen view. */
113
+ expanded?: boolean;
114
+ }
115
+
116
+ /** Padding preset shared by every coordinate system; `HorizontalBars`
117
+ overrides `.left` with a label-driven width. */
118
+ function layout(expanded: boolean): { top: number; right: number; bottom: number; left: number } {
119
+ return expanded
120
+ ? { top: 18, right: 16, bottom: 32, left: 52 }
121
+ : { top: 12, right: 12, bottom: 26, left: 38 };
122
+ }
123
+
124
+ interface Pad {
125
+ top: number;
126
+ right: number;
127
+ bottom: number;
128
+ left: number;
129
+ }
130
+
131
+ /** Gridlines, mono tick labels and the `--chart-axis` baseline for a
132
+ numeric axis — `"y"` (vertical value axis, horizontal gridlines,
133
+ baseline along the bottom) for `VerticalPlot`/`HistogramBars`, `"x"`
134
+ (horizontal value axis, vertical gridlines, baseline on the left) for
135
+ `HorizontalBars`. */
136
+ function ValueGrid({
137
+ axis,
138
+ ticks,
139
+ scale,
140
+ pad,
141
+ plotWidth,
142
+ plotHeight,
143
+ format,
144
+ fontSize,
145
+ }: {
146
+ axis: "x" | "y";
147
+ ticks: number[];
148
+ scale: (tick: number) => number;
149
+ pad: Pad;
150
+ plotWidth: number;
151
+ plotHeight: number;
152
+ format: (value: number) => string;
153
+ fontSize: number;
154
+ }) {
155
+ const innerH = plotHeight - pad.top - pad.bottom;
156
+ const tickClass = "font-mono font-normal leading-[1.62] text-[length:var(--size-sm)]";
157
+ if (axis === "y") {
158
+ return (
159
+ <>
160
+ {ticks.map((tick) => (
161
+ <g key={tick}>
162
+ <line
163
+ x1={pad.left}
164
+ x2={plotWidth - pad.right}
165
+ y1={scale(tick)}
166
+ y2={scale(tick)}
167
+ stroke="var(--chart-grid)"
168
+ strokeWidth="1"
169
+ />
170
+ <text
171
+ x={pad.left - 8}
172
+ y={scale(tick) + 3.5}
173
+ textAnchor="end"
174
+ className={tickClass}
175
+ style={{ fontSize, fill: "var(--chart-label)" }}
176
+ >
177
+ {format(tick)}
178
+ </text>
179
+ </g>
180
+ ))}
181
+ <line
182
+ x1={pad.left}
183
+ x2={plotWidth - pad.right}
184
+ y1={pad.top + innerH}
185
+ y2={pad.top + innerH}
186
+ stroke="var(--chart-axis)"
187
+ strokeWidth="1"
188
+ />
189
+ </>
190
+ );
191
+ }
192
+ return (
193
+ <>
194
+ {ticks.map((tick) => (
195
+ <g key={tick}>
196
+ <line
197
+ x1={scale(tick)}
198
+ x2={scale(tick)}
199
+ y1={pad.top}
200
+ y2={pad.top + innerH}
201
+ stroke="var(--chart-grid)"
202
+ strokeWidth="1"
203
+ />
204
+ <text
205
+ x={scale(tick)}
206
+ y={plotHeight - 8}
207
+ textAnchor="middle"
208
+ className={tickClass}
209
+ style={{ fontSize, fill: "var(--chart-label)" }}
210
+ >
211
+ {format(tick)}
212
+ </text>
213
+ </g>
214
+ ))}
215
+ <line
216
+ x1={pad.left}
217
+ x2={pad.left}
218
+ y1={pad.top}
219
+ y2={pad.top + innerH}
220
+ stroke="var(--chart-axis)"
221
+ strokeWidth="1"
222
+ />
223
+ </>
224
+ );
225
+ }
226
+
227
+ /** The hover value readout: bold mono text over the plot area. */
228
+ function HoverReadout({
229
+ x,
230
+ y,
231
+ textAnchor = "middle",
232
+ fontSize,
233
+ text,
234
+ }: {
235
+ x: number;
236
+ y: number;
237
+ textAnchor?: "start" | "middle" | "end";
238
+ fontSize: number;
239
+ text: string;
240
+ }) {
241
+ return (
242
+ <text
243
+ x={x}
244
+ y={y}
245
+ textAnchor={textAnchor}
246
+ className="font-mono leading-[1.62] text-[length:var(--size-sm)] font-semibold"
247
+ style={{ fontSize, fill: "var(--text-heading)" }}
248
+ >
249
+ {text}
250
+ </text>
251
+ );
252
+ }
253
+
254
+ interface PlotBodyProps {
255
+ unit?: string;
256
+ width: number;
257
+ height: number;
258
+ expanded: boolean;
259
+ fontSize: number;
260
+ }
261
+
262
+ /** Vertical bar / line / area — today's `ChartPlot` body, unchanged apart
263
+ from `toNumber` and `countTicks`. */
264
+ function VerticalPlot({
265
+ type,
266
+ data,
267
+ keys,
268
+ unit,
269
+ width: plotWidth,
270
+ height: plotHeight,
271
+ expanded,
272
+ fontSize,
273
+ }: PlotBodyProps & { type: "bar" | "line" | "area"; data: ChartDatum[]; keys: string[] }) {
274
+ const [hover, setHover] = useState(-1);
275
+ const fmt = (value: number) => `${value}${unit ?? ""}`;
276
+ const pad = layout(expanded);
277
+ const innerW = plotWidth - pad.left - pad.right;
278
+ const innerH = plotHeight - pad.top - pad.bottom;
279
+ const max = niceMax(
280
+ Math.max(1, ...data.flatMap((datum) => keys.map((key) => toNumber(datum[key])))),
281
+ );
282
+ const ticks = countTicks(max);
283
+ const xAt = (index: number) => pad.left + (innerW / Math.max(1, data.length)) * (index + 0.5);
284
+ const yAt = (value: number) => pad.top + innerH - (value / max) * innerH;
285
+ const bandW = innerW / Math.max(1, data.length);
286
+ const barW = Math.min(expanded ? 72 : 34, (bandW * 0.62) / keys.length);
287
+
288
+ const linePath = (key: string) =>
289
+ data
290
+ .map(
291
+ (datum, index) =>
292
+ (index ? "L" : "M") + xAt(index).toFixed(1) + " " + yAt(toNumber(datum[key])).toFixed(1),
293
+ )
294
+ .join(" ");
295
+ const areaPath = (key: string) =>
296
+ linePath(key) +
297
+ " L" +
298
+ xAt(data.length - 1).toFixed(1) +
299
+ " " +
300
+ (pad.top + innerH) +
301
+ " L" +
302
+ xAt(0).toFixed(1) +
303
+ " " +
304
+ (pad.top + innerH) +
305
+ " Z";
306
+
307
+ return (
308
+ <>
309
+ <ValueGrid
310
+ axis="y"
311
+ ticks={ticks}
312
+ scale={yAt}
313
+ pad={pad}
314
+ plotWidth={plotWidth}
315
+ plotHeight={plotHeight}
316
+ format={fmt}
317
+ fontSize={fontSize}
318
+ />
319
+ {type === "area"
320
+ ? keys.map((key, seriesIndex) => (
321
+ <path
322
+ key={`a${key}`}
323
+ d={areaPath(key)}
324
+ fill={SERIES_COLORS[seriesIndex % 6]}
325
+ opacity="0.14"
326
+ />
327
+ ))
328
+ : null}
329
+ {type === "line" || type === "area"
330
+ ? keys.map((key, seriesIndex) => (
331
+ <g key={`l${key}`}>
332
+ <path
333
+ d={linePath(key)}
334
+ fill="none"
335
+ stroke={SERIES_COLORS[seriesIndex % 6]}
336
+ strokeWidth="2"
337
+ strokeLinecap="round"
338
+ strokeLinejoin="round"
339
+ />
340
+ {data.map((datum, index) => (
341
+ <circle
342
+ key={index}
343
+ cx={xAt(index)}
344
+ cy={yAt(toNumber(datum[key]))}
345
+ r={hover === index ? 4.5 : 3}
346
+ fill="var(--surface-card)"
347
+ stroke={SERIES_COLORS[seriesIndex % 6]}
348
+ strokeWidth="2"
349
+ />
350
+ ))}
351
+ </g>
352
+ ))
353
+ : null}
354
+ {type === "bar"
355
+ ? data.map((datum, index) => (
356
+ <g key={index}>
357
+ <rect
358
+ x={pad.left + bandW * index}
359
+ y={pad.top}
360
+ width={bandW}
361
+ height={innerH}
362
+ fill={hover === index ? "var(--surface-hover)" : "transparent"}
363
+ />
364
+ {keys.map((key, seriesIndex) => {
365
+ const barX = xAt(index) - (barW * keys.length) / 2 + barW * seriesIndex;
366
+ const barH = Math.max(1, (toNumber(datum[key]) / max) * innerH);
367
+ return (
368
+ <rect
369
+ key={key}
370
+ x={barX}
371
+ y={pad.top + innerH - barH}
372
+ width={barW - 2}
373
+ height={barH}
374
+ rx="3"
375
+ fill={SERIES_COLORS[seriesIndex % 6]}
376
+ opacity={hover === -1 || hover === index ? 1 : 0.45}
377
+ />
378
+ );
379
+ })}
380
+ </g>
381
+ ))
382
+ : null}
383
+ {data.map((datum, index) => (
384
+ <text
385
+ key={`x${index}`}
386
+ x={xAt(index)}
387
+ y={plotHeight - 8}
388
+ textAnchor="middle"
389
+ className="font-sans font-medium leading-normal text-[length:var(--size-sm)]"
390
+ style={{ fontSize, fill: "var(--chart-label)" }}
391
+ >
392
+ {datum.label}
393
+ </text>
394
+ ))}
395
+ {hover >= 0 && data[hover] ? (
396
+ <HoverReadout
397
+ x={xAt(hover)}
398
+ y={pad.top - 2}
399
+ fontSize={fontSize}
400
+ text={keys.map((key) => fmt(toNumber(data[hover][key]))).join(" · ")}
401
+ />
402
+ ) : null}
403
+ {/* One invisible hit column per x position, on top of every series, so
404
+ hover works the same for bars, lines and areas. */}
405
+ {data.map((_datum, index) => (
406
+ <rect
407
+ key={`hit${index}`}
408
+ x={pad.left + bandW * index}
409
+ y={pad.top}
410
+ width={bandW}
411
+ height={innerH}
412
+ fill="transparent"
413
+ onMouseEnter={() => setHover(index)}
414
+ onMouseLeave={() => setHover(-1)}
415
+ />
416
+ ))}
417
+ </>
418
+ );
419
+ }
420
+
421
+ /** Horizontal bars — the mirror image of `VerticalPlot`: the value axis
422
+ runs along x, categories stack along y, and the label column on the
423
+ left is sized to the longest category label. */
424
+ function HorizontalBars({
425
+ data,
426
+ keys,
427
+ unit,
428
+ width: plotWidth,
429
+ height: plotHeight,
430
+ expanded,
431
+ fontSize,
432
+ }: PlotBodyProps & { data: ChartDatum[]; keys: string[] }) {
433
+ const [hover, setHover] = useState(-1);
434
+ const fmt = (value: number) => `${value}${unit ?? ""}`;
435
+ const labels = data.map((datum) => datum.label);
436
+ const pad: Pad = {
437
+ ...layout(expanded),
438
+ left: labelColumnWidth(labels, {
439
+ fontSize,
440
+ min: expanded ? 52 : 38,
441
+ max: Math.min(expanded ? 260 : 180, plotWidth * 0.4),
442
+ }),
443
+ };
444
+ const innerW = plotWidth - pad.left - pad.right;
445
+ const innerH = plotHeight - pad.top - pad.bottom;
446
+ const max = niceMax(
447
+ Math.max(1, ...data.flatMap((datum) => keys.map((key) => toNumber(datum[key])))),
448
+ );
449
+ const ticks = countTicks(max);
450
+ const yAt = (index: number) => pad.top + (innerH / Math.max(1, data.length)) * (index + 0.5);
451
+ const bandH = innerH / Math.max(1, data.length);
452
+ const barH = Math.min(expanded ? 72 : 34, (bandH * 0.62) / keys.length);
453
+
454
+ return (
455
+ <>
456
+ <ValueGrid
457
+ axis="x"
458
+ ticks={ticks}
459
+ scale={(tick) => pad.left + (tick / max) * innerW}
460
+ pad={pad}
461
+ plotWidth={plotWidth}
462
+ plotHeight={plotHeight}
463
+ format={fmt}
464
+ fontSize={fontSize}
465
+ />
466
+ {data.map((datum, index) => (
467
+ <g key={index}>
468
+ <rect
469
+ x={pad.left}
470
+ y={pad.top + bandH * index}
471
+ width={innerW}
472
+ height={bandH}
473
+ fill={hover === index ? "var(--surface-hover)" : "transparent"}
474
+ />
475
+ {keys.map((key, seriesIndex) => {
476
+ const barY = yAt(index) - (barH * keys.length) / 2 + barH * seriesIndex;
477
+ const barLength = Math.max(1, (toNumber(datum[key]) / max) * innerW);
478
+ return (
479
+ <rect
480
+ key={key}
481
+ x={pad.left}
482
+ y={barY}
483
+ width={barLength}
484
+ height={barH - 2}
485
+ rx="3"
486
+ fill={SERIES_COLORS[seriesIndex % 6]}
487
+ opacity={hover === -1 || hover === index ? 1 : 0.45}
488
+ />
489
+ );
490
+ })}
491
+ <text
492
+ x={pad.left - 8}
493
+ y={yAt(index) + 3.5}
494
+ textAnchor="end"
495
+ className="font-sans font-medium leading-normal text-[length:var(--size-sm)]"
496
+ style={{ fontSize, fill: "var(--chart-label)" }}
497
+ >
498
+ {fitLabel(datum.label, pad.left - 8, fontSize)}
499
+ </text>
500
+ </g>
501
+ ))}
502
+ {hover >= 0 && data[hover] ? (
503
+ <HoverReadout
504
+ x={plotWidth - 4}
505
+ y={pad.top - 4}
506
+ textAnchor="end"
507
+ fontSize={fontSize}
508
+ text={keys.map((key) => fmt(toNumber(data[hover][key]))).join(" · ")}
509
+ />
510
+ ) : null}
511
+ {data.map((_datum, index) => (
512
+ <rect
513
+ key={`hit${index}`}
514
+ x={pad.left}
515
+ y={pad.top + bandH * index}
516
+ width={innerW}
517
+ height={bandH}
518
+ fill="transparent"
519
+ onMouseEnter={() => setHover(index)}
520
+ onMouseLeave={() => setHover(-1)}
521
+ />
522
+ ))}
523
+ </>
524
+ );
525
+ }
526
+
527
+ /** A histogram over raw `values`, binned on the client. Bars sit flush
528
+ against each other (a 2px seam, no band padding); the x axis labels bin
529
+ edges instead of categories. */
530
+ function HistogramBars({
531
+ values,
532
+ bins,
533
+ unit,
534
+ width: plotWidth,
535
+ height: plotHeight,
536
+ expanded,
537
+ fontSize,
538
+ }: PlotBodyProps & { values: number[]; bins?: number }) {
539
+ const [hover, setHover] = useState(-1);
540
+ const binList = useMemo(() => binValues(values, bins), [values, bins]);
541
+ const pad = layout(expanded);
542
+ const innerW = plotWidth - pad.left - pad.right;
543
+ const innerH = plotHeight - pad.top - pad.bottom;
544
+ const max = niceMax(Math.max(1, ...binList.map((bin) => bin.value)));
545
+ const ticks = countTicks(max);
546
+ const binW = innerW / Math.max(1, binList.length);
547
+ const edgeIndices = edgeTickIndices(binList.length + 1, expanded ? 12 : 8);
548
+ const edgeStep = binList[0] ? binList[0].end - binList[0].start : 1;
549
+ const edgeValue = (index: number) =>
550
+ index < binList.length ? binList[index]!.start : (binList[binList.length - 1]?.end ?? 0);
551
+
552
+ return (
553
+ <>
554
+ <ValueGrid
555
+ axis="y"
556
+ ticks={ticks}
557
+ scale={(tick) => pad.top + innerH - (tick / max) * innerH}
558
+ pad={pad}
559
+ plotWidth={plotWidth}
560
+ plotHeight={plotHeight}
561
+ format={(value) => `${value}`}
562
+ fontSize={fontSize}
563
+ />
564
+ {binList.map((bin, index) => {
565
+ const barHeight = bin.value > 0 ? Math.max(1, (bin.value / max) * innerH) : 0;
566
+ return (
567
+ <rect
568
+ key={index}
569
+ x={pad.left + binW * index}
570
+ y={pad.top + innerH - barHeight}
571
+ width={binW - 2}
572
+ height={barHeight}
573
+ fill="var(--chart-1)"
574
+ opacity={hover === -1 || hover === index ? 1 : 0.45}
575
+ />
576
+ );
577
+ })}
578
+ {binList.length > 0
579
+ ? edgeIndices.map((index) => (
580
+ <text
581
+ key={index}
582
+ x={pad.left + binW * index}
583
+ y={plotHeight - 8}
584
+ textAnchor={index === 0 ? "start" : index === binList.length ? "end" : "middle"}
585
+ className="font-mono font-normal leading-[1.62] text-[length:var(--size-sm)]"
586
+ style={{ fontSize, fill: "var(--chart-label)" }}
587
+ >
588
+ {formatBinEdge(edgeValue(index), edgeStep)}
589
+ {unit ?? ""}
590
+ </text>
591
+ ))
592
+ : null}
593
+ {hover >= 0 && binList[hover] ? (
594
+ <HoverReadout
595
+ x={clampToPlot(pad.left + binW * (hover + 0.5), pad, plotWidth)}
596
+ y={pad.top - 2}
597
+ fontSize={fontSize}
598
+ text={`${binList[hover]!.label}${unit ?? ""} · ${binList[hover]!.value}`}
599
+ />
600
+ ) : null}
601
+ {binList.map((_bin, index) => (
602
+ <rect
603
+ key={`hit${index}`}
604
+ x={pad.left + binW * index}
605
+ y={pad.top}
606
+ width={binW}
607
+ height={innerH}
608
+ fill="transparent"
609
+ onMouseEnter={() => setHover(index)}
610
+ onMouseLeave={() => setHover(-1)}
611
+ />
612
+ ))}
613
+ </>
614
+ );
615
+ }
616
+
617
+ /** Keeps the histogram hover readout inside the plot area so a bin near
618
+ either edge can't push the text past the card's border. */
619
+ function clampToPlot(x: number, pad: Pad, plotWidth: number): number {
620
+ return Math.min(plotWidth - pad.right - 4, Math.max(pad.left + 4, x));
621
+ }
622
+
623
+ /** The frame: one `<svg>` shared by every chart shape, sized by `layout()`
624
+ and filled by whichever coordinate system `type`/`orientation` selects. */
625
+ function ChartPlot({
626
+ type,
627
+ data,
628
+ keys,
629
+ unit,
630
+ orientation,
631
+ values,
632
+ bins,
633
+ width: plotWidth,
634
+ height: plotHeight,
635
+ expanded = false,
636
+ }: PlotProps) {
637
+ const fontSize = expanded ? 12 : 10;
638
+ const body = { unit, width: plotWidth, height: plotHeight, expanded, fontSize };
639
+
640
+ return (
641
+ <svg
642
+ viewBox={`0 0 ${plotWidth} ${plotHeight}`}
643
+ width={expanded ? plotWidth : "100%"}
644
+ height={plotHeight}
645
+ role="img"
646
+ className="block overflow-visible"
647
+ >
648
+ {type === "histogram" ? (
649
+ <HistogramBars {...body} values={values} bins={bins} />
650
+ ) : type === "bar" && orientation === "horizontal" ? (
651
+ <HorizontalBars {...body} data={data} keys={keys} />
652
+ ) : (
653
+ <VerticalPlot {...body} type={type} data={data} keys={keys} />
654
+ )}
655
+ </svg>
656
+ );
657
+ }
658
+
659
+ function Legend({ keys }: { keys: string[] }) {
660
+ return (
661
+ <span className="flex gap-3">
662
+ {keys.map((key, index) => (
663
+ <span
664
+ key={key}
665
+ className="inline-flex items-center gap-1.5 font-sans font-medium leading-normal text-[length:var(--size-xs)] text-text-muted"
666
+ >
667
+ <span
668
+ className="h-2 w-2 rounded-[2px]"
669
+ style={{ background: SERIES_COLORS[index % 6] }}
670
+ />
671
+ {key}
672
+ </span>
673
+ ))}
674
+ </span>
675
+ );
676
+ }
677
+
678
+ function AxisLabels({ xLabel, yLabel }: { xLabel?: string; yLabel?: string }) {
679
+ if (!xLabel && !yLabel) return null;
680
+ return (
681
+ <div className="mt-2 flex justify-between font-mono font-semibold leading-[1.2] text-[length:var(--size-2xs)] uppercase tracking-[var(--tracking-caps)] text-text-subtle">
682
+ <span>{yLabel}</span>
683
+ <span>{xLabel}</span>
684
+ </div>
685
+ );
686
+ }
687
+
688
+ const ORIENTATION_ICON: Record<Orientation, string> = {
689
+ vertical: "chart-column",
690
+ horizontal: "chart-bar",
691
+ };
692
+
693
+ const ORIENTATION_LABEL: Record<Orientation, string> = {
694
+ vertical: "Vertical bars",
695
+ horizontal: "Horizontal bars",
696
+ };
697
+
698
+ /**
699
+ * Flips a bar chart's orientation for the reader's own view, the same way
700
+ * `DirectionMenu` (`CodeBlock.tsx`) flips a flowchart's layout — the doc's
701
+ * `orientation` prop, and anything exported or copied, stays as written.
702
+ */
703
+ function OrientationMenu({
704
+ value,
705
+ authored,
706
+ onChange,
707
+ }: {
708
+ value: Orientation;
709
+ authored: Orientation;
710
+ onChange: (orientation: Orientation) => void;
711
+ }) {
712
+ return (
713
+ <Dropdown
714
+ icon={ORIENTATION_ICON[value]}
715
+ size="sm"
716
+ label="Orientation"
717
+ value={value}
718
+ onChange={(next) => onChange(next as Orientation)}
719
+ options={(["vertical", "horizontal"] as const).map((orientation) => ({
720
+ value: orientation,
721
+ label: ORIENTATION_LABEL[orientation],
722
+ description: orientation === authored ? "As written" : undefined,
723
+ }))}
724
+ />
725
+ );
726
+ }
727
+
728
+ /**
729
+ * The modal body: measures the space it's given and redraws the chart at that
730
+ * size, so the expanded view gains plot area rather than magnified type.
731
+ */
732
+ function ExpandedChart({
733
+ plot,
734
+ keys,
735
+ showLegend,
736
+ xLabel,
737
+ yLabel,
738
+ caption,
739
+ }: {
740
+ plot: Omit<PlotProps, "width" | "height" | "expanded">;
741
+ keys: string[];
742
+ showLegend: boolean;
743
+ xLabel?: string;
744
+ yLabel?: string;
745
+ caption?: string;
746
+ }) {
747
+ const areaRef = useRef<HTMLDivElement>(null);
748
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
749
+
750
+ useEffect(() => {
751
+ const area = areaRef.current;
752
+ if (!area) return;
753
+ const observer = new ResizeObserver((entries) => {
754
+ const entry = entries[0];
755
+ if (!entry) return;
756
+ const { width, height } = entry.contentRect;
757
+ setSize({ width: Math.floor(width), height: Math.floor(height) });
758
+ });
759
+ observer.observe(area);
760
+ return () => observer.disconnect();
761
+ }, []);
762
+
763
+ return (
764
+ <div className="flex h-full flex-col bg-surface-card">
765
+ {showLegend ? (
766
+ <div className="flex shrink-0 justify-end px-6 pt-4">
767
+ <Legend keys={keys} />
768
+ </div>
769
+ ) : null}
770
+ <div className="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-5">
771
+ <div ref={areaRef} className="min-h-0 flex-1">
772
+ {size && size.width > 0 && size.height > 0 ? (
773
+ <ChartPlot {...plot} width={size.width} height={size.height} expanded />
774
+ ) : null}
775
+ </div>
776
+ <AxisLabels xLabel={xLabel} yLabel={yLabel} />
777
+ </div>
778
+ {caption ? (
779
+ <div className="shrink-0 border-t border-border-subtle px-6 py-2.5 font-sans font-medium leading-normal text-[length:var(--size-xs)] text-text-subtle">
780
+ {caption}
781
+ </div>
782
+ ) : null}
783
+ </div>
784
+ );
785
+ }
786
+
787
+ export default function Chart({
788
+ type = "bar",
789
+ orientation = "vertical",
790
+ data = [],
791
+ series,
792
+ values = [],
793
+ bins,
794
+ height = 220,
795
+ filename,
796
+ caption,
797
+ xLabel,
798
+ yLabel,
799
+ unit,
800
+ legend = true,
801
+ }: ChartProps) {
802
+ const keys = series && series.length ? series : ["value"];
803
+ const [expanded, setExpanded] = useState(false);
804
+ const [chosenOrientation, setChosenOrientation] = useState<{
805
+ authored: Orientation;
806
+ value: Orientation;
807
+ } | null>(null);
808
+ const shownOrientation =
809
+ chosenOrientation !== null && chosenOrientation.authored === orientation
810
+ ? chosenOrientation.value
811
+ : orientation;
812
+ const isHorizontalBar = type === "bar" && shownOrientation === "horizontal";
813
+ const showLegend = legend && keys.length > 1 && type !== "histogram";
814
+ const plot = { type, data, keys, unit, orientation: shownOrientation, values, bins };
815
+ const orientationMenu =
816
+ type === "bar" ? (
817
+ <OrientationMenu
818
+ value={shownOrientation}
819
+ authored={orientation}
820
+ onChange={(next) => setChosenOrientation({ authored: orientation, value: next })}
821
+ />
822
+ ) : null;
823
+ const hint =
824
+ type === "histogram"
825
+ ? "Hover a bin for its range and count"
826
+ : isHorizontalBar
827
+ ? "Hover a row for values"
828
+ : "Hover a column for values";
829
+
830
+ return (
831
+ <figure className="not-prose overflow-hidden rounded-lg border border-border-default bg-surface-card">
832
+ <div className="flex h-[38px] items-center gap-3 border-b border-border-subtle pr-2.5 pl-4">
833
+ <Icon name="chart-column" size={13} className="text-text-subtle" />
834
+ <span className="flex-1 font-mono font-normal leading-[1.62] text-[length:var(--size-xs)] text-text-subtle">
835
+ {filename || type}
836
+ </span>
837
+ {showLegend ? <Legend keys={keys} /> : null}
838
+ {orientationMenu}
839
+ <IconButton
840
+ icon="expand"
841
+ label="Expand chart"
842
+ size="sm"
843
+ className="-mr-2"
844
+ onClick={() => setExpanded(true)}
845
+ />
846
+ </div>
847
+ <div className="p-4">
848
+ <ChartPlot {...plot} width={640} height={height} />
849
+ <AxisLabels xLabel={xLabel} yLabel={yLabel} />
850
+ </div>
851
+ {caption ? (
852
+ <figcaption className="border-t border-border-subtle px-4 py-2.5 font-sans font-medium leading-normal text-[length:var(--size-xs)] text-text-subtle">
853
+ {caption}
854
+ </figcaption>
855
+ ) : null}
856
+ <ExpandModal
857
+ open={expanded}
858
+ onClose={() => setExpanded(false)}
859
+ icon="chart-column"
860
+ title={filename || "Chart"}
861
+ hint={hint}
862
+ actions={orientationMenu}
863
+ >
864
+ <ExpandedChart
865
+ plot={plot}
866
+ keys={keys}
867
+ showLegend={showLegend}
868
+ xLabel={xLabel}
869
+ yLabel={yLabel}
870
+ caption={caption}
871
+ />
872
+ </ExpandModal>
873
+ </figure>
874
+ );
875
+ }