@niekes/charts-3d 0.4.0 → 0.5.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 +0 -1
- package/dist/index.d.ts +35 -44
- package/dist/index.js +693 -695
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -299,14 +318,14 @@ function keymapProps(keys, label) {
|
|
|
299
318
|
const help = keys.help[0];
|
|
300
319
|
const said = [
|
|
301
320
|
"3D chart.",
|
|
302
|
-
keys.on("dragRotate") &&
|
|
321
|
+
keys.on("dragRotate") && `${modLabel()} and drag to rotate it.`,
|
|
303
322
|
help && `Press ${aria(help)} for all shortcuts.`
|
|
304
323
|
];
|
|
305
324
|
return {
|
|
306
325
|
"aria-label": label ?? said.filter(Boolean).join(" "),
|
|
307
326
|
"aria-keyshortcuts": keys.aria || void 0,
|
|
308
327
|
tabIndex: keys.aria ? 0 : void 0,
|
|
309
|
-
className: cn("block h-auto w-full outline-hidden select-none mask-y-from-96%", drag && "
|
|
328
|
+
className: cn("block h-auto w-full outline-hidden select-none mask-x-from-96% mask-y-from-96%", drag && "touch-none")
|
|
310
329
|
};
|
|
311
330
|
}
|
|
312
331
|
/**
|
|
@@ -318,7 +337,7 @@ function useScene(requested, width, height) {
|
|
|
318
337
|
const [tooltip, setTooltip] = React.useState(null);
|
|
319
338
|
const focus = useFocus();
|
|
320
339
|
const renderer = useRenderer(requested);
|
|
321
|
-
const { canvas, repaint } = useCanvas(scene, width);
|
|
340
|
+
const { canvas, repaint } = useCanvas(scene, width, height, renderer);
|
|
322
341
|
const { hover, hide, leave } = useHover(scene, width, height, setTooltip, focus);
|
|
323
342
|
return {
|
|
324
343
|
scene,
|
|
@@ -387,8 +406,10 @@ function animates() {
|
|
|
387
406
|
/**
|
|
388
407
|
* Eases a chart into new data: the numbers of `values` glide from the ones
|
|
389
408
|
* 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
|
-
*
|
|
409
|
+
* that is new starts at `enter`, or where it ends; a key that is gone glides
|
|
410
|
+
* back to `enter`, the mirror of its entering - without one it stands where
|
|
411
|
+
* it is - and leaves when the glide is done. Without time, or with reduced
|
|
412
|
+
* motion, the numbers jump.
|
|
392
413
|
*/
|
|
393
414
|
function useTween(values, enter) {
|
|
394
415
|
const { animationDuration: duration = 600 } = useChart3D();
|
|
@@ -424,32 +445,65 @@ function useTween(values, enter) {
|
|
|
424
445
|
}, [tween.key, duration]);
|
|
425
446
|
return shown;
|
|
426
447
|
}
|
|
427
|
-
/** The numbers of `tween` on screen: `from` eased into `to`, a new key from `enter` or where it ends. */
|
|
448
|
+
/** 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
449
|
function tweenNumbers({ from, to, t }, enter) {
|
|
429
450
|
const s = t * t * (3 - 2 * t);
|
|
430
|
-
|
|
451
|
+
const gone = t >= 1 ? [] : Object.entries(from).filter(([k]) => !(k in to));
|
|
452
|
+
return Object.fromEntries([...Object.entries(to).map(([k, end]) => {
|
|
431
453
|
const start = from[k] ?? enter ?? end;
|
|
432
454
|
return [k, t >= 1 ? end : start + (end - start) * s];
|
|
433
|
-
}));
|
|
455
|
+
}), ...gone.map(([k, start]) => [k, start + ((enter ?? start) - start) * s])]);
|
|
434
456
|
}
|
|
435
457
|
/**
|
|
436
458
|
* `spec` gliding into new data with the marks: the ends of every axis glide
|
|
437
459
|
* as `useTween` glides the numbers, where ends rounded from the numbers on
|
|
438
460
|
* screen would jump from one round number to the next, and the box with
|
|
439
|
-
* them. The ticks
|
|
461
|
+
* them. The ticks stay those of `spec`; labels of ticks outside the gliding
|
|
462
|
+
* ends fade out in `edgeLabels` instead of popping out of the range. The
|
|
463
|
+
* outermost ticks glide too, so the line of the axis grows to a new tick and
|
|
464
|
+
* shrinks from a dropped one instead of jumping when the glide is done.
|
|
440
465
|
*/
|
|
441
466
|
function useTweenAxes(spec) {
|
|
442
|
-
const
|
|
467
|
+
const axes = [
|
|
443
468
|
"x",
|
|
444
469
|
"y",
|
|
445
470
|
"z"
|
|
446
|
-
]
|
|
471
|
+
];
|
|
472
|
+
const last = (domain) => domain[domain.length - 1];
|
|
473
|
+
const target = Object.fromEntries(axes.flatMap((a) => {
|
|
474
|
+
const { domain, ticks } = spec[a];
|
|
475
|
+
return [
|
|
476
|
+
[`${a}0`, domain[0]],
|
|
477
|
+
[`${a}1`, last(domain)],
|
|
478
|
+
[`${a}t0`, ticks.at(0) ?? domain[0]],
|
|
479
|
+
[`${a}t1`, ticks.at(-1) ?? last(domain)]
|
|
480
|
+
];
|
|
481
|
+
}));
|
|
482
|
+
const ends = useTween(target);
|
|
483
|
+
const gliding = Object.keys(target).some((k) => ends[k] !== target[k]);
|
|
484
|
+
const key = JSON.stringify(spec);
|
|
485
|
+
const memory = React.useRef({
|
|
486
|
+
key,
|
|
487
|
+
target: spec,
|
|
488
|
+
from: spec
|
|
489
|
+
});
|
|
490
|
+
if (memory.current.key !== key) memory.current = {
|
|
491
|
+
key,
|
|
492
|
+
target: spec,
|
|
493
|
+
from: memory.current.target
|
|
494
|
+
};
|
|
447
495
|
const glided = (a) => {
|
|
448
496
|
const [lo, hi] = [ends[`${a}0`], ends[`${a}1`]];
|
|
449
|
-
const
|
|
497
|
+
const [d0, d1] = [spec[a].domain[0], last(spec[a].domain)];
|
|
498
|
+
const gone = gliding ? memory.current.from[a].ticks.filter((t) => !spec[a].ticks.includes(t)) : [];
|
|
499
|
+
const ticks = gone.length ? [...spec[a].ticks, ...gone].sort((t, u) => t - u) : spec[a].ticks;
|
|
500
|
+
const leaving = gone.filter((t) => within(Math.min(d0, d1), Math.max(d0, d1), t));
|
|
501
|
+
const span = [ends[`${a}t0`], ends[`${a}t1`]];
|
|
450
502
|
return {
|
|
451
503
|
domain: [lo, hi],
|
|
452
|
-
ticks
|
|
504
|
+
ticks,
|
|
505
|
+
span,
|
|
506
|
+
...leaving.length ? { leaving } : {}
|
|
453
507
|
};
|
|
454
508
|
};
|
|
455
509
|
return {
|
|
@@ -531,19 +585,20 @@ function between(from, to, k) {
|
|
|
531
585
|
}
|
|
532
586
|
/**
|
|
533
587
|
* 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
|
-
*
|
|
588
|
+
* has them on: a drag turns it, or pans it while Alt is held, the keys step
|
|
589
|
+
* it, Ctrl or ⌘ with the wheel zooms it and a double click resets it.
|
|
536
590
|
* `data-orbiting` among them is true while the camera turns. `onDragStart`
|
|
537
591
|
* runs when a press becomes a drag, `onHover` on a move without one.
|
|
538
592
|
*/
|
|
539
593
|
function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
540
594
|
const { view, jump, glide } = useGlide(defaultView);
|
|
541
595
|
const [orbiting, setOrbiting] = React.useState(false);
|
|
596
|
+
const held = useModsHeld();
|
|
542
597
|
const drag = React.useRef(null);
|
|
543
598
|
const ref = useWheelZoom(jump, keys.on("wheelZoom"));
|
|
544
599
|
const move = (step) => jump((v) => moved(v, step, defaultView));
|
|
545
600
|
const step = (step) => glide((v) => moved(v, step, defaultView));
|
|
546
|
-
const
|
|
601
|
+
const keyHandlers = useKeys(keys, step, setOrbiting);
|
|
547
602
|
const release = () => {
|
|
548
603
|
drag.current = null;
|
|
549
604
|
setOrbiting(false);
|
|
@@ -553,15 +608,18 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
553
608
|
handlers: {
|
|
554
609
|
ref,
|
|
555
610
|
onPointerDown: (e) => {
|
|
556
|
-
|
|
611
|
+
const pans = keys.on("dragPan") && e.altKey;
|
|
612
|
+
if (pans || keys.on("dragRotate") && platformMod(e)) drag.current = {
|
|
557
613
|
x: e.clientX,
|
|
558
614
|
y: e.clientY,
|
|
559
|
-
moved: false
|
|
615
|
+
moved: false,
|
|
616
|
+
rotate: !pans
|
|
560
617
|
};
|
|
561
618
|
},
|
|
562
619
|
onPointerMove: (e) => {
|
|
563
620
|
const d = drag.current;
|
|
564
621
|
if (!d) return onHover(e);
|
|
622
|
+
if (d.rotate ? !platformMod(e) : !e.altKey) return release();
|
|
565
623
|
const dx = e.clientX - d.x;
|
|
566
624
|
const dy = e.clientY - d.y;
|
|
567
625
|
if (!d.moved && Math.hypot(dx, dy) < CLICK_DISTANCE) return;
|
|
@@ -572,82 +630,120 @@ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
|
|
|
572
630
|
drag.current = {
|
|
573
631
|
x: e.clientX,
|
|
574
632
|
y: e.clientY,
|
|
575
|
-
moved: true
|
|
633
|
+
moved: true,
|
|
634
|
+
rotate: d.rotate
|
|
576
635
|
};
|
|
577
636
|
const k = width / e.currentTarget.getBoundingClientRect().width;
|
|
578
|
-
setOrbiting(
|
|
579
|
-
move(
|
|
580
|
-
x: dx * k,
|
|
581
|
-
y: dy * k
|
|
582
|
-
} } : {
|
|
637
|
+
setOrbiting(d.rotate);
|
|
638
|
+
move(d.rotate ? {
|
|
583
639
|
yaw: dx * DRAG,
|
|
584
640
|
tilt: dy * DRAG
|
|
585
|
-
}
|
|
641
|
+
} : { pan: {
|
|
642
|
+
x: dx * k,
|
|
643
|
+
y: dy * k
|
|
644
|
+
} });
|
|
586
645
|
},
|
|
587
646
|
onPointerUp: release,
|
|
588
647
|
onPointerCancel: release,
|
|
589
648
|
onDoubleClick: keys.on("doubleClickReset") ? () => step({ reset: true }) : void 0,
|
|
590
649
|
...keyHandlers,
|
|
591
|
-
"data-orbiting": orbiting
|
|
650
|
+
"data-orbiting": orbiting,
|
|
651
|
+
"data-mod": keys.on("dragRotate") && held.mod ? true : void 0,
|
|
652
|
+
"data-alt": keys.on("dragPan") && held.alt ? true : void 0
|
|
592
653
|
}
|
|
593
654
|
};
|
|
594
655
|
}
|
|
595
656
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
657
|
+
* Whether the platform modifier or Alt is held, so the cursor shows a hand
|
|
658
|
+
* before the drag rotates and the four arrows before it pans. A missed keyup
|
|
659
|
+
* (window blur while held) must not stick either cursor.
|
|
660
|
+
*/
|
|
661
|
+
function useModsHeld() {
|
|
662
|
+
const [mod, setMod] = React.useState(false);
|
|
663
|
+
const [alt, setAlt] = React.useState(false);
|
|
664
|
+
React.useEffect(() => {
|
|
665
|
+
const modKey = mac() ? "Meta" : "Control";
|
|
666
|
+
const onKey = (e) => {
|
|
667
|
+
if (e.key === modKey) setMod(e.type === "keydown");
|
|
668
|
+
if (e.key === "Alt") setAlt(e.type === "keydown");
|
|
669
|
+
};
|
|
670
|
+
const onBlur = () => {
|
|
671
|
+
setMod(false);
|
|
672
|
+
setAlt(false);
|
|
673
|
+
};
|
|
674
|
+
window.addEventListener("keydown", onKey);
|
|
675
|
+
window.addEventListener("keyup", onKey);
|
|
676
|
+
window.addEventListener("blur", onBlur);
|
|
677
|
+
return () => {
|
|
678
|
+
window.removeEventListener("keydown", onKey);
|
|
679
|
+
window.removeEventListener("keyup", onKey);
|
|
680
|
+
window.removeEventListener("blur", onBlur);
|
|
681
|
+
};
|
|
682
|
+
}, []);
|
|
683
|
+
return {
|
|
684
|
+
mod,
|
|
685
|
+
alt
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The handlers that step the camera with the keys of `keys`. A key that
|
|
690
|
+
* turns the camera marks it as orbiting until it is let go.
|
|
599
691
|
*/
|
|
600
692
|
function useKeys(keys, move, setOrbiting) {
|
|
601
|
-
const space = React.useRef(false);
|
|
602
693
|
const onKeyDown = (e) => {
|
|
603
|
-
const pan = e.key === " " && keys.on("dragPan");
|
|
604
|
-
if (pan) space.current = true;
|
|
605
694
|
const step = keys.moves[pressed(e)];
|
|
606
|
-
if (!step && !pan) return;
|
|
607
|
-
e.preventDefault();
|
|
608
695
|
if (!step) return;
|
|
696
|
+
e.preventDefault();
|
|
609
697
|
setOrbiting(Boolean(step.yaw || step.tilt));
|
|
610
698
|
move(step);
|
|
611
699
|
};
|
|
612
|
-
const onKeyUp = (
|
|
613
|
-
if (e.key === " ") space.current = false;
|
|
614
|
-
setOrbiting(false);
|
|
615
|
-
};
|
|
700
|
+
const onKeyUp = () => setOrbiting(false);
|
|
616
701
|
return {
|
|
617
|
-
space,
|
|
618
702
|
onKeyDown,
|
|
619
703
|
onKeyUp
|
|
620
704
|
};
|
|
621
705
|
}
|
|
622
706
|
/**
|
|
623
|
-
* A ref for the svg that zooms the view with
|
|
624
|
-
*
|
|
707
|
+
* A ref for the svg that zooms the view with the platform modifier and the
|
|
708
|
+
* wheel, if `on`. Ctrl and the wheel zooms on a Mac too, because a trackpad
|
|
709
|
+
* pinch arrives that way, indistinguishable from the keys. The wheel alone
|
|
710
|
+
* still scrolls the page. While the wheel zooms, the svg says so in
|
|
711
|
+
* `data-zoom` - `"in"` or `"out"`, the direction of its cursor - until
|
|
712
|
+
* `ZOOM_CURSOR` after the last tick.
|
|
625
713
|
*/
|
|
626
714
|
function useWheelZoom(jump, on) {
|
|
627
715
|
return React.useCallback((svg) => {
|
|
628
|
-
if (!on) return;
|
|
716
|
+
if (!on || !svg) return;
|
|
717
|
+
let timer;
|
|
629
718
|
const onWheel = (e) => {
|
|
630
|
-
if (!e
|
|
719
|
+
if (!platformMod(e) && !e.ctrlKey) return;
|
|
631
720
|
e.preventDefault();
|
|
632
721
|
jump((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
|
|
722
|
+
svg.dataset.zoom = e.deltaY < 0 ? "in" : "out";
|
|
723
|
+
clearTimeout(timer);
|
|
724
|
+
timer = setTimeout(() => delete svg.dataset.zoom, ZOOM_CURSOR);
|
|
725
|
+
};
|
|
726
|
+
svg.addEventListener("wheel", onWheel, { passive: false });
|
|
727
|
+
return () => {
|
|
728
|
+
clearTimeout(timer);
|
|
729
|
+
svg.removeEventListener("wheel", onWheel);
|
|
633
730
|
};
|
|
634
|
-
svg?.addEventListener("wheel", onWheel, { passive: false });
|
|
635
|
-
return () => svg?.removeEventListener("wheel", onWheel);
|
|
636
731
|
}, [jump, on]);
|
|
637
732
|
}
|
|
638
733
|
/**
|
|
639
734
|
* 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
|
-
*
|
|
735
|
+
* focus on its groups. The highlight follows the mark: `hover` sets the
|
|
736
|
+
* tooltip only when the pointer moves onto another mark, and the focus only
|
|
737
|
+
* when the groups change, so a move within a mark does not render the chart
|
|
738
|
+
* again; off the mark and when the pointer leaves the chart, `leave` drops
|
|
739
|
+
* both. While a drag turns the chart under the captured pointer, `hide`
|
|
740
|
+
* drops the tooltip and keeps the focus, so a flow does not flash away and
|
|
741
|
+
* can be followed from every side.
|
|
646
742
|
*/
|
|
647
743
|
function useHover(scene, width, height, setTooltip, focus) {
|
|
648
744
|
const hovered = React.useRef(void 0);
|
|
649
745
|
const show = (mark) => {
|
|
650
|
-
|
|
746
|
+
focus.set(mark?.groups);
|
|
651
747
|
if (mark?.key === hovered.current?.key) return;
|
|
652
748
|
hovered.current = mark;
|
|
653
749
|
setTooltip(mark?.tooltip ?? null);
|
|
@@ -661,11 +757,11 @@ function useHover(scene, width, height, setTooltip, focus) {
|
|
|
661
757
|
};
|
|
662
758
|
show(hit(scene.current, at));
|
|
663
759
|
},
|
|
664
|
-
hide: () =>
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
760
|
+
hide: () => {
|
|
761
|
+
hovered.current = void 0;
|
|
762
|
+
setTooltip(null);
|
|
763
|
+
},
|
|
764
|
+
leave: () => show(void 0)
|
|
669
765
|
};
|
|
670
766
|
}
|
|
671
767
|
/** A store for the groups in focus, which tells its listeners only when the groups change. */
|
|
@@ -688,17 +784,11 @@ function useFocus() {
|
|
|
688
784
|
});
|
|
689
785
|
return focus;
|
|
690
786
|
}
|
|
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) {
|
|
787
|
+
/** Whether an item shares a group with the focus: the hover highlights those items with an edge. */
|
|
788
|
+
function useLit(items) {
|
|
697
789
|
const { focus } = useChart3D();
|
|
698
790
|
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);
|
|
791
|
+
return (item) => item.groups !== void 0 && (groups?.some((group) => item.groups.includes(group)) ?? false);
|
|
702
792
|
}
|
|
703
793
|
/** The topmost mark under `at`: the last one painted that holds it. */
|
|
704
794
|
function hit(marks, at) {
|
|
@@ -719,12 +809,14 @@ function inside(polygon, at) {
|
|
|
719
809
|
}
|
|
720
810
|
/**
|
|
721
811
|
* 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
|
|
812
|
+
* render on the client, then the requested one if the browser has it, with
|
|
813
|
+
* "webgl" falling back to "canvas" and "canvas" to SVG.
|
|
723
814
|
*/
|
|
724
815
|
function useRenderer(requested) {
|
|
725
816
|
const [renderer, setRenderer] = React.useState("svg");
|
|
726
817
|
React.useEffect(() => {
|
|
727
|
-
|
|
818
|
+
if (requested === "webgl" && document.createElement("canvas").getContext("webgl")) return setRenderer("webgl");
|
|
819
|
+
const canvas = requested !== "svg" && document.createElement("canvas").getContext("2d");
|
|
728
820
|
setRenderer(canvas ? "canvas" : "svg");
|
|
729
821
|
}, [requested]);
|
|
730
822
|
return renderer;
|
|
@@ -732,15 +824,18 @@ function useRenderer(requested) {
|
|
|
732
824
|
/**
|
|
733
825
|
* A ref for the canvas of the marks, and the repaint that paints them: on new
|
|
734
826
|
* 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
|
|
827
|
+
* resolves on the canvas itself, inside the chart, so the `--color-*` apply;
|
|
828
|
+
* webgl takes the resolved color as bytes from a 1×1 canvas.
|
|
736
829
|
*/
|
|
737
|
-
function useCanvas(scene, width) {
|
|
830
|
+
function useCanvas(scene, width, height, renderer) {
|
|
738
831
|
const element = React.useRef(null);
|
|
739
832
|
const colors = React.useRef(/* @__PURE__ */ new Map());
|
|
833
|
+
const bytes = React.useRef(/* @__PURE__ */ new Map());
|
|
834
|
+
const pixel = React.useRef(null);
|
|
740
835
|
const repaint = React.useCallback(() => {
|
|
741
836
|
const canvas = element.current;
|
|
742
837
|
if (!canvas) return;
|
|
743
|
-
|
|
838
|
+
const resolve = (css) => {
|
|
744
839
|
let color = colors.current.get(css);
|
|
745
840
|
if (color === void 0) {
|
|
746
841
|
canvas.style.color = css;
|
|
@@ -748,8 +843,17 @@ function useCanvas(scene, width) {
|
|
|
748
843
|
colors.current.set(css, color);
|
|
749
844
|
}
|
|
750
845
|
return color;
|
|
751
|
-
}
|
|
752
|
-
|
|
846
|
+
};
|
|
847
|
+
if (renderer === "webgl") {
|
|
848
|
+
pixel.current ??= onePixel();
|
|
849
|
+
paintGL(canvas, scene.current, width, height, (css) => colorBytes(resolve(css), pixel.current, bytes.current));
|
|
850
|
+
} else paint(canvas, scene.current, width, resolve);
|
|
851
|
+
}, [
|
|
852
|
+
scene,
|
|
853
|
+
width,
|
|
854
|
+
height,
|
|
855
|
+
renderer
|
|
856
|
+
]);
|
|
753
857
|
return {
|
|
754
858
|
canvas: React.useCallback((node) => {
|
|
755
859
|
element.current = node;
|
|
@@ -758,6 +862,7 @@ function useCanvas(scene, width) {
|
|
|
758
862
|
resize.observe(node);
|
|
759
863
|
const theme = new MutationObserver(() => {
|
|
760
864
|
colors.current.clear();
|
|
865
|
+
bytes.current.clear();
|
|
761
866
|
repaint();
|
|
762
867
|
});
|
|
763
868
|
theme.observe(document.documentElement, { attributeFilter: ["class", "style"] });
|
|
@@ -786,21 +891,132 @@ function paint(canvas, marks, width, color) {
|
|
|
786
891
|
} else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
|
|
787
892
|
ctx.fillStyle = color(fill);
|
|
788
893
|
ctx.fill();
|
|
789
|
-
const outline = r === void 0 ? fill : stroke;
|
|
894
|
+
const outline = r === void 0 ? stroke ?? fill : stroke;
|
|
790
895
|
if (!outline) continue;
|
|
791
896
|
ctx.strokeStyle = color(outline);
|
|
792
897
|
ctx.lineWidth = r === void 0 ? .5 : 1;
|
|
793
898
|
ctx.stroke();
|
|
794
899
|
}
|
|
795
900
|
}
|
|
901
|
+
/** A 1×1 canvas whose 2D context resolves a color to bytes, for the webgl renderer. */
|
|
902
|
+
function onePixel() {
|
|
903
|
+
const canvas = document.createElement("canvas");
|
|
904
|
+
canvas.width = 1;
|
|
905
|
+
canvas.height = 1;
|
|
906
|
+
return canvas.getContext("2d");
|
|
907
|
+
}
|
|
908
|
+
/** The resolved `color` as bytes, drawn on the 1×1 `pixel` and read back, cached per color. */
|
|
909
|
+
function colorBytes(color, pixel, cache) {
|
|
910
|
+
let rgba = cache.get(color);
|
|
911
|
+
if (rgba === void 0) {
|
|
912
|
+
pixel.clearRect(0, 0, 1, 1);
|
|
913
|
+
pixel.fillStyle = color;
|
|
914
|
+
pixel.fillRect(0, 0, 1, 1);
|
|
915
|
+
rgba = [...pixel.getImageData(0, 0, 1, 1).data];
|
|
916
|
+
cache.set(color, rgba);
|
|
917
|
+
}
|
|
918
|
+
return rgba;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Paints `marks` on a webgl `canvas`, from the view box of `width`×`height` to
|
|
922
|
+
* the pixels of the canvas: a fan per polygon, a fan of 12 segments per
|
|
923
|
+
* circle, in paint order and without a depth test, the color of each as bytes.
|
|
924
|
+
*/
|
|
925
|
+
function paintGL(canvas, marks, width, height, color) {
|
|
926
|
+
canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
|
|
927
|
+
canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
|
|
928
|
+
const gl = canvas.getContext("webgl");
|
|
929
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
930
|
+
gl.disable(gl.DEPTH_TEST);
|
|
931
|
+
gl.enable(gl.BLEND);
|
|
932
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
933
|
+
const program = programOf(gl);
|
|
934
|
+
gl.useProgram(program);
|
|
935
|
+
gl.uniform2f(gl.getUniformLocation(program, "u_res"), 2 / width, 2 / height);
|
|
936
|
+
const where = gl.getAttribLocation(program, "a_pos");
|
|
937
|
+
gl.enableVertexAttribArray(where);
|
|
938
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOf(gl));
|
|
939
|
+
gl.vertexAttribPointer(where, 2, gl.FLOAT, false, 0, 0);
|
|
940
|
+
const tint = (css) => {
|
|
941
|
+
const [r, g, b, a] = color(css);
|
|
942
|
+
gl.uniform4f(gl.getUniformLocation(program, "u_color"), r / 255, g / 255, b / 255, a / 255);
|
|
943
|
+
};
|
|
944
|
+
const draw = (mode, points) => {
|
|
945
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points.flatMap((p) => [p.x, p.y])), gl.DYNAMIC_DRAW);
|
|
946
|
+
gl.drawArrays(mode, 0, points.length);
|
|
947
|
+
};
|
|
948
|
+
const [, widest] = gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE) ?? [1, 1];
|
|
949
|
+
gl.lineWidth(Math.min(1.5 * devicePixelRatio, widest));
|
|
950
|
+
for (const mark of marks) {
|
|
951
|
+
tint(mark.fill);
|
|
952
|
+
draw(gl.TRIANGLES, trianglesOf(mark));
|
|
953
|
+
if (mark.stroke) {
|
|
954
|
+
tint(mark.stroke);
|
|
955
|
+
draw(gl.LINE_LOOP, outlineOf(mark));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/** The 12 points of the circle around `c` of radius `r`. */
|
|
960
|
+
const ringOf = (c, r) => Array.from({ length: 12 }, (_, i) => ({
|
|
961
|
+
x: c.x + r * Math.cos(i / 12 * 2 * Math.PI),
|
|
962
|
+
y: c.y + r * Math.sin(i / 12 * 2 * Math.PI)
|
|
963
|
+
}));
|
|
964
|
+
/** The triangles of `mark` as view-box points: a fan per polygon, a fan of 12 segments per circle. */
|
|
965
|
+
function trianglesOf(mark) {
|
|
966
|
+
const { points, r } = mark;
|
|
967
|
+
if (r !== void 0) {
|
|
968
|
+
const ring = ringOf(points[0], r);
|
|
969
|
+
return ring.flatMap((p, i) => [
|
|
970
|
+
points[0],
|
|
971
|
+
p,
|
|
972
|
+
ring[(i + 1) % 12]
|
|
973
|
+
]);
|
|
974
|
+
}
|
|
975
|
+
return points.slice(1, -1).flatMap((p, i) => [
|
|
976
|
+
points[0],
|
|
977
|
+
p,
|
|
978
|
+
points[i + 2]
|
|
979
|
+
]);
|
|
980
|
+
}
|
|
981
|
+
/** The outline of `mark` as view-box points: its polygon, or the ring of its circle. */
|
|
982
|
+
const outlineOf = (mark) => mark.r === void 0 ? mark.points : ringOf(mark.points[0], mark.r);
|
|
983
|
+
const programs = /* @__PURE__ */ new WeakMap();
|
|
984
|
+
const buffers = /* @__PURE__ */ new WeakMap();
|
|
985
|
+
/** The paint program of `gl`, compiled once: view-box points in, clip space out, one color per draw. */
|
|
986
|
+
function programOf(gl) {
|
|
987
|
+
let program = programs.get(gl);
|
|
988
|
+
if (!program) {
|
|
989
|
+
program = gl.createProgram();
|
|
990
|
+
gl.attachShader(program, shaderOf(gl, gl.VERTEX_SHADER, "attribute vec2 a_pos; uniform vec2 u_res; void main() { gl_Position = vec4(a_pos.x * u_res.x - 1.0, 1.0 - a_pos.y * u_res.y, 0.0, 1.0); }"));
|
|
991
|
+
gl.attachShader(program, shaderOf(gl, gl.FRAGMENT_SHADER, "precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }"));
|
|
992
|
+
gl.linkProgram(program);
|
|
993
|
+
programs.set(gl, program);
|
|
994
|
+
}
|
|
995
|
+
return program;
|
|
996
|
+
}
|
|
997
|
+
function shaderOf(gl, type, source) {
|
|
998
|
+
const shader = gl.createShader(type);
|
|
999
|
+
gl.shaderSource(shader, source);
|
|
1000
|
+
gl.compileShader(shader);
|
|
1001
|
+
return shader;
|
|
1002
|
+
}
|
|
1003
|
+
function bufferOf(gl) {
|
|
1004
|
+
let buffer = buffers.get(gl);
|
|
1005
|
+
if (!buffer) {
|
|
1006
|
+
buffer = gl.createBuffer();
|
|
1007
|
+
buffers.set(gl, buffer);
|
|
1008
|
+
}
|
|
1009
|
+
return buffer;
|
|
1010
|
+
}
|
|
796
1011
|
/** The canvas over the chart box that the marks are painted on, if the chart draws on one. */
|
|
797
1012
|
function ChartCanvas3D({ renderer, canvas, width, height }) {
|
|
798
|
-
if (renderer
|
|
1013
|
+
if (renderer === "svg") return null;
|
|
799
1014
|
return /* @__PURE__ */ jsx("canvas", {
|
|
1015
|
+
"data-slot": "chart-3d-canvas",
|
|
800
1016
|
ref: canvas,
|
|
801
|
-
className: "pointer-events-none absolute top-0 left-0 w-full mask-y-from-96%",
|
|
1017
|
+
className: "pointer-events-none absolute top-0 left-0 w-full mask-x-from-96% mask-y-from-96%",
|
|
802
1018
|
style: { aspectRatio: `${width} / ${height}` }
|
|
803
|
-
});
|
|
1019
|
+
}, renderer);
|
|
804
1020
|
}
|
|
805
1021
|
const GIZMO = [
|
|
806
1022
|
{
|
|
@@ -868,42 +1084,49 @@ function ChartGizmo3D({ camera, corner }) {
|
|
|
868
1084
|
});
|
|
869
1085
|
}
|
|
870
1086
|
/**
|
|
1087
|
+
* The focus edge of a fill: `--chart-3d-focus`, or the fill itself lightened,
|
|
1088
|
+
* so the highlight carries the color of what it highlights.
|
|
1089
|
+
*/
|
|
1090
|
+
const focusOf = (fill) => `var(--chart-3d-focus, color-mix(in oklch, ${fill} 60%, white))`;
|
|
1091
|
+
/**
|
|
871
1092
|
* The marks of a chart, drawn by the renderer of the container: as SVG
|
|
872
1093
|
* elements, or on the canvas over the chart box. The hover reads them too.
|
|
873
1094
|
*/
|
|
874
1095
|
function Marks3D({ marks }) {
|
|
875
1096
|
const { renderer, scene, repaint } = useChart3D();
|
|
876
|
-
const
|
|
877
|
-
const drawn = marks.map((mark) =>
|
|
1097
|
+
const lit = useLit(marks);
|
|
1098
|
+
const drawn = marks.map((mark) => lit(mark) ? {
|
|
878
1099
|
...mark,
|
|
879
|
-
|
|
1100
|
+
stroke: mark.stroke && mark.stroke !== "var(--background)" ? mark.stroke : focusOf(mark.fill)
|
|
880
1101
|
} : mark);
|
|
881
1102
|
React.useLayoutEffect(() => {
|
|
882
1103
|
scene.current = drawn;
|
|
883
1104
|
repaint();
|
|
884
1105
|
});
|
|
885
|
-
if (renderer
|
|
886
|
-
return
|
|
887
|
-
|
|
888
|
-
fill,
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1106
|
+
if (renderer !== "svg") return null;
|
|
1107
|
+
return /* @__PURE__ */ jsx("g", {
|
|
1108
|
+
"data-slot": "chart-3d-marks",
|
|
1109
|
+
children: drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
|
|
1110
|
+
d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
|
|
1111
|
+
fill,
|
|
1112
|
+
stroke: stroke ?? fill,
|
|
1113
|
+
strokeWidth: stroke === focusOf(fill) ? 1.5 : .5,
|
|
1114
|
+
strokeLinejoin: "round"
|
|
1115
|
+
}, key) : /* @__PURE__ */ jsx("circle", {
|
|
1116
|
+
cx: points[0].x,
|
|
1117
|
+
cy: points[0].y,
|
|
1118
|
+
r,
|
|
1119
|
+
fill,
|
|
1120
|
+
stroke,
|
|
1121
|
+
strokeWidth: stroke ? 1 : void 0
|
|
1122
|
+
}, key))
|
|
1123
|
+
});
|
|
900
1124
|
}
|
|
901
1125
|
/**
|
|
902
1126
|
* The labels of the marks of a chart, ringed by the background so that they
|
|
903
1127
|
* read on the marks. Like the labels of the box, they are SVG with every renderer.
|
|
904
1128
|
*/
|
|
905
1129
|
function Labels3D({ labels }) {
|
|
906
|
-
const fades = useFades(labels);
|
|
907
1130
|
return /* @__PURE__ */ jsx("g", {
|
|
908
1131
|
"data-slot": "chart-3d-labels",
|
|
909
1132
|
children: labels.map((label) => {
|
|
@@ -916,7 +1139,7 @@ function Labels3D({ labels }) {
|
|
|
916
1139
|
paintOrder: "stroke",
|
|
917
1140
|
strokeWidth: 3,
|
|
918
1141
|
strokeLinejoin: "round",
|
|
919
|
-
className:
|
|
1142
|
+
className: "fill-foreground stroke-background text-[10px] font-medium",
|
|
920
1143
|
children: label.text
|
|
921
1144
|
}, label.key);
|
|
922
1145
|
})
|
|
@@ -924,19 +1147,33 @@ function Labels3D({ labels }) {
|
|
|
924
1147
|
}
|
|
925
1148
|
function ChartStyle({ id, config }) {
|
|
926
1149
|
const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
|
|
927
|
-
|
|
928
|
-
|
|
1150
|
+
const entering = [
|
|
1151
|
+
"text",
|
|
1152
|
+
"line",
|
|
1153
|
+
"path"
|
|
1154
|
+
].map((el) => `[data-chart=${id}] [data-slot="chart-3d-axes"] ${el}`).join(", ");
|
|
1155
|
+
const base = `@keyframes chart-3d-axes-in { from { opacity: 0; } }
|
|
1156
|
+
${entering} { animation: chart-3d-axes-in 300ms ease-out; }
|
|
1157
|
+
@media (prefers-reduced-motion: reduce) { ${entering} { animation: none; } }
|
|
1158
|
+
[data-chart=${id}] svg[data-mod="true"] { cursor: grab; }
|
|
1159
|
+
[data-chart=${id}] svg[data-alt="true"] { cursor: move; }
|
|
1160
|
+
[data-chart=${id}] svg[data-zoom="in"] { cursor: zoom-in; }
|
|
1161
|
+
[data-chart=${id}] svg[data-zoom="out"] { cursor: zoom-out; }
|
|
1162
|
+
[data-chart=${id}] svg[data-orbiting="true"] { cursor: grabbing; }`;
|
|
1163
|
+
if (!colorConfig.length) return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: base } });
|
|
1164
|
+
return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: [base, ...Object.entries(THEMES).map(([theme, prefix]) => `
|
|
929
1165
|
${prefix} [data-chart=${id}] {
|
|
930
1166
|
${colorConfig.map(([key, item]) => {
|
|
931
1167
|
const color = item.theme?.[theme] || item.color;
|
|
932
1168
|
return color ? ` --color-${key}: ${color};` : null;
|
|
933
1169
|
}).join("\n")}
|
|
934
1170
|
}
|
|
935
|
-
`).join("\n") } });
|
|
1171
|
+
`)].join("\n") } });
|
|
936
1172
|
}
|
|
937
1173
|
function ChartTooltip3D({ tooltip, width, height }) {
|
|
938
1174
|
if (!tooltip) return null;
|
|
939
1175
|
return /* @__PURE__ */ jsxs("div", {
|
|
1176
|
+
"data-slot": "chart-3d-tooltip",
|
|
940
1177
|
className: "pointer-events-none absolute z-10 grid min-w-32 -translate-x-1/2 -translate-y-[calc(100%+0.5rem)] gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
|
941
1178
|
style: {
|
|
942
1179
|
left: `${tooltip.at.x / width * 100}%`,
|
|
@@ -983,9 +1220,18 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
983
1220
|
chart?.addEventListener("keydown", onKeyDown);
|
|
984
1221
|
return () => chart?.removeEventListener("keydown", onKeyDown);
|
|
985
1222
|
}, [keys]);
|
|
1223
|
+
React.useEffect(() => {
|
|
1224
|
+
if (!open) return;
|
|
1225
|
+
const onPointerDown = (e) => {
|
|
1226
|
+
if (!ref.current?.contains(e.target)) setOpen(false);
|
|
1227
|
+
};
|
|
1228
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
1229
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
1230
|
+
}, [open]);
|
|
986
1231
|
if (!rows.length) return null;
|
|
987
1232
|
return /* @__PURE__ */ jsxs("div", {
|
|
988
1233
|
ref,
|
|
1234
|
+
"data-slot": "chart-3d-shortcuts",
|
|
989
1235
|
className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
|
|
990
1236
|
children: [/* @__PURE__ */ jsx("button", {
|
|
991
1237
|
type: "button",
|
|
@@ -994,7 +1240,7 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
994
1240
|
"aria-expanded": open,
|
|
995
1241
|
"aria-controls": open ? id : void 0,
|
|
996
1242
|
onClick: () => setOpen((o) => !o),
|
|
997
|
-
className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-
|
|
1243
|
+
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
1244
|
children: /* @__PURE__ */ jsx(KeyboardIcon, {})
|
|
999
1245
|
}), open && /* @__PURE__ */ jsx(ChartShortcutList3D, {
|
|
1000
1246
|
id,
|
|
@@ -1002,31 +1248,44 @@ function ChartShortcuts3D({ rows, help, label }) {
|
|
|
1002
1248
|
})]
|
|
1003
1249
|
});
|
|
1004
1250
|
}
|
|
1005
|
-
/**
|
|
1251
|
+
/** One group of keys as chips. */
|
|
1252
|
+
function KbdGroup3D({ group }) {
|
|
1253
|
+
return /* @__PURE__ */ jsx("span", {
|
|
1254
|
+
"data-slot": "kbd-group",
|
|
1255
|
+
className: "flex gap-1",
|
|
1256
|
+
children: group.map((key, j) => /* @__PURE__ */ jsx("kbd", {
|
|
1257
|
+
"data-slot": "kbd",
|
|
1258
|
+
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",
|
|
1259
|
+
children: key
|
|
1260
|
+
}, j))
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
/** The list of the shortcut panel: per row its label, then its keys, a rule, and its gestures. */
|
|
1006
1264
|
function ChartShortcutList3D({ id, rows }) {
|
|
1007
1265
|
return /* @__PURE__ */ jsx("dl", {
|
|
1008
1266
|
id,
|
|
1009
|
-
"data-slot": "chart-3d-shortcuts",
|
|
1267
|
+
"data-slot": "chart-3d-shortcuts-panel",
|
|
1010
1268
|
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", {
|
|
1269
|
+
children: rows.map(({ label, keys, gestures }) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("dt", {
|
|
1270
|
+
"data-slot": "chart-3d-shortcuts-label",
|
|
1023
1271
|
className: "text-muted-foreground",
|
|
1024
1272
|
children: label
|
|
1273
|
+
}), /* @__PURE__ */ jsxs("dd", {
|
|
1274
|
+
className: "flex flex-wrap items-center justify-end gap-x-2 gap-y-1",
|
|
1275
|
+
children: [
|
|
1276
|
+
keys.map((group, i) => /* @__PURE__ */ jsx(KbdGroup3D, { group }, i)),
|
|
1277
|
+
keys.length > 0 && gestures.length > 0 && /* @__PURE__ */ jsx("span", {
|
|
1278
|
+
"data-slot": "chart-3d-shortcuts-rule",
|
|
1279
|
+
className: "h-4 w-px bg-border"
|
|
1280
|
+
}),
|
|
1281
|
+
gestures.map((group, i) => /* @__PURE__ */ jsx(KbdGroup3D, { group }, i))
|
|
1282
|
+
]
|
|
1025
1283
|
})] }, label))
|
|
1026
1284
|
});
|
|
1027
1285
|
}
|
|
1028
1286
|
function ChartLegend3D({ config }) {
|
|
1029
1287
|
return /* @__PURE__ */ jsx("div", {
|
|
1288
|
+
"data-slot": "chart-3d-legend",
|
|
1030
1289
|
className: "flex items-center justify-center gap-4 pt-3",
|
|
1031
1290
|
children: Object.entries(config).map(([key, item]) => /* @__PURE__ */ jsxs("div", {
|
|
1032
1291
|
className: "flex items-center gap-1.5",
|
|
@@ -1047,15 +1306,32 @@ function ChartLegend3D({ config }) {
|
|
|
1047
1306
|
function Axes3D({ spec, world, format = {}, titles = {} }) {
|
|
1048
1307
|
const { camera } = useChart3D();
|
|
1049
1308
|
const box = intoWorld(axes3D().camera(camera), world);
|
|
1050
|
-
const { labels, lines, marks } = labelAxes(box.edges(spec), format, titles);
|
|
1309
|
+
const { labels, lines, marks } = labelAxes(box.edges(spec), spec, format, titles);
|
|
1310
|
+
const leaving = new Set([
|
|
1311
|
+
"x",
|
|
1312
|
+
"y",
|
|
1313
|
+
"z"
|
|
1314
|
+
].flatMap((a) => (spec[a].leaving ?? []).map((value) => `${a}-${value}`)));
|
|
1051
1315
|
return /* @__PURE__ */ jsxs("g", {
|
|
1052
1316
|
"data-slot": "chart-3d-axes",
|
|
1053
1317
|
children: [
|
|
1054
|
-
box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
|
|
1055
|
-
d:
|
|
1318
|
+
box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("path", {
|
|
1319
|
+
d: quad(wall),
|
|
1056
1320
|
className: "fill-muted/40 stroke-border",
|
|
1057
1321
|
strokeWidth: .75
|
|
1058
|
-
},
|
|
1322
|
+
}), wall.grid.map((segment) => {
|
|
1323
|
+
const [from, to] = [segment[0], segment[segment.length - 1]];
|
|
1324
|
+
const key = gridKey(wall, from, to);
|
|
1325
|
+
return /* @__PURE__ */ jsx("line", {
|
|
1326
|
+
x1: from.projected.x,
|
|
1327
|
+
y1: from.projected.y,
|
|
1328
|
+
x2: to.projected.x,
|
|
1329
|
+
y2: to.projected.y,
|
|
1330
|
+
style: { opacity: leaving.has(key) ? 0 : 1 },
|
|
1331
|
+
className: "stroke-border transition-opacity duration-300 ease-out motion-reduce:transition-none",
|
|
1332
|
+
strokeWidth: .75
|
|
1333
|
+
}, key);
|
|
1334
|
+
})] }, `${wall.axis}${wall.side}`)),
|
|
1059
1335
|
lines.map((line) => /* @__PURE__ */ jsx(AxisLine3D, {
|
|
1060
1336
|
line,
|
|
1061
1337
|
ink: "stroke-foreground",
|
|
@@ -1075,6 +1351,25 @@ const PAD = 2;
|
|
|
1075
1351
|
const FADE = 4;
|
|
1076
1352
|
const DODGE = 12;
|
|
1077
1353
|
const MARK = 6;
|
|
1354
|
+
/** The closed quad of a wall; its gridlines are their own lines, so a new one fades in. */
|
|
1355
|
+
function quad(wall) {
|
|
1356
|
+
return `M${wall.map(({ projected }) => `${projected.x},${projected.y}`).join("L")}Z`;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* The key of a gridline: the axis and value of the tick it marks - the one
|
|
1360
|
+
* coordinate its ends share beside the wall's own axis - so the gridline of
|
|
1361
|
+
* a tick that stays glides on, and one of a new tick mounts and fades in.
|
|
1362
|
+
* Exactly one coordinate differs along the line, because a domain always has
|
|
1363
|
+
* width: `axis` widens an empty extent.
|
|
1364
|
+
*/
|
|
1365
|
+
function gridKey(wall, from, to) {
|
|
1366
|
+
const axis = [
|
|
1367
|
+
"x",
|
|
1368
|
+
"y",
|
|
1369
|
+
"z"
|
|
1370
|
+
].find((a) => a !== wall.axis && from[a] === to[a]);
|
|
1371
|
+
return `${axis}-${from[axis]}`;
|
|
1372
|
+
}
|
|
1078
1373
|
/**
|
|
1079
1374
|
* The labels of the axes on all their edges, the line of every axis along its
|
|
1080
1375
|
* edge and the mark of each of its ticks, shown on the edges the axes sit on.
|
|
@@ -1082,11 +1377,12 @@ const MARK = 6;
|
|
|
1082
1377
|
* those of an axis with fewer ticks, which say more each, and the titles give
|
|
1083
1378
|
* way to the ticks.
|
|
1084
1379
|
*/
|
|
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]));
|
|
1380
|
+
function labelAxes(edges, spec, format, titles) {
|
|
1381
|
+
const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, spec[edge.axis], format[edge.axis] ?? String, titles[edge.axis]));
|
|
1087
1382
|
const shown = axes.filter((axis) => axis.chosen).sort((a, b) => a.ticks.length - b.ticks.length);
|
|
1088
1383
|
const placed = [];
|
|
1089
1384
|
for (const label of [...shown.flatMap((a) => a.ticks), ...shown.flatMap((a) => a.title ?? [])]) {
|
|
1385
|
+
if (label.opacity === 0) continue;
|
|
1090
1386
|
label.opacity = shows(label, placed);
|
|
1091
1387
|
placed.push(label);
|
|
1092
1388
|
}
|
|
@@ -1102,13 +1398,47 @@ function labelAxes(edges, format, titles) {
|
|
|
1102
1398
|
* the rows that `fit` finds, and the title of the axis beyond them. The
|
|
1103
1399
|
* labels of an edge the axis does not sit on are there, but do not show.
|
|
1104
1400
|
*/
|
|
1105
|
-
|
|
1401
|
+
/**
|
|
1402
|
+
* The point of `value` along `edge`, between the ticks that bracket it: the
|
|
1403
|
+
* projection keeps the edge straight, so the point lies on their line. An
|
|
1404
|
+
* axis of one tick is a point.
|
|
1405
|
+
*/
|
|
1406
|
+
function along(edge, value) {
|
|
1407
|
+
if (edge.length === 1) return edge[0].projected;
|
|
1408
|
+
let i = 0;
|
|
1409
|
+
while (i < edge.length - 2 && edge[i + 1][edge.axis] < value) i += 1;
|
|
1410
|
+
const [a, b] = [edge[i][edge.axis], edge[i + 1][edge.axis]];
|
|
1411
|
+
const t = clamp(0, 1, (value - a) / (b - a));
|
|
1412
|
+
const [p, q] = [edge[i].projected, edge[i + 1].projected];
|
|
1413
|
+
return {
|
|
1414
|
+
x: p.x + (q.x - p.x) * t,
|
|
1415
|
+
y: p.y + (q.y - p.y) * t
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
/** The ticks whose value lies in `domain`, with the ends of `domain`. */
|
|
1419
|
+
function inRange(ticks, domain) {
|
|
1420
|
+
const range = {
|
|
1421
|
+
lo: Math.min(...domain),
|
|
1422
|
+
hi: Math.max(...domain)
|
|
1423
|
+
};
|
|
1424
|
+
return {
|
|
1425
|
+
ranged: ticks.filter((t) => t.value !== void 0 && within(range.lo, range.hi, t.value)),
|
|
1426
|
+
range
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
/** An edge the axis does not sit on hides its labels; on a chosen one, the ticks outside the gliding ends fade out. */
|
|
1430
|
+
function hideOffRange(labels, range, chosen) {
|
|
1431
|
+
if (!chosen) for (const l of labels) l.opacity = 0;
|
|
1432
|
+
else for (const l of labels) if (l.value !== void 0 && !within(range.lo, range.hi, l.value)) l.opacity = 0;
|
|
1433
|
+
}
|
|
1434
|
+
function edgeLabels(edge, { domain, leaving = [], span }, format, title) {
|
|
1106
1435
|
const { axis, normal: n, chosen } = edge;
|
|
1107
|
-
const label = (key, at, text, h, gap) => {
|
|
1436
|
+
const label = (key, at, text, h, gap, value) => {
|
|
1108
1437
|
const w = textWidth(text, h);
|
|
1109
1438
|
return {
|
|
1110
1439
|
key,
|
|
1111
1440
|
axis,
|
|
1441
|
+
value,
|
|
1112
1442
|
at: beside(at, n, w, h, gap),
|
|
1113
1443
|
w,
|
|
1114
1444
|
h,
|
|
@@ -1118,15 +1448,18 @@ function edgeLabels(edge, format, title) {
|
|
|
1118
1448
|
};
|
|
1119
1449
|
};
|
|
1120
1450
|
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
|
|
1451
|
+
const ticks = edge.map((tick) => label(`${id}-${tick[axis]}`, tick.projected, format(tick[axis]), 10, GAP, tick[axis]));
|
|
1452
|
+
for (const t of ticks) if (t.value !== void 0 && leaving.includes(t.value)) t.opacity = 0;
|
|
1453
|
+
const { ranged, range } = inRange(ticks.filter((t) => t.opacity > 0), domain);
|
|
1454
|
+
const depth = Math.max(0, ...ranged.map((t) => Math.abs(n.x) * t.w + Math.abs(n.y) * t.h));
|
|
1123
1455
|
const row = {
|
|
1124
1456
|
x: n.x * (depth + PAD),
|
|
1125
1457
|
y: n.y * (depth + PAD)
|
|
1126
1458
|
};
|
|
1127
|
-
const rows = fit(
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1459
|
+
const rows = fit(ranged, n, row);
|
|
1460
|
+
const [s0, s1] = span ?? [edge[0][axis], edge[edge.length - 1][axis]];
|
|
1461
|
+
const start = along(edge, clamp(range.lo, range.hi, s0));
|
|
1462
|
+
const end = along(edge, clamp(range.lo, range.hi, s1));
|
|
1130
1463
|
const middle = {
|
|
1131
1464
|
x: (start.x + end.x) / 2,
|
|
1132
1465
|
y: (start.y + end.y) / 2
|
|
@@ -1136,7 +1469,7 @@ function edgeLabels(edge, format, title) {
|
|
|
1136
1469
|
title: true
|
|
1137
1470
|
} : void 0;
|
|
1138
1471
|
if (head && rows === 2) head.shift = row;
|
|
1139
|
-
|
|
1472
|
+
hideOffRange([...ticks, ...head ? [head] : []], range, chosen);
|
|
1140
1473
|
return {
|
|
1141
1474
|
chosen,
|
|
1142
1475
|
ticks,
|
|
@@ -1288,7 +1621,7 @@ function AxisLine3D({ line: { from, to, opacity }, ink, width }) {
|
|
|
1288
1621
|
y2: to.y,
|
|
1289
1622
|
strokeWidth: width,
|
|
1290
1623
|
style: { opacity: Math.round(opacity * 100) / 100 },
|
|
1291
|
-
className: cn("transition-opacity duration-
|
|
1624
|
+
className: cn("transition-opacity duration-300 ease-out motion-reduce:transition-none", ink)
|
|
1292
1625
|
});
|
|
1293
1626
|
}
|
|
1294
1627
|
/**
|
|
@@ -1305,7 +1638,7 @@ function AxisLabel3D({ label: { at, shift, opacity, text, title } }) {
|
|
|
1305
1638
|
opacity: Math.round(opacity * 100) / 100,
|
|
1306
1639
|
translate: `${shift.x}px ${shift.y}px`
|
|
1307
1640
|
},
|
|
1308
|
-
className: cn("transition-[opacity,translate] duration-
|
|
1641
|
+
className: cn("transition-[opacity,translate] duration-300 ease-out motion-reduce:transition-none", title ? "fill-foreground font-medium" : "fill-muted-foreground text-[10px]"),
|
|
1309
1642
|
children: text
|
|
1310
1643
|
});
|
|
1311
1644
|
}
|
|
@@ -1363,9 +1696,15 @@ function shade(face, color) {
|
|
|
1363
1696
|
const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
|
|
1364
1697
|
return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
|
|
1365
1698
|
}
|
|
1366
|
-
/**
|
|
1367
|
-
|
|
1368
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* `color` at `opacity`, from 0 to 1: a CSS color-mix with transparent, so a
|
|
1701
|
+
* mark fades on every renderer while its color stays in CSS. Rounded to a
|
|
1702
|
+
* percent, so the renderers that resolve colors cache a bounded few. Fade
|
|
1703
|
+
* after `shade`, which would mix a translucent color back towards opaque.
|
|
1704
|
+
*/
|
|
1705
|
+
function faded(color, opacity) {
|
|
1706
|
+
const percent = Math.round(opacity * 100);
|
|
1707
|
+
return percent >= 100 ? color : `color-mix(in oklab, ${color} ${percent}%, transparent)`;
|
|
1369
1708
|
}
|
|
1370
1709
|
/** The mean of the projected points, where a tooltip points at. */
|
|
1371
1710
|
function center(points) {
|
|
@@ -1399,80 +1738,99 @@ function cubeMarks(id, faces, color, tooltip) {
|
|
|
1399
1738
|
function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
|
|
1400
1739
|
const { config, camera } = useChart3D();
|
|
1401
1740
|
const series = keys ?? Object.keys(config);
|
|
1402
|
-
const
|
|
1403
|
-
const
|
|
1404
|
-
const
|
|
1405
|
-
const
|
|
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));
|
|
1741
|
+
const names = data.map((d) => String(d[index] ?? ""));
|
|
1742
|
+
const target = barValues(names, data, series);
|
|
1743
|
+
const values = useTween(target, 0);
|
|
1744
|
+
const rows = useTween(Object.fromEntries(names.map((name, i) => [name, i])));
|
|
1745
|
+
const opacity = useTween(Object.fromEntries(names.map((name) => [name, 1])), 0);
|
|
1746
|
+
const cells = barCells(rows, series, values);
|
|
1747
|
+
const box = barBox(data.length, series.length, Object.values(target));
|
|
1748
|
+
const spec = useTweenAxes(box.spec);
|
|
1749
|
+
const depth = useTween({ depth: box.depth }).depth;
|
|
1750
|
+
const world = worldBox(spec, depth);
|
|
1751
|
+
const bars = barShapes(camera, cells, world, cellSize(spec, depth, barSize));
|
|
1416
1752
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1417
1753
|
spec,
|
|
1418
1754
|
world,
|
|
1419
1755
|
titles,
|
|
1420
1756
|
format: {
|
|
1421
|
-
x: (i) => (format.x ?? String)(String(data[i][index] ?? "")),
|
|
1757
|
+
x: (i) => (format.x ?? String)(String(data[i]?.[index] ?? "")),
|
|
1422
1758
|
y: format.y,
|
|
1423
1759
|
z: (j) => config[series[j]]?.label ?? series[j]
|
|
1424
1760
|
}
|
|
1425
1761
|
}), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
|
|
1426
|
-
id: `${bar.
|
|
1762
|
+
id: `${bar.name}-${bar.key}`,
|
|
1427
1763
|
faces: bar.faces,
|
|
1428
1764
|
color: `var(--color-${bar.key})`,
|
|
1429
|
-
title:
|
|
1765
|
+
title: bar.name,
|
|
1430
1766
|
label: config[bar.key]?.label ?? bar.key,
|
|
1431
|
-
value: bar.value
|
|
1767
|
+
value: bar.value,
|
|
1768
|
+
opacity: opacity[bar.name]
|
|
1432
1769
|
})) })] });
|
|
1433
1770
|
}
|
|
1434
|
-
/** The
|
|
1435
|
-
function
|
|
1436
|
-
return data.
|
|
1437
|
-
|
|
1438
|
-
j,
|
|
1771
|
+
/** The cubes of the cells, projected by `camera` and painted back to front by their footprint, as the bars stand on one floor. */
|
|
1772
|
+
function barShapes(camera, cells, world, size) {
|
|
1773
|
+
return cubes3D().camera(camera).data(cells.map(({ name, x, j, key, value }) => Object.assign(barCorners(world, x, j, value, size), {
|
|
1774
|
+
name,
|
|
1439
1775
|
key,
|
|
1440
|
-
value
|
|
1441
|
-
})));
|
|
1776
|
+
value
|
|
1777
|
+
}))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
|
|
1442
1778
|
}
|
|
1443
1779
|
/**
|
|
1444
|
-
* The
|
|
1445
|
-
* along x
|
|
1780
|
+
* The cell of every bar, the rows still gliding away among them: the row
|
|
1781
|
+
* position along x, the series `key` along z, with the tweened value. A cell
|
|
1782
|
+
* only one of the tweens still knows is on its way out and stays unseen.
|
|
1446
1783
|
*/
|
|
1447
|
-
function
|
|
1448
|
-
return Object.
|
|
1784
|
+
function barCells(rows, series, values) {
|
|
1785
|
+
return Object.entries(rows).flatMap(([name, x]) => series.flatMap((key, j) => {
|
|
1786
|
+
const value = values[`${name}-${key}`];
|
|
1787
|
+
return value === void 0 ? [] : [{
|
|
1788
|
+
name,
|
|
1789
|
+
x,
|
|
1790
|
+
j,
|
|
1791
|
+
key,
|
|
1792
|
+
value
|
|
1793
|
+
}];
|
|
1794
|
+
}));
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* The value of every cell of the chart, keyed as its mark: the name of its
|
|
1798
|
+
* entry along x and the series `key` along z, standing as 0 where the entry
|
|
1799
|
+
* holds none. Keyed by name, not place, so a bar follows its entry when the
|
|
1800
|
+
* data reorders.
|
|
1801
|
+
*/
|
|
1802
|
+
function barValues(names, data, series) {
|
|
1803
|
+
return Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${names[i]}-${key}`, Number(d[key]) || 0])));
|
|
1449
1804
|
}
|
|
1450
1805
|
/**
|
|
1451
1806
|
* 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.
|
|
1807
|
+
* with half a cell of room between the outer rows and the walls.
|
|
1454
1808
|
*/
|
|
1455
|
-
function barBox(entries, series, values
|
|
1809
|
+
function barBox(entries, series, values) {
|
|
1456
1810
|
const index = (length) => Array.from({ length }, (_, i) => i);
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1811
|
+
return {
|
|
1812
|
+
spec: {
|
|
1813
|
+
x: pad({
|
|
1814
|
+
domain: [-.5, entries - .5],
|
|
1815
|
+
ticks: index(entries)
|
|
1816
|
+
}, 1),
|
|
1817
|
+
y: axis(Math.min(0, ...values), Math.max(0, ...values)),
|
|
1818
|
+
z: pad({
|
|
1819
|
+
domain: [-.5, series - .5],
|
|
1820
|
+
ticks: index(series)
|
|
1821
|
+
}, 1)
|
|
1822
|
+
},
|
|
1823
|
+
depth: Math.min(1, Math.max(.25, series / entries))
|
|
1467
1824
|
};
|
|
1468
|
-
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* The size of a bar, the share `barSize` of its cell - of the gliding ends of
|
|
1828
|
+
* `spec`, so a bar eases into a new cell size as rows come and go.
|
|
1829
|
+
*/
|
|
1830
|
+
function cellSize(spec, depth, barSize) {
|
|
1469
1831
|
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
|
-
}
|
|
1832
|
+
width: 2 / (spec.x.domain[1] - spec.x.domain[0]) * barSize,
|
|
1833
|
+
depth: 2 * depth / (spec.z.domain[1] - spec.z.domain[0]) * barSize
|
|
1476
1834
|
};
|
|
1477
1835
|
}
|
|
1478
1836
|
/**
|
|
@@ -1490,8 +1848,8 @@ function barCorners(world, x, z, value, size) {
|
|
|
1490
1848
|
height: Math.abs(floor - top)
|
|
1491
1849
|
});
|
|
1492
1850
|
}
|
|
1493
|
-
/** The marks of one bar: the faces that look at the camera, shaded
|
|
1494
|
-
function barMarks({ id, faces, color, title, label, value }) {
|
|
1851
|
+
/** 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. */
|
|
1852
|
+
function barMarks({ id, faces, color, title, label, value, opacity }) {
|
|
1495
1853
|
return cubeMarks(id, faces, color, {
|
|
1496
1854
|
at: center(faces.find((face) => face.face === "top")),
|
|
1497
1855
|
title,
|
|
@@ -1500,51 +1858,49 @@ function barMarks({ id, faces, color, title, label, value }) {
|
|
|
1500
1858
|
value,
|
|
1501
1859
|
color
|
|
1502
1860
|
}]
|
|
1503
|
-
})
|
|
1861
|
+
}).map((mark) => ({
|
|
1862
|
+
...mark,
|
|
1863
|
+
fill: faded(mark.fill, opacity),
|
|
1864
|
+
groups: [id]
|
|
1865
|
+
}));
|
|
1504
1866
|
}
|
|
1505
1867
|
// registry/new-york/ui/scatter-chart-3d.tsx
|
|
1506
1868
|
/**
|
|
1507
1869
|
* Points in the chart box, at the fields `x`, `y` and `z` of each entry.
|
|
1508
1870
|
* `series` names the field whose value is the config key of the color.
|
|
1509
1871
|
*/
|
|
1510
|
-
function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
|
|
1872
|
+
function ScatterChart3D({ data, x, y, z, id, series, radius = 4, format, titles }) {
|
|
1511
1873
|
const { config, camera } = useChart3D();
|
|
1512
1874
|
const fields = {
|
|
1513
1875
|
x,
|
|
1514
1876
|
y,
|
|
1515
1877
|
z
|
|
1516
1878
|
};
|
|
1517
|
-
const
|
|
1518
|
-
const read = (
|
|
1879
|
+
const names = data.map((d, i) => id ? String(d[id]) : String(i));
|
|
1880
|
+
const { radii, opacity, read, key } = usePointTweens(names, data, fields, series, config, radius);
|
|
1519
1881
|
const spec = useTweenAxes(pointAxes(data, fields));
|
|
1520
1882
|
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
|
-
|
|
1542
|
-
label: titles?.[a] ?? fields[a],
|
|
1543
|
-
value: read(p.i, a)
|
|
1544
|
-
}))
|
|
1545
|
-
}
|
|
1546
|
-
};
|
|
1547
|
-
});
|
|
1883
|
+
const placed = usePointPlaces(names, radii, read, world);
|
|
1884
|
+
const marks = points3D().camera(camera).x((p) => p.x).y((p) => p.y).z((p) => p.z).data(placed).sort(sort).map((p) => ({
|
|
1885
|
+
key: p.name,
|
|
1886
|
+
points: [p.projected],
|
|
1887
|
+
r: radii[p.name],
|
|
1888
|
+
fill: faded(`var(--color-${key(p.name)})`, opacity[p.name]),
|
|
1889
|
+
groups: [p.name],
|
|
1890
|
+
stroke: faded("var(--background)", opacity[p.name]),
|
|
1891
|
+
tooltip: {
|
|
1892
|
+
at: p.projected,
|
|
1893
|
+
title: series ? config[key(p.name)]?.label ?? key(p.name) : void 0,
|
|
1894
|
+
items: [
|
|
1895
|
+
"x",
|
|
1896
|
+
"y",
|
|
1897
|
+
"z"
|
|
1898
|
+
].map((a) => ({
|
|
1899
|
+
label: titles?.[a] ?? fields[a],
|
|
1900
|
+
value: read(p.name, a)
|
|
1901
|
+
}))
|
|
1902
|
+
}
|
|
1903
|
+
}));
|
|
1548
1904
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1549
1905
|
spec,
|
|
1550
1906
|
world,
|
|
@@ -1564,13 +1920,74 @@ function pointAxes(data, fields) {
|
|
|
1564
1920
|
z: range("z")
|
|
1565
1921
|
};
|
|
1566
1922
|
}
|
|
1567
|
-
/**
|
|
1568
|
-
|
|
1923
|
+
/**
|
|
1924
|
+
* The tweened numbers of the chart, by the name of each entry: the positions
|
|
1925
|
+
* glide entry by entry - a gone point stands in place - and the radius and
|
|
1926
|
+
* the opacity enter from 0, so a new point grows as it fades in and a gone
|
|
1927
|
+
* one shrinks as it fades away. `read` and `key` look them up, also for a
|
|
1928
|
+
* point still leaving.
|
|
1929
|
+
*/
|
|
1930
|
+
function usePointTweens(names, data, fields, series, config, radius) {
|
|
1931
|
+
const values = useTween(pointValues(names, data, fields));
|
|
1932
|
+
const radii = useTween(Object.fromEntries(names.map((name) => [name, radius])), 0);
|
|
1933
|
+
const opacity = useTween(Object.fromEntries(names.map((name) => [name, 1])), 0);
|
|
1934
|
+
const fallback = Object.keys(config)[0];
|
|
1935
|
+
return {
|
|
1936
|
+
radii,
|
|
1937
|
+
opacity,
|
|
1938
|
+
read: (name, axis) => values[`${axis}${name}`],
|
|
1939
|
+
key: usePointKeys(names, data, series, fallback, radii)
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* The place of every point in the world box, by name. A living point follows
|
|
1944
|
+
* its gliding numbers through the gliding axes; a gone one stays frozen where
|
|
1945
|
+
* it was, so it shrinks away in place instead of riding the rescaling axes
|
|
1946
|
+
* out of the box. A point only one of the tweens still knows stays unseen.
|
|
1947
|
+
*/
|
|
1948
|
+
function usePointPlaces(names, alive, read, world) {
|
|
1949
|
+
const places = React.useRef(/* @__PURE__ */ new Map());
|
|
1950
|
+
const live = new Set(names);
|
|
1951
|
+
const placed = Object.keys(alive).flatMap((name) => {
|
|
1952
|
+
if (!live.has(name)) {
|
|
1953
|
+
const held = places.current.get(name);
|
|
1954
|
+
return held ? [{
|
|
1955
|
+
name,
|
|
1956
|
+
...held
|
|
1957
|
+
}] : [];
|
|
1958
|
+
}
|
|
1959
|
+
const p = {
|
|
1960
|
+
x: world.x(read(name, "x")),
|
|
1961
|
+
y: world.y(read(name, "y")),
|
|
1962
|
+
z: world.z(read(name, "z"))
|
|
1963
|
+
};
|
|
1964
|
+
places.current.set(name, p);
|
|
1965
|
+
return [{
|
|
1966
|
+
name,
|
|
1967
|
+
...p
|
|
1968
|
+
}];
|
|
1969
|
+
});
|
|
1970
|
+
for (const name of places.current.keys()) if (!(name in alive)) places.current.delete(name);
|
|
1971
|
+
return placed;
|
|
1972
|
+
}
|
|
1973
|
+
/** The position of every entry, keyed by its axis and name, gliding into new data. */
|
|
1974
|
+
function pointValues(names, data, fields) {
|
|
1569
1975
|
return Object.fromEntries(data.flatMap((d, i) => [
|
|
1570
1976
|
"x",
|
|
1571
1977
|
"y",
|
|
1572
1978
|
"z"
|
|
1573
|
-
].map((a) => [`${a}${i}`, Number(d[fields[a]])])));
|
|
1979
|
+
].map((a) => [`${a}${names[i]}`, Number(d[fields[a]])])));
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* The config key of every point's color, by name - remembered while a gone
|
|
1983
|
+
* point shrinks away, since its entry has already left the data - and
|
|
1984
|
+
* forgotten with the point, so the memory stays the size of the chart.
|
|
1985
|
+
*/
|
|
1986
|
+
function usePointKeys(names, data, series, fallback, alive) {
|
|
1987
|
+
const known = React.useRef(/* @__PURE__ */ new Map());
|
|
1988
|
+
data.forEach((d, i) => known.current.set(names[i], series ? String(d[series]) : fallback));
|
|
1989
|
+
for (const name of known.current.keys()) if (!(name in alive) && !names.includes(name)) known.current.delete(name);
|
|
1990
|
+
return (name) => known.current.get(name) ?? fallback;
|
|
1574
1991
|
}
|
|
1575
1992
|
// registry/new-york/ui/surface-chart-3d.tsx
|
|
1576
1993
|
/**
|
|
@@ -1582,8 +1999,10 @@ function pointValues(data, fields) {
|
|
|
1582
1999
|
function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
1583
2000
|
const { config, camera } = useChart3D();
|
|
1584
2001
|
const [low, high = low] = colors ?? Object.keys(config);
|
|
1585
|
-
const
|
|
1586
|
-
const
|
|
2002
|
+
const [floor = 0, peak = 0] = extent(data.flat());
|
|
2003
|
+
const target = gridValues(data);
|
|
2004
|
+
const heights = gridHeights(useTween(target), floor);
|
|
2005
|
+
const fade = useTween(Object.fromEntries(Object.keys(target).map((k) => [k, 1])), 0);
|
|
1587
2006
|
const rows = data.length;
|
|
1588
2007
|
const columns = data[0]?.length ?? 0;
|
|
1589
2008
|
const [x0, x1] = domain.x ?? [0, columns - 1];
|
|
@@ -1593,7 +2012,6 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
1593
2012
|
z: (z1 - z0) / Math.max(1, rows - 1)
|
|
1594
2013
|
};
|
|
1595
2014
|
const [lo = 0, hi = 0] = extent(heights.flat());
|
|
1596
|
-
const [floor = 0, peak = 0] = extent(data.flat());
|
|
1597
2015
|
const spec = useTweenAxes({
|
|
1598
2016
|
x: domain.x ? axis(x0, x1) : pad(axis(x0, x1), cell.x),
|
|
1599
2017
|
y: axis(floor, peak),
|
|
@@ -1602,28 +2020,20 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
1602
2020
|
const world = worldBox(spec);
|
|
1603
2021
|
const toX = linear([0, columns - 1], [x0, x1]);
|
|
1604
2022
|
const toZ = linear([0, rows - 1], [z0, z1]);
|
|
1605
|
-
const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1,
|
|
2023
|
+
const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1, heights[0]?.length ?? 0));
|
|
1606
2024
|
const points = heights.flatMap((row, r) => row.map((y, c) => ({
|
|
1607
2025
|
x: toX(c),
|
|
1608
2026
|
y,
|
|
1609
|
-
z: toZ(r)
|
|
2027
|
+
z: toZ(r),
|
|
2028
|
+
r,
|
|
2029
|
+
c
|
|
1610
2030
|
})));
|
|
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
|
-
});
|
|
2031
|
+
const marks = grid.data(points).sort(sort).map((quad) => quadMark(quad, fade, {
|
|
2032
|
+
lo,
|
|
2033
|
+
hi,
|
|
2034
|
+
low,
|
|
2035
|
+
high
|
|
2036
|
+
}, titles?.y));
|
|
1627
2037
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1628
2038
|
spec,
|
|
1629
2039
|
world,
|
|
@@ -1631,460 +2041,48 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
|
|
|
1631
2041
|
titles
|
|
1632
2042
|
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
1633
2043
|
}
|
|
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
2044
|
/**
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
* to node, as thick as its value, that turns from the color of its source to
|
|
1648
|
-
* the color of its target.
|
|
2045
|
+
* The mark of one cell: its quad, shaded by the color ramp at its height and
|
|
2046
|
+
* faded in place by `fade` while the cell enters or leaves.
|
|
1649
2047
|
*/
|
|
1650
|
-
function
|
|
1651
|
-
const
|
|
1652
|
-
const
|
|
1653
|
-
const
|
|
1654
|
-
|
|
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) {
|
|
2048
|
+
function quadMark(quad, fade, ramp, title) {
|
|
2049
|
+
const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
|
|
2050
|
+
const t = Math.round((height - ramp.lo) / (ramp.hi - ramp.lo || 1) * 100);
|
|
2051
|
+
const shaded = shade(quad, `color-mix(in oklch, var(--color-${ramp.high}) ${t}%, var(--color-${ramp.low}))`);
|
|
2052
|
+
const seen = Math.min(...quad.map((p) => fade[`${p.r}-${p.c}`] ?? 0));
|
|
1663
2053
|
return {
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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,
|
|
2054
|
+
key: quad.plane,
|
|
2055
|
+
points: quad.map((p) => p.projected),
|
|
2056
|
+
fill: seen < 1 ? `color-mix(in srgb, ${shaded} ${Math.round(seen * 100)}%, transparent)` : shaded,
|
|
2057
|
+
groups: [quad.plane],
|
|
2058
|
+
tooltip: {
|
|
2059
|
+
at: center(quad),
|
|
1687
2060
|
items: [{
|
|
1688
|
-
label,
|
|
1689
|
-
value:
|
|
1690
|
-
color
|
|
2061
|
+
label: title ?? "value",
|
|
2062
|
+
value: height
|
|
1691
2063
|
}]
|
|
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
|
-
/**
|
|
1745
|
-
* The nodes of `data` in the order they first appear, and the flows between
|
|
1746
|
-
* them, without the ones of no value. The column of a node is the longest
|
|
1747
|
-
* path to it from a source; the sinks move to the last column.
|
|
1748
|
-
*/
|
|
1749
|
-
function sankeyColumns(data) {
|
|
1750
|
-
const nodes = /* @__PURE__ */ new Map();
|
|
1751
|
-
const node = (id) => {
|
|
1752
|
-
if (!nodes.has(id)) nodes.set(id, newNode(id, 0));
|
|
1753
|
-
return nodes.get(id);
|
|
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;
|
|
1774
|
-
return {
|
|
1775
|
-
nodes: [...nodes.values()],
|
|
1776
|
-
flows,
|
|
1777
|
-
columns: last + 1
|
|
1778
|
-
};
|
|
1779
|
-
}
|
|
1780
|
-
/**
|
|
1781
|
-
* Splits every flow into hops from one column to the next. A flow that skips a
|
|
1782
|
-
* column passes it at a waypoint, a node of its own, so it has a slot in every
|
|
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
2064
|
}
|
|
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
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
/**
|
|
1871
|
-
* The lanes of a knot. Every hop takes the lowest lane that no hop crossing it
|
|
1872
|
-
* holds, which needs the fewest, and the lightest lane goes to the front. The
|
|
1873
|
-
* lanes share the depth of a flow, a third of a lane apart, so nothing reaches
|
|
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.
|
|
1877
|
-
*/
|
|
1878
|
-
function knotHops(hops, ky) {
|
|
1879
|
-
const lanes = [];
|
|
1880
|
-
for (const [i, hop] of hops.entries()) {
|
|
1881
|
-
const taken = hops.slice(0, i).flatMap((above, j) => above.y1 > hop.y1 ? [lanes[j]] : []);
|
|
1882
|
-
let lane = 0;
|
|
1883
|
-
while (taken.includes(lane)) lane += 1;
|
|
1884
|
-
lanes.push(lane);
|
|
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
2065
|
};
|
|
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;
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
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
2066
|
}
|
|
1914
|
-
/** The
|
|
1915
|
-
function
|
|
1916
|
-
return
|
|
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));
|
|
2067
|
+
/** The height of every cell, keyed by its row and column, gliding into new data. */
|
|
2068
|
+
function gridValues(data) {
|
|
2069
|
+
return Object.fromEntries(data.flatMap((row, r) => row.map((y, c) => [`${r}-${c}`, y])));
|
|
2059
2070
|
}
|
|
2060
2071
|
/**
|
|
2061
|
-
* The
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
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.
|
|
2072
|
+
* The gliding heights as a lattice. It spans the cells still leaving too, so
|
|
2073
|
+
* a shrunken grid sinks away instead of popping; a corner only one dimension
|
|
2074
|
+
* kept stands at the floor until it goes.
|
|
2066
2075
|
*/
|
|
2067
|
-
function
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
-
};
|
|
2076
|
+
function gridHeights(tweened, floor) {
|
|
2077
|
+
let rows = 0;
|
|
2078
|
+
let columns = 0;
|
|
2079
|
+
for (const key of Object.keys(tweened)) {
|
|
2080
|
+
const [r, c] = key.split("-").map(Number);
|
|
2081
|
+
rows = Math.max(rows, r + 1);
|
|
2082
|
+
columns = Math.max(columns, c + 1);
|
|
2083
|
+
}
|
|
2084
|
+
return Array.from({ length: rows }, (_, r) => Array.from({ length: columns }, (_, c) => tweened[`${r}-${c}`] ?? floor));
|
|
2087
2085
|
}
|
|
2088
|
-
export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D,
|
|
2086
|
+
export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, faded, intoWorld, linear, pad, shade, useChart3D, useTween, useTweenAxes, worldBox };
|
|
2089
2087
|
|
|
2090
2088
|
//# sourceMappingURL=index.js.map
|