@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/LICENSE +27 -0
- package/NOTICE +33 -0
- package/README.md +88 -2
- package/dist/index.d.ts +267 -0
- package/dist/index.js +1251 -0
- package/dist/index.js.map +1 -0
- package/package.json +112 -4
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
|