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