@niekes/charts-3d 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,108 +26,241 @@ const KEY_STEP = Math.PI / 36;
26
26
  const PAN_STEP = 20;
27
27
  const ZOOM_STEP = 1.2;
28
28
  const CLICK_DISTANCE = 4;
29
- const SHORTCUTS = [
30
- {
31
- hint: ["Drag"],
32
- label: "Rotate"
29
+ const ZOOM_CURSOR = 400;
30
+ const SHORTCUTS = {
31
+ dragRotate: {
32
+ label: "Rotate",
33
+ hint: ["Mod", "Drag"]
33
34
  },
34
- {
35
- hint: [
36
- "←",
37
- "→",
38
- "↑",
39
- "↓"
40
- ],
35
+ rotateLeft: {
41
36
  label: "Rotate",
42
- keys: {
43
- ArrowLeft: { yaw: -KEY_STEP },
44
- ArrowRight: { yaw: KEY_STEP },
45
- ArrowUp: { tilt: -KEY_STEP },
46
- ArrowDown: { tilt: KEY_STEP }
47
- }
37
+ keys: ["ArrowLeft"],
38
+ move: { yaw: -KEY_STEP }
48
39
  },
49
- {
50
- hint: ["Space", "Drag"],
51
- label: "Pan"
40
+ rotateRight: {
41
+ label: "Rotate",
42
+ keys: ["ArrowRight"],
43
+ move: { yaw: KEY_STEP }
52
44
  },
53
- {
54
- hint: [
55
- "Shift",
56
- "←",
57
- "→",
58
- "↑",
59
- "↓"
60
- ],
45
+ rotateUp: {
46
+ label: "Rotate",
47
+ keys: ["ArrowUp"],
48
+ move: { tilt: -KEY_STEP }
49
+ },
50
+ rotateDown: {
51
+ label: "Rotate",
52
+ keys: ["ArrowDown"],
53
+ move: { tilt: KEY_STEP }
54
+ },
55
+ dragPan: {
61
56
  label: "Pan",
62
- keys: {
63
- "Shift+ArrowLeft": { pan: {
64
- x: -20,
65
- y: 0
66
- } },
67
- "Shift+ArrowRight": { pan: {
68
- x: PAN_STEP,
69
- y: 0
70
- } },
71
- "Shift+ArrowUp": { pan: {
72
- x: 0,
73
- y: -20
74
- } },
75
- "Shift+ArrowDown": { pan: {
76
- x: 0,
77
- y: PAN_STEP
78
- } }
79
- }
57
+ hint: ["Alt", "Drag"]
80
58
  },
81
- {
82
- hint: ["+", "−"],
59
+ panLeft: {
60
+ label: "Pan",
61
+ keys: ["Shift+ArrowLeft"],
62
+ move: { pan: {
63
+ x: -20,
64
+ y: 0
65
+ } }
66
+ },
67
+ panRight: {
68
+ label: "Pan",
69
+ keys: ["Shift+ArrowRight"],
70
+ move: { pan: {
71
+ x: PAN_STEP,
72
+ y: 0
73
+ } }
74
+ },
75
+ panUp: {
76
+ label: "Pan",
77
+ keys: ["Shift+ArrowUp"],
78
+ move: { pan: {
79
+ x: 0,
80
+ y: -20
81
+ } }
82
+ },
83
+ panDown: {
84
+ label: "Pan",
85
+ keys: ["Shift+ArrowDown"],
86
+ move: { pan: {
87
+ x: 0,
88
+ y: PAN_STEP
89
+ } }
90
+ },
91
+ zoomIn: {
83
92
  label: "Zoom",
84
- keys: {
85
- "+": { zoom: ZOOM_STEP },
86
- "=": { zoom: ZOOM_STEP },
87
- "-": { zoom: 1 / ZOOM_STEP }
88
- }
93
+ keys: ["Plus", "="],
94
+ move: { zoom: ZOOM_STEP }
89
95
  },
90
- {
91
- hint: ["Ctrl", "Scroll"],
92
- label: "Zoom"
96
+ zoomOut: {
97
+ label: "Zoom",
98
+ keys: ["-"],
99
+ move: { zoom: 1 / ZOOM_STEP }
93
100
  },
94
- {
95
- hint: ["0"],
101
+ wheelZoom: {
102
+ label: "Zoom",
103
+ hint: ["Mod", "Scroll"]
104
+ },
105
+ reset: {
96
106
  label: "Reset",
97
- keys: { "0": { reset: true } }
107
+ keys: ["0"],
108
+ move: { reset: true }
98
109
  },
99
- {
100
- hint: ["Double-click"],
101
- label: "Reset"
110
+ doubleClickReset: {
111
+ label: "Reset",
112
+ hint: ["Double-click"]
102
113
  },
103
- {
104
- hint: ["?"],
105
- label: "Shortcuts"
114
+ help: {
115
+ label: "Keyboard shortcuts",
116
+ keys: ["?"]
106
117
  }
118
+ };
119
+ const MODIFIERS = [
120
+ "Control",
121
+ "Alt",
122
+ "Meta",
123
+ "Shift"
107
124
  ];
108
- const KEYS = Object.assign({}, ...SHORTCUTS.map((s) => s.keys));
109
- const KEY_SHORTCUTS = Object.keys(KEYS).map((k) => k === "+" ? "Plus" : k).join(" ");
125
+ /** Whether the reader is on a Mac, where the platform modifier is ⌘; elsewhere it is Ctrl. */
126
+ const mac = () => /mac/i.test(globalThis.navigator?.userAgentData?.platform ?? globalThis.navigator?.platform ?? "");
127
+ /** The label of the platform modifier: ⌘ on a Mac, Ctrl elsewhere. */
128
+ const modLabel = () => mac() ? "⌘" : "Ctrl";
129
+ /** The label of the Alt key: ⌥ on a Mac, Alt elsewhere. */
130
+ const altLabel = () => mac() ? "⌥" : "Alt";
131
+ /** Whether the platform modifier - ⌘ on a Mac, Ctrl elsewhere - is held in `e`. */
132
+ const platformMod = (e) => mac() ? e.metaKey : e.ctrlKey;
133
+ /** Whether `value` lies between the ends `lo` and `hi`. */
134
+ const within = (lo, hi, value) => value >= lo && value <= hi;
135
+ const NAMED = {
136
+ Plus: "+",
137
+ Space: " "
138
+ };
139
+ const ARIA = {
140
+ "+": "Plus",
141
+ " ": "Space"
142
+ };
143
+ const KBD = {
144
+ ArrowLeft: "←",
145
+ ArrowRight: "→",
146
+ ArrowUp: "↑",
147
+ ArrowDown: "↓",
148
+ "-": "−",
149
+ " ": "Space",
150
+ Control: "Ctrl",
151
+ Meta: "⌘"
152
+ };
153
+ /** Keys split at `+`, which is the key itself at the end: `"Control++"` is Control and `+`. */
154
+ const split = (keys) => keys.split(/\+(?!$)/);
155
+ /**
156
+ * Keys in the one form a binding and a press compare in: the modifiers in a
157
+ * fixed order, then the key, Plus as `+` and Space as " ". Shift counts only
158
+ * beside a named key; a character such as "?" or "A" is typed with it already.
159
+ */
160
+ function combo(keys) {
161
+ const parts = split(keys);
162
+ const key = NAMED[parts.at(-1)] ?? parts.at(-1);
163
+ return [...MODIFIERS.filter((m) => parts.includes(m) && (m !== "Shift" || key.length > 1)), key].join("+");
164
+ }
165
+ /** The keys of a press, in the form of `combo`. */
166
+ function pressed(e) {
167
+ return combo([...[
168
+ e.ctrlKey && "Control",
169
+ e.altKey && "Alt",
170
+ e.metaKey && "Meta",
171
+ e.shiftKey && "Shift"
172
+ ], e.key].filter(Boolean).join("+"));
173
+ }
174
+ /** Keys in the form of `combo`, the way `aria-keyshortcuts` writes them. */
175
+ function aria(keys) {
176
+ const parts = split(keys);
177
+ return [...parts.slice(0, -1), ARIA[parts.at(-1)] ?? parts.at(-1)].join("+");
178
+ }
179
+ /** Every action, `keymap` over its defaults: on unless it is off or a key action without keys. */
180
+ function shortcutsOf(keymap) {
181
+ return Object.keys(SHORTCUTS).map((action) => {
182
+ const own = keymap && keymap[action];
183
+ const { keys = [], ...shortcut } = {
184
+ ...SHORTCUTS[action],
185
+ ...Array.isArray(own) ? { keys: own } : own || {}
186
+ };
187
+ const on = own !== false && (shortcut.hint !== void 0 || keys.length > 0);
188
+ return {
189
+ ...shortcut,
190
+ action,
191
+ on,
192
+ keys: keys.map(combo)
193
+ };
194
+ });
195
+ }
196
+ /**
197
+ * The rows of the shortcut panel: the actions by label, the keys of a row in
198
+ * groups of the same modifiers, left of its gestures, each a group of its own.
199
+ */
200
+ function rowsOf(shortcuts) {
201
+ 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);
204
+ const row = rows.get(label) ?? {
205
+ keys: /* @__PURE__ */ new Map(),
206
+ gestures: []
207
+ };
208
+ if (hint) row.gestures.push(parts);
209
+ else {
210
+ const group = parts.slice(0, -1).join("+");
211
+ const known = row.keys.get(group);
212
+ row.keys.set(group, known ? [...known, parts.at(-1)] : parts);
213
+ }
214
+ rows.set(label, row);
215
+ }
216
+ return [...rows].map(([label, row]) => ({
217
+ label,
218
+ keys: [...row.keys.values()],
219
+ gestures: row.gestures
220
+ }));
221
+ }
222
+ /**
223
+ * 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.
226
+ */
227
+ function keymapOf(keymap, panel) {
228
+ const all = shortcutsOf(keymap);
229
+ const on = all.filter((s) => s.on && (panel || s.action !== "help"));
230
+ return {
231
+ on: (action) => on.some((s) => s.action === action),
232
+ moves: Object.fromEntries(on.flatMap((s) => s.keys.map((k) => [k, s.move]))),
233
+ help: on.find((s) => s.action === "help")?.keys ?? [],
234
+ label: all.find((s) => s.action === "help").label,
235
+ aria: on.flatMap((s) => s.keys.map(aria)).join(" "),
236
+ rows: panel ? rowsOf(on) : []
237
+ };
238
+ }
110
239
  function ChartContainer3D({ id, config, defaultView = {
111
240
  yaw: Math.PI / 6,
112
241
  tilt: Math.PI / 6
113
- }, width = 640, height = 400, legend = false, renderer: requested = "svg", shortcuts = true, className, children, "aria-label": ariaLabel = "3D chart. Drag to rotate it, press ? for all shortcuts.", ...props }) {
242
+ }, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", className, children, "aria-label": ariaLabel, ...props }) {
114
243
  const uniqueId = React.useId();
115
244
  const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
116
- const [tooltip, setTooltip] = React.useState(null);
117
- const { scene, renderer, canvas, repaint, hover, leave } = useScene(requested, width, height, setTooltip);
118
- const { view, orbiting, handlers } = useOrbit(defaultView, width, leave, hover);
245
+ const { scene, renderer, canvas, repaint, hover, hide, leave, tooltip, focus } = useScene(requested, width, height);
246
+ const keys = keymapOf(keymap, shortcuts);
247
+ const { view, handlers } = useOrbit(defaultView, width, keys, hide, hover);
119
248
  const camera = useCamera(view, width, height);
120
249
  const context = React.useMemo(() => ({
121
250
  config,
122
251
  camera,
123
252
  renderer,
124
253
  scene,
125
- repaint
254
+ repaint,
255
+ focus,
256
+ animationDuration
126
257
  }), [
127
258
  config,
128
259
  camera,
129
260
  renderer,
130
- repaint
261
+ repaint,
262
+ focus,
263
+ animationDuration
131
264
  ]);
132
265
  return /* @__PURE__ */ jsxs("div", {
133
266
  "data-slot": "chart-3d",
@@ -145,11 +278,7 @@ function ChartContainer3D({ id, config, defaultView = {
145
278
  /* @__PURE__ */ jsx("svg", {
146
279
  viewBox: `0 0 ${width} ${height}`,
147
280
  role: "img",
148
- "aria-label": ariaLabel,
149
- "aria-keyshortcuts": shortcuts ? `${KEY_SHORTCUTS} ?` : KEY_SHORTCUTS,
150
- tabIndex: 0,
151
- className: "block h-auto w-full cursor-grab touch-none outline-hidden select-none mask-y-from-96% active:cursor-grabbing",
152
- "data-orbiting": orbiting,
281
+ ...keymapProps(keys, ariaLabel),
153
282
  onPointerLeave: leave,
154
283
  ...handlers,
155
284
  children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
@@ -163,6 +292,10 @@ function ChartContainer3D({ id, config, defaultView = {
163
292
  width,
164
293
  height
165
294
  }),
295
+ /* @__PURE__ */ jsx(ChartGizmo3D, {
296
+ camera,
297
+ corner: gizmo
298
+ }),
166
299
  /* @__PURE__ */ jsx(ChartTooltip3D, {
167
300
  tooltip,
168
301
  width,
@@ -170,27 +303,52 @@ function ChartContainer3D({ id, config, defaultView = {
170
303
  })
171
304
  ]
172
305
  }),
173
- shortcuts && /* @__PURE__ */ jsx(ChartShortcuts3D, {}),
306
+ /* @__PURE__ */ jsx(ChartShortcuts3D, { ...keys }),
174
307
  legend && /* @__PURE__ */ jsx(ChartLegend3D, { config })
175
308
  ]
176
309
  });
177
310
  }
178
311
  /**
312
+ * What the svg says about its keymap: a label that names the drag and the
313
+ * help key if they are on, the keys, the focus only if a key does something
314
+ * and the grab cursor only if a drag does; without one, a touch scrolls the page.
315
+ */
316
+ function keymapProps(keys, label) {
317
+ const drag = keys.on("dragRotate") || keys.on("dragPan");
318
+ const help = keys.help[0];
319
+ const said = [
320
+ "3D chart.",
321
+ keys.on("dragRotate") && `${modLabel()} and drag to rotate it.`,
322
+ help && `Press ${aria(help)} for all shortcuts.`
323
+ ];
324
+ return {
325
+ "aria-label": label ?? said.filter(Boolean).join(" "),
326
+ "aria-keyshortcuts": keys.aria || void 0,
327
+ tabIndex: keys.aria ? 0 : void 0,
328
+ className: cn("block h-auto w-full outline-hidden select-none mask-x-from-96% mask-y-from-96%", drag && "touch-none")
329
+ };
330
+ }
331
+ /**
179
332
  * The marks of the chart and what reads them: the renderer that draws them,
180
333
  * the canvas it paints them on and the hover that shows their tooltips.
181
334
  */
182
- function useScene(requested, width, height, setTooltip) {
335
+ function useScene(requested, width, height) {
183
336
  const scene = React.useRef([]);
337
+ const [tooltip, setTooltip] = React.useState(null);
338
+ const focus = useFocus();
184
339
  const renderer = useRenderer(requested);
185
- const { canvas, repaint } = useCanvas(scene, width);
186
- const { hover, leave } = useHover(scene, width, height, setTooltip);
340
+ const { canvas, repaint } = useCanvas(scene, width, height, renderer);
341
+ const { hover, hide, leave } = useHover(scene, width, height, setTooltip, focus);
187
342
  return {
188
343
  scene,
189
344
  renderer,
190
345
  canvas,
191
346
  repaint,
192
347
  hover,
193
- leave
348
+ hide,
349
+ leave,
350
+ tooltip,
351
+ focus
194
352
  };
195
353
  }
196
354
  /** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
@@ -204,6 +362,11 @@ function useCamera(view, width, height) {
204
362
  height
205
363
  ]);
206
364
  }
365
+ const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
366
+ const ZERO = {
367
+ x: 0,
368
+ y: 0
369
+ };
207
370
  /** `view` moved by one step. The tilt stops at the zenith and the nadir, the zoom at 1/4 and 8. */
208
371
  function moved(view, move, initial) {
209
372
  if (move.reset) return initial;
@@ -221,7 +384,6 @@ function moved(view, move, initial) {
221
384
  x: 0,
222
385
  y: 0
223
386
  };
224
- const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
225
387
  return {
226
388
  ...view,
227
389
  yaw: view.yaw + step.yaw,
@@ -234,38 +396,230 @@ function moved(view, move, initial) {
234
396
  };
235
397
  }
236
398
  /**
237
- * The view of the camera, and the handlers that move it: a drag turns it,
238
- * or pans it while Space is held, the keys of `SHORTCUTS` step it, Ctrl or ⌘
239
- * with the wheel zooms it and a double click resets it. `orbiting` is true
240
- * while the camera turns. `onDragStart` runs when a press becomes a drag,
241
- * `onHover` on a move without one.
399
+ * Whether the chart may move on its own: only where the browser says the
400
+ * reader has not asked for reduced motion. Where it cannot say, as on the
401
+ * server and in jsdom, the chart jumps.
402
+ */
403
+ function animates() {
404
+ return Boolean(globalThis.matchMedia?.("(prefers-reduced-motion: no-preference)").matches);
405
+ }
406
+ /**
407
+ * Eases a chart into new data: the numbers of `values` glide from the ones
408
+ * on screen to these over the animationDuration of the container. A key
409
+ * that is new starts at `enter`, or where it ends; a key that is gone glides
410
+ * back to `enter`, the mirror of its entering - without one it stands where
411
+ * it is - and leaves when the glide is done. Without time, or with reduced
412
+ * motion, the numbers jump.
242
413
  */
243
- function useOrbit(defaultView, width, onDragStart, onHover) {
414
+ function useTween(values, enter) {
415
+ const { animationDuration: duration = 600 } = useChart3D();
416
+ const key = JSON.stringify(values);
417
+ const [tween, setTween] = React.useState({
418
+ key,
419
+ from: values,
420
+ to: values,
421
+ t: 1
422
+ });
423
+ const shown = tweenNumbers(tween, enter);
424
+ if (key !== tween.key) setTween({
425
+ key,
426
+ from: shown,
427
+ to: values,
428
+ t: duration > 0 && animates() ? 0 : 1
429
+ });
430
+ React.useEffect(() => {
431
+ if (tween.t >= 1) return;
432
+ let start = null;
433
+ let frame = 0;
434
+ const step = (now) => {
435
+ start ??= now;
436
+ const t = Math.min(1, (now - start) / duration);
437
+ setTween((tw) => ({
438
+ ...tw,
439
+ t
440
+ }));
441
+ if (t < 1) frame = requestAnimationFrame(step);
442
+ };
443
+ frame = requestAnimationFrame(step);
444
+ return () => cancelAnimationFrame(frame);
445
+ }, [tween.key, duration]);
446
+ return shown;
447
+ }
448
+ /** The numbers of `tween` on screen: `from` eased into `to`, a new key from `enter` or where it ends, a gone one gliding back to `enter` until the glide is done. */
449
+ function tweenNumbers({ from, to, t }, enter) {
450
+ const s = t * t * (3 - 2 * t);
451
+ const gone = t >= 1 ? [] : Object.entries(from).filter(([k]) => !(k in to));
452
+ return Object.fromEntries([...Object.entries(to).map(([k, end]) => {
453
+ const start = from[k] ?? enter ?? end;
454
+ return [k, t >= 1 ? end : start + (end - start) * s];
455
+ }), ...gone.map(([k, start]) => [k, start + ((enter ?? start) - start) * s])]);
456
+ }
457
+ /**
458
+ * `spec` gliding into new data with the marks: the ends of every axis glide
459
+ * as `useTween` glides the numbers, where ends rounded from the numbers on
460
+ * screen would jump from one round number to the next, and the box with
461
+ * them. The ticks stay those of `spec`; labels of ticks outside the gliding
462
+ * ends fade out in `edgeLabels` instead of popping out of the range. The
463
+ * outermost ticks glide too, so the line of the axis grows to a new tick and
464
+ * shrinks from a dropped one instead of jumping when the glide is done.
465
+ */
466
+ function useTweenAxes(spec) {
467
+ const axes = [
468
+ "x",
469
+ "y",
470
+ "z"
471
+ ];
472
+ const last = (domain) => domain[domain.length - 1];
473
+ const target = Object.fromEntries(axes.flatMap((a) => {
474
+ const { domain, ticks } = spec[a];
475
+ return [
476
+ [`${a}0`, domain[0]],
477
+ [`${a}1`, last(domain)],
478
+ [`${a}t0`, ticks.at(0) ?? domain[0]],
479
+ [`${a}t1`, ticks.at(-1) ?? last(domain)]
480
+ ];
481
+ }));
482
+ const ends = useTween(target);
483
+ const gliding = Object.keys(target).some((k) => ends[k] !== target[k]);
484
+ const key = JSON.stringify(spec);
485
+ const memory = React.useRef({
486
+ key,
487
+ target: spec,
488
+ from: spec
489
+ });
490
+ if (memory.current.key !== key) memory.current = {
491
+ key,
492
+ target: spec,
493
+ from: memory.current.target
494
+ };
495
+ const glided = (a) => {
496
+ const [lo, hi] = [ends[`${a}0`], ends[`${a}1`]];
497
+ const [d0, d1] = [spec[a].domain[0], last(spec[a].domain)];
498
+ const gone = gliding ? memory.current.from[a].ticks.filter((t) => !spec[a].ticks.includes(t)) : [];
499
+ const ticks = gone.length ? [...spec[a].ticks, ...gone].sort((t, u) => t - u) : spec[a].ticks;
500
+ const leaving = gone.filter((t) => within(Math.min(d0, d1), Math.max(d0, d1), t));
501
+ const span = [ends[`${a}t0`], ends[`${a}t1`]];
502
+ return {
503
+ domain: [lo, hi],
504
+ ticks,
505
+ span,
506
+ ...leaving.length ? { leaving } : {}
507
+ };
508
+ };
509
+ return {
510
+ x: glided("x"),
511
+ y: glided("y"),
512
+ z: glided("z")
513
+ };
514
+ }
515
+ const GLIDE = 200;
516
+ /**
517
+ * The view of the camera, `jump`, which moves it at once, and `glide`, which
518
+ * moves it there in `GLIDE` milliseconds, easing out. A glide under way
519
+ * heads for the next view from where it is, so a held key turns the camera
520
+ * smoothly; a jump stops it. Where the reader asks for reduced motion, or
521
+ * the browser cannot say, a glide jumps.
522
+ */
523
+ function useGlide(defaultView) {
244
524
  const [view, setView] = React.useState(defaultView);
525
+ const now = React.useRef(defaultView);
526
+ const run = React.useRef(null);
527
+ const show = React.useCallback((v) => setView(now.current = v), []);
528
+ const stop = React.useCallback(() => {
529
+ cancelAnimationFrame(run.current?.frame ?? 0);
530
+ run.current = null;
531
+ }, []);
532
+ const jump = React.useCallback((to) => {
533
+ stop();
534
+ show(to(now.current));
535
+ }, [show, stop]);
536
+ React.useEffect(() => stop, [stop]);
537
+ const glide = (to) => {
538
+ if (!animates()) return jump(to);
539
+ const end = to(run.current?.to ?? now.current);
540
+ const from = {
541
+ ...now.current,
542
+ yaw: end.yaw + remainder(now.current.yaw - end.yaw)
543
+ };
544
+ const start = performance.now();
545
+ stop();
546
+ const frame = (time) => {
547
+ const t = clamp(0, 1, (time - start) / GLIDE);
548
+ show(t < 1 ? between(from, end, 1 - (1 - t) ** 3) : end);
549
+ if (t < 1) run.current = {
550
+ to: end,
551
+ frame: requestAnimationFrame(frame)
552
+ };
553
+ else run.current = null;
554
+ };
555
+ run.current = {
556
+ to: end,
557
+ frame: requestAnimationFrame(frame)
558
+ };
559
+ };
560
+ return {
561
+ view,
562
+ jump,
563
+ glide
564
+ };
565
+ }
566
+ /** The angle `a` in the turn from -π to π. */
567
+ function remainder(a) {
568
+ return a - 2 * Math.PI * Math.round(a / (2 * Math.PI));
569
+ }
570
+ /** The view the share `k` of the way from `from` to `to`. */
571
+ function between(from, to, k) {
572
+ const mix = (a, b) => a + (b - a) * k;
573
+ const pan = from.pan ?? ZERO;
574
+ const goal = to.pan ?? ZERO;
575
+ return {
576
+ ...to,
577
+ yaw: mix(from.yaw, to.yaw),
578
+ tilt: mix(from.tilt, to.tilt),
579
+ zoom: mix(from.zoom ?? 1, to.zoom ?? 1),
580
+ pan: {
581
+ x: mix(pan.x, goal.x),
582
+ y: mix(pan.y, goal.y)
583
+ }
584
+ };
585
+ }
586
+ /**
587
+ * The view of the camera, and the handlers that move it, as far as `keys`
588
+ * has them on: a drag turns it, or pans it while Alt is held, the keys step
589
+ * it, Ctrl or ⌘ with the wheel zooms it and a double click resets it.
590
+ * `data-orbiting` among them is true while the camera turns. `onDragStart`
591
+ * runs when a press becomes a drag, `onHover` on a move without one.
592
+ */
593
+ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
594
+ const { view, jump, glide } = useGlide(defaultView);
245
595
  const [orbiting, setOrbiting] = React.useState(false);
596
+ const held = useModsHeld();
246
597
  const drag = React.useRef(null);
247
- const space = React.useRef(false);
248
- const ref = useWheelZoom(setView);
249
- const move = (step) => setView((v) => moved(v, step, defaultView));
598
+ const ref = useWheelZoom(jump, keys.on("wheelZoom"));
599
+ const move = (step) => jump((v) => moved(v, step, defaultView));
600
+ const step = (step) => glide((v) => moved(v, step, defaultView));
601
+ const keyHandlers = useKeys(keys, step, setOrbiting);
250
602
  const release = () => {
251
603
  drag.current = null;
252
604
  setOrbiting(false);
253
605
  };
254
606
  return {
255
607
  view,
256
- orbiting,
257
608
  handlers: {
258
609
  ref,
259
610
  onPointerDown: (e) => {
260
- drag.current = {
611
+ const pans = keys.on("dragPan") && e.altKey;
612
+ if (pans || keys.on("dragRotate") && platformMod(e)) drag.current = {
261
613
  x: e.clientX,
262
614
  y: e.clientY,
263
- moved: false
615
+ moved: false,
616
+ rotate: !pans
264
617
  };
265
618
  },
266
619
  onPointerMove: (e) => {
267
620
  const d = drag.current;
268
621
  if (!d) return onHover(e);
622
+ if (d.rotate ? !platformMod(e) : !e.altKey) return release();
269
623
  const dx = e.clientX - d.x;
270
624
  const dy = e.clientY - d.y;
271
625
  if (!d.moved && Math.hypot(dx, dy) < CLICK_DISTANCE) return;
@@ -276,60 +630,120 @@ function useOrbit(defaultView, width, onDragStart, onHover) {
276
630
  drag.current = {
277
631
  x: e.clientX,
278
632
  y: e.clientY,
279
- moved: true
633
+ moved: true,
634
+ rotate: d.rotate
280
635
  };
281
636
  const k = width / e.currentTarget.getBoundingClientRect().width;
282
- setOrbiting(!space.current);
283
- move(space.current ? { pan: {
284
- x: dx * k,
285
- y: dy * k
286
- } } : {
637
+ setOrbiting(d.rotate);
638
+ move(d.rotate ? {
287
639
  yaw: dx * DRAG,
288
640
  tilt: dy * DRAG
289
- });
641
+ } : { pan: {
642
+ x: dx * k,
643
+ y: dy * k
644
+ } });
290
645
  },
291
646
  onPointerUp: release,
292
647
  onPointerCancel: release,
293
- onDoubleClick: () => move({ reset: true }),
294
- onKeyDown: (e) => {
295
- if (e.key === " ") space.current = true;
296
- const step = e.shiftKey && KEYS[`Shift+${e.key}`] || KEYS[e.key];
297
- if (!step && e.key !== " ") return;
298
- e.preventDefault();
299
- if (!step) return;
300
- setOrbiting(Boolean(step.yaw || step.tilt));
301
- move(step);
302
- },
303
- onKeyUp: (e) => {
304
- if (e.key === " ") space.current = false;
305
- setOrbiting(false);
306
- }
648
+ onDoubleClick: keys.on("doubleClickReset") ? () => step({ reset: true }) : void 0,
649
+ ...keyHandlers,
650
+ "data-orbiting": orbiting,
651
+ "data-mod": keys.on("dragRotate") && held.mod ? true : void 0,
652
+ "data-alt": keys.on("dragPan") && held.alt ? true : void 0
307
653
  }
308
654
  };
309
655
  }
310
656
  /**
311
- * A ref for the svg that zooms the view with Ctrl or ⌘ and the wheel, which
312
- * is also how a trackpad pinches. The wheel alone still scrolls the page.
657
+ * Whether the platform modifier or Alt is held, so the cursor shows a hand
658
+ * before the drag rotates and the four arrows before it pans. A missed keyup
659
+ * (window blur while held) must not stick either cursor.
313
660
  */
314
- function useWheelZoom(setView) {
661
+ function useModsHeld() {
662
+ const [mod, setMod] = React.useState(false);
663
+ const [alt, setAlt] = React.useState(false);
664
+ React.useEffect(() => {
665
+ const modKey = mac() ? "Meta" : "Control";
666
+ const onKey = (e) => {
667
+ if (e.key === modKey) setMod(e.type === "keydown");
668
+ if (e.key === "Alt") setAlt(e.type === "keydown");
669
+ };
670
+ const onBlur = () => {
671
+ setMod(false);
672
+ setAlt(false);
673
+ };
674
+ window.addEventListener("keydown", onKey);
675
+ window.addEventListener("keyup", onKey);
676
+ window.addEventListener("blur", onBlur);
677
+ return () => {
678
+ window.removeEventListener("keydown", onKey);
679
+ window.removeEventListener("keyup", onKey);
680
+ window.removeEventListener("blur", onBlur);
681
+ };
682
+ }, []);
683
+ return {
684
+ mod,
685
+ alt
686
+ };
687
+ }
688
+ /**
689
+ * The handlers that step the camera with the keys of `keys`. A key that
690
+ * turns the camera marks it as orbiting until it is let go.
691
+ */
692
+ function useKeys(keys, move, setOrbiting) {
693
+ const onKeyDown = (e) => {
694
+ const step = keys.moves[pressed(e)];
695
+ if (!step) return;
696
+ e.preventDefault();
697
+ setOrbiting(Boolean(step.yaw || step.tilt));
698
+ move(step);
699
+ };
700
+ const onKeyUp = () => setOrbiting(false);
701
+ return {
702
+ onKeyDown,
703
+ onKeyUp
704
+ };
705
+ }
706
+ /**
707
+ * A ref for the svg that zooms the view with the platform modifier and the
708
+ * wheel, if `on`. Ctrl and the wheel zooms on a Mac too, because a trackpad
709
+ * pinch arrives that way, indistinguishable from the keys. The wheel alone
710
+ * still scrolls the page. While the wheel zooms, the svg says so in
711
+ * `data-zoom` - `"in"` or `"out"`, the direction of its cursor - until
712
+ * `ZOOM_CURSOR` after the last tick.
713
+ */
714
+ function useWheelZoom(jump, on) {
315
715
  return React.useCallback((svg) => {
716
+ if (!on || !svg) return;
717
+ let timer;
316
718
  const onWheel = (e) => {
317
- if (!e.ctrlKey && !e.metaKey) return;
719
+ if (!platformMod(e) && !e.ctrlKey) return;
318
720
  e.preventDefault();
319
- setView((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
721
+ jump((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
722
+ svg.dataset.zoom = e.deltaY < 0 ? "in" : "out";
723
+ clearTimeout(timer);
724
+ timer = setTimeout(() => delete svg.dataset.zoom, ZOOM_CURSOR);
725
+ };
726
+ svg.addEventListener("wheel", onWheel, { passive: false });
727
+ return () => {
728
+ clearTimeout(timer);
729
+ svg.removeEventListener("wheel", onWheel);
320
730
  };
321
- svg?.addEventListener("wheel", onWheel, { passive: false });
322
- return () => svg?.removeEventListener("wheel", onWheel);
323
- }, [setView]);
731
+ }, [jump, on]);
324
732
  }
325
733
  /**
326
- * The tooltip of the mark under the pointer, the one painted last. `hover`
327
- * sets it only when the pointer moves onto another mark, so a move within
328
- * one does not render the chart again.
734
+ * The tooltip of the mark under the pointer, the one painted last, and the
735
+ * focus on its groups. The highlight follows the mark: `hover` sets the
736
+ * tooltip only when the pointer moves onto another mark, and the focus only
737
+ * when the groups change, so a move within a mark does not render the chart
738
+ * again; off the mark and when the pointer leaves the chart, `leave` drops
739
+ * both. While a drag turns the chart under the captured pointer, `hide`
740
+ * drops the tooltip and keeps the focus, so a flow does not flash away and
741
+ * can be followed from every side.
329
742
  */
330
- function useHover(scene, width, height, setTooltip) {
743
+ function useHover(scene, width, height, setTooltip, focus) {
331
744
  const hovered = React.useRef(void 0);
332
745
  const show = (mark) => {
746
+ focus.set(mark?.groups);
333
747
  if (mark?.key === hovered.current?.key) return;
334
748
  hovered.current = mark;
335
749
  setTooltip(mark?.tooltip ?? null);
@@ -343,9 +757,39 @@ function useHover(scene, width, height, setTooltip) {
343
757
  };
344
758
  show(hit(scene.current, at));
345
759
  },
760
+ hide: () => {
761
+ hovered.current = void 0;
762
+ setTooltip(null);
763
+ },
346
764
  leave: () => show(void 0)
347
765
  };
348
766
  }
767
+ /** A store for the groups in focus, which tells its listeners only when the groups change. */
768
+ function useFocus() {
769
+ const [focus] = React.useState(() => {
770
+ let groups;
771
+ const listeners = /* @__PURE__ */ new Set();
772
+ return {
773
+ get: () => groups,
774
+ set: (next) => {
775
+ if (JSON.stringify(next) === JSON.stringify(groups)) return;
776
+ groups = next;
777
+ listeners.forEach((listener) => listener());
778
+ },
779
+ subscribe: (listener) => {
780
+ listeners.add(listener);
781
+ return () => listeners.delete(listener);
782
+ }
783
+ };
784
+ });
785
+ return focus;
786
+ }
787
+ /** Whether an item shares a group with the focus: the hover highlights those items with an edge. */
788
+ function useLit(items) {
789
+ const { focus } = useChart3D();
790
+ const groups = React.useSyncExternalStore(focus.subscribe, focus.get, focus.get);
791
+ return (item) => item.groups !== void 0 && (groups?.some((group) => item.groups.includes(group)) ?? false);
792
+ }
349
793
  /** The topmost mark under `at`: the last one painted that holds it. */
350
794
  function hit(marks, at) {
351
795
  for (let i = marks.length - 1; i >= 0; i -= 1) {
@@ -365,12 +809,14 @@ function inside(polygon, at) {
365
809
  }
366
810
  /**
367
811
  * The renderer the chart draws with: SVG on the server and in the first
368
- * render on the client, then the requested one if the browser has it.
812
+ * render on the client, then the requested one if the browser has it, with
813
+ * "webgl" falling back to "canvas" and "canvas" to SVG.
369
814
  */
370
815
  function useRenderer(requested) {
371
816
  const [renderer, setRenderer] = React.useState("svg");
372
817
  React.useEffect(() => {
373
- const canvas = requested === "canvas" && document.createElement("canvas").getContext("2d");
818
+ if (requested === "webgl" && document.createElement("canvas").getContext("webgl")) return setRenderer("webgl");
819
+ const canvas = requested !== "svg" && document.createElement("canvas").getContext("2d");
374
820
  setRenderer(canvas ? "canvas" : "svg");
375
821
  }, [requested]);
376
822
  return renderer;
@@ -378,15 +824,18 @@ function useRenderer(requested) {
378
824
  /**
379
825
  * A ref for the canvas of the marks, and the repaint that paints them: on new
380
826
  * marks, when the canvas changes its size and when the theme changes. A color
381
- * resolves on the canvas itself, inside the chart, so the `--color-*` apply.
827
+ * resolves on the canvas itself, inside the chart, so the `--color-*` apply;
828
+ * webgl takes the resolved color as bytes from a 1×1 canvas.
382
829
  */
383
- function useCanvas(scene, width) {
830
+ function useCanvas(scene, width, height, renderer) {
384
831
  const element = React.useRef(null);
385
832
  const colors = React.useRef(/* @__PURE__ */ new Map());
833
+ const bytes = React.useRef(/* @__PURE__ */ new Map());
834
+ const pixel = React.useRef(null);
386
835
  const repaint = React.useCallback(() => {
387
836
  const canvas = element.current;
388
837
  if (!canvas) return;
389
- paint(canvas, scene.current, width, (css) => {
838
+ const resolve = (css) => {
390
839
  let color = colors.current.get(css);
391
840
  if (color === void 0) {
392
841
  canvas.style.color = css;
@@ -394,8 +843,17 @@ function useCanvas(scene, width) {
394
843
  colors.current.set(css, color);
395
844
  }
396
845
  return color;
397
- });
398
- }, [scene, width]);
846
+ };
847
+ if (renderer === "webgl") {
848
+ pixel.current ??= onePixel();
849
+ paintGL(canvas, scene.current, width, height, (css) => colorBytes(resolve(css), pixel.current, bytes.current));
850
+ } else paint(canvas, scene.current, width, resolve);
851
+ }, [
852
+ scene,
853
+ width,
854
+ height,
855
+ renderer
856
+ ]);
399
857
  return {
400
858
  canvas: React.useCallback((node) => {
401
859
  element.current = node;
@@ -404,6 +862,7 @@ function useCanvas(scene, width) {
404
862
  resize.observe(node);
405
863
  const theme = new MutationObserver(() => {
406
864
  colors.current.clear();
865
+ bytes.current.clear();
407
866
  repaint();
408
867
  });
409
868
  theme.observe(document.documentElement, { attributeFilter: ["class", "style"] });
@@ -432,47 +891,236 @@ function paint(canvas, marks, width, color) {
432
891
  } else ctx.arc(points[0].x, points[0].y, r, 0, 2 * Math.PI);
433
892
  ctx.fillStyle = color(fill);
434
893
  ctx.fill();
435
- const outline = r === void 0 ? fill : stroke;
894
+ const outline = r === void 0 ? stroke ?? fill : stroke;
436
895
  if (!outline) continue;
437
896
  ctx.strokeStyle = color(outline);
438
897
  ctx.lineWidth = r === void 0 ? .5 : 1;
439
898
  ctx.stroke();
440
899
  }
441
900
  }
901
+ /** A 1×1 canvas whose 2D context resolves a color to bytes, for the webgl renderer. */
902
+ function onePixel() {
903
+ const canvas = document.createElement("canvas");
904
+ canvas.width = 1;
905
+ canvas.height = 1;
906
+ return canvas.getContext("2d");
907
+ }
908
+ /** The resolved `color` as bytes, drawn on the 1×1 `pixel` and read back, cached per color. */
909
+ function colorBytes(color, pixel, cache) {
910
+ let rgba = cache.get(color);
911
+ if (rgba === void 0) {
912
+ pixel.clearRect(0, 0, 1, 1);
913
+ pixel.fillStyle = color;
914
+ pixel.fillRect(0, 0, 1, 1);
915
+ rgba = [...pixel.getImageData(0, 0, 1, 1).data];
916
+ cache.set(color, rgba);
917
+ }
918
+ return rgba;
919
+ }
920
+ /**
921
+ * Paints `marks` on a webgl `canvas`, from the view box of `width`×`height` to
922
+ * the pixels of the canvas: a fan per polygon, a fan of 12 segments per
923
+ * circle, in paint order and without a depth test, the color of each as bytes.
924
+ */
925
+ function paintGL(canvas, marks, width, height, color) {
926
+ canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
927
+ canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
928
+ const gl = canvas.getContext("webgl");
929
+ gl.viewport(0, 0, canvas.width, canvas.height);
930
+ gl.disable(gl.DEPTH_TEST);
931
+ gl.enable(gl.BLEND);
932
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
933
+ const program = programOf(gl);
934
+ gl.useProgram(program);
935
+ gl.uniform2f(gl.getUniformLocation(program, "u_res"), 2 / width, 2 / height);
936
+ const where = gl.getAttribLocation(program, "a_pos");
937
+ gl.enableVertexAttribArray(where);
938
+ gl.bindBuffer(gl.ARRAY_BUFFER, bufferOf(gl));
939
+ gl.vertexAttribPointer(where, 2, gl.FLOAT, false, 0, 0);
940
+ const tint = (css) => {
941
+ const [r, g, b, a] = color(css);
942
+ gl.uniform4f(gl.getUniformLocation(program, "u_color"), r / 255, g / 255, b / 255, a / 255);
943
+ };
944
+ const draw = (mode, points) => {
945
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points.flatMap((p) => [p.x, p.y])), gl.DYNAMIC_DRAW);
946
+ gl.drawArrays(mode, 0, points.length);
947
+ };
948
+ const [, widest] = gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE) ?? [1, 1];
949
+ gl.lineWidth(Math.min(1.5 * devicePixelRatio, widest));
950
+ for (const mark of marks) {
951
+ tint(mark.fill);
952
+ draw(gl.TRIANGLES, trianglesOf(mark));
953
+ if (mark.stroke) {
954
+ tint(mark.stroke);
955
+ draw(gl.LINE_LOOP, outlineOf(mark));
956
+ }
957
+ }
958
+ }
959
+ /** The 12 points of the circle around `c` of radius `r`. */
960
+ const ringOf = (c, r) => Array.from({ length: 12 }, (_, i) => ({
961
+ x: c.x + r * Math.cos(i / 12 * 2 * Math.PI),
962
+ y: c.y + r * Math.sin(i / 12 * 2 * Math.PI)
963
+ }));
964
+ /** The triangles of `mark` as view-box points: a fan per polygon, a fan of 12 segments per circle. */
965
+ function trianglesOf(mark) {
966
+ const { points, r } = mark;
967
+ if (r !== void 0) {
968
+ const ring = ringOf(points[0], r);
969
+ return ring.flatMap((p, i) => [
970
+ points[0],
971
+ p,
972
+ ring[(i + 1) % 12]
973
+ ]);
974
+ }
975
+ return points.slice(1, -1).flatMap((p, i) => [
976
+ points[0],
977
+ p,
978
+ points[i + 2]
979
+ ]);
980
+ }
981
+ /** The outline of `mark` as view-box points: its polygon, or the ring of its circle. */
982
+ const outlineOf = (mark) => mark.r === void 0 ? mark.points : ringOf(mark.points[0], mark.r);
983
+ const programs = /* @__PURE__ */ new WeakMap();
984
+ const buffers = /* @__PURE__ */ new WeakMap();
985
+ /** The paint program of `gl`, compiled once: view-box points in, clip space out, one color per draw. */
986
+ function programOf(gl) {
987
+ let program = programs.get(gl);
988
+ if (!program) {
989
+ program = gl.createProgram();
990
+ gl.attachShader(program, shaderOf(gl, gl.VERTEX_SHADER, "attribute vec2 a_pos; uniform vec2 u_res; void main() { gl_Position = vec4(a_pos.x * u_res.x - 1.0, 1.0 - a_pos.y * u_res.y, 0.0, 1.0); }"));
991
+ gl.attachShader(program, shaderOf(gl, gl.FRAGMENT_SHADER, "precision mediump float; uniform vec4 u_color; void main() { gl_FragColor = u_color; }"));
992
+ gl.linkProgram(program);
993
+ programs.set(gl, program);
994
+ }
995
+ return program;
996
+ }
997
+ function shaderOf(gl, type, source) {
998
+ const shader = gl.createShader(type);
999
+ gl.shaderSource(shader, source);
1000
+ gl.compileShader(shader);
1001
+ return shader;
1002
+ }
1003
+ function bufferOf(gl) {
1004
+ let buffer = buffers.get(gl);
1005
+ if (!buffer) {
1006
+ buffer = gl.createBuffer();
1007
+ buffers.set(gl, buffer);
1008
+ }
1009
+ return buffer;
1010
+ }
442
1011
  /** The canvas over the chart box that the marks are painted on, if the chart draws on one. */
443
1012
  function ChartCanvas3D({ renderer, canvas, width, height }) {
444
- if (renderer !== "canvas") return null;
1013
+ if (renderer === "svg") return null;
445
1014
  return /* @__PURE__ */ jsx("canvas", {
1015
+ "data-slot": "chart-3d-canvas",
446
1016
  ref: canvas,
447
- className: "pointer-events-none absolute top-0 left-0 w-full mask-y-from-96%",
1017
+ className: "pointer-events-none absolute top-0 left-0 w-full mask-x-from-96% mask-y-from-96%",
448
1018
  style: { aspectRatio: `${width} / ${height}` }
1019
+ }, renderer);
1020
+ }
1021
+ const GIZMO = [
1022
+ {
1023
+ axis: "x",
1024
+ x: 1,
1025
+ y: 0,
1026
+ z: 0
1027
+ },
1028
+ {
1029
+ axis: "y",
1030
+ x: 0,
1031
+ y: -1,
1032
+ z: 0
1033
+ },
1034
+ {
1035
+ axis: "z",
1036
+ x: 0,
1037
+ y: 0,
1038
+ z: 1
1039
+ }
1040
+ ];
1041
+ const CORNERS = {
1042
+ "top-left": "top-2 left-2",
1043
+ "top-right": "top-2 right-2",
1044
+ "bottom-left": "bottom-2 left-2",
1045
+ "bottom-right": "bottom-2 right-2"
1046
+ };
1047
+ /**
1048
+ * The axes of the world in a corner of the chart: turned with the camera,
1049
+ * neither panned nor zoomed, the axis nearest the camera on top. An axis
1050
+ * draws in `currentColor`, so a text color on its `data-axis` recolors it.
1051
+ */
1052
+ function ChartGizmo3D({ camera, corner }) {
1053
+ if (!corner) return null;
1054
+ const eye = camera3D().origin({
1055
+ x: 24,
1056
+ y: 24
1057
+ }).scale(18).rotateY(camera.rotateY()).rotateX(camera.rotateX());
1058
+ const ends = points3D().camera(eye).data(GIZMO).sort(sort);
1059
+ return /* @__PURE__ */ jsx("svg", {
1060
+ "data-slot": "chart-3d-gizmo",
1061
+ viewBox: "0 0 48 48",
1062
+ "aria-hidden": "true",
1063
+ className: cn("pointer-events-none absolute size-12 text-muted-foreground", CORNERS[corner]),
1064
+ children: ends.map(({ axis, projected: p }) => /* @__PURE__ */ jsxs("g", {
1065
+ "data-axis": axis,
1066
+ children: [/* @__PURE__ */ jsx("line", {
1067
+ x1: 24,
1068
+ y1: 24,
1069
+ x2: 24 + (p.x - 24) * .6,
1070
+ y2: 24 + (p.y - 24) * .6,
1071
+ stroke: "currentColor",
1072
+ strokeWidth: 1.5,
1073
+ strokeLinecap: "round"
1074
+ }), /* @__PURE__ */ jsx("text", {
1075
+ x: p.x,
1076
+ y: p.y,
1077
+ textAnchor: "middle",
1078
+ dominantBaseline: "central",
1079
+ fill: "currentColor",
1080
+ className: "text-[10px] font-medium",
1081
+ children: axis.toUpperCase()
1082
+ })]
1083
+ }, axis))
449
1084
  });
450
1085
  }
451
1086
  /**
1087
+ * The focus edge of a fill: `--chart-3d-focus`, or the fill itself lightened,
1088
+ * so the highlight carries the color of what it highlights.
1089
+ */
1090
+ const focusOf = (fill) => `var(--chart-3d-focus, color-mix(in oklch, ${fill} 60%, white))`;
1091
+ /**
452
1092
  * The marks of a chart, drawn by the renderer of the container: as SVG
453
1093
  * elements, or on the canvas over the chart box. The hover reads them too.
454
1094
  */
455
1095
  function Marks3D({ marks }) {
456
1096
  const { renderer, scene, repaint } = useChart3D();
1097
+ const lit = useLit(marks);
1098
+ const drawn = marks.map((mark) => lit(mark) ? {
1099
+ ...mark,
1100
+ stroke: mark.stroke && mark.stroke !== "var(--background)" ? mark.stroke : focusOf(mark.fill)
1101
+ } : mark);
457
1102
  React.useLayoutEffect(() => {
458
- scene.current = marks;
1103
+ scene.current = drawn;
459
1104
  repaint();
460
1105
  });
461
- if (renderer === "canvas") return null;
462
- return marks.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
463
- d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
464
- fill,
465
- stroke: fill,
466
- strokeWidth: .5,
467
- strokeLinejoin: "round"
468
- }, key) : /* @__PURE__ */ jsx("circle", {
469
- cx: points[0].x,
470
- cy: points[0].y,
471
- r,
472
- fill,
473
- stroke,
474
- strokeWidth: stroke ? 1 : void 0
475
- }, key));
1106
+ if (renderer !== "svg") return null;
1107
+ return /* @__PURE__ */ jsx("g", {
1108
+ "data-slot": "chart-3d-marks",
1109
+ children: drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
1110
+ d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
1111
+ fill,
1112
+ stroke: stroke ?? fill,
1113
+ strokeWidth: stroke === focusOf(fill) ? 1.5 : .5,
1114
+ strokeLinejoin: "round"
1115
+ }, key) : /* @__PURE__ */ jsx("circle", {
1116
+ cx: points[0].x,
1117
+ cy: points[0].y,
1118
+ r,
1119
+ fill,
1120
+ stroke,
1121
+ strokeWidth: stroke ? 1 : void 0
1122
+ }, key))
1123
+ });
476
1124
  }
477
1125
  /**
478
1126
  * The labels of the marks of a chart, ringed by the background so that they
@@ -481,34 +1129,51 @@ function Marks3D({ marks }) {
481
1129
  function Labels3D({ labels }) {
482
1130
  return /* @__PURE__ */ jsx("g", {
483
1131
  "data-slot": "chart-3d-labels",
484
- children: labels.map(({ key, at, text, anchor }) => /* @__PURE__ */ jsx("text", {
485
- x: at.x,
486
- y: at.y,
487
- textAnchor: anchor,
488
- dominantBaseline: "central",
489
- paintOrder: "stroke",
490
- strokeWidth: 3,
491
- strokeLinejoin: "round",
492
- className: "fill-foreground stroke-background text-[10px] font-medium",
493
- children: text
494
- }, key))
1132
+ children: labels.map((label) => {
1133
+ const { x, y } = label.toward ? beside(label.at, label.toward, textWidth(label.text, 10), 10, 0) : label.at;
1134
+ return /* @__PURE__ */ jsx("text", {
1135
+ x,
1136
+ y,
1137
+ textAnchor: label.toward ? "middle" : label.anchor,
1138
+ dominantBaseline: "central",
1139
+ paintOrder: "stroke",
1140
+ strokeWidth: 3,
1141
+ strokeLinejoin: "round",
1142
+ className: "fill-foreground stroke-background text-[10px] font-medium",
1143
+ children: label.text
1144
+ }, label.key);
1145
+ })
495
1146
  });
496
1147
  }
497
1148
  function ChartStyle({ id, config }) {
498
1149
  const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
499
- if (!colorConfig.length) return null;
500
- return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: Object.entries(THEMES).map(([theme, prefix]) => `
1150
+ const entering = [
1151
+ "text",
1152
+ "line",
1153
+ "path"
1154
+ ].map((el) => `[data-chart=${id}] [data-slot="chart-3d-axes"] ${el}`).join(", ");
1155
+ const base = `@keyframes chart-3d-axes-in { from { opacity: 0; } }
1156
+ ${entering} { animation: chart-3d-axes-in 300ms ease-out; }
1157
+ @media (prefers-reduced-motion: reduce) { ${entering} { animation: none; } }
1158
+ [data-chart=${id}] svg[data-mod="true"] { cursor: grab; }
1159
+ [data-chart=${id}] svg[data-alt="true"] { cursor: move; }
1160
+ [data-chart=${id}] svg[data-zoom="in"] { cursor: zoom-in; }
1161
+ [data-chart=${id}] svg[data-zoom="out"] { cursor: zoom-out; }
1162
+ [data-chart=${id}] svg[data-orbiting="true"] { cursor: grabbing; }`;
1163
+ if (!colorConfig.length) return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: base } });
1164
+ return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: [base, ...Object.entries(THEMES).map(([theme, prefix]) => `
501
1165
  ${prefix} [data-chart=${id}] {
502
1166
  ${colorConfig.map(([key, item]) => {
503
1167
  const color = item.theme?.[theme] || item.color;
504
1168
  return color ? ` --color-${key}: ${color};` : null;
505
1169
  }).join("\n")}
506
1170
  }
507
- `).join("\n") } });
1171
+ `)].join("\n") } });
508
1172
  }
509
1173
  function ChartTooltip3D({ tooltip, width, height }) {
510
1174
  if (!tooltip) return null;
511
1175
  return /* @__PURE__ */ jsxs("div", {
1176
+ "data-slot": "chart-3d-tooltip",
512
1177
  className: "pointer-events-none absolute z-10 grid min-w-32 -translate-x-1/2 -translate-y-[calc(100%+0.5rem)] gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
513
1178
  style: {
514
1179
  left: `${tooltip.at.x / width * 100}%`,
@@ -537,50 +1202,90 @@ function ChartTooltip3D({ tooltip, width, height }) {
537
1202
  });
538
1203
  }
539
1204
  /**
540
- * A button in the corner of the chart that lists `SHORTCUTS`. `?` on the
541
- * chart opens and closes the list, Escape closes it.
1205
+ * A button in the corner of the chart, named `label`, that lists the rows of
1206
+ * its keymap. The `help` keys on the chart open and close the list, Escape
1207
+ * closes it.
542
1208
  */
543
- function ChartShortcuts3D() {
1209
+ function ChartShortcuts3D({ rows, help, label }) {
544
1210
  const [open, setOpen] = React.useState(false);
545
1211
  const ref = React.useRef(null);
1212
+ const id = React.useId();
1213
+ const keys = help.join("\n");
546
1214
  React.useEffect(() => {
547
1215
  const chart = ref.current?.parentElement;
548
1216
  const onKeyDown = (e) => {
549
- if (e.key === "?") setOpen((o) => !o);
1217
+ if (keys.split("\n").includes(pressed(e))) setOpen((o) => !o);
550
1218
  if (e.key === "Escape") setOpen(false);
551
1219
  };
552
1220
  chart?.addEventListener("keydown", onKeyDown);
553
1221
  return () => chart?.removeEventListener("keydown", onKeyDown);
554
- }, []);
1222
+ }, [keys]);
1223
+ React.useEffect(() => {
1224
+ if (!open) return;
1225
+ const onPointerDown = (e) => {
1226
+ if (!ref.current?.contains(e.target)) setOpen(false);
1227
+ };
1228
+ document.addEventListener("pointerdown", onPointerDown);
1229
+ return () => document.removeEventListener("pointerdown", onPointerDown);
1230
+ }, [open]);
1231
+ if (!rows.length) return null;
555
1232
  return /* @__PURE__ */ jsxs("div", {
556
1233
  ref,
1234
+ "data-slot": "chart-3d-shortcuts",
557
1235
  className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
558
1236
  children: [/* @__PURE__ */ jsx("button", {
559
1237
  type: "button",
560
- "aria-label": "Keyboard shortcuts",
1238
+ "data-slot": "chart-3d-shortcuts-trigger",
1239
+ "aria-label": label,
561
1240
  "aria-expanded": open,
1241
+ "aria-controls": open ? id : void 0,
562
1242
  onClick: () => setOpen((o) => !o),
563
- className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4",
1243
+ className: "inline-flex size-7 cursor-pointer items-center justify-center rounded-md bg-background/60 text-muted-foreground outline-none transition-colors hover:bg-background hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4",
564
1244
  children: /* @__PURE__ */ jsx(KeyboardIcon, {})
565
- }), open && /* @__PURE__ */ jsx("div", {
566
- className: "grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-2 shadow-xl",
567
- children: SHORTCUTS.map(({ hint, label }, i) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("span", {
568
- "data-slot": "kbd-group",
569
- className: "flex gap-1",
570
- children: hint.map((key) => /* @__PURE__ */ jsx("kbd", {
571
- "data-slot": "kbd",
572
- className: "pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
573
- children: key
574
- }, key))
575
- }), /* @__PURE__ */ jsx("span", {
576
- className: "text-muted-foreground",
577
- children: label
578
- })] }, i))
1245
+ }), open && /* @__PURE__ */ jsx(ChartShortcutList3D, {
1246
+ id,
1247
+ rows
579
1248
  })]
580
1249
  });
581
1250
  }
1251
+ /** One group of keys as chips. */
1252
+ function KbdGroup3D({ group }) {
1253
+ return /* @__PURE__ */ jsx("span", {
1254
+ "data-slot": "kbd-group",
1255
+ className: "flex gap-1",
1256
+ children: group.map((key, j) => /* @__PURE__ */ jsx("kbd", {
1257
+ "data-slot": "kbd",
1258
+ className: "pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
1259
+ children: key
1260
+ }, j))
1261
+ });
1262
+ }
1263
+ /** The list of the shortcut panel: per row its label, then its keys, a rule, and its gestures. */
1264
+ function ChartShortcutList3D({ id, rows }) {
1265
+ return /* @__PURE__ */ jsx("dl", {
1266
+ id,
1267
+ "data-slot": "chart-3d-shortcuts-panel",
1268
+ className: "grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-2 shadow-xl",
1269
+ children: rows.map(({ label, keys, gestures }) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("dt", {
1270
+ "data-slot": "chart-3d-shortcuts-label",
1271
+ className: "text-muted-foreground",
1272
+ children: label
1273
+ }), /* @__PURE__ */ jsxs("dd", {
1274
+ className: "flex flex-wrap items-center justify-end gap-x-2 gap-y-1",
1275
+ children: [
1276
+ keys.map((group, i) => /* @__PURE__ */ jsx(KbdGroup3D, { group }, i)),
1277
+ keys.length > 0 && gestures.length > 0 && /* @__PURE__ */ jsx("span", {
1278
+ "data-slot": "chart-3d-shortcuts-rule",
1279
+ className: "h-4 w-px bg-border"
1280
+ }),
1281
+ gestures.map((group, i) => /* @__PURE__ */ jsx(KbdGroup3D, { group }, i))
1282
+ ]
1283
+ })] }, label))
1284
+ });
1285
+ }
582
1286
  function ChartLegend3D({ config }) {
583
1287
  return /* @__PURE__ */ jsx("div", {
1288
+ "data-slot": "chart-3d-legend",
584
1289
  className: "flex items-center justify-center gap-4 pt-3",
585
1290
  children: Object.entries(config).map(([key, item]) => /* @__PURE__ */ jsxs("div", {
586
1291
  className: "flex items-center gap-1.5",
@@ -593,104 +1298,348 @@ function ChartLegend3D({ config }) {
593
1298
  }
594
1299
  /**
595
1300
  * The walls of the chart box that face away from the camera, with their
596
- * gridlines, and an axis with ticks on the outline of the box.
1301
+ * gridlines, the line of every axis along its edge with the mark of each of
1302
+ * its ticks, and the labels of every axis on each of its four edges. Only the
1303
+ * edge the axis sits on shows them, so when the axis moves to another edge,
1304
+ * its line, its marks and its labels fade from one to the other.
597
1305
  */
598
1306
  function Axes3D({ spec, world, format = {}, titles = {} }) {
599
1307
  const { camera } = useChart3D();
600
- const box = axes3D().camera(camera).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
1308
+ const box = intoWorld(axes3D().camera(camera), world);
1309
+ const { labels, lines, marks } = labelAxes(box.edges(spec), spec, format, titles);
1310
+ const leaving = new Set([
1311
+ "x",
1312
+ "y",
1313
+ "z"
1314
+ ].flatMap((a) => (spec[a].leaving ?? []).map((value) => `${a}-${value}`)));
601
1315
  return /* @__PURE__ */ jsxs("g", {
602
1316
  "data-slot": "chart-3d-axes",
603
- children: [box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
604
- d: box.draw(wall),
605
- className: "fill-muted/40 stroke-border",
606
- strokeWidth: .75
607
- }, `${wall.axis}${wall.side}`)), labelAxes(box.axes(spec), format, titles).map((axis) => /* @__PURE__ */ jsx(AxisEdge3D, {
608
- ticks: axis.ticks,
609
- title: axis.title
610
- }, axis.key))]
1317
+ children: [
1318
+ box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("path", {
1319
+ d: quad(wall),
1320
+ className: "fill-muted/40 stroke-border",
1321
+ strokeWidth: .75
1322
+ }), wall.grid.map((segment) => {
1323
+ const [from, to] = [segment[0], segment[segment.length - 1]];
1324
+ const key = gridKey(wall, from, to);
1325
+ return /* @__PURE__ */ jsx("line", {
1326
+ x1: from.projected.x,
1327
+ y1: from.projected.y,
1328
+ x2: to.projected.x,
1329
+ y2: to.projected.y,
1330
+ style: { opacity: leaving.has(key) ? 0 : 1 },
1331
+ className: "stroke-border transition-opacity duration-300 ease-out motion-reduce:transition-none",
1332
+ strokeWidth: .75
1333
+ }, key);
1334
+ })] }, `${wall.axis}${wall.side}`)),
1335
+ lines.map((line) => /* @__PURE__ */ jsx(AxisLine3D, {
1336
+ line,
1337
+ ink: "stroke-foreground",
1338
+ width: 1.5
1339
+ }, line.key)),
1340
+ marks.map((mark) => /* @__PURE__ */ jsx(AxisLine3D, {
1341
+ line: mark,
1342
+ ink: "stroke-foreground/60",
1343
+ width: 1
1344
+ }, mark.key)),
1345
+ labels.map((label) => /* @__PURE__ */ jsx(AxisLabel3D, { label }, label.key))
1346
+ ]
611
1347
  });
612
1348
  }
1349
+ const GAP = 10;
1350
+ const PAD = 2;
1351
+ const FADE = 4;
1352
+ const DODGE = 12;
1353
+ const MARK = 6;
1354
+ /** The closed quad of a wall; its gridlines are their own lines, so a new one fades in. */
1355
+ function quad(wall) {
1356
+ return `M${wall.map(({ projected }) => `${projected.x},${projected.y}`).join("L")}Z`;
1357
+ }
613
1358
  /**
614
- * The labels of one axis on the outline of the box: a label per tick, pushed
615
- * away from the box along the normal of the edge, and the title of the axis
616
- * further out. `key` names the edge, one of the four the axis moves between
617
- * as the camera turns.
1359
+ * The key of a gridline: the axis and value of the tick it marks - the one
1360
+ * coordinate its ends share beside the wall's own axis - so the gridline of
1361
+ * a tick that stays glides on, and one of a new tick mounts and fades in.
1362
+ * Exactly one coordinate differs along the line, because a domain always has
1363
+ * width: `axis` widens an empty extent.
618
1364
  */
619
- function axisLabels(edge, format, title) {
620
- const { normal: n } = edge;
621
- const start = edge[0].projected;
622
- const end = edge[edge.length - 1].projected;
623
- const anchor = n.x > .3 ? "start" : n.x < -.3 ? "end" : "middle";
624
- const label = (at, out, text, size) => ({
625
- x: at.x + n.x * out,
626
- y: at.y + n.y * out,
627
- anchor,
628
- text,
629
- size
630
- });
1365
+ function gridKey(wall, from, to) {
1366
+ const axis = [
1367
+ "x",
1368
+ "y",
1369
+ "z"
1370
+ ].find((a) => a !== wall.axis && from[a] === to[a]);
1371
+ return `${axis}-${from[axis]}`;
1372
+ }
1373
+ /**
1374
+ * The labels of the axes on all their edges, the line of every axis along its
1375
+ * edge and the mark of each of its ticks, shown on the edges the axes sit on.
1376
+ * Every edge fits its own tick labels; then the labels of an axis give way to
1377
+ * those of an axis with fewer ticks, which say more each, and the titles give
1378
+ * way to the ticks.
1379
+ */
1380
+ function labelAxes(edges, spec, format, titles) {
1381
+ const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, spec[edge.axis], format[edge.axis] ?? String, titles[edge.axis]));
1382
+ const shown = axes.filter((axis) => axis.chosen).sort((a, b) => a.ticks.length - b.ticks.length);
1383
+ const placed = [];
1384
+ for (const label of [...shown.flatMap((a) => a.ticks), ...shown.flatMap((a) => a.title ?? [])]) {
1385
+ if (label.opacity === 0) continue;
1386
+ label.opacity = shows(label, placed);
1387
+ placed.push(label);
1388
+ }
1389
+ for (const axis of axes) axis.marks.forEach((mark, i) => mark.opacity = axis.ticks[i].opacity);
1390
+ return {
1391
+ labels: axes.flatMap((axis) => [...axis.ticks, ...axis.title ? [axis.title] : []]),
1392
+ lines: axes.map((axis) => axis.line),
1393
+ marks: axes.flatMap((axis) => axis.marks)
1394
+ };
1395
+ }
1396
+ /**
1397
+ * The labels of one edge: a label per tick beside it, away from the box, in
1398
+ * the rows that `fit` finds, and the title of the axis beyond them. The
1399
+ * labels of an edge the axis does not sit on are there, but do not show.
1400
+ */
1401
+ /**
1402
+ * The point of `value` along `edge`, between the ticks that bracket it: the
1403
+ * projection keeps the edge straight, so the point lies on their line. An
1404
+ * axis of one tick is a point.
1405
+ */
1406
+ function along(edge, value) {
1407
+ if (edge.length === 1) return edge[0].projected;
1408
+ let i = 0;
1409
+ while (i < edge.length - 2 && edge[i + 1][edge.axis] < value) i += 1;
1410
+ const [a, b] = [edge[i][edge.axis], edge[i + 1][edge.axis]];
1411
+ const t = clamp(0, 1, (value - a) / (b - a));
1412
+ const [p, q] = [edge[i].projected, edge[i + 1].projected];
1413
+ return {
1414
+ x: p.x + (q.x - p.x) * t,
1415
+ y: p.y + (q.y - p.y) * t
1416
+ };
1417
+ }
1418
+ /** The ticks whose value lies in `domain`, with the ends of `domain`. */
1419
+ function inRange(ticks, domain) {
1420
+ const range = {
1421
+ lo: Math.min(...domain),
1422
+ hi: Math.max(...domain)
1423
+ };
1424
+ return {
1425
+ ranged: ticks.filter((t) => t.value !== void 0 && within(range.lo, range.hi, t.value)),
1426
+ range
1427
+ };
1428
+ }
1429
+ /** An edge the axis does not sit on hides its labels; on a chosen one, the ticks outside the gliding ends fade out. */
1430
+ function hideOffRange(labels, range, chosen) {
1431
+ if (!chosen) for (const l of labels) l.opacity = 0;
1432
+ else for (const l of labels) if (l.value !== void 0 && !within(range.lo, range.hi, l.value)) l.opacity = 0;
1433
+ }
1434
+ function edgeLabels(edge, { domain, leaving = [], span }, format, title) {
1435
+ const { axis, normal: n, chosen } = edge;
1436
+ const label = (key, at, text, h, gap, value) => {
1437
+ const w = textWidth(text, h);
1438
+ return {
1439
+ key,
1440
+ axis,
1441
+ value,
1442
+ at: beside(at, n, w, h, gap),
1443
+ w,
1444
+ h,
1445
+ shift: ZERO,
1446
+ text,
1447
+ opacity: 1
1448
+ };
1449
+ };
1450
+ const id = `${axis}${edge.sides.join("")}`;
1451
+ const ticks = edge.map((tick) => label(`${id}-${tick[axis]}`, tick.projected, format(tick[axis]), 10, GAP, tick[axis]));
1452
+ for (const t of ticks) if (t.value !== void 0 && leaving.includes(t.value)) t.opacity = 0;
1453
+ const { ranged, range } = inRange(ticks.filter((t) => t.opacity > 0), domain);
1454
+ const depth = Math.max(0, ...ranged.map((t) => Math.abs(n.x) * t.w + Math.abs(n.y) * t.h));
1455
+ const row = {
1456
+ x: n.x * (depth + PAD),
1457
+ y: n.y * (depth + PAD)
1458
+ };
1459
+ const rows = fit(ranged, n, row);
1460
+ const [s0, s1] = span ?? [edge[0][axis], edge[edge.length - 1][axis]];
1461
+ const start = along(edge, clamp(range.lo, range.hi, s0));
1462
+ const end = along(edge, clamp(range.lo, range.hi, s1));
631
1463
  const middle = {
632
1464
  x: (start.x + end.x) / 2,
633
1465
  y: (start.y + end.y) / 2
634
1466
  };
1467
+ const head = title ? {
1468
+ ...label(`${id}-title`, middle, title, 12, GAP + depth + GAP),
1469
+ title: true
1470
+ } : void 0;
1471
+ if (head && rows === 2) head.shift = row;
1472
+ hideOffRange([...ticks, ...head ? [head] : []], range, chosen);
635
1473
  return {
636
- key: `${edge.axis}:${edge[0].x},${edge[0].y},${edge[0].z}`,
637
- ticks: edge.map((tick) => label(tick.projected, 10, format(tick[edge.axis]), 10)),
638
- title: title ? label(middle, 44, title, 12) : void 0
1474
+ chosen,
1475
+ ticks,
1476
+ title: head,
1477
+ line: {
1478
+ key: id,
1479
+ from: start,
1480
+ to: end,
1481
+ opacity: chosen ? 1 : 0
1482
+ },
1483
+ marks: edge.map((tick, i) => ({
1484
+ key: ticks[i].key,
1485
+ from: tick.projected,
1486
+ to: {
1487
+ x: tick.projected.x + n.x * MARK,
1488
+ y: tick.projected.y + n.y * MARK
1489
+ },
1490
+ opacity: ticks[i].opacity
1491
+ }))
639
1492
  };
640
1493
  }
641
1494
  /**
642
- * The labels of the axes, with every tick label hidden that would cover a
643
- * title or a tick label before it, so an axis seen end on keeps its first
644
- * label and a short one every other.
1495
+ * Fits the tick labels of an edge into the space between its ticks: in one
1496
+ * row if they keep `PAD` apart, or slid along the edge if no label has to
1497
+ * slide further than `DODGE`, else every other label a `row` further out if
1498
+ * that is enough, else every `stride`-th label of one row, 2, 4, 8… The
1499
+ * labels of a stride are among those of the one before, so as the edge
1500
+ * shortens, a label only ever leaves and none comes in for it. Returns the rows.
645
1501
  */
646
- function labelAxes(edges, format, titles) {
647
- const axes = edges.filter((edge) => edge.length).map((edge) => axisLabels(edge, format[edge.axis] ?? String, titles[edge.axis]));
648
- const placed = axes.flatMap((axis) => axis.title ? [bounds(axis.title)] : []);
649
- for (const label of axes.flatMap((axis) => axis.ticks)) {
650
- const b = bounds(label);
651
- label.hidden = placed.some((p) => b.left < p.right && p.left < b.right && b.top < p.bottom && p.top < b.bottom);
652
- if (!label.hidden) placed.push(b);
653
- }
654
- return axes;
655
- }
656
- /** The box a label covers, guessed from its text, as the browser lays it out only after the render. */
657
- function bounds({ x, y, anchor, text, size }) {
658
- const width = (typeof text === "string" || typeof text === "number" ? String(text).length : 4) * size * .6;
659
- const left = x - width * {
660
- start: 0,
661
- middle: .5,
662
- end: 1
663
- }[anchor];
1502
+ function fit(ticks, normal, row) {
1503
+ if (clear(ticks, 1) || slide(ticks, normal)) return 1;
1504
+ ticks.forEach((t, i) => t.shift = i % 2 ? row : ZERO);
1505
+ if (clear(ticks, 2)) return 2;
1506
+ ticks.forEach((t) => t.shift = ZERO);
1507
+ let stride = 2;
1508
+ while (stride < ticks.length && !clear(ticks, stride, stride)) stride *= 2;
1509
+ ticks.forEach((t, i) => t.opacity = i % stride ? 0 : 1);
1510
+ return 1;
1511
+ }
1512
+ /** Whether every label from every `every`-th keeps `PAD` apart from the one `step` after it. */
1513
+ function clear(ticks, step, every = 1) {
1514
+ return ticks.every((t, i) => i % every > 0 || i + step >= ticks.length || apart(t, ticks[i + step]) > 1.999999);
1515
+ }
1516
+ /**
1517
+ * Slides the labels of an edge along it, square to its `normal`, as little
1518
+ * as they can until each has the room it needs. True if that is enough and
1519
+ * no label slides further than `DODGE`; the slide grows from nothing as the
1520
+ * labels near, so it follows the camera without a jump.
1521
+ */
1522
+ function slide(ticks, normal) {
1523
+ const t = {
1524
+ x: -normal.y,
1525
+ y: normal.x
1526
+ };
1527
+ const along = ({ at }) => at.x * t.x + at.y * t.y;
1528
+ const line = [...ticks].sort((a, b) => along(a) - along(b));
1529
+ const gaps = line.slice(1).map((b, i) => room(line[i], b, normal, t));
1530
+ const placed = dodge(line.map(along), gaps);
1531
+ line.forEach((label, i) => {
1532
+ const d = placed[i] - along(label);
1533
+ label.shift = {
1534
+ x: t.x * d,
1535
+ y: t.y * d
1536
+ };
1537
+ });
1538
+ return line.every((label, i) => Math.abs(placed[i] - along(label)) <= DODGE) && clear(ticks, 1);
1539
+ }
1540
+ /**
1541
+ * How far along `t` the label `b` has to stand from `a`, which comes before
1542
+ * it there, for their boxes to keep `PAD` apart, as far as they stand apart
1543
+ * along `normal`, square to `t`.
1544
+ */
1545
+ function room(a, b, normal, t) {
1546
+ const off = (b.at.x - a.at.x) * normal.x + (b.at.y - a.at.y) * normal.y;
1547
+ const x = ((a.w + b.w) / 2 + PAD - off * normal.x * Math.sign(t.x)) / Math.abs(t.x);
1548
+ const y = ((a.h + b.h) / 2 + PAD - off * normal.y * Math.sign(t.y)) / Math.abs(t.y);
1549
+ return Math.min(x, y);
1550
+ }
1551
+ /**
1552
+ * Places points on a line in their order, `gaps[i]` at least between the
1553
+ * i-th and the next, and moves them as little as it can: points that would
1554
+ * come too close move as one block, centred on the places they want.
1555
+ */
1556
+ function dodge(want, gaps) {
1557
+ const blocks = [];
1558
+ want.forEach((at, start) => {
1559
+ let block = {
1560
+ start,
1561
+ offsets: [0],
1562
+ at
1563
+ };
1564
+ let last = blocks.at(-1);
1565
+ while (last && last.at + last.offsets.at(-1) + gaps[block.start - 1] > block.at) {
1566
+ blocks.pop();
1567
+ const base = last.offsets.at(-1) + gaps[block.start - 1];
1568
+ const offsets = [...last.offsets, ...block.offsets.map((o) => o + base)];
1569
+ const first = last.start;
1570
+ block = {
1571
+ start: first,
1572
+ offsets,
1573
+ at: offsets.reduce((sum, o, k) => sum + want[first + k] - o, 0) / offsets.length
1574
+ };
1575
+ last = blocks.at(-1);
1576
+ }
1577
+ blocks.push(block);
1578
+ });
1579
+ return blocks.flatMap((block) => block.offsets.map((o) => block.at + o));
1580
+ }
1581
+ /**
1582
+ * How much of a label shows beside the labels placed before it: it fades as
1583
+ * it nears a label of another axis, by as much as that one shows. As this
1584
+ * follows the geometry, the label fades as smoothly as the camera turns.
1585
+ */
1586
+ function shows(label, placed) {
1587
+ 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);
1588
+ }
1589
+ /** The space between the boxes of two labels, negative where they overlap. */
1590
+ function apart(a, b) {
1591
+ const dx = Math.abs(a.at.x + a.shift.x - b.at.x - b.shift.x) - (a.w + b.w) / 2;
1592
+ const dy = Math.abs(a.at.y + a.shift.y - b.at.y - b.shift.y) - (a.h + b.h) / 2;
1593
+ return Math.max(dx, dy);
1594
+ }
1595
+ /**
1596
+ * The centre of a box `w`×`h` that lies `gap` from `at` towards `toward`, a
1597
+ * unit vector. As the vector turns, the box slides round `at` and keeps the
1598
+ * gap, where an anchor that switches from start to end would jump.
1599
+ */
1600
+ function beside(at, toward, w, h, gap) {
1601
+ const out = gap + (Math.abs(toward.x) * w + Math.abs(toward.y) * h) / 2;
664
1602
  return {
665
- left,
666
- right: left + width,
667
- top: y - size / 2,
668
- bottom: y + size / 2
1603
+ x: at.x + toward.x * out,
1604
+ y: at.y + toward.y * out
669
1605
  };
670
1606
  }
1607
+ /** The width of a label at the font size `size`, guessed from its text, as the browser lays it out only after the render. */
1608
+ function textWidth(text, size) {
1609
+ return (typeof text === "string" || typeof text === "number" ? String(text).length : 4) * size * .6;
1610
+ }
671
1611
  /**
672
- * The labels of one axis, dimmed while the camera turns. When the axis jumps
673
- * to another edge of the box, its key changes, so they mount anew and fade in
674
- * there.
1612
+ * A line of the axes, in the ink of the kind it is: the line of an axis along
1613
+ * its edge, or the mark of one of its ticks. Its place follows the camera at
1614
+ * once; a change of edge fades it in a transition.
675
1615
  */
676
- function AxisEdge3D({ ticks, title }) {
677
- return /* @__PURE__ */ jsxs("g", {
678
- className: "transition-opacity duration-300 starting:opacity-0 in-data-[orbiting=true]:opacity-50",
679
- children: [ticks.map(({ x, y, anchor, text, hidden }, i) => /* @__PURE__ */ jsx("text", {
680
- x,
681
- y,
682
- textAnchor: anchor,
683
- dominantBaseline: "central",
684
- className: cn("fill-muted-foreground text-[10px] transition-opacity", hidden && "opacity-0"),
685
- children: text
686
- }, i)), title && /* @__PURE__ */ jsx("text", {
687
- x: title.x,
688
- y: title.y,
689
- textAnchor: title.anchor,
690
- dominantBaseline: "central",
691
- className: "fill-foreground font-medium",
692
- children: title.text
693
- })]
1616
+ function AxisLine3D({ line: { from, to, opacity }, ink, width }) {
1617
+ return /* @__PURE__ */ jsx("line", {
1618
+ x1: from.x,
1619
+ y1: from.y,
1620
+ x2: to.x,
1621
+ y2: to.y,
1622
+ strokeWidth: width,
1623
+ style: { opacity: Math.round(opacity * 100) / 100 },
1624
+ className: cn("transition-opacity duration-300 ease-out motion-reduce:transition-none", ink)
1625
+ });
1626
+ }
1627
+ /**
1628
+ * A label of the chart box. Its place follows the camera at once; a change
1629
+ * of edge, of row or of the labels that fit fades and moves it in a transition.
1630
+ */
1631
+ function AxisLabel3D({ label: { at, shift, opacity, text, title } }) {
1632
+ return /* @__PURE__ */ jsx("text", {
1633
+ x: at.x,
1634
+ y: at.y,
1635
+ textAnchor: "middle",
1636
+ dominantBaseline: "central",
1637
+ style: {
1638
+ opacity: Math.round(opacity * 100) / 100,
1639
+ translate: `${shift.x}px ${shift.y}px`
1640
+ },
1641
+ className: cn("transition-[opacity,translate] duration-300 ease-out motion-reduce:transition-none", title ? "fill-foreground font-medium" : "fill-muted-foreground text-[10px]"),
1642
+ children: text
694
1643
  });
695
1644
  }
696
1645
  /** A nice axis from `lo` to `hi` with about `count` ticks. */
@@ -701,6 +1650,17 @@ function axis(lo, hi, count = 5) {
701
1650
  ticks: ticks(start, stop, count)
702
1651
  };
703
1652
  }
1653
+ /**
1654
+ * The axis `spec` with its domain widened by half a `cell` on each side, so
1655
+ * the box keeps room between the outermost data and its walls. The ticks stay
1656
+ * where they were, on the data.
1657
+ */
1658
+ function pad(spec, cell) {
1659
+ return {
1660
+ domain: [spec.domain[0] - cell / 2, spec.domain[1] + cell / 2],
1661
+ ticks: spec.ticks
1662
+ };
1663
+ }
704
1664
  /** The linear map from `domain` to `range`. */
705
1665
  function linear([d0, d1], [r0, r1]) {
706
1666
  return (value) => r0 + (value - d0) / (d1 - d0 || 1) * (r1 - r0);
@@ -713,6 +1673,13 @@ function worldBox(spec, depth = 1) {
713
1673
  z: linear(spec.z.domain, [-depth, depth])
714
1674
  };
715
1675
  }
1676
+ /**
1677
+ * Hands the projection its accessors into the world box, so every chart maps
1678
+ * its shapes onto the screen the same way.
1679
+ */
1680
+ function intoWorld(projection, world) {
1681
+ return projection.x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
1682
+ }
716
1683
  const LIGHT = {
717
1684
  x: -.4,
718
1685
  y: -.8,
@@ -729,6 +1696,16 @@ function shade(face, color) {
729
1696
  const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
730
1697
  return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
731
1698
  }
1699
+ /**
1700
+ * `color` at `opacity`, from 0 to 1: a CSS color-mix with transparent, so a
1701
+ * mark fades on every renderer while its color stays in CSS. Rounded to a
1702
+ * percent, so the renderers that resolve colors cache a bounded few. Fade
1703
+ * after `shade`, which would mix a translucent color back towards opaque.
1704
+ */
1705
+ function faded(color, opacity) {
1706
+ const percent = Math.round(opacity * 100);
1707
+ return percent >= 100 ? color : `color-mix(in oklab, ${color} ${percent}%, transparent)`;
1708
+ }
732
1709
  /** The mean of the projected points, where a tooltip points at. */
733
1710
  function center(points) {
734
1711
  const sum = points.reduce((s, p) => ({
@@ -761,62 +1738,99 @@ function cubeMarks(id, faces, color, tooltip) {
761
1738
  function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
762
1739
  const { config, camera } = useChart3D();
763
1740
  const series = keys ?? Object.keys(config);
764
- const cells = data.flatMap((d, i) => series.map((key, j) => ({
765
- i,
766
- j,
767
- key,
768
- value: Number(d[key]) || 0
769
- })));
770
- const { spec, world, size } = barBox(data.length, series.length, cells.map((cell) => cell.value), barSize);
771
- const bars = cubes3D().camera(camera).data(cells.map(({ i, j, key, value }) => Object.assign(barCorners(world, i, j, value, size), {
772
- i,
773
- key,
774
- value
775
- }))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
1741
+ const names = data.map((d) => String(d[index] ?? ""));
1742
+ const target = barValues(names, data, series);
1743
+ const values = useTween(target, 0);
1744
+ const rows = useTween(Object.fromEntries(names.map((name, i) => [name, i])));
1745
+ const opacity = useTween(Object.fromEntries(names.map((name) => [name, 1])), 0);
1746
+ const cells = barCells(rows, series, values);
1747
+ const box = barBox(data.length, series.length, Object.values(target));
1748
+ const spec = useTweenAxes(box.spec);
1749
+ const depth = useTween({ depth: box.depth }).depth;
1750
+ const world = worldBox(spec, depth);
1751
+ const bars = barShapes(camera, cells, world, cellSize(spec, depth, barSize));
776
1752
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
777
1753
  spec,
778
1754
  world,
779
1755
  titles,
780
1756
  format: {
781
- x: (i) => (format.x ?? String)(String(data[i][index] ?? "")),
1757
+ x: (i) => (format.x ?? String)(String(data[i]?.[index] ?? "")),
782
1758
  y: format.y,
783
1759
  z: (j) => config[series[j]]?.label ?? series[j]
784
1760
  }
785
1761
  }), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
786
- id: `${bar.i}-${bar.key}`,
1762
+ id: `${bar.name}-${bar.key}`,
787
1763
  faces: bar.faces,
788
1764
  color: `var(--color-${bar.key})`,
789
- title: String(data[bar.i][index]),
1765
+ title: bar.name,
790
1766
  label: config[bar.key]?.label ?? bar.key,
791
- value: bar.value
1767
+ value: bar.value,
1768
+ opacity: opacity[bar.name]
792
1769
  })) })] });
793
1770
  }
1771
+ /** The cubes of the cells, projected by `camera` and painted back to front by their footprint, as the bars stand on one floor. */
1772
+ function barShapes(camera, cells, world, size) {
1773
+ return cubes3D().camera(camera).data(cells.map(({ name, x, j, key, value }) => Object.assign(barCorners(world, x, j, value, size), {
1774
+ name,
1775
+ key,
1776
+ value
1777
+ }))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
1778
+ }
1779
+ /**
1780
+ * The cell of every bar, the rows still gliding away among them: the row
1781
+ * position along x, the series `key` along z, with the tweened value. A cell
1782
+ * only one of the tweens still knows is on its way out and stays unseen.
1783
+ */
1784
+ function barCells(rows, series, values) {
1785
+ return Object.entries(rows).flatMap(([name, x]) => series.flatMap((key, j) => {
1786
+ const value = values[`${name}-${key}`];
1787
+ return value === void 0 ? [] : [{
1788
+ name,
1789
+ x,
1790
+ j,
1791
+ key,
1792
+ value
1793
+ }];
1794
+ }));
1795
+ }
1796
+ /**
1797
+ * The value of every cell of the chart, keyed as its mark: the name of its
1798
+ * entry along x and the series `key` along z, standing as 0 where the entry
1799
+ * holds none. Keyed by name, not place, so a bar follows its entry when the
1800
+ * data reorders.
1801
+ */
1802
+ function barValues(names, data, series) {
1803
+ return Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${names[i]}-${key}`, Number(d[key]) || 0])));
1804
+ }
794
1805
  /**
795
1806
  * The box of a bar chart: a row per entry along x, a row per series along z,
796
- * and the size of a bar, the share `barSize` of its cell.
1807
+ * with half a cell of room between the outer rows and the walls.
797
1808
  */
798
- function barBox(entries, series, values, barSize) {
1809
+ function barBox(entries, series, values) {
799
1810
  const index = (length) => Array.from({ length }, (_, i) => i);
800
- const spec = {
801
- x: {
802
- domain: [-.5, entries - .5],
803
- ticks: index(entries)
1811
+ return {
1812
+ spec: {
1813
+ x: pad({
1814
+ domain: [-.5, entries - .5],
1815
+ ticks: index(entries)
1816
+ }, 1),
1817
+ y: axis(Math.min(0, ...values), Math.max(0, ...values)),
1818
+ z: pad({
1819
+ domain: [-.5, series - .5],
1820
+ ticks: index(series)
1821
+ }, 1)
804
1822
  },
805
- y: axis(Math.min(0, ...values), Math.max(0, ...values)),
806
- z: {
807
- domain: [-.5, series - .5],
808
- ticks: index(series)
809
- }
810
- };
811
- const depth = Math.min(1, Math.max(.25, series / entries));
812
- const size = {
813
- width: 2 / entries * barSize,
814
- depth: 2 * depth / series * barSize
1823
+ depth: Math.min(1, Math.max(.25, series / entries))
815
1824
  };
1825
+ }
1826
+ /**
1827
+ * The size of a bar, the share `barSize` of its cell - of the gliding ends of
1828
+ * `spec`, so a bar eases into a new cell size as rows come and go.
1829
+ */
1830
+ function cellSize(spec, depth, barSize) {
816
1831
  return {
817
- spec,
818
- world: worldBox(spec, depth),
819
- size
1832
+ width: 2 / (spec.x.domain[1] - spec.x.domain[0]) * barSize,
1833
+ depth: 2 * depth / (spec.z.domain[1] - spec.z.domain[0]) * barSize
820
1834
  };
821
1835
  }
822
1836
  /**
@@ -834,8 +1848,8 @@ function barCorners(world, x, z, value, size) {
834
1848
  height: Math.abs(floor - top)
835
1849
  });
836
1850
  }
837
- /** The marks of one bar: the faces that look at the camera, shaded, each with its entry and value on hover. */
838
- function barMarks({ id, faces, color, title, label, value }) {
1851
+ /** The marks of one bar: the faces that look at the camera, shaded and faded to `opacity`, each with its entry and value on hover. Its group is the bar alone, so hovering highlights one bar, not its series. */
1852
+ function barMarks({ id, faces, color, title, label, value, opacity }) {
839
1853
  return cubeMarks(id, faces, color, {
840
1854
  at: center(faces.find((face) => face.face === "top")),
841
1855
  title,
@@ -844,64 +1858,136 @@ function barMarks({ id, faces, color, title, label, value }) {
844
1858
  value,
845
1859
  color
846
1860
  }]
847
- });
1861
+ }).map((mark) => ({
1862
+ ...mark,
1863
+ fill: faded(mark.fill, opacity),
1864
+ groups: [id]
1865
+ }));
848
1866
  }
849
1867
  // registry/new-york/ui/scatter-chart-3d.tsx
850
1868
  /**
851
1869
  * Points in the chart box, at the fields `x`, `y` and `z` of each entry.
852
1870
  * `series` names the field whose value is the config key of the color.
853
1871
  */
854
- function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
1872
+ function ScatterChart3D({ data, x, y, z, id, series, radius = 4, format, titles }) {
855
1873
  const { config, camera } = useChart3D();
856
1874
  const fields = {
857
1875
  x,
858
1876
  y,
859
1877
  z
860
1878
  };
861
- const read = (d, axis) => Number(d[fields[axis]]);
1879
+ const names = data.map((d, i) => id ? String(d[id]) : String(i));
1880
+ const { radii, opacity, read, key } = usePointTweens(names, data, fields, series, config, radius);
1881
+ const spec = useTweenAxes(pointAxes(data, fields));
1882
+ const world = worldBox(spec);
1883
+ const placed = usePointPlaces(names, radii, read, world);
1884
+ const marks = points3D().camera(camera).x((p) => p.x).y((p) => p.y).z((p) => p.z).data(placed).sort(sort).map((p) => ({
1885
+ key: p.name,
1886
+ points: [p.projected],
1887
+ r: radii[p.name],
1888
+ fill: faded(`var(--color-${key(p.name)})`, opacity[p.name]),
1889
+ groups: [p.name],
1890
+ stroke: faded("var(--background)", opacity[p.name]),
1891
+ tooltip: {
1892
+ at: p.projected,
1893
+ title: series ? config[key(p.name)]?.label ?? key(p.name) : void 0,
1894
+ items: [
1895
+ "x",
1896
+ "y",
1897
+ "z"
1898
+ ].map((a) => ({
1899
+ label: titles?.[a] ?? fields[a],
1900
+ value: read(p.name, a)
1901
+ }))
1902
+ }
1903
+ }));
1904
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
1905
+ spec,
1906
+ world,
1907
+ format,
1908
+ titles
1909
+ }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
1910
+ }
1911
+ /** The axes that hold every entry of `data`, a nice one per field. */
1912
+ function pointAxes(data, fields) {
862
1913
  const range = (a) => {
863
- const [lo = 0, hi = 0] = extent(data, (d) => read(d, a));
1914
+ const [lo = 0, hi = 0] = extent(data, (d) => Number(d[fields[a]]));
864
1915
  return axis(lo, hi);
865
1916
  };
866
- const spec = {
1917
+ return {
867
1918
  x: range("x"),
868
1919
  y: range("y"),
869
1920
  z: range("z")
870
1921
  };
871
- const world = worldBox(spec);
872
- const points = points3D().camera(camera).x(({ d }) => world.x(read(d, "x"))).y(({ d }) => world.y(read(d, "y"))).z(({ d }) => world.z(read(d, "z")));
1922
+ }
1923
+ /**
1924
+ * The tweened numbers of the chart, by the name of each entry: the positions
1925
+ * glide entry by entry - a gone point stands in place - and the radius and
1926
+ * the opacity enter from 0, so a new point grows as it fades in and a gone
1927
+ * one shrinks as it fades away. `read` and `key` look them up, also for a
1928
+ * point still leaving.
1929
+ */
1930
+ function usePointTweens(names, data, fields, series, config, radius) {
1931
+ const values = useTween(pointValues(names, data, fields));
1932
+ const radii = useTween(Object.fromEntries(names.map((name) => [name, radius])), 0);
1933
+ const opacity = useTween(Object.fromEntries(names.map((name) => [name, 1])), 0);
873
1934
  const fallback = Object.keys(config)[0];
874
- const marks = points.data(data.map((d, i) => ({
875
- d,
876
- i
877
- }))).sort(sort).map((p) => {
878
- const key = series ? String(p.d[series]) : fallback;
879
- return {
880
- key: String(p.i),
881
- points: [p.projected],
882
- r: radius,
883
- fill: `var(--color-${key})`,
884
- stroke: "var(--background)",
885
- tooltip: {
886
- at: p.projected,
887
- title: series ? config[key]?.label ?? key : void 0,
888
- items: [
889
- "x",
890
- "y",
891
- "z"
892
- ].map((a) => ({
893
- label: titles?.[a] ?? fields[a],
894
- value: read(p.d, a)
895
- }))
896
- }
1935
+ return {
1936
+ radii,
1937
+ opacity,
1938
+ read: (name, axis) => values[`${axis}${name}`],
1939
+ key: usePointKeys(names, data, series, fallback, radii)
1940
+ };
1941
+ }
1942
+ /**
1943
+ * The place of every point in the world box, by name. A living point follows
1944
+ * its gliding numbers through the gliding axes; a gone one stays frozen where
1945
+ * it was, so it shrinks away in place instead of riding the rescaling axes
1946
+ * out of the box. A point only one of the tweens still knows stays unseen.
1947
+ */
1948
+ function usePointPlaces(names, alive, read, world) {
1949
+ const places = React.useRef(/* @__PURE__ */ new Map());
1950
+ const live = new Set(names);
1951
+ const placed = Object.keys(alive).flatMap((name) => {
1952
+ if (!live.has(name)) {
1953
+ const held = places.current.get(name);
1954
+ return held ? [{
1955
+ name,
1956
+ ...held
1957
+ }] : [];
1958
+ }
1959
+ const p = {
1960
+ x: world.x(read(name, "x")),
1961
+ y: world.y(read(name, "y")),
1962
+ z: world.z(read(name, "z"))
897
1963
  };
1964
+ places.current.set(name, p);
1965
+ return [{
1966
+ name,
1967
+ ...p
1968
+ }];
898
1969
  });
899
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
900
- spec,
901
- world,
902
- format,
903
- titles
904
- }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
1970
+ for (const name of places.current.keys()) if (!(name in alive)) places.current.delete(name);
1971
+ return placed;
1972
+ }
1973
+ /** The position of every entry, keyed by its axis and name, gliding into new data. */
1974
+ function pointValues(names, data, fields) {
1975
+ return Object.fromEntries(data.flatMap((d, i) => [
1976
+ "x",
1977
+ "y",
1978
+ "z"
1979
+ ].map((a) => [`${a}${names[i]}`, Number(d[fields[a]])])));
1980
+ }
1981
+ /**
1982
+ * The config key of every point's color, by name - remembered while a gone
1983
+ * point shrinks away, since its entry has already left the data - and
1984
+ * forgotten with the point, so the memory stays the size of the chart.
1985
+ */
1986
+ function usePointKeys(names, data, series, fallback, alive) {
1987
+ const known = React.useRef(/* @__PURE__ */ new Map());
1988
+ data.forEach((d, i) => known.current.set(names[i], series ? String(d[series]) : fallback));
1989
+ for (const name of known.current.keys()) if (!(name in alive) && !names.includes(name)) known.current.delete(name);
1990
+ return (name) => known.current.get(name) ?? fallback;
905
1991
  }
906
1992
  // registry/new-york/ui/surface-chart-3d.tsx
907
1993
  /**
@@ -913,41 +1999,41 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
913
1999
  function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
914
2000
  const { config, camera } = useChart3D();
915
2001
  const [low, high = low] = colors ?? Object.keys(config);
2002
+ const [floor = 0, peak = 0] = extent(data.flat());
2003
+ const target = gridValues(data);
2004
+ const heights = gridHeights(useTween(target), floor);
2005
+ const fade = useTween(Object.fromEntries(Object.keys(target).map((k) => [k, 1])), 0);
916
2006
  const rows = data.length;
917
2007
  const columns = data[0]?.length ?? 0;
918
2008
  const [x0, x1] = domain.x ?? [0, columns - 1];
919
2009
  const [z0, z1] = domain.z ?? [0, rows - 1];
920
- const [lo = 0, hi = 0] = extent(data.flat());
921
- const spec = {
922
- x: axis(x0, x1),
923
- y: axis(lo, hi),
924
- z: axis(z0, z1)
2010
+ const cell = {
2011
+ x: (x1 - x0) / Math.max(1, columns - 1),
2012
+ z: (z1 - z0) / Math.max(1, rows - 1)
925
2013
  };
2014
+ const [lo = 0, hi = 0] = extent(heights.flat());
2015
+ const spec = useTweenAxes({
2016
+ x: domain.x ? axis(x0, x1) : pad(axis(x0, x1), cell.x),
2017
+ y: axis(floor, peak),
2018
+ z: domain.z ? axis(z0, z1) : pad(axis(z0, z1), cell.z)
2019
+ });
926
2020
  const world = worldBox(spec);
927
2021
  const toX = linear([0, columns - 1], [x0, x1]);
928
2022
  const toZ = linear([0, rows - 1], [z0, z1]);
929
- const grid = gridPlanes3D().camera(camera).rows(Math.max(1, columns)).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
930
- const points = data.flatMap((row, r) => row.map((y, c) => ({
2023
+ const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1, heights[0]?.length ?? 0));
2024
+ const points = heights.flatMap((row, r) => row.map((y, c) => ({
931
2025
  x: toX(c),
932
2026
  y,
933
- z: toZ(r)
2027
+ z: toZ(r),
2028
+ r,
2029
+ c
934
2030
  })));
935
- const marks = grid.data(points).sort(sort).map((quad) => {
936
- const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
937
- const t = Math.round((height - lo) / (hi - lo || 1) * 100);
938
- return {
939
- key: quad.plane,
940
- points: quad.map((p) => p.projected),
941
- fill: shade(quad, `color-mix(in oklch, var(--color-${high}) ${t}%, var(--color-${low}))`),
942
- tooltip: {
943
- at: center(quad),
944
- items: [{
945
- label: titles?.y ?? "value",
946
- value: height
947
- }]
948
- }
949
- };
950
- });
2031
+ const marks = grid.data(points).sort(sort).map((quad) => quadMark(quad, fade, {
2032
+ lo,
2033
+ hi,
2034
+ low,
2035
+ high
2036
+ }, titles?.y));
951
2037
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
952
2038
  spec,
953
2039
  world,
@@ -955,297 +2041,48 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
955
2041
  titles
956
2042
  }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
957
2043
  }
958
- // registry/new-york/ui/sankey-chart-3d.tsx
959
- const DEPTH = .4;
960
- const RIBBON = .7;
961
- const SEGMENTS = 16;
962
2044
  /**
963
- * Flows from left to right: every node a box in its column, as high as the
964
- * larger of what flows in and what flows out, and every flow a band from node
965
- * to node, as thick as its value, that turns from the color of its source to
966
- * the color of its target.
2045
+ * The mark of one cell: its quad, shaded by the color ramp at its height and
2046
+ * faded in place by `fade` while the cell enters or leaves.
967
2047
  */
968
- function SankeyChart3D({ data, nodeWidth = .2, nodePadding = .05, format = (value) => value }) {
969
- const { config, camera } = useChart3D();
970
- const { nodes, columns, ky } = sankeyLayout(data, nodePadding);
971
- const pieces = depthSort(cubes3D().camera(camera).data(sankeyPieces(nodes, columns, ky, nodeWidth)), camera);
972
- const labels = pieces.flatMap(({ key, faces, node }) => node ? [nodeLabel(key, faces, node.column < (columns - 1) / 2 ? "left" : "right", style(config, node).label)] : []);
973
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Marks3D, { marks: pieces.flatMap((piece) => pieceMarks(piece, config, format)) }), /* @__PURE__ */ jsx(Labels3D, { labels })] });
974
- }
975
- /** The label and the color of a node, from the chart config. */
976
- function style(config, node) {
2048
+ function quadMark(quad, fade, ramp, title) {
2049
+ const height = quad.reduce((sum, p) => sum + p.y, 0) / quad.length;
2050
+ const t = Math.round((height - ramp.lo) / (ramp.hi - ramp.lo || 1) * 100);
2051
+ const shaded = shade(quad, `color-mix(in oklch, var(--color-${ramp.high}) ${t}%, var(--color-${ramp.low}))`);
2052
+ const seen = Math.min(...quad.map((p) => fade[`${p.r}-${p.c}`] ?? 0));
977
2053
  return {
978
- label: config[node.id]?.label ?? node.id,
979
- color: config[node.id] ? `var(--color-${node.id})` : "var(--muted-foreground)"
980
- };
981
- }
982
- /**
983
- * The marks of a piece, each with its tooltip: the faces of a node, or the
984
- * faces of a slice of a flow, in the color it has turned to there.
985
- */
986
- function pieceMarks({ key, faces, node, flow, t }, config, format) {
987
- const at = center(faces.find((face) => face.face === "top"));
988
- if (node) {
989
- const { label, color } = style(config, node);
990
- return cubeMarks(key, faces, color, {
991
- at,
2054
+ key: quad.plane,
2055
+ points: quad.map((p) => p.projected),
2056
+ fill: seen < 1 ? `color-mix(in srgb, ${shaded} ${Math.round(seen * 100)}%, transparent)` : shaded,
2057
+ groups: [quad.plane],
2058
+ tooltip: {
2059
+ at: center(quad),
992
2060
  items: [{
993
- label,
994
- value: format(node.value),
995
- color
2061
+ label: title ?? "value",
2062
+ value: height
996
2063
  }]
997
- });
998
- }
999
- const source = style(config, flow.source);
1000
- const target = style(config, flow.target);
1001
- const item = {
1002
- label: /* @__PURE__ */ jsxs(Fragment, { children: [
1003
- source.label,
1004
- " → ",
1005
- target.label
1006
- ] }),
1007
- value: format(flow.value),
1008
- color: source.color
1009
- };
1010
- return cubeMarks(key, faces.filter((face) => face.face !== "left" && face.face !== "right"), `color-mix(in oklch, color-mix(in oklch, ${target.color} ${t}%, ${source.color}) 75%, var(--background))`, {
1011
- at,
1012
- items: [item]
1013
- });
1014
- }
1015
- /** The nodes of `data`, waypoints included, stacked in their columns, and `ky`, the world units per value. */
1016
- function sankeyLayout(data, nodePadding) {
1017
- const { nodes, flows, columns } = sankeyColumns(data);
1018
- const all = sankeyHops(nodes, flows);
1019
- const ky = stackColumns(all, columns, nodePadding);
1020
- stackHops(all, ky);
1021
- return {
1022
- nodes: all,
1023
- columns,
1024
- ky
1025
- };
1026
- }
1027
- function newNode(id, column, flow) {
1028
- return {
1029
- id,
1030
- column,
1031
- value: 0,
1032
- y0: 0,
1033
- y1: 0,
1034
- incoming: [],
1035
- outgoing: [],
1036
- flow
1037
- };
1038
- }
1039
- function middle(node) {
1040
- return (node.y0 + node.y1) / 2;
1041
- }
1042
- /**
1043
- * The nodes of `data` in the order they first appear, and the flows between
1044
- * them, without the ones of no value. The column of a node is the longest
1045
- * path to it from a source; the sinks move to the last column.
1046
- */
1047
- function sankeyColumns(data) {
1048
- const nodes = /* @__PURE__ */ new Map();
1049
- const node = (id) => {
1050
- if (!nodes.has(id)) nodes.set(id, newNode(id, 0));
1051
- return nodes.get(id);
1052
- };
1053
- const flows = data.filter((d) => d.value > 0).map((d, i) => ({
1054
- key: String(i),
1055
- source: node(d.source),
1056
- target: node(d.target),
1057
- value: d.value
1058
- }));
1059
- for (let pass = 0; flows.some((f) => f.target.column <= f.source.column); pass += 1) {
1060
- if (pass === nodes.size) throw new Error("SankeyChart3D: the flows form a cycle");
1061
- for (const f of flows) f.target.column = Math.max(f.target.column, f.source.column + 1);
1062
- }
1063
- const last = Math.max(0, ...flows.map((f) => f.target.column));
1064
- const sources = new Set(flows.map((f) => f.source));
1065
- for (const n of nodes.values()) if (!sources.has(n)) n.column = last;
1066
- return {
1067
- nodes: [...nodes.values()],
1068
- flows,
1069
- columns: last + 1
1070
- };
1071
- }
1072
- /**
1073
- * Splits every flow into hops from one column to the next. A flow that skips a
1074
- * column passes it at a waypoint, a node of its own, so it has a slot in every
1075
- * column it crosses and never runs through another node.
1076
- */
1077
- function sankeyHops(nodes, flows) {
1078
- const all = [...nodes];
1079
- for (const flow of flows) {
1080
- let source = flow.source;
1081
- for (let column = source.column + 1; column <= flow.target.column; column += 1) {
1082
- const target = column < flow.target.column ? newNode(`${flow.key}:${column}`, column, flow) : flow.target;
1083
- if (target.flow) all.push(target);
1084
- const hop = {
1085
- flow,
1086
- source,
1087
- target,
1088
- y0: 0,
1089
- y1: 0
1090
- };
1091
- source.outgoing.push(hop);
1092
- target.incoming.push(hop);
1093
- source = target;
1094
- }
1095
- }
1096
- return all;
1097
- }
1098
- /**
1099
- * Sizes the nodes to the largest scale at which every column fits, and stacks
1100
- * each column from the top, centered. A column after the first orders its nodes
1101
- * by the mean height of their sources, which uncrosses most flows. Returns the
1102
- * scale, world units per value.
1103
- */
1104
- function stackColumns(nodes, columns, nodePadding) {
1105
- const total = (hops) => hops.reduce((sum, hop) => sum + hop.flow.value, 0);
1106
- for (const node of nodes) node.value = Math.max(total(node.incoming), total(node.outgoing));
1107
- const stacks = Array.from({ length: columns }, (_, c) => nodes.filter((n) => n.column === c));
1108
- const sum = (stack) => stack.reduce((s, n) => s + n.value, 0);
1109
- const pad = Math.min(2 * nodePadding, 2 / Math.max(1, ...stacks.map((s) => s.length)));
1110
- const ky = Math.min(...stacks.map((s) => (2 - pad * (s.length - 1)) / sum(s)));
1111
- const mean = (node) => node.incoming.reduce((s, hop) => s + hop.flow.value * middle(hop.source), 0) / total(node.incoming);
1112
- for (const [c, stack] of stacks.entries()) {
1113
- if (c > 0) stack.sort((a, b) => mean(a) - mean(b));
1114
- let y = -(sum(stack) * ky + pad * (stack.length - 1)) / 2;
1115
- for (const node of stack) {
1116
- node.y0 = y;
1117
- node.y1 = y + node.value * ky;
1118
- y = node.y1 + pad;
1119
- }
1120
- }
1121
- return ky;
1122
- }
1123
- /**
1124
- * Stacks the hops at every node from its top: the ones that leave it in the
1125
- * order of their targets, the ones that arrive in the order of their sources,
1126
- * so that the hops of a node do not cross.
1127
- */
1128
- function stackHops(nodes, ky) {
1129
- for (const node of nodes) {
1130
- let y = node.y0;
1131
- for (const hop of node.outgoing.sort((a, b) => middle(a.target) - middle(b.target))) {
1132
- hop.y0 = y + hop.flow.value * ky / 2;
1133
- y += hop.flow.value * ky;
1134
2064
  }
1135
- y = node.y0;
1136
- for (const hop of node.incoming.sort((a, b) => middle(a.source) - middle(b.source))) {
1137
- hop.y1 = y + hop.flow.value * ky / 2;
1138
- y += hop.flow.value * ky;
1139
- }
1140
- }
1141
- }
1142
- /**
1143
- * The pieces of the chart: a box per node, a flat slice of its flow per
1144
- * waypoint and `SEGMENTS` slices per hop. The columns stand from x = -1 to 1,
1145
- * a node the share `nodeWidth` of the distance between two of them wide.
1146
- */
1147
- function sankeyPieces(nodes, columns, ky, nodeWidth) {
1148
- const step = 2 / Math.max(1, columns - 1);
1149
- const x = (column) => -1 + column * step;
1150
- const half = nodeWidth * step / 2;
1151
- const share = (flow, at) => {
1152
- const from = x(flow.source.column) + half;
1153
- return Math.round((at - from) / (x(flow.target.column) - half - from) * 100);
1154
2065
  };
1155
- return nodes.flatMap((node) => {
1156
- const band = x(node.column);
1157
- const own = node.flow ? {
1158
- key: `way:${node.id}`,
1159
- band,
1160
- flow: node.flow,
1161
- t: share(node.flow, band)
1162
- } : {
1163
- key: `node:${node.id}`,
1164
- band,
1165
- node,
1166
- t: 0
1167
- };
1168
- const depth = node.flow ? RIBBON * DEPTH : DEPTH;
1169
- const box = slab(band - half, middle(node), band + half, middle(node), node.y1 - node.y0, depth);
1170
- return [Object.assign(box, own), ...node.outgoing.flatMap((hop) => hopPieces(hop, band + half, x(node.column + 1) - half, ky, share))];
1171
- });
1172
2066
  }
1173
- /**
1174
- * The slices of a hop from `x0` to `x1`, along an S-curve that leaves and
1175
- * arrives level, so that the hops of a flow join without a kink. Every hop
1176
- * between the same two columns has the same slices.
1177
- */
1178
- function hopPieces(hop, x0, x1, ky, share) {
1179
- const x = (k) => x0 + (x1 - x0) * k / SEGMENTS;
1180
- const y = (k) => {
1181
- const t = k / SEGMENTS;
1182
- return hop.y0 + (hop.y1 - hop.y0) * t * t * (3 - 2 * t);
1183
- };
1184
- return Array.from({ length: SEGMENTS }, (_, k) => {
1185
- const band = (x(k) + x(k + 1)) / 2;
1186
- return Object.assign(slab(x(k), y(k), x(k + 1), y(k + 1), hop.flow.value * ky, RIBBON * DEPTH), {
1187
- key: `hop:${hop.flow.key}:${hop.source.column}:${k}`,
1188
- band,
1189
- flow: hop.flow,
1190
- t: share(hop.flow, band)
1191
- });
1192
- });
1193
- }
1194
- /** The corners of a slab from `x0` to `x1`, its middle at `y0` and `y1`, `height` high and `depth` deep, in the order of box(). */
1195
- function slab(x0, y0, x1, y1, height, depth) {
1196
- return [depth / 2, -depth / 2].flatMap((z) => [
1197
- {
1198
- x: x0,
1199
- y: y0 - height / 2,
1200
- z
1201
- },
1202
- {
1203
- x: x0,
1204
- y: y0 + height / 2,
1205
- z
1206
- },
1207
- {
1208
- x: x1,
1209
- y: y1 + height / 2,
1210
- z
1211
- },
1212
- {
1213
- x: x1,
1214
- y: y1 - height / 2,
1215
- z
1216
- }
1217
- ]);
1218
- }
1219
- /**
1220
- * The pieces in the order to paint them. The slabs of the columns and of the
1221
- * slices lie between planes x = const, so they paint from the slab farthest
1222
- * from the eye to the nearest, which is exact; within a slab, where the pieces
1223
- * lie one above the other, by depth.
1224
- */
1225
- function depthSort(pieces, camera) {
1226
- const { x: vx } = camera.viewDirection();
1227
- const eye = vx * camera.perspective();
1228
- const far = (x) => Number.isFinite(eye) ? Math.abs(x - eye) : -x * Math.sign(vx);
1229
- return pieces.sort((a, b) => far(b.band) - far(a.band) || sort(a, b));
2067
+ /** The height of every cell, keyed by its row and column, gliding into new data. */
2068
+ function gridValues(data) {
2069
+ return Object.fromEntries(data.flatMap((row, r) => row.map((y, c) => [`${r}-${c}`, y])));
1230
2070
  }
1231
2071
  /**
1232
- * The label of a node beside its `outer` face, pushed away from the other side,
1233
- * so that it stays outside whichever way the camera turns.
2072
+ * The gliding heights as a lattice. It spans the cells still leaving too, so
2073
+ * a shrunken grid sinks away instead of popping; a corner only one dimension
2074
+ * kept stands at the floor until it goes.
1234
2075
  */
1235
- function nodeLabel(key, faces, outer, text) {
1236
- const side = (name) => center(faces.find((face) => face.face === name));
1237
- const at = side(outer);
1238
- const away = Math.sign(at.x - side(outer === "left" ? "right" : "left").x) || (outer === "left" ? -1 : 1);
1239
- return {
1240
- key,
1241
- at: {
1242
- x: at.x + 8 * away,
1243
- y: at.y
1244
- },
1245
- text,
1246
- anchor: away < 0 ? "end" : "start"
1247
- };
2076
+ function gridHeights(tweened, floor) {
2077
+ let rows = 0;
2078
+ let columns = 0;
2079
+ for (const key of Object.keys(tweened)) {
2080
+ const [r, c] = key.split("-").map(Number);
2081
+ rows = Math.max(rows, r + 1);
2082
+ columns = Math.max(columns, c + 1);
2083
+ }
2084
+ return Array.from({ length: rows }, (_, r) => Array.from({ length: columns }, (_, c) => tweened[`${r}-${c}`] ?? floor));
1248
2085
  }
1249
- export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, linear, shade, useChart3D, worldBox };
2086
+ export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, faded, intoWorld, linear, pad, shade, useChart3D, useTween, useTweenAxes, worldBox };
1250
2087
 
1251
2088
  //# sourceMappingURL=index.js.map