@niekes/charts-3d 0.5.0 → 0.7.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/NOTICE +22 -0
- package/README.md +6 -5
- package/dist/index.d.ts +533 -29
- package/dist/index.js +2116 -449
- package/dist/index.js.map +1 -1
- package/package.json +13 -4
package/dist/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client"
|
|
2
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";
|
|
3
|
+
import { extent, nice, range, ticks } from "d3-array";
|
|
4
|
+
import { axes3D, box, camera3D, cubes3D, gridPlanes3D, normal, points3D, polygons3D, sort } from "d3-3d";
|
|
6
5
|
import { clsx } from "clsx";
|
|
7
6
|
import { twMerge } from "tailwind-merge";
|
|
8
7
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { geoArea, geoOrthographic, geoPath } from "d3-geo";
|
|
9
9
|
// lib/utils.ts
|
|
10
10
|
function cn(...inputs) {
|
|
11
11
|
return twMerge(clsx(inputs));
|
|
@@ -30,7 +30,11 @@ const ZOOM_CURSOR = 400;
|
|
|
30
30
|
const SHORTCUTS = {
|
|
31
31
|
dragRotate: {
|
|
32
32
|
label: "Rotate",
|
|
33
|
-
|
|
33
|
+
gesture: ["Mod", "Drag"]
|
|
34
|
+
},
|
|
35
|
+
touchOrbit: {
|
|
36
|
+
label: "Rotate",
|
|
37
|
+
gesture: ["Two-finger drag"]
|
|
34
38
|
},
|
|
35
39
|
rotateLeft: {
|
|
36
40
|
label: "Rotate",
|
|
@@ -54,7 +58,7 @@ const SHORTCUTS = {
|
|
|
54
58
|
},
|
|
55
59
|
dragPan: {
|
|
56
60
|
label: "Pan",
|
|
57
|
-
|
|
61
|
+
gesture: ["Alt", "Drag"]
|
|
58
62
|
},
|
|
59
63
|
panLeft: {
|
|
60
64
|
label: "Pan",
|
|
@@ -100,7 +104,11 @@ const SHORTCUTS = {
|
|
|
100
104
|
},
|
|
101
105
|
wheelZoom: {
|
|
102
106
|
label: "Zoom",
|
|
103
|
-
|
|
107
|
+
gesture: ["Mod", "Scroll"]
|
|
108
|
+
},
|
|
109
|
+
pinchZoom: {
|
|
110
|
+
label: "Zoom",
|
|
111
|
+
gesture: ["Pinch"]
|
|
104
112
|
},
|
|
105
113
|
reset: {
|
|
106
114
|
label: "Reset",
|
|
@@ -109,7 +117,7 @@ const SHORTCUTS = {
|
|
|
109
117
|
},
|
|
110
118
|
doubleClickReset: {
|
|
111
119
|
label: "Reset",
|
|
112
|
-
|
|
120
|
+
gesture: ["Double-click"]
|
|
113
121
|
},
|
|
114
122
|
help: {
|
|
115
123
|
label: "Keyboard shortcuts",
|
|
@@ -128,6 +136,8 @@ const mac = () => /mac/i.test(globalThis.navigator?.userAgentData?.platform ?? g
|
|
|
128
136
|
const modLabel = () => mac() ? "⌘" : "Ctrl";
|
|
129
137
|
/** The label of the Alt key: ⌥ on a Mac, Alt elsewhere. */
|
|
130
138
|
const altLabel = () => mac() ? "⌥" : "Alt";
|
|
139
|
+
/** The platform never changes, so its store has nothing to tell. */
|
|
140
|
+
const never = () => () => {};
|
|
131
141
|
/** Whether the platform modifier - ⌘ on a Mac, Ctrl elsewhere - is held in `e`. */
|
|
132
142
|
const platformMod = (e) => mac() ? e.metaKey : e.ctrlKey;
|
|
133
143
|
/** Whether `value` lies between the ends `lo` and `hi`. */
|
|
@@ -184,7 +194,7 @@ function shortcutsOf(keymap) {
|
|
|
184
194
|
...SHORTCUTS[action],
|
|
185
195
|
...Array.isArray(own) ? { keys: own } : own || {}
|
|
186
196
|
};
|
|
187
|
-
const on = own !== false && (shortcut.
|
|
197
|
+
const on = own !== false && (shortcut.gesture !== void 0 || keys.length > 0);
|
|
188
198
|
return {
|
|
189
199
|
...shortcut,
|
|
190
200
|
action,
|
|
@@ -193,19 +203,24 @@ function shortcutsOf(keymap) {
|
|
|
193
203
|
};
|
|
194
204
|
});
|
|
195
205
|
}
|
|
206
|
+
/** The keys or the gesture of a shortcut as the panel writes them, with the modifiers of the reader's platform. */
|
|
207
|
+
const partsOf = ({ keys, gesture }) => (gesture ?? split(keys[0])).map((k) => k === "Mod" ? modLabel() : k === "Alt" ? altLabel() : KBD[k] ?? k);
|
|
196
208
|
/**
|
|
197
209
|
* The rows of the shortcut panel: the actions by label, the keys of a row in
|
|
198
210
|
* groups of the same modifiers, left of its gestures, each a group of its own.
|
|
199
211
|
*/
|
|
200
212
|
function rowsOf(shortcuts) {
|
|
201
213
|
const rows = /* @__PURE__ */ new Map();
|
|
202
|
-
for (const { label, keys,
|
|
203
|
-
const parts = (
|
|
214
|
+
for (const { label, keys, gesture } of shortcuts) {
|
|
215
|
+
const parts = partsOf({
|
|
216
|
+
keys,
|
|
217
|
+
gesture
|
|
218
|
+
});
|
|
204
219
|
const row = rows.get(label) ?? {
|
|
205
220
|
keys: /* @__PURE__ */ new Map(),
|
|
206
221
|
gestures: []
|
|
207
222
|
};
|
|
208
|
-
if (
|
|
223
|
+
if (gesture) row.gestures.push(parts);
|
|
209
224
|
else {
|
|
210
225
|
const group = parts.slice(0, -1).join("+");
|
|
211
226
|
const known = row.keys.get(group);
|
|
@@ -221,8 +236,8 @@ function rowsOf(shortcuts) {
|
|
|
221
236
|
}
|
|
222
237
|
/**
|
|
223
238
|
* What a keymap does: the actions that are on, the step of each key, the help
|
|
224
|
-
* keys
|
|
225
|
-
* nothing to open and are off.
|
|
239
|
+
* keys, the rows of the panel and the hint of a gesture. Without the `panel`,
|
|
240
|
+
* the help keys have nothing to open and are off.
|
|
226
241
|
*/
|
|
227
242
|
function keymapOf(keymap, panel) {
|
|
228
243
|
const all = shortcutsOf(keymap);
|
|
@@ -233,24 +248,31 @@ function keymapOf(keymap, panel) {
|
|
|
233
248
|
help: on.find((s) => s.action === "help")?.keys ?? [],
|
|
234
249
|
label: all.find((s) => s.action === "help").label,
|
|
235
250
|
aria: on.flatMap((s) => s.keys.map(aria)).join(" "),
|
|
236
|
-
rows: panel ? rowsOf(on) : []
|
|
251
|
+
rows: panel ? rowsOf(on) : [],
|
|
252
|
+
hint: (action) => {
|
|
253
|
+
const shortcut = on.find((s) => s.action === action);
|
|
254
|
+
return shortcut && {
|
|
255
|
+
label: shortcut.label,
|
|
256
|
+
gesture: partsOf(shortcut)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
237
259
|
};
|
|
238
260
|
}
|
|
239
261
|
function ChartContainer3D({ id, config, defaultView = {
|
|
240
262
|
yaw: Math.PI / 6,
|
|
241
263
|
tilt: Math.PI / 6
|
|
242
|
-
}, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", className, children, "aria-label": ariaLabel, ...props }) {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
const { scene, renderer, canvas, repaint, hover, hide, leave, tooltip, focus } = useScene(requested, width, height);
|
|
264
|
+
}, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", tooltip = "top-right", className, children, "aria-label": ariaLabel, ...props }) {
|
|
265
|
+
const chartId = chartIdOf(id, React.useId());
|
|
266
|
+
const { scene, place, renderer, canvas, repaint, hover, hide, leave, tip, focus } = useScene(requested, width, height, tooltip);
|
|
246
267
|
const keys = keymapOf(keymap, shortcuts);
|
|
247
|
-
const { view, handlers } = useOrbit(defaultView, width, keys, hide, hover);
|
|
268
|
+
const { view, handlers, hint } = useOrbit(defaultView, width, keys, hide, hover);
|
|
248
269
|
const camera = useCamera(view, width, height);
|
|
249
270
|
const context = React.useMemo(() => ({
|
|
250
271
|
config,
|
|
251
272
|
camera,
|
|
252
273
|
renderer,
|
|
253
274
|
scene,
|
|
275
|
+
place,
|
|
254
276
|
repaint,
|
|
255
277
|
focus,
|
|
256
278
|
animationDuration
|
|
@@ -258,6 +280,7 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
258
280
|
config,
|
|
259
281
|
camera,
|
|
260
282
|
renderer,
|
|
283
|
+
place,
|
|
261
284
|
repaint,
|
|
262
285
|
focus,
|
|
263
286
|
animationDuration
|
|
@@ -275,10 +298,10 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
275
298
|
/* @__PURE__ */ jsxs("div", {
|
|
276
299
|
className: "relative rounded-lg has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50",
|
|
277
300
|
children: [
|
|
278
|
-
/* @__PURE__ */ jsx(
|
|
301
|
+
/* @__PURE__ */ jsx(ChartSvg3D, {
|
|
279
302
|
viewBox: `0 0 ${width} ${height}`,
|
|
280
|
-
|
|
281
|
-
|
|
303
|
+
keys,
|
|
304
|
+
label: ariaLabel,
|
|
282
305
|
onPointerLeave: leave,
|
|
283
306
|
...handlers,
|
|
284
307
|
children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
|
|
@@ -296,11 +319,8 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
296
319
|
camera,
|
|
297
320
|
corner: gizmo
|
|
298
321
|
}),
|
|
299
|
-
/* @__PURE__ */ jsx(ChartTooltip3D, {
|
|
300
|
-
|
|
301
|
-
width,
|
|
302
|
-
height
|
|
303
|
-
})
|
|
322
|
+
/* @__PURE__ */ jsx(ChartTooltip3D, { tip }),
|
|
323
|
+
/* @__PURE__ */ jsx(ChartHint3D, { ...hint })
|
|
304
324
|
]
|
|
305
325
|
}),
|
|
306
326
|
/* @__PURE__ */ jsx(ChartShortcuts3D, { ...keys }),
|
|
@@ -308,49 +328,141 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
308
328
|
]
|
|
309
329
|
});
|
|
310
330
|
}
|
|
331
|
+
/** The id the colors of a chart are scoped to: its own `id`, or the one React made up. */
|
|
332
|
+
function chartIdOf(id, uniqueId) {
|
|
333
|
+
return `chart-${id || uniqueId.replace(/:/g, "")}`;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The svg of the chart, saying what `keys` do. The server knows no platform
|
|
337
|
+
* and says Ctrl; the store renders once more after hydration, with the
|
|
338
|
+
* modifier of the reader. The panel reads the platform directly: it opens on
|
|
339
|
+
* the client only.
|
|
340
|
+
*/
|
|
341
|
+
function ChartSvg3D({ keys, label, viewBox, ...props }) {
|
|
342
|
+
const mod = React.useSyncExternalStore(never, modLabel, () => "Ctrl");
|
|
343
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
344
|
+
viewBox,
|
|
345
|
+
role: "img",
|
|
346
|
+
...keymapProps(keys, mod, label),
|
|
347
|
+
...props
|
|
348
|
+
});
|
|
349
|
+
}
|
|
311
350
|
/**
|
|
312
|
-
* What the svg says about its keymap: a label that names the drag and the
|
|
351
|
+
* What the svg says about its keymap: a label that names the `mod` drag and the
|
|
313
352
|
* help key if they are on, the keys, the focus only if a key does something
|
|
314
|
-
* and
|
|
353
|
+
* and, if a drag or two fingers do, `touch-pan-y`: a vertical swipe scrolls
|
|
354
|
+
* the page and every other touch reaches the chart.
|
|
315
355
|
*/
|
|
316
|
-
function keymapProps(keys, label) {
|
|
317
|
-
const drag =
|
|
356
|
+
function keymapProps(keys, mod, label) {
|
|
357
|
+
const drag = [
|
|
358
|
+
"dragRotate",
|
|
359
|
+
"dragPan",
|
|
360
|
+
"touchOrbit",
|
|
361
|
+
"pinchZoom"
|
|
362
|
+
].some(keys.on);
|
|
318
363
|
const help = keys.help[0];
|
|
319
364
|
const said = [
|
|
320
365
|
"3D chart.",
|
|
321
|
-
keys.on("dragRotate") && `${
|
|
366
|
+
keys.on("dragRotate") && `${mod} and drag to rotate it.`,
|
|
322
367
|
help && `Press ${aria(help)} for all shortcuts.`
|
|
323
368
|
];
|
|
324
369
|
return {
|
|
325
370
|
"aria-label": label ?? said.filter(Boolean).join(" "),
|
|
326
371
|
"aria-keyshortcuts": keys.aria || void 0,
|
|
327
372
|
tabIndex: keys.aria ? 0 : void 0,
|
|
328
|
-
className: cn("block h-auto w-full outline-hidden select-none mask-x-from-96% mask-y-from-96%", drag && "touch-
|
|
373
|
+
className: cn("block h-auto w-full outline-hidden select-none mask-x-from-96% mask-y-from-96%", drag && "touch-pan-y")
|
|
329
374
|
};
|
|
330
375
|
}
|
|
376
|
+
const PRECEDING = 2;
|
|
331
377
|
/**
|
|
332
378
|
* The marks of the chart and what reads them: the renderer that draws them,
|
|
333
|
-
* the canvas it paints them on and the hover that shows their tooltips.
|
|
379
|
+
* the canvas it paints them on and the hover that shows their tooltips. The
|
|
380
|
+
* scene holds the marks of every `Marks3D` in the container, each set placed
|
|
381
|
+
* by `place`, in the order of the sets in the document - the order SVG paints
|
|
382
|
+
* them in - so a set that mounts later but sits earlier lies under the others
|
|
383
|
+
* on every renderer.
|
|
334
384
|
*/
|
|
335
|
-
function useScene(requested, width, height) {
|
|
385
|
+
function useScene(requested, width, height, placement) {
|
|
336
386
|
const scene = React.useRef([]);
|
|
337
|
-
const
|
|
387
|
+
const sets = React.useRef(/* @__PURE__ */ new Map());
|
|
388
|
+
const place = React.useCallback((id, set) => {
|
|
389
|
+
if (set) sets.current.set(id, set);
|
|
390
|
+
else sets.current.delete(id);
|
|
391
|
+
scene.current = [...sets.current.values()].sort((a, b) => a.node.compareDocumentPosition(b.node) & PRECEDING ? 1 : -1).flatMap((s) => s.marks);
|
|
392
|
+
}, []);
|
|
393
|
+
const tip = useTip(placement);
|
|
338
394
|
const focus = useFocus();
|
|
339
395
|
const renderer = useRenderer(requested);
|
|
340
|
-
const { canvas, repaint } = useCanvas(scene, width
|
|
341
|
-
const { hover, hide, leave } = useHover(scene, width, height,
|
|
396
|
+
const { canvas, repaint } = useCanvas(scene, width);
|
|
397
|
+
const { hover, hide, leave } = useHover(scene, width, height, tip, focus);
|
|
342
398
|
return {
|
|
343
399
|
scene,
|
|
400
|
+
place,
|
|
344
401
|
renderer,
|
|
345
402
|
canvas,
|
|
346
403
|
repaint,
|
|
347
404
|
hover,
|
|
348
405
|
hide,
|
|
349
406
|
leave,
|
|
350
|
-
|
|
407
|
+
tip,
|
|
351
408
|
focus
|
|
352
409
|
};
|
|
353
410
|
}
|
|
411
|
+
const TIP_GAP = 12;
|
|
412
|
+
const shift = (isBefore) => isBefore ? `calc(-100% - ${TIP_GAP}px)` : `${TIP_GAP}px`;
|
|
413
|
+
/**
|
|
414
|
+
* Whether a tooltip of `size` goes before the pointer at `at`, on an axis of
|
|
415
|
+
* `length`: as `preferred`, unless it would leave the chart there and the
|
|
416
|
+
* other side has more room.
|
|
417
|
+
*/
|
|
418
|
+
function before(at, size, length, preferred) {
|
|
419
|
+
const room = preferred ? at : length - at;
|
|
420
|
+
return room < size + TIP_GAP && length - room > room ? !preferred : preferred;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The tooltip of the hovered mark and where it sits: beside the pointer, in
|
|
424
|
+
* the pixels of the chart, at `placement`. `follow` moves it with the pointer
|
|
425
|
+
* straight in the DOM, so a move within a mark renders nothing; `place` puts
|
|
426
|
+
* it there again once it shows, or its content changes its size.
|
|
427
|
+
*/
|
|
428
|
+
function useTip(placement) {
|
|
429
|
+
const [tooltip, set] = React.useState(null);
|
|
430
|
+
const ref = React.useRef(null);
|
|
431
|
+
const pointer = React.useRef({
|
|
432
|
+
x: 0,
|
|
433
|
+
y: 0,
|
|
434
|
+
width: 0,
|
|
435
|
+
height: 0
|
|
436
|
+
});
|
|
437
|
+
const place = React.useCallback(() => {
|
|
438
|
+
const el = ref.current;
|
|
439
|
+
if (!el) return;
|
|
440
|
+
const shows = !el.dataset.placement;
|
|
441
|
+
if (shows) el.style.transition = "none";
|
|
442
|
+
const { x, y, width, height } = pointer.current;
|
|
443
|
+
const top = before(y, el.offsetHeight, height, placement.startsWith("top"));
|
|
444
|
+
const left = before(x, el.offsetWidth, width, placement.endsWith("left"));
|
|
445
|
+
el.style.left = `${x}px`;
|
|
446
|
+
el.style.top = `${y}px`;
|
|
447
|
+
el.style.translate = `${shift(left)} ${shift(top)}`;
|
|
448
|
+
el.dataset.placement = `${top ? "top" : "bottom"}-${left ? "left" : "right"}`;
|
|
449
|
+
if (shows) {
|
|
450
|
+
el.getBoundingClientRect();
|
|
451
|
+
el.style.transition = "";
|
|
452
|
+
}
|
|
453
|
+
}, [placement]);
|
|
454
|
+
const follow = (at) => {
|
|
455
|
+
pointer.current = at;
|
|
456
|
+
place();
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
tooltip,
|
|
460
|
+
set,
|
|
461
|
+
ref,
|
|
462
|
+
place,
|
|
463
|
+
follow
|
|
464
|
+
};
|
|
465
|
+
}
|
|
354
466
|
/** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
|
|
355
467
|
function useCamera(view, width, height) {
|
|
356
468
|
return React.useMemo(() => camera3D().origin({
|
|
@@ -367,7 +479,14 @@ const ZERO = {
|
|
|
367
479
|
x: 0,
|
|
368
480
|
y: 0
|
|
369
481
|
};
|
|
370
|
-
/**
|
|
482
|
+
/**
|
|
483
|
+
* `view` moved by one step. The tilt stops at the zenith and the nadir, the
|
|
484
|
+
* zoom at 1/4 and 8, and a turn is divided by the zoom: zoomed in, a drag
|
|
485
|
+
* moves the picture under the pointer as far as zoomed out, and a key turns
|
|
486
|
+
* it as far on screen. A zoom keeps the point `at` where it is, by the zoom
|
|
487
|
+
* after its limits, so the chart stands still at them; without `at` that
|
|
488
|
+
* point is the origin of the chart, and the pan stays.
|
|
489
|
+
*/
|
|
371
490
|
function moved(view, move, initial) {
|
|
372
491
|
if (move.reset) return initial;
|
|
373
492
|
const step = {
|
|
@@ -384,14 +503,18 @@ function moved(view, move, initial) {
|
|
|
384
503
|
x: 0,
|
|
385
504
|
y: 0
|
|
386
505
|
};
|
|
506
|
+
const from = view.zoom ?? 1;
|
|
507
|
+
const zoom = clamp(.25, 8, from * step.zoom);
|
|
508
|
+
const k = zoom / from;
|
|
509
|
+
const at = move.at ?? pan;
|
|
387
510
|
return {
|
|
388
511
|
...view,
|
|
389
|
-
yaw: view.yaw + step.yaw,
|
|
390
|
-
tilt: clamp(-Math.PI / 2, Math.PI / 2, view.tilt + step.tilt),
|
|
391
|
-
zoom
|
|
512
|
+
yaw: view.yaw + step.yaw / from,
|
|
513
|
+
tilt: clamp(-Math.PI / 2, Math.PI / 2, view.tilt + step.tilt / from),
|
|
514
|
+
zoom,
|
|
392
515
|
pan: {
|
|
393
|
-
x: pan.x + step.pan.x,
|
|
394
|
-
y: pan.y + step.pan.y
|
|
516
|
+
x: at.x - (at.x - pan.x) * k + step.pan.x,
|
|
517
|
+
y: at.y - (at.y - pan.y) * k + step.pan.y
|
|
395
518
|
}
|
|
396
519
|
};
|
|
397
520
|
}
|
|
@@ -409,11 +532,17 @@ function animates() {
|
|
|
409
532
|
* that is new starts at `enter`, or where it ends; a key that is gone glides
|
|
410
533
|
* back to `enter`, the mirror of its entering - without one it stands where
|
|
411
534
|
* it is - and leaves when the glide is done. Without time, or with reduced
|
|
412
|
-
* motion, the numbers jump.
|
|
535
|
+
* motion, the numbers jump. At rest it hands back the record of its target,
|
|
536
|
+
* no copy, so no frame rebuilds it: read it, never write to it.
|
|
413
537
|
*/
|
|
414
538
|
function useTween(values, enter) {
|
|
415
539
|
const { animationDuration: duration = 600 } = useChart3D();
|
|
416
|
-
const
|
|
540
|
+
const last = React.useRef(null);
|
|
541
|
+
if (last.current?.values !== values) last.current = {
|
|
542
|
+
values,
|
|
543
|
+
key: JSON.stringify(values)
|
|
544
|
+
};
|
|
545
|
+
const key = last.current.key;
|
|
417
546
|
const [tween, setTween] = React.useState({
|
|
418
547
|
key,
|
|
419
548
|
from: values,
|
|
@@ -433,7 +562,7 @@ function useTween(values, enter) {
|
|
|
433
562
|
let frame = 0;
|
|
434
563
|
const step = (now) => {
|
|
435
564
|
start ??= now;
|
|
436
|
-
const t = Math.min(1, (now - start) / duration);
|
|
565
|
+
const t = duration > 0 ? Math.min(1, (now - start) / duration) : 1;
|
|
437
566
|
setTween((tw) => ({
|
|
438
567
|
...tw,
|
|
439
568
|
t
|
|
@@ -445,13 +574,19 @@ function useTween(values, enter) {
|
|
|
445
574
|
}, [tween.key, duration]);
|
|
446
575
|
return shown;
|
|
447
576
|
}
|
|
448
|
-
/**
|
|
577
|
+
/**
|
|
578
|
+
* The numbers of `tween` on screen: `from` eased into `to`, a new key from
|
|
579
|
+
* `enter` or where it ends, a gone one gliding back to `enter` until the
|
|
580
|
+
* glide is done. At rest, as while the camera turns, they are the target
|
|
581
|
+
* itself: nothing to build.
|
|
582
|
+
*/
|
|
449
583
|
function tweenNumbers({ from, to, t }, enter) {
|
|
584
|
+
if (t >= 1) return to;
|
|
450
585
|
const s = t * t * (3 - 2 * t);
|
|
451
|
-
const gone =
|
|
586
|
+
const gone = Object.entries(from).filter(([k]) => !(k in to));
|
|
452
587
|
return Object.fromEntries([...Object.entries(to).map(([k, end]) => {
|
|
453
588
|
const start = from[k] ?? enter ?? end;
|
|
454
|
-
return [k,
|
|
589
|
+
return [k, start + (end - start) * s];
|
|
455
590
|
}), ...gone.map(([k, start]) => [k, start + ((enter ?? start) - start) * s])]);
|
|
456
591
|
}
|
|
457
592
|
/**
|
|
@@ -586,16 +721,19 @@ function between(from, to, k) {
|
|
|
586
721
|
/**
|
|
587
722
|
* The view of the camera, and the handlers that move it, as far as `keys`
|
|
588
723
|
* has them on: a drag turns it, or pans it while Alt is held, the keys step
|
|
589
|
-
* it, Ctrl or ⌘ with the wheel zooms it and a double click resets it
|
|
724
|
+
* it, Ctrl or ⌘ with the wheel zooms it and a double click resets it - a
|
|
725
|
+
* drag following the pointer at once, a key and a double click gliding.
|
|
590
726
|
* `data-orbiting` among them is true while the camera turns. `onDragStart`
|
|
591
|
-
* runs when a press becomes a drag, `onHover` on a move without one
|
|
727
|
+
* runs when a press becomes a drag, `onHover` on a move without one - but
|
|
728
|
+
* under two fingers, where the tooltip would jump from one to the other, it
|
|
729
|
+
* goes, as in a drag.
|
|
592
730
|
*/
|
|
593
731
|
function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
594
732
|
const { view, jump, glide } = useGlide(defaultView);
|
|
595
733
|
const [orbiting, setOrbiting] = React.useState(false);
|
|
596
734
|
const held = useModsHeld();
|
|
597
735
|
const drag = React.useRef(null);
|
|
598
|
-
const ref =
|
|
736
|
+
const { ref, touching, hint } = useGestures(jump, keys, width, setOrbiting);
|
|
599
737
|
const move = (step) => jump((v) => moved(v, step, defaultView));
|
|
600
738
|
const step = (step) => glide((v) => moved(v, step, defaultView));
|
|
601
739
|
const keyHandlers = useKeys(keys, step, setOrbiting);
|
|
@@ -608,6 +746,7 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
608
746
|
handlers: {
|
|
609
747
|
ref,
|
|
610
748
|
onPointerDown: (e) => {
|
|
749
|
+
if (e.pointerType === "touch") return onHover(e);
|
|
611
750
|
const pans = keys.on("dragPan") && e.altKey;
|
|
612
751
|
if (pans || keys.on("dragRotate") && platformMod(e)) drag.current = {
|
|
613
752
|
x: e.clientX,
|
|
@@ -618,7 +757,7 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
618
757
|
},
|
|
619
758
|
onPointerMove: (e) => {
|
|
620
759
|
const d = drag.current;
|
|
621
|
-
if (!d) return onHover(e);
|
|
760
|
+
if (!d) return touching.current ? onDragStart() : onHover(e);
|
|
622
761
|
if (d.rotate ? !platformMod(e) : !e.altKey) return release();
|
|
623
762
|
const dx = e.clientX - d.x;
|
|
624
763
|
const dy = e.clientY - d.y;
|
|
@@ -650,7 +789,8 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
650
789
|
"data-orbiting": orbiting,
|
|
651
790
|
"data-mod": keys.on("dragRotate") && held.mod ? true : void 0,
|
|
652
791
|
"data-alt": keys.on("dragPan") && held.alt ? true : void 0
|
|
653
|
-
}
|
|
792
|
+
},
|
|
793
|
+
hint
|
|
654
794
|
};
|
|
655
795
|
}
|
|
656
796
|
/**
|
|
@@ -703,22 +843,203 @@ function useKeys(keys, move, setOrbiting) {
|
|
|
703
843
|
onKeyUp
|
|
704
844
|
};
|
|
705
845
|
}
|
|
846
|
+
/** A ref for the svg that hands it to the listeners of the wheel, of two fingers and of a missed drag. */
|
|
847
|
+
function useGestures(jump, keys, width, setOrbiting) {
|
|
848
|
+
const wheel = useWheelZoom(jump, keys.on("wheelZoom"), width);
|
|
849
|
+
const touch = useTouch(jump, keys, width, setOrbiting);
|
|
850
|
+
const missed = useMissedDrag(keys);
|
|
851
|
+
return {
|
|
852
|
+
ref: React.useCallback((svg) => {
|
|
853
|
+
const off = [
|
|
854
|
+
wheel(svg),
|
|
855
|
+
touch.ref(svg),
|
|
856
|
+
missed.ref(svg)
|
|
857
|
+
];
|
|
858
|
+
return () => off.forEach((f) => f?.());
|
|
859
|
+
}, [
|
|
860
|
+
wheel,
|
|
861
|
+
touch.ref,
|
|
862
|
+
missed.ref
|
|
863
|
+
]),
|
|
864
|
+
touching: touch.touching,
|
|
865
|
+
hint: missed.hint
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const MISS_DISTANCE = 8;
|
|
869
|
+
const HINT = 1500;
|
|
870
|
+
const HINT_FADE = 200;
|
|
871
|
+
/**
|
|
872
|
+
* A ref for the svg that watches for a drag that turns nothing - the mouse
|
|
873
|
+
* without a modifier, or one finger sideways, `MISS_DISTANCE` or further -
|
|
874
|
+
* and the hint that names what would have turned the chart: the gesture of
|
|
875
|
+
* `dragRotate` or `touchOrbit`, none where `keys` has it off. The hint stays
|
|
876
|
+
* `HINT` after the lift, fades for `HINT_FADE` and then says nothing.
|
|
877
|
+
*/
|
|
878
|
+
function useMissedDrag(keys) {
|
|
879
|
+
const [missed, setMissed] = React.useState();
|
|
880
|
+
const [open, setOpen] = React.useState(false);
|
|
881
|
+
const [mouse, finger, pan] = [
|
|
882
|
+
keys.on("dragRotate"),
|
|
883
|
+
keys.on("touchOrbit"),
|
|
884
|
+
keys.on("dragPan")
|
|
885
|
+
];
|
|
886
|
+
return {
|
|
887
|
+
ref: React.useCallback((svg) => {
|
|
888
|
+
if (!svg) return;
|
|
889
|
+
let press = null;
|
|
890
|
+
let timer;
|
|
891
|
+
const down = (e) => {
|
|
892
|
+
const touch = e.pointerType === "touch";
|
|
893
|
+
press = (touch ? finger && e.isPrimary : mouse && !platformMod(e) && !(pan && e.altKey)) && e.button === 0 ? {
|
|
894
|
+
x: e.clientX,
|
|
895
|
+
y: e.clientY,
|
|
896
|
+
touch
|
|
897
|
+
} : null;
|
|
898
|
+
};
|
|
899
|
+
const move = (e) => {
|
|
900
|
+
const [dx, dy] = press ? [e.clientX - press.x, e.clientY - press.y] : [0, 0];
|
|
901
|
+
if (!press || Math.hypot(dx, dy) < MISS_DISTANCE) return;
|
|
902
|
+
if (!press.touch || Math.abs(dx) > Math.abs(dy)) {
|
|
903
|
+
clearTimeout(timer);
|
|
904
|
+
setMissed(press.touch ? "touchOrbit" : "dragRotate");
|
|
905
|
+
setOpen(true);
|
|
906
|
+
}
|
|
907
|
+
press = null;
|
|
908
|
+
};
|
|
909
|
+
const up = () => {
|
|
910
|
+
press = null;
|
|
911
|
+
clearTimeout(timer);
|
|
912
|
+
timer = setTimeout(() => {
|
|
913
|
+
setOpen(false);
|
|
914
|
+
timer = setTimeout(() => setMissed(void 0), HINT_FADE);
|
|
915
|
+
}, HINT);
|
|
916
|
+
};
|
|
917
|
+
const listeners = {
|
|
918
|
+
pointerdown: down,
|
|
919
|
+
pointermove: move,
|
|
920
|
+
pointerup: up,
|
|
921
|
+
pointercancel: up,
|
|
922
|
+
pointerleave: up
|
|
923
|
+
};
|
|
924
|
+
for (const [type, f] of Object.entries(listeners)) svg.addEventListener(type, f);
|
|
925
|
+
return () => {
|
|
926
|
+
clearTimeout(timer);
|
|
927
|
+
for (const [type, f] of Object.entries(listeners)) svg.removeEventListener(type, f);
|
|
928
|
+
};
|
|
929
|
+
}, [
|
|
930
|
+
mouse,
|
|
931
|
+
finger,
|
|
932
|
+
pan
|
|
933
|
+
]),
|
|
934
|
+
hint: {
|
|
935
|
+
shown: missed && keys.hint(missed),
|
|
936
|
+
open
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
/** The point (`x`, `y`) of the client in the units of the view box of `svg`, `width` wide, from its middle. */
|
|
941
|
+
function inViewBox(svg, width, x, y) {
|
|
942
|
+
const box = svg.getBoundingClientRect();
|
|
943
|
+
const k = width / box.width;
|
|
944
|
+
return {
|
|
945
|
+
x: (x - box.left - box.width / 2) * k,
|
|
946
|
+
y: (y - box.top - box.height / 2) * k
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
const pinchOf = (a, b) => ({
|
|
950
|
+
x: (a.clientX + b.clientX) / 2,
|
|
951
|
+
y: (a.clientY + b.clientY) / 2,
|
|
952
|
+
d: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY)
|
|
953
|
+
});
|
|
954
|
+
/**
|
|
955
|
+
* The step of the camera from the fingers `last` to `now`: the move of the
|
|
956
|
+
* point between them turns it, if `orbit`, as far as a drag of the mouse, and
|
|
957
|
+
* the change of their distance zooms it, if `zoom`. Two fingers on one pixel
|
|
958
|
+
* have no distance to zoom from.
|
|
959
|
+
*/
|
|
960
|
+
function pinchStep(last, now, orbit, zoom) {
|
|
961
|
+
const [dx, dy] = orbit ? [now.x - last.x, now.y - last.y] : [0, 0];
|
|
962
|
+
return {
|
|
963
|
+
yaw: dx * DRAG,
|
|
964
|
+
tilt: dy * DRAG,
|
|
965
|
+
zoom: zoom ? now.d / Math.max(last.d, 1) : 1
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* A ref for the svg that turns the camera with two fingers on it and zooms it
|
|
970
|
+
* with their pinch, as far as `keys` has them on: the move of the point
|
|
971
|
+
* between them turns, the change of their distance zooms to that point, both
|
|
972
|
+
* at once. One finger is left to the browser, which scrolls the page with it
|
|
973
|
+
* (ADR-0001), and so is a touch it has begun to scroll. The listeners are the
|
|
974
|
+
* core's own: only one that is not passive keeps the browser from scrolling
|
|
975
|
+
* under two fingers. `touching` is true while two fingers hold the chart.
|
|
976
|
+
*/
|
|
977
|
+
function useTouch(jump, keys, width, setOrbiting) {
|
|
978
|
+
const orbit = keys.on("touchOrbit");
|
|
979
|
+
const zoom = keys.on("pinchZoom");
|
|
980
|
+
const touching = React.useRef(false);
|
|
981
|
+
return {
|
|
982
|
+
ref: React.useCallback((svg) => {
|
|
983
|
+
if (!svg || !(orbit || zoom)) return;
|
|
984
|
+
let last = null;
|
|
985
|
+
const onTouch = (e) => {
|
|
986
|
+
if (e.targetTouches.length !== 2 || !e.cancelable) {
|
|
987
|
+
if (last) setOrbiting(false);
|
|
988
|
+
touching.current = false;
|
|
989
|
+
last = null;
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
e.preventDefault();
|
|
993
|
+
touching.current = true;
|
|
994
|
+
const now = pinchOf(e.targetTouches[0], e.targetTouches[1]);
|
|
995
|
+
if (last) {
|
|
996
|
+
const step = {
|
|
997
|
+
...pinchStep(last, now, orbit, zoom),
|
|
998
|
+
at: inViewBox(svg, width, now.x, now.y)
|
|
999
|
+
};
|
|
1000
|
+
jump((v) => moved(v, step, v));
|
|
1001
|
+
setOrbiting(Boolean(step.yaw || step.tilt));
|
|
1002
|
+
}
|
|
1003
|
+
last = now;
|
|
1004
|
+
};
|
|
1005
|
+
const events = [
|
|
1006
|
+
"touchstart",
|
|
1007
|
+
"touchmove",
|
|
1008
|
+
"touchend",
|
|
1009
|
+
"touchcancel"
|
|
1010
|
+
];
|
|
1011
|
+
for (const type of events) svg.addEventListener(type, onTouch, { passive: false });
|
|
1012
|
+
return () => events.forEach((type) => svg.removeEventListener(type, onTouch));
|
|
1013
|
+
}, [
|
|
1014
|
+
jump,
|
|
1015
|
+
orbit,
|
|
1016
|
+
zoom,
|
|
1017
|
+
width,
|
|
1018
|
+
setOrbiting
|
|
1019
|
+
]),
|
|
1020
|
+
touching
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
706
1023
|
/**
|
|
707
|
-
* A ref for the svg that zooms the view
|
|
708
|
-
* wheel, if `on`. Ctrl and the wheel zooms on a Mac too, because a trackpad
|
|
1024
|
+
* A ref for the svg that zooms the view to the cursor with the platform
|
|
1025
|
+
* modifier and the wheel, if `on`. Ctrl and the wheel zooms on a Mac too, because a trackpad
|
|
709
1026
|
* pinch arrives that way, indistinguishable from the keys. The wheel alone
|
|
710
1027
|
* still scrolls the page. While the wheel zooms, the svg says so in
|
|
711
1028
|
* `data-zoom` - `"in"` or `"out"`, the direction of its cursor - until
|
|
712
1029
|
* `ZOOM_CURSOR` after the last tick.
|
|
713
1030
|
*/
|
|
714
|
-
function useWheelZoom(jump, on) {
|
|
1031
|
+
function useWheelZoom(jump, on, width) {
|
|
715
1032
|
return React.useCallback((svg) => {
|
|
716
1033
|
if (!on || !svg) return;
|
|
717
1034
|
let timer;
|
|
718
1035
|
const onWheel = (e) => {
|
|
719
1036
|
if (!platformMod(e) && !e.ctrlKey) return;
|
|
720
1037
|
e.preventDefault();
|
|
721
|
-
|
|
1038
|
+
const at = inViewBox(svg, width, e.clientX, e.clientY);
|
|
1039
|
+
jump((v) => moved(v, {
|
|
1040
|
+
zoom: 2 ** (-e.deltaY / 200),
|
|
1041
|
+
at
|
|
1042
|
+
}, v));
|
|
722
1043
|
svg.dataset.zoom = e.deltaY < 0 ? "in" : "out";
|
|
723
1044
|
clearTimeout(timer);
|
|
724
1045
|
timer = setTimeout(() => delete svg.dataset.zoom, ZOOM_CURSOR);
|
|
@@ -728,42 +1049,76 @@ function useWheelZoom(jump, on) {
|
|
|
728
1049
|
clearTimeout(timer);
|
|
729
1050
|
svg.removeEventListener("wheel", onWheel);
|
|
730
1051
|
};
|
|
731
|
-
}, [
|
|
1052
|
+
}, [
|
|
1053
|
+
jump,
|
|
1054
|
+
on,
|
|
1055
|
+
width
|
|
1056
|
+
]);
|
|
732
1057
|
}
|
|
733
1058
|
/**
|
|
734
1059
|
* The tooltip of the mark under the pointer, the one painted last, and the
|
|
735
1060
|
* focus on its groups. The highlight follows the mark: `hover` sets the
|
|
736
|
-
* tooltip only when the pointer moves onto another mark,
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
|
|
743
|
-
|
|
1061
|
+
* tooltip only when the pointer moves onto another mark, or onto new data
|
|
1062
|
+
* under the same one, and the focus only when the groups change, so a move
|
|
1063
|
+
* within a mark of a chart at rest does not render it again; off the mark and
|
|
1064
|
+
* when the pointer leaves the chart, `leave` drops both. A finger lifting
|
|
1065
|
+
* leaves the chart too: what it tapped stays until a tap elsewhere, on the
|
|
1066
|
+
* chart or off it. While a drag turns the chart under the captured pointer,
|
|
1067
|
+
* `hide` drops the tooltip and keeps the focus, so a flow does not flash away
|
|
1068
|
+
* and can be followed from every side.
|
|
1069
|
+
*/
|
|
1070
|
+
function useHover(scene, width, height, tip, focus) {
|
|
744
1071
|
const hovered = React.useRef(void 0);
|
|
1072
|
+
const svg = React.useRef(null);
|
|
1073
|
+
const { set } = tip;
|
|
1074
|
+
useTapElsewhere(svg, React.useCallback(() => {
|
|
1075
|
+
hovered.current = void 0;
|
|
1076
|
+
focus.set(void 0);
|
|
1077
|
+
set(null);
|
|
1078
|
+
}, [focus, set]));
|
|
745
1079
|
const show = (mark) => {
|
|
746
1080
|
focus.set(mark?.groups);
|
|
747
|
-
if (mark?.
|
|
1081
|
+
if (mark?.tooltip === hovered.current?.tooltip) return;
|
|
748
1082
|
hovered.current = mark;
|
|
749
|
-
|
|
1083
|
+
const tooltip = mark?.tooltip;
|
|
1084
|
+
tip.set((typeof tooltip === "function" ? tooltip() : tooltip) ?? null);
|
|
750
1085
|
};
|
|
751
1086
|
return {
|
|
752
1087
|
hover: (e) => {
|
|
1088
|
+
svg.current = e.currentTarget;
|
|
753
1089
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1090
|
+
const x = e.clientX - rect.left;
|
|
1091
|
+
const y = e.clientY - rect.top;
|
|
1092
|
+
tip.follow({
|
|
1093
|
+
x,
|
|
1094
|
+
y,
|
|
1095
|
+
width: rect.width,
|
|
1096
|
+
height: rect.height
|
|
1097
|
+
});
|
|
1098
|
+
show(hit(scene.current, {
|
|
1099
|
+
x: x / rect.width * width,
|
|
1100
|
+
y: y / rect.height * height
|
|
1101
|
+
}));
|
|
759
1102
|
},
|
|
760
1103
|
hide: () => {
|
|
761
1104
|
hovered.current = void 0;
|
|
762
|
-
|
|
1105
|
+
tip.set(null);
|
|
763
1106
|
},
|
|
764
|
-
leave: () =>
|
|
1107
|
+
leave: (e) => {
|
|
1108
|
+
if (e.pointerType !== "touch" || !hovered.current) show(void 0);
|
|
1109
|
+
}
|
|
765
1110
|
};
|
|
766
1111
|
}
|
|
1112
|
+
/** Calls `clear` when a finger taps anywhere but `svg`: on the page, or on another chart. */
|
|
1113
|
+
function useTapElsewhere(svg, clear) {
|
|
1114
|
+
React.useEffect(() => {
|
|
1115
|
+
const tap = (e) => {
|
|
1116
|
+
if (e.pointerType === "touch" && !svg.current?.contains(e.target)) clear();
|
|
1117
|
+
};
|
|
1118
|
+
document.addEventListener("pointerdown", tap);
|
|
1119
|
+
return () => document.removeEventListener("pointerdown", tap);
|
|
1120
|
+
}, [svg, clear]);
|
|
1121
|
+
}
|
|
767
1122
|
/** A store for the groups in focus, which tells its listeners only when the groups change. */
|
|
768
1123
|
function useFocus() {
|
|
769
1124
|
const [focus] = React.useState(() => {
|
|
@@ -784,7 +1139,7 @@ function useFocus() {
|
|
|
784
1139
|
});
|
|
785
1140
|
return focus;
|
|
786
1141
|
}
|
|
787
|
-
/** Whether an item shares a group with the focus: the hover
|
|
1142
|
+
/** Whether an item shares a group with the focus: the hover lights those items. */
|
|
788
1143
|
function useLit(items) {
|
|
789
1144
|
const { focus } = useChart3D();
|
|
790
1145
|
const groups = React.useSyncExternalStore(focus.subscribe, focus.get, focus.get);
|
|
@@ -809,13 +1164,12 @@ function inside(polygon, at) {
|
|
|
809
1164
|
}
|
|
810
1165
|
/**
|
|
811
1166
|
* The renderer the chart draws with: SVG on the server and in the first
|
|
812
|
-
* render on the client, then
|
|
813
|
-
*
|
|
1167
|
+
* render on the client, then a canvas if the browser has one, for "canvas"
|
|
1168
|
+
* and "webgl" alike.
|
|
814
1169
|
*/
|
|
815
1170
|
function useRenderer(requested) {
|
|
816
1171
|
const [renderer, setRenderer] = React.useState("svg");
|
|
817
1172
|
React.useEffect(() => {
|
|
818
|
-
if (requested === "webgl" && document.createElement("canvas").getContext("webgl")) return setRenderer("webgl");
|
|
819
1173
|
const canvas = requested !== "svg" && document.createElement("canvas").getContext("2d");
|
|
820
1174
|
setRenderer(canvas ? "canvas" : "svg");
|
|
821
1175
|
}, [requested]);
|
|
@@ -824,36 +1178,16 @@ function useRenderer(requested) {
|
|
|
824
1178
|
/**
|
|
825
1179
|
* A ref for the canvas of the marks, and the repaint that paints them: on new
|
|
826
1180
|
* marks, when the canvas changes its size and when the theme changes. A color
|
|
827
|
-
* resolves on the canvas itself, inside the chart, so the `--color-*` apply
|
|
828
|
-
* webgl takes the resolved color as bytes from a 1×1 canvas.
|
|
1181
|
+
* resolves on the canvas itself, inside the chart, so the `--color-*` apply.
|
|
829
1182
|
*/
|
|
830
|
-
function useCanvas(scene, width
|
|
1183
|
+
function useCanvas(scene, width) {
|
|
831
1184
|
const element = React.useRef(null);
|
|
832
1185
|
const colors = React.useRef(/* @__PURE__ */ new Map());
|
|
833
|
-
const bytes = React.useRef(/* @__PURE__ */ new Map());
|
|
834
|
-
const pixel = React.useRef(null);
|
|
835
1186
|
const repaint = React.useCallback(() => {
|
|
836
1187
|
const canvas = element.current;
|
|
837
1188
|
if (!canvas) return;
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if (color === void 0) {
|
|
841
|
-
canvas.style.color = css;
|
|
842
|
-
color = getComputedStyle(canvas).color;
|
|
843
|
-
colors.current.set(css, color);
|
|
844
|
-
}
|
|
845
|
-
return color;
|
|
846
|
-
};
|
|
847
|
-
if (renderer === "webgl") {
|
|
848
|
-
pixel.current ??= onePixel();
|
|
849
|
-
paintGL(canvas, scene.current, width, height, (css) => colorBytes(resolve(css), pixel.current, bytes.current));
|
|
850
|
-
} else paint(canvas, scene.current, width, resolve);
|
|
851
|
-
}, [
|
|
852
|
-
scene,
|
|
853
|
-
width,
|
|
854
|
-
height,
|
|
855
|
-
renderer
|
|
856
|
-
]);
|
|
1189
|
+
paint(canvas, scene.current, width, (css) => resolved(canvas, css, colors.current));
|
|
1190
|
+
}, [scene, width]);
|
|
857
1191
|
return {
|
|
858
1192
|
canvas: React.useCallback((node) => {
|
|
859
1193
|
element.current = node;
|
|
@@ -862,7 +1196,6 @@ function useCanvas(scene, width, height, renderer) {
|
|
|
862
1196
|
resize.observe(node);
|
|
863
1197
|
const theme = new MutationObserver(() => {
|
|
864
1198
|
colors.current.clear();
|
|
865
|
-
bytes.current.clear();
|
|
866
1199
|
repaint();
|
|
867
1200
|
});
|
|
868
1201
|
theme.observe(document.documentElement, { attributeFilter: ["class", "style"] });
|
|
@@ -875,139 +1208,78 @@ function useCanvas(scene, width, height, renderer) {
|
|
|
875
1208
|
repaint
|
|
876
1209
|
};
|
|
877
1210
|
}
|
|
1211
|
+
/**
|
|
1212
|
+
* The color that `css` resolves to on `canvas`, inside the chart, as the sRGB
|
|
1213
|
+
* the canvas paints it in, cached per color: the canvas parses `fillStyle`
|
|
1214
|
+
* at every mark, and an `rgb()` four times faster than the `oklch()` the
|
|
1215
|
+
* theme resolves to.
|
|
1216
|
+
*/
|
|
1217
|
+
function resolved(canvas, css, cache) {
|
|
1218
|
+
let color = cache.get(css);
|
|
1219
|
+
if (color === void 0) {
|
|
1220
|
+
canvas.style.color = css;
|
|
1221
|
+
color = srgb(getComputedStyle(canvas).color);
|
|
1222
|
+
cache.set(css, color);
|
|
1223
|
+
}
|
|
1224
|
+
return color;
|
|
1225
|
+
}
|
|
1226
|
+
let pixel = null;
|
|
1227
|
+
/** `color` as the `rgb()` a canvas paints it in, read back from a pixel. */
|
|
1228
|
+
function srgb(color) {
|
|
1229
|
+
pixel ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
1230
|
+
pixel.clearRect(0, 0, 1, 1);
|
|
1231
|
+
pixel.fillStyle = color;
|
|
1232
|
+
pixel.fillRect(0, 0, 1, 1);
|
|
1233
|
+
const [r, g, b, a] = pixel.getImageData(0, 0, 1, 1).data;
|
|
1234
|
+
return `rgb(${r} ${g} ${b} / ${+(a / 255).toFixed(3)})`;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Sizes `canvas` to its pixels on screen, or, where it has that size, clears
|
|
1238
|
+
* it: setting the size throws its buffer away and makes a new one, every frame.
|
|
1239
|
+
*/
|
|
1240
|
+
function fitCanvas(canvas, ctx) {
|
|
1241
|
+
const width = Math.round(canvas.clientWidth * devicePixelRatio);
|
|
1242
|
+
const height = Math.round(canvas.clientHeight * devicePixelRatio);
|
|
1243
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
1244
|
+
canvas.width = width;
|
|
1245
|
+
canvas.height = height;
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1249
|
+
ctx.clearRect(0, 0, width, height);
|
|
1250
|
+
}
|
|
878
1251
|
/** Paints `marks` on `canvas`, from the view box of `width` to the pixels of the canvas. */
|
|
879
1252
|
function paint(canvas, marks, width, color) {
|
|
880
|
-
canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
|
|
881
|
-
canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
|
|
882
1253
|
const ctx = canvas.getContext("2d");
|
|
1254
|
+
fitCanvas(canvas, ctx);
|
|
883
1255
|
const scale = canvas.width / width;
|
|
884
1256
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
885
1257
|
ctx.lineJoin = "round";
|
|
886
|
-
for (const
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
} else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
|
|
892
|
-
ctx.fillStyle = color(fill);
|
|
1258
|
+
for (const mark of marks) {
|
|
1259
|
+
const { r, fill, stroke } = mark;
|
|
1260
|
+
trace(ctx, mark);
|
|
1261
|
+
const filled = color(fill);
|
|
1262
|
+
ctx.fillStyle = filled;
|
|
893
1263
|
ctx.fill();
|
|
894
1264
|
const outline = r === void 0 ? stroke ?? fill : stroke;
|
|
895
1265
|
if (!outline) continue;
|
|
896
|
-
ctx.strokeStyle = color(outline);
|
|
1266
|
+
ctx.strokeStyle = outline === fill ? filled : color(outline);
|
|
897
1267
|
ctx.lineWidth = r === void 0 ? .5 : 1;
|
|
898
1268
|
ctx.stroke();
|
|
899
1269
|
}
|
|
900
1270
|
}
|
|
901
|
-
/**
|
|
902
|
-
function
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
pixel.fillStyle = color;
|
|
914
|
-
pixel.fillRect(0, 0, 1, 1);
|
|
915
|
-
rgba = [...pixel.getImageData(0, 0, 1, 1).data];
|
|
916
|
-
cache.set(color, rgba);
|
|
917
|
-
}
|
|
918
|
-
return rgba;
|
|
919
|
-
}
|
|
920
|
-
/**
|
|
921
|
-
* Paints `marks` on a webgl `canvas`, from the view box of `width`×`height` to
|
|
922
|
-
* the pixels of the canvas: a fan per polygon, a fan of 12 segments per
|
|
923
|
-
* circle, in paint order and without a depth test, the color of each as bytes.
|
|
924
|
-
*/
|
|
925
|
-
function paintGL(canvas, marks, width, height, color) {
|
|
926
|
-
canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
|
|
927
|
-
canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
|
|
928
|
-
const gl = canvas.getContext("webgl");
|
|
929
|
-
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
930
|
-
gl.disable(gl.DEPTH_TEST);
|
|
931
|
-
gl.enable(gl.BLEND);
|
|
932
|
-
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
933
|
-
const program = programOf(gl);
|
|
934
|
-
gl.useProgram(program);
|
|
935
|
-
gl.uniform2f(gl.getUniformLocation(program, "u_res"), 2 / width, 2 / height);
|
|
936
|
-
const where = gl.getAttribLocation(program, "a_pos");
|
|
937
|
-
gl.enableVertexAttribArray(where);
|
|
938
|
-
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOf(gl));
|
|
939
|
-
gl.vertexAttribPointer(where, 2, gl.FLOAT, false, 0, 0);
|
|
940
|
-
const tint = (css) => {
|
|
941
|
-
const [r, g, b, a] = color(css);
|
|
942
|
-
gl.uniform4f(gl.getUniformLocation(program, "u_color"), r / 255, g / 255, b / 255, a / 255);
|
|
943
|
-
};
|
|
944
|
-
const draw = (mode, points) => {
|
|
945
|
-
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points.flatMap((p) => [p.x, p.y])), gl.DYNAMIC_DRAW);
|
|
946
|
-
gl.drawArrays(mode, 0, points.length);
|
|
947
|
-
};
|
|
948
|
-
const [, widest] = gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE) ?? [1, 1];
|
|
949
|
-
gl.lineWidth(Math.min(1.5 * devicePixelRatio, widest));
|
|
950
|
-
for (const mark of marks) {
|
|
951
|
-
tint(mark.fill);
|
|
952
|
-
draw(gl.TRIANGLES, trianglesOf(mark));
|
|
953
|
-
if (mark.stroke) {
|
|
954
|
-
tint(mark.stroke);
|
|
955
|
-
draw(gl.LINE_LOOP, outlineOf(mark));
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
/** The 12 points of the circle around `c` of radius `r`. */
|
|
960
|
-
const ringOf = (c, r) => Array.from({ length: 12 }, (_, i) => ({
|
|
961
|
-
x: c.x + r * Math.cos(i / 12 * 2 * Math.PI),
|
|
962
|
-
y: c.y + r * Math.sin(i / 12 * 2 * Math.PI)
|
|
963
|
-
}));
|
|
964
|
-
/** The triangles of `mark` as view-box points: a fan per polygon, a fan of 12 segments per circle. */
|
|
965
|
-
function trianglesOf(mark) {
|
|
966
|
-
const { points, r } = mark;
|
|
967
|
-
if (r !== void 0) {
|
|
968
|
-
const ring = ringOf(points[0], r);
|
|
969
|
-
return ring.flatMap((p, i) => [
|
|
970
|
-
points[0],
|
|
971
|
-
p,
|
|
972
|
-
ring[(i + 1) % 12]
|
|
973
|
-
]);
|
|
974
|
-
}
|
|
975
|
-
return points.slice(1, -1).flatMap((p, i) => [
|
|
976
|
-
points[0],
|
|
977
|
-
p,
|
|
978
|
-
points[i + 2]
|
|
979
|
-
]);
|
|
980
|
-
}
|
|
981
|
-
/** The outline of `mark` as view-box points: its polygon, or the ring of its circle. */
|
|
982
|
-
const outlineOf = (mark) => mark.r === void 0 ? mark.points : ringOf(mark.points[0], mark.r);
|
|
983
|
-
const programs = /* @__PURE__ */ new WeakMap();
|
|
984
|
-
const buffers = /* @__PURE__ */ new WeakMap();
|
|
985
|
-
/** The paint program of `gl`, compiled once: view-box points in, clip space out, one color per draw. */
|
|
986
|
-
function programOf(gl) {
|
|
987
|
-
let program = programs.get(gl);
|
|
988
|
-
if (!program) {
|
|
989
|
-
program = gl.createProgram();
|
|
990
|
-
gl.attachShader(program, shaderOf(gl, gl.VERTEX_SHADER, "attribute vec2 a_pos; uniform vec2 u_res; void main() { gl_Position = vec4(a_pos.x * u_res.x - 1.0, 1.0 - a_pos.y * u_res.y, 0.0, 1.0); }"));
|
|
991
|
-
gl.attachShader(program, shaderOf(gl, gl.FRAGMENT_SHADER, "precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }"));
|
|
992
|
-
gl.linkProgram(program);
|
|
993
|
-
programs.set(gl, program);
|
|
994
|
-
}
|
|
995
|
-
return program;
|
|
996
|
-
}
|
|
997
|
-
function shaderOf(gl, type, source) {
|
|
998
|
-
const shader = gl.createShader(type);
|
|
999
|
-
gl.shaderSource(shader, source);
|
|
1000
|
-
gl.compileShader(shader);
|
|
1001
|
-
return shader;
|
|
1002
|
-
}
|
|
1003
|
-
function bufferOf(gl) {
|
|
1004
|
-
let buffer = buffers.get(gl);
|
|
1005
|
-
if (!buffer) {
|
|
1006
|
-
buffer = gl.createBuffer();
|
|
1007
|
-
buffers.set(gl, buffer);
|
|
1008
|
-
}
|
|
1009
|
-
return buffer;
|
|
1010
|
-
}
|
|
1271
|
+
/** Traces the outline of `mark` as the path of `ctx`: its polygon, or its circle. */
|
|
1272
|
+
function trace(ctx, { points, r }) {
|
|
1273
|
+
ctx.beginPath();
|
|
1274
|
+
if (r === void 0) {
|
|
1275
|
+
for (const p of points) ctx.lineTo(p.x, p.y);
|
|
1276
|
+
ctx.closePath();
|
|
1277
|
+
} else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
|
|
1278
|
+
}
|
|
1279
|
+
/** Twice the signed area of the triangle `a`, `b`, `c`: its sign tells which way it turns, 0 on a line. */
|
|
1280
|
+
const turn = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
1281
|
+
/** Twice the signed area of a ring: its sign tells which way it winds. */
|
|
1282
|
+
const areaOf = (ring) => ring.reduce((sum, p, i) => sum + turn(ring[0], p, ring[i + 1] ?? p), 0);
|
|
1011
1283
|
/** The canvas over the chart box that the marks are painted on, if the chart draws on one. */
|
|
1012
1284
|
function ChartCanvas3D({ renderer, canvas, width, height }) {
|
|
1013
1285
|
if (renderer === "svg") return null;
|
|
@@ -1016,7 +1288,7 @@ function ChartCanvas3D({ renderer, canvas, width, height }) {
|
|
|
1016
1288
|
ref: canvas,
|
|
1017
1289
|
className: "pointer-events-none absolute top-0 left-0 w-full mask-x-from-96% mask-y-from-96%",
|
|
1018
1290
|
style: { aspectRatio: `${width} / ${height}` }
|
|
1019
|
-
}
|
|
1291
|
+
});
|
|
1020
1292
|
}
|
|
1021
1293
|
const GIZMO = [
|
|
1022
1294
|
{
|
|
@@ -1084,8 +1356,21 @@ function ChartGizmo3D({ camera, corner }) {
|
|
|
1084
1356
|
});
|
|
1085
1357
|
}
|
|
1086
1358
|
/**
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1359
|
+
* `text` as part of a CSS identifier: every character but an ASCII letter, a
|
|
1360
|
+
* digit, `-` and `_` written as its escape, such as the space of "Net sales"
|
|
1361
|
+
* or the dot of "q1.sales". Unescaped, it would end the name, and the browser
|
|
1362
|
+
* would drop the rule that declares the color.
|
|
1363
|
+
*/
|
|
1364
|
+
const cssName = (text) => text.replace(/[^\w-]/gu, (c) => `\\${c.codePointAt(0).toString(16)} `);
|
|
1365
|
+
/**
|
|
1366
|
+
* The color of the config key `key`: the CSS variable the container declares
|
|
1367
|
+
* for it. A chart without a config has no first key to fall back on, and
|
|
1368
|
+
* draws in a variable nobody declares: colorless, as before.
|
|
1369
|
+
*/
|
|
1370
|
+
const colorOf = (key) => `var(--color-${cssName(String(key))})`;
|
|
1371
|
+
/**
|
|
1372
|
+
* The focus color of a fill, which a lit mark takes: `--chart-3d-focus`, or the
|
|
1373
|
+
* fill itself lightened, so the highlight carries the color of what it highlights.
|
|
1089
1374
|
*/
|
|
1090
1375
|
const focusOf = (fill) => `var(--chart-3d-focus, color-mix(in oklch, ${fill} 60%, white))`;
|
|
1091
1376
|
/**
|
|
@@ -1093,29 +1378,42 @@ const focusOf = (fill) => `var(--chart-3d-focus, color-mix(in oklch, ${fill} 60%
|
|
|
1093
1378
|
* elements, or on the canvas over the chart box. The hover reads them too.
|
|
1094
1379
|
*/
|
|
1095
1380
|
function Marks3D({ marks }) {
|
|
1096
|
-
const { renderer,
|
|
1381
|
+
const { renderer, place, repaint } = useChart3D();
|
|
1382
|
+
const id = React.useId();
|
|
1383
|
+
const node = React.useRef(null);
|
|
1097
1384
|
const lit = useLit(marks);
|
|
1098
1385
|
const drawn = marks.map((mark) => lit(mark) ? {
|
|
1099
1386
|
...mark,
|
|
1100
|
-
|
|
1387
|
+
fill: focusOf(mark.fill)
|
|
1101
1388
|
} : mark);
|
|
1102
1389
|
React.useLayoutEffect(() => {
|
|
1103
|
-
|
|
1390
|
+
place(id, {
|
|
1391
|
+
marks: drawn,
|
|
1392
|
+
node: node.current
|
|
1393
|
+
});
|
|
1104
1394
|
repaint();
|
|
1105
1395
|
});
|
|
1106
|
-
|
|
1396
|
+
React.useLayoutEffect(() => () => {
|
|
1397
|
+
place(id);
|
|
1398
|
+
repaint();
|
|
1399
|
+
}, [
|
|
1400
|
+
id,
|
|
1401
|
+
place,
|
|
1402
|
+
repaint
|
|
1403
|
+
]);
|
|
1107
1404
|
return /* @__PURE__ */ jsx("g", {
|
|
1405
|
+
ref: node,
|
|
1108
1406
|
"data-slot": "chart-3d-marks",
|
|
1109
|
-
children: drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
|
|
1407
|
+
children: renderer === "svg" && drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
|
|
1110
1408
|
d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
|
|
1111
1409
|
fill,
|
|
1112
1410
|
stroke: stroke ?? fill,
|
|
1113
|
-
strokeWidth:
|
|
1411
|
+
strokeWidth: .5,
|
|
1114
1412
|
strokeLinejoin: "round"
|
|
1115
1413
|
}, key) : /* @__PURE__ */ jsx("circle", {
|
|
1116
|
-
cx: points[0].x,
|
|
1117
|
-
cy: points[0].y,
|
|
1118
|
-
r,
|
|
1414
|
+
cx: +points[0].x.toFixed(2),
|
|
1415
|
+
cy: +points[0].y.toFixed(2),
|
|
1416
|
+
r: +r.toFixed(2),
|
|
1119
1417
|
fill,
|
|
1120
1418
|
stroke,
|
|
1121
1419
|
strokeWidth: stroke ? 1 : void 0
|
|
@@ -1146,39 +1444,39 @@ function Labels3D({ labels }) {
|
|
|
1146
1444
|
});
|
|
1147
1445
|
}
|
|
1148
1446
|
function ChartStyle({ id, config }) {
|
|
1447
|
+
const scope = `[data-chart=${cssName(id)}]`;
|
|
1149
1448
|
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
|
|
1150
1449
|
const entering = [
|
|
1151
1450
|
"text",
|
|
1152
1451
|
"line",
|
|
1153
1452
|
"path"
|
|
1154
|
-
].map((el) =>
|
|
1453
|
+
].map((el) => `${scope} [data-slot="chart-3d-axes"] ${el}`).join(", ");
|
|
1155
1454
|
const base = `@keyframes chart-3d-axes-in { from { opacity: 0; } }
|
|
1156
1455
|
${entering} { animation: chart-3d-axes-in 300ms ease-out; }
|
|
1157
1456
|
@media (prefers-reduced-motion: reduce) { ${entering} { animation: none; } }
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: [base, ...
|
|
1165
|
-
${prefix}
|
|
1457
|
+
${scope} svg[data-mod="true"] { cursor: grab; }
|
|
1458
|
+
${scope} svg[data-alt="true"] { cursor: move; }
|
|
1459
|
+
${scope} svg[data-zoom="in"] { cursor: zoom-in; }
|
|
1460
|
+
${scope} svg[data-zoom="out"] { cursor: zoom-out; }
|
|
1461
|
+
${scope} svg[data-orbiting="true"] { cursor: grabbing; }`;
|
|
1462
|
+
const themes = colorConfig.length ? Object.entries(THEMES) : [];
|
|
1463
|
+
return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: [base, ...themes.map(([theme, prefix]) => `
|
|
1464
|
+
${prefix} ${scope} {
|
|
1166
1465
|
${colorConfig.map(([key, item]) => {
|
|
1167
1466
|
const color = item.theme?.[theme] || item.color;
|
|
1168
|
-
return color ? ` --color-${key}: ${color};` : null;
|
|
1467
|
+
return color ? ` --color-${cssName(key)}: ${color};` : null;
|
|
1169
1468
|
}).join("\n")}
|
|
1170
1469
|
}
|
|
1171
|
-
`)].join("\n") } });
|
|
1470
|
+
`)].join("\n").replaceAll("<", "\\3c ") } });
|
|
1172
1471
|
}
|
|
1173
|
-
function ChartTooltip3D({
|
|
1472
|
+
function ChartTooltip3D({ tip }) {
|
|
1473
|
+
const { tooltip, ref, place } = tip;
|
|
1474
|
+
React.useLayoutEffect(place, [tooltip, place]);
|
|
1174
1475
|
if (!tooltip) return null;
|
|
1175
1476
|
return /* @__PURE__ */ jsxs("div", {
|
|
1477
|
+
ref,
|
|
1176
1478
|
"data-slot": "chart-3d-tooltip",
|
|
1177
|
-
className: "pointer-events-none absolute z-10 grid min-w-32
|
|
1178
|
-
style: {
|
|
1179
|
-
left: `${tooltip.at.x / width * 100}%`,
|
|
1180
|
-
top: `${tooltip.at.y / height * 100}%`
|
|
1181
|
-
},
|
|
1479
|
+
className: "pointer-events-none absolute z-10 grid w-max min-w-32 gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl motion-safe:transition-[translate]",
|
|
1182
1480
|
children: [tooltip.title && /* @__PURE__ */ jsx("div", {
|
|
1183
1481
|
className: "font-medium",
|
|
1184
1482
|
children: tooltip.title
|
|
@@ -1202,6 +1500,28 @@ function ChartTooltip3D({ tooltip, width, height }) {
|
|
|
1202
1500
|
});
|
|
1203
1501
|
}
|
|
1204
1502
|
/**
|
|
1503
|
+
* The hint of a drag that missed, a box in the middle of the chart over a
|
|
1504
|
+
* light veil, above the tooltip: always there, empty while it has nothing to
|
|
1505
|
+
* say, so a screen reader hears its first word. The veil has no blur, which
|
|
1506
|
+
* would cost every frame of the drag it shows during.
|
|
1507
|
+
*/
|
|
1508
|
+
function ChartHint3D({ shown, open }) {
|
|
1509
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1510
|
+
role: "status",
|
|
1511
|
+
"data-slot": "chart-3d-hint",
|
|
1512
|
+
"data-state": open ? "open" : "closed",
|
|
1513
|
+
className: "pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-[inherit] bg-background/40 motion-safe:transition-opacity data-[state=closed]:opacity-0",
|
|
1514
|
+
children: shown && /* @__PURE__ */ jsxs("div", {
|
|
1515
|
+
"data-slot": "chart-3d-hint-box",
|
|
1516
|
+
className: "flex items-center gap-2 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 shadow-xl",
|
|
1517
|
+
children: [/* @__PURE__ */ jsx(KbdGroup3D, { group: shown.gesture }), /* @__PURE__ */ jsx("span", {
|
|
1518
|
+
className: "text-muted-foreground",
|
|
1519
|
+
children: shown.label
|
|
1520
|
+
})]
|
|
1521
|
+
})
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1205
1525
|
* A button in the corner of the chart, named `label`, that lists the rows of
|
|
1206
1526
|
* its keymap. The `help` keys on the chart open and close the list, Escape
|
|
1207
1527
|
* closes it.
|
|
@@ -1241,13 +1561,35 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
1241
1561
|
"aria-controls": open ? id : void 0,
|
|
1242
1562
|
onClick: () => setOpen((o) => !o),
|
|
1243
1563
|
className: "inline-flex size-7 cursor-pointer items-center justify-center rounded-md bg-background/60 text-muted-foreground outline-none transition-colors hover:bg-background hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4",
|
|
1244
|
-
children: /* @__PURE__ */ jsx(
|
|
1564
|
+
children: /* @__PURE__ */ jsx(KeyboardIcon3D, {})
|
|
1245
1565
|
}), open && /* @__PURE__ */ jsx(ChartShortcutList3D, {
|
|
1246
1566
|
id,
|
|
1247
1567
|
rows
|
|
1248
1568
|
})]
|
|
1249
1569
|
});
|
|
1250
1570
|
}
|
|
1571
|
+
/**
|
|
1572
|
+
* The keyboard of the shortcut button: the keyboard icon of Lucide,
|
|
1573
|
+
* Copyright (c) Lucide Icons and Contributors, ISC License, https://lucide.dev.
|
|
1574
|
+
*/
|
|
1575
|
+
function KeyboardIcon3D() {
|
|
1576
|
+
return /* @__PURE__ */ jsxs("svg", {
|
|
1577
|
+
"aria-hidden": "true",
|
|
1578
|
+
viewBox: "0 0 24 24",
|
|
1579
|
+
fill: "none",
|
|
1580
|
+
stroke: "currentColor",
|
|
1581
|
+
strokeWidth: "2",
|
|
1582
|
+
strokeLinecap: "round",
|
|
1583
|
+
strokeLinejoin: "round",
|
|
1584
|
+
children: [/* @__PURE__ */ jsx("path", { d: "M10 8h.01M12 12h.01M14 8h.01M16 12h.01M18 8h.01M6 8h.01M7 16h10M8 12h.01" }), /* @__PURE__ */ jsx("rect", {
|
|
1585
|
+
width: "20",
|
|
1586
|
+
height: "16",
|
|
1587
|
+
x: "2",
|
|
1588
|
+
y: "4",
|
|
1589
|
+
rx: "2"
|
|
1590
|
+
})]
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1251
1593
|
/** One group of keys as chips. */
|
|
1252
1594
|
function KbdGroup3D({ group }) {
|
|
1253
1595
|
return /* @__PURE__ */ jsx("span", {
|
|
@@ -1286,12 +1628,12 @@ function ChartShortcutList3D({ id, rows }) {
|
|
|
1286
1628
|
function ChartLegend3D({ config }) {
|
|
1287
1629
|
return /* @__PURE__ */ jsx("div", {
|
|
1288
1630
|
"data-slot": "chart-3d-legend",
|
|
1289
|
-
className: "flex items-center justify-center gap-4 pt-3",
|
|
1631
|
+
className: "flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3",
|
|
1290
1632
|
children: Object.entries(config).map(([key, item]) => /* @__PURE__ */ jsxs("div", {
|
|
1291
1633
|
className: "flex items-center gap-1.5",
|
|
1292
1634
|
children: [item.icon ? /* @__PURE__ */ jsx(item.icon, {}) : /* @__PURE__ */ jsx("span", {
|
|
1293
1635
|
className: "size-2 rounded-[2px]",
|
|
1294
|
-
style: { background:
|
|
1636
|
+
style: { background: colorOf(key) }
|
|
1295
1637
|
}), item.label ?? key]
|
|
1296
1638
|
}, key))
|
|
1297
1639
|
});
|
|
@@ -1351,6 +1693,7 @@ const PAD = 2;
|
|
|
1351
1693
|
const FADE = 4;
|
|
1352
1694
|
const DODGE = 12;
|
|
1353
1695
|
const MARK = 6;
|
|
1696
|
+
const LIFT = 30;
|
|
1354
1697
|
/** The closed quad of a wall; its gridlines are their own lines, so a new one fades in. */
|
|
1355
1698
|
function quad(wall) {
|
|
1356
1699
|
return `M${wall.map(({ projected }) => `${projected.x},${projected.y}`).join("L")}Z`;
|
|
@@ -1378,7 +1721,7 @@ function gridKey(wall, from, to) {
|
|
|
1378
1721
|
* way to the ticks.
|
|
1379
1722
|
*/
|
|
1380
1723
|
function labelAxes(edges, spec, format, titles) {
|
|
1381
|
-
const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, spec[edge.axis], format[edge.axis] ?? String, titles[edge.axis]));
|
|
1724
|
+
const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, edges, spec[edge.axis], format[edge.axis] ?? String, titles[edge.axis]));
|
|
1382
1725
|
const shown = axes.filter((axis) => axis.chosen).sort((a, b) => a.ticks.length - b.ticks.length);
|
|
1383
1726
|
const placed = [];
|
|
1384
1727
|
for (const label of [...shown.flatMap((a) => a.ticks), ...shown.flatMap((a) => a.title ?? [])]) {
|
|
@@ -1431,7 +1774,7 @@ function hideOffRange(labels, range, chosen) {
|
|
|
1431
1774
|
if (!chosen) for (const l of labels) l.opacity = 0;
|
|
1432
1775
|
else for (const l of labels) if (l.value !== void 0 && !within(range.lo, range.hi, l.value)) l.opacity = 0;
|
|
1433
1776
|
}
|
|
1434
|
-
function edgeLabels(edge, { domain, leaving = [], span }, format, title) {
|
|
1777
|
+
function edgeLabels(edge, edges, { domain, leaving = [], span }, format, title) {
|
|
1435
1778
|
const { axis, normal: n, chosen } = edge;
|
|
1436
1779
|
const label = (key, at, text, h, gap, value) => {
|
|
1437
1780
|
const w = textWidth(text, h);
|
|
@@ -1460,15 +1803,9 @@ function edgeLabels(edge, { domain, leaving = [], span }, format, title) {
|
|
|
1460
1803
|
const [s0, s1] = span ?? [edge[0][axis], edge[edge.length - 1][axis]];
|
|
1461
1804
|
const start = along(edge, clamp(range.lo, range.hi, s0));
|
|
1462
1805
|
const end = along(edge, clamp(range.lo, range.hi, s1));
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
};
|
|
1467
|
-
const head = title ? {
|
|
1468
|
-
...label(`${id}-title`, middle, title, 12, GAP + depth + GAP),
|
|
1469
|
-
title: true
|
|
1470
|
-
} : void 0;
|
|
1471
|
-
if (head && rows === 2) head.shift = row;
|
|
1806
|
+
const upper = clamp(range.lo, range.hi, end.y <= start.y ? s1 : s0);
|
|
1807
|
+
const head = title ? titleLabel(`${id}-title`, axis, title, start, end, n, depth, face(edge, edges, upper)) : void 0;
|
|
1808
|
+
if (head && rows === 2 && axis !== "y") head.shift = row;
|
|
1472
1809
|
hideOffRange([...ticks, ...head ? [head] : []], range, chosen);
|
|
1473
1810
|
return {
|
|
1474
1811
|
chosen,
|
|
@@ -1492,6 +1829,68 @@ function edgeLabels(edge, { domain, leaving = [], span }, format, title) {
|
|
|
1492
1829
|
};
|
|
1493
1830
|
}
|
|
1494
1831
|
/**
|
|
1832
|
+
* The top of the box: the ends at `value` of the four edges of `edge`'s
|
|
1833
|
+
* axis, round the face they frame.
|
|
1834
|
+
*/
|
|
1835
|
+
function face(edge, edges, value) {
|
|
1836
|
+
const round = ({ sides: [a, b] }) => a ? 3 - b : b;
|
|
1837
|
+
return edges.filter((e) => e.axis === edge.axis).sort((p, q) => round(p) - round(q)).map((e) => along(e, value));
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* The height on screen of `face` between `x0` and `x1`: its least y there,
|
|
1841
|
+
* at a corner inside or where an edge crosses either end.
|
|
1842
|
+
*/
|
|
1843
|
+
function highest(face, x0, x1) {
|
|
1844
|
+
const [lo, hi] = [Math.min(x0, x1), Math.max(x0, x1)];
|
|
1845
|
+
const inside = face.filter((p) => within(lo, hi, p.x)).map((p) => p.y);
|
|
1846
|
+
const crossing = face.flatMap((p, i) => {
|
|
1847
|
+
const q = face[(i + 1) % face.length];
|
|
1848
|
+
return [lo, hi].filter((x) => p.x !== q.x && within(Math.min(p.x, q.x), Math.max(p.x, q.x), x)).map((x) => p.y + (q.y - p.y) * (x - p.x) / (q.x - p.x));
|
|
1849
|
+
});
|
|
1850
|
+
return Math.min(...inside, ...crossing);
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* The title of an axis, upright. The y axis has a free end, the top: its
|
|
1854
|
+
* title stands over that corner on the box's side of the line, its edge on
|
|
1855
|
+
* the line, a gap clear of the `top` of the box as far as it reaches, rising
|
|
1856
|
+
* with it by up to `LIFT`. A floor axis ends in corners the other axes label,
|
|
1857
|
+
* so its title sits beyond its tick labels, at the middle of its edge,
|
|
1858
|
+
* `depth` being how far they reach out.
|
|
1859
|
+
*/
|
|
1860
|
+
function titleLabel(key, axis, text, start, end, normal, depth, top) {
|
|
1861
|
+
const h = 12;
|
|
1862
|
+
const w = textWidth(text, h);
|
|
1863
|
+
const middle = {
|
|
1864
|
+
x: (start.x + end.x) / 2,
|
|
1865
|
+
y: (start.y + end.y) / 2
|
|
1866
|
+
};
|
|
1867
|
+
const label = {
|
|
1868
|
+
key,
|
|
1869
|
+
axis,
|
|
1870
|
+
text,
|
|
1871
|
+
title: true,
|
|
1872
|
+
opacity: 1,
|
|
1873
|
+
shift: ZERO,
|
|
1874
|
+
w,
|
|
1875
|
+
h
|
|
1876
|
+
};
|
|
1877
|
+
if (axis !== "y") return {
|
|
1878
|
+
...label,
|
|
1879
|
+
at: beside(middle, normal, w, h, GAP + depth + GAP)
|
|
1880
|
+
};
|
|
1881
|
+
const corner = end.y <= start.y ? end : start;
|
|
1882
|
+
const side = Math.sign(-normal.x) || 1;
|
|
1883
|
+
const under = Math.max(corner.y - LIFT, highest(top, corner.x, corner.x + side * w));
|
|
1884
|
+
return {
|
|
1885
|
+
...label,
|
|
1886
|
+
anchor: side > 0 ? "start" : "end",
|
|
1887
|
+
at: {
|
|
1888
|
+
x: corner.x + side * w / 2,
|
|
1889
|
+
y: under - GAP - h / 2
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1495
1894
|
* Fits the tick labels of an edge into the space between its ticks: in one
|
|
1496
1895
|
* row if they keep `PAD` apart, or slid along the edge if no label has to
|
|
1497
1896
|
* slide further than `DODGE`, else every other label a `row` further out if
|
|
@@ -1584,7 +1983,7 @@ function dodge(want, gaps) {
|
|
|
1584
1983
|
* follows the geometry, the label fades as smoothly as the camera turns.
|
|
1585
1984
|
*/
|
|
1586
1985
|
function shows(label, placed) {
|
|
1587
|
-
return placed.reduce((opacity, p) => p.axis === label.axis ? opacity : Math.min(opacity, 1 - (1 - clamp(0, 1, apart(label, p) / FADE)) * p.opacity), label.opacity);
|
|
1986
|
+
return placed.reduce((opacity, p) => p.axis === label.axis && !label.title ? opacity : Math.min(opacity, 1 - (1 - clamp(0, 1, apart(label, p) / FADE)) * p.opacity), label.opacity);
|
|
1588
1987
|
}
|
|
1589
1988
|
/** The space between the boxes of two labels, negative where they overlap. */
|
|
1590
1989
|
function apart(a, b) {
|
|
@@ -1628,11 +2027,12 @@ function AxisLine3D({ line: { from, to, opacity }, ink, width }) {
|
|
|
1628
2027
|
* A label of the chart box. Its place follows the camera at once; a change
|
|
1629
2028
|
* of edge, of row or of the labels that fit fades and moves it in a transition.
|
|
1630
2029
|
*/
|
|
1631
|
-
function AxisLabel3D({ label: { at, shift, opacity, text, title } }) {
|
|
2030
|
+
function AxisLabel3D({ label: { at, w, shift, opacity, text, title, anchor } }) {
|
|
2031
|
+
const x = at.x + (anchor === "start" ? -w / 2 : anchor === "end" ? w / 2 : 0);
|
|
1632
2032
|
return /* @__PURE__ */ jsx("text", {
|
|
1633
|
-
x
|
|
2033
|
+
x,
|
|
1634
2034
|
y: at.y,
|
|
1635
|
-
textAnchor: "middle",
|
|
2035
|
+
textAnchor: anchor ?? "middle",
|
|
1636
2036
|
dominantBaseline: "central",
|
|
1637
2037
|
style: {
|
|
1638
2038
|
opacity: Math.round(opacity * 100) / 100,
|
|
@@ -1674,6 +2074,104 @@ function worldBox(spec, depth = 1) {
|
|
|
1674
2074
|
};
|
|
1675
2075
|
}
|
|
1676
2076
|
/**
|
|
2077
|
+
* The point at `lon` and `lat`, in degrees, on the sphere of `radius` around
|
|
2078
|
+
* the center of the world box: the prime meridian faces the viewer at yaw 0,
|
|
2079
|
+
* east lies to the right and the north pole is up - towards -y, as the box
|
|
2080
|
+
* counts y. The globe chart builds its tiles from it, since a chart may not
|
|
2081
|
+
* do trigonometry of its own.
|
|
2082
|
+
*/
|
|
2083
|
+
function globe(lon, lat, radius = 1) {
|
|
2084
|
+
const [theta, phi] = [lon, lat].map((degrees) => degrees * Math.PI / 180);
|
|
2085
|
+
const flat = radius * Math.cos(phi);
|
|
2086
|
+
return {
|
|
2087
|
+
x: flat * Math.sin(theta),
|
|
2088
|
+
y: -radius * Math.sin(phi),
|
|
2089
|
+
z: flat * Math.cos(theta)
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
/** The radius of the globe in the world box: `geoView` projects the land at it, and a geo chart places its marks on it. */
|
|
2093
|
+
const GLOBE_RADIUS = 1.15;
|
|
2094
|
+
/**
|
|
2095
|
+
* The camera without its perspective, a copy, since d3-3d shares the view of
|
|
2096
|
+
* a camera it is handed: d3-geo has no perspective, so a geo chart projects
|
|
2097
|
+
* its marks without one too, and they stay on the land.
|
|
2098
|
+
*/
|
|
2099
|
+
function orthographic(camera) {
|
|
2100
|
+
return camera3D().origin(camera.origin()).scale(camera.scale()).rotateX(camera.rotateX()).rotateY(camera.rotateY());
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* Sets a projection of d3-geo to the camera: its rotation in degrees, its
|
|
2104
|
+
* scale at the radius of the globe and its origin, so the land turns, pans
|
|
2105
|
+
* and zooms with the marks.
|
|
2106
|
+
*/
|
|
2107
|
+
function geoView(camera, projection) {
|
|
2108
|
+
const degrees = (radians) => radians * 180 / Math.PI;
|
|
2109
|
+
const { x, y } = camera.origin();
|
|
2110
|
+
return projection.rotate([degrees(camera.rotateY()), degrees(camera.rotateX())]).scale(camera.scale() * GLOBE_RADIUS).translate([x, y]);
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Stands in for a canvas under d3-geo's `geoPath`: `marks(draw, key, fill)`
|
|
2114
|
+
* runs `draw` and returns every ring it drew, cut at the horizon as d3-geo
|
|
2115
|
+
* cuts it, as a polygon mark keyed `key-i`. A point is not a region: the ring
|
|
2116
|
+
* its `moveTo` began goes with its `arc`. A hole is not one either: a ring
|
|
2117
|
+
* wound the other way than the largest ring is left out, so the region
|
|
2118
|
+
* around a hole fills it once, not twice.
|
|
2119
|
+
*/
|
|
2120
|
+
function ringSink() {
|
|
2121
|
+
const rings = [];
|
|
2122
|
+
return {
|
|
2123
|
+
beginPath: () => {
|
|
2124
|
+
rings.length = 0;
|
|
2125
|
+
},
|
|
2126
|
+
moveTo: (x, y) => {
|
|
2127
|
+
rings.push([{
|
|
2128
|
+
x,
|
|
2129
|
+
y
|
|
2130
|
+
}]);
|
|
2131
|
+
},
|
|
2132
|
+
lineTo: (x, y) => {
|
|
2133
|
+
rings.at(-1).push({
|
|
2134
|
+
x,
|
|
2135
|
+
y
|
|
2136
|
+
});
|
|
2137
|
+
},
|
|
2138
|
+
closePath: () => {},
|
|
2139
|
+
arc: () => {
|
|
2140
|
+
rings.pop();
|
|
2141
|
+
},
|
|
2142
|
+
marks: (draw, key, fill) => {
|
|
2143
|
+
rings.length = 0;
|
|
2144
|
+
draw();
|
|
2145
|
+
const areas = rings.map(areaOf);
|
|
2146
|
+
const largest = areas.reduce((a, b) => Math.abs(b) > Math.abs(a) ? b : a, 0);
|
|
2147
|
+
return rings.flatMap((points, i) => areas[i] * largest < 0 ? [] : [{
|
|
2148
|
+
key: `${key}-${i}`,
|
|
2149
|
+
points,
|
|
2150
|
+
fill
|
|
2151
|
+
}]);
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* The earth under the marks of a geo chart: the ocean and the land as marks,
|
|
2157
|
+
* flat in `--chart-3d-ocean` and `--chart-3d-land`, falling back to the
|
|
2158
|
+
* colors of the walls - scenery, grouped by nothing, with no tooltip, that
|
|
2159
|
+
* neither enters nor glides. The chart hands in d3-geo's `geoOrthographic()`
|
|
2160
|
+
* and `geoPath`, so the core carries no d3-geo; `earth` sets the projection
|
|
2161
|
+
* to the camera and draws through a sink that collects every ring, cut at
|
|
2162
|
+
* the horizon, as a polygon. `path` and `sink` come along for a chart that
|
|
2163
|
+
* draws regions of its own: `sink.marks(() => path(feature), key, fill)`.
|
|
2164
|
+
*/
|
|
2165
|
+
function earth(land, camera, projection, geoPath) {
|
|
2166
|
+
const sink = ringSink();
|
|
2167
|
+
const path = geoPath(geoView(camera, projection), sink);
|
|
2168
|
+
return {
|
|
2169
|
+
marks: [...sink.marks(() => path({ type: "Sphere" }), "ocean", "var(--chart-3d-ocean, var(--muted))"), ...land.features.flatMap((feature, i) => sink.marks(() => path(feature), `land-${i}`, "var(--chart-3d-land, var(--border))"))],
|
|
2170
|
+
path,
|
|
2171
|
+
sink
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
1677
2175
|
* Hands the projection its accessors into the world box, so every chart maps
|
|
1678
2176
|
* its shapes onto the screen the same way.
|
|
1679
2177
|
*/
|
|
@@ -1706,20 +2204,90 @@ function faded(color, opacity) {
|
|
|
1706
2204
|
const percent = Math.round(opacity * 100);
|
|
1707
2205
|
return percent >= 100 ? color : `color-mix(in oklab, ${color} ${percent}%, transparent)`;
|
|
1708
2206
|
}
|
|
1709
|
-
/**
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
2207
|
+
/**
|
|
2208
|
+
* Whether `value` is a number a chart can place: whatever `Number` reads as a
|
|
2209
|
+
* finite number - a number, a string of one, a date, a bigint - but not what
|
|
2210
|
+
* it reads as 0 for want of a value: null, a blank string and a boolean would
|
|
2211
|
+
* draw an entry without a place at the origin.
|
|
2212
|
+
*/
|
|
2213
|
+
function finite(value) {
|
|
2214
|
+
const isBlank = typeof value === "string" && value.trim() === "";
|
|
2215
|
+
return value != null && typeof value !== "boolean" && !isBlank && Number.isFinite(Number(value));
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* `value` as an amount a mark is sized by, such as the area of a dot or the
|
|
2219
|
+
* height of a tile: one below 0, or one that is not a finite number, stands
|
|
2220
|
+
* as 0.
|
|
2221
|
+
*/
|
|
2222
|
+
function nonNegative(value) {
|
|
2223
|
+
return finite(value) ? Math.max(0, Number(value)) : 0;
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* The entries of `data` and their series, for a chart that lays the entries
|
|
2227
|
+
* out along x and the series along z, as the bar and the area chart do. The
|
|
2228
|
+
* name of every entry comes from its `index` field, the series are `keys` or
|
|
2229
|
+
* the keys of the config, and the `target` of every cell is keyed by the name
|
|
2230
|
+
* of its entry and its series, so it follows its entry when the data
|
|
2231
|
+
* reorders; a value that is not a finite number stands as 0. Each glides by name:
|
|
2232
|
+
* `values` from 0, `rows` - the place of every entry - to where it stands, and
|
|
2233
|
+
* `presence` from 0 to 1 as an entry comes and back as it goes, the gone ones
|
|
2234
|
+
* still among them. `labels` name the ticks: the entry at a tick of x, through
|
|
2235
|
+
* `format.x` - a tick of a gone entry fades out unnamed - and the series at a
|
|
2236
|
+
* tick of z. Every entry is keyed by its id - its name, made unique where a
|
|
2237
|
+
* name repeats - and `nameOf` reads the name back; `entries` holds the entry
|
|
2238
|
+
* behind every id still in the data.
|
|
2239
|
+
*/
|
|
2240
|
+
function useSeries({ data, index, keys, format = {} }) {
|
|
2241
|
+
const { config } = useChart3D();
|
|
2242
|
+
const series = React.useMemo(() => keys ?? Object.keys(config), [keys, config]);
|
|
2243
|
+
const { names, ids, target, order, present } = React.useMemo(() => {
|
|
2244
|
+
const names = data.map((d) => String(d[index] ?? ""));
|
|
2245
|
+
const ids = uniqueIds(names);
|
|
2246
|
+
return {
|
|
2247
|
+
names,
|
|
2248
|
+
ids,
|
|
2249
|
+
target: Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${ids[i]}-${key}`, finite(d[key]) ? Number(d[key]) : 0]))),
|
|
2250
|
+
order: Object.fromEntries(ids.map((id, i) => [id, i])),
|
|
2251
|
+
present: Object.fromEntries(ids.map((id) => [id, 1]))
|
|
2252
|
+
};
|
|
2253
|
+
}, [
|
|
2254
|
+
data,
|
|
2255
|
+
index,
|
|
2256
|
+
series
|
|
2257
|
+
]);
|
|
2258
|
+
const labels = {
|
|
2259
|
+
x: (i) => (format.x ?? String)(names[i] ?? ""),
|
|
2260
|
+
y: format.y,
|
|
2261
|
+
z: (j) => config[series[j]]?.label ?? series[j]
|
|
2262
|
+
};
|
|
1718
2263
|
return {
|
|
1719
|
-
|
|
1720
|
-
|
|
2264
|
+
series,
|
|
2265
|
+
target,
|
|
2266
|
+
entries: new Map(data.map((d, i) => [ids[i], d])),
|
|
2267
|
+
values: useTween(target, 0),
|
|
2268
|
+
rows: useTween(order),
|
|
2269
|
+
presence: useTween(present, 0),
|
|
2270
|
+
labels,
|
|
2271
|
+
nameOf
|
|
1721
2272
|
};
|
|
1722
2273
|
}
|
|
2274
|
+
const REPEAT = "\0";
|
|
2275
|
+
/**
|
|
2276
|
+
* An id per name, unique: a name the first time, the name and its count when
|
|
2277
|
+
* it comes again, so entries of the same name are entries of their own.
|
|
2278
|
+
* Nothing else tells them apart, so the order of the data does: the first
|
|
2279
|
+
* "Jan" stays the first through an update, whichever entry that is.
|
|
2280
|
+
*/
|
|
2281
|
+
function uniqueIds(names) {
|
|
2282
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2283
|
+
return names.map((name) => {
|
|
2284
|
+
const count = (seen.get(name) ?? 0) + 1;
|
|
2285
|
+
seen.set(name, count);
|
|
2286
|
+
return count === 1 ? name : `${name}${REPEAT}${count}`;
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
/** The name of the entry of `id`, as its data names it. */
|
|
2290
|
+
const nameOf = (id) => id.split(REPEAT)[0];
|
|
1723
2291
|
/** The marks of a cube: its faces that look at the camera, shaded in `color`, each with `tooltip` on hover. */
|
|
1724
2292
|
function cubeMarks(id, faces, color, tooltip) {
|
|
1725
2293
|
return faces.filter((face) => face.ccw).map((face) => ({
|
|
@@ -1729,55 +2297,345 @@ function cubeMarks(id, faces, color, tooltip) {
|
|
|
1729
2297
|
tooltip
|
|
1730
2298
|
}));
|
|
1731
2299
|
}
|
|
1732
|
-
// registry/new-york/ui/bar-chart-3d.tsx
|
|
1733
2300
|
/**
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
2301
|
+
* The corners of a bar on the floor of `world`, at `x` and `z` in the values
|
|
2302
|
+
* of the data, from 0 to `value`, `size` wide and deep in the world box.
|
|
2303
|
+
* box() rises towards -y, so a negative bar starts at its value.
|
|
1737
2304
|
*/
|
|
1738
|
-
function
|
|
1739
|
-
const
|
|
1740
|
-
const
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
const spec = useTweenAxes(box.spec);
|
|
1749
|
-
const depth = useTween({ depth: box.depth }).depth;
|
|
1750
|
-
const world = worldBox(spec, depth);
|
|
1751
|
-
const bars = barShapes(camera, cells, world, cellSize(spec, depth, barSize));
|
|
1752
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1753
|
-
spec,
|
|
1754
|
-
world,
|
|
1755
|
-
titles,
|
|
1756
|
-
format: {
|
|
1757
|
-
x: (i) => (format.x ?? String)(String(data[i]?.[index] ?? "")),
|
|
1758
|
-
y: format.y,
|
|
1759
|
-
z: (j) => config[series[j]]?.label ?? series[j]
|
|
1760
|
-
}
|
|
1761
|
-
}), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
|
|
1762
|
-
id: `${bar.name}-${bar.key}`,
|
|
1763
|
-
faces: bar.faces,
|
|
1764
|
-
color: `var(--color-${bar.key})`,
|
|
1765
|
-
title: bar.name,
|
|
1766
|
-
label: config[bar.key]?.label ?? bar.key,
|
|
1767
|
-
value: bar.value,
|
|
1768
|
-
opacity: opacity[bar.name]
|
|
1769
|
-
})) })] });
|
|
2305
|
+
function barCorners(world, x, z, value, size) {
|
|
2306
|
+
const floor = world.y(0);
|
|
2307
|
+
const top = world.y(value);
|
|
2308
|
+
return box({
|
|
2309
|
+
x: world.x(x),
|
|
2310
|
+
y: Math.max(floor, top),
|
|
2311
|
+
z: world.z(z),
|
|
2312
|
+
...size,
|
|
2313
|
+
height: Math.abs(floor - top)
|
|
2314
|
+
});
|
|
1770
2315
|
}
|
|
1771
|
-
/** The
|
|
1772
|
-
function
|
|
1773
|
-
return
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
}))
|
|
2316
|
+
/** The marks of one bar: the faces that look at the camera, shaded and faded to `opacity`, each with `tooltip` on hover. Its group is the bar alone, so hovering highlights one bar. */
|
|
2317
|
+
function barMarks({ id, faces, color, tooltip, opacity }) {
|
|
2318
|
+
return cubeMarks(id, faces, color, tooltip).map((mark) => ({
|
|
2319
|
+
...mark,
|
|
2320
|
+
fill: faded(mark.fill, opacity),
|
|
2321
|
+
groups: [id]
|
|
2322
|
+
}));
|
|
1778
2323
|
}
|
|
1779
2324
|
/**
|
|
1780
|
-
* The
|
|
2325
|
+
* The color at `value` on a ramp from the config color `low` at `lo` to
|
|
2326
|
+
* `high` at `hi` - a value beyond them takes the color of the nearer end: a
|
|
2327
|
+
* CSS color-mix, so it follows the theme.
|
|
2328
|
+
*/
|
|
2329
|
+
function ramp(value, [lo, hi], [low, high]) {
|
|
2330
|
+
const t = Math.min(100, Math.max(0, Math.round((value - lo) / (hi - lo || 1) * 100)));
|
|
2331
|
+
return `color-mix(in oklch, ${colorOf(high)} ${t}%, ${colorOf(low)})`;
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Where the `count` columns or rows of a grid stand along one axis of
|
|
2335
|
+
* arrangement: at their value of `at`, in any order, or evenly over `ends`,
|
|
2336
|
+
* by default their indices; one without a finite value has no place. With
|
|
2337
|
+
* the cell, the smallest gap between two places - 1 where there is none -
|
|
2338
|
+
* and the axis over the places, its ticks within: its walls half a cell
|
|
2339
|
+
* beyond them - the padding - or at them where not `padded`, never at a
|
|
2340
|
+
* rounder tick, which would leave the box empty beyond the data.
|
|
2341
|
+
*/
|
|
2342
|
+
function gridAxis(count, { at, ends = [0, count - 1], padded = true } = {}) {
|
|
2343
|
+
const even = linear([0, count - 1], ends);
|
|
2344
|
+
const values = Array.from({ length: count }, (_, i) => at ? at[i] : even(i));
|
|
2345
|
+
const places = Object.fromEntries(values.flatMap((v, i) => finite(v) ? [[i, v]] : []));
|
|
2346
|
+
const sorted = [...new Set(Object.values(places))].sort((a, b) => a - b);
|
|
2347
|
+
const gaps = sorted.slice(1).map((v, i) => v - sorted[i]);
|
|
2348
|
+
const cell = gaps.length ? Math.min(...gaps) : 1;
|
|
2349
|
+
const [lo = 0, hi = lo] = [sorted[0], sorted.at(-1)];
|
|
2350
|
+
return {
|
|
2351
|
+
places,
|
|
2352
|
+
cell,
|
|
2353
|
+
spec: pad({
|
|
2354
|
+
domain: [lo, hi],
|
|
2355
|
+
ticks: ticks(lo, hi, 5)
|
|
2356
|
+
}, padded || lo === hi ? cell : 0)
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
/** `cubes`, each with its bottom face as `floor`, found once: bars on one floor paint back to front by it. */
|
|
2360
|
+
function floored(cubes) {
|
|
2361
|
+
return cubes.map((cube) => Object.assign(cube, { floor: cube.faces.find((f) => f.face === "bottom") }));
|
|
2362
|
+
}
|
|
2363
|
+
// registry/new-york/ui/area-chart-3d.tsx
|
|
2364
|
+
const RIBBON = 2 / 3;
|
|
2365
|
+
/**
|
|
2366
|
+
* Areas pushed back into ribbons: the entries of `data` along x, one ribbon
|
|
2367
|
+
* per series along z, the area under its line reaching down to the floor of
|
|
2368
|
+
* the box. The series are the keys of the chart config unless `keys` names
|
|
2369
|
+
* them.
|
|
2370
|
+
*/
|
|
2371
|
+
function AreaChart3D({ data, index, keys, format = {}, titles, tooltip }) {
|
|
2372
|
+
const { config, camera } = useChart3D();
|
|
2373
|
+
const { series, target, entries, values, rows, presence, labels, nameOf } = useSeries({
|
|
2374
|
+
data,
|
|
2375
|
+
index,
|
|
2376
|
+
keys,
|
|
2377
|
+
format
|
|
2378
|
+
});
|
|
2379
|
+
const spec = useTweenAxes(areaAxes(data.length, series.length, Object.values(target)));
|
|
2380
|
+
const depth = useTween({ depth: Math.min(1, Math.max(.25, series.length / 6)) }).depth;
|
|
2381
|
+
const world = worldBox(spec, depth);
|
|
2382
|
+
const polygons = intoWorld(polygons3D().camera(camera), world);
|
|
2383
|
+
const ends = [spec.x.domain[0] + .5, spec.x.domain[1] - .5];
|
|
2384
|
+
const toward = Math.sign(camera.viewDirection().x);
|
|
2385
|
+
const marks = series.flatMap((key, j) => {
|
|
2386
|
+
const line = linePoints(rows, values, presence, key, ends);
|
|
2387
|
+
if (!line.length) return [];
|
|
2388
|
+
const z = [j - RIBBON / 2, j + RIBBON / 2];
|
|
2389
|
+
return [{
|
|
2390
|
+
key,
|
|
2391
|
+
line,
|
|
2392
|
+
faces: ribbonFaces(polygons, line, spec.y.domain[0], z)
|
|
2393
|
+
}];
|
|
2394
|
+
}).sort((a, b) => sort(a.faces[4], b.faces[4])).flatMap(({ key, line, faces }) => ribbonMarks(key, painted(faces, toward), line, (names) => ribbonTooltip(key, names, {
|
|
2395
|
+
config,
|
|
2396
|
+
entries,
|
|
2397
|
+
target,
|
|
2398
|
+
nameOf
|
|
2399
|
+
}, {
|
|
2400
|
+
format,
|
|
2401
|
+
tooltip
|
|
2402
|
+
})));
|
|
2403
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
2404
|
+
spec,
|
|
2405
|
+
world,
|
|
2406
|
+
titles,
|
|
2407
|
+
format: labels
|
|
2408
|
+
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* The points of the line of the series `key`, in order along x: every entry,
|
|
2412
|
+
* the ones still gliding away among them, at its gliding row and value. An
|
|
2413
|
+
* entry that comes or goes unfolds from the point before it - the one after
|
|
2414
|
+
* it, at the start of the line - and folds back into it, as far as its
|
|
2415
|
+
* `presence`, so the ribbon opens and closes without a step. Its value comes
|
|
2416
|
+
* from nothing as its presence grows - the two glide in step - so the part of
|
|
2417
|
+
* its neighbour it has not yet left is added: the point heads straight from
|
|
2418
|
+
* the neighbour to its own, never down towards the floor. Every
|
|
2419
|
+
* point stays within the gliding `ends` of the x axis, so a line with no
|
|
2420
|
+
* neighbour to unfold from - one that was empty - grows inside the box
|
|
2421
|
+
* instead of standing beyond its walls. A point only one of the tweens still
|
|
2422
|
+
* knows is on its way out and stays unseen.
|
|
2423
|
+
*/
|
|
2424
|
+
function linePoints(rows, values, presence, key, [lo, hi]) {
|
|
2425
|
+
const own = Object.entries(rows).sort(([, a], [, b]) => a - b).flatMap(([name, x]) => {
|
|
2426
|
+
const value = values[`${name}-${key}`];
|
|
2427
|
+
return value === void 0 ? [] : [{
|
|
2428
|
+
name,
|
|
2429
|
+
x,
|
|
2430
|
+
value,
|
|
2431
|
+
seen: presence[name]
|
|
2432
|
+
}];
|
|
2433
|
+
});
|
|
2434
|
+
let anchor = own.find((p) => p.seen === 1);
|
|
2435
|
+
return own.map((p) => {
|
|
2436
|
+
if (p.seen === 1) anchor = p;
|
|
2437
|
+
const { x } = anchor ?? p;
|
|
2438
|
+
return {
|
|
2439
|
+
name: p.name,
|
|
2440
|
+
x: Math.min(hi, Math.max(lo, x + p.seen * (p.x - x))),
|
|
2441
|
+
value: p.value + (1 - p.seen) * (anchor?.value ?? 0)
|
|
2442
|
+
};
|
|
2443
|
+
}).sort((a, b) => a.x - b.x);
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* The faces of the ribbon of `line`, projected: the area under the line,
|
|
2447
|
+
* down to `floor`, pushed back from `z[0]` to `z[1]`. Each is wound
|
|
2448
|
+
* counter-clockwise as seen from outside, so `ccw` tells whether it faces the
|
|
2449
|
+
* camera. The first five lie on its bounding box - the front and the back,
|
|
2450
|
+
* one polygon each however many points the line has, the two ends and the
|
|
2451
|
+
* bottom - the rest are its top, one quad per segment of the line.
|
|
2452
|
+
*/
|
|
2453
|
+
function ribbonFaces(polygons, line, floor, [back, front]) {
|
|
2454
|
+
const on = (p, z) => ({
|
|
2455
|
+
x: p.x,
|
|
2456
|
+
y: p.value,
|
|
2457
|
+
z
|
|
2458
|
+
});
|
|
2459
|
+
const under = (p, z) => ({
|
|
2460
|
+
x: p.x,
|
|
2461
|
+
y: floor,
|
|
2462
|
+
z
|
|
2463
|
+
});
|
|
2464
|
+
const face = (name, points, segment = -1) => Object.assign(points, {
|
|
2465
|
+
face: name,
|
|
2466
|
+
segment
|
|
2467
|
+
});
|
|
2468
|
+
const [a, o] = [line[0], line[line.length - 1]];
|
|
2469
|
+
return polygons.data([
|
|
2470
|
+
face("front", [
|
|
2471
|
+
on(a, front),
|
|
2472
|
+
under(a, front),
|
|
2473
|
+
under(o, front),
|
|
2474
|
+
...line.slice(1).reverse().map((p) => on(p, front))
|
|
2475
|
+
]),
|
|
2476
|
+
face("back", [
|
|
2477
|
+
...line.map((p) => on(p, back)),
|
|
2478
|
+
under(o, back),
|
|
2479
|
+
under(a, back)
|
|
2480
|
+
]),
|
|
2481
|
+
face("left", [
|
|
2482
|
+
on(a, back),
|
|
2483
|
+
under(a, back),
|
|
2484
|
+
under(a, front),
|
|
2485
|
+
on(a, front)
|
|
2486
|
+
]),
|
|
2487
|
+
face("right", [
|
|
2488
|
+
on(o, front),
|
|
2489
|
+
under(o, front),
|
|
2490
|
+
under(o, back),
|
|
2491
|
+
on(o, back)
|
|
2492
|
+
]),
|
|
2493
|
+
face("bottom", [
|
|
2494
|
+
under(a, front),
|
|
2495
|
+
under(a, back),
|
|
2496
|
+
under(o, back),
|
|
2497
|
+
under(o, front)
|
|
2498
|
+
]),
|
|
2499
|
+
...line.slice(1).map((p, i) => face("top", [
|
|
2500
|
+
on(line[i], front),
|
|
2501
|
+
on(p, front),
|
|
2502
|
+
on(p, back),
|
|
2503
|
+
on(line[i], back)
|
|
2504
|
+
], i))
|
|
2505
|
+
]);
|
|
2506
|
+
}
|
|
2507
|
+
/**
|
|
2508
|
+
* The faces of a ribbon that face the camera, in paint order. Every segment
|
|
2509
|
+
* of the top is a slab, and the planes x = const separate them: the top is
|
|
2510
|
+
* painted from the end of the ribbon away from the camera, which `toward`,
|
|
2511
|
+
* the sign of x in the view direction, names. The faces of the bounding box
|
|
2512
|
+
* come last: the ribbon lies wholly behind each of them.
|
|
2513
|
+
*/
|
|
2514
|
+
function painted(faces, toward) {
|
|
2515
|
+
const shown = faces.filter((f) => f.ccw);
|
|
2516
|
+
return [...shown.filter((f) => f.face === "top").sort((a, b) => toward * (a.segment - b.segment)), ...shown.filter((f) => f.face !== "top")];
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* The marks of one ribbon, shaded in the color of its series. Each face is a
|
|
2520
|
+
* group of its own, so hovering highlights the face under the pointer alone.
|
|
2521
|
+
* A segment of the top is keyed by the entry it starts at, so it keeps its
|
|
2522
|
+
* mark when entries come or go before it. Every face says what `tooltip`
|
|
2523
|
+
* makes of the entries it spans: the two ends of a segment of the top, none
|
|
2524
|
+
* on a side.
|
|
2525
|
+
*/
|
|
2526
|
+
function ribbonMarks(key, faces, line, tooltip) {
|
|
2527
|
+
const color = colorOf(key);
|
|
2528
|
+
const spans = (f) => f.face === "top" ? [line[f.segment].name, line[f.segment + 1].name] : [];
|
|
2529
|
+
return faces.map((f) => {
|
|
2530
|
+
const id = f.face === "top" ? `${key}-top-${line[f.segment].name}` : `${key}-${f.face}`;
|
|
2531
|
+
return {
|
|
2532
|
+
key: id,
|
|
2533
|
+
points: f.map((p) => p.projected),
|
|
2534
|
+
fill: shade(f, color),
|
|
2535
|
+
groups: [id],
|
|
2536
|
+
tooltip: tooltip(spans(f))
|
|
2537
|
+
};
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* What a face of the ribbon of `key` says on hover, made when the pointer
|
|
2542
|
+
* lands on it: the series and, on a segment of the top, the entries it
|
|
2543
|
+
* spans as the ticks of x name them, with their values as the ticks of y
|
|
2544
|
+
* name them - or what `tooltip` makes of that. A segment whose entry has
|
|
2545
|
+
* left the data says nothing.
|
|
2546
|
+
*/
|
|
2547
|
+
function ribbonTooltip(key, names, { config, entries, target, nameOf }, { format = {}, tooltip = (tip) => tip }) {
|
|
2548
|
+
const spanned = names.flatMap((name) => {
|
|
2549
|
+
const entry = entries.get(name);
|
|
2550
|
+
return entry ? [entry] : [];
|
|
2551
|
+
});
|
|
2552
|
+
if (spanned.length < names.length) return void 0;
|
|
2553
|
+
const color = colorOf(key);
|
|
2554
|
+
return () => tooltip({
|
|
2555
|
+
title: config[key]?.label ?? key,
|
|
2556
|
+
items: names.map((name) => {
|
|
2557
|
+
const value = target[`${name}-${key}`];
|
|
2558
|
+
return {
|
|
2559
|
+
label: (format.x ?? String)(nameOf(name)),
|
|
2560
|
+
value: format.y?.(value) ?? value,
|
|
2561
|
+
color
|
|
2562
|
+
};
|
|
2563
|
+
})
|
|
2564
|
+
}, {
|
|
2565
|
+
key,
|
|
2566
|
+
entries: spanned
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* The axes of an area chart: the entries along x, with half a row of room
|
|
2571
|
+
* between the first and the last and the walls, a row per series along z.
|
|
2572
|
+
* A line has many entries, so x ticks about ten of them, not every one.
|
|
2573
|
+
*/
|
|
2574
|
+
function areaAxes(entries, series, values) {
|
|
2575
|
+
const every = entries ? ticks(0, entries - 1, 10).filter(Number.isInteger) : [];
|
|
2576
|
+
return {
|
|
2577
|
+
x: pad({
|
|
2578
|
+
domain: [0, Math.max(0, entries - 1)],
|
|
2579
|
+
ticks: every
|
|
2580
|
+
}, 1),
|
|
2581
|
+
y: axis(Math.min(0, ...values), Math.max(0, ...values)),
|
|
2582
|
+
z: pad({
|
|
2583
|
+
domain: [0, Math.max(0, series - 1)],
|
|
2584
|
+
ticks: range(series)
|
|
2585
|
+
}, 1)
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
// registry/new-york/ui/bar-chart-3d.tsx
|
|
2589
|
+
/**
|
|
2590
|
+
* Bars on a floor: one row per entry of `data` along x, one per series
|
|
2591
|
+
* along z, the value as the height. The series are the keys of the chart
|
|
2592
|
+
* config unless `keys` names them.
|
|
2593
|
+
*/
|
|
2594
|
+
function BarChart3D({ data, index, keys, barSize = .6, format, titles, tooltip }) {
|
|
2595
|
+
const { config, camera } = useChart3D();
|
|
2596
|
+
const { series, target, entries, values, rows, presence, labels, nameOf } = useSeries({
|
|
2597
|
+
data,
|
|
2598
|
+
index,
|
|
2599
|
+
keys,
|
|
2600
|
+
format
|
|
2601
|
+
});
|
|
2602
|
+
const cells = barCells(rows, series, values);
|
|
2603
|
+
const box = barBox(data.length, series.length, Object.values(target));
|
|
2604
|
+
const spec = useTweenAxes(box.spec);
|
|
2605
|
+
const depth = useTween({ depth: box.depth }).depth;
|
|
2606
|
+
const world = worldBox(spec, depth);
|
|
2607
|
+
const bars = barShapes(camera, cells, world, cellSize(spec, depth, barSize));
|
|
2608
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
2609
|
+
spec,
|
|
2610
|
+
world,
|
|
2611
|
+
titles,
|
|
2612
|
+
format: labels
|
|
2613
|
+
}), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
|
|
2614
|
+
id: `${bar.name}-${bar.key}`,
|
|
2615
|
+
faces: bar.faces,
|
|
2616
|
+
color: colorOf(bar.key),
|
|
2617
|
+
tooltip: barTooltip({
|
|
2618
|
+
id: bar.name,
|
|
2619
|
+
name: nameOf(bar.name),
|
|
2620
|
+
key: bar.key
|
|
2621
|
+
}, entries.get(bar.name), target, {
|
|
2622
|
+
config,
|
|
2623
|
+
format,
|
|
2624
|
+
tooltip
|
|
2625
|
+
}),
|
|
2626
|
+
opacity: presence[bar.name]
|
|
2627
|
+
})) })] });
|
|
2628
|
+
}
|
|
2629
|
+
/** The cubes of the cells, projected by `camera` and painted back to front by their footprint, as the bars stand on one floor. */
|
|
2630
|
+
function barShapes(camera, cells, world, size) {
|
|
2631
|
+
return floored(cubes3D().camera(camera).data(cells.map(({ name, x, j, key, value }) => Object.assign(barCorners(world, x, j, value, size), {
|
|
2632
|
+
name,
|
|
2633
|
+
key,
|
|
2634
|
+
value
|
|
2635
|
+
})))).sort((a, b) => sort(a.floor, b.floor));
|
|
2636
|
+
}
|
|
2637
|
+
/**
|
|
2638
|
+
* The cell of every bar, the rows still gliding away among them: the row
|
|
1781
2639
|
* position along x, the series `key` along z, with the tweened value. A cell
|
|
1782
2640
|
* only one of the tweens still knows is on its way out and stays unseen.
|
|
1783
2641
|
*/
|
|
@@ -1794,15 +2652,6 @@ function barCells(rows, series, values) {
|
|
|
1794
2652
|
}));
|
|
1795
2653
|
}
|
|
1796
2654
|
/**
|
|
1797
|
-
* The value of every cell of the chart, keyed as its mark: the name of its
|
|
1798
|
-
* entry along x and the series `key` along z, standing as 0 where the entry
|
|
1799
|
-
* holds none. Keyed by name, not place, so a bar follows its entry when the
|
|
1800
|
-
* data reorders.
|
|
1801
|
-
*/
|
|
1802
|
-
function barValues(names, data, series) {
|
|
1803
|
-
return Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${names[i]}-${key}`, Number(d[key]) || 0])));
|
|
1804
|
-
}
|
|
1805
|
-
/**
|
|
1806
2655
|
* The box of a bar chart: a row per entry along x, a row per series along z,
|
|
1807
2656
|
* with half a cell of room between the outer rows and the walls.
|
|
1808
2657
|
*/
|
|
@@ -1820,7 +2669,7 @@ function barBox(entries, series, values) {
|
|
|
1820
2669
|
ticks: index(series)
|
|
1821
2670
|
}, 1)
|
|
1822
2671
|
},
|
|
1823
|
-
depth: Math.min(1, Math.max(.25, series / entries))
|
|
2672
|
+
depth: Math.min(1, Math.max(.25, series / (entries || 1)))
|
|
1824
2673
|
};
|
|
1825
2674
|
}
|
|
1826
2675
|
/**
|
|
@@ -1834,72 +2683,701 @@ function cellSize(spec, depth, barSize) {
|
|
|
1834
2683
|
};
|
|
1835
2684
|
}
|
|
1836
2685
|
/**
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
2686
|
+
* What the bar of `entry` says on hover, made when the pointer lands on it:
|
|
2687
|
+
* the entry as the ticks of x name it and its value as the ticks of y name
|
|
2688
|
+
* it - or what `tooltip` makes of that. A bar whose entry has left the data
|
|
2689
|
+
* says nothing.
|
|
1839
2690
|
*/
|
|
1840
|
-
function
|
|
1841
|
-
|
|
1842
|
-
const
|
|
1843
|
-
return
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2691
|
+
function barTooltip({ id, name, key }, entry, target, { config, format = {}, tooltip = (tip) => tip }) {
|
|
2692
|
+
if (!entry) return void 0;
|
|
2693
|
+
const value = target[`${id}-${key}`];
|
|
2694
|
+
return () => tooltip({
|
|
2695
|
+
title: (format.x ?? String)(name),
|
|
2696
|
+
items: [{
|
|
2697
|
+
label: config[key]?.label ?? key,
|
|
2698
|
+
value: format.y?.(value) ?? value,
|
|
2699
|
+
color: colorOf(key)
|
|
2700
|
+
}]
|
|
2701
|
+
}, {
|
|
2702
|
+
entry,
|
|
2703
|
+
key
|
|
1849
2704
|
});
|
|
1850
2705
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2706
|
+
// registry/new-york/ui/bar-field-chart-3d.tsx
|
|
2707
|
+
const BAR = .8;
|
|
2708
|
+
/**
|
|
2709
|
+
* A field of bars over a grid: `data[row][column]` is the value of a bar, the
|
|
2710
|
+
* columns run along x and the rows along z. Everything about a bar comes from
|
|
2711
|
+
* the data - its place from the value of its column and row, its footprint
|
|
2712
|
+
* from the smallest gap between two of them, its height from its value, its
|
|
2713
|
+
* color from the ramp between the two `colors` at the lowest and the highest
|
|
2714
|
+
* value.
|
|
2715
|
+
*/
|
|
2716
|
+
function BarFieldChart3D({ data, domain = {}, x, z, tooltip, format, titles, colors }) {
|
|
2717
|
+
const { config, camera } = useChart3D();
|
|
2718
|
+
const [low, high = low] = colors ?? Object.keys(config);
|
|
2719
|
+
const field = useField(data, {
|
|
2720
|
+
domain,
|
|
2721
|
+
x,
|
|
2722
|
+
z
|
|
2723
|
+
});
|
|
2724
|
+
const values = useTween(field.target, 0);
|
|
2725
|
+
const presence = useTween(field.present, 0);
|
|
2726
|
+
const y = axis(Math.min(0, field.lo), Math.max(0, field.hi));
|
|
2727
|
+
const spec = useTweenAxes({
|
|
2728
|
+
x: field.x.spec,
|
|
2729
|
+
y,
|
|
2730
|
+
z: field.z.spec
|
|
2731
|
+
});
|
|
2732
|
+
const world = worldBox(spec);
|
|
2733
|
+
const places = {
|
|
2734
|
+
x: useTween(field.x.places),
|
|
2735
|
+
z: useTween(field.z.places)
|
|
2736
|
+
};
|
|
2737
|
+
const cell = useTween({
|
|
2738
|
+
x: field.x.cell,
|
|
2739
|
+
z: field.z.cell
|
|
2740
|
+
});
|
|
2741
|
+
const size = {
|
|
2742
|
+
width: Math.abs(world.x(cell.x) - world.x(0)) * BAR,
|
|
2743
|
+
depth: Math.abs(world.z(cell.z) - world.z(0)) * BAR
|
|
2744
|
+
};
|
|
2745
|
+
const span = useTween({
|
|
2746
|
+
lo: field.lo,
|
|
2747
|
+
hi: field.hi
|
|
2748
|
+
});
|
|
2749
|
+
const marks = floored(cubes3D().camera(camera).data(fieldCorners(values, world, places, size))).sort((a, b) => sort(a.floor, b.floor)).flatMap((bar) => barMarks({
|
|
2750
|
+
id: bar.key,
|
|
2751
|
+
faces: bar.faces,
|
|
2752
|
+
color: ramp(bar.value, [span.lo, span.hi], [low, high]),
|
|
2753
|
+
tooltip: fieldTooltip(bar, field, {
|
|
2754
|
+
format,
|
|
2755
|
+
titles,
|
|
2756
|
+
tooltip
|
|
2757
|
+
}),
|
|
2758
|
+
opacity: presence[bar.key]
|
|
2759
|
+
}));
|
|
2760
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
2761
|
+
spec,
|
|
2762
|
+
world,
|
|
2763
|
+
format,
|
|
2764
|
+
titles
|
|
2765
|
+
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* The value of every bar, keyed by its row and column so it follows its cell
|
|
2769
|
+
* through updates, with its presence, the lowest and highest value and the
|
|
2770
|
+
* places of the columns and rows - built once per change of the data, not at
|
|
2771
|
+
* every frame the camera turns. A bar draws where its value and both its
|
|
2772
|
+
* places are finite numbers.
|
|
2773
|
+
*/
|
|
2774
|
+
function useField(data, { domain, x, z }) {
|
|
2775
|
+
return React.useMemo(() => {
|
|
2776
|
+
const sides = {
|
|
2777
|
+
x: gridAxis(Math.max(0, ...data.map((row) => row.length)), {
|
|
2778
|
+
at: x,
|
|
2779
|
+
ends: domain.x
|
|
2780
|
+
}),
|
|
2781
|
+
z: gridAxis(data.length, {
|
|
2782
|
+
at: z,
|
|
2783
|
+
ends: domain.z
|
|
2784
|
+
})
|
|
2785
|
+
};
|
|
2786
|
+
const target = Object.fromEntries(data.flatMap((row, r) => row.flatMap((v, c) => finite(v) && c in sides.x.places && r in sides.z.places ? [[`${r}-${c}`, v]] : [])));
|
|
2787
|
+
const [lo = 0, hi = 0] = extent(Object.values(target));
|
|
2788
|
+
return {
|
|
2789
|
+
...sides,
|
|
2790
|
+
target,
|
|
2791
|
+
present: Object.fromEntries(Object.keys(target).map((key) => [key, 1])),
|
|
2792
|
+
lo,
|
|
2793
|
+
hi
|
|
2794
|
+
};
|
|
2795
|
+
}, [
|
|
2796
|
+
data,
|
|
2797
|
+
x,
|
|
2798
|
+
z,
|
|
2799
|
+
domain.x,
|
|
2800
|
+
domain.z
|
|
2801
|
+
]);
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* The corners of every bar still on screen, from 0 to its gliding value, at
|
|
2805
|
+
* its row and column, `size` wide and deep.
|
|
2806
|
+
*/
|
|
2807
|
+
function fieldCorners(values, world, at, size) {
|
|
2808
|
+
return Object.entries(values).map(([key, value]) => {
|
|
2809
|
+
const [row, column] = key.split("-").map(Number);
|
|
2810
|
+
const corners = barCorners(world, at.x[column], at.z[row], value, size);
|
|
2811
|
+
return Object.assign(corners, {
|
|
2812
|
+
key,
|
|
2813
|
+
row,
|
|
2814
|
+
column,
|
|
2815
|
+
value
|
|
2816
|
+
});
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2820
|
+
* What a bar says on hover, made when the pointer lands on it: its x and z
|
|
2821
|
+
* as the ticks name them and its value as the ticks of y name it - or what
|
|
2822
|
+
* `tooltip` makes of that. A bar that has left the data, its value or its
|
|
2823
|
+
* place, says nothing.
|
|
2824
|
+
*/
|
|
2825
|
+
function fieldTooltip({ key, row, column }, { target, x: xs, z: zs }, { format = {}, titles = {}, tooltip = (tip) => tip }) {
|
|
2826
|
+
if (!(key in target)) return void 0;
|
|
2827
|
+
const value = target[key];
|
|
2828
|
+
const named = (name, v) => ({
|
|
2829
|
+
label: titles[name] ?? (name === "y" ? "value" : name),
|
|
2830
|
+
value: format[name]?.(v) ?? v
|
|
2831
|
+
});
|
|
2832
|
+
const [x, z] = [xs.places[column], zs.places[row]];
|
|
2833
|
+
return () => tooltip({ items: [
|
|
2834
|
+
named("x", x),
|
|
2835
|
+
named("z", z),
|
|
2836
|
+
named("y", value)
|
|
2837
|
+
] }, {
|
|
2838
|
+
row,
|
|
2839
|
+
column,
|
|
2840
|
+
x,
|
|
2841
|
+
z,
|
|
2842
|
+
value
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
// registry/new-york/ui/choropleth-chart-3d.tsx
|
|
2846
|
+
/**
|
|
2847
|
+
* The regions of the land on a globe, each in the color of its class. The
|
|
2848
|
+
* region an entry names is colored over the land, in the config color of
|
|
2849
|
+
* its series; one without an entry stays land. Nothing grows: a region fades
|
|
2850
|
+
* in with its entry, out without it, and one that changes series leaves as
|
|
2851
|
+
* one and enters as the other.
|
|
2852
|
+
*/
|
|
2853
|
+
function ChoroplethChart3D({ data, land, id, value, series, label, format, tooltip }) {
|
|
2854
|
+
const { config, camera } = useChart3D();
|
|
2855
|
+
const largerFirst = useLargerFirst(land);
|
|
2856
|
+
const reading = {
|
|
2857
|
+
config,
|
|
2858
|
+
value,
|
|
2859
|
+
format,
|
|
2860
|
+
tooltip
|
|
2861
|
+
};
|
|
2862
|
+
const { regions, present } = useRegions(data, {
|
|
2863
|
+
id,
|
|
2864
|
+
value,
|
|
2865
|
+
series,
|
|
2866
|
+
label
|
|
2867
|
+
}, Object.keys(config)[0]);
|
|
2868
|
+
const presence = useTween(present, 0);
|
|
2869
|
+
const opaque = new Set(Object.entries(presence).flatMap(([pid, o]) => o >= 1 ? [JSON.parse(pid)[0]] : []));
|
|
2870
|
+
const { marks: scenery, path, sink } = earth({ features: land.features.filter((f) => f.id === void 0 || !opaque.has(String(f.id))) }, camera, geoOrthographic(), geoPath);
|
|
2871
|
+
const features = new Map(land.features.filter((f) => f.id !== void 0).map((f) => [String(f.id), f]));
|
|
2872
|
+
const overlays = Object.entries(presence).sort(([a], [b]) => largerFirst(a, b)).flatMap(([pid, opacity]) => {
|
|
2873
|
+
const [rid, key] = JSON.parse(pid);
|
|
2874
|
+
const feature = features.get(rid);
|
|
2875
|
+
if (!feature) return [];
|
|
2876
|
+
const region = regions.get(rid);
|
|
2877
|
+
const says = region?.key === key ? regionTooltip(region, reading) : void 0;
|
|
2878
|
+
return sink.marks(() => path(feature), `region-${rid}-${key}`, faded(colorOf(key), opacity)).map((mark) => ({
|
|
2879
|
+
...mark,
|
|
2880
|
+
groups: [rid],
|
|
2881
|
+
tooltip: says
|
|
2882
|
+
}));
|
|
2883
|
+
});
|
|
2884
|
+
return /* @__PURE__ */ jsx(Marks3D, { marks: [...scenery, ...overlays] });
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Orders the presence ids of two regions, the larger region first, so a
|
|
2888
|
+
* region inside another - Lesotho in South Africa - is painted on top, where
|
|
2889
|
+
* the pointer finds it.
|
|
2890
|
+
*/
|
|
2891
|
+
function useLargerFirst(land) {
|
|
2892
|
+
const areas = React.useMemo(() => new Map(land.features.map((f) => [String(f.id), geoArea(f)])), [land]);
|
|
2893
|
+
const size = (pid) => areas.get(JSON.parse(pid)[0]) ?? 0;
|
|
2894
|
+
return (a, b) => size(b) - size(a);
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* What a region says on hover, made when the pointer lands on it: its name,
|
|
2898
|
+
* or the label of its series, over its value in the series color, labelled
|
|
2899
|
+
* by the field - or what `tooltip` makes of that.
|
|
2900
|
+
*/
|
|
2901
|
+
function regionTooltip({ key, value, name, entry }, { config, value: field, format, tooltip = (tip) => tip }) {
|
|
2902
|
+
return () => tooltip({
|
|
2903
|
+
title: name ?? config[key]?.label ?? key,
|
|
1856
2904
|
items: [{
|
|
1857
|
-
label,
|
|
1858
|
-
value,
|
|
1859
|
-
color
|
|
2905
|
+
label: field,
|
|
2906
|
+
value: format?.(value) ?? value,
|
|
2907
|
+
color: colorOf(key)
|
|
1860
2908
|
}]
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
2909
|
+
}, { entry });
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* The region of every entry and its presence - built once per change of the
|
|
2913
|
+
* data, not at every frame the camera turns. The presence is keyed by the
|
|
2914
|
+
* region and its series, so a region that changes color leaves as one and
|
|
2915
|
+
* enters as the other, each fading.
|
|
2916
|
+
*/
|
|
2917
|
+
function useRegions(data, fields, fallback) {
|
|
2918
|
+
const { id, value, series, label } = fields;
|
|
2919
|
+
return React.useMemo(() => {
|
|
2920
|
+
const regions = regionValues(data, {
|
|
2921
|
+
id,
|
|
2922
|
+
value,
|
|
2923
|
+
series,
|
|
2924
|
+
label
|
|
2925
|
+
}, fallback);
|
|
2926
|
+
return {
|
|
2927
|
+
regions,
|
|
2928
|
+
present: Object.fromEntries([...regions.values()].map((r) => [JSON.stringify([r.rid, r.key]), 1]))
|
|
2929
|
+
};
|
|
2930
|
+
}, [
|
|
2931
|
+
data,
|
|
2932
|
+
id,
|
|
2933
|
+
value,
|
|
2934
|
+
series,
|
|
2935
|
+
label,
|
|
2936
|
+
fallback
|
|
2937
|
+
]);
|
|
2938
|
+
}
|
|
2939
|
+
/** The region of every entry - the last entry on a region wins - by the id it names. */
|
|
2940
|
+
function regionValues(data, fields, fallback) {
|
|
2941
|
+
const regions = /* @__PURE__ */ new Map();
|
|
2942
|
+
for (const d of data) {
|
|
2943
|
+
const rid = String(d[fields.id]);
|
|
2944
|
+
regions.set(rid, {
|
|
2945
|
+
rid,
|
|
2946
|
+
key: fields.series ? String(d[fields.series]) : fallback,
|
|
2947
|
+
value: Number(d[fields.value]) || 0,
|
|
2948
|
+
entry: d,
|
|
2949
|
+
name: fields.label ? String(d[fields.label]) : void 0
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
return regions;
|
|
2953
|
+
}
|
|
2954
|
+
// registry/new-york/ui/globe-chart-3d.tsx
|
|
2955
|
+
/** The width of a tile in degrees, unless the chart says. */
|
|
2956
|
+
const STEP = 5;
|
|
2957
|
+
/** The height of a tile at the ceiling, in the world box. */
|
|
2958
|
+
const HEIGHT = .4;
|
|
2959
|
+
/**
|
|
2960
|
+
* A globe of tiles between meridians and parallels, `step` degrees wide, on
|
|
2961
|
+
* the land. The tile that holds an entry rises from the globe to the entry's
|
|
2962
|
+
* value, in the color of its series; one without an entry is not drawn.
|
|
2963
|
+
*/
|
|
2964
|
+
function GlobeChart3D({ data, land, lon, lat, value, series, label, step: requested = STEP, format, tooltip }) {
|
|
2965
|
+
const { config, camera } = useChart3D();
|
|
2966
|
+
const step = requested > 0 && requested <= 180 ? requested : STEP;
|
|
2967
|
+
const fields = {
|
|
2968
|
+
lon,
|
|
2969
|
+
lat,
|
|
2970
|
+
value,
|
|
2971
|
+
series,
|
|
2972
|
+
label
|
|
2973
|
+
};
|
|
2974
|
+
const { tiles, target, present } = useTiles(data, fields, step, Object.keys(config)[0]);
|
|
2975
|
+
const heights = useTween(target, 0);
|
|
2976
|
+
const presence = useTween(present, 0);
|
|
2977
|
+
const { ceiling } = useTween({ ceiling: axis(0, Object.values(target).reduce((max, v) => Math.max(max, v), 0)).domain[1] });
|
|
2978
|
+
const eye = orthographic(camera);
|
|
2979
|
+
const ids = Object.keys(heights);
|
|
2980
|
+
const shapes = useShapes(ids, step);
|
|
2981
|
+
const tooltips = useTileTooltips(tiles, {
|
|
2982
|
+
config,
|
|
2983
|
+
fields,
|
|
2984
|
+
format,
|
|
2985
|
+
tooltip
|
|
2986
|
+
});
|
|
2987
|
+
const near = nearTiles(ids, shapes, step, eye);
|
|
2988
|
+
const prisms = polygons3D().camera(eye).data(prismFaces(near, shapes, heights, ceiling, eye.scale())).map((face) => Object.assign(face, { centroid: {
|
|
2989
|
+
...face.centroid,
|
|
2990
|
+
z: +face.centroid.z.toFixed(9)
|
|
2991
|
+
} })).sort(sort).filter((face) => face.ccw && face.centroid.z > 0).map((face) => prismMark(face, shapes.get(face.id).key, presence[face.id], tooltips.get(face.id)));
|
|
2992
|
+
const scenery = earth(land, camera, geoOrthographic(), geoPath).marks;
|
|
2993
|
+
return /* @__PURE__ */ jsx(Marks3D, { marks: [...scenery, ...prisms] });
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* The tile of every entry, the value of each and its presence - built once
|
|
2997
|
+
* per change of the data, not at every frame the camera turns. A tile is
|
|
2998
|
+
* keyed by its place and its series, so a tile that changes color leaves as
|
|
2999
|
+
* one and enters as the other.
|
|
3000
|
+
*/
|
|
3001
|
+
function useTiles(data, fields, step, fallback) {
|
|
3002
|
+
const { lon, lat, value, series, label } = fields;
|
|
3003
|
+
return React.useMemo(() => {
|
|
3004
|
+
const tiles = tileValues(data, {
|
|
3005
|
+
lon,
|
|
3006
|
+
lat,
|
|
3007
|
+
value,
|
|
3008
|
+
series,
|
|
3009
|
+
label
|
|
3010
|
+
}, step, fallback);
|
|
3011
|
+
return {
|
|
3012
|
+
tiles,
|
|
3013
|
+
target: Object.fromEntries([...tiles.values()].map((t) => [t.id, t.value])),
|
|
3014
|
+
present: Object.fromEntries([...tiles.keys()].map((k) => [k, 1]))
|
|
3015
|
+
};
|
|
3016
|
+
}, [
|
|
3017
|
+
data,
|
|
3018
|
+
lon,
|
|
3019
|
+
lat,
|
|
3020
|
+
value,
|
|
3021
|
+
series,
|
|
3022
|
+
label,
|
|
3023
|
+
step,
|
|
3024
|
+
fallback
|
|
3025
|
+
]);
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* The shape of every tile `ids` names, read from its id once and not at
|
|
3029
|
+
* every frame the camera turns - the gone ones too, while they shrink - and
|
|
3030
|
+
* forgotten with the tile, so the memory stays the size of the globe.
|
|
3031
|
+
*/
|
|
3032
|
+
function useShapes(ids, step) {
|
|
3033
|
+
const memory = React.useRef({
|
|
3034
|
+
step,
|
|
3035
|
+
shapes: /* @__PURE__ */ new Map()
|
|
3036
|
+
});
|
|
3037
|
+
if (memory.current.step !== step) memory.current = {
|
|
3038
|
+
step,
|
|
3039
|
+
shapes: /* @__PURE__ */ new Map()
|
|
3040
|
+
};
|
|
3041
|
+
const { shapes } = memory.current;
|
|
3042
|
+
for (const id of ids) {
|
|
3043
|
+
if (shapes.has(id)) continue;
|
|
3044
|
+
const { lon, lat, key } = tile(id);
|
|
3045
|
+
shapes.set(id, {
|
|
3046
|
+
key,
|
|
3047
|
+
center: globe(lon, lat, GLOBE_RADIUS),
|
|
3048
|
+
ground: corners(lon, lat, step, GLOBE_RADIUS)
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
if (shapes.size > ids.length) {
|
|
3052
|
+
const live = new Set(ids);
|
|
3053
|
+
for (const id of shapes.keys()) if (!live.has(id)) shapes.delete(id);
|
|
3054
|
+
}
|
|
3055
|
+
return shapes;
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* What every tile says on hover, made once per change of the tiles or of
|
|
3059
|
+
* what reads them, not at every frame the camera turns; every face of a tile
|
|
3060
|
+
* shares it.
|
|
3061
|
+
*/
|
|
3062
|
+
function useTileTooltips(tiles, { config, fields, format, tooltip }) {
|
|
3063
|
+
const { lon, lat, value, series, label } = fields;
|
|
3064
|
+
return React.useMemo(() => {
|
|
3065
|
+
const reading = {
|
|
3066
|
+
config,
|
|
3067
|
+
fields: {
|
|
3068
|
+
lon,
|
|
3069
|
+
lat,
|
|
3070
|
+
value,
|
|
3071
|
+
series,
|
|
3072
|
+
label
|
|
3073
|
+
},
|
|
3074
|
+
format,
|
|
3075
|
+
tooltip
|
|
3076
|
+
};
|
|
3077
|
+
return new Map([...tiles].map(([id, t]) => [id, tileTooltip(t, reading)]));
|
|
3078
|
+
}, [
|
|
3079
|
+
tiles,
|
|
3080
|
+
config,
|
|
3081
|
+
lon,
|
|
3082
|
+
lat,
|
|
3083
|
+
value,
|
|
3084
|
+
series,
|
|
3085
|
+
label,
|
|
3086
|
+
format,
|
|
3087
|
+
tooltip
|
|
3088
|
+
]);
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* The ids of the tiles whose center lies in front of the center plane of the
|
|
3092
|
+
* globe, or less than half a tile behind it: every face of any other tile is
|
|
3093
|
+
* behind the plane too, and projecting those only to cull them cost half the
|
|
3094
|
+
* frame.
|
|
3095
|
+
*/
|
|
3096
|
+
function nearTiles(ids, shapes, step, eye) {
|
|
3097
|
+
const edge = -globe(0, step / 2, GLOBE_RADIUS).y;
|
|
3098
|
+
const centers = ids.map((id) => ({
|
|
3099
|
+
id,
|
|
3100
|
+
...shapes.get(id).center
|
|
1865
3101
|
}));
|
|
3102
|
+
return points3D().camera(eye).data(centers).filter((p) => p.rotated.z > -edge).map((p) => p.id);
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* The tile of every entry - the last entry on a tile wins - keyed by the
|
|
3106
|
+
* center of the tile and the config key of its color, with its value and, if
|
|
3107
|
+
* the chart names entries, the name of that last entry, and every entry on
|
|
3108
|
+
* the tile, gathered in one list: a copy per entry costs the square of them.
|
|
3109
|
+
*/
|
|
3110
|
+
function tileValues(data, fields, step, fallback) {
|
|
3111
|
+
const tiles = /* @__PURE__ */ new Map();
|
|
3112
|
+
for (const d of data) {
|
|
3113
|
+
if (!finite(d[fields.lon]) || !finite(d[fields.lat])) continue;
|
|
3114
|
+
const [lon, lat] = [Number(d[fields.lon]), Number(d[fields.lat])];
|
|
3115
|
+
const center = `${snap(lon, -180, 180, step)},${snap(lat, -90, 90, step)}`;
|
|
3116
|
+
const key = fields.series ? String(d[fields.series]) : fallback;
|
|
3117
|
+
const entries = tiles.get(center)?.entries ?? [];
|
|
3118
|
+
entries.push(d);
|
|
3119
|
+
tiles.set(center, {
|
|
3120
|
+
id: `${center},${key}`,
|
|
3121
|
+
value: nonNegative(d[fields.value]),
|
|
3122
|
+
entries,
|
|
3123
|
+
name: fields.label ? String(d[fields.label]) : void 0
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
return new Map([...tiles.values()].map((t) => [t.id, t]));
|
|
3127
|
+
}
|
|
3128
|
+
/** The center of the tile between `lo` and `hi` that holds `v`; the last tile holds the far edge and beyond. */
|
|
3129
|
+
function snap(v, lo, hi, step) {
|
|
3130
|
+
return lo + (Math.min(Math.max(0, Math.floor((v - lo) / step)), Math.round((hi - lo) / step) - 1) + .5) * step;
|
|
3131
|
+
}
|
|
3132
|
+
/** The center and the config key of a tile that rises, read back from its id. */
|
|
3133
|
+
function tile(id) {
|
|
3134
|
+
const [lon, lat, ...key] = id.split(",");
|
|
3135
|
+
return {
|
|
3136
|
+
lon: Number(lon),
|
|
3137
|
+
lat: Number(lat),
|
|
3138
|
+
key: key.join(",")
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* The faces of the tiles `ids` - the top of each and, where the tile is a
|
|
3143
|
+
* pixel tall on screen, its four sides - the gone ones still shrinking among
|
|
3144
|
+
* them. A side thinner than a pixel draws nothing, and on a globe of
|
|
3145
|
+
* thousands of flat tiles the sides are most of the faces, so leaving them
|
|
3146
|
+
* out turns it about twice as fast.
|
|
3147
|
+
*/
|
|
3148
|
+
function prismFaces(ids, shapes, heights, ceiling, scale) {
|
|
3149
|
+
return ids.flatMap((id) => {
|
|
3150
|
+
const bottom = shapes.get(id).ground;
|
|
3151
|
+
const lift = heights[id] / ceiling * HEIGHT;
|
|
3152
|
+
const lifted = (GLOBE_RADIUS + lift) / GLOBE_RADIUS;
|
|
3153
|
+
const top = Object.assign(bottom.map((p) => ({
|
|
3154
|
+
x: p.x * lifted,
|
|
3155
|
+
y: p.y * lifted,
|
|
3156
|
+
z: p.z * lifted
|
|
3157
|
+
})), {
|
|
3158
|
+
id,
|
|
3159
|
+
part: "top"
|
|
3160
|
+
});
|
|
3161
|
+
if (lift * scale < 1) return [top];
|
|
3162
|
+
return [top, ...bottom.map((b, i) => {
|
|
3163
|
+
const j = (i + 1) % 4;
|
|
3164
|
+
return Object.assign([
|
|
3165
|
+
b,
|
|
3166
|
+
bottom[j],
|
|
3167
|
+
top[j],
|
|
3168
|
+
top[i]
|
|
3169
|
+
], {
|
|
3170
|
+
id,
|
|
3171
|
+
part: `side${i}`
|
|
3172
|
+
});
|
|
3173
|
+
})];
|
|
3174
|
+
});
|
|
3175
|
+
}
|
|
3176
|
+
/** The corners of the tile around `lon` and `lat` at `radius`, wound to face outwards. */
|
|
3177
|
+
function corners(lon, lat, step, radius) {
|
|
3178
|
+
const [w, e, s, n] = [
|
|
3179
|
+
lon - step / 2,
|
|
3180
|
+
lon + step / 2,
|
|
3181
|
+
lat - step / 2,
|
|
3182
|
+
lat + step / 2
|
|
3183
|
+
];
|
|
3184
|
+
return [
|
|
3185
|
+
globe(w, s, radius),
|
|
3186
|
+
globe(e, s, radius),
|
|
3187
|
+
globe(e, n, radius),
|
|
3188
|
+
globe(w, n, radius)
|
|
3189
|
+
];
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* What a tile says on hover, made when the pointer lands on one of its
|
|
3193
|
+
* faces: its name, or the label of its series, over its value in the series
|
|
3194
|
+
* color and the center of the tile, labelled by the fields - or what
|
|
3195
|
+
* `tooltip` makes of that. A tile whose entries have left the data says
|
|
3196
|
+
* nothing.
|
|
3197
|
+
*/
|
|
3198
|
+
function tileTooltip({ id, value, name, entries }, { config, fields, format, tooltip = (tip) => tip }) {
|
|
3199
|
+
return () => {
|
|
3200
|
+
const { lon, lat, key } = tile(id);
|
|
3201
|
+
return tooltip({
|
|
3202
|
+
title: name ?? config[key]?.label ?? key,
|
|
3203
|
+
items: [
|
|
3204
|
+
{
|
|
3205
|
+
label: fields.value,
|
|
3206
|
+
value: format?.(value) ?? value,
|
|
3207
|
+
color: colorOf(key)
|
|
3208
|
+
},
|
|
3209
|
+
{
|
|
3210
|
+
label: fields.lat,
|
|
3211
|
+
value: lat
|
|
3212
|
+
},
|
|
3213
|
+
{
|
|
3214
|
+
label: fields.lon,
|
|
3215
|
+
value: lon
|
|
3216
|
+
}
|
|
3217
|
+
]
|
|
3218
|
+
}, { entries });
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
3221
|
+
/**
|
|
3222
|
+
* The mark of one face of a tile that rises: shaded in the color of its
|
|
3223
|
+
* series and faded to its presence. Its group is the tile alone, so hovering
|
|
3224
|
+
* highlights one tile, not its series.
|
|
3225
|
+
*/
|
|
3226
|
+
function prismMark(face, key, presence, tooltip) {
|
|
3227
|
+
return {
|
|
3228
|
+
key: `${face.id}-${face.part}`,
|
|
3229
|
+
points: face.map((p) => p.projected),
|
|
3230
|
+
fill: faded(shade(face, colorOf(key)), presence),
|
|
3231
|
+
groups: [face.id],
|
|
3232
|
+
tooltip
|
|
3233
|
+
};
|
|
3234
|
+
}
|
|
3235
|
+
// registry/new-york/ui/places-chart-3d.tsx
|
|
3236
|
+
/**
|
|
3237
|
+
* Dots on the land of a globe, one per place, its area its value. A dot on
|
|
3238
|
+
* the far side is hidden by the globe.
|
|
3239
|
+
*/
|
|
3240
|
+
function PlacesChart3D({ data, land, lon, lat, value, series, id, radius = 6, format, tooltip }) {
|
|
3241
|
+
const { config, camera } = useChart3D();
|
|
3242
|
+
const fields = {
|
|
3243
|
+
lon,
|
|
3244
|
+
lat,
|
|
3245
|
+
value,
|
|
3246
|
+
series,
|
|
3247
|
+
id
|
|
3248
|
+
};
|
|
3249
|
+
const { entries, names, target, present } = useDots(data, fields);
|
|
3250
|
+
const sizes = useTween(target, 0);
|
|
3251
|
+
const opacity = useTween(present, 0);
|
|
3252
|
+
const { peak } = useTween({ peak: Math.max(0, ...Object.values(target)) });
|
|
3253
|
+
const place = usePlaces(names, entries, fields, Object.keys(config)[0], sizes);
|
|
3254
|
+
const reading = {
|
|
3255
|
+
config,
|
|
3256
|
+
fields,
|
|
3257
|
+
radius,
|
|
3258
|
+
format,
|
|
3259
|
+
tooltip
|
|
3260
|
+
};
|
|
3261
|
+
const behind = new Map(entries.map((d, i) => [names[i], d]));
|
|
3262
|
+
const dots = points3D().camera(orthographic(camera)).x((p) => p.x).y((p) => p.y).z((p) => p.z).data(Object.keys(sizes).map((name) => {
|
|
3263
|
+
const { lon, lat } = place(name);
|
|
3264
|
+
return {
|
|
3265
|
+
name,
|
|
3266
|
+
...globe(lon, lat, GLOBE_RADIUS)
|
|
3267
|
+
};
|
|
3268
|
+
})).filter((p) => p.rotated.z > 0).sort(sort).map((p) => dotMark(p, place(p.name), sizes[p.name] / (peak || 1), behind.get(p.name), opacity[p.name], reading));
|
|
3269
|
+
const scenery = earth(land, camera, geoOrthographic(), geoPath).marks;
|
|
3270
|
+
return /* @__PURE__ */ jsx(Marks3D, { marks: [...scenery, ...dots] });
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* The entries with a place on the globe, the name of each, their values and
|
|
3274
|
+
* their presence - built once per change of the data, not at every frame the
|
|
3275
|
+
* camera turns.
|
|
3276
|
+
*/
|
|
3277
|
+
function useDots(data, fields) {
|
|
3278
|
+
const { lon, lat, value, id } = fields;
|
|
3279
|
+
return React.useMemo(() => {
|
|
3280
|
+
const entries = data.filter((d) => finite(d[lon]) && finite(d[lat]));
|
|
3281
|
+
const names = entries.map((d) => id ? String(d[id]) : `${Number(d[lon])},${Number(d[lat])}`);
|
|
3282
|
+
return {
|
|
3283
|
+
entries,
|
|
3284
|
+
names,
|
|
3285
|
+
target: Object.fromEntries(entries.map((d, i) => [names[i], value ? nonNegative(d[value]) : 1])),
|
|
3286
|
+
present: Object.fromEntries(names.map((name) => [name, 1]))
|
|
3287
|
+
};
|
|
3288
|
+
}, [
|
|
3289
|
+
data,
|
|
3290
|
+
lon,
|
|
3291
|
+
lat,
|
|
3292
|
+
value,
|
|
3293
|
+
id
|
|
3294
|
+
]);
|
|
3295
|
+
}
|
|
3296
|
+
/**
|
|
3297
|
+
* The mark of one dot: a circle whose area is its share of the peak, in the
|
|
3298
|
+
* color of its series, faded to its presence and ringed by the background so
|
|
3299
|
+
* that dots in front stand out. Its group is the dot alone, so hovering
|
|
3300
|
+
* highlights that dot only.
|
|
3301
|
+
*/
|
|
3302
|
+
function dotMark(p, place, share, entry, presence, reading) {
|
|
3303
|
+
return {
|
|
3304
|
+
key: p.name,
|
|
3305
|
+
points: [p.projected],
|
|
3306
|
+
r: reading.radius * Math.sqrt(share),
|
|
3307
|
+
fill: faded(colorOf(place.key), presence),
|
|
3308
|
+
stroke: faded("var(--background)", presence),
|
|
3309
|
+
groups: [p.name],
|
|
3310
|
+
tooltip: dotTooltip(p.name, place, entry, reading)
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* What a dot says on hover, made when the pointer lands on it - thousands of
|
|
3315
|
+
* dots, one tooltip: its name, or its series, over its value and its place,
|
|
3316
|
+
* labelled by the fields - or what `tooltip` makes of that. A dot whose
|
|
3317
|
+
* entry has left the data says nothing.
|
|
3318
|
+
*/
|
|
3319
|
+
function dotTooltip(name, { lon, lat, key }, entry, { config, fields, format, tooltip = (tip) => tip }) {
|
|
3320
|
+
if (!entry) return void 0;
|
|
3321
|
+
const value = fields.value ? nonNegative(entry[fields.value]) : 0;
|
|
3322
|
+
return () => tooltip({
|
|
3323
|
+
title: fields.id ? name : config[key]?.label ?? key,
|
|
3324
|
+
items: [
|
|
3325
|
+
...fields.value ? [{
|
|
3326
|
+
label: fields.value,
|
|
3327
|
+
value: format?.(value) ?? value,
|
|
3328
|
+
color: colorOf(key)
|
|
3329
|
+
}] : [],
|
|
3330
|
+
{
|
|
3331
|
+
label: fields.lat,
|
|
3332
|
+
value: lat
|
|
3333
|
+
},
|
|
3334
|
+
{
|
|
3335
|
+
label: fields.lon,
|
|
3336
|
+
value: lon
|
|
3337
|
+
}
|
|
3338
|
+
]
|
|
3339
|
+
}, { entry });
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3342
|
+
* The place and the config key of every dot, by name - remembered while a
|
|
3343
|
+
* gone dot shrinks away, since its entry has already left the data - and
|
|
3344
|
+
* forgotten with the dot, so the memory stays the size of the chart.
|
|
3345
|
+
*/
|
|
3346
|
+
function usePlaces(names, data, fields, fallback, alive) {
|
|
3347
|
+
const known = React.useRef(/* @__PURE__ */ new Map());
|
|
3348
|
+
data.forEach((d, i) => known.current.set(names[i], {
|
|
3349
|
+
lon: Number(d[fields.lon]),
|
|
3350
|
+
lat: Number(d[fields.lat]),
|
|
3351
|
+
key: fields.series ? String(d[fields.series]) : fallback
|
|
3352
|
+
}));
|
|
3353
|
+
const live = new Set(names);
|
|
3354
|
+
for (const name of known.current.keys()) if (!(name in alive) && !live.has(name)) known.current.delete(name);
|
|
3355
|
+
return (name) => known.current.get(name);
|
|
1866
3356
|
}
|
|
1867
3357
|
// registry/new-york/ui/scatter-chart-3d.tsx
|
|
1868
3358
|
/**
|
|
1869
3359
|
* Points in the chart box, at the fields `x`, `y` and `z` of each entry.
|
|
1870
3360
|
* `series` names the field whose value is the config key of the color.
|
|
1871
3361
|
*/
|
|
1872
|
-
function ScatterChart3D({ data, x, y, z, id, series, radius = 4, format, titles }) {
|
|
3362
|
+
function ScatterChart3D({ data: all, x, y, z, id, series, radius = 4, format, titles, tooltip }) {
|
|
1873
3363
|
const { config, camera } = useChart3D();
|
|
1874
|
-
const fields =
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
z
|
|
1878
|
-
};
|
|
1879
|
-
const names = data.map((d, i) => id ? String(d[id]) : String(i));
|
|
1880
|
-
const { radii, opacity, read, key } = usePointTweens(names, data, fields, series, config, radius);
|
|
1881
|
-
const spec = useTweenAxes(pointAxes(data, fields));
|
|
3364
|
+
const { fields, data, names, entries, values, axes } = usePoints(all, x, y, z, id);
|
|
3365
|
+
const { radii, opacity, read, key } = usePointTweens(names, data, values, series, config, radius);
|
|
3366
|
+
const spec = useTweenAxes(axes);
|
|
1882
3367
|
const world = worldBox(spec);
|
|
1883
3368
|
const placed = usePointPlaces(names, radii, read, world);
|
|
1884
3369
|
const marks = points3D().camera(camera).x((p) => p.x).y((p) => p.y).z((p) => p.z).data(placed).sort(sort).map((p) => ({
|
|
1885
3370
|
key: p.name,
|
|
1886
3371
|
points: [p.projected],
|
|
1887
3372
|
r: radii[p.name],
|
|
1888
|
-
fill: faded(
|
|
3373
|
+
fill: faded(colorOf(key(p.name)), opacity[p.name]),
|
|
1889
3374
|
groups: [p.name],
|
|
1890
3375
|
stroke: faded("var(--background)", opacity[p.name]),
|
|
1891
|
-
tooltip: {
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
"y",
|
|
1897
|
-
"z"
|
|
1898
|
-
].map((a) => ({
|
|
1899
|
-
label: titles?.[a] ?? fields[a],
|
|
1900
|
-
value: read(p.name, a)
|
|
1901
|
-
}))
|
|
1902
|
-
}
|
|
3376
|
+
tooltip: pointTooltip(entries.get(p.name), series ? config[key(p.name)]?.label ?? key(p.name) : void 0, fields, {
|
|
3377
|
+
format,
|
|
3378
|
+
titles,
|
|
3379
|
+
tooltip
|
|
3380
|
+
})
|
|
1903
3381
|
}));
|
|
1904
3382
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1905
3383
|
spec,
|
|
@@ -1908,6 +3386,67 @@ function ScatterChart3D({ data, x, y, z, id, series, radius = 4, format, titles
|
|
|
1908
3386
|
titles
|
|
1909
3387
|
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
1910
3388
|
}
|
|
3389
|
+
/**
|
|
3390
|
+
* What a point says on hover, made when the pointer lands on it: its series,
|
|
3391
|
+
* where `title` names one, and its x, y and z as the ticks of each axis name
|
|
3392
|
+
* them - or what `tooltip` makes of that. A point whose entry has left the
|
|
3393
|
+
* data says nothing.
|
|
3394
|
+
*/
|
|
3395
|
+
function pointTooltip(entry, title, fields, { format = {}, titles = {}, tooltip = (tip) => tip }) {
|
|
3396
|
+
if (!entry) return void 0;
|
|
3397
|
+
return () => tooltip({
|
|
3398
|
+
title,
|
|
3399
|
+
items: [
|
|
3400
|
+
"x",
|
|
3401
|
+
"y",
|
|
3402
|
+
"z"
|
|
3403
|
+
].map((a) => {
|
|
3404
|
+
const value = Number(entry[fields[a]]);
|
|
3405
|
+
return {
|
|
3406
|
+
label: titles[a] ?? fields[a],
|
|
3407
|
+
value: format[a]?.(value) ?? value
|
|
3408
|
+
};
|
|
3409
|
+
})
|
|
3410
|
+
}, { entry });
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* The entries with a place in the box, their names, the entry behind each
|
|
3414
|
+
* name, their positions and the axes that hold them - built once per change
|
|
3415
|
+
* of the data, not at every frame the camera turns.
|
|
3416
|
+
*/
|
|
3417
|
+
function usePoints(all, x, y, z, id) {
|
|
3418
|
+
return React.useMemo(() => {
|
|
3419
|
+
const fields = {
|
|
3420
|
+
x,
|
|
3421
|
+
y,
|
|
3422
|
+
z
|
|
3423
|
+
};
|
|
3424
|
+
const placed = all.flatMap((d, i) => [
|
|
3425
|
+
x,
|
|
3426
|
+
y,
|
|
3427
|
+
z
|
|
3428
|
+
].every((f) => finite(d[f])) ? [{
|
|
3429
|
+
d,
|
|
3430
|
+
name: id ? String(d[id]) : String(i)
|
|
3431
|
+
}] : []);
|
|
3432
|
+
const data = placed.map((p) => p.d);
|
|
3433
|
+
const names = placed.map((p) => p.name);
|
|
3434
|
+
return {
|
|
3435
|
+
fields,
|
|
3436
|
+
data,
|
|
3437
|
+
names,
|
|
3438
|
+
entries: new Map(names.map((name, i) => [name, data[i]])),
|
|
3439
|
+
values: pointValues(names, data, fields),
|
|
3440
|
+
axes: pointAxes(data, fields)
|
|
3441
|
+
};
|
|
3442
|
+
}, [
|
|
3443
|
+
all,
|
|
3444
|
+
x,
|
|
3445
|
+
y,
|
|
3446
|
+
z,
|
|
3447
|
+
id
|
|
3448
|
+
]);
|
|
3449
|
+
}
|
|
1911
3450
|
/** The axes that hold every entry of `data`, a nice one per field. */
|
|
1912
3451
|
function pointAxes(data, fields) {
|
|
1913
3452
|
const range = (a) => {
|
|
@@ -1927,10 +3466,10 @@ function pointAxes(data, fields) {
|
|
|
1927
3466
|
* one shrinks as it fades away. `read` and `key` look them up, also for a
|
|
1928
3467
|
* point still leaving.
|
|
1929
3468
|
*/
|
|
1930
|
-
function usePointTweens(names, data,
|
|
1931
|
-
const values = useTween(
|
|
1932
|
-
const radii = useTween(Object.fromEntries(names.map((name) => [name, radius])), 0);
|
|
1933
|
-
const opacity = useTween(Object.fromEntries(names.map((name) => [name, 1])), 0);
|
|
3469
|
+
function usePointTweens(names, data, target, series, config, radius) {
|
|
3470
|
+
const values = useTween(target);
|
|
3471
|
+
const radii = useTween(React.useMemo(() => Object.fromEntries(names.map((name) => [name, radius])), [names, radius]), 0);
|
|
3472
|
+
const opacity = useTween(React.useMemo(() => Object.fromEntries(names.map((name) => [name, 1])), [names]), 0);
|
|
1934
3473
|
const fallback = Object.keys(config)[0];
|
|
1935
3474
|
return {
|
|
1936
3475
|
radii,
|
|
@@ -1996,44 +3535,57 @@ function usePointKeys(names, data, series, fallback, alive) {
|
|
|
1996
3535
|
* `colors` at the lowest height to the second at the highest; both are keys
|
|
1997
3536
|
* of the chart config, by default its first two.
|
|
1998
3537
|
*/
|
|
1999
|
-
function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
3538
|
+
function SurfaceChart3D({ data: raw, domain = {}, x, z, colors, format, titles, tooltip }) {
|
|
2000
3539
|
const { config, camera } = useChart3D();
|
|
2001
3540
|
const [low, high = low] = colors ?? Object.keys(config);
|
|
2002
|
-
const
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
const
|
|
2008
|
-
const
|
|
2009
|
-
const
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
3541
|
+
const { floor, peak, target, present, ...sides } = useGrid(raw, {
|
|
3542
|
+
domain,
|
|
3543
|
+
x,
|
|
3544
|
+
z
|
|
3545
|
+
});
|
|
3546
|
+
const heights = useTween(target);
|
|
3547
|
+
const fade = useTween(present, 0);
|
|
3548
|
+
const placesX = useTween(sides.x.places);
|
|
3549
|
+
const placesZ = useTween(sides.z.places);
|
|
3550
|
+
const { lattice, points } = React.useMemo(() => {
|
|
3551
|
+
const lattice = gridLattice(heights, {
|
|
3552
|
+
x: placesX,
|
|
3553
|
+
z: placesZ
|
|
3554
|
+
}, floor);
|
|
3555
|
+
return {
|
|
3556
|
+
lattice,
|
|
3557
|
+
points: gridPoints(lattice, {
|
|
3558
|
+
x: placesX,
|
|
3559
|
+
z: placesZ
|
|
3560
|
+
}, {
|
|
3561
|
+
raw,
|
|
3562
|
+
present,
|
|
3563
|
+
keys: [low, high],
|
|
3564
|
+
format,
|
|
3565
|
+
titles,
|
|
3566
|
+
tooltip
|
|
3567
|
+
})
|
|
3568
|
+
};
|
|
3569
|
+
}, [
|
|
3570
|
+
heights,
|
|
3571
|
+
placesX,
|
|
3572
|
+
placesZ,
|
|
3573
|
+
floor,
|
|
3574
|
+
raw,
|
|
3575
|
+
present,
|
|
3576
|
+
low,
|
|
3577
|
+
high,
|
|
3578
|
+
format,
|
|
3579
|
+
titles,
|
|
3580
|
+
tooltip
|
|
3581
|
+
]);
|
|
2015
3582
|
const spec = useTweenAxes({
|
|
2016
|
-
x:
|
|
3583
|
+
x: sides.x.spec,
|
|
2017
3584
|
y: axis(floor, peak),
|
|
2018
|
-
z:
|
|
3585
|
+
z: sides.z.spec
|
|
2019
3586
|
});
|
|
2020
3587
|
const world = worldBox(spec);
|
|
2021
|
-
const
|
|
2022
|
-
const toZ = linear([0, rows - 1], [z0, z1]);
|
|
2023
|
-
const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1, heights[0]?.length ?? 0));
|
|
2024
|
-
const points = heights.flatMap((row, r) => row.map((y, c) => ({
|
|
2025
|
-
x: toX(c),
|
|
2026
|
-
y,
|
|
2027
|
-
z: toZ(r),
|
|
2028
|
-
r,
|
|
2029
|
-
c
|
|
2030
|
-
})));
|
|
2031
|
-
const marks = grid.data(points).sort(sort).map((quad) => quadMark(quad, fade, {
|
|
2032
|
-
lo,
|
|
2033
|
-
hi,
|
|
2034
|
-
low,
|
|
2035
|
-
high
|
|
2036
|
-
}, titles?.y));
|
|
3588
|
+
const marks = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1, lattice[0]?.length ?? 0)).data(points).sort(sort).map((quad) => quadMark(quad, fade));
|
|
2037
3589
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
2038
3590
|
spec,
|
|
2039
3591
|
world,
|
|
@@ -2042,47 +3594,162 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
2042
3594
|
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
2043
3595
|
}
|
|
2044
3596
|
/**
|
|
2045
|
-
* The
|
|
2046
|
-
*
|
|
3597
|
+
* The points of the lattice at their places, each cell on the point it
|
|
3598
|
+
* starts at - its lowest row and column - with its color on the ramp at its
|
|
3599
|
+
* height, the mean of its corners, and its tooltip.
|
|
3600
|
+
*/
|
|
3601
|
+
function gridPoints(lattice, places, { raw, present, keys, ...say }) {
|
|
3602
|
+
const [lo = 0, hi = 0] = extent(lattice.flat(), (p) => p.y);
|
|
3603
|
+
return lattice.flatMap((row, i) => row.map(({ r, c, y }, j) => {
|
|
3604
|
+
const point = {
|
|
3605
|
+
x: places.x[c],
|
|
3606
|
+
y,
|
|
3607
|
+
z: places.z[r],
|
|
3608
|
+
r,
|
|
3609
|
+
c,
|
|
3610
|
+
key: `${r}-${c}`
|
|
3611
|
+
};
|
|
3612
|
+
const corners = [
|
|
3613
|
+
row[j + 1],
|
|
3614
|
+
lattice[i + 1]?.[j],
|
|
3615
|
+
lattice[i + 1]?.[j + 1]
|
|
3616
|
+
];
|
|
3617
|
+
if (corners.every(Boolean)) {
|
|
3618
|
+
const quad = [{
|
|
3619
|
+
r,
|
|
3620
|
+
c,
|
|
3621
|
+
y
|
|
3622
|
+
}, ...corners];
|
|
3623
|
+
const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
|
|
3624
|
+
point.cell = {
|
|
3625
|
+
key: point.key,
|
|
3626
|
+
color: ramp(height, [lo, hi], keys),
|
|
3627
|
+
tooltip: cellTooltip(quad, raw, present, say)
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
return point;
|
|
3631
|
+
}));
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* What a cell says on hover, made when the pointer lands on it: its height,
|
|
3635
|
+
* the mean of its four corners in the data, as the ticks of y name it - or
|
|
3636
|
+
* what `tooltip` makes of that. A cell on its way out of the data - its
|
|
3637
|
+
* height or its place - says nothing, and so does one with a height that is no number, where the
|
|
3638
|
+
* stand-in at the floor would make a value up.
|
|
3639
|
+
*/
|
|
3640
|
+
function cellTooltip(quad, data, present, { format = {}, titles = {}, tooltip = (tip) => tip }) {
|
|
3641
|
+
if (quad.some((p) => !(`${p.r}-${p.c}` in present) || !finite(data[p.r][p.c]))) return void 0;
|
|
3642
|
+
return () => {
|
|
3643
|
+
const value = quad.reduce((sum, p) => sum + data[p.r][p.c], 0) / quad.length;
|
|
3644
|
+
const row = Math.min(...quad.map((p) => p.r));
|
|
3645
|
+
const column = Math.min(...quad.map((p) => p.c));
|
|
3646
|
+
return tooltip({ items: [{
|
|
3647
|
+
label: titles.y ?? "value",
|
|
3648
|
+
value: format.y?.(value) ?? value
|
|
3649
|
+
}] }, {
|
|
3650
|
+
row,
|
|
3651
|
+
column,
|
|
3652
|
+
value
|
|
3653
|
+
});
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* The mark of one cell: its quad, shaded in the color of its cell and faded
|
|
3658
|
+
* in place by `fade` while the cell enters or leaves. It is named by the row
|
|
3659
|
+
* and column it starts at, as d3-3d numbers its quads anew as the grid grows.
|
|
2047
3660
|
*/
|
|
2048
|
-
function quadMark(quad, fade
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
const
|
|
2052
|
-
|
|
3661
|
+
function quadMark(quad, fade) {
|
|
3662
|
+
let start = quad[0];
|
|
3663
|
+
let seen = 1;
|
|
3664
|
+
for (const p of quad) {
|
|
3665
|
+
if (p.r <= start.r && p.c <= start.c) start = p;
|
|
3666
|
+
seen = Math.min(seen, fade[p.key] ?? 0);
|
|
3667
|
+
}
|
|
3668
|
+
const { key, color, tooltip } = start.cell;
|
|
3669
|
+
const shaded = shade(quad, color);
|
|
2053
3670
|
return {
|
|
2054
|
-
key
|
|
3671
|
+
key,
|
|
2055
3672
|
points: quad.map((p) => p.projected),
|
|
2056
3673
|
fill: seen < 1 ? `color-mix(in srgb, ${shaded} ${Math.round(seen * 100)}%, transparent)` : shaded,
|
|
2057
|
-
groups: [
|
|
2058
|
-
tooltip
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
3674
|
+
groups: [key],
|
|
3675
|
+
tooltip
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* The grid with every height a number, its lowest and highest height, the
|
|
3680
|
+
* height of every cell and its presence, and the places of its columns and
|
|
3681
|
+
* rows - built once per change of the data, not at every frame the camera
|
|
3682
|
+
* turns. A column or row without a place drops out of all of them.
|
|
3683
|
+
*/
|
|
3684
|
+
function useGrid(raw, { domain, x, z }) {
|
|
3685
|
+
return React.useMemo(() => {
|
|
3686
|
+
const sides = {
|
|
3687
|
+
x: gridAxis(Math.max(0, ...raw.map((row) => row.length)), {
|
|
3688
|
+
at: x,
|
|
3689
|
+
ends: domain.x,
|
|
3690
|
+
padded: !(x || domain.x)
|
|
3691
|
+
}),
|
|
3692
|
+
z: gridAxis(raw.length, {
|
|
3693
|
+
at: z,
|
|
3694
|
+
ends: domain.z,
|
|
3695
|
+
padded: !(z || domain.z)
|
|
3696
|
+
})
|
|
3697
|
+
};
|
|
3698
|
+
const grid = cleanGrid(raw.map((row, r) => r in sides.z.places ? row.map((y, c) => c in sides.x.places ? y : NaN) : []));
|
|
3699
|
+
const target = gridValues(grid.data, sides);
|
|
3700
|
+
return {
|
|
3701
|
+
...grid,
|
|
3702
|
+
...sides,
|
|
3703
|
+
target,
|
|
3704
|
+
present: Object.fromEntries(Object.keys(target).map((k) => [k, 1]))
|
|
3705
|
+
};
|
|
3706
|
+
}, [
|
|
3707
|
+
raw,
|
|
3708
|
+
x,
|
|
3709
|
+
z,
|
|
3710
|
+
domain.x,
|
|
3711
|
+
domain.z
|
|
3712
|
+
]);
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* The grid with every height a number: one that is not - NaN, Infinity -
|
|
3716
|
+
* stands at the lowest of the others, so one cell cannot blank the surface.
|
|
3717
|
+
* With the lowest and the highest height, 0 for a grid without one.
|
|
3718
|
+
*/
|
|
3719
|
+
function cleanGrid(raw) {
|
|
3720
|
+
const [floor = 0, peak = 0] = extent(raw.flat().filter(finite));
|
|
3721
|
+
return {
|
|
3722
|
+
data: raw.map((row) => row.map((y) => finite(y) ? y : floor)),
|
|
3723
|
+
floor,
|
|
3724
|
+
peak
|
|
2065
3725
|
};
|
|
2066
3726
|
}
|
|
2067
|
-
/** The height of every cell, keyed by its row and column, gliding into new data. */
|
|
2068
|
-
function gridValues(data) {
|
|
2069
|
-
return Object.fromEntries(data.flatMap((row, r) => row.
|
|
3727
|
+
/** The height of every cell with a place, keyed by its row and column, gliding into new data. */
|
|
3728
|
+
function gridValues(data, sides) {
|
|
3729
|
+
return Object.fromEntries(data.flatMap((row, r) => row.flatMap((y, c) => c in sides.x.places && r in sides.z.places ? [[`${r}-${c}`, y]] : [])));
|
|
2070
3730
|
}
|
|
2071
3731
|
/**
|
|
2072
|
-
* The gliding heights as a lattice
|
|
2073
|
-
*
|
|
2074
|
-
* kept stands at the floor until it
|
|
3732
|
+
* The gliding heights as a lattice over the rows and columns they name. It
|
|
3733
|
+
* spans the cells still leaving too, so a shrunken grid sinks away instead
|
|
3734
|
+
* of popping; a corner only one dimension kept stands at the floor until it
|
|
3735
|
+
* goes. A column or row without a place is no line of it: new heights
|
|
3736
|
+
* glide a leaving one on while its place, and its fade, are gone.
|
|
2075
3737
|
*/
|
|
2076
|
-
function
|
|
2077
|
-
|
|
2078
|
-
|
|
3738
|
+
function gridLattice(tweened, places, floor) {
|
|
3739
|
+
const rows = /* @__PURE__ */ new Set();
|
|
3740
|
+
const columns = /* @__PURE__ */ new Set();
|
|
2079
3741
|
for (const key of Object.keys(tweened)) {
|
|
2080
3742
|
const [r, c] = key.split("-").map(Number);
|
|
2081
|
-
|
|
2082
|
-
|
|
3743
|
+
if (r in places.z) rows.add(r);
|
|
3744
|
+
if (c in places.x) columns.add(c);
|
|
2083
3745
|
}
|
|
2084
|
-
|
|
3746
|
+
const ascending = (set) => [...set].sort((a, b) => a - b);
|
|
3747
|
+
return ascending(rows).map((r) => ascending(columns).map((c) => ({
|
|
3748
|
+
r,
|
|
3749
|
+
c,
|
|
3750
|
+
y: tweened[`${r}-${c}`] ?? floor
|
|
3751
|
+
})));
|
|
2085
3752
|
}
|
|
2086
|
-
export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis,
|
|
3753
|
+
export { AreaChart3D, Axes3D, BarChart3D, BarFieldChart3D, ChartContainer3D, ChartLegend3D, ChoroplethChart3D, GLOBE_RADIUS, GlobeChart3D, Labels3D, Marks3D, PlacesChart3D, ScatterChart3D, SurfaceChart3D, axis, barCorners, barMarks, colorOf, cubeMarks, earth, faded, finite, floored, globe, gridAxis, intoWorld, linear, nonNegative, orthographic, pad, ramp, shade, useChart3D, useSeries, useTween, useTweenAxes, worldBox };
|
|
2087
3754
|
|
|
2088
3755
|
//# sourceMappingURL=index.js.map
|