@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/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: ["Space", "Drag"]
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: ["Ctrl", "Scroll"]
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, a gesture in a group of its own.
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 { action, label, keys, hint } of shortcuts) {
192
- const parts = hint ?? split(keys[0]).map((k) => KBD[k] ?? k);
193
- const group = hint ? action : parts.slice(0, -1).join("+");
194
- const groups = rows.get(label) ?? /* @__PURE__ */ new Map();
195
- const known = groups.get(group);
196
- rows.set(label, groups.set(group, known ? [...known, parts.at(-1)] : parts));
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, groups]) => ({
216
+ return [...rows].map(([label, row]) => ({
199
217
  label,
200
- groups: [...groups.values()]
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") && "Drag to rotate it.",
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 && "cursor-grab touch-none active:cursor-grabbing")
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 goes
391
- * at once. Without time, or with reduced motion, the numbers jump.
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
- return Object.fromEntries(Object.entries(to).map(([k, end]) => {
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 are those of `spec` that lie between the ends.
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 ends = useTween(Object.fromEntries([
467
+ const axes = [
443
468
  "x",
444
469
  "y",
445
470
  "z"
446
- ].flatMap((a) => [[`${a}0`, spec[a].domain[0]], [`${a}1`, spec[a].domain[spec[a].domain.length - 1]]])));
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 inside = (t) => t >= Math.min(lo, hi) && t <= Math.max(lo, hi);
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: spec[a].ticks.filter(inside)
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 Space is held, the keys
535
- * step it, Ctrl or ⌘ with the wheel zooms it and a double click resets it.
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 { space, ...keyHandlers } = useKeys(keys, step, setOrbiting);
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
- if (space.current || keys.on("dragRotate")) drag.current = {
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(!space.current);
579
- move(space.current ? { pan: {
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
- * The handlers that step the camera with the keys of `keys`, and `space`,
597
- * which is true while Space is held for a drag to pan. A key that turns the
598
- * camera marks it as orbiting until it is let go.
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 = (e) => {
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 Ctrl or ⌘ and the wheel, which
624
- * is also how a trackpad pinches, if `on`. The wheel alone still scrolls the page.
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.ctrlKey && !e.metaKey) return;
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. `hover` sets the tooltip only when the pointer moves
641
- * onto another mark, and the focus only when the groups change, so a move
642
- * within a mark does not render the chart again. Off the marks and while a
643
- * drag turns the chart, `hide` drops the tooltip but keeps the focus, so a
644
- * flow does not flash in the gaps beside it and can be followed from every
645
- * side; `leave` drops both.
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
- if (mark?.groups) focus.set(mark.groups);
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: () => show(void 0),
665
- leave: () => {
666
- show(void 0);
667
- focus.set(void 0);
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
- * Whether an item fades under the focus: it has groups and shares none with
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
- const lit = (of) => of?.some((group) => groups?.includes(group)) ?? false;
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
- const canvas = requested === "canvas" && document.createElement("canvas").getContext("2d");
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
- paint(canvas, scene.current, width, (css) => {
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
- }, [scene, width]);
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 !== "canvas") return null;
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 fades = useFades(marks);
877
- const drawn = marks.map((mark) => fades(mark) ? {
1097
+ const lit = useLit(marks);
1098
+ const drawn = marks.map((mark) => lit(mark) ? {
878
1099
  ...mark,
879
- fill: fade(mark.fill)
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 === "canvas") return null;
886
- return drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
887
- d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
888
- fill,
889
- stroke: fill,
890
- strokeWidth: .5,
891
- strokeLinejoin: "round"
892
- }, key) : /* @__PURE__ */ jsx("circle", {
893
- cx: points[0].x,
894
- cy: points[0].y,
895
- r,
896
- fill,
897
- stroke,
898
- strokeWidth: stroke ? 1 : void 0
899
- }, key));
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: cn("fill-foreground stroke-background text-[10px] font-medium", fades(label) && "opacity-25"),
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
- if (!colorConfig.length) return null;
928
- return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: Object.entries(THEMES).map(([theme, prefix]) => `
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-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4",
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
- /** The list of the shortcut panel: per row, its groups of keys and its label. */
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, groups }) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("dt", {
1012
- className: "flex flex-wrap gap-x-2 gap-y-1",
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: box.draw(wall),
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
- }, `${wall.axis}${wall.side}`)),
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
- function edgeLabels(edge, format, title) {
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 depth = Math.max(...ticks.map((t) => Math.abs(n.x) * t.w + Math.abs(n.y) * t.h));
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(ticks, n, row);
1128
- const start = edge[0].projected;
1129
- const end = edge[edge.length - 1].projected;
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
- if (!chosen) for (const l of [...ticks, ...head ? [head] : []]) l.opacity = 0;
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-200 ease-out motion-reduce:transition-none", ink)
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-200 ease-out motion-reduce:transition-none", title ? "fill-foreground font-medium" : "fill-muted-foreground text-[10px]"),
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
- /** `color` out of focus: a quarter of it over the background, opaque, so the seams between faces stay closed. */
1367
- function fade(color) {
1368
- return `color-mix(in oklch, ${color} 25%, var(--background))`;
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 target = barValues(data, series);
1403
- const cells = barCells(data, series, useTween(target, 0));
1404
- const box = barBox(data.length, series.length, Object.values(target), barSize);
1405
- const spec = {
1406
- ...box.spec,
1407
- y: useTweenAxes(box.spec).y
1408
- };
1409
- const world = worldBox(spec, box.depth);
1410
- const { size } = box;
1411
- const bars = cubes3D().camera(camera).data(cells.map(({ i, j, key, value }) => Object.assign(barCorners(world, i, j, value, size), {
1412
- i,
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.i}-${bar.key}`,
1762
+ id: `${bar.name}-${bar.key}`,
1427
1763
  faces: bar.faces,
1428
1764
  color: `var(--color-${bar.key})`,
1429
- title: String(data[bar.i][index]),
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 cell of every bar: the entry `i` along x, the series `key` along z, with its tweened value. */
1435
- function barCells(data, series, values) {
1436
- return data.flatMap((_, i) => series.map((key, j) => ({
1437
- i,
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: values[`${i}-${key}`]
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 value of every cell of the chart, keyed as its mark: the entry `i`
1445
- * along x and the series `key` along z, standing as 0 where the entry holds none.
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 barValues(data, series) {
1448
- return Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${i}-${key}`, Number(d[key]) || 0])));
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, and the
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, barSize) {
1809
+ function barBox(entries, series, values) {
1456
1810
  const index = (length) => Array.from({ length }, (_, i) => i);
1457
- const spec = {
1458
- x: pad({
1459
- domain: [-.5, entries - .5],
1460
- ticks: index(entries)
1461
- }, 1),
1462
- y: axis(Math.min(0, ...values), Math.max(0, ...values)),
1463
- z: pad({
1464
- domain: [-.5, series - .5],
1465
- ticks: index(series)
1466
- }, 1)
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
- const depth = Math.min(1, Math.max(.25, series / entries));
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, each with its entry and value on hover. */
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 values = useTween(pointValues(data, fields));
1518
- const read = (i, axis) => values[`${axis}${i}`];
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 points = points3D().camera(camera).x(({ i }) => world.x(read(i, "x"))).y(({ i }) => world.y(read(i, "y"))).z(({ i }) => world.z(read(i, "z")));
1522
- const fallback = Object.keys(config)[0];
1523
- const marks = points.data(data.map((d, i) => ({
1524
- d,
1525
- i
1526
- }))).sort(sort).map((p) => {
1527
- const key = series ? String(p.d[series]) : fallback;
1528
- return {
1529
- key: String(p.i),
1530
- points: [p.projected],
1531
- r: radius,
1532
- fill: `var(--color-${key})`,
1533
- stroke: "var(--background)",
1534
- tooltip: {
1535
- at: p.projected,
1536
- title: series ? config[key]?.label ?? key : void 0,
1537
- items: [
1538
- "x",
1539
- "y",
1540
- "z"
1541
- ].map((a) => ({
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
- /** The position of every entry, keyed by its axis and index, gliding into new data. */
1568
- function pointValues(data, fields) {
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 tweened = useTween(gridValues(data));
1586
- const heights = data.map((row, r) => row.map((_, c) => tweened[`${r}-${c}`]));
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, columns));
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
- const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
1613
- const t = Math.round((height - lo) / (hi - lo || 1) * 100);
1614
- return {
1615
- key: quad.plane,
1616
- points: quad.map((p) => p.projected),
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
- * 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.
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 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) {
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
- 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,
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: format(node.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 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));
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 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.
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 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
- };
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, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, intoWorld, linear, pad, shade, useChart3D, useTween, useTweenAxes, worldBox };
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