@niekes/charts-3d 0.4.0 → 0.6.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/README.md +1 -1
- package/dist/index.d.ts +103 -49
- package/dist/index.js +1019 -743
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
3
|
+
import { extent, nice, range, ticks } from "d3-array";
|
|
4
|
+
import { axes3D, box, camera3D, cubes3D, gridPlanes3D, normal, points3D, polygons3D, sort } from "d3-3d";
|
|
5
5
|
import { KeyboardIcon } from "lucide-react";
|
|
6
6
|
import { clsx } from "clsx";
|
|
7
7
|
import { twMerge } from "tailwind-merge";
|
|
@@ -26,10 +26,11 @@ const KEY_STEP = Math.PI / 36;
|
|
|
26
26
|
const PAN_STEP = 20;
|
|
27
27
|
const ZOOM_STEP = 1.2;
|
|
28
28
|
const CLICK_DISTANCE = 4;
|
|
29
|
+
const ZOOM_CURSOR = 400;
|
|
29
30
|
const SHORTCUTS = {
|
|
30
31
|
dragRotate: {
|
|
31
32
|
label: "Rotate",
|
|
32
|
-
hint: ["Drag"]
|
|
33
|
+
hint: ["Mod", "Drag"]
|
|
33
34
|
},
|
|
34
35
|
rotateLeft: {
|
|
35
36
|
label: "Rotate",
|
|
@@ -53,7 +54,7 @@ const SHORTCUTS = {
|
|
|
53
54
|
},
|
|
54
55
|
dragPan: {
|
|
55
56
|
label: "Pan",
|
|
56
|
-
hint: ["
|
|
57
|
+
hint: ["Alt", "Drag"]
|
|
57
58
|
},
|
|
58
59
|
panLeft: {
|
|
59
60
|
label: "Pan",
|
|
@@ -99,7 +100,7 @@ const SHORTCUTS = {
|
|
|
99
100
|
},
|
|
100
101
|
wheelZoom: {
|
|
101
102
|
label: "Zoom",
|
|
102
|
-
hint: ["
|
|
103
|
+
hint: ["Mod", "Scroll"]
|
|
103
104
|
},
|
|
104
105
|
reset: {
|
|
105
106
|
label: "Reset",
|
|
@@ -121,6 +122,16 @@ const MODIFIERS = [
|
|
|
121
122
|
"Meta",
|
|
122
123
|
"Shift"
|
|
123
124
|
];
|
|
125
|
+
/** Whether the reader is on a Mac, where the platform modifier is ⌘; elsewhere it is Ctrl. */
|
|
126
|
+
const mac = () => /mac/i.test(globalThis.navigator?.userAgentData?.platform ?? globalThis.navigator?.platform ?? "");
|
|
127
|
+
/** The label of the platform modifier: ⌘ on a Mac, Ctrl elsewhere. */
|
|
128
|
+
const modLabel = () => mac() ? "⌘" : "Ctrl";
|
|
129
|
+
/** The label of the Alt key: ⌥ on a Mac, Alt elsewhere. */
|
|
130
|
+
const altLabel = () => mac() ? "⌥" : "Alt";
|
|
131
|
+
/** Whether the platform modifier - ⌘ on a Mac, Ctrl elsewhere - is held in `e`. */
|
|
132
|
+
const platformMod = (e) => mac() ? e.metaKey : e.ctrlKey;
|
|
133
|
+
/** Whether `value` lies between the ends `lo` and `hi`. */
|
|
134
|
+
const within = (lo, hi, value) => value >= lo && value <= hi;
|
|
124
135
|
const NAMED = {
|
|
125
136
|
Plus: "+",
|
|
126
137
|
Space: " "
|
|
@@ -184,20 +195,28 @@ function shortcutsOf(keymap) {
|
|
|
184
195
|
}
|
|
185
196
|
/**
|
|
186
197
|
* The rows of the shortcut panel: the actions by label, the keys of a row in
|
|
187
|
-
* groups of the same modifiers,
|
|
198
|
+
* groups of the same modifiers, left of its gestures, each a group of its own.
|
|
188
199
|
*/
|
|
189
200
|
function rowsOf(shortcuts) {
|
|
190
201
|
const rows = /* @__PURE__ */ new Map();
|
|
191
|
-
for (const {
|
|
192
|
-
const parts = hint ?? split(keys[0]).map((k) => KBD[k] ?? k);
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
for (const { label, keys, hint } of shortcuts) {
|
|
203
|
+
const parts = (hint ?? split(keys[0])).map((k) => k === "Mod" ? modLabel() : k === "Alt" ? altLabel() : KBD[k] ?? k);
|
|
204
|
+
const row = rows.get(label) ?? {
|
|
205
|
+
keys: /* @__PURE__ */ new Map(),
|
|
206
|
+
gestures: []
|
|
207
|
+
};
|
|
208
|
+
if (hint) row.gestures.push(parts);
|
|
209
|
+
else {
|
|
210
|
+
const group = parts.slice(0, -1).join("+");
|
|
211
|
+
const known = row.keys.get(group);
|
|
212
|
+
row.keys.set(group, known ? [...known, parts.at(-1)] : parts);
|
|
213
|
+
}
|
|
214
|
+
rows.set(label, row);
|
|
197
215
|
}
|
|
198
|
-
return [...rows].map(([label,
|
|
216
|
+
return [...rows].map(([label, row]) => ({
|
|
199
217
|
label,
|
|
200
|
-
|
|
218
|
+
keys: [...row.keys.values()],
|
|
219
|
+
gestures: row.gestures
|
|
201
220
|
}));
|
|
202
221
|
}
|
|
203
222
|
/**
|
|
@@ -220,10 +239,9 @@ function keymapOf(keymap, panel) {
|
|
|
220
239
|
function ChartContainer3D({ id, config, defaultView = {
|
|
221
240
|
yaw: Math.PI / 6,
|
|
222
241
|
tilt: Math.PI / 6
|
|
223
|
-
}, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", className, children, "aria-label": ariaLabel, ...props }) {
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
const { scene, renderer, canvas, repaint, hover, hide, leave, tooltip, focus } = useScene(requested, width, height);
|
|
242
|
+
}, 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 }) {
|
|
243
|
+
const chartId = chartIdOf(id, React.useId());
|
|
244
|
+
const { scene, renderer, canvas, repaint, hover, hide, leave, tip, focus } = useScene(requested, width, height, tooltip);
|
|
227
245
|
const keys = keymapOf(keymap, shortcuts);
|
|
228
246
|
const { view, handlers } = useOrbit(defaultView, width, keys, hide, hover);
|
|
229
247
|
const camera = useCamera(view, width, height);
|
|
@@ -277,11 +295,7 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
277
295
|
camera,
|
|
278
296
|
corner: gizmo
|
|
279
297
|
}),
|
|
280
|
-
/* @__PURE__ */ jsx(ChartTooltip3D, {
|
|
281
|
-
tooltip,
|
|
282
|
-
width,
|
|
283
|
-
height
|
|
284
|
-
})
|
|
298
|
+
/* @__PURE__ */ jsx(ChartTooltip3D, { tip })
|
|
285
299
|
]
|
|
286
300
|
}),
|
|
287
301
|
/* @__PURE__ */ jsx(ChartShortcuts3D, { ...keys }),
|
|
@@ -289,6 +303,10 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
289
303
|
]
|
|
290
304
|
});
|
|
291
305
|
}
|
|
306
|
+
/** The id the colors of a chart are scoped to: its own `id`, or the one React made up. */
|
|
307
|
+
function chartIdOf(id, uniqueId) {
|
|
308
|
+
return `chart-${id || uniqueId.replace(/:/g, "")}`;
|
|
309
|
+
}
|
|
292
310
|
/**
|
|
293
311
|
* What the svg says about its keymap: a label that names the drag and the
|
|
294
312
|
* help key if they are on, the keys, the focus only if a key does something
|
|
@@ -299,27 +317,27 @@ function keymapProps(keys, label) {
|
|
|
299
317
|
const help = keys.help[0];
|
|
300
318
|
const said = [
|
|
301
319
|
"3D chart.",
|
|
302
|
-
keys.on("dragRotate") &&
|
|
320
|
+
keys.on("dragRotate") && `${modLabel()} and drag to rotate it.`,
|
|
303
321
|
help && `Press ${aria(help)} for all shortcuts.`
|
|
304
322
|
];
|
|
305
323
|
return {
|
|
306
324
|
"aria-label": label ?? said.filter(Boolean).join(" "),
|
|
307
325
|
"aria-keyshortcuts": keys.aria || void 0,
|
|
308
326
|
tabIndex: keys.aria ? 0 : void 0,
|
|
309
|
-
className: cn("block h-auto w-full outline-hidden select-none mask-y-from-96%", drag && "
|
|
327
|
+
className: cn("block h-auto w-full outline-hidden select-none mask-x-from-96% mask-y-from-96%", drag && "touch-none")
|
|
310
328
|
};
|
|
311
329
|
}
|
|
312
330
|
/**
|
|
313
331
|
* The marks of the chart and what reads them: the renderer that draws them,
|
|
314
332
|
* the canvas it paints them on and the hover that shows their tooltips.
|
|
315
333
|
*/
|
|
316
|
-
function useScene(requested, width, height) {
|
|
334
|
+
function useScene(requested, width, height, placement) {
|
|
317
335
|
const scene = React.useRef([]);
|
|
318
|
-
const
|
|
336
|
+
const tip = useTip(placement);
|
|
319
337
|
const focus = useFocus();
|
|
320
338
|
const renderer = useRenderer(requested);
|
|
321
|
-
const { canvas, repaint } = useCanvas(scene, width);
|
|
322
|
-
const { hover, hide, leave } = useHover(scene, width, height,
|
|
339
|
+
const { canvas, repaint } = useCanvas(scene, width, height, renderer);
|
|
340
|
+
const { hover, hide, leave } = useHover(scene, width, height, tip, focus);
|
|
323
341
|
return {
|
|
324
342
|
scene,
|
|
325
343
|
renderer,
|
|
@@ -328,10 +346,65 @@ function useScene(requested, width, height) {
|
|
|
328
346
|
hover,
|
|
329
347
|
hide,
|
|
330
348
|
leave,
|
|
331
|
-
|
|
349
|
+
tip,
|
|
332
350
|
focus
|
|
333
351
|
};
|
|
334
352
|
}
|
|
353
|
+
const TIP_GAP = 12;
|
|
354
|
+
const shift = (isBefore) => isBefore ? `calc(-100% - ${TIP_GAP}px)` : `${TIP_GAP}px`;
|
|
355
|
+
/**
|
|
356
|
+
* Whether a tooltip of `size` goes before the pointer at `at`, on an axis of
|
|
357
|
+
* `length`: as `preferred`, unless it would leave the chart there and the
|
|
358
|
+
* other side has more room.
|
|
359
|
+
*/
|
|
360
|
+
function before(at, size, length, preferred) {
|
|
361
|
+
const room = preferred ? at : length - at;
|
|
362
|
+
return room < size + TIP_GAP && length - room > room ? !preferred : preferred;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The tooltip of the hovered mark and where it sits: beside the pointer, in
|
|
366
|
+
* the pixels of the chart, at `placement`. `follow` moves it with the pointer
|
|
367
|
+
* straight in the DOM, so a move within a mark renders nothing; `place` puts
|
|
368
|
+
* it there again once it shows, or its content changes its size.
|
|
369
|
+
*/
|
|
370
|
+
function useTip(placement) {
|
|
371
|
+
const [tooltip, set] = React.useState(null);
|
|
372
|
+
const ref = React.useRef(null);
|
|
373
|
+
const pointer = React.useRef({
|
|
374
|
+
x: 0,
|
|
375
|
+
y: 0,
|
|
376
|
+
width: 0,
|
|
377
|
+
height: 0
|
|
378
|
+
});
|
|
379
|
+
const place = React.useCallback(() => {
|
|
380
|
+
const el = ref.current;
|
|
381
|
+
if (!el) return;
|
|
382
|
+
const shows = !el.dataset.placement;
|
|
383
|
+
if (shows) el.style.transition = "none";
|
|
384
|
+
const { x, y, width, height } = pointer.current;
|
|
385
|
+
const top = before(y, el.offsetHeight, height, placement.startsWith("top"));
|
|
386
|
+
const left = before(x, el.offsetWidth, width, placement.endsWith("left"));
|
|
387
|
+
el.style.left = `${x}px`;
|
|
388
|
+
el.style.top = `${y}px`;
|
|
389
|
+
el.style.translate = `${shift(left)} ${shift(top)}`;
|
|
390
|
+
el.dataset.placement = `${top ? "top" : "bottom"}-${left ? "left" : "right"}`;
|
|
391
|
+
if (shows) {
|
|
392
|
+
el.getBoundingClientRect();
|
|
393
|
+
el.style.transition = "";
|
|
394
|
+
}
|
|
395
|
+
}, [placement]);
|
|
396
|
+
const follow = (at) => {
|
|
397
|
+
pointer.current = at;
|
|
398
|
+
place();
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
tooltip,
|
|
402
|
+
set,
|
|
403
|
+
ref,
|
|
404
|
+
place,
|
|
405
|
+
follow
|
|
406
|
+
};
|
|
407
|
+
}
|
|
335
408
|
/** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
|
|
336
409
|
function useCamera(view, width, height) {
|
|
337
410
|
return React.useMemo(() => camera3D().origin({
|
|
@@ -387,8 +460,10 @@ function animates() {
|
|
|
387
460
|
/**
|
|
388
461
|
* Eases a chart into new data: the numbers of `values` glide from the ones
|
|
389
462
|
* on screen to these over the animationDuration of the container. A key
|
|
390
|
-
* that is new starts at `enter`, or where it ends; a key that is gone
|
|
391
|
-
*
|
|
463
|
+
* that is new starts at `enter`, or where it ends; a key that is gone glides
|
|
464
|
+
* back to `enter`, the mirror of its entering - without one it stands where
|
|
465
|
+
* it is - and leaves when the glide is done. Without time, or with reduced
|
|
466
|
+
* motion, the numbers jump.
|
|
392
467
|
*/
|
|
393
468
|
function useTween(values, enter) {
|
|
394
469
|
const { animationDuration: duration = 600 } = useChart3D();
|
|
@@ -424,32 +499,65 @@ function useTween(values, enter) {
|
|
|
424
499
|
}, [tween.key, duration]);
|
|
425
500
|
return shown;
|
|
426
501
|
}
|
|
427
|
-
/** The numbers of `tween` on screen: `from` eased into `to`, a new key from `enter` or where it ends. */
|
|
502
|
+
/** The numbers of `tween` on screen: `from` eased into `to`, a new key from `enter` or where it ends, a gone one gliding back to `enter` until the glide is done. */
|
|
428
503
|
function tweenNumbers({ from, to, t }, enter) {
|
|
429
504
|
const s = t * t * (3 - 2 * t);
|
|
430
|
-
|
|
505
|
+
const gone = t >= 1 ? [] : Object.entries(from).filter(([k]) => !(k in to));
|
|
506
|
+
return Object.fromEntries([...Object.entries(to).map(([k, end]) => {
|
|
431
507
|
const start = from[k] ?? enter ?? end;
|
|
432
508
|
return [k, t >= 1 ? end : start + (end - start) * s];
|
|
433
|
-
}));
|
|
509
|
+
}), ...gone.map(([k, start]) => [k, start + ((enter ?? start) - start) * s])]);
|
|
434
510
|
}
|
|
435
511
|
/**
|
|
436
512
|
* `spec` gliding into new data with the marks: the ends of every axis glide
|
|
437
513
|
* as `useTween` glides the numbers, where ends rounded from the numbers on
|
|
438
514
|
* screen would jump from one round number to the next, and the box with
|
|
439
|
-
* them. The ticks
|
|
515
|
+
* them. The ticks stay those of `spec`; labels of ticks outside the gliding
|
|
516
|
+
* ends fade out in `edgeLabels` instead of popping out of the range. The
|
|
517
|
+
* outermost ticks glide too, so the line of the axis grows to a new tick and
|
|
518
|
+
* shrinks from a dropped one instead of jumping when the glide is done.
|
|
440
519
|
*/
|
|
441
520
|
function useTweenAxes(spec) {
|
|
442
|
-
const
|
|
521
|
+
const axes = [
|
|
443
522
|
"x",
|
|
444
523
|
"y",
|
|
445
524
|
"z"
|
|
446
|
-
]
|
|
525
|
+
];
|
|
526
|
+
const last = (domain) => domain[domain.length - 1];
|
|
527
|
+
const target = Object.fromEntries(axes.flatMap((a) => {
|
|
528
|
+
const { domain, ticks } = spec[a];
|
|
529
|
+
return [
|
|
530
|
+
[`${a}0`, domain[0]],
|
|
531
|
+
[`${a}1`, last(domain)],
|
|
532
|
+
[`${a}t0`, ticks.at(0) ?? domain[0]],
|
|
533
|
+
[`${a}t1`, ticks.at(-1) ?? last(domain)]
|
|
534
|
+
];
|
|
535
|
+
}));
|
|
536
|
+
const ends = useTween(target);
|
|
537
|
+
const gliding = Object.keys(target).some((k) => ends[k] !== target[k]);
|
|
538
|
+
const key = JSON.stringify(spec);
|
|
539
|
+
const memory = React.useRef({
|
|
540
|
+
key,
|
|
541
|
+
target: spec,
|
|
542
|
+
from: spec
|
|
543
|
+
});
|
|
544
|
+
if (memory.current.key !== key) memory.current = {
|
|
545
|
+
key,
|
|
546
|
+
target: spec,
|
|
547
|
+
from: memory.current.target
|
|
548
|
+
};
|
|
447
549
|
const glided = (a) => {
|
|
448
550
|
const [lo, hi] = [ends[`${a}0`], ends[`${a}1`]];
|
|
449
|
-
const
|
|
551
|
+
const [d0, d1] = [spec[a].domain[0], last(spec[a].domain)];
|
|
552
|
+
const gone = gliding ? memory.current.from[a].ticks.filter((t) => !spec[a].ticks.includes(t)) : [];
|
|
553
|
+
const ticks = gone.length ? [...spec[a].ticks, ...gone].sort((t, u) => t - u) : spec[a].ticks;
|
|
554
|
+
const leaving = gone.filter((t) => within(Math.min(d0, d1), Math.max(d0, d1), t));
|
|
555
|
+
const span = [ends[`${a}t0`], ends[`${a}t1`]];
|
|
450
556
|
return {
|
|
451
557
|
domain: [lo, hi],
|
|
452
|
-
ticks
|
|
558
|
+
ticks,
|
|
559
|
+
span,
|
|
560
|
+
...leaving.length ? { leaving } : {}
|
|
453
561
|
};
|
|
454
562
|
};
|
|
455
563
|
return {
|
|
@@ -531,19 +639,20 @@ function between(from, to, k) {
|
|
|
531
639
|
}
|
|
532
640
|
/**
|
|
533
641
|
* The view of the camera, and the handlers that move it, as far as `keys`
|
|
534
|
-
* has them on: a drag turns it, or pans it while
|
|
535
|
-
*
|
|
642
|
+
* has them on: a drag turns it, or pans it while Alt is held, the keys step
|
|
643
|
+
* it, Ctrl or ⌘ with the wheel zooms it and a double click resets it.
|
|
536
644
|
* `data-orbiting` among them is true while the camera turns. `onDragStart`
|
|
537
645
|
* runs when a press becomes a drag, `onHover` on a move without one.
|
|
538
646
|
*/
|
|
539
647
|
function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
540
648
|
const { view, jump, glide } = useGlide(defaultView);
|
|
541
649
|
const [orbiting, setOrbiting] = React.useState(false);
|
|
650
|
+
const held = useModsHeld();
|
|
542
651
|
const drag = React.useRef(null);
|
|
543
652
|
const ref = useWheelZoom(jump, keys.on("wheelZoom"));
|
|
544
653
|
const move = (step) => jump((v) => moved(v, step, defaultView));
|
|
545
654
|
const step = (step) => glide((v) => moved(v, step, defaultView));
|
|
546
|
-
const
|
|
655
|
+
const keyHandlers = useKeys(keys, step, setOrbiting);
|
|
547
656
|
const release = () => {
|
|
548
657
|
drag.current = null;
|
|
549
658
|
setOrbiting(false);
|
|
@@ -553,15 +662,18 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
553
662
|
handlers: {
|
|
554
663
|
ref,
|
|
555
664
|
onPointerDown: (e) => {
|
|
556
|
-
|
|
665
|
+
const pans = keys.on("dragPan") && e.altKey;
|
|
666
|
+
if (pans || keys.on("dragRotate") && platformMod(e)) drag.current = {
|
|
557
667
|
x: e.clientX,
|
|
558
668
|
y: e.clientY,
|
|
559
|
-
moved: false
|
|
669
|
+
moved: false,
|
|
670
|
+
rotate: !pans
|
|
560
671
|
};
|
|
561
672
|
},
|
|
562
673
|
onPointerMove: (e) => {
|
|
563
674
|
const d = drag.current;
|
|
564
675
|
if (!d) return onHover(e);
|
|
676
|
+
if (d.rotate ? !platformMod(e) : !e.altKey) return release();
|
|
565
677
|
const dx = e.clientX - d.x;
|
|
566
678
|
const dy = e.clientY - d.y;
|
|
567
679
|
if (!d.moved && Math.hypot(dx, dy) < CLICK_DISTANCE) return;
|
|
@@ -572,100 +684,145 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
572
684
|
drag.current = {
|
|
573
685
|
x: e.clientX,
|
|
574
686
|
y: e.clientY,
|
|
575
|
-
moved: true
|
|
687
|
+
moved: true,
|
|
688
|
+
rotate: d.rotate
|
|
576
689
|
};
|
|
577
690
|
const k = width / e.currentTarget.getBoundingClientRect().width;
|
|
578
|
-
setOrbiting(
|
|
579
|
-
move(
|
|
580
|
-
x: dx * k,
|
|
581
|
-
y: dy * k
|
|
582
|
-
} } : {
|
|
691
|
+
setOrbiting(d.rotate);
|
|
692
|
+
move(d.rotate ? {
|
|
583
693
|
yaw: dx * DRAG,
|
|
584
694
|
tilt: dy * DRAG
|
|
585
|
-
}
|
|
695
|
+
} : { pan: {
|
|
696
|
+
x: dx * k,
|
|
697
|
+
y: dy * k
|
|
698
|
+
} });
|
|
586
699
|
},
|
|
587
700
|
onPointerUp: release,
|
|
588
701
|
onPointerCancel: release,
|
|
589
702
|
onDoubleClick: keys.on("doubleClickReset") ? () => step({ reset: true }) : void 0,
|
|
590
703
|
...keyHandlers,
|
|
591
|
-
"data-orbiting": orbiting
|
|
704
|
+
"data-orbiting": orbiting,
|
|
705
|
+
"data-mod": keys.on("dragRotate") && held.mod ? true : void 0,
|
|
706
|
+
"data-alt": keys.on("dragPan") && held.alt ? true : void 0
|
|
592
707
|
}
|
|
593
708
|
};
|
|
594
709
|
}
|
|
595
710
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
711
|
+
* Whether the platform modifier or Alt is held, so the cursor shows a hand
|
|
712
|
+
* before the drag rotates and the four arrows before it pans. A missed keyup
|
|
713
|
+
* (window blur while held) must not stick either cursor.
|
|
714
|
+
*/
|
|
715
|
+
function useModsHeld() {
|
|
716
|
+
const [mod, setMod] = React.useState(false);
|
|
717
|
+
const [alt, setAlt] = React.useState(false);
|
|
718
|
+
React.useEffect(() => {
|
|
719
|
+
const modKey = mac() ? "Meta" : "Control";
|
|
720
|
+
const onKey = (e) => {
|
|
721
|
+
if (e.key === modKey) setMod(e.type === "keydown");
|
|
722
|
+
if (e.key === "Alt") setAlt(e.type === "keydown");
|
|
723
|
+
};
|
|
724
|
+
const onBlur = () => {
|
|
725
|
+
setMod(false);
|
|
726
|
+
setAlt(false);
|
|
727
|
+
};
|
|
728
|
+
window.addEventListener("keydown", onKey);
|
|
729
|
+
window.addEventListener("keyup", onKey);
|
|
730
|
+
window.addEventListener("blur", onBlur);
|
|
731
|
+
return () => {
|
|
732
|
+
window.removeEventListener("keydown", onKey);
|
|
733
|
+
window.removeEventListener("keyup", onKey);
|
|
734
|
+
window.removeEventListener("blur", onBlur);
|
|
735
|
+
};
|
|
736
|
+
}, []);
|
|
737
|
+
return {
|
|
738
|
+
mod,
|
|
739
|
+
alt
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* The handlers that step the camera with the keys of `keys`. A key that
|
|
744
|
+
* turns the camera marks it as orbiting until it is let go.
|
|
599
745
|
*/
|
|
600
746
|
function useKeys(keys, move, setOrbiting) {
|
|
601
|
-
const space = React.useRef(false);
|
|
602
747
|
const onKeyDown = (e) => {
|
|
603
|
-
const pan = e.key === " " && keys.on("dragPan");
|
|
604
|
-
if (pan) space.current = true;
|
|
605
748
|
const step = keys.moves[pressed(e)];
|
|
606
|
-
if (!step && !pan) return;
|
|
607
|
-
e.preventDefault();
|
|
608
749
|
if (!step) return;
|
|
750
|
+
e.preventDefault();
|
|
609
751
|
setOrbiting(Boolean(step.yaw || step.tilt));
|
|
610
752
|
move(step);
|
|
611
753
|
};
|
|
612
|
-
const onKeyUp = (
|
|
613
|
-
if (e.key === " ") space.current = false;
|
|
614
|
-
setOrbiting(false);
|
|
615
|
-
};
|
|
754
|
+
const onKeyUp = () => setOrbiting(false);
|
|
616
755
|
return {
|
|
617
|
-
space,
|
|
618
756
|
onKeyDown,
|
|
619
757
|
onKeyUp
|
|
620
758
|
};
|
|
621
759
|
}
|
|
622
760
|
/**
|
|
623
|
-
* A ref for the svg that zooms the view with
|
|
624
|
-
*
|
|
761
|
+
* A ref for the svg that zooms the view with the platform modifier and the
|
|
762
|
+
* wheel, if `on`. Ctrl and the wheel zooms on a Mac too, because a trackpad
|
|
763
|
+
* pinch arrives that way, indistinguishable from the keys. The wheel alone
|
|
764
|
+
* still scrolls the page. While the wheel zooms, the svg says so in
|
|
765
|
+
* `data-zoom` - `"in"` or `"out"`, the direction of its cursor - until
|
|
766
|
+
* `ZOOM_CURSOR` after the last tick.
|
|
625
767
|
*/
|
|
626
768
|
function useWheelZoom(jump, on) {
|
|
627
769
|
return React.useCallback((svg) => {
|
|
628
|
-
if (!on) return;
|
|
770
|
+
if (!on || !svg) return;
|
|
771
|
+
let timer;
|
|
629
772
|
const onWheel = (e) => {
|
|
630
|
-
if (!e
|
|
773
|
+
if (!platformMod(e) && !e.ctrlKey) return;
|
|
631
774
|
e.preventDefault();
|
|
632
775
|
jump((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
|
|
776
|
+
svg.dataset.zoom = e.deltaY < 0 ? "in" : "out";
|
|
777
|
+
clearTimeout(timer);
|
|
778
|
+
timer = setTimeout(() => delete svg.dataset.zoom, ZOOM_CURSOR);
|
|
779
|
+
};
|
|
780
|
+
svg.addEventListener("wheel", onWheel, { passive: false });
|
|
781
|
+
return () => {
|
|
782
|
+
clearTimeout(timer);
|
|
783
|
+
svg.removeEventListener("wheel", onWheel);
|
|
633
784
|
};
|
|
634
|
-
svg?.addEventListener("wheel", onWheel, { passive: false });
|
|
635
|
-
return () => svg?.removeEventListener("wheel", onWheel);
|
|
636
785
|
}, [jump, on]);
|
|
637
786
|
}
|
|
638
787
|
/**
|
|
639
788
|
* The tooltip of the mark under the pointer, the one painted last, and the
|
|
640
|
-
* focus on its groups.
|
|
641
|
-
* onto another mark, and the focus only
|
|
642
|
-
* within a mark does not render the chart
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
789
|
+
* focus on its groups. The highlight follows the mark: `hover` sets the
|
|
790
|
+
* tooltip only when the pointer moves onto another mark, and the focus only
|
|
791
|
+
* when the groups change, so a move within a mark does not render the chart
|
|
792
|
+
* again; off the mark and when the pointer leaves the chart, `leave` drops
|
|
793
|
+
* both. While a drag turns the chart under the captured pointer, `hide`
|
|
794
|
+
* drops the tooltip and keeps the focus, so a flow does not flash away and
|
|
795
|
+
* can be followed from every side.
|
|
646
796
|
*/
|
|
647
|
-
function useHover(scene, width, height,
|
|
797
|
+
function useHover(scene, width, height, tip, focus) {
|
|
648
798
|
const hovered = React.useRef(void 0);
|
|
649
799
|
const show = (mark) => {
|
|
650
|
-
|
|
800
|
+
focus.set(mark?.groups);
|
|
651
801
|
if (mark?.key === hovered.current?.key) return;
|
|
652
802
|
hovered.current = mark;
|
|
653
|
-
|
|
803
|
+
tip.set(mark?.tooltip ?? null);
|
|
654
804
|
};
|
|
655
805
|
return {
|
|
656
806
|
hover: (e) => {
|
|
657
807
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
808
|
+
const x = e.clientX - rect.left;
|
|
809
|
+
const y = e.clientY - rect.top;
|
|
810
|
+
tip.follow({
|
|
811
|
+
x,
|
|
812
|
+
y,
|
|
813
|
+
width: rect.width,
|
|
814
|
+
height: rect.height
|
|
815
|
+
});
|
|
816
|
+
show(hit(scene.current, {
|
|
817
|
+
x: x / rect.width * width,
|
|
818
|
+
y: y / rect.height * height
|
|
819
|
+
}));
|
|
663
820
|
},
|
|
664
|
-
hide: () =>
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
821
|
+
hide: () => {
|
|
822
|
+
hovered.current = void 0;
|
|
823
|
+
tip.set(null);
|
|
824
|
+
},
|
|
825
|
+
leave: () => show(void 0)
|
|
669
826
|
};
|
|
670
827
|
}
|
|
671
828
|
/** A store for the groups in focus, which tells its listeners only when the groups change. */
|
|
@@ -688,17 +845,11 @@ function useFocus() {
|
|
|
688
845
|
});
|
|
689
846
|
return focus;
|
|
690
847
|
}
|
|
691
|
-
/**
|
|
692
|
-
|
|
693
|
-
* the focus, while some item of `items` does, so a focus on groups that are
|
|
694
|
-
* gone fades nothing.
|
|
695
|
-
*/
|
|
696
|
-
function useFades(items) {
|
|
848
|
+
/** Whether an item shares a group with the focus: the hover highlights those items with an edge. */
|
|
849
|
+
function useLit(items) {
|
|
697
850
|
const { focus } = useChart3D();
|
|
698
851
|
const groups = React.useSyncExternalStore(focus.subscribe, focus.get, focus.get);
|
|
699
|
-
|
|
700
|
-
const on = items.some((item) => lit(item.groups));
|
|
701
|
-
return (item) => on && item.groups !== void 0 && !lit(item.groups);
|
|
852
|
+
return (item) => item.groups !== void 0 && (groups?.some((group) => item.groups.includes(group)) ?? false);
|
|
702
853
|
}
|
|
703
854
|
/** The topmost mark under `at`: the last one painted that holds it. */
|
|
704
855
|
function hit(marks, at) {
|
|
@@ -719,12 +870,14 @@ function inside(polygon, at) {
|
|
|
719
870
|
}
|
|
720
871
|
/**
|
|
721
872
|
* The renderer the chart draws with: SVG on the server and in the first
|
|
722
|
-
* render on the client, then the requested one if the browser has it
|
|
873
|
+
* render on the client, then the requested one if the browser has it, with
|
|
874
|
+
* "webgl" falling back to "canvas" and "canvas" to SVG.
|
|
723
875
|
*/
|
|
724
876
|
function useRenderer(requested) {
|
|
725
877
|
const [renderer, setRenderer] = React.useState("svg");
|
|
726
878
|
React.useEffect(() => {
|
|
727
|
-
|
|
879
|
+
if (requested === "webgl" && document.createElement("canvas").getContext("webgl")) return setRenderer("webgl");
|
|
880
|
+
const canvas = requested !== "svg" && document.createElement("canvas").getContext("2d");
|
|
728
881
|
setRenderer(canvas ? "canvas" : "svg");
|
|
729
882
|
}, [requested]);
|
|
730
883
|
return renderer;
|
|
@@ -732,15 +885,18 @@ function useRenderer(requested) {
|
|
|
732
885
|
/**
|
|
733
886
|
* A ref for the canvas of the marks, and the repaint that paints them: on new
|
|
734
887
|
* marks, when the canvas changes its size and when the theme changes. A color
|
|
735
|
-
* resolves on the canvas itself, inside the chart, so the `--color-*` apply
|
|
888
|
+
* resolves on the canvas itself, inside the chart, so the `--color-*` apply;
|
|
889
|
+
* webgl takes the resolved color as bytes from a 1×1 canvas.
|
|
736
890
|
*/
|
|
737
|
-
function useCanvas(scene, width) {
|
|
891
|
+
function useCanvas(scene, width, height, renderer) {
|
|
738
892
|
const element = React.useRef(null);
|
|
739
893
|
const colors = React.useRef(/* @__PURE__ */ new Map());
|
|
894
|
+
const bytes = React.useRef(/* @__PURE__ */ new Map());
|
|
895
|
+
const pixel = React.useRef(null);
|
|
740
896
|
const repaint = React.useCallback(() => {
|
|
741
897
|
const canvas = element.current;
|
|
742
898
|
if (!canvas) return;
|
|
743
|
-
|
|
899
|
+
const resolve = (css) => {
|
|
744
900
|
let color = colors.current.get(css);
|
|
745
901
|
if (color === void 0) {
|
|
746
902
|
canvas.style.color = css;
|
|
@@ -748,8 +904,17 @@ function useCanvas(scene, width) {
|
|
|
748
904
|
colors.current.set(css, color);
|
|
749
905
|
}
|
|
750
906
|
return color;
|
|
751
|
-
}
|
|
752
|
-
|
|
907
|
+
};
|
|
908
|
+
if (renderer === "webgl") {
|
|
909
|
+
pixel.current ??= onePixel();
|
|
910
|
+
paintGL(canvas, scene.current, width, height, (css) => colorBytes(resolve(css), pixel.current, bytes.current));
|
|
911
|
+
} else paint(canvas, scene.current, width, resolve);
|
|
912
|
+
}, [
|
|
913
|
+
scene,
|
|
914
|
+
width,
|
|
915
|
+
height,
|
|
916
|
+
renderer
|
|
917
|
+
]);
|
|
753
918
|
return {
|
|
754
919
|
canvas: React.useCallback((node) => {
|
|
755
920
|
element.current = node;
|
|
@@ -758,6 +923,7 @@ function useCanvas(scene, width) {
|
|
|
758
923
|
resize.observe(node);
|
|
759
924
|
const theme = new MutationObserver(() => {
|
|
760
925
|
colors.current.clear();
|
|
926
|
+
bytes.current.clear();
|
|
761
927
|
repaint();
|
|
762
928
|
});
|
|
763
929
|
theme.observe(document.documentElement, { attributeFilter: ["class", "style"] });
|
|
@@ -786,21 +952,156 @@ function paint(canvas, marks, width, color) {
|
|
|
786
952
|
} else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
|
|
787
953
|
ctx.fillStyle = color(fill);
|
|
788
954
|
ctx.fill();
|
|
789
|
-
const outline = r === void 0 ? fill : stroke;
|
|
955
|
+
const outline = r === void 0 ? stroke ?? fill : stroke;
|
|
790
956
|
if (!outline) continue;
|
|
791
957
|
ctx.strokeStyle = color(outline);
|
|
792
958
|
ctx.lineWidth = r === void 0 ? .5 : 1;
|
|
793
959
|
ctx.stroke();
|
|
794
960
|
}
|
|
795
961
|
}
|
|
962
|
+
/** A 1×1 canvas whose 2D context resolves a color to bytes, for the webgl renderer. */
|
|
963
|
+
function onePixel() {
|
|
964
|
+
const canvas = document.createElement("canvas");
|
|
965
|
+
canvas.width = 1;
|
|
966
|
+
canvas.height = 1;
|
|
967
|
+
return canvas.getContext("2d");
|
|
968
|
+
}
|
|
969
|
+
/** The resolved `color` as bytes, drawn on the 1×1 `pixel` and read back, cached per color. */
|
|
970
|
+
function colorBytes(color, pixel, cache) {
|
|
971
|
+
let rgba = cache.get(color);
|
|
972
|
+
if (rgba === void 0) {
|
|
973
|
+
pixel.clearRect(0, 0, 1, 1);
|
|
974
|
+
pixel.fillStyle = color;
|
|
975
|
+
pixel.fillRect(0, 0, 1, 1);
|
|
976
|
+
rgba = [...pixel.getImageData(0, 0, 1, 1).data];
|
|
977
|
+
cache.set(color, rgba);
|
|
978
|
+
}
|
|
979
|
+
return rgba;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Paints `marks` on a webgl `canvas`, from the view box of `width`×`height` to
|
|
983
|
+
* the pixels of the canvas: a polygon ear by ear, a fan of 12 segments per
|
|
984
|
+
* circle, in paint order and without a depth test, the color of each as bytes.
|
|
985
|
+
*/
|
|
986
|
+
function paintGL(canvas, marks, width, height, color) {
|
|
987
|
+
canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
|
|
988
|
+
canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
|
|
989
|
+
const gl = canvas.getContext("webgl");
|
|
990
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
991
|
+
gl.disable(gl.DEPTH_TEST);
|
|
992
|
+
gl.enable(gl.BLEND);
|
|
993
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
994
|
+
const program = programOf(gl);
|
|
995
|
+
gl.useProgram(program);
|
|
996
|
+
gl.uniform2f(gl.getUniformLocation(program, "u_res"), 2 / width, 2 / height);
|
|
997
|
+
const where = gl.getAttribLocation(program, "a_pos");
|
|
998
|
+
gl.enableVertexAttribArray(where);
|
|
999
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOf(gl));
|
|
1000
|
+
gl.vertexAttribPointer(where, 2, gl.FLOAT, false, 0, 0);
|
|
1001
|
+
const tint = (css) => {
|
|
1002
|
+
const [r, g, b, a] = color(css);
|
|
1003
|
+
gl.uniform4f(gl.getUniformLocation(program, "u_color"), r / 255, g / 255, b / 255, a / 255);
|
|
1004
|
+
};
|
|
1005
|
+
const draw = (mode, points) => {
|
|
1006
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points.flatMap((p) => [p.x, p.y])), gl.DYNAMIC_DRAW);
|
|
1007
|
+
gl.drawArrays(mode, 0, points.length);
|
|
1008
|
+
};
|
|
1009
|
+
const [, widest] = gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE) ?? [1, 1];
|
|
1010
|
+
gl.lineWidth(Math.min(1.5 * devicePixelRatio, widest));
|
|
1011
|
+
for (const mark of marks) {
|
|
1012
|
+
tint(mark.fill);
|
|
1013
|
+
draw(gl.TRIANGLES, trianglesOf(mark));
|
|
1014
|
+
if (mark.stroke) {
|
|
1015
|
+
tint(mark.stroke);
|
|
1016
|
+
draw(gl.LINE_LOOP, outlineOf(mark));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/** The 12 points of the circle around `c` of radius `r`. */
|
|
1021
|
+
const ringOf = (c, r) => Array.from({ length: 12 }, (_, i) => ({
|
|
1022
|
+
x: c.x + r * Math.cos(i / 12 * 2 * Math.PI),
|
|
1023
|
+
y: c.y + r * Math.sin(i / 12 * 2 * Math.PI)
|
|
1024
|
+
}));
|
|
1025
|
+
/** The triangles of `mark` as view-box points: its polygon ear by ear, a fan of 12 segments per circle. */
|
|
1026
|
+
function trianglesOf(mark) {
|
|
1027
|
+
const { points, r } = mark;
|
|
1028
|
+
if (r === void 0) return earsOf(points);
|
|
1029
|
+
const ring = ringOf(points[0], r);
|
|
1030
|
+
return ring.flatMap((p, i) => [
|
|
1031
|
+
points[0],
|
|
1032
|
+
p,
|
|
1033
|
+
ring[(i + 1) % 12]
|
|
1034
|
+
]);
|
|
1035
|
+
}
|
|
1036
|
+
/** Twice the signed area of the triangle `a`, `b`, `c`: its sign tells which way it turns, 0 on a line. */
|
|
1037
|
+
const turn = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
1038
|
+
/**
|
|
1039
|
+
* The triangles of a polygon, cut off ear by ear, so a concave one - the
|
|
1040
|
+
* front of a ribbon - fills as on the canvas, where a fan would spill over
|
|
1041
|
+
* its dents. An ear is a corner that turns the way of the polygon, or not at
|
|
1042
|
+
* all, with no other corner inside it; a simple polygon always has one. One
|
|
1043
|
+
* that crosses itself may have none, and then loses its first corner.
|
|
1044
|
+
*/
|
|
1045
|
+
function earsOf(points) {
|
|
1046
|
+
const left = [...points];
|
|
1047
|
+
const way = Math.sign(left.reduce((sum, p, i) => sum + turn(left[0], p, left[i + 1] ?? p), 0));
|
|
1048
|
+
const triangles = [];
|
|
1049
|
+
while (left.length > 3) {
|
|
1050
|
+
const corner = (i) => [
|
|
1051
|
+
left.at(i - 1),
|
|
1052
|
+
left[i],
|
|
1053
|
+
left[(i + 1) % left.length]
|
|
1054
|
+
];
|
|
1055
|
+
const ear = left.findIndex((_, i) => {
|
|
1056
|
+
const [a, b, c] = corner(i);
|
|
1057
|
+
if (way * turn(a, b, c) < 0) return false;
|
|
1058
|
+
return !left.some((p) => way * turn(a, b, p) > 0 && way * turn(b, c, p) > 0 && way * turn(c, a, p) > 0);
|
|
1059
|
+
});
|
|
1060
|
+
const i = Math.max(0, ear);
|
|
1061
|
+
triangles.push(...corner(i));
|
|
1062
|
+
left.splice(i, 1);
|
|
1063
|
+
}
|
|
1064
|
+
return [...triangles, ...left];
|
|
1065
|
+
}
|
|
1066
|
+
/** The outline of `mark` as view-box points: its polygon, or the ring of its circle. */
|
|
1067
|
+
const outlineOf = (mark) => mark.r === void 0 ? mark.points : ringOf(mark.points[0], mark.r);
|
|
1068
|
+
const programs = /* @__PURE__ */ new WeakMap();
|
|
1069
|
+
const buffers = /* @__PURE__ */ new WeakMap();
|
|
1070
|
+
/** The paint program of `gl`, compiled once: view-box points in, clip space out, one color per draw. */
|
|
1071
|
+
function programOf(gl) {
|
|
1072
|
+
let program = programs.get(gl);
|
|
1073
|
+
if (!program) {
|
|
1074
|
+
program = gl.createProgram();
|
|
1075
|
+
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); }"));
|
|
1076
|
+
gl.attachShader(program, shaderOf(gl, gl.FRAGMENT_SHADER, "precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }"));
|
|
1077
|
+
gl.linkProgram(program);
|
|
1078
|
+
programs.set(gl, program);
|
|
1079
|
+
}
|
|
1080
|
+
return program;
|
|
1081
|
+
}
|
|
1082
|
+
function shaderOf(gl, type, source) {
|
|
1083
|
+
const shader = gl.createShader(type);
|
|
1084
|
+
gl.shaderSource(shader, source);
|
|
1085
|
+
gl.compileShader(shader);
|
|
1086
|
+
return shader;
|
|
1087
|
+
}
|
|
1088
|
+
function bufferOf(gl) {
|
|
1089
|
+
let buffer = buffers.get(gl);
|
|
1090
|
+
if (!buffer) {
|
|
1091
|
+
buffer = gl.createBuffer();
|
|
1092
|
+
buffers.set(gl, buffer);
|
|
1093
|
+
}
|
|
1094
|
+
return buffer;
|
|
1095
|
+
}
|
|
796
1096
|
/** The canvas over the chart box that the marks are painted on, if the chart draws on one. */
|
|
797
1097
|
function ChartCanvas3D({ renderer, canvas, width, height }) {
|
|
798
|
-
if (renderer
|
|
1098
|
+
if (renderer === "svg") return null;
|
|
799
1099
|
return /* @__PURE__ */ jsx("canvas", {
|
|
1100
|
+
"data-slot": "chart-3d-canvas",
|
|
800
1101
|
ref: canvas,
|
|
801
|
-
className: "pointer-events-none absolute top-0 left-0 w-full mask-y-from-96%",
|
|
1102
|
+
className: "pointer-events-none absolute top-0 left-0 w-full mask-x-from-96% mask-y-from-96%",
|
|
802
1103
|
style: { aspectRatio: `${width} / ${height}` }
|
|
803
|
-
});
|
|
1104
|
+
}, renderer);
|
|
804
1105
|
}
|
|
805
1106
|
const GIZMO = [
|
|
806
1107
|
{
|
|
@@ -868,42 +1169,49 @@ function ChartGizmo3D({ camera, corner }) {
|
|
|
868
1169
|
});
|
|
869
1170
|
}
|
|
870
1171
|
/**
|
|
1172
|
+
* The focus edge of a fill: `--chart-3d-focus`, or the fill itself lightened,
|
|
1173
|
+
* so the highlight carries the color of what it highlights.
|
|
1174
|
+
*/
|
|
1175
|
+
const focusOf = (fill) => `var(--chart-3d-focus, color-mix(in oklch, ${fill} 60%, white))`;
|
|
1176
|
+
/**
|
|
871
1177
|
* The marks of a chart, drawn by the renderer of the container: as SVG
|
|
872
1178
|
* elements, or on the canvas over the chart box. The hover reads them too.
|
|
873
1179
|
*/
|
|
874
1180
|
function Marks3D({ marks }) {
|
|
875
1181
|
const { renderer, scene, repaint } = useChart3D();
|
|
876
|
-
const
|
|
877
|
-
const drawn = marks.map((mark) =>
|
|
1182
|
+
const lit = useLit(marks);
|
|
1183
|
+
const drawn = marks.map((mark) => lit(mark) ? {
|
|
878
1184
|
...mark,
|
|
879
|
-
|
|
1185
|
+
stroke: mark.stroke && mark.stroke !== "var(--background)" ? mark.stroke : focusOf(mark.fill)
|
|
880
1186
|
} : mark);
|
|
881
1187
|
React.useLayoutEffect(() => {
|
|
882
1188
|
scene.current = drawn;
|
|
883
1189
|
repaint();
|
|
884
1190
|
});
|
|
885
|
-
if (renderer
|
|
886
|
-
return
|
|
887
|
-
|
|
888
|
-
fill,
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1191
|
+
if (renderer !== "svg") return null;
|
|
1192
|
+
return /* @__PURE__ */ jsx("g", {
|
|
1193
|
+
"data-slot": "chart-3d-marks",
|
|
1194
|
+
children: drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
|
|
1195
|
+
d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
|
|
1196
|
+
fill,
|
|
1197
|
+
stroke: stroke ?? fill,
|
|
1198
|
+
strokeWidth: stroke === focusOf(fill) ? 1.5 : .5,
|
|
1199
|
+
strokeLinejoin: "round"
|
|
1200
|
+
}, key) : /* @__PURE__ */ jsx("circle", {
|
|
1201
|
+
cx: points[0].x,
|
|
1202
|
+
cy: points[0].y,
|
|
1203
|
+
r,
|
|
1204
|
+
fill,
|
|
1205
|
+
stroke,
|
|
1206
|
+
strokeWidth: stroke ? 1 : void 0
|
|
1207
|
+
}, key))
|
|
1208
|
+
});
|
|
900
1209
|
}
|
|
901
1210
|
/**
|
|
902
1211
|
* The labels of the marks of a chart, ringed by the background so that they
|
|
903
1212
|
* read on the marks. Like the labels of the box, they are SVG with every renderer.
|
|
904
1213
|
*/
|
|
905
1214
|
function Labels3D({ labels }) {
|
|
906
|
-
const fades = useFades(labels);
|
|
907
1215
|
return /* @__PURE__ */ jsx("g", {
|
|
908
1216
|
"data-slot": "chart-3d-labels",
|
|
909
1217
|
children: labels.map((label) => {
|
|
@@ -916,7 +1224,7 @@ function Labels3D({ labels }) {
|
|
|
916
1224
|
paintOrder: "stroke",
|
|
917
1225
|
strokeWidth: 3,
|
|
918
1226
|
strokeLinejoin: "round",
|
|
919
|
-
className:
|
|
1227
|
+
className: "fill-foreground stroke-background text-[10px] font-medium",
|
|
920
1228
|
children: label.text
|
|
921
1229
|
}, label.key);
|
|
922
1230
|
})
|
|
@@ -924,24 +1232,37 @@ function Labels3D({ labels }) {
|
|
|
924
1232
|
}
|
|
925
1233
|
function ChartStyle({ id, config }) {
|
|
926
1234
|
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
|
|
927
|
-
|
|
928
|
-
|
|
1235
|
+
const entering = [
|
|
1236
|
+
"text",
|
|
1237
|
+
"line",
|
|
1238
|
+
"path"
|
|
1239
|
+
].map((el) => `[data-chart=${id}] [data-slot="chart-3d-axes"] ${el}`).join(", ");
|
|
1240
|
+
const base = `@keyframes chart-3d-axes-in { from { opacity: 0; } }
|
|
1241
|
+
${entering} { animation: chart-3d-axes-in 300ms ease-out; }
|
|
1242
|
+
@media (prefers-reduced-motion: reduce) { ${entering} { animation: none; } }
|
|
1243
|
+
[data-chart=${id}] svg[data-mod="true"] { cursor: grab; }
|
|
1244
|
+
[data-chart=${id}] svg[data-alt="true"] { cursor: move; }
|
|
1245
|
+
[data-chart=${id}] svg[data-zoom="in"] { cursor: zoom-in; }
|
|
1246
|
+
[data-chart=${id}] svg[data-zoom="out"] { cursor: zoom-out; }
|
|
1247
|
+
[data-chart=${id}] svg[data-orbiting="true"] { cursor: grabbing; }`;
|
|
1248
|
+
if (!colorConfig.length) return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: base } });
|
|
1249
|
+
return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: [base, ...Object.entries(THEMES).map(([theme, prefix]) => `
|
|
929
1250
|
${prefix} [data-chart=${id}] {
|
|
930
1251
|
${colorConfig.map(([key, item]) => {
|
|
931
1252
|
const color = item.theme?.[theme] || item.color;
|
|
932
1253
|
return color ? ` --color-${key}: ${color};` : null;
|
|
933
1254
|
}).join("\n")}
|
|
934
1255
|
}
|
|
935
|
-
`).join("\n") } });
|
|
1256
|
+
`)].join("\n") } });
|
|
936
1257
|
}
|
|
937
|
-
function ChartTooltip3D({
|
|
1258
|
+
function ChartTooltip3D({ tip }) {
|
|
1259
|
+
const { tooltip, ref, place } = tip;
|
|
1260
|
+
React.useLayoutEffect(place, [tooltip, place]);
|
|
938
1261
|
if (!tooltip) return null;
|
|
939
1262
|
return /* @__PURE__ */ jsxs("div", {
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
top: `${tooltip.at.y / height * 100}%`
|
|
944
|
-
},
|
|
1263
|
+
ref,
|
|
1264
|
+
"data-slot": "chart-3d-tooltip",
|
|
1265
|
+
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]",
|
|
945
1266
|
children: [tooltip.title && /* @__PURE__ */ jsx("div", {
|
|
946
1267
|
className: "font-medium",
|
|
947
1268
|
children: tooltip.title
|
|
@@ -983,9 +1304,18 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
983
1304
|
chart?.addEventListener("keydown", onKeyDown);
|
|
984
1305
|
return () => chart?.removeEventListener("keydown", onKeyDown);
|
|
985
1306
|
}, [keys]);
|
|
1307
|
+
React.useEffect(() => {
|
|
1308
|
+
if (!open) return;
|
|
1309
|
+
const onPointerDown = (e) => {
|
|
1310
|
+
if (!ref.current?.contains(e.target)) setOpen(false);
|
|
1311
|
+
};
|
|
1312
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
1313
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
1314
|
+
}, [open]);
|
|
986
1315
|
if (!rows.length) return null;
|
|
987
1316
|
return /* @__PURE__ */ jsxs("div", {
|
|
988
1317
|
ref,
|
|
1318
|
+
"data-slot": "chart-3d-shortcuts",
|
|
989
1319
|
className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
|
|
990
1320
|
children: [/* @__PURE__ */ jsx("button", {
|
|
991
1321
|
type: "button",
|
|
@@ -994,7 +1324,7 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
994
1324
|
"aria-expanded": open,
|
|
995
1325
|
"aria-controls": open ? id : void 0,
|
|
996
1326
|
onClick: () => setOpen((o) => !o),
|
|
997
|
-
className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-
|
|
1327
|
+
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",
|
|
998
1328
|
children: /* @__PURE__ */ jsx(KeyboardIcon, {})
|
|
999
1329
|
}), open && /* @__PURE__ */ jsx(ChartShortcutList3D, {
|
|
1000
1330
|
id,
|
|
@@ -1002,31 +1332,44 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
1002
1332
|
})]
|
|
1003
1333
|
});
|
|
1004
1334
|
}
|
|
1005
|
-
/**
|
|
1335
|
+
/** One group of keys as chips. */
|
|
1336
|
+
function KbdGroup3D({ group }) {
|
|
1337
|
+
return /* @__PURE__ */ jsx("span", {
|
|
1338
|
+
"data-slot": "kbd-group",
|
|
1339
|
+
className: "flex gap-1",
|
|
1340
|
+
children: group.map((key, j) => /* @__PURE__ */ jsx("kbd", {
|
|
1341
|
+
"data-slot": "kbd",
|
|
1342
|
+
className: "pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
|
|
1343
|
+
children: key
|
|
1344
|
+
}, j))
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
/** The list of the shortcut panel: per row its label, then its keys, a rule, and its gestures. */
|
|
1006
1348
|
function ChartShortcutList3D({ id, rows }) {
|
|
1007
1349
|
return /* @__PURE__ */ jsx("dl", {
|
|
1008
1350
|
id,
|
|
1009
|
-
"data-slot": "chart-3d-shortcuts",
|
|
1351
|
+
"data-slot": "chart-3d-shortcuts-panel",
|
|
1010
1352
|
className: "grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-2 shadow-xl",
|
|
1011
|
-
children: rows.map(({ label,
|
|
1012
|
-
|
|
1013
|
-
children: groups.map((group, i) => /* @__PURE__ */ jsx("span", {
|
|
1014
|
-
"data-slot": "kbd-group",
|
|
1015
|
-
className: "flex gap-1",
|
|
1016
|
-
children: group.map((key, j) => /* @__PURE__ */ jsx("kbd", {
|
|
1017
|
-
"data-slot": "kbd",
|
|
1018
|
-
className: "pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
|
|
1019
|
-
children: key
|
|
1020
|
-
}, j))
|
|
1021
|
-
}, i))
|
|
1022
|
-
}), /* @__PURE__ */ jsx("dd", {
|
|
1353
|
+
children: rows.map(({ label, keys, gestures }) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("dt", {
|
|
1354
|
+
"data-slot": "chart-3d-shortcuts-label",
|
|
1023
1355
|
className: "text-muted-foreground",
|
|
1024
1356
|
children: label
|
|
1357
|
+
}), /* @__PURE__ */ jsxs("dd", {
|
|
1358
|
+
className: "flex flex-wrap items-center justify-end gap-x-2 gap-y-1",
|
|
1359
|
+
children: [
|
|
1360
|
+
keys.map((group, i) => /* @__PURE__ */ jsx(KbdGroup3D, { group }, i)),
|
|
1361
|
+
keys.length > 0 && gestures.length > 0 && /* @__PURE__ */ jsx("span", {
|
|
1362
|
+
"data-slot": "chart-3d-shortcuts-rule",
|
|
1363
|
+
className: "h-4 w-px bg-border"
|
|
1364
|
+
}),
|
|
1365
|
+
gestures.map((group, i) => /* @__PURE__ */ jsx(KbdGroup3D, { group }, i))
|
|
1366
|
+
]
|
|
1025
1367
|
})] }, label))
|
|
1026
1368
|
});
|
|
1027
1369
|
}
|
|
1028
1370
|
function ChartLegend3D({ config }) {
|
|
1029
1371
|
return /* @__PURE__ */ jsx("div", {
|
|
1372
|
+
"data-slot": "chart-3d-legend",
|
|
1030
1373
|
className: "flex items-center justify-center gap-4 pt-3",
|
|
1031
1374
|
children: Object.entries(config).map(([key, item]) => /* @__PURE__ */ jsxs("div", {
|
|
1032
1375
|
className: "flex items-center gap-1.5",
|
|
@@ -1047,15 +1390,32 @@ function ChartLegend3D({ config }) {
|
|
|
1047
1390
|
function Axes3D({ spec, world, format = {}, titles = {} }) {
|
|
1048
1391
|
const { camera } = useChart3D();
|
|
1049
1392
|
const box = intoWorld(axes3D().camera(camera), world);
|
|
1050
|
-
const { labels, lines, marks } = labelAxes(box.edges(spec), format, titles);
|
|
1393
|
+
const { labels, lines, marks } = labelAxes(box.edges(spec), spec, format, titles);
|
|
1394
|
+
const leaving = new Set([
|
|
1395
|
+
"x",
|
|
1396
|
+
"y",
|
|
1397
|
+
"z"
|
|
1398
|
+
].flatMap((a) => (spec[a].leaving ?? []).map((value) => `${a}-${value}`)));
|
|
1051
1399
|
return /* @__PURE__ */ jsxs("g", {
|
|
1052
1400
|
"data-slot": "chart-3d-axes",
|
|
1053
1401
|
children: [
|
|
1054
|
-
box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
|
|
1055
|
-
d:
|
|
1402
|
+
box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("path", {
|
|
1403
|
+
d: quad(wall),
|
|
1056
1404
|
className: "fill-muted/40 stroke-border",
|
|
1057
1405
|
strokeWidth: .75
|
|
1058
|
-
},
|
|
1406
|
+
}), wall.grid.map((segment) => {
|
|
1407
|
+
const [from, to] = [segment[0], segment[segment.length - 1]];
|
|
1408
|
+
const key = gridKey(wall, from, to);
|
|
1409
|
+
return /* @__PURE__ */ jsx("line", {
|
|
1410
|
+
x1: from.projected.x,
|
|
1411
|
+
y1: from.projected.y,
|
|
1412
|
+
x2: to.projected.x,
|
|
1413
|
+
y2: to.projected.y,
|
|
1414
|
+
style: { opacity: leaving.has(key) ? 0 : 1 },
|
|
1415
|
+
className: "stroke-border transition-opacity duration-300 ease-out motion-reduce:transition-none",
|
|
1416
|
+
strokeWidth: .75
|
|
1417
|
+
}, key);
|
|
1418
|
+
})] }, `${wall.axis}${wall.side}`)),
|
|
1059
1419
|
lines.map((line) => /* @__PURE__ */ jsx(AxisLine3D, {
|
|
1060
1420
|
line,
|
|
1061
1421
|
ink: "stroke-foreground",
|
|
@@ -1075,6 +1435,25 @@ const PAD = 2;
|
|
|
1075
1435
|
const FADE = 4;
|
|
1076
1436
|
const DODGE = 12;
|
|
1077
1437
|
const MARK = 6;
|
|
1438
|
+
/** The closed quad of a wall; its gridlines are their own lines, so a new one fades in. */
|
|
1439
|
+
function quad(wall) {
|
|
1440
|
+
return `M${wall.map(({ projected }) => `${projected.x},${projected.y}`).join("L")}Z`;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* The key of a gridline: the axis and value of the tick it marks - the one
|
|
1444
|
+
* coordinate its ends share beside the wall's own axis - so the gridline of
|
|
1445
|
+
* a tick that stays glides on, and one of a new tick mounts and fades in.
|
|
1446
|
+
* Exactly one coordinate differs along the line, because a domain always has
|
|
1447
|
+
* width: `axis` widens an empty extent.
|
|
1448
|
+
*/
|
|
1449
|
+
function gridKey(wall, from, to) {
|
|
1450
|
+
const axis = [
|
|
1451
|
+
"x",
|
|
1452
|
+
"y",
|
|
1453
|
+
"z"
|
|
1454
|
+
].find((a) => a !== wall.axis && from[a] === to[a]);
|
|
1455
|
+
return `${axis}-${from[axis]}`;
|
|
1456
|
+
}
|
|
1078
1457
|
/**
|
|
1079
1458
|
* The labels of the axes on all their edges, the line of every axis along its
|
|
1080
1459
|
* edge and the mark of each of its ticks, shown on the edges the axes sit on.
|
|
@@ -1082,11 +1461,12 @@ const MARK = 6;
|
|
|
1082
1461
|
* those of an axis with fewer ticks, which say more each, and the titles give
|
|
1083
1462
|
* way to the ticks.
|
|
1084
1463
|
*/
|
|
1085
|
-
function labelAxes(edges, format, titles) {
|
|
1086
|
-
const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, format[edge.axis] ?? String, titles[edge.axis]));
|
|
1464
|
+
function labelAxes(edges, spec, format, titles) {
|
|
1465
|
+
const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, spec[edge.axis], format[edge.axis] ?? String, titles[edge.axis]));
|
|
1087
1466
|
const shown = axes.filter((axis) => axis.chosen).sort((a, b) => a.ticks.length - b.ticks.length);
|
|
1088
1467
|
const placed = [];
|
|
1089
1468
|
for (const label of [...shown.flatMap((a) => a.ticks), ...shown.flatMap((a) => a.title ?? [])]) {
|
|
1469
|
+
if (label.opacity === 0) continue;
|
|
1090
1470
|
label.opacity = shows(label, placed);
|
|
1091
1471
|
placed.push(label);
|
|
1092
1472
|
}
|
|
@@ -1102,13 +1482,47 @@ function labelAxes(edges, format, titles) {
|
|
|
1102
1482
|
* the rows that `fit` finds, and the title of the axis beyond them. The
|
|
1103
1483
|
* labels of an edge the axis does not sit on are there, but do not show.
|
|
1104
1484
|
*/
|
|
1105
|
-
|
|
1485
|
+
/**
|
|
1486
|
+
* The point of `value` along `edge`, between the ticks that bracket it: the
|
|
1487
|
+
* projection keeps the edge straight, so the point lies on their line. An
|
|
1488
|
+
* axis of one tick is a point.
|
|
1489
|
+
*/
|
|
1490
|
+
function along(edge, value) {
|
|
1491
|
+
if (edge.length === 1) return edge[0].projected;
|
|
1492
|
+
let i = 0;
|
|
1493
|
+
while (i < edge.length - 2 && edge[i + 1][edge.axis] < value) i += 1;
|
|
1494
|
+
const [a, b] = [edge[i][edge.axis], edge[i + 1][edge.axis]];
|
|
1495
|
+
const t = clamp(0, 1, (value - a) / (b - a));
|
|
1496
|
+
const [p, q] = [edge[i].projected, edge[i + 1].projected];
|
|
1497
|
+
return {
|
|
1498
|
+
x: p.x + (q.x - p.x) * t,
|
|
1499
|
+
y: p.y + (q.y - p.y) * t
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
/** The ticks whose value lies in `domain`, with the ends of `domain`. */
|
|
1503
|
+
function inRange(ticks, domain) {
|
|
1504
|
+
const range = {
|
|
1505
|
+
lo: Math.min(...domain),
|
|
1506
|
+
hi: Math.max(...domain)
|
|
1507
|
+
};
|
|
1508
|
+
return {
|
|
1509
|
+
ranged: ticks.filter((t) => t.value !== void 0 && within(range.lo, range.hi, t.value)),
|
|
1510
|
+
range
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
/** An edge the axis does not sit on hides its labels; on a chosen one, the ticks outside the gliding ends fade out. */
|
|
1514
|
+
function hideOffRange(labels, range, chosen) {
|
|
1515
|
+
if (!chosen) for (const l of labels) l.opacity = 0;
|
|
1516
|
+
else for (const l of labels) if (l.value !== void 0 && !within(range.lo, range.hi, l.value)) l.opacity = 0;
|
|
1517
|
+
}
|
|
1518
|
+
function edgeLabels(edge, { domain, leaving = [], span }, format, title) {
|
|
1106
1519
|
const { axis, normal: n, chosen } = edge;
|
|
1107
|
-
const label = (key, at, text, h, gap) => {
|
|
1520
|
+
const label = (key, at, text, h, gap, value) => {
|
|
1108
1521
|
const w = textWidth(text, h);
|
|
1109
1522
|
return {
|
|
1110
1523
|
key,
|
|
1111
1524
|
axis,
|
|
1525
|
+
value,
|
|
1112
1526
|
at: beside(at, n, w, h, gap),
|
|
1113
1527
|
w,
|
|
1114
1528
|
h,
|
|
@@ -1118,15 +1532,18 @@ function edgeLabels(edge, format, title) {
|
|
|
1118
1532
|
};
|
|
1119
1533
|
};
|
|
1120
1534
|
const id = `${axis}${edge.sides.join("")}`;
|
|
1121
|
-
const ticks = edge.map((tick) => label(`${id}-${tick[axis]}`, tick.projected, format(tick[axis]), 10, GAP));
|
|
1122
|
-
const
|
|
1535
|
+
const ticks = edge.map((tick) => label(`${id}-${tick[axis]}`, tick.projected, format(tick[axis]), 10, GAP, tick[axis]));
|
|
1536
|
+
for (const t of ticks) if (t.value !== void 0 && leaving.includes(t.value)) t.opacity = 0;
|
|
1537
|
+
const { ranged, range } = inRange(ticks.filter((t) => t.opacity > 0), domain);
|
|
1538
|
+
const depth = Math.max(0, ...ranged.map((t) => Math.abs(n.x) * t.w + Math.abs(n.y) * t.h));
|
|
1123
1539
|
const row = {
|
|
1124
1540
|
x: n.x * (depth + PAD),
|
|
1125
1541
|
y: n.y * (depth + PAD)
|
|
1126
1542
|
};
|
|
1127
|
-
const rows = fit(
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1543
|
+
const rows = fit(ranged, n, row);
|
|
1544
|
+
const [s0, s1] = span ?? [edge[0][axis], edge[edge.length - 1][axis]];
|
|
1545
|
+
const start = along(edge, clamp(range.lo, range.hi, s0));
|
|
1546
|
+
const end = along(edge, clamp(range.lo, range.hi, s1));
|
|
1130
1547
|
const middle = {
|
|
1131
1548
|
x: (start.x + end.x) / 2,
|
|
1132
1549
|
y: (start.y + end.y) / 2
|
|
@@ -1136,7 +1553,7 @@ function edgeLabels(edge, format, title) {
|
|
|
1136
1553
|
title: true
|
|
1137
1554
|
} : void 0;
|
|
1138
1555
|
if (head && rows === 2) head.shift = row;
|
|
1139
|
-
|
|
1556
|
+
hideOffRange([...ticks, ...head ? [head] : []], range, chosen);
|
|
1140
1557
|
return {
|
|
1141
1558
|
chosen,
|
|
1142
1559
|
ticks,
|
|
@@ -1288,7 +1705,7 @@ function AxisLine3D({ line: { from, to, opacity }, ink, width }) {
|
|
|
1288
1705
|
y2: to.y,
|
|
1289
1706
|
strokeWidth: width,
|
|
1290
1707
|
style: { opacity: Math.round(opacity * 100) / 100 },
|
|
1291
|
-
className: cn("transition-opacity duration-
|
|
1708
|
+
className: cn("transition-opacity duration-300 ease-out motion-reduce:transition-none", ink)
|
|
1292
1709
|
});
|
|
1293
1710
|
}
|
|
1294
1711
|
/**
|
|
@@ -1305,7 +1722,7 @@ function AxisLabel3D({ label: { at, shift, opacity, text, title } }) {
|
|
|
1305
1722
|
opacity: Math.round(opacity * 100) / 100,
|
|
1306
1723
|
translate: `${shift.x}px ${shift.y}px`
|
|
1307
1724
|
},
|
|
1308
|
-
className: cn("transition-[opacity,translate] duration-
|
|
1725
|
+
className: cn("transition-[opacity,translate] duration-300 ease-out motion-reduce:transition-none", title ? "fill-foreground font-medium" : "fill-muted-foreground text-[10px]"),
|
|
1309
1726
|
children: text
|
|
1310
1727
|
});
|
|
1311
1728
|
}
|
|
@@ -1363,22 +1780,46 @@ function shade(face, color) {
|
|
|
1363
1780
|
const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
|
|
1364
1781
|
return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
|
|
1365
1782
|
}
|
|
1366
|
-
/**
|
|
1367
|
-
|
|
1368
|
-
|
|
1783
|
+
/**
|
|
1784
|
+
* `color` at `opacity`, from 0 to 1: a CSS color-mix with transparent, so a
|
|
1785
|
+
* mark fades on every renderer while its color stays in CSS. Rounded to a
|
|
1786
|
+
* percent, so the renderers that resolve colors cache a bounded few. Fade
|
|
1787
|
+
* after `shade`, which would mix a translucent color back towards opaque.
|
|
1788
|
+
*/
|
|
1789
|
+
function faded(color, opacity) {
|
|
1790
|
+
const percent = Math.round(opacity * 100);
|
|
1791
|
+
return percent >= 100 ? color : `color-mix(in oklab, ${color} ${percent}%, transparent)`;
|
|
1369
1792
|
}
|
|
1370
|
-
/**
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1793
|
+
/**
|
|
1794
|
+
* The entries of `data` and their series, for a chart that lays the entries
|
|
1795
|
+
* out along x and the series along z, as the bar and the area chart do. The
|
|
1796
|
+
* name of every entry comes from its `index` field, the series are `keys` or
|
|
1797
|
+
* the keys of the config, and the `target` of every cell is keyed by the name
|
|
1798
|
+
* of its entry and its series, so it follows its entry when the data
|
|
1799
|
+
* reorders; a value that is not a number stands as 0. Each glides by name:
|
|
1800
|
+
* `values` from 0, `rows` - the place of every entry - to where it stands, and
|
|
1801
|
+
* `presence` from 0 to 1 as an entry comes and back as it goes, the gone ones
|
|
1802
|
+
* still among them. `labels` name the ticks: the entry at a tick of x, through
|
|
1803
|
+
* `format.x` - a tick of a gone entry fades out unnamed - and the series at a
|
|
1804
|
+
* tick of z.
|
|
1805
|
+
*/
|
|
1806
|
+
function useSeries({ data, index, keys, format = {} }) {
|
|
1807
|
+
const { config } = useChart3D();
|
|
1808
|
+
const series = keys ?? Object.keys(config);
|
|
1809
|
+
const names = data.map((d) => String(d[index] ?? ""));
|
|
1810
|
+
const target = Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${names[i]}-${key}`, Number(d[key]) || 0])));
|
|
1811
|
+
const labels = {
|
|
1812
|
+
x: (i) => (format.x ?? String)(names[i] ?? ""),
|
|
1813
|
+
y: format.y,
|
|
1814
|
+
z: (j) => config[series[j]]?.label ?? series[j]
|
|
1815
|
+
};
|
|
1379
1816
|
return {
|
|
1380
|
-
|
|
1381
|
-
|
|
1817
|
+
series,
|
|
1818
|
+
target,
|
|
1819
|
+
values: useTween(target, 0),
|
|
1820
|
+
rows: useTween(Object.fromEntries(names.map((name, i) => [name, i]))),
|
|
1821
|
+
presence: useTween(Object.fromEntries(names.map((name) => [name, 1])), 0),
|
|
1822
|
+
labels
|
|
1382
1823
|
};
|
|
1383
1824
|
}
|
|
1384
1825
|
/** The marks of a cube: its faces that look at the camera, shaded in `color`, each with `tooltip` on hover. */
|
|
@@ -1390,89 +1831,289 @@ function cubeMarks(id, faces, color, tooltip) {
|
|
|
1390
1831
|
tooltip
|
|
1391
1832
|
}));
|
|
1392
1833
|
}
|
|
1834
|
+
// registry/new-york/ui/area-chart-3d.tsx
|
|
1835
|
+
const RIBBON = 2 / 3;
|
|
1836
|
+
/**
|
|
1837
|
+
* Areas pushed back into ribbons: the entries of `data` along x, one ribbon
|
|
1838
|
+
* per series along z, the area under its line reaching down to the floor of
|
|
1839
|
+
* the box. The series are the keys of the chart config unless `keys` names
|
|
1840
|
+
* them.
|
|
1841
|
+
*/
|
|
1842
|
+
function AreaChart3D({ data, index, keys, format = {}, titles }) {
|
|
1843
|
+
const { config, camera } = useChart3D();
|
|
1844
|
+
const { series, target, values, rows, presence, labels } = useSeries({
|
|
1845
|
+
data,
|
|
1846
|
+
index,
|
|
1847
|
+
keys,
|
|
1848
|
+
format
|
|
1849
|
+
});
|
|
1850
|
+
const spec = useTweenAxes(areaAxes(data.length, series.length, Object.values(target)));
|
|
1851
|
+
const depth = useTween({ depth: Math.min(1, Math.max(.25, series.length / 6)) }).depth;
|
|
1852
|
+
const world = worldBox(spec, depth);
|
|
1853
|
+
const polygons = intoWorld(polygons3D().camera(camera), world);
|
|
1854
|
+
const ends = [spec.x.domain[0] + .5, spec.x.domain[1] - .5];
|
|
1855
|
+
const toward = Math.sign(camera.viewDirection().x);
|
|
1856
|
+
const marks = series.flatMap((key, j) => {
|
|
1857
|
+
const line = linePoints(rows, values, presence, key, ends);
|
|
1858
|
+
if (!line.length) return [];
|
|
1859
|
+
const z = [j - RIBBON / 2, j + RIBBON / 2];
|
|
1860
|
+
return [{
|
|
1861
|
+
key,
|
|
1862
|
+
line,
|
|
1863
|
+
faces: ribbonFaces(polygons, line, spec.y.domain[0], z)
|
|
1864
|
+
}];
|
|
1865
|
+
}).sort((a, b) => sort(a.faces[4], b.faces[4])).flatMap(({ key, line, faces }) => ribbonMarks(key, painted(faces, toward), line, config[key]?.label ?? key, format.x ?? String));
|
|
1866
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1867
|
+
spec,
|
|
1868
|
+
world,
|
|
1869
|
+
titles,
|
|
1870
|
+
format: labels
|
|
1871
|
+
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* The points of the line of the series `key`, in order along x: every entry,
|
|
1875
|
+
* the ones still gliding away among them, at its gliding row and value. An
|
|
1876
|
+
* entry that comes or goes unfolds from the point before it - the one after
|
|
1877
|
+
* it, at the start of the line - and folds back into it, as far as its
|
|
1878
|
+
* `presence`, so the ribbon opens and closes without a step. Its value comes
|
|
1879
|
+
* from nothing as its presence grows - the two glide in step - so the part of
|
|
1880
|
+
* its neighbour it has not yet left is added: the point heads straight from
|
|
1881
|
+
* the neighbour to its own, never down towards the floor. Every
|
|
1882
|
+
* point stays within the gliding `ends` of the x axis, so a line with no
|
|
1883
|
+
* neighbour to unfold from - one that was empty - grows inside the box
|
|
1884
|
+
* instead of standing beyond its walls. A point only one of the tweens still
|
|
1885
|
+
* knows is on its way out and stays unseen.
|
|
1886
|
+
*/
|
|
1887
|
+
function linePoints(rows, values, presence, key, [lo, hi]) {
|
|
1888
|
+
const own = Object.entries(rows).sort(([, a], [, b]) => a - b).flatMap(([name, x]) => {
|
|
1889
|
+
const value = values[`${name}-${key}`];
|
|
1890
|
+
return value === void 0 ? [] : [{
|
|
1891
|
+
name,
|
|
1892
|
+
x,
|
|
1893
|
+
value,
|
|
1894
|
+
seen: presence[name]
|
|
1895
|
+
}];
|
|
1896
|
+
});
|
|
1897
|
+
let anchor = own.find((p) => p.seen === 1);
|
|
1898
|
+
return own.map((p) => {
|
|
1899
|
+
if (p.seen === 1) anchor = p;
|
|
1900
|
+
const { x } = anchor ?? p;
|
|
1901
|
+
return {
|
|
1902
|
+
name: p.name,
|
|
1903
|
+
x: Math.min(hi, Math.max(lo, x + p.seen * (p.x - x))),
|
|
1904
|
+
value: p.value + (1 - p.seen) * (anchor?.value ?? 0)
|
|
1905
|
+
};
|
|
1906
|
+
}).sort((a, b) => a.x - b.x);
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* The faces of the ribbon of `line`, projected: the area under the line,
|
|
1910
|
+
* down to `floor`, pushed back from `z[0]` to `z[1]`. Each is wound
|
|
1911
|
+
* counter-clockwise as seen from outside, so `ccw` tells whether it faces the
|
|
1912
|
+
* camera. The first five lie on its bounding box - the front and the back,
|
|
1913
|
+
* one polygon each however many points the line has, the two ends and the
|
|
1914
|
+
* bottom - the rest are its top, one quad per segment of the line.
|
|
1915
|
+
*/
|
|
1916
|
+
function ribbonFaces(polygons, line, floor, [back, front]) {
|
|
1917
|
+
const on = (p, z) => ({
|
|
1918
|
+
x: p.x,
|
|
1919
|
+
y: p.value,
|
|
1920
|
+
z
|
|
1921
|
+
});
|
|
1922
|
+
const under = (p, z) => ({
|
|
1923
|
+
x: p.x,
|
|
1924
|
+
y: floor,
|
|
1925
|
+
z
|
|
1926
|
+
});
|
|
1927
|
+
const face = (name, points, segment = -1) => Object.assign(points, {
|
|
1928
|
+
face: name,
|
|
1929
|
+
segment
|
|
1930
|
+
});
|
|
1931
|
+
const [a, o] = [line[0], line[line.length - 1]];
|
|
1932
|
+
return polygons.data([
|
|
1933
|
+
face("front", [
|
|
1934
|
+
on(a, front),
|
|
1935
|
+
under(a, front),
|
|
1936
|
+
under(o, front),
|
|
1937
|
+
...line.slice(1).reverse().map((p) => on(p, front))
|
|
1938
|
+
]),
|
|
1939
|
+
face("back", [
|
|
1940
|
+
...line.map((p) => on(p, back)),
|
|
1941
|
+
under(o, back),
|
|
1942
|
+
under(a, back)
|
|
1943
|
+
]),
|
|
1944
|
+
face("left", [
|
|
1945
|
+
on(a, back),
|
|
1946
|
+
under(a, back),
|
|
1947
|
+
under(a, front),
|
|
1948
|
+
on(a, front)
|
|
1949
|
+
]),
|
|
1950
|
+
face("right", [
|
|
1951
|
+
on(o, front),
|
|
1952
|
+
under(o, front),
|
|
1953
|
+
under(o, back),
|
|
1954
|
+
on(o, back)
|
|
1955
|
+
]),
|
|
1956
|
+
face("bottom", [
|
|
1957
|
+
under(a, front),
|
|
1958
|
+
under(a, back),
|
|
1959
|
+
under(o, back),
|
|
1960
|
+
under(o, front)
|
|
1961
|
+
]),
|
|
1962
|
+
...line.slice(1).map((p, i) => face("top", [
|
|
1963
|
+
on(line[i], front),
|
|
1964
|
+
on(p, front),
|
|
1965
|
+
on(p, back),
|
|
1966
|
+
on(line[i], back)
|
|
1967
|
+
], i))
|
|
1968
|
+
]);
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* The faces of a ribbon that face the camera, in paint order. Every segment
|
|
1972
|
+
* of the top is a slab, and the planes x = const separate them: the top is
|
|
1973
|
+
* painted from the end of the ribbon away from the camera, which `toward`,
|
|
1974
|
+
* the sign of x in the view direction, names. The faces of the bounding box
|
|
1975
|
+
* come last: the ribbon lies wholly behind each of them.
|
|
1976
|
+
*/
|
|
1977
|
+
function painted(faces, toward) {
|
|
1978
|
+
const shown = faces.filter((f) => f.ccw);
|
|
1979
|
+
return [...shown.filter((f) => f.face === "top").sort((a, b) => toward * (a.segment - b.segment)), ...shown.filter((f) => f.face !== "top")];
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* The marks of one ribbon, shaded in the color of its series. Each face is a
|
|
1983
|
+
* group of its own, so hovering highlights the face under the pointer alone.
|
|
1984
|
+
* A segment of the top is keyed by the entry it starts at, so it keeps its
|
|
1985
|
+
* mark when entries come or go before it. Every face names the series; a
|
|
1986
|
+
* segment of the top tells the values at its two ends.
|
|
1987
|
+
*/
|
|
1988
|
+
function ribbonMarks(key, faces, line, label, name) {
|
|
1989
|
+
const color = `var(--color-${key})`;
|
|
1990
|
+
const spans = (f) => f.face === "top" ? [line[f.segment], line[f.segment + 1]] : [];
|
|
1991
|
+
return faces.map((f) => {
|
|
1992
|
+
const id = f.face === "top" ? `${key}-top-${line[f.segment].name}` : `${key}-${f.face}`;
|
|
1993
|
+
return {
|
|
1994
|
+
key: id,
|
|
1995
|
+
points: f.map((p) => p.projected),
|
|
1996
|
+
fill: shade(f, color),
|
|
1997
|
+
groups: [id],
|
|
1998
|
+
tooltip: {
|
|
1999
|
+
title: label,
|
|
2000
|
+
items: spans(f).map((p) => ({
|
|
2001
|
+
label: name(p.name),
|
|
2002
|
+
value: p.value,
|
|
2003
|
+
color
|
|
2004
|
+
}))
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* The axes of an area chart: the entries along x, with half a row of room
|
|
2011
|
+
* between the first and the last and the walls, a row per series along z.
|
|
2012
|
+
* A line has many entries, so x ticks about ten of them, not every one.
|
|
2013
|
+
*/
|
|
2014
|
+
function areaAxes(entries, series, values) {
|
|
2015
|
+
const every = entries ? ticks(0, entries - 1, 10).filter(Number.isInteger) : [];
|
|
2016
|
+
return {
|
|
2017
|
+
x: pad({
|
|
2018
|
+
domain: [0, Math.max(0, entries - 1)],
|
|
2019
|
+
ticks: every
|
|
2020
|
+
}, 1),
|
|
2021
|
+
y: axis(Math.min(0, ...values), Math.max(0, ...values)),
|
|
2022
|
+
z: pad({
|
|
2023
|
+
domain: [0, Math.max(0, series - 1)],
|
|
2024
|
+
ticks: range(series)
|
|
2025
|
+
}, 1)
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
1393
2028
|
// registry/new-york/ui/bar-chart-3d.tsx
|
|
1394
2029
|
/**
|
|
1395
2030
|
* Bars on a floor: one row per entry of `data` along x, one per series
|
|
1396
2031
|
* along z, the value as the height. The series are the keys of the chart
|
|
1397
2032
|
* config unless `keys` names them.
|
|
1398
2033
|
*/
|
|
1399
|
-
function BarChart3D({ data, index, keys, barSize = .6, format
|
|
2034
|
+
function BarChart3D({ data, index, keys, barSize = .6, format, titles }) {
|
|
1400
2035
|
const { config, camera } = useChart3D();
|
|
1401
|
-
const series
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const
|
|
1410
|
-
const
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
key,
|
|
1414
|
-
value
|
|
1415
|
-
}))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
|
|
2036
|
+
const { series, target, values, rows, presence, labels } = useSeries({
|
|
2037
|
+
data,
|
|
2038
|
+
index,
|
|
2039
|
+
keys,
|
|
2040
|
+
format
|
|
2041
|
+
});
|
|
2042
|
+
const cells = barCells(rows, series, values);
|
|
2043
|
+
const box = barBox(data.length, series.length, Object.values(target));
|
|
2044
|
+
const spec = useTweenAxes(box.spec);
|
|
2045
|
+
const depth = useTween({ depth: box.depth }).depth;
|
|
2046
|
+
const world = worldBox(spec, depth);
|
|
2047
|
+
const bars = barShapes(camera, cells, world, cellSize(spec, depth, barSize));
|
|
1416
2048
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1417
2049
|
spec,
|
|
1418
2050
|
world,
|
|
1419
2051
|
titles,
|
|
1420
|
-
format:
|
|
1421
|
-
x: (i) => (format.x ?? String)(String(data[i][index] ?? "")),
|
|
1422
|
-
y: format.y,
|
|
1423
|
-
z: (j) => config[series[j]]?.label ?? series[j]
|
|
1424
|
-
}
|
|
2052
|
+
format: labels
|
|
1425
2053
|
}), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
|
|
1426
|
-
id: `${bar.
|
|
2054
|
+
id: `${bar.name}-${bar.key}`,
|
|
1427
2055
|
faces: bar.faces,
|
|
1428
2056
|
color: `var(--color-${bar.key})`,
|
|
1429
|
-
title:
|
|
2057
|
+
title: bar.name,
|
|
1430
2058
|
label: config[bar.key]?.label ?? bar.key,
|
|
1431
|
-
value: bar.value
|
|
2059
|
+
value: bar.value,
|
|
2060
|
+
opacity: presence[bar.name]
|
|
1432
2061
|
})) })] });
|
|
1433
2062
|
}
|
|
1434
|
-
/** The
|
|
1435
|
-
function
|
|
1436
|
-
return data.
|
|
1437
|
-
|
|
1438
|
-
j,
|
|
2063
|
+
/** The cubes of the cells, projected by `camera` and painted back to front by their footprint, as the bars stand on one floor. */
|
|
2064
|
+
function barShapes(camera, cells, world, size) {
|
|
2065
|
+
return cubes3D().camera(camera).data(cells.map(({ name, x, j, key, value }) => Object.assign(barCorners(world, x, j, value, size), {
|
|
2066
|
+
name,
|
|
1439
2067
|
key,
|
|
1440
|
-
value
|
|
1441
|
-
})));
|
|
2068
|
+
value
|
|
2069
|
+
}))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
|
|
1442
2070
|
}
|
|
1443
2071
|
/**
|
|
1444
|
-
* The
|
|
1445
|
-
* along x
|
|
2072
|
+
* The cell of every bar, the rows still gliding away among them: the row
|
|
2073
|
+
* position along x, the series `key` along z, with the tweened value. A cell
|
|
2074
|
+
* only one of the tweens still knows is on its way out and stays unseen.
|
|
1446
2075
|
*/
|
|
1447
|
-
function
|
|
1448
|
-
return Object.
|
|
2076
|
+
function barCells(rows, series, values) {
|
|
2077
|
+
return Object.entries(rows).flatMap(([name, x]) => series.flatMap((key, j) => {
|
|
2078
|
+
const value = values[`${name}-${key}`];
|
|
2079
|
+
return value === void 0 ? [] : [{
|
|
2080
|
+
name,
|
|
2081
|
+
x,
|
|
2082
|
+
j,
|
|
2083
|
+
key,
|
|
2084
|
+
value
|
|
2085
|
+
}];
|
|
2086
|
+
}));
|
|
1449
2087
|
}
|
|
1450
2088
|
/**
|
|
1451
2089
|
* The box of a bar chart: a row per entry along x, a row per series along z,
|
|
1452
|
-
* with half a cell of room between the outer rows and the walls
|
|
1453
|
-
* size of a bar, the share `barSize` of its cell.
|
|
2090
|
+
* with half a cell of room between the outer rows and the walls.
|
|
1454
2091
|
*/
|
|
1455
|
-
function barBox(entries, series, values
|
|
2092
|
+
function barBox(entries, series, values) {
|
|
1456
2093
|
const index = (length) => Array.from({ length }, (_, i) => i);
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
2094
|
+
return {
|
|
2095
|
+
spec: {
|
|
2096
|
+
x: pad({
|
|
2097
|
+
domain: [-.5, entries - .5],
|
|
2098
|
+
ticks: index(entries)
|
|
2099
|
+
}, 1),
|
|
2100
|
+
y: axis(Math.min(0, ...values), Math.max(0, ...values)),
|
|
2101
|
+
z: pad({
|
|
2102
|
+
domain: [-.5, series - .5],
|
|
2103
|
+
ticks: index(series)
|
|
2104
|
+
}, 1)
|
|
2105
|
+
},
|
|
2106
|
+
depth: Math.min(1, Math.max(.25, series / entries))
|
|
1467
2107
|
};
|
|
1468
|
-
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* The size of a bar, the share `barSize` of its cell - of the gliding ends of
|
|
2111
|
+
* `spec`, so a bar eases into a new cell size as rows come and go.
|
|
2112
|
+
*/
|
|
2113
|
+
function cellSize(spec, depth, barSize) {
|
|
1469
2114
|
return {
|
|
1470
|
-
spec,
|
|
1471
|
-
depth
|
|
1472
|
-
size: {
|
|
1473
|
-
width: 2 / (spec.x.domain[1] - spec.x.domain[0]) * barSize,
|
|
1474
|
-
depth: 2 * depth / (spec.z.domain[1] - spec.z.domain[0]) * barSize
|
|
1475
|
-
}
|
|
2115
|
+
width: 2 / (spec.x.domain[1] - spec.x.domain[0]) * barSize,
|
|
2116
|
+
depth: 2 * depth / (spec.z.domain[1] - spec.z.domain[0]) * barSize
|
|
1476
2117
|
};
|
|
1477
2118
|
}
|
|
1478
2119
|
/**
|
|
@@ -1490,61 +2131,57 @@ function barCorners(world, x, z, value, size) {
|
|
|
1490
2131
|
height: Math.abs(floor - top)
|
|
1491
2132
|
});
|
|
1492
2133
|
}
|
|
1493
|
-
/** The marks of one bar: the faces that look at the camera, shaded
|
|
1494
|
-
function barMarks({ id, faces, color, title, label, value }) {
|
|
2134
|
+
/** The marks of one bar: the faces that look at the camera, shaded and faded to `opacity`, each with its entry and value on hover. Its group is the bar alone, so hovering highlights one bar, not its series. */
|
|
2135
|
+
function barMarks({ id, faces, color, title, label, value, opacity }) {
|
|
1495
2136
|
return cubeMarks(id, faces, color, {
|
|
1496
|
-
at: center(faces.find((face) => face.face === "top")),
|
|
1497
2137
|
title,
|
|
1498
2138
|
items: [{
|
|
1499
2139
|
label,
|
|
1500
2140
|
value,
|
|
1501
2141
|
color
|
|
1502
2142
|
}]
|
|
1503
|
-
})
|
|
2143
|
+
}).map((mark) => ({
|
|
2144
|
+
...mark,
|
|
2145
|
+
fill: faded(mark.fill, opacity),
|
|
2146
|
+
groups: [id]
|
|
2147
|
+
}));
|
|
1504
2148
|
}
|
|
1505
2149
|
// registry/new-york/ui/scatter-chart-3d.tsx
|
|
1506
2150
|
/**
|
|
1507
2151
|
* Points in the chart box, at the fields `x`, `y` and `z` of each entry.
|
|
1508
2152
|
* `series` names the field whose value is the config key of the color.
|
|
1509
2153
|
*/
|
|
1510
|
-
function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
|
|
2154
|
+
function ScatterChart3D({ data, x, y, z, id, series, radius = 4, format, titles }) {
|
|
1511
2155
|
const { config, camera } = useChart3D();
|
|
1512
2156
|
const fields = {
|
|
1513
2157
|
x,
|
|
1514
2158
|
y,
|
|
1515
2159
|
z
|
|
1516
2160
|
};
|
|
1517
|
-
const
|
|
1518
|
-
const read = (
|
|
2161
|
+
const names = data.map((d, i) => id ? String(d[id]) : String(i));
|
|
2162
|
+
const { radii, opacity, read, key } = usePointTweens(names, data, fields, series, config, radius);
|
|
1519
2163
|
const spec = useTweenAxes(pointAxes(data, fields));
|
|
1520
2164
|
const world = worldBox(spec);
|
|
1521
|
-
const
|
|
1522
|
-
const
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
].map((a) => ({
|
|
1542
|
-
label: titles?.[a] ?? fields[a],
|
|
1543
|
-
value: read(p.i, a)
|
|
1544
|
-
}))
|
|
1545
|
-
}
|
|
1546
|
-
};
|
|
1547
|
-
});
|
|
2165
|
+
const placed = usePointPlaces(names, radii, read, world);
|
|
2166
|
+
const marks = points3D().camera(camera).x((p) => p.x).y((p) => p.y).z((p) => p.z).data(placed).sort(sort).map((p) => ({
|
|
2167
|
+
key: p.name,
|
|
2168
|
+
points: [p.projected],
|
|
2169
|
+
r: radii[p.name],
|
|
2170
|
+
fill: faded(`var(--color-${key(p.name)})`, opacity[p.name]),
|
|
2171
|
+
groups: [p.name],
|
|
2172
|
+
stroke: faded("var(--background)", opacity[p.name]),
|
|
2173
|
+
tooltip: {
|
|
2174
|
+
title: series ? config[key(p.name)]?.label ?? key(p.name) : void 0,
|
|
2175
|
+
items: [
|
|
2176
|
+
"x",
|
|
2177
|
+
"y",
|
|
2178
|
+
"z"
|
|
2179
|
+
].map((a) => ({
|
|
2180
|
+
label: titles?.[a] ?? fields[a],
|
|
2181
|
+
value: read(p.name, a)
|
|
2182
|
+
}))
|
|
2183
|
+
}
|
|
2184
|
+
}));
|
|
1548
2185
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1549
2186
|
spec,
|
|
1550
2187
|
world,
|
|
@@ -1564,13 +2201,74 @@ function pointAxes(data, fields) {
|
|
|
1564
2201
|
z: range("z")
|
|
1565
2202
|
};
|
|
1566
2203
|
}
|
|
1567
|
-
/**
|
|
1568
|
-
|
|
2204
|
+
/**
|
|
2205
|
+
* The tweened numbers of the chart, by the name of each entry: the positions
|
|
2206
|
+
* glide entry by entry - a gone point stands in place - and the radius and
|
|
2207
|
+
* the opacity enter from 0, so a new point grows as it fades in and a gone
|
|
2208
|
+
* one shrinks as it fades away. `read` and `key` look them up, also for a
|
|
2209
|
+
* point still leaving.
|
|
2210
|
+
*/
|
|
2211
|
+
function usePointTweens(names, data, fields, series, config, radius) {
|
|
2212
|
+
const values = useTween(pointValues(names, data, fields));
|
|
2213
|
+
const radii = useTween(Object.fromEntries(names.map((name) => [name, radius])), 0);
|
|
2214
|
+
const opacity = useTween(Object.fromEntries(names.map((name) => [name, 1])), 0);
|
|
2215
|
+
const fallback = Object.keys(config)[0];
|
|
2216
|
+
return {
|
|
2217
|
+
radii,
|
|
2218
|
+
opacity,
|
|
2219
|
+
read: (name, axis) => values[`${axis}${name}`],
|
|
2220
|
+
key: usePointKeys(names, data, series, fallback, radii)
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* The place of every point in the world box, by name. A living point follows
|
|
2225
|
+
* its gliding numbers through the gliding axes; a gone one stays frozen where
|
|
2226
|
+
* it was, so it shrinks away in place instead of riding the rescaling axes
|
|
2227
|
+
* out of the box. A point only one of the tweens still knows stays unseen.
|
|
2228
|
+
*/
|
|
2229
|
+
function usePointPlaces(names, alive, read, world) {
|
|
2230
|
+
const places = React.useRef(/* @__PURE__ */ new Map());
|
|
2231
|
+
const live = new Set(names);
|
|
2232
|
+
const placed = Object.keys(alive).flatMap((name) => {
|
|
2233
|
+
if (!live.has(name)) {
|
|
2234
|
+
const held = places.current.get(name);
|
|
2235
|
+
return held ? [{
|
|
2236
|
+
name,
|
|
2237
|
+
...held
|
|
2238
|
+
}] : [];
|
|
2239
|
+
}
|
|
2240
|
+
const p = {
|
|
2241
|
+
x: world.x(read(name, "x")),
|
|
2242
|
+
y: world.y(read(name, "y")),
|
|
2243
|
+
z: world.z(read(name, "z"))
|
|
2244
|
+
};
|
|
2245
|
+
places.current.set(name, p);
|
|
2246
|
+
return [{
|
|
2247
|
+
name,
|
|
2248
|
+
...p
|
|
2249
|
+
}];
|
|
2250
|
+
});
|
|
2251
|
+
for (const name of places.current.keys()) if (!(name in alive)) places.current.delete(name);
|
|
2252
|
+
return placed;
|
|
2253
|
+
}
|
|
2254
|
+
/** The position of every entry, keyed by its axis and name, gliding into new data. */
|
|
2255
|
+
function pointValues(names, data, fields) {
|
|
1569
2256
|
return Object.fromEntries(data.flatMap((d, i) => [
|
|
1570
2257
|
"x",
|
|
1571
2258
|
"y",
|
|
1572
2259
|
"z"
|
|
1573
|
-
].map((a) => [`${a}${i}`, Number(d[fields[a]])])));
|
|
2260
|
+
].map((a) => [`${a}${names[i]}`, Number(d[fields[a]])])));
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* The config key of every point's color, by name - remembered while a gone
|
|
2264
|
+
* point shrinks away, since its entry has already left the data - and
|
|
2265
|
+
* forgotten with the point, so the memory stays the size of the chart.
|
|
2266
|
+
*/
|
|
2267
|
+
function usePointKeys(names, data, series, fallback, alive) {
|
|
2268
|
+
const known = React.useRef(/* @__PURE__ */ new Map());
|
|
2269
|
+
data.forEach((d, i) => known.current.set(names[i], series ? String(d[series]) : fallback));
|
|
2270
|
+
for (const name of known.current.keys()) if (!(name in alive) && !names.includes(name)) known.current.delete(name);
|
|
2271
|
+
return (name) => known.current.get(name) ?? fallback;
|
|
1574
2272
|
}
|
|
1575
2273
|
// registry/new-york/ui/surface-chart-3d.tsx
|
|
1576
2274
|
/**
|
|
@@ -1582,8 +2280,10 @@ function pointValues(data, fields) {
|
|
|
1582
2280
|
function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
1583
2281
|
const { config, camera } = useChart3D();
|
|
1584
2282
|
const [low, high = low] = colors ?? Object.keys(config);
|
|
1585
|
-
const
|
|
1586
|
-
const
|
|
2283
|
+
const [floor = 0, peak = 0] = extent(data.flat());
|
|
2284
|
+
const target = gridValues(data);
|
|
2285
|
+
const heights = gridHeights(useTween(target), floor);
|
|
2286
|
+
const fade = useTween(Object.fromEntries(Object.keys(target).map((k) => [k, 1])), 0);
|
|
1587
2287
|
const rows = data.length;
|
|
1588
2288
|
const columns = data[0]?.length ?? 0;
|
|
1589
2289
|
const [x0, x1] = domain.x ?? [0, columns - 1];
|
|
@@ -1593,7 +2293,6 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
1593
2293
|
z: (z1 - z0) / Math.max(1, rows - 1)
|
|
1594
2294
|
};
|
|
1595
2295
|
const [lo = 0, hi = 0] = extent(heights.flat());
|
|
1596
|
-
const [floor = 0, peak = 0] = extent(data.flat());
|
|
1597
2296
|
const spec = useTweenAxes({
|
|
1598
2297
|
x: domain.x ? axis(x0, x1) : pad(axis(x0, x1), cell.x),
|
|
1599
2298
|
y: axis(floor, peak),
|
|
@@ -1602,28 +2301,20 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
1602
2301
|
const world = worldBox(spec);
|
|
1603
2302
|
const toX = linear([0, columns - 1], [x0, x1]);
|
|
1604
2303
|
const toZ = linear([0, rows - 1], [z0, z1]);
|
|
1605
|
-
const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1,
|
|
2304
|
+
const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1, heights[0]?.length ?? 0));
|
|
1606
2305
|
const points = heights.flatMap((row, r) => row.map((y, c) => ({
|
|
1607
2306
|
x: toX(c),
|
|
1608
2307
|
y,
|
|
1609
|
-
z: toZ(r)
|
|
2308
|
+
z: toZ(r),
|
|
2309
|
+
r,
|
|
2310
|
+
c
|
|
1610
2311
|
})));
|
|
1611
|
-
const marks = grid.data(points).sort(sort).map((quad) => {
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
fill: shade(quad, `color-mix(in oklch, var(--color-${high}) ${t}%, var(--color-${low}))`),
|
|
1618
|
-
tooltip: {
|
|
1619
|
-
at: center(quad),
|
|
1620
|
-
items: [{
|
|
1621
|
-
label: titles?.y ?? "value",
|
|
1622
|
-
value: height
|
|
1623
|
-
}]
|
|
1624
|
-
}
|
|
1625
|
-
};
|
|
1626
|
-
});
|
|
2312
|
+
const marks = grid.data(points).sort(sort).map((quad) => quadMark(quad, fade, {
|
|
2313
|
+
lo,
|
|
2314
|
+
hi,
|
|
2315
|
+
low,
|
|
2316
|
+
high
|
|
2317
|
+
}, titles?.y));
|
|
1627
2318
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1628
2319
|
spec,
|
|
1629
2320
|
world,
|
|
@@ -1631,460 +2322,45 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
1631
2322
|
titles
|
|
1632
2323
|
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
1633
2324
|
}
|
|
1634
|
-
/** The height of every cell, keyed by its row and column, gliding into new data. */
|
|
1635
|
-
function gridValues(data) {
|
|
1636
|
-
return Object.fromEntries(data.flatMap((row, r) => row.map((y, c) => [`${r}-${c}`, y])));
|
|
1637
|
-
}
|
|
1638
|
-
// registry/new-york/ui/sankey-chart-3d.tsx
|
|
1639
|
-
const DEPTH = .4;
|
|
1640
|
-
const RIBBON = .7;
|
|
1641
|
-
const SEGMENTS = 16;
|
|
1642
|
-
const STEEPEST = 1.5;
|
|
1643
|
-
const SWING = 8;
|
|
1644
|
-
/**
|
|
1645
|
-
* Flows from left to right: every node a box in its column, as high as the
|
|
1646
|
-
* larger of what flows in and what flows out, and every flow a band from node
|
|
1647
|
-
* to node, as thick as its value, that turns from the color of its source to
|
|
1648
|
-
* the color of its target.
|
|
1649
|
-
*/
|
|
1650
|
-
function SankeyChart3D({ data, nodeWidth = .2, nodePadding = .05, format = (value) => value }) {
|
|
1651
|
-
const { config, camera } = useChart3D();
|
|
1652
|
-
const values = useTween(Object.fromEntries(data.map((d, i) => [String(i), d.value])), 0);
|
|
1653
|
-
const { nodes, columns, ky } = sankeyLayout(data.map((d, i) => ({
|
|
1654
|
-
...d,
|
|
1655
|
-
value: values[String(i)]
|
|
1656
|
-
})), nodePadding);
|
|
1657
|
-
const pieces = depthSort(cubes3D().camera(camera).data(sankeyPieces(nodes, columns, ky, nodeWidth)), camera);
|
|
1658
|
-
const labels = pieces.flatMap(({ key, faces, node }) => node ? [nodeLabel(key, faces, node.column < (columns - 1) / 2 ? "left" : "right", style(config, node).label, links(node))] : []);
|
|
1659
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Marks3D, { marks: pieces.flatMap((piece) => pieceMarks(piece, config, format)) }), /* @__PURE__ */ jsx(Labels3D, { labels })] });
|
|
1660
|
-
}
|
|
1661
|
-
/** The label and the color of a node, from the chart config. */
|
|
1662
|
-
function style(config, node) {
|
|
1663
|
-
return {
|
|
1664
|
-
label: config[node.id]?.label ?? node.id,
|
|
1665
|
-
color: config[node.id] ? `var(--color-${node.id})` : "var(--muted-foreground)"
|
|
1666
|
-
};
|
|
1667
|
-
}
|
|
1668
|
-
/**
|
|
1669
|
-
* The keys of the flows at a node. Its marks and its label carry them, so the
|
|
1670
|
-
* focus on the node lights its flows and the nodes at their other ends, and
|
|
1671
|
-
* the focus on a flow lights its two nodes.
|
|
1672
|
-
*/
|
|
1673
|
-
function links(node) {
|
|
1674
|
-
return [...new Set([...node.incoming, ...node.outgoing].map((hop) => hop.flow.key))];
|
|
1675
|
-
}
|
|
1676
|
-
/**
|
|
1677
|
-
* The marks of a piece, each with its tooltip and its groups: the faces of a
|
|
1678
|
-
* node, or the faces of a slice of a flow, in the color it has turned to there.
|
|
1679
|
-
*/
|
|
1680
|
-
function pieceMarks({ key, faces, node, flow, t }, config, format) {
|
|
1681
|
-
const at = center(faces.find((face) => face.face === "top"));
|
|
1682
|
-
if (node) {
|
|
1683
|
-
const { label, color } = style(config, node);
|
|
1684
|
-
const groups = links(node);
|
|
1685
|
-
return cubeMarks(key, faces, color, {
|
|
1686
|
-
at,
|
|
1687
|
-
items: [{
|
|
1688
|
-
label,
|
|
1689
|
-
value: format(node.value),
|
|
1690
|
-
color
|
|
1691
|
-
}]
|
|
1692
|
-
}).map((mark) => ({
|
|
1693
|
-
...mark,
|
|
1694
|
-
groups
|
|
1695
|
-
}));
|
|
1696
|
-
}
|
|
1697
|
-
const source = style(config, flow.source);
|
|
1698
|
-
const target = style(config, flow.target);
|
|
1699
|
-
const item = {
|
|
1700
|
-
label: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1701
|
-
source.label,
|
|
1702
|
-
" → ",
|
|
1703
|
-
target.label
|
|
1704
|
-
] }),
|
|
1705
|
-
value: format(flow.value),
|
|
1706
|
-
color: source.color
|
|
1707
|
-
};
|
|
1708
|
-
return cubeMarks(key, faces.filter((face) => face.face !== "left" && face.face !== "right"), `color-mix(in oklch, color-mix(in oklch, ${target.color} ${t}%, ${source.color}) 75%, var(--background))`, {
|
|
1709
|
-
at,
|
|
1710
|
-
items: [item]
|
|
1711
|
-
}).map((mark) => ({
|
|
1712
|
-
...mark,
|
|
1713
|
-
groups: [flow.key]
|
|
1714
|
-
}));
|
|
1715
|
-
}
|
|
1716
|
-
/** The nodes of `data`, waypoints included, stacked in their columns, and `ky`, the world units per value. */
|
|
1717
|
-
function sankeyLayout(data, nodePadding) {
|
|
1718
|
-
const { nodes, flows, columns } = sankeyColumns(data);
|
|
1719
|
-
const all = sankeyHops(nodes, flows);
|
|
1720
|
-
const ky = stackColumns(all, columns, nodePadding);
|
|
1721
|
-
stackHops(all, ky);
|
|
1722
|
-
weaveHops(all, columns, ky);
|
|
1723
|
-
return {
|
|
1724
|
-
nodes: all,
|
|
1725
|
-
columns,
|
|
1726
|
-
ky
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
function newNode(id, column, flow) {
|
|
1730
|
-
return {
|
|
1731
|
-
id,
|
|
1732
|
-
column,
|
|
1733
|
-
value: 0,
|
|
1734
|
-
y0: 0,
|
|
1735
|
-
y1: 0,
|
|
1736
|
-
incoming: [],
|
|
1737
|
-
outgoing: [],
|
|
1738
|
-
flow
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1741
|
-
function middle(node) {
|
|
1742
|
-
return (node.y0 + node.y1) / 2;
|
|
1743
|
-
}
|
|
1744
2325
|
/**
|
|
1745
|
-
* The
|
|
1746
|
-
*
|
|
1747
|
-
* path to it from a source; the sinks move to the last column.
|
|
2326
|
+
* The mark of one cell: its quad, shaded by the color ramp at its height and
|
|
2327
|
+
* faded in place by `fade` while the cell enters or leaves.
|
|
1748
2328
|
*/
|
|
1749
|
-
function
|
|
1750
|
-
const
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
};
|
|
1755
|
-
const seen = /* @__PURE__ */ new Map();
|
|
1756
|
-
const flows = data.filter((d) => d.value > 0).map((d) => {
|
|
1757
|
-
const ends = JSON.stringify([d.source, d.target]);
|
|
1758
|
-
const count = seen.get(ends) ?? 0;
|
|
1759
|
-
seen.set(ends, count + 1);
|
|
1760
|
-
return {
|
|
1761
|
-
key: count ? `${ends}#${count}` : ends,
|
|
1762
|
-
source: node(d.source),
|
|
1763
|
-
target: node(d.target),
|
|
1764
|
-
value: d.value
|
|
1765
|
-
};
|
|
1766
|
-
});
|
|
1767
|
-
for (let pass = 0; flows.some((f) => f.target.column <= f.source.column); pass += 1) {
|
|
1768
|
-
if (pass === nodes.size) throw new Error("SankeyChart3D: the flows form a cycle");
|
|
1769
|
-
for (const f of flows) f.target.column = Math.max(f.target.column, f.source.column + 1);
|
|
1770
|
-
}
|
|
1771
|
-
const last = Math.max(0, ...flows.map((f) => f.target.column));
|
|
1772
|
-
const sources = new Set(flows.map((f) => f.source));
|
|
1773
|
-
for (const n of nodes.values()) if (!sources.has(n)) n.column = last;
|
|
2329
|
+
function quadMark(quad, fade, ramp, title) {
|
|
2330
|
+
const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
|
|
2331
|
+
const t = Math.round((height - ramp.lo) / (ramp.hi - ramp.lo || 1) * 100);
|
|
2332
|
+
const shaded = shade(quad, `color-mix(in oklch, var(--color-${ramp.high}) ${t}%, var(--color-${ramp.low}))`);
|
|
2333
|
+
const seen = Math.min(...quad.map((p) => fade[`${p.r}-${p.c}`] ?? 0));
|
|
1774
2334
|
return {
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
2335
|
+
key: quad.plane,
|
|
2336
|
+
points: quad.map((p) => p.projected),
|
|
2337
|
+
fill: seen < 1 ? `color-mix(in srgb, ${shaded} ${Math.round(seen * 100)}%, transparent)` : shaded,
|
|
2338
|
+
groups: [quad.plane],
|
|
2339
|
+
tooltip: { items: [{
|
|
2340
|
+
label: title ?? "value",
|
|
2341
|
+
value: height
|
|
2342
|
+
}] }
|
|
1778
2343
|
};
|
|
1779
2344
|
}
|
|
1780
|
-
/**
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
* column it crosses and never runs through another node.
|
|
1784
|
-
*/
|
|
1785
|
-
function sankeyHops(nodes, flows) {
|
|
1786
|
-
const all = [...nodes];
|
|
1787
|
-
for (const flow of flows) {
|
|
1788
|
-
let source = flow.source;
|
|
1789
|
-
for (let column = source.column + 1; column <= flow.target.column; column += 1) {
|
|
1790
|
-
const target = column < flow.target.column ? newNode(`${flow.key}:${column}`, column, flow) : flow.target;
|
|
1791
|
-
if (target.flow) all.push(target);
|
|
1792
|
-
const hop = {
|
|
1793
|
-
flow,
|
|
1794
|
-
source,
|
|
1795
|
-
target,
|
|
1796
|
-
y0: 0,
|
|
1797
|
-
y1: 0,
|
|
1798
|
-
z: 0
|
|
1799
|
-
};
|
|
1800
|
-
source.outgoing.push(hop);
|
|
1801
|
-
target.incoming.push(hop);
|
|
1802
|
-
source = target;
|
|
1803
|
-
}
|
|
1804
|
-
}
|
|
1805
|
-
return all;
|
|
1806
|
-
}
|
|
1807
|
-
/**
|
|
1808
|
-
* Sizes the nodes to the largest scale at which every column fits, and stacks
|
|
1809
|
-
* each column from the top, centered. A column after the first orders its nodes
|
|
1810
|
-
* by the mean height of their sources, which uncrosses most flows. Returns the
|
|
1811
|
-
* scale, world units per value.
|
|
1812
|
-
*/
|
|
1813
|
-
function stackColumns(nodes, columns, nodePadding) {
|
|
1814
|
-
const total = (hops) => hops.reduce((sum, hop) => sum + hop.flow.value, 0);
|
|
1815
|
-
for (const node of nodes) node.value = Math.max(total(node.incoming), total(node.outgoing));
|
|
1816
|
-
const stacks = Array.from({ length: columns }, (_, c) => nodes.filter((n) => n.column === c));
|
|
1817
|
-
const sum = (stack) => stack.reduce((s, n) => s + n.value, 0);
|
|
1818
|
-
const pad = Math.min(2 * nodePadding, 2 / Math.max(1, ...stacks.map((s) => s.length)));
|
|
1819
|
-
const ky = Math.min(...stacks.map((s) => (2 - pad * (s.length - 1)) / sum(s)));
|
|
1820
|
-
const mean = (node) => node.incoming.reduce((s, hop) => s + hop.flow.value * middle(hop.source), 0) / total(node.incoming);
|
|
1821
|
-
for (const [c, stack] of stacks.entries()) {
|
|
1822
|
-
if (c > 0) stack.sort((a, b) => mean(a) - mean(b));
|
|
1823
|
-
let y = -(sum(stack) * ky + pad * (stack.length - 1)) / 2;
|
|
1824
|
-
for (const node of stack) {
|
|
1825
|
-
node.y0 = y;
|
|
1826
|
-
node.y1 = y + node.value * ky;
|
|
1827
|
-
y = node.y1 + pad;
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
return ky;
|
|
1831
|
-
}
|
|
1832
|
-
/**
|
|
1833
|
-
* Stacks the hops at every node from its top: the ones that leave it in the
|
|
1834
|
-
* order of their targets, the ones that arrive in the order of their sources,
|
|
1835
|
-
* so that the hops of a node do not cross.
|
|
1836
|
-
*/
|
|
1837
|
-
function stackHops(nodes, ky) {
|
|
1838
|
-
for (const node of nodes) {
|
|
1839
|
-
let y = node.y0;
|
|
1840
|
-
for (const hop of node.outgoing.sort((a, b) => middle(a.target) - middle(b.target))) {
|
|
1841
|
-
hop.y0 = y + hop.flow.value * ky / 2;
|
|
1842
|
-
y += hop.flow.value * ky;
|
|
1843
|
-
}
|
|
1844
|
-
y = node.y0;
|
|
1845
|
-
for (const hop of node.incoming.sort((a, b) => middle(a.source) - middle(b.source))) {
|
|
1846
|
-
hop.y1 = y + hop.flow.value * ky / 2;
|
|
1847
|
-
y += hop.flow.value * ky;
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
/**
|
|
1852
|
-
* Parts the hops that cross, gap by gap. The hops of a gap that cross, directly
|
|
1853
|
-
* or through one another, form a knot. The knots of a gap lie one above the
|
|
1854
|
-
* other across the whole gap; only the hops within a knot share their depth in lanes.
|
|
1855
|
-
*/
|
|
1856
|
-
function weaveHops(nodes, columns, ky) {
|
|
1857
|
-
for (let column = 0; column < columns - 1; column += 1) {
|
|
1858
|
-
const hops = nodes.filter((node) => node.column === column).flatMap((node) => node.outgoing).sort((a, b) => a.y0 - b.y0);
|
|
1859
|
-
const rank = new Map([...hops].sort((a, b) => a.y1 - b.y1).map((hop, i) => [hop, i]));
|
|
1860
|
-
let start = 0;
|
|
1861
|
-
let reach = 0;
|
|
1862
|
-
hops.forEach((hop, i) => {
|
|
1863
|
-
reach = Math.max(reach, rank.get(hop));
|
|
1864
|
-
if (reach > i) return;
|
|
1865
|
-
if (i > start) knotHops(hops.slice(start, i + 1), ky);
|
|
1866
|
-
start = i + 1;
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
2345
|
+
/** The height of every cell, keyed by its row and column, gliding into new data. */
|
|
2346
|
+
function gridValues(data) {
|
|
2347
|
+
return Object.fromEntries(data.flatMap((row, r) => row.map((y, c) => [`${r}-${c}`, y])));
|
|
1869
2348
|
}
|
|
1870
2349
|
/**
|
|
1871
|
-
* The
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1874
|
-
* out of it. The hops hold their lanes over the slices where any two of them
|
|
1875
|
-
* overlap, so their depth is constant there, and leave the rest for the way in
|
|
1876
|
-
* and out.
|
|
2350
|
+
* The gliding heights as a lattice. It spans the cells still leaving too, so
|
|
2351
|
+
* a shrunken grid sinks away instead of popping; a corner only one dimension
|
|
2352
|
+
* kept stands at the floor until it goes.
|
|
1877
2353
|
*/
|
|
1878
|
-
function
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
}
|
|
1886
|
-
const count = Math.max(...lanes) + 1;
|
|
1887
|
-
const load = (lane) => hops.reduce((sum, hop, i) => sum + (lanes[i] === lane ? hop.flow.value : 0), 0);
|
|
1888
|
-
const front = Array.from({ length: count }, (_, lane) => lane).sort((a, b) => load(a) - load(b));
|
|
1889
|
-
const slices = hops.flatMap((a, i) => hops.slice(i + 1).flatMap((b) => a.y1 > b.y1 ? overlaps(a, b, ky) : []));
|
|
1890
|
-
const ribbon = RIBBON * DEPTH;
|
|
1891
|
-
const depth = 3 * ribbon / (4 * count - 1);
|
|
1892
|
-
const knot = {
|
|
1893
|
-
from: Math.min(...slices),
|
|
1894
|
-
to: Math.max(...slices) + 1,
|
|
1895
|
-
first: hops[0],
|
|
1896
|
-
depth
|
|
1897
|
-
};
|
|
1898
|
-
for (const [i, hop] of hops.entries()) {
|
|
1899
|
-
hop.z = (ribbon - depth) / 2 - front.indexOf(lanes[i]) * 4 * depth / 3;
|
|
1900
|
-
hop.knot = knot;
|
|
2354
|
+
function gridHeights(tweened, floor) {
|
|
2355
|
+
let rows = 0;
|
|
2356
|
+
let columns = 0;
|
|
2357
|
+
for (const key of Object.keys(tweened)) {
|
|
2358
|
+
const [r, c] = key.split("-").map(Number);
|
|
2359
|
+
rows = Math.max(rows, r + 1);
|
|
2360
|
+
columns = Math.max(columns, c + 1);
|
|
1901
2361
|
}
|
|
2362
|
+
return Array.from({ length: rows }, (_, r) => Array.from({ length: columns }, (_, c) => tweened[`${r}-${c}`] ?? floor));
|
|
1902
2363
|
}
|
|
1903
|
-
|
|
1904
|
-
* The slices in which two hops, `a` and `b`, overlap in height. Along a slice
|
|
1905
|
-
* their distance runs straight from one cross-section to the next, so they
|
|
1906
|
-
* overlap in it if they are closer than half their thicknesses at one of its
|
|
1907
|
-
* ends, or pass each other within it.
|
|
1908
|
-
*/
|
|
1909
|
-
function overlaps(a, b, ky) {
|
|
1910
|
-
const apart = (k) => a.y0 - b.y0 + (a.y1 - b.y1 - (a.y0 - b.y0)) * ease(k / SEGMENTS);
|
|
1911
|
-
const near = (a.flow.value + b.flow.value) * ky / 2;
|
|
1912
|
-
return Array.from({ length: SEGMENTS }, (_, k) => k).filter((k) => Math.abs(apart(k)) < near || Math.abs(apart(k + 1)) < near || Math.sign(apart(k)) !== Math.sign(apart(k + 1)));
|
|
1913
|
-
}
|
|
1914
|
-
/** The S-curve of a flow, from 0 at t = 0 to 1 at t = 1, level at both ends. */
|
|
1915
|
-
function ease(t) {
|
|
1916
|
-
return t * t * (3 - 2 * t);
|
|
1917
|
-
}
|
|
1918
|
-
/**
|
|
1919
|
-
* How far a hop of `knot` has moved into its lane at the cross-section `k`:
|
|
1920
|
-
* not at all at a node, fully between the borders of the knot, and along the
|
|
1921
|
-
* S-curve in between. A knot that starts at a node starts there in its lanes.
|
|
1922
|
-
*/
|
|
1923
|
-
function bump(k, knot) {
|
|
1924
|
-
if (!knot) return 0;
|
|
1925
|
-
if (k < knot.from) return ease(k / knot.from);
|
|
1926
|
-
if (k > knot.to) return ease((SEGMENTS - k) / (SEGMENTS - knot.to));
|
|
1927
|
-
return 1;
|
|
1928
|
-
}
|
|
1929
|
-
/**
|
|
1930
|
-
* The pieces of the chart: a box per node, a flat slice of its flow per
|
|
1931
|
-
* waypoint and `SEGMENTS` slices per hop. The columns stand from x = -1 to 1,
|
|
1932
|
-
* a node the share `nodeWidth` of the distance between two of them wide.
|
|
1933
|
-
*/
|
|
1934
|
-
function sankeyPieces(nodes, columns, ky, nodeWidth) {
|
|
1935
|
-
const step = 2 / Math.max(1, columns - 1);
|
|
1936
|
-
const x = (column) => -1 + column * step;
|
|
1937
|
-
const half = nodeWidth * step / 2;
|
|
1938
|
-
const share = (flow, at) => {
|
|
1939
|
-
const from = x(flow.source.column) + half;
|
|
1940
|
-
return Math.round((at - from) / (x(flow.target.column) - half - from) * 100);
|
|
1941
|
-
};
|
|
1942
|
-
return nodes.flatMap((node) => {
|
|
1943
|
-
const band = x(node.column);
|
|
1944
|
-
const y = middle(node);
|
|
1945
|
-
const own = node.flow ? {
|
|
1946
|
-
key: `way:${node.id}`,
|
|
1947
|
-
band,
|
|
1948
|
-
flow: node.flow,
|
|
1949
|
-
t: share(node.flow, band)
|
|
1950
|
-
} : {
|
|
1951
|
-
key: `node:${node.id}`,
|
|
1952
|
-
band,
|
|
1953
|
-
node,
|
|
1954
|
-
t: 0
|
|
1955
|
-
};
|
|
1956
|
-
const d = node.flow ? RIBBON * DEPTH : DEPTH;
|
|
1957
|
-
const box = slab({
|
|
1958
|
-
x: band - half,
|
|
1959
|
-
y,
|
|
1960
|
-
z: 0,
|
|
1961
|
-
d
|
|
1962
|
-
}, {
|
|
1963
|
-
x: band + half,
|
|
1964
|
-
y,
|
|
1965
|
-
z: 0,
|
|
1966
|
-
d
|
|
1967
|
-
}, node.y1 - node.y0);
|
|
1968
|
-
return [Object.assign(box, own, {
|
|
1969
|
-
row: y,
|
|
1970
|
-
lane: 0,
|
|
1971
|
-
level: y
|
|
1972
|
-
}), ...node.outgoing.flatMap((hop) => hopPieces(hop, band + half, x(node.column + 1) - half, ky, share))];
|
|
1973
|
-
});
|
|
1974
|
-
}
|
|
1975
|
-
/**
|
|
1976
|
-
* The slices of a hop from `x0` to `x1`, along an S-curve that leaves and
|
|
1977
|
-
* arrives level, so that the hops of a flow join without a kink. A hop of a
|
|
1978
|
-
* knot narrows into its lane and widens back along the same curve. Every hop
|
|
1979
|
-
* between the same two columns has the same slices.
|
|
1980
|
-
*/
|
|
1981
|
-
function hopPieces(hop, x0, x1, ky, share) {
|
|
1982
|
-
const ribbon = RIBBON * DEPTH;
|
|
1983
|
-
const gentle = (slices, depth) => (ribbon - depth) * 1.5 / (slices * (x1 - x0) / SEGMENTS) <= STEEPEST;
|
|
1984
|
-
const knot = hop.knot && {
|
|
1985
|
-
...hop.knot,
|
|
1986
|
-
from: gentle(hop.knot.from, hop.knot.depth) ? hop.knot.from : 0,
|
|
1987
|
-
to: gentle(SEGMENTS - hop.knot.to, hop.knot.depth) ? hop.knot.to : SEGMENTS
|
|
1988
|
-
};
|
|
1989
|
-
const at = (of, k) => {
|
|
1990
|
-
const into = bump(k, knot);
|
|
1991
|
-
return {
|
|
1992
|
-
x: x0 + (x1 - x0) * k / SEGMENTS,
|
|
1993
|
-
y: of.y0 + (of.y1 - of.y0) * ease(k / SEGMENTS),
|
|
1994
|
-
z: of.z * into,
|
|
1995
|
-
d: ribbon + ((knot?.depth ?? ribbon) - ribbon) * into
|
|
1996
|
-
};
|
|
1997
|
-
};
|
|
1998
|
-
return Array.from({ length: SEGMENTS }, (_, k) => {
|
|
1999
|
-
const [from, to] = [at(hop, k), at(hop, k + 1)];
|
|
2000
|
-
const first = knot ? [at(knot.first, k), at(knot.first, k + 1)] : [from, to];
|
|
2001
|
-
const band = (from.x + to.x) / 2;
|
|
2002
|
-
return Object.assign(slab(from, to, hop.flow.value * ky), {
|
|
2003
|
-
key: `hop:${hop.flow.key}:${hop.source.column}:${k}`,
|
|
2004
|
-
band,
|
|
2005
|
-
flow: hop.flow,
|
|
2006
|
-
t: share(hop.flow, band),
|
|
2007
|
-
row: (first[0].y + first[1].y) / 2,
|
|
2008
|
-
lane: knot && k >= knot.from && k < knot.to ? hop.z : 0,
|
|
2009
|
-
level: (from.y + to.y) / 2
|
|
2010
|
-
});
|
|
2011
|
-
});
|
|
2012
|
-
}
|
|
2013
|
-
/**
|
|
2014
|
-
* The corners of a slab from the cross-section around `from` to the one around
|
|
2015
|
-
* `to`, `height` high and each `d` deep, in the order of box(). Two rectangles
|
|
2016
|
-
* of one height keep the faces planar and the slab convex.
|
|
2017
|
-
*/
|
|
2018
|
-
function slab(from, to, height) {
|
|
2019
|
-
return [1, -1].flatMap((side) => [
|
|
2020
|
-
{
|
|
2021
|
-
x: from.x,
|
|
2022
|
-
y: from.y - height / 2,
|
|
2023
|
-
z: from.z + side * from.d / 2
|
|
2024
|
-
},
|
|
2025
|
-
{
|
|
2026
|
-
x: from.x,
|
|
2027
|
-
y: from.y + height / 2,
|
|
2028
|
-
z: from.z + side * from.d / 2
|
|
2029
|
-
},
|
|
2030
|
-
{
|
|
2031
|
-
x: to.x,
|
|
2032
|
-
y: to.y + height / 2,
|
|
2033
|
-
z: to.z + side * to.d / 2
|
|
2034
|
-
},
|
|
2035
|
-
{
|
|
2036
|
-
x: to.x,
|
|
2037
|
-
y: to.y - height / 2,
|
|
2038
|
-
z: to.z + side * to.d / 2
|
|
2039
|
-
}
|
|
2040
|
-
]);
|
|
2041
|
-
}
|
|
2042
|
-
/**
|
|
2043
|
-
* The pieces in the order to paint them, by the first plane that parts two of
|
|
2044
|
-
* them: the slabs of the columns and slices lie between planes x = const; the
|
|
2045
|
-
* knots of a slice, one above the other; the lanes of a knot, where its hops
|
|
2046
|
-
* hold them, one behind the other; and the hops of a lane, or of a knot
|
|
2047
|
-
* outside its borders, which do not overlap there, one above the other. Each
|
|
2048
|
-
* key paints the part farther from the eye first, so the order is exact for
|
|
2049
|
-
* every eye, and keys of numbers cannot form a cycle.
|
|
2050
|
-
*/
|
|
2051
|
-
function depthSort(pieces, camera) {
|
|
2052
|
-
const view = camera.viewDirection();
|
|
2053
|
-
const perspective = camera.perspective();
|
|
2054
|
-
const far = (at, v) => {
|
|
2055
|
-
const eye = v * perspective;
|
|
2056
|
-
return Number.isFinite(eye) ? Math.abs(at - eye) : -at * Math.sign(v);
|
|
2057
|
-
};
|
|
2058
|
-
return pieces.sort((a, b) => far(b.band, view.x) - far(a.band, view.x) || far(b.row, view.y) - far(a.row, view.y) || far(b.lane, view.z) - far(a.lane, view.z) || far(b.level, view.y) - far(a.level, view.y) || sort(a, b));
|
|
2059
|
-
}
|
|
2060
|
-
/**
|
|
2061
|
-
* The label of a node beside its `outer` face, pushed away from the other side,
|
|
2062
|
-
* so that it stays outside whichever way the camera turns. Seen edge on, where
|
|
2063
|
-
* the two sides meet and away has no direction, the label lifts over the node,
|
|
2064
|
-
* so it swings round to the other side instead of jumping across. It carries
|
|
2065
|
-
* the groups of the node, so it fades with the node.
|
|
2066
|
-
*/
|
|
2067
|
-
function nodeLabel(key, faces, outer, text, groups) {
|
|
2068
|
-
const side = (name) => center(faces.find((face) => face.face === name));
|
|
2069
|
-
const at = side(outer);
|
|
2070
|
-
const away = at.x - side(outer === "left" ? "right" : "left").x;
|
|
2071
|
-
const lift = Math.max(0, SWING - Math.abs(away));
|
|
2072
|
-
const length = Math.hypot(away, lift);
|
|
2073
|
-
const toward = {
|
|
2074
|
-
x: away / length,
|
|
2075
|
-
y: -lift / length
|
|
2076
|
-
};
|
|
2077
|
-
return {
|
|
2078
|
-
key,
|
|
2079
|
-
at: {
|
|
2080
|
-
x: at.x + 8 * toward.x,
|
|
2081
|
-
y: at.y + 8 * toward.y
|
|
2082
|
-
},
|
|
2083
|
-
text,
|
|
2084
|
-
toward,
|
|
2085
|
-
groups
|
|
2086
|
-
};
|
|
2087
|
-
}
|
|
2088
|
-
export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, intoWorld, linear, pad, shade, useChart3D, useTween, useTweenAxes, worldBox };
|
|
2364
|
+
export { AreaChart3D, Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis, cubeMarks, faded, intoWorld, linear, pad, shade, useChart3D, useSeries, useTween, useTweenAxes, worldBox };
|
|
2089
2365
|
|
|
2090
2366
|
//# sourceMappingURL=index.js.map
|