@niekes/charts-3d 0.0.0-stage → 0.2.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,875 @@
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
+ function ChartContainer3D({ id, config, defaultView = {
110
+ yaw: Math.PI / 6,
111
+ tilt: Math.PI / 6
112
+ }, 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 }) {
113
+ const uniqueId = React.useId();
114
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
115
+ const [tooltip, setTooltip] = React.useState(null);
116
+ const { scene, renderer, canvas, repaint, hover, leave } = useScene(requested, width, height, setTooltip);
117
+ const { view, orbiting, handlers } = useOrbit(defaultView, width, leave, hover);
118
+ const camera = useCamera(view, width, height);
119
+ const context = React.useMemo(() => ({
120
+ config,
121
+ camera,
122
+ renderer,
123
+ scene,
124
+ repaint
125
+ }), [
126
+ config,
127
+ camera,
128
+ renderer,
129
+ repaint
130
+ ]);
131
+ return /* @__PURE__ */ jsxs("div", {
132
+ "data-slot": "chart-3d",
133
+ "data-chart": chartId,
134
+ className: cn("relative w-full text-xs", className),
135
+ ...props,
136
+ children: [
137
+ /* @__PURE__ */ jsx(ChartStyle, {
138
+ id: chartId,
139
+ config
140
+ }),
141
+ /* @__PURE__ */ jsx("svg", {
142
+ viewBox: `0 0 ${width} ${height}`,
143
+ role: "img",
144
+ "aria-label": ariaLabel,
145
+ tabIndex: 0,
146
+ className: "block h-auto w-full cursor-grab touch-none rounded-lg outline-hidden select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing",
147
+ "data-orbiting": orbiting,
148
+ onPointerLeave: leave,
149
+ ...handlers,
150
+ children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
151
+ value: context,
152
+ children
153
+ })
154
+ }),
155
+ /* @__PURE__ */ jsx(ChartCanvas3D, {
156
+ renderer,
157
+ canvas,
158
+ width,
159
+ height
160
+ }),
161
+ /* @__PURE__ */ jsx(ChartTooltip3D, {
162
+ tooltip,
163
+ width,
164
+ height
165
+ }),
166
+ shortcuts && /* @__PURE__ */ jsx(ChartShortcuts3D, {}),
167
+ legend && /* @__PURE__ */ jsx(ChartLegend3D, { config })
168
+ ]
169
+ });
170
+ }
171
+ /**
172
+ * The marks of the chart and what reads them: the renderer that draws them,
173
+ * the canvas it paints them on and the hover that shows their tooltips.
174
+ */
175
+ function useScene(requested, width, height, setTooltip) {
176
+ const scene = React.useRef([]);
177
+ const renderer = useRenderer(requested);
178
+ const { canvas, repaint } = useCanvas(scene, width);
179
+ const { hover, leave } = useHover(scene, width, height, setTooltip);
180
+ return {
181
+ scene,
182
+ renderer,
183
+ canvas,
184
+ repaint,
185
+ hover,
186
+ leave
187
+ };
188
+ }
189
+ /** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
190
+ function useCamera(view, width, height) {
191
+ return React.useMemo(() => camera3D().origin({
192
+ x: width / 2 + (view.pan?.x ?? 0),
193
+ y: height / 2 + (view.pan?.y ?? 0)
194
+ }).scale(Math.min(width, height) * .3 * (view.zoom ?? 1)).perspective(view.perspective ?? Infinity).rotateY(view.yaw).rotateX(-view.tilt), [
195
+ view,
196
+ width,
197
+ height
198
+ ]);
199
+ }
200
+ /** `view` moved by one step. The tilt stops at the zenith and the nadir, the zoom at 1/4 and 8. */
201
+ function moved(view, move, initial) {
202
+ if (move.reset) return initial;
203
+ const step = {
204
+ yaw: 0,
205
+ tilt: 0,
206
+ zoom: 1,
207
+ pan: {
208
+ x: 0,
209
+ y: 0
210
+ },
211
+ ...move
212
+ };
213
+ const pan = view.pan ?? {
214
+ x: 0,
215
+ y: 0
216
+ };
217
+ const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
218
+ return {
219
+ ...view,
220
+ yaw: view.yaw + step.yaw,
221
+ tilt: clamp(-Math.PI / 2, Math.PI / 2, view.tilt + step.tilt),
222
+ zoom: clamp(.25, 8, (view.zoom ?? 1) * step.zoom),
223
+ pan: {
224
+ x: pan.x + step.pan.x,
225
+ y: pan.y + step.pan.y
226
+ }
227
+ };
228
+ }
229
+ /**
230
+ * The view of the camera, and the handlers that move it: a drag turns it,
231
+ * or pans it while Space is held, the keys of `SHORTCUTS` step it, Ctrl or ⌘
232
+ * with the wheel zooms it and a double click resets it. `orbiting` is true
233
+ * while the camera turns. `onDragStart` runs when a press becomes a drag,
234
+ * `onHover` on a move without one.
235
+ */
236
+ function useOrbit(defaultView, width, onDragStart, onHover) {
237
+ const [view, setView] = React.useState(defaultView);
238
+ const [orbiting, setOrbiting] = React.useState(false);
239
+ const drag = React.useRef(null);
240
+ const space = React.useRef(false);
241
+ const ref = useWheelZoom(setView);
242
+ const move = (step) => setView((v) => moved(v, step, defaultView));
243
+ const release = () => {
244
+ drag.current = null;
245
+ setOrbiting(false);
246
+ };
247
+ return {
248
+ view,
249
+ orbiting,
250
+ handlers: {
251
+ ref,
252
+ onPointerDown: (e) => {
253
+ drag.current = {
254
+ x: e.clientX,
255
+ y: e.clientY,
256
+ moved: false
257
+ };
258
+ },
259
+ onPointerMove: (e) => {
260
+ const d = drag.current;
261
+ if (!d) return onHover(e);
262
+ const dx = e.clientX - d.x;
263
+ const dy = e.clientY - d.y;
264
+ if (!d.moved && Math.hypot(dx, dy) < CLICK_DISTANCE) return;
265
+ if (!d.moved) {
266
+ e.currentTarget.setPointerCapture(e.pointerId);
267
+ onDragStart();
268
+ }
269
+ drag.current = {
270
+ x: e.clientX,
271
+ y: e.clientY,
272
+ moved: true
273
+ };
274
+ const k = width / e.currentTarget.getBoundingClientRect().width;
275
+ setOrbiting(!space.current);
276
+ move(space.current ? { pan: {
277
+ x: dx * k,
278
+ y: dy * k
279
+ } } : {
280
+ yaw: dx * DRAG,
281
+ tilt: dy * DRAG
282
+ });
283
+ },
284
+ onPointerUp: release,
285
+ onPointerCancel: release,
286
+ onDoubleClick: () => move({ reset: true }),
287
+ onKeyDown: (e) => {
288
+ if (e.key === " ") space.current = true;
289
+ const step = e.shiftKey && KEYS[`Shift+${e.key}`] || KEYS[e.key];
290
+ if (!step && e.key !== " ") return;
291
+ e.preventDefault();
292
+ if (!step) return;
293
+ setOrbiting(Boolean(step.yaw || step.tilt));
294
+ move(step);
295
+ },
296
+ onKeyUp: (e) => {
297
+ if (e.key === " ") space.current = false;
298
+ setOrbiting(false);
299
+ }
300
+ }
301
+ };
302
+ }
303
+ /**
304
+ * A ref for the svg that zooms the view with Ctrl or ⌘ and the wheel, which
305
+ * is also how a trackpad pinches. The wheel alone still scrolls the page.
306
+ */
307
+ function useWheelZoom(setView) {
308
+ return React.useCallback((svg) => {
309
+ const onWheel = (e) => {
310
+ if (!e.ctrlKey && !e.metaKey) return;
311
+ e.preventDefault();
312
+ setView((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
313
+ };
314
+ svg?.addEventListener("wheel", onWheel, { passive: false });
315
+ return () => svg?.removeEventListener("wheel", onWheel);
316
+ }, [setView]);
317
+ }
318
+ /**
319
+ * The tooltip of the mark under the pointer, the one painted last. `hover`
320
+ * sets it only when the pointer moves onto another mark, so a move within
321
+ * one does not render the chart again.
322
+ */
323
+ function useHover(scene, width, height, setTooltip) {
324
+ const hovered = React.useRef(void 0);
325
+ const show = (mark) => {
326
+ if (mark?.key === hovered.current?.key) return;
327
+ hovered.current = mark;
328
+ setTooltip(mark?.tooltip ?? null);
329
+ };
330
+ return {
331
+ hover: (e) => {
332
+ const rect = e.currentTarget.getBoundingClientRect();
333
+ const at = {
334
+ x: (e.clientX - rect.left) / rect.width * width,
335
+ y: (e.clientY - rect.top) / rect.height * height
336
+ };
337
+ show(hit(scene.current, at));
338
+ },
339
+ leave: () => show(void 0)
340
+ };
341
+ }
342
+ /** The topmost mark under `at`: the last one painted that holds it. */
343
+ function hit(marks, at) {
344
+ for (let i = marks.length - 1; i >= 0; i -= 1) {
345
+ const { points, r } = marks[i];
346
+ if (r === void 0 ? inside(points, at) : Math.hypot(points[0].x - at.x, points[0].y - at.y) <= r) return marks[i];
347
+ }
348
+ }
349
+ /** Whether `at` lies inside `polygon`: a ray from it crosses an odd number of its edges. */
350
+ function inside(polygon, at) {
351
+ let odd = false;
352
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
353
+ const a = polygon[i];
354
+ const b = polygon[j];
355
+ 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;
356
+ }
357
+ return odd;
358
+ }
359
+ /**
360
+ * The renderer the chart draws with: SVG on the server and in the first
361
+ * render on the client, then the requested one if the browser has it.
362
+ */
363
+ function useRenderer(requested) {
364
+ const [renderer, setRenderer] = React.useState("svg");
365
+ React.useEffect(() => {
366
+ const canvas = requested === "canvas" && document.createElement("canvas").getContext("2d");
367
+ setRenderer(canvas ? "canvas" : "svg");
368
+ }, [requested]);
369
+ return renderer;
370
+ }
371
+ /**
372
+ * A ref for the canvas of the marks, and the repaint that paints them: on new
373
+ * marks, when the canvas changes its size and when the theme changes. A color
374
+ * resolves on the canvas itself, inside the chart, so the `--color-*` apply.
375
+ */
376
+ function useCanvas(scene, width) {
377
+ const element = React.useRef(null);
378
+ const colors = React.useRef(/* @__PURE__ */ new Map());
379
+ const repaint = React.useCallback(() => {
380
+ const canvas = element.current;
381
+ if (!canvas) return;
382
+ paint(canvas, scene.current, width, (css) => {
383
+ let color = colors.current.get(css);
384
+ if (color === void 0) {
385
+ canvas.style.color = css;
386
+ color = getComputedStyle(canvas).color;
387
+ colors.current.set(css, color);
388
+ }
389
+ return color;
390
+ });
391
+ }, [scene, width]);
392
+ return {
393
+ canvas: React.useCallback((node) => {
394
+ element.current = node;
395
+ if (!node) return;
396
+ repaint();
397
+ const resize = new ResizeObserver(repaint);
398
+ resize.observe(node);
399
+ const theme = new MutationObserver(() => {
400
+ colors.current.clear();
401
+ repaint();
402
+ });
403
+ theme.observe(document.documentElement, { attributeFilter: ["class", "style"] });
404
+ return () => {
405
+ element.current = null;
406
+ resize.disconnect();
407
+ theme.disconnect();
408
+ };
409
+ }, [repaint]),
410
+ repaint
411
+ };
412
+ }
413
+ /** Paints `marks` on `canvas`, from the view box of `width` to the pixels of the canvas. */
414
+ function paint(canvas, marks, width, color) {
415
+ canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
416
+ canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
417
+ const ctx = canvas.getContext("2d");
418
+ if (!ctx) return;
419
+ const scale = canvas.width / width;
420
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
421
+ ctx.lineJoin = "round";
422
+ for (const { points, r, fill, stroke } of marks) {
423
+ ctx.beginPath();
424
+ if (r === void 0) {
425
+ for (const p of points) ctx.lineTo(p.x, p.y);
426
+ ctx.closePath();
427
+ } else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
428
+ ctx.fillStyle = color(fill);
429
+ ctx.fill();
430
+ const outline = r === void 0 ? fill : stroke;
431
+ if (!outline) continue;
432
+ ctx.strokeStyle = color(outline);
433
+ ctx.lineWidth = r === void 0 ? .5 : 1;
434
+ ctx.stroke();
435
+ }
436
+ }
437
+ /** The canvas over the chart box that the marks are painted on, if the chart draws on one. */
438
+ function ChartCanvas3D({ renderer, canvas, width, height }) {
439
+ if (renderer !== "canvas") return null;
440
+ return /* @__PURE__ */ jsx("canvas", {
441
+ ref: canvas,
442
+ className: "pointer-events-none absolute top-0 left-0 w-full",
443
+ style: { aspectRatio: `${width} / ${height}` }
444
+ });
445
+ }
446
+ /**
447
+ * The marks of a chart, drawn by the renderer of the container: as SVG
448
+ * elements, or on the canvas over the chart box. The hover reads them too.
449
+ */
450
+ function Marks3D({ marks }) {
451
+ const { renderer, scene, repaint } = useChart3D();
452
+ React.useLayoutEffect(() => {
453
+ scene.current = marks;
454
+ repaint();
455
+ });
456
+ if (renderer === "canvas") return null;
457
+ return marks.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
458
+ d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
459
+ fill,
460
+ stroke: fill,
461
+ strokeWidth: .5,
462
+ strokeLinejoin: "round"
463
+ }, key) : /* @__PURE__ */ jsx("circle", {
464
+ cx: points[0].x,
465
+ cy: points[0].y,
466
+ r,
467
+ fill,
468
+ stroke,
469
+ strokeWidth: stroke ? 1 : void 0
470
+ }, key));
471
+ }
472
+ function ChartStyle({ id, config }) {
473
+ const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
474
+ if (!colorConfig.length) return null;
475
+ return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: Object.entries(THEMES).map(([theme, prefix]) => `
476
+ ${prefix} [data-chart=${id}] {
477
+ ${colorConfig.map(([key, item]) => {
478
+ const color = item.theme?.[theme] || item.color;
479
+ return color ? ` --color-${key}: ${color};` : null;
480
+ }).join("\n")}
481
+ }
482
+ `).join("\n") } });
483
+ }
484
+ function ChartTooltip3D({ tooltip, width, height }) {
485
+ if (!tooltip) return null;
486
+ return /* @__PURE__ */ jsxs("div", {
487
+ 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",
488
+ style: {
489
+ left: `${tooltip.at.x / width * 100}%`,
490
+ top: `${tooltip.at.y / height * 100}%`
491
+ },
492
+ children: [tooltip.title && /* @__PURE__ */ jsx("div", {
493
+ className: "font-medium",
494
+ children: tooltip.title
495
+ }), tooltip.items.map((item, i) => /* @__PURE__ */ jsxs("div", {
496
+ className: "flex items-center gap-2",
497
+ children: [
498
+ item.color && /* @__PURE__ */ jsx("span", {
499
+ className: "size-2.5 shrink-0 rounded-[2px]",
500
+ style: { background: item.color }
501
+ }),
502
+ /* @__PURE__ */ jsx("span", {
503
+ className: "text-muted-foreground",
504
+ children: item.label
505
+ }),
506
+ /* @__PURE__ */ jsx("span", {
507
+ className: "ml-auto font-mono font-medium text-foreground tabular-nums",
508
+ children: typeof item.value === "number" ? item.value.toLocaleString() : item.value
509
+ })
510
+ ]
511
+ }, i))]
512
+ });
513
+ }
514
+ /**
515
+ * A button in the corner of the chart that lists `SHORTCUTS`. `?` on the
516
+ * chart opens and closes the list, Escape closes it.
517
+ */
518
+ function ChartShortcuts3D() {
519
+ const [open, setOpen] = React.useState(false);
520
+ const ref = React.useRef(null);
521
+ React.useEffect(() => {
522
+ const chart = ref.current?.parentElement;
523
+ const onKeyDown = (e) => {
524
+ if (e.key === "?") setOpen((o) => !o);
525
+ if (e.key === "Escape") setOpen(false);
526
+ };
527
+ chart?.addEventListener("keydown", onKeyDown);
528
+ return () => chart?.removeEventListener("keydown", onKeyDown);
529
+ }, []);
530
+ return /* @__PURE__ */ jsxs("div", {
531
+ ref,
532
+ className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
533
+ children: [/* @__PURE__ */ jsx("button", {
534
+ type: "button",
535
+ "aria-label": "Keyboard shortcuts",
536
+ "aria-expanded": open,
537
+ onClick: () => setOpen((o) => !o),
538
+ 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",
539
+ children: /* @__PURE__ */ jsx(KeyboardIcon, {})
540
+ }), open && /* @__PURE__ */ jsx("div", {
541
+ 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",
542
+ children: SHORTCUTS.map(({ hint, label }, i) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("span", {
543
+ className: "flex gap-1",
544
+ children: hint.map((key) => /* @__PURE__ */ jsx("kbd", {
545
+ 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",
546
+ children: key
547
+ }, key))
548
+ }), /* @__PURE__ */ jsx("span", {
549
+ className: "text-muted-foreground",
550
+ children: label
551
+ })] }, i))
552
+ })]
553
+ });
554
+ }
555
+ function ChartLegend3D({ config }) {
556
+ return /* @__PURE__ */ jsx("div", {
557
+ className: "flex items-center justify-center gap-4 pt-3",
558
+ children: Object.entries(config).map(([key, item]) => /* @__PURE__ */ jsxs("div", {
559
+ className: "flex items-center gap-1.5",
560
+ children: [item.icon ? /* @__PURE__ */ jsx(item.icon, {}) : /* @__PURE__ */ jsx("span", {
561
+ className: "size-2 rounded-[2px]",
562
+ style: { background: `var(--color-${key})` }
563
+ }), item.label ?? key]
564
+ }, key))
565
+ });
566
+ }
567
+ /**
568
+ * The walls of the chart box that face away from the camera, with their
569
+ * gridlines, and an axis with ticks on the outline of the box.
570
+ */
571
+ function Axes3D({ spec, world, format = {}, titles = {} }) {
572
+ const { camera } = useChart3D();
573
+ const box = axes3D().camera(camera).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
574
+ return /* @__PURE__ */ jsxs("g", {
575
+ "data-slot": "chart-3d-axes",
576
+ children: [box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
577
+ d: box.draw(wall),
578
+ className: "fill-muted/40 stroke-border",
579
+ strokeWidth: .75
580
+ }, `${wall.axis}${wall.side}`)), box.axes(spec).map((edge) => /* @__PURE__ */ jsx(AxisEdge3D, {
581
+ edge,
582
+ format: format[edge.axis] ?? String,
583
+ title: titles[edge.axis]
584
+ }, edge.axis))]
585
+ });
586
+ }
587
+ /**
588
+ * The tick labels of one axis on the outline of the box, pushed away from the
589
+ * box along the normal of the edge, and the title of the axis further out.
590
+ */
591
+ function AxisEdge3D({ edge, format, title }) {
592
+ if (!edge.length) return null;
593
+ const { normal: n } = edge;
594
+ const start = edge[0].projected;
595
+ const end = edge[edge.length - 1].projected;
596
+ const anchor = n.x > .3 ? "start" : n.x < -.3 ? "end" : "middle";
597
+ return /* @__PURE__ */ jsxs("g", {
598
+ className: "transition-opacity delay-300 duration-300 in-data-[orbiting=true]:opacity-0 in-data-[orbiting=true]:delay-0 in-data-[orbiting=true]:duration-150",
599
+ children: [edge.map((tick, i) => /* @__PURE__ */ jsx("text", {
600
+ x: tick.projected.x + n.x * 10,
601
+ y: tick.projected.y + n.y * 10,
602
+ textAnchor: anchor,
603
+ dominantBaseline: "central",
604
+ className: "fill-muted-foreground text-[10px]",
605
+ children: format(tick[edge.axis])
606
+ }, i)), title && /* @__PURE__ */ jsx("text", {
607
+ x: (start.x + end.x) / 2 + n.x * 44,
608
+ y: (start.y + end.y) / 2 + n.y * 44,
609
+ textAnchor: anchor,
610
+ dominantBaseline: "central",
611
+ className: "fill-foreground font-medium",
612
+ children: title
613
+ })]
614
+ });
615
+ }
616
+ /** A nice axis from `lo` to `hi` with about `count` ticks. */
617
+ function axis(lo, hi, count = 5) {
618
+ const [start, stop] = nice(lo, lo === hi ? hi + 1 : hi, count);
619
+ return {
620
+ domain: [start, stop],
621
+ ticks: ticks(start, stop, count)
622
+ };
623
+ }
624
+ /** The linear map from `domain` to `range`. */
625
+ function linear([d0, d1], [r0, r1]) {
626
+ return (value) => r0 + (value - d0) / (d1 - d0 || 1) * (r1 - r0);
627
+ }
628
+ /** The world box of `spec`: x from -1 to 1, y up from 1 to -1, z from -`depth` to `depth`. */
629
+ function worldBox(spec, depth = 1) {
630
+ return {
631
+ x: linear(spec.x.domain, [-1, 1]),
632
+ y: linear(spec.y.domain, [1, -1]),
633
+ z: linear(spec.z.domain, [-depth, depth])
634
+ };
635
+ }
636
+ const LIGHT = {
637
+ x: -.4,
638
+ y: -.8,
639
+ z: .44
640
+ };
641
+ /**
642
+ * `color` lit by the light from the upper left: the face that looks at it
643
+ * keeps the color, one that turns away gets darker. A CSS color-mix, so the
644
+ * shade follows the theme variables.
645
+ */
646
+ function shade(face, color) {
647
+ const n = normal(face.map((p) => p.rotated));
648
+ const side = n.z < 0 ? -1 : 1;
649
+ const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
650
+ return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
651
+ }
652
+ /** The mean of the projected points, where a tooltip points at. */
653
+ function center(points) {
654
+ const sum = points.reduce((s, p) => ({
655
+ x: s.x + p.projected.x,
656
+ y: s.y + p.projected.y
657
+ }), {
658
+ x: 0,
659
+ y: 0
660
+ });
661
+ return {
662
+ x: sum.x / points.length,
663
+ y: sum.y / points.length
664
+ };
665
+ }
666
+ // registry/new-york/ui/bar-chart-3d.tsx
667
+ /**
668
+ * Bars on a floor: one row per entry of `data` along x, one per series
669
+ * along z, the value as the height. The series are the keys of the chart
670
+ * config unless `keys` names them.
671
+ */
672
+ function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
673
+ const { config, camera } = useChart3D();
674
+ const series = keys ?? Object.keys(config);
675
+ const values = data.flatMap((d) => series.map((key) => Number(d[key]) || 0));
676
+ const { spec, world, size } = barBox(data.length, series.length, values, barSize);
677
+ const bars = cubes3D().camera(camera).data(data.flatMap((d, i) => series.map((key, j) => {
678
+ const value = Number(d[key]) || 0;
679
+ return Object.assign(barCorners(world, i, j, value, size), {
680
+ i,
681
+ key,
682
+ value
683
+ });
684
+ }))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
685
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
686
+ spec,
687
+ world,
688
+ titles,
689
+ format: {
690
+ x: (i) => (format.x ?? String)(String(data[i]?.[index] ?? "")),
691
+ y: format.y,
692
+ z: (j) => config[series[j]]?.label ?? series[j]
693
+ }
694
+ }), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
695
+ id: `${bar.i}-${bar.key}`,
696
+ faces: bar.faces,
697
+ color: `var(--color-${bar.key})`,
698
+ title: String(data[bar.i][index]),
699
+ label: config[bar.key]?.label ?? bar.key,
700
+ value: bar.value
701
+ })) })] });
702
+ }
703
+ /**
704
+ * The box of a bar chart: a row per entry along x, a row per series along z,
705
+ * and the size of a bar, the share `barSize` of its cell.
706
+ */
707
+ function barBox(entries, series, values, barSize) {
708
+ const index = (length) => Array.from({ length }, (_, i) => i);
709
+ const spec = {
710
+ x: {
711
+ domain: [-.5, entries - .5],
712
+ ticks: index(entries)
713
+ },
714
+ y: axis(Math.min(0, ...values), Math.max(0, ...values)),
715
+ z: {
716
+ domain: [-.5, series - .5],
717
+ ticks: index(series)
718
+ }
719
+ };
720
+ const depth = Math.min(1, Math.max(.25, series / entries));
721
+ const size = {
722
+ width: 2 / entries * barSize,
723
+ depth: 2 * depth / series * barSize
724
+ };
725
+ return {
726
+ spec,
727
+ world: worldBox(spec, depth),
728
+ size
729
+ };
730
+ }
731
+ /**
732
+ * The corners of one bar, from the floor to its value, at row `x` and series
733
+ * `z`. box() rises towards -y, so a negative bar starts at its value.
734
+ */
735
+ function barCorners(world, x, z, value, size) {
736
+ const floor = world.y(0);
737
+ const top = world.y(value);
738
+ return box({
739
+ x: world.x(x),
740
+ y: Math.max(floor, top),
741
+ z: world.z(z),
742
+ ...size,
743
+ height: Math.abs(floor - top)
744
+ });
745
+ }
746
+ /** The marks of one bar: the faces that look at the camera, shaded, each with its entry and value on hover. */
747
+ function barMarks({ id, faces, color, title, label, value }) {
748
+ const tooltip = {
749
+ at: center(faces.find((face) => face.face === "top")),
750
+ title,
751
+ items: [{
752
+ label,
753
+ value,
754
+ color
755
+ }]
756
+ };
757
+ return faces.filter((face) => face.ccw).map((face) => ({
758
+ key: `${id}-${face.face}`,
759
+ points: face.map((p) => p.projected),
760
+ fill: shade(face, color),
761
+ tooltip
762
+ }));
763
+ }
764
+ // registry/new-york/ui/scatter-chart-3d.tsx
765
+ /**
766
+ * Points in the chart box, at the fields `x`, `y` and `z` of each entry.
767
+ * `series` names the field whose value is the config key of the color.
768
+ */
769
+ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
770
+ const { config, camera } = useChart3D();
771
+ const fields = {
772
+ x,
773
+ y,
774
+ z
775
+ };
776
+ const read = (d, axis) => Number(d[fields[axis]]);
777
+ const range = (a) => {
778
+ const [lo = 0, hi = 0] = extent(data, (d) => read(d, a));
779
+ return axis(lo, hi);
780
+ };
781
+ const spec = {
782
+ x: range("x"),
783
+ y: range("y"),
784
+ z: range("z")
785
+ };
786
+ const world = worldBox(spec);
787
+ 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")));
788
+ const fallback = Object.keys(config)[0];
789
+ const marks = points.data(data.map((d, i) => ({
790
+ d,
791
+ i
792
+ }))).sort(sort).map((p) => {
793
+ const key = series ? String(p.d[series]) : fallback;
794
+ return {
795
+ key: String(p.i),
796
+ points: [p.projected],
797
+ r: radius,
798
+ fill: `var(--color-${key})`,
799
+ stroke: "var(--background)",
800
+ tooltip: {
801
+ at: p.projected,
802
+ title: series ? config[key]?.label ?? key : void 0,
803
+ items: [
804
+ "x",
805
+ "y",
806
+ "z"
807
+ ].map((a) => ({
808
+ label: titles?.[a] ?? fields[a],
809
+ value: read(p.d, a)
810
+ }))
811
+ }
812
+ };
813
+ });
814
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
815
+ spec,
816
+ world,
817
+ format,
818
+ titles
819
+ }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
820
+ }
821
+ // registry/new-york/ui/surface-chart-3d.tsx
822
+ /**
823
+ * A surface over a grid: `data[row][column]` is the height, the columns run
824
+ * along x and the rows along z. The fill ramps from the first color of
825
+ * `colors` at the lowest height to the second at the highest; both are keys
826
+ * of the chart config, by default its first two.
827
+ */
828
+ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
829
+ const { config, camera } = useChart3D();
830
+ const [low, high = low] = colors ?? Object.keys(config);
831
+ const rows = data.length;
832
+ const columns = data[0]?.length ?? 0;
833
+ const [x0, x1] = domain.x ?? [0, columns - 1];
834
+ const [z0, z1] = domain.z ?? [0, rows - 1];
835
+ const [lo = 0, hi = 0] = extent(data.flat());
836
+ const spec = {
837
+ x: axis(x0, x1),
838
+ y: axis(lo, hi),
839
+ z: axis(z0, z1)
840
+ };
841
+ const world = worldBox(spec);
842
+ const toX = linear([0, columns - 1], [x0, x1]);
843
+ const toZ = linear([0, rows - 1], [z0, z1]);
844
+ const grid = gridPlanes3D().camera(camera).rows(columns).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
845
+ const points = data.flatMap((row, r) => row.map((y, c) => ({
846
+ x: toX(c),
847
+ y,
848
+ z: toZ(r)
849
+ })));
850
+ const marks = grid.data(points).sort(sort).map((quad) => {
851
+ const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
852
+ const t = Math.round((height - lo) / (hi - lo || 1) * 100);
853
+ return {
854
+ key: quad.plane,
855
+ points: quad.map((p) => p.projected),
856
+ fill: shade(quad, `color-mix(in oklch, var(--color-${high}) ${t}%, var(--color-${low}))`),
857
+ tooltip: {
858
+ at: center(quad),
859
+ items: [{
860
+ label: titles?.y ?? "value",
861
+ value: height
862
+ }]
863
+ }
864
+ };
865
+ });
866
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
867
+ spec,
868
+ world,
869
+ format,
870
+ titles
871
+ }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
872
+ }
873
+ export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis, center, linear, shade, useChart3D, worldBox };
874
+
875
+ //# sourceMappingURL=index.js.map