@niekes/charts-3d 0.6.0 → 0.7.0

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