@niekes/charts-3d 0.4.0 → 0.6.0

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