@niekes/charts-3d 0.0.0-stage → 0.3.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/dist/index.js ADDED
@@ -0,0 +1,1251 @@
1
+ "use client"
2
+ import * as React from "react";
3
+ import { extent, nice, ticks } from "d3-array";
4
+ import { axes3D, box, camera3D, cubes3D, gridPlanes3D, normal, points3D, sort } from "d3-3d";
5
+ import { KeyboardIcon } from "lucide-react";
6
+ import { clsx } from "clsx";
7
+ import { twMerge } from "tailwind-merge";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
+ // lib/utils.ts
10
+ function cn(...inputs) {
11
+ return twMerge(clsx(inputs));
12
+ }
13
+ // registry/new-york/ui/chart-3d.tsx
14
+ const THEMES = {
15
+ light: "",
16
+ dark: ".dark"
17
+ };
18
+ const Chart3DContext = React.createContext(null);
19
+ function useChart3D() {
20
+ const context = React.useContext(Chart3DContext);
21
+ if (!context) throw new Error("useChart3D must be used within a <ChartContainer3D />");
22
+ return context;
23
+ }
24
+ const DRAG = Math.PI / 230;
25
+ const KEY_STEP = Math.PI / 36;
26
+ const PAN_STEP = 20;
27
+ const ZOOM_STEP = 1.2;
28
+ const CLICK_DISTANCE = 4;
29
+ const SHORTCUTS = [
30
+ {
31
+ hint: ["Drag"],
32
+ label: "Rotate"
33
+ },
34
+ {
35
+ hint: [
36
+ "←",
37
+ "→",
38
+ "↑",
39
+ "↓"
40
+ ],
41
+ label: "Rotate",
42
+ keys: {
43
+ ArrowLeft: { yaw: -KEY_STEP },
44
+ ArrowRight: { yaw: KEY_STEP },
45
+ ArrowUp: { tilt: -KEY_STEP },
46
+ ArrowDown: { tilt: KEY_STEP }
47
+ }
48
+ },
49
+ {
50
+ hint: ["Space", "Drag"],
51
+ label: "Pan"
52
+ },
53
+ {
54
+ hint: [
55
+ "Shift",
56
+ "←",
57
+ "→",
58
+ "↑",
59
+ "↓"
60
+ ],
61
+ label: "Pan",
62
+ keys: {
63
+ "Shift+ArrowLeft": { pan: {
64
+ x: -20,
65
+ y: 0
66
+ } },
67
+ "Shift+ArrowRight": { pan: {
68
+ x: PAN_STEP,
69
+ y: 0
70
+ } },
71
+ "Shift+ArrowUp": { pan: {
72
+ x: 0,
73
+ y: -20
74
+ } },
75
+ "Shift+ArrowDown": { pan: {
76
+ x: 0,
77
+ y: PAN_STEP
78
+ } }
79
+ }
80
+ },
81
+ {
82
+ hint: ["+", "−"],
83
+ label: "Zoom",
84
+ keys: {
85
+ "+": { zoom: ZOOM_STEP },
86
+ "=": { zoom: ZOOM_STEP },
87
+ "-": { zoom: 1 / ZOOM_STEP }
88
+ }
89
+ },
90
+ {
91
+ hint: ["Ctrl", "Scroll"],
92
+ label: "Zoom"
93
+ },
94
+ {
95
+ hint: ["0"],
96
+ label: "Reset",
97
+ keys: { "0": { reset: true } }
98
+ },
99
+ {
100
+ hint: ["Double-click"],
101
+ label: "Reset"
102
+ },
103
+ {
104
+ hint: ["?"],
105
+ label: "Shortcuts"
106
+ }
107
+ ];
108
+ const KEYS = Object.assign({}, ...SHORTCUTS.map((s) => s.keys));
109
+ const KEY_SHORTCUTS = Object.keys(KEYS).map((k) => k === "+" ? "Plus" : k).join(" ");
110
+ function ChartContainer3D({ id, config, defaultView = {
111
+ yaw: Math.PI / 6,
112
+ tilt: Math.PI / 6
113
+ }, width = 640, height = 400, legend = false, renderer: requested = "svg", shortcuts = true, className, children, "aria-label": ariaLabel = "3D chart. Drag to rotate it, press ? for all shortcuts.", ...props }) {
114
+ const uniqueId = React.useId();
115
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
116
+ const [tooltip, setTooltip] = React.useState(null);
117
+ const { scene, renderer, canvas, repaint, hover, leave } = useScene(requested, width, height, setTooltip);
118
+ const { view, orbiting, handlers } = useOrbit(defaultView, width, leave, hover);
119
+ const camera = useCamera(view, width, height);
120
+ const context = React.useMemo(() => ({
121
+ config,
122
+ camera,
123
+ renderer,
124
+ scene,
125
+ repaint
126
+ }), [
127
+ config,
128
+ camera,
129
+ renderer,
130
+ repaint
131
+ ]);
132
+ return /* @__PURE__ */ jsxs("div", {
133
+ "data-slot": "chart-3d",
134
+ "data-chart": chartId,
135
+ className: cn("relative w-full text-xs", className),
136
+ ...props,
137
+ children: [
138
+ /* @__PURE__ */ jsx(ChartStyle, {
139
+ id: chartId,
140
+ config
141
+ }),
142
+ /* @__PURE__ */ jsxs("div", {
143
+ className: "relative rounded-lg has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50",
144
+ children: [
145
+ /* @__PURE__ */ jsx("svg", {
146
+ viewBox: `0 0 ${width} ${height}`,
147
+ role: "img",
148
+ "aria-label": ariaLabel,
149
+ "aria-keyshortcuts": shortcuts ? `${KEY_SHORTCUTS} ?` : KEY_SHORTCUTS,
150
+ tabIndex: 0,
151
+ className: "block h-auto w-full cursor-grab touch-none outline-hidden select-none mask-y-from-96% active:cursor-grabbing",
152
+ "data-orbiting": orbiting,
153
+ onPointerLeave: leave,
154
+ ...handlers,
155
+ children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
156
+ value: context,
157
+ children
158
+ })
159
+ }),
160
+ /* @__PURE__ */ jsx(ChartCanvas3D, {
161
+ renderer,
162
+ canvas,
163
+ width,
164
+ height
165
+ }),
166
+ /* @__PURE__ */ jsx(ChartTooltip3D, {
167
+ tooltip,
168
+ width,
169
+ height
170
+ })
171
+ ]
172
+ }),
173
+ shortcuts && /* @__PURE__ */ jsx(ChartShortcuts3D, {}),
174
+ legend && /* @__PURE__ */ jsx(ChartLegend3D, { config })
175
+ ]
176
+ });
177
+ }
178
+ /**
179
+ * The marks of the chart and what reads them: the renderer that draws them,
180
+ * the canvas it paints them on and the hover that shows their tooltips.
181
+ */
182
+ function useScene(requested, width, height, setTooltip) {
183
+ const scene = React.useRef([]);
184
+ const renderer = useRenderer(requested);
185
+ const { canvas, repaint } = useCanvas(scene, width);
186
+ const { hover, leave } = useHover(scene, width, height, setTooltip);
187
+ return {
188
+ scene,
189
+ renderer,
190
+ canvas,
191
+ repaint,
192
+ hover,
193
+ leave
194
+ };
195
+ }
196
+ /** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
197
+ function useCamera(view, width, height) {
198
+ return React.useMemo(() => camera3D().origin({
199
+ x: width / 2 + (view.pan?.x ?? 0),
200
+ y: height / 2 + (view.pan?.y ?? 0)
201
+ }).scale(Math.min(width, height) * .3 * (view.zoom ?? 1)).perspective(view.perspective ?? Infinity).rotateY(view.yaw).rotateX(-view.tilt), [
202
+ view,
203
+ width,
204
+ height
205
+ ]);
206
+ }
207
+ /** `view` moved by one step. The tilt stops at the zenith and the nadir, the zoom at 1/4 and 8. */
208
+ function moved(view, move, initial) {
209
+ if (move.reset) return initial;
210
+ const step = {
211
+ yaw: 0,
212
+ tilt: 0,
213
+ zoom: 1,
214
+ pan: {
215
+ x: 0,
216
+ y: 0
217
+ },
218
+ ...move
219
+ };
220
+ const pan = view.pan ?? {
221
+ x: 0,
222
+ y: 0
223
+ };
224
+ const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
225
+ return {
226
+ ...view,
227
+ yaw: view.yaw + step.yaw,
228
+ tilt: clamp(-Math.PI / 2, Math.PI / 2, view.tilt + step.tilt),
229
+ zoom: clamp(.25, 8, (view.zoom ?? 1) * step.zoom),
230
+ pan: {
231
+ x: pan.x + step.pan.x,
232
+ y: pan.y + step.pan.y
233
+ }
234
+ };
235
+ }
236
+ /**
237
+ * The view of the camera, and the handlers that move it: a drag turns it,
238
+ * or pans it while Space is held, the keys of `SHORTCUTS` step it, Ctrl or ⌘
239
+ * with the wheel zooms it and a double click resets it. `orbiting` is true
240
+ * while the camera turns. `onDragStart` runs when a press becomes a drag,
241
+ * `onHover` on a move without one.
242
+ */
243
+ function useOrbit(defaultView, width, onDragStart, onHover) {
244
+ const [view, setView] = React.useState(defaultView);
245
+ const [orbiting, setOrbiting] = React.useState(false);
246
+ const drag = React.useRef(null);
247
+ const space = React.useRef(false);
248
+ const ref = useWheelZoom(setView);
249
+ const move = (step) => setView((v) => moved(v, step, defaultView));
250
+ const release = () => {
251
+ drag.current = null;
252
+ setOrbiting(false);
253
+ };
254
+ return {
255
+ view,
256
+ orbiting,
257
+ handlers: {
258
+ ref,
259
+ onPointerDown: (e) => {
260
+ drag.current = {
261
+ x: e.clientX,
262
+ y: e.clientY,
263
+ moved: false
264
+ };
265
+ },
266
+ onPointerMove: (e) => {
267
+ const d = drag.current;
268
+ if (!d) return onHover(e);
269
+ const dx = e.clientX - d.x;
270
+ const dy = e.clientY - d.y;
271
+ if (!d.moved && Math.hypot(dx, dy) < CLICK_DISTANCE) return;
272
+ if (!d.moved) {
273
+ e.currentTarget.setPointerCapture(e.pointerId);
274
+ onDragStart();
275
+ }
276
+ drag.current = {
277
+ x: e.clientX,
278
+ y: e.clientY,
279
+ moved: true
280
+ };
281
+ const k = width / e.currentTarget.getBoundingClientRect().width;
282
+ setOrbiting(!space.current);
283
+ move(space.current ? { pan: {
284
+ x: dx * k,
285
+ y: dy * k
286
+ } } : {
287
+ yaw: dx * DRAG,
288
+ tilt: dy * DRAG
289
+ });
290
+ },
291
+ onPointerUp: release,
292
+ onPointerCancel: release,
293
+ onDoubleClick: () => move({ reset: true }),
294
+ onKeyDown: (e) => {
295
+ if (e.key === " ") space.current = true;
296
+ const step = e.shiftKey && KEYS[`Shift+${e.key}`] || KEYS[e.key];
297
+ if (!step && e.key !== " ") return;
298
+ e.preventDefault();
299
+ if (!step) return;
300
+ setOrbiting(Boolean(step.yaw || step.tilt));
301
+ move(step);
302
+ },
303
+ onKeyUp: (e) => {
304
+ if (e.key === " ") space.current = false;
305
+ setOrbiting(false);
306
+ }
307
+ }
308
+ };
309
+ }
310
+ /**
311
+ * A ref for the svg that zooms the view with Ctrl or ⌘ and the wheel, which
312
+ * is also how a trackpad pinches. The wheel alone still scrolls the page.
313
+ */
314
+ function useWheelZoom(setView) {
315
+ return React.useCallback((svg) => {
316
+ const onWheel = (e) => {
317
+ if (!e.ctrlKey && !e.metaKey) return;
318
+ e.preventDefault();
319
+ setView((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
320
+ };
321
+ svg?.addEventListener("wheel", onWheel, { passive: false });
322
+ return () => svg?.removeEventListener("wheel", onWheel);
323
+ }, [setView]);
324
+ }
325
+ /**
326
+ * The tooltip of the mark under the pointer, the one painted last. `hover`
327
+ * sets it only when the pointer moves onto another mark, so a move within
328
+ * one does not render the chart again.
329
+ */
330
+ function useHover(scene, width, height, setTooltip) {
331
+ const hovered = React.useRef(void 0);
332
+ const show = (mark) => {
333
+ if (mark?.key === hovered.current?.key) return;
334
+ hovered.current = mark;
335
+ setTooltip(mark?.tooltip ?? null);
336
+ };
337
+ return {
338
+ hover: (e) => {
339
+ const rect = e.currentTarget.getBoundingClientRect();
340
+ const at = {
341
+ x: (e.clientX - rect.left) / rect.width * width,
342
+ y: (e.clientY - rect.top) / rect.height * height
343
+ };
344
+ show(hit(scene.current, at));
345
+ },
346
+ leave: () => show(void 0)
347
+ };
348
+ }
349
+ /** The topmost mark under `at`: the last one painted that holds it. */
350
+ function hit(marks, at) {
351
+ for (let i = marks.length - 1; i >= 0; i -= 1) {
352
+ const { points, r } = marks[i];
353
+ if (r === void 0 ? inside(points, at) : Math.hypot(points[0].x - at.x, points[0].y - at.y) <= r) return marks[i];
354
+ }
355
+ }
356
+ /** Whether `at` lies inside `polygon`: a ray from it crosses an odd number of its edges. */
357
+ function inside(polygon, at) {
358
+ let odd = false;
359
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
360
+ const a = polygon[i];
361
+ const b = polygon[j];
362
+ if (a.y > at.y !== b.y > at.y && at.x < (b.x - a.x) * (at.y - a.y) / (b.y - a.y) + a.x) odd = !odd;
363
+ }
364
+ return odd;
365
+ }
366
+ /**
367
+ * The renderer the chart draws with: SVG on the server and in the first
368
+ * render on the client, then the requested one if the browser has it.
369
+ */
370
+ function useRenderer(requested) {
371
+ const [renderer, setRenderer] = React.useState("svg");
372
+ React.useEffect(() => {
373
+ const canvas = requested === "canvas" && document.createElement("canvas").getContext("2d");
374
+ setRenderer(canvas ? "canvas" : "svg");
375
+ }, [requested]);
376
+ return renderer;
377
+ }
378
+ /**
379
+ * A ref for the canvas of the marks, and the repaint that paints them: on new
380
+ * marks, when the canvas changes its size and when the theme changes. A color
381
+ * resolves on the canvas itself, inside the chart, so the `--color-*` apply.
382
+ */
383
+ function useCanvas(scene, width) {
384
+ const element = React.useRef(null);
385
+ const colors = React.useRef(/* @__PURE__ */ new Map());
386
+ const repaint = React.useCallback(() => {
387
+ const canvas = element.current;
388
+ if (!canvas) return;
389
+ paint(canvas, scene.current, width, (css) => {
390
+ let color = colors.current.get(css);
391
+ if (color === void 0) {
392
+ canvas.style.color = css;
393
+ color = getComputedStyle(canvas).color;
394
+ colors.current.set(css, color);
395
+ }
396
+ return color;
397
+ });
398
+ }, [scene, width]);
399
+ return {
400
+ canvas: React.useCallback((node) => {
401
+ element.current = node;
402
+ repaint();
403
+ const resize = new ResizeObserver(repaint);
404
+ resize.observe(node);
405
+ const theme = new MutationObserver(() => {
406
+ colors.current.clear();
407
+ repaint();
408
+ });
409
+ theme.observe(document.documentElement, { attributeFilter: ["class", "style"] });
410
+ return () => {
411
+ element.current = null;
412
+ resize.disconnect();
413
+ theme.disconnect();
414
+ };
415
+ }, [repaint]),
416
+ repaint
417
+ };
418
+ }
419
+ /** Paints `marks` on `canvas`, from the view box of `width` to the pixels of the canvas. */
420
+ function paint(canvas, marks, width, color) {
421
+ canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
422
+ canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
423
+ const ctx = canvas.getContext("2d");
424
+ const scale = canvas.width / width;
425
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
426
+ ctx.lineJoin = "round";
427
+ for (const { points, r, fill, stroke } of marks) {
428
+ ctx.beginPath();
429
+ if (r === void 0) {
430
+ for (const p of points) ctx.lineTo(p.x, p.y);
431
+ ctx.closePath();
432
+ } else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
433
+ ctx.fillStyle = color(fill);
434
+ ctx.fill();
435
+ const outline = r === void 0 ? fill : stroke;
436
+ if (!outline) continue;
437
+ ctx.strokeStyle = color(outline);
438
+ ctx.lineWidth = r === void 0 ? .5 : 1;
439
+ ctx.stroke();
440
+ }
441
+ }
442
+ /** The canvas over the chart box that the marks are painted on, if the chart draws on one. */
443
+ function ChartCanvas3D({ renderer, canvas, width, height }) {
444
+ if (renderer !== "canvas") return null;
445
+ return /* @__PURE__ */ jsx("canvas", {
446
+ ref: canvas,
447
+ className: "pointer-events-none absolute top-0 left-0 w-full mask-y-from-96%",
448
+ style: { aspectRatio: `${width} / ${height}` }
449
+ });
450
+ }
451
+ /**
452
+ * The marks of a chart, drawn by the renderer of the container: as SVG
453
+ * elements, or on the canvas over the chart box. The hover reads them too.
454
+ */
455
+ function Marks3D({ marks }) {
456
+ const { renderer, scene, repaint } = useChart3D();
457
+ React.useLayoutEffect(() => {
458
+ scene.current = marks;
459
+ repaint();
460
+ });
461
+ if (renderer === "canvas") return null;
462
+ return marks.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
463
+ d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
464
+ fill,
465
+ stroke: fill,
466
+ strokeWidth: .5,
467
+ strokeLinejoin: "round"
468
+ }, key) : /* @__PURE__ */ jsx("circle", {
469
+ cx: points[0].x,
470
+ cy: points[0].y,
471
+ r,
472
+ fill,
473
+ stroke,
474
+ strokeWidth: stroke ? 1 : void 0
475
+ }, key));
476
+ }
477
+ /**
478
+ * The labels of the marks of a chart, ringed by the background so that they
479
+ * read on the marks. Like the labels of the box, they are SVG with every renderer.
480
+ */
481
+ function Labels3D({ labels }) {
482
+ return /* @__PURE__ */ jsx("g", {
483
+ "data-slot": "chart-3d-labels",
484
+ children: labels.map(({ key, at, text, anchor }) => /* @__PURE__ */ jsx("text", {
485
+ x: at.x,
486
+ y: at.y,
487
+ textAnchor: anchor,
488
+ dominantBaseline: "central",
489
+ paintOrder: "stroke",
490
+ strokeWidth: 3,
491
+ strokeLinejoin: "round",
492
+ className: "fill-foreground stroke-background text-[10px] font-medium",
493
+ children: text
494
+ }, key))
495
+ });
496
+ }
497
+ function ChartStyle({ id, config }) {
498
+ const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
499
+ if (!colorConfig.length) return null;
500
+ return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: Object.entries(THEMES).map(([theme, prefix]) => `
501
+ ${prefix} [data-chart=${id}] {
502
+ ${colorConfig.map(([key, item]) => {
503
+ const color = item.theme?.[theme] || item.color;
504
+ return color ? ` --color-${key}: ${color};` : null;
505
+ }).join("\n")}
506
+ }
507
+ `).join("\n") } });
508
+ }
509
+ function ChartTooltip3D({ tooltip, width, height }) {
510
+ if (!tooltip) return null;
511
+ return /* @__PURE__ */ jsxs("div", {
512
+ className: "pointer-events-none absolute z-10 grid min-w-32 -translate-x-1/2 -translate-y-[calc(100%+0.5rem)] gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
513
+ style: {
514
+ left: `${tooltip.at.x / width * 100}%`,
515
+ top: `${tooltip.at.y / height * 100}%`
516
+ },
517
+ children: [tooltip.title && /* @__PURE__ */ jsx("div", {
518
+ className: "font-medium",
519
+ children: tooltip.title
520
+ }), tooltip.items.map((item, i) => /* @__PURE__ */ jsxs("div", {
521
+ className: "flex items-center gap-2",
522
+ children: [
523
+ item.color && /* @__PURE__ */ jsx("span", {
524
+ className: "size-2.5 shrink-0 rounded-[2px]",
525
+ style: { background: item.color }
526
+ }),
527
+ /* @__PURE__ */ jsx("span", {
528
+ className: "text-muted-foreground",
529
+ children: item.label
530
+ }),
531
+ /* @__PURE__ */ jsx("span", {
532
+ className: "ml-auto font-mono font-medium text-foreground tabular-nums",
533
+ children: typeof item.value === "number" ? item.value.toLocaleString() : item.value
534
+ })
535
+ ]
536
+ }, i))]
537
+ });
538
+ }
539
+ /**
540
+ * A button in the corner of the chart that lists `SHORTCUTS`. `?` on the
541
+ * chart opens and closes the list, Escape closes it.
542
+ */
543
+ function ChartShortcuts3D() {
544
+ const [open, setOpen] = React.useState(false);
545
+ const ref = React.useRef(null);
546
+ React.useEffect(() => {
547
+ const chart = ref.current?.parentElement;
548
+ const onKeyDown = (e) => {
549
+ if (e.key === "?") setOpen((o) => !o);
550
+ if (e.key === "Escape") setOpen(false);
551
+ };
552
+ chart?.addEventListener("keydown", onKeyDown);
553
+ return () => chart?.removeEventListener("keydown", onKeyDown);
554
+ }, []);
555
+ return /* @__PURE__ */ jsxs("div", {
556
+ ref,
557
+ className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
558
+ children: [/* @__PURE__ */ jsx("button", {
559
+ type: "button",
560
+ "aria-label": "Keyboard shortcuts",
561
+ "aria-expanded": open,
562
+ onClick: () => setOpen((o) => !o),
563
+ className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4",
564
+ children: /* @__PURE__ */ jsx(KeyboardIcon, {})
565
+ }), open && /* @__PURE__ */ jsx("div", {
566
+ className: "grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-2 shadow-xl",
567
+ children: SHORTCUTS.map(({ hint, label }, i) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("span", {
568
+ "data-slot": "kbd-group",
569
+ className: "flex gap-1",
570
+ children: hint.map((key) => /* @__PURE__ */ jsx("kbd", {
571
+ "data-slot": "kbd",
572
+ className: "pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
573
+ children: key
574
+ }, key))
575
+ }), /* @__PURE__ */ jsx("span", {
576
+ className: "text-muted-foreground",
577
+ children: label
578
+ })] }, i))
579
+ })]
580
+ });
581
+ }
582
+ function ChartLegend3D({ config }) {
583
+ return /* @__PURE__ */ jsx("div", {
584
+ className: "flex items-center justify-center gap-4 pt-3",
585
+ children: Object.entries(config).map(([key, item]) => /* @__PURE__ */ jsxs("div", {
586
+ className: "flex items-center gap-1.5",
587
+ children: [item.icon ? /* @__PURE__ */ jsx(item.icon, {}) : /* @__PURE__ */ jsx("span", {
588
+ className: "size-2 rounded-[2px]",
589
+ style: { background: `var(--color-${key})` }
590
+ }), item.label ?? key]
591
+ }, key))
592
+ });
593
+ }
594
+ /**
595
+ * The walls of the chart box that face away from the camera, with their
596
+ * gridlines, and an axis with ticks on the outline of the box.
597
+ */
598
+ function Axes3D({ spec, world, format = {}, titles = {} }) {
599
+ const { camera } = useChart3D();
600
+ const box = axes3D().camera(camera).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
601
+ return /* @__PURE__ */ jsxs("g", {
602
+ "data-slot": "chart-3d-axes",
603
+ children: [box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
604
+ d: box.draw(wall),
605
+ className: "fill-muted/40 stroke-border",
606
+ strokeWidth: .75
607
+ }, `${wall.axis}${wall.side}`)), labelAxes(box.axes(spec), format, titles).map((axis) => /* @__PURE__ */ jsx(AxisEdge3D, {
608
+ ticks: axis.ticks,
609
+ title: axis.title
610
+ }, axis.key))]
611
+ });
612
+ }
613
+ /**
614
+ * The labels of one axis on the outline of the box: a label per tick, pushed
615
+ * away from the box along the normal of the edge, and the title of the axis
616
+ * further out. `key` names the edge, one of the four the axis moves between
617
+ * as the camera turns.
618
+ */
619
+ function axisLabels(edge, format, title) {
620
+ const { normal: n } = edge;
621
+ const start = edge[0].projected;
622
+ const end = edge[edge.length - 1].projected;
623
+ const anchor = n.x > .3 ? "start" : n.x < -.3 ? "end" : "middle";
624
+ const label = (at, out, text, size) => ({
625
+ x: at.x + n.x * out,
626
+ y: at.y + n.y * out,
627
+ anchor,
628
+ text,
629
+ size
630
+ });
631
+ const middle = {
632
+ x: (start.x + end.x) / 2,
633
+ y: (start.y + end.y) / 2
634
+ };
635
+ return {
636
+ key: `${edge.axis}:${edge[0].x},${edge[0].y},${edge[0].z}`,
637
+ ticks: edge.map((tick) => label(tick.projected, 10, format(tick[edge.axis]), 10)),
638
+ title: title ? label(middle, 44, title, 12) : void 0
639
+ };
640
+ }
641
+ /**
642
+ * The labels of the axes, with every tick label hidden that would cover a
643
+ * title or a tick label before it, so an axis seen end on keeps its first
644
+ * label and a short one every other.
645
+ */
646
+ function labelAxes(edges, format, titles) {
647
+ const axes = edges.filter((edge) => edge.length).map((edge) => axisLabels(edge, format[edge.axis] ?? String, titles[edge.axis]));
648
+ const placed = axes.flatMap((axis) => axis.title ? [bounds(axis.title)] : []);
649
+ for (const label of axes.flatMap((axis) => axis.ticks)) {
650
+ const b = bounds(label);
651
+ label.hidden = placed.some((p) => b.left < p.right && p.left < b.right && b.top < p.bottom && p.top < b.bottom);
652
+ if (!label.hidden) placed.push(b);
653
+ }
654
+ return axes;
655
+ }
656
+ /** The box a label covers, guessed from its text, as the browser lays it out only after the render. */
657
+ function bounds({ x, y, anchor, text, size }) {
658
+ const width = (typeof text === "string" || typeof text === "number" ? String(text).length : 4) * size * .6;
659
+ const left = x - width * {
660
+ start: 0,
661
+ middle: .5,
662
+ end: 1
663
+ }[anchor];
664
+ return {
665
+ left,
666
+ right: left + width,
667
+ top: y - size / 2,
668
+ bottom: y + size / 2
669
+ };
670
+ }
671
+ /**
672
+ * The labels of one axis, dimmed while the camera turns. When the axis jumps
673
+ * to another edge of the box, its key changes, so they mount anew and fade in
674
+ * there.
675
+ */
676
+ function AxisEdge3D({ ticks, title }) {
677
+ return /* @__PURE__ */ jsxs("g", {
678
+ className: "transition-opacity duration-300 starting:opacity-0 in-data-[orbiting=true]:opacity-50",
679
+ children: [ticks.map(({ x, y, anchor, text, hidden }, i) => /* @__PURE__ */ jsx("text", {
680
+ x,
681
+ y,
682
+ textAnchor: anchor,
683
+ dominantBaseline: "central",
684
+ className: cn("fill-muted-foreground text-[10px] transition-opacity", hidden && "opacity-0"),
685
+ children: text
686
+ }, i)), title && /* @__PURE__ */ jsx("text", {
687
+ x: title.x,
688
+ y: title.y,
689
+ textAnchor: title.anchor,
690
+ dominantBaseline: "central",
691
+ className: "fill-foreground font-medium",
692
+ children: title.text
693
+ })]
694
+ });
695
+ }
696
+ /** A nice axis from `lo` to `hi` with about `count` ticks. */
697
+ function axis(lo, hi, count = 5) {
698
+ const [start, stop] = nice(lo, lo === hi ? hi + 1 : hi, count);
699
+ return {
700
+ domain: [start, stop],
701
+ ticks: ticks(start, stop, count)
702
+ };
703
+ }
704
+ /** The linear map from `domain` to `range`. */
705
+ function linear([d0, d1], [r0, r1]) {
706
+ return (value) => r0 + (value - d0) / (d1 - d0 || 1) * (r1 - r0);
707
+ }
708
+ /** The world box of `spec`: x from -1 to 1, y up from 1 to -1, z from -`depth` to `depth`. */
709
+ function worldBox(spec, depth = 1) {
710
+ return {
711
+ x: linear(spec.x.domain, [-1, 1]),
712
+ y: linear(spec.y.domain, [1, -1]),
713
+ z: linear(spec.z.domain, [-depth, depth])
714
+ };
715
+ }
716
+ const LIGHT = {
717
+ x: -.4,
718
+ y: -.8,
719
+ z: .44
720
+ };
721
+ /**
722
+ * `color` lit by the light from the upper left: the face that looks at it
723
+ * keeps the color, one that turns away gets darker. A CSS color-mix, so the
724
+ * shade follows the theme variables.
725
+ */
726
+ function shade(face, color) {
727
+ const n = normal(face.map((p) => p.rotated));
728
+ const side = n.z < 0 ? -1 : 1;
729
+ const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
730
+ return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
731
+ }
732
+ /** The mean of the projected points, where a tooltip points at. */
733
+ function center(points) {
734
+ const sum = points.reduce((s, p) => ({
735
+ x: s.x + p.projected.x,
736
+ y: s.y + p.projected.y
737
+ }), {
738
+ x: 0,
739
+ y: 0
740
+ });
741
+ return {
742
+ x: sum.x / points.length,
743
+ y: sum.y / points.length
744
+ };
745
+ }
746
+ /** The marks of a cube: its faces that look at the camera, shaded in `color`, each with `tooltip` on hover. */
747
+ function cubeMarks(id, faces, color, tooltip) {
748
+ return faces.filter((face) => face.ccw).map((face) => ({
749
+ key: `${id}-${face.face}`,
750
+ points: face.map((p) => p.projected),
751
+ fill: shade(face, color),
752
+ tooltip
753
+ }));
754
+ }
755
+ // registry/new-york/ui/bar-chart-3d.tsx
756
+ /**
757
+ * Bars on a floor: one row per entry of `data` along x, one per series
758
+ * along z, the value as the height. The series are the keys of the chart
759
+ * config unless `keys` names them.
760
+ */
761
+ function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
762
+ const { config, camera } = useChart3D();
763
+ const series = keys ?? Object.keys(config);
764
+ const cells = data.flatMap((d, i) => series.map((key, j) => ({
765
+ i,
766
+ j,
767
+ key,
768
+ value: Number(d[key]) || 0
769
+ })));
770
+ const { spec, world, size } = barBox(data.length, series.length, cells.map((cell) => cell.value), barSize);
771
+ const bars = cubes3D().camera(camera).data(cells.map(({ i, j, key, value }) => Object.assign(barCorners(world, i, j, value, size), {
772
+ i,
773
+ key,
774
+ value
775
+ }))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
776
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
777
+ spec,
778
+ world,
779
+ titles,
780
+ format: {
781
+ x: (i) => (format.x ?? String)(String(data[i][index] ?? "")),
782
+ y: format.y,
783
+ z: (j) => config[series[j]]?.label ?? series[j]
784
+ }
785
+ }), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
786
+ id: `${bar.i}-${bar.key}`,
787
+ faces: bar.faces,
788
+ color: `var(--color-${bar.key})`,
789
+ title: String(data[bar.i][index]),
790
+ label: config[bar.key]?.label ?? bar.key,
791
+ value: bar.value
792
+ })) })] });
793
+ }
794
+ /**
795
+ * The box of a bar chart: a row per entry along x, a row per series along z,
796
+ * and the size of a bar, the share `barSize` of its cell.
797
+ */
798
+ function barBox(entries, series, values, barSize) {
799
+ const index = (length) => Array.from({ length }, (_, i) => i);
800
+ const spec = {
801
+ x: {
802
+ domain: [-.5, entries - .5],
803
+ ticks: index(entries)
804
+ },
805
+ y: axis(Math.min(0, ...values), Math.max(0, ...values)),
806
+ z: {
807
+ domain: [-.5, series - .5],
808
+ ticks: index(series)
809
+ }
810
+ };
811
+ const depth = Math.min(1, Math.max(.25, series / entries));
812
+ const size = {
813
+ width: 2 / entries * barSize,
814
+ depth: 2 * depth / series * barSize
815
+ };
816
+ return {
817
+ spec,
818
+ world: worldBox(spec, depth),
819
+ size
820
+ };
821
+ }
822
+ /**
823
+ * The corners of one bar, from the floor to its value, at row `x` and series
824
+ * `z`. box() rises towards -y, so a negative bar starts at its value.
825
+ */
826
+ function barCorners(world, x, z, value, size) {
827
+ const floor = world.y(0);
828
+ const top = world.y(value);
829
+ return box({
830
+ x: world.x(x),
831
+ y: Math.max(floor, top),
832
+ z: world.z(z),
833
+ ...size,
834
+ height: Math.abs(floor - top)
835
+ });
836
+ }
837
+ /** The marks of one bar: the faces that look at the camera, shaded, each with its entry and value on hover. */
838
+ function barMarks({ id, faces, color, title, label, value }) {
839
+ return cubeMarks(id, faces, color, {
840
+ at: center(faces.find((face) => face.face === "top")),
841
+ title,
842
+ items: [{
843
+ label,
844
+ value,
845
+ color
846
+ }]
847
+ });
848
+ }
849
+ // registry/new-york/ui/scatter-chart-3d.tsx
850
+ /**
851
+ * Points in the chart box, at the fields `x`, `y` and `z` of each entry.
852
+ * `series` names the field whose value is the config key of the color.
853
+ */
854
+ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
855
+ const { config, camera } = useChart3D();
856
+ const fields = {
857
+ x,
858
+ y,
859
+ z
860
+ };
861
+ const read = (d, axis) => Number(d[fields[axis]]);
862
+ const range = (a) => {
863
+ const [lo = 0, hi = 0] = extent(data, (d) => read(d, a));
864
+ return axis(lo, hi);
865
+ };
866
+ const spec = {
867
+ x: range("x"),
868
+ y: range("y"),
869
+ z: range("z")
870
+ };
871
+ const world = worldBox(spec);
872
+ const points = points3D().camera(camera).x(({ d }) => world.x(read(d, "x"))).y(({ d }) => world.y(read(d, "y"))).z(({ d }) => world.z(read(d, "z")));
873
+ const fallback = Object.keys(config)[0];
874
+ const marks = points.data(data.map((d, i) => ({
875
+ d,
876
+ i
877
+ }))).sort(sort).map((p) => {
878
+ const key = series ? String(p.d[series]) : fallback;
879
+ return {
880
+ key: String(p.i),
881
+ points: [p.projected],
882
+ r: radius,
883
+ fill: `var(--color-${key})`,
884
+ stroke: "var(--background)",
885
+ tooltip: {
886
+ at: p.projected,
887
+ title: series ? config[key]?.label ?? key : void 0,
888
+ items: [
889
+ "x",
890
+ "y",
891
+ "z"
892
+ ].map((a) => ({
893
+ label: titles?.[a] ?? fields[a],
894
+ value: read(p.d, a)
895
+ }))
896
+ }
897
+ };
898
+ });
899
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
900
+ spec,
901
+ world,
902
+ format,
903
+ titles
904
+ }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
905
+ }
906
+ // registry/new-york/ui/surface-chart-3d.tsx
907
+ /**
908
+ * A surface over a grid: `data[row][column]` is the height, the columns run
909
+ * along x and the rows along z. The fill ramps from the first color of
910
+ * `colors` at the lowest height to the second at the highest; both are keys
911
+ * of the chart config, by default its first two.
912
+ */
913
+ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
914
+ const { config, camera } = useChart3D();
915
+ const [low, high = low] = colors ?? Object.keys(config);
916
+ const rows = data.length;
917
+ const columns = data[0]?.length ?? 0;
918
+ const [x0, x1] = domain.x ?? [0, columns - 1];
919
+ const [z0, z1] = domain.z ?? [0, rows - 1];
920
+ const [lo = 0, hi = 0] = extent(data.flat());
921
+ const spec = {
922
+ x: axis(x0, x1),
923
+ y: axis(lo, hi),
924
+ z: axis(z0, z1)
925
+ };
926
+ const world = worldBox(spec);
927
+ const toX = linear([0, columns - 1], [x0, x1]);
928
+ const toZ = linear([0, rows - 1], [z0, z1]);
929
+ const grid = gridPlanes3D().camera(camera).rows(Math.max(1, columns)).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
930
+ const points = data.flatMap((row, r) => row.map((y, c) => ({
931
+ x: toX(c),
932
+ y,
933
+ z: toZ(r)
934
+ })));
935
+ const marks = grid.data(points).sort(sort).map((quad) => {
936
+ const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
937
+ const t = Math.round((height - lo) / (hi - lo || 1) * 100);
938
+ return {
939
+ key: quad.plane,
940
+ points: quad.map((p) => p.projected),
941
+ fill: shade(quad, `color-mix(in oklch, var(--color-${high}) ${t}%, var(--color-${low}))`),
942
+ tooltip: {
943
+ at: center(quad),
944
+ items: [{
945
+ label: titles?.y ?? "value",
946
+ value: height
947
+ }]
948
+ }
949
+ };
950
+ });
951
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
952
+ spec,
953
+ world,
954
+ format,
955
+ titles
956
+ }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
957
+ }
958
+ // registry/new-york/ui/sankey-chart-3d.tsx
959
+ const DEPTH = .4;
960
+ const RIBBON = .7;
961
+ const SEGMENTS = 16;
962
+ /**
963
+ * Flows from left to right: every node a box in its column, as high as the
964
+ * larger of what flows in and what flows out, and every flow a band from node
965
+ * to node, as thick as its value, that turns from the color of its source to
966
+ * the color of its target.
967
+ */
968
+ function SankeyChart3D({ data, nodeWidth = .2, nodePadding = .05, format = (value) => value }) {
969
+ const { config, camera } = useChart3D();
970
+ const { nodes, columns, ky } = sankeyLayout(data, nodePadding);
971
+ const pieces = depthSort(cubes3D().camera(camera).data(sankeyPieces(nodes, columns, ky, nodeWidth)), camera);
972
+ const labels = pieces.flatMap(({ key, faces, node }) => node ? [nodeLabel(key, faces, node.column < (columns - 1) / 2 ? "left" : "right", style(config, node).label)] : []);
973
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Marks3D, { marks: pieces.flatMap((piece) => pieceMarks(piece, config, format)) }), /* @__PURE__ */ jsx(Labels3D, { labels })] });
974
+ }
975
+ /** The label and the color of a node, from the chart config. */
976
+ function style(config, node) {
977
+ return {
978
+ label: config[node.id]?.label ?? node.id,
979
+ color: config[node.id] ? `var(--color-${node.id})` : "var(--muted-foreground)"
980
+ };
981
+ }
982
+ /**
983
+ * The marks of a piece, each with its tooltip: the faces of a node, or the
984
+ * faces of a slice of a flow, in the color it has turned to there.
985
+ */
986
+ function pieceMarks({ key, faces, node, flow, t }, config, format) {
987
+ const at = center(faces.find((face) => face.face === "top"));
988
+ if (node) {
989
+ const { label, color } = style(config, node);
990
+ return cubeMarks(key, faces, color, {
991
+ at,
992
+ items: [{
993
+ label,
994
+ value: format(node.value),
995
+ color
996
+ }]
997
+ });
998
+ }
999
+ const source = style(config, flow.source);
1000
+ const target = style(config, flow.target);
1001
+ const item = {
1002
+ label: /* @__PURE__ */ jsxs(Fragment, { children: [
1003
+ source.label,
1004
+ " → ",
1005
+ target.label
1006
+ ] }),
1007
+ value: format(flow.value),
1008
+ color: source.color
1009
+ };
1010
+ return cubeMarks(key, faces.filter((face) => face.face !== "left" && face.face !== "right"), `color-mix(in oklch, color-mix(in oklch, ${target.color} ${t}%, ${source.color}) 75%, var(--background))`, {
1011
+ at,
1012
+ items: [item]
1013
+ });
1014
+ }
1015
+ /** The nodes of `data`, waypoints included, stacked in their columns, and `ky`, the world units per value. */
1016
+ function sankeyLayout(data, nodePadding) {
1017
+ const { nodes, flows, columns } = sankeyColumns(data);
1018
+ const all = sankeyHops(nodes, flows);
1019
+ const ky = stackColumns(all, columns, nodePadding);
1020
+ stackHops(all, ky);
1021
+ return {
1022
+ nodes: all,
1023
+ columns,
1024
+ ky
1025
+ };
1026
+ }
1027
+ function newNode(id, column, flow) {
1028
+ return {
1029
+ id,
1030
+ column,
1031
+ value: 0,
1032
+ y0: 0,
1033
+ y1: 0,
1034
+ incoming: [],
1035
+ outgoing: [],
1036
+ flow
1037
+ };
1038
+ }
1039
+ function middle(node) {
1040
+ return (node.y0 + node.y1) / 2;
1041
+ }
1042
+ /**
1043
+ * The nodes of `data` in the order they first appear, and the flows between
1044
+ * them, without the ones of no value. The column of a node is the longest
1045
+ * path to it from a source; the sinks move to the last column.
1046
+ */
1047
+ function sankeyColumns(data) {
1048
+ const nodes = /* @__PURE__ */ new Map();
1049
+ const node = (id) => {
1050
+ if (!nodes.has(id)) nodes.set(id, newNode(id, 0));
1051
+ return nodes.get(id);
1052
+ };
1053
+ const flows = data.filter((d) => d.value > 0).map((d, i) => ({
1054
+ key: String(i),
1055
+ source: node(d.source),
1056
+ target: node(d.target),
1057
+ value: d.value
1058
+ }));
1059
+ for (let pass = 0; flows.some((f) => f.target.column <= f.source.column); pass += 1) {
1060
+ if (pass === nodes.size) throw new Error("SankeyChart3D: the flows form a cycle");
1061
+ for (const f of flows) f.target.column = Math.max(f.target.column, f.source.column + 1);
1062
+ }
1063
+ const last = Math.max(0, ...flows.map((f) => f.target.column));
1064
+ const sources = new Set(flows.map((f) => f.source));
1065
+ for (const n of nodes.values()) if (!sources.has(n)) n.column = last;
1066
+ return {
1067
+ nodes: [...nodes.values()],
1068
+ flows,
1069
+ columns: last + 1
1070
+ };
1071
+ }
1072
+ /**
1073
+ * Splits every flow into hops from one column to the next. A flow that skips a
1074
+ * column passes it at a waypoint, a node of its own, so it has a slot in every
1075
+ * column it crosses and never runs through another node.
1076
+ */
1077
+ function sankeyHops(nodes, flows) {
1078
+ const all = [...nodes];
1079
+ for (const flow of flows) {
1080
+ let source = flow.source;
1081
+ for (let column = source.column + 1; column <= flow.target.column; column += 1) {
1082
+ const target = column < flow.target.column ? newNode(`${flow.key}:${column}`, column, flow) : flow.target;
1083
+ if (target.flow) all.push(target);
1084
+ const hop = {
1085
+ flow,
1086
+ source,
1087
+ target,
1088
+ y0: 0,
1089
+ y1: 0
1090
+ };
1091
+ source.outgoing.push(hop);
1092
+ target.incoming.push(hop);
1093
+ source = target;
1094
+ }
1095
+ }
1096
+ return all;
1097
+ }
1098
+ /**
1099
+ * Sizes the nodes to the largest scale at which every column fits, and stacks
1100
+ * each column from the top, centered. A column after the first orders its nodes
1101
+ * by the mean height of their sources, which uncrosses most flows. Returns the
1102
+ * scale, world units per value.
1103
+ */
1104
+ function stackColumns(nodes, columns, nodePadding) {
1105
+ const total = (hops) => hops.reduce((sum, hop) => sum + hop.flow.value, 0);
1106
+ for (const node of nodes) node.value = Math.max(total(node.incoming), total(node.outgoing));
1107
+ const stacks = Array.from({ length: columns }, (_, c) => nodes.filter((n) => n.column === c));
1108
+ const sum = (stack) => stack.reduce((s, n) => s + n.value, 0);
1109
+ const pad = Math.min(2 * nodePadding, 2 / Math.max(1, ...stacks.map((s) => s.length)));
1110
+ const ky = Math.min(...stacks.map((s) => (2 - pad * (s.length - 1)) / sum(s)));
1111
+ const mean = (node) => node.incoming.reduce((s, hop) => s + hop.flow.value * middle(hop.source), 0) / total(node.incoming);
1112
+ for (const [c, stack] of stacks.entries()) {
1113
+ if (c > 0) stack.sort((a, b) => mean(a) - mean(b));
1114
+ let y = -(sum(stack) * ky + pad * (stack.length - 1)) / 2;
1115
+ for (const node of stack) {
1116
+ node.y0 = y;
1117
+ node.y1 = y + node.value * ky;
1118
+ y = node.y1 + pad;
1119
+ }
1120
+ }
1121
+ return ky;
1122
+ }
1123
+ /**
1124
+ * Stacks the hops at every node from its top: the ones that leave it in the
1125
+ * order of their targets, the ones that arrive in the order of their sources,
1126
+ * so that the hops of a node do not cross.
1127
+ */
1128
+ function stackHops(nodes, ky) {
1129
+ for (const node of nodes) {
1130
+ let y = node.y0;
1131
+ for (const hop of node.outgoing.sort((a, b) => middle(a.target) - middle(b.target))) {
1132
+ hop.y0 = y + hop.flow.value * ky / 2;
1133
+ y += hop.flow.value * ky;
1134
+ }
1135
+ y = node.y0;
1136
+ for (const hop of node.incoming.sort((a, b) => middle(a.source) - middle(b.source))) {
1137
+ hop.y1 = y + hop.flow.value * ky / 2;
1138
+ y += hop.flow.value * ky;
1139
+ }
1140
+ }
1141
+ }
1142
+ /**
1143
+ * The pieces of the chart: a box per node, a flat slice of its flow per
1144
+ * waypoint and `SEGMENTS` slices per hop. The columns stand from x = -1 to 1,
1145
+ * a node the share `nodeWidth` of the distance between two of them wide.
1146
+ */
1147
+ function sankeyPieces(nodes, columns, ky, nodeWidth) {
1148
+ const step = 2 / Math.max(1, columns - 1);
1149
+ const x = (column) => -1 + column * step;
1150
+ const half = nodeWidth * step / 2;
1151
+ const share = (flow, at) => {
1152
+ const from = x(flow.source.column) + half;
1153
+ return Math.round((at - from) / (x(flow.target.column) - half - from) * 100);
1154
+ };
1155
+ return nodes.flatMap((node) => {
1156
+ const band = x(node.column);
1157
+ const own = node.flow ? {
1158
+ key: `way:${node.id}`,
1159
+ band,
1160
+ flow: node.flow,
1161
+ t: share(node.flow, band)
1162
+ } : {
1163
+ key: `node:${node.id}`,
1164
+ band,
1165
+ node,
1166
+ t: 0
1167
+ };
1168
+ const depth = node.flow ? RIBBON * DEPTH : DEPTH;
1169
+ const box = slab(band - half, middle(node), band + half, middle(node), node.y1 - node.y0, depth);
1170
+ return [Object.assign(box, own), ...node.outgoing.flatMap((hop) => hopPieces(hop, band + half, x(node.column + 1) - half, ky, share))];
1171
+ });
1172
+ }
1173
+ /**
1174
+ * The slices of a hop from `x0` to `x1`, along an S-curve that leaves and
1175
+ * arrives level, so that the hops of a flow join without a kink. Every hop
1176
+ * between the same two columns has the same slices.
1177
+ */
1178
+ function hopPieces(hop, x0, x1, ky, share) {
1179
+ const x = (k) => x0 + (x1 - x0) * k / SEGMENTS;
1180
+ const y = (k) => {
1181
+ const t = k / SEGMENTS;
1182
+ return hop.y0 + (hop.y1 - hop.y0) * t * t * (3 - 2 * t);
1183
+ };
1184
+ return Array.from({ length: SEGMENTS }, (_, k) => {
1185
+ const band = (x(k) + x(k + 1)) / 2;
1186
+ return Object.assign(slab(x(k), y(k), x(k + 1), y(k + 1), hop.flow.value * ky, RIBBON * DEPTH), {
1187
+ key: `hop:${hop.flow.key}:${hop.source.column}:${k}`,
1188
+ band,
1189
+ flow: hop.flow,
1190
+ t: share(hop.flow, band)
1191
+ });
1192
+ });
1193
+ }
1194
+ /** The corners of a slab from `x0` to `x1`, its middle at `y0` and `y1`, `height` high and `depth` deep, in the order of box(). */
1195
+ function slab(x0, y0, x1, y1, height, depth) {
1196
+ return [depth / 2, -depth / 2].flatMap((z) => [
1197
+ {
1198
+ x: x0,
1199
+ y: y0 - height / 2,
1200
+ z
1201
+ },
1202
+ {
1203
+ x: x0,
1204
+ y: y0 + height / 2,
1205
+ z
1206
+ },
1207
+ {
1208
+ x: x1,
1209
+ y: y1 + height / 2,
1210
+ z
1211
+ },
1212
+ {
1213
+ x: x1,
1214
+ y: y1 - height / 2,
1215
+ z
1216
+ }
1217
+ ]);
1218
+ }
1219
+ /**
1220
+ * The pieces in the order to paint them. The slabs of the columns and of the
1221
+ * slices lie between planes x = const, so they paint from the slab farthest
1222
+ * from the eye to the nearest, which is exact; within a slab, where the pieces
1223
+ * lie one above the other, by depth.
1224
+ */
1225
+ function depthSort(pieces, camera) {
1226
+ const { x: vx } = camera.viewDirection();
1227
+ const eye = vx * camera.perspective();
1228
+ const far = (x) => Number.isFinite(eye) ? Math.abs(x - eye) : -x * Math.sign(vx);
1229
+ return pieces.sort((a, b) => far(b.band) - far(a.band) || sort(a, b));
1230
+ }
1231
+ /**
1232
+ * The label of a node beside its `outer` face, pushed away from the other side,
1233
+ * so that it stays outside whichever way the camera turns.
1234
+ */
1235
+ function nodeLabel(key, faces, outer, text) {
1236
+ const side = (name) => center(faces.find((face) => face.face === name));
1237
+ const at = side(outer);
1238
+ const away = Math.sign(at.x - side(outer === "left" ? "right" : "left").x) || (outer === "left" ? -1 : 1);
1239
+ return {
1240
+ key,
1241
+ at: {
1242
+ x: at.x + 8 * away,
1243
+ y: at.y
1244
+ },
1245
+ text,
1246
+ anchor: away < 0 ? "end" : "start"
1247
+ };
1248
+ }
1249
+ export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, linear, shade, useChart3D, worldBox };
1250
+
1251
+ //# sourceMappingURL=index.js.map