@niekes/charts-3d 0.3.0 → 0.4.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,222 @@ 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 SHORTCUTS = {
30
+ dragRotate: {
31
+ label: "Rotate",
32
+ hint: ["Drag"]
33
33
  },
34
- {
35
- hint: [
36
- "←",
37
- "→",
38
- "↑",
39
- "↓"
40
- ],
34
+ rotateLeft: {
41
35
  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
- }
36
+ keys: ["ArrowLeft"],
37
+ move: { yaw: -KEY_STEP }
48
38
  },
49
- {
50
- hint: ["Space", "Drag"],
51
- label: "Pan"
39
+ rotateRight: {
40
+ label: "Rotate",
41
+ keys: ["ArrowRight"],
42
+ move: { yaw: KEY_STEP }
52
43
  },
53
- {
54
- hint: [
55
- "Shift",
56
- "←",
57
- "→",
58
- "↑",
59
- "↓"
60
- ],
44
+ rotateUp: {
45
+ label: "Rotate",
46
+ keys: ["ArrowUp"],
47
+ move: { tilt: -KEY_STEP }
48
+ },
49
+ rotateDown: {
50
+ label: "Rotate",
51
+ keys: ["ArrowDown"],
52
+ move: { tilt: KEY_STEP }
53
+ },
54
+ dragPan: {
61
55
  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
- }
56
+ hint: ["Space", "Drag"]
80
57
  },
81
- {
82
- hint: ["+", "−"],
58
+ panLeft: {
59
+ label: "Pan",
60
+ keys: ["Shift+ArrowLeft"],
61
+ move: { pan: {
62
+ x: -20,
63
+ y: 0
64
+ } }
65
+ },
66
+ panRight: {
67
+ label: "Pan",
68
+ keys: ["Shift+ArrowRight"],
69
+ move: { pan: {
70
+ x: PAN_STEP,
71
+ y: 0
72
+ } }
73
+ },
74
+ panUp: {
75
+ label: "Pan",
76
+ keys: ["Shift+ArrowUp"],
77
+ move: { pan: {
78
+ x: 0,
79
+ y: -20
80
+ } }
81
+ },
82
+ panDown: {
83
+ label: "Pan",
84
+ keys: ["Shift+ArrowDown"],
85
+ move: { pan: {
86
+ x: 0,
87
+ y: PAN_STEP
88
+ } }
89
+ },
90
+ zoomIn: {
83
91
  label: "Zoom",
84
- keys: {
85
- "+": { zoom: ZOOM_STEP },
86
- "=": { zoom: ZOOM_STEP },
87
- "-": { zoom: 1 / ZOOM_STEP }
88
- }
92
+ keys: ["Plus", "="],
93
+ move: { zoom: ZOOM_STEP }
89
94
  },
90
- {
91
- hint: ["Ctrl", "Scroll"],
92
- label: "Zoom"
95
+ zoomOut: {
96
+ label: "Zoom",
97
+ keys: ["-"],
98
+ move: { zoom: 1 / ZOOM_STEP }
93
99
  },
94
- {
95
- hint: ["0"],
100
+ wheelZoom: {
101
+ label: "Zoom",
102
+ hint: ["Ctrl", "Scroll"]
103
+ },
104
+ reset: {
96
105
  label: "Reset",
97
- keys: { "0": { reset: true } }
106
+ keys: ["0"],
107
+ move: { reset: true }
98
108
  },
99
- {
100
- hint: ["Double-click"],
101
- label: "Reset"
109
+ doubleClickReset: {
110
+ label: "Reset",
111
+ hint: ["Double-click"]
102
112
  },
103
- {
104
- hint: ["?"],
105
- label: "Shortcuts"
113
+ help: {
114
+ label: "Keyboard shortcuts",
115
+ keys: ["?"]
106
116
  }
117
+ };
118
+ const MODIFIERS = [
119
+ "Control",
120
+ "Alt",
121
+ "Meta",
122
+ "Shift"
107
123
  ];
108
- const KEYS = Object.assign({}, ...SHORTCUTS.map((s) => s.keys));
109
- const KEY_SHORTCUTS = Object.keys(KEYS).map((k) => k === "+" ? "Plus" : k).join(" ");
124
+ const NAMED = {
125
+ Plus: "+",
126
+ Space: " "
127
+ };
128
+ const ARIA = {
129
+ "+": "Plus",
130
+ " ": "Space"
131
+ };
132
+ const KBD = {
133
+ ArrowLeft: "←",
134
+ ArrowRight: "→",
135
+ ArrowUp: "↑",
136
+ ArrowDown: "↓",
137
+ "-": "−",
138
+ " ": "Space",
139
+ Control: "Ctrl",
140
+ Meta: "⌘"
141
+ };
142
+ /** Keys split at `+`, which is the key itself at the end: `"Control++"` is Control and `+`. */
143
+ const split = (keys) => keys.split(/\+(?!$)/);
144
+ /**
145
+ * Keys in the one form a binding and a press compare in: the modifiers in a
146
+ * fixed order, then the key, Plus as `+` and Space as " ". Shift counts only
147
+ * beside a named key; a character such as "?" or "A" is typed with it already.
148
+ */
149
+ function combo(keys) {
150
+ const parts = split(keys);
151
+ const key = NAMED[parts.at(-1)] ?? parts.at(-1);
152
+ return [...MODIFIERS.filter((m) => parts.includes(m) && (m !== "Shift" || key.length > 1)), key].join("+");
153
+ }
154
+ /** The keys of a press, in the form of `combo`. */
155
+ function pressed(e) {
156
+ return combo([...[
157
+ e.ctrlKey && "Control",
158
+ e.altKey && "Alt",
159
+ e.metaKey && "Meta",
160
+ e.shiftKey && "Shift"
161
+ ], e.key].filter(Boolean).join("+"));
162
+ }
163
+ /** Keys in the form of `combo`, the way `aria-keyshortcuts` writes them. */
164
+ function aria(keys) {
165
+ const parts = split(keys);
166
+ return [...parts.slice(0, -1), ARIA[parts.at(-1)] ?? parts.at(-1)].join("+");
167
+ }
168
+ /** Every action, `keymap` over its defaults: on unless it is off or a key action without keys. */
169
+ function shortcutsOf(keymap) {
170
+ return Object.keys(SHORTCUTS).map((action) => {
171
+ const own = keymap && keymap[action];
172
+ const { keys = [], ...shortcut } = {
173
+ ...SHORTCUTS[action],
174
+ ...Array.isArray(own) ? { keys: own } : own || {}
175
+ };
176
+ const on = own !== false && (shortcut.hint !== void 0 || keys.length > 0);
177
+ return {
178
+ ...shortcut,
179
+ action,
180
+ on,
181
+ keys: keys.map(combo)
182
+ };
183
+ });
184
+ }
185
+ /**
186
+ * The rows of the shortcut panel: the actions by label, the keys of a row in
187
+ * groups of the same modifiers, a gesture in a group of its own.
188
+ */
189
+ function rowsOf(shortcuts) {
190
+ const rows = /* @__PURE__ */ new Map();
191
+ for (const { action, label, keys, hint } of shortcuts) {
192
+ const parts = hint ?? split(keys[0]).map((k) => KBD[k] ?? k);
193
+ const group = hint ? action : parts.slice(0, -1).join("+");
194
+ const groups = rows.get(label) ?? /* @__PURE__ */ new Map();
195
+ const known = groups.get(group);
196
+ rows.set(label, groups.set(group, known ? [...known, parts.at(-1)] : parts));
197
+ }
198
+ return [...rows].map(([label, groups]) => ({
199
+ label,
200
+ groups: [...groups.values()]
201
+ }));
202
+ }
203
+ /**
204
+ * What a keymap does: the actions that are on, the step of each key, the help
205
+ * keys and the rows of the panel. Without the `panel`, the help keys have
206
+ * nothing to open and are off.
207
+ */
208
+ function keymapOf(keymap, panel) {
209
+ const all = shortcutsOf(keymap);
210
+ const on = all.filter((s) => s.on && (panel || s.action !== "help"));
211
+ return {
212
+ on: (action) => on.some((s) => s.action === action),
213
+ moves: Object.fromEntries(on.flatMap((s) => s.keys.map((k) => [k, s.move]))),
214
+ help: on.find((s) => s.action === "help")?.keys ?? [],
215
+ label: all.find((s) => s.action === "help").label,
216
+ aria: on.flatMap((s) => s.keys.map(aria)).join(" "),
217
+ rows: panel ? rowsOf(on) : []
218
+ };
219
+ }
110
220
  function ChartContainer3D({ id, config, defaultView = {
111
221
  yaw: Math.PI / 6,
112
222
  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 }) {
223
+ }, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", className, children, "aria-label": ariaLabel, ...props }) {
114
224
  const uniqueId = React.useId();
115
225
  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);
226
+ const { scene, renderer, canvas, repaint, hover, hide, leave, tooltip, focus } = useScene(requested, width, height);
227
+ const keys = keymapOf(keymap, shortcuts);
228
+ const { view, handlers } = useOrbit(defaultView, width, keys, hide, hover);
119
229
  const camera = useCamera(view, width, height);
120
230
  const context = React.useMemo(() => ({
121
231
  config,
122
232
  camera,
123
233
  renderer,
124
234
  scene,
125
- repaint
235
+ repaint,
236
+ focus,
237
+ animationDuration
126
238
  }), [
127
239
  config,
128
240
  camera,
129
241
  renderer,
130
- repaint
242
+ repaint,
243
+ focus,
244
+ animationDuration
131
245
  ]);
132
246
  return /* @__PURE__ */ jsxs("div", {
133
247
  "data-slot": "chart-3d",
@@ -145,11 +259,7 @@ function ChartContainer3D({ id, config, defaultView = {
145
259
  /* @__PURE__ */ jsx("svg", {
146
260
  viewBox: `0 0 ${width} ${height}`,
147
261
  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,
262
+ ...keymapProps(keys, ariaLabel),
153
263
  onPointerLeave: leave,
154
264
  ...handlers,
155
265
  children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
@@ -163,6 +273,10 @@ function ChartContainer3D({ id, config, defaultView = {
163
273
  width,
164
274
  height
165
275
  }),
276
+ /* @__PURE__ */ jsx(ChartGizmo3D, {
277
+ camera,
278
+ corner: gizmo
279
+ }),
166
280
  /* @__PURE__ */ jsx(ChartTooltip3D, {
167
281
  tooltip,
168
282
  width,
@@ -170,27 +284,52 @@ function ChartContainer3D({ id, config, defaultView = {
170
284
  })
171
285
  ]
172
286
  }),
173
- shortcuts && /* @__PURE__ */ jsx(ChartShortcuts3D, {}),
287
+ /* @__PURE__ */ jsx(ChartShortcuts3D, { ...keys }),
174
288
  legend && /* @__PURE__ */ jsx(ChartLegend3D, { config })
175
289
  ]
176
290
  });
177
291
  }
178
292
  /**
293
+ * What the svg says about its keymap: a label that names the drag and the
294
+ * help key if they are on, the keys, the focus only if a key does something
295
+ * and the grab cursor only if a drag does; without one, a touch scrolls the page.
296
+ */
297
+ function keymapProps(keys, label) {
298
+ const drag = keys.on("dragRotate") || keys.on("dragPan");
299
+ const help = keys.help[0];
300
+ const said = [
301
+ "3D chart.",
302
+ keys.on("dragRotate") && "Drag to rotate it.",
303
+ help && `Press ${aria(help)} for all shortcuts.`
304
+ ];
305
+ return {
306
+ "aria-label": label ?? said.filter(Boolean).join(" "),
307
+ "aria-keyshortcuts": keys.aria || void 0,
308
+ tabIndex: keys.aria ? 0 : void 0,
309
+ className: cn("block h-auto w-full outline-hidden select-none mask-y-from-96%", drag && "cursor-grab touch-none active:cursor-grabbing")
310
+ };
311
+ }
312
+ /**
179
313
  * The marks of the chart and what reads them: the renderer that draws them,
180
314
  * the canvas it paints them on and the hover that shows their tooltips.
181
315
  */
182
- function useScene(requested, width, height, setTooltip) {
316
+ function useScene(requested, width, height) {
183
317
  const scene = React.useRef([]);
318
+ const [tooltip, setTooltip] = React.useState(null);
319
+ const focus = useFocus();
184
320
  const renderer = useRenderer(requested);
185
321
  const { canvas, repaint } = useCanvas(scene, width);
186
- const { hover, leave } = useHover(scene, width, height, setTooltip);
322
+ const { hover, hide, leave } = useHover(scene, width, height, setTooltip, focus);
187
323
  return {
188
324
  scene,
189
325
  renderer,
190
326
  canvas,
191
327
  repaint,
192
328
  hover,
193
- leave
329
+ hide,
330
+ leave,
331
+ tooltip,
332
+ focus
194
333
  };
195
334
  }
196
335
  /** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
@@ -204,6 +343,11 @@ function useCamera(view, width, height) {
204
343
  height
205
344
  ]);
206
345
  }
346
+ const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
347
+ const ZERO = {
348
+ x: 0,
349
+ y: 0
350
+ };
207
351
  /** `view` moved by one step. The tilt stops at the zenith and the nadir, the zoom at 1/4 and 8. */
208
352
  function moved(view, move, initial) {
209
353
  if (move.reset) return initial;
@@ -221,7 +365,6 @@ function moved(view, move, initial) {
221
365
  x: 0,
222
366
  y: 0
223
367
  };
224
- const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
225
368
  return {
226
369
  ...view,
227
370
  yaw: view.yaw + step.yaw,
@@ -234,30 +377,183 @@ function moved(view, move, initial) {
234
377
  };
235
378
  }
236
379
  /**
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.
380
+ * Whether the chart may move on its own: only where the browser says the
381
+ * reader has not asked for reduced motion. Where it cannot say, as on the
382
+ * server and in jsdom, the chart jumps.
383
+ */
384
+ function animates() {
385
+ return Boolean(globalThis.matchMedia?.("(prefers-reduced-motion: no-preference)").matches);
386
+ }
387
+ /**
388
+ * Eases a chart into new data: the numbers of `values` glide from the ones
389
+ * on screen to these over the animationDuration of the container. A key
390
+ * that is new starts at `enter`, or where it ends; a key that is gone goes
391
+ * at once. Without time, or with reduced motion, the numbers jump.
242
392
  */
243
- function useOrbit(defaultView, width, onDragStart, onHover) {
393
+ function useTween(values, enter) {
394
+ const { animationDuration: duration = 600 } = useChart3D();
395
+ const key = JSON.stringify(values);
396
+ const [tween, setTween] = React.useState({
397
+ key,
398
+ from: values,
399
+ to: values,
400
+ t: 1
401
+ });
402
+ const shown = tweenNumbers(tween, enter);
403
+ if (key !== tween.key) setTween({
404
+ key,
405
+ from: shown,
406
+ to: values,
407
+ t: duration > 0 && animates() ? 0 : 1
408
+ });
409
+ React.useEffect(() => {
410
+ if (tween.t >= 1) return;
411
+ let start = null;
412
+ let frame = 0;
413
+ const step = (now) => {
414
+ start ??= now;
415
+ const t = Math.min(1, (now - start) / duration);
416
+ setTween((tw) => ({
417
+ ...tw,
418
+ t
419
+ }));
420
+ if (t < 1) frame = requestAnimationFrame(step);
421
+ };
422
+ frame = requestAnimationFrame(step);
423
+ return () => cancelAnimationFrame(frame);
424
+ }, [tween.key, duration]);
425
+ return shown;
426
+ }
427
+ /** The numbers of `tween` on screen: `from` eased into `to`, a new key from `enter` or where it ends. */
428
+ function tweenNumbers({ from, to, t }, enter) {
429
+ const s = t * t * (3 - 2 * t);
430
+ return Object.fromEntries(Object.entries(to).map(([k, end]) => {
431
+ const start = from[k] ?? enter ?? end;
432
+ return [k, t >= 1 ? end : start + (end - start) * s];
433
+ }));
434
+ }
435
+ /**
436
+ * `spec` gliding into new data with the marks: the ends of every axis glide
437
+ * as `useTween` glides the numbers, where ends rounded from the numbers on
438
+ * screen would jump from one round number to the next, and the box with
439
+ * them. The ticks are those of `spec` that lie between the ends.
440
+ */
441
+ function useTweenAxes(spec) {
442
+ const ends = useTween(Object.fromEntries([
443
+ "x",
444
+ "y",
445
+ "z"
446
+ ].flatMap((a) => [[`${a}0`, spec[a].domain[0]], [`${a}1`, spec[a].domain[spec[a].domain.length - 1]]])));
447
+ const glided = (a) => {
448
+ const [lo, hi] = [ends[`${a}0`], ends[`${a}1`]];
449
+ const inside = (t) => t >= Math.min(lo, hi) && t <= Math.max(lo, hi);
450
+ return {
451
+ domain: [lo, hi],
452
+ ticks: spec[a].ticks.filter(inside)
453
+ };
454
+ };
455
+ return {
456
+ x: glided("x"),
457
+ y: glided("y"),
458
+ z: glided("z")
459
+ };
460
+ }
461
+ const GLIDE = 200;
462
+ /**
463
+ * The view of the camera, `jump`, which moves it at once, and `glide`, which
464
+ * moves it there in `GLIDE` milliseconds, easing out. A glide under way
465
+ * heads for the next view from where it is, so a held key turns the camera
466
+ * smoothly; a jump stops it. Where the reader asks for reduced motion, or
467
+ * the browser cannot say, a glide jumps.
468
+ */
469
+ function useGlide(defaultView) {
244
470
  const [view, setView] = React.useState(defaultView);
471
+ const now = React.useRef(defaultView);
472
+ const run = React.useRef(null);
473
+ const show = React.useCallback((v) => setView(now.current = v), []);
474
+ const stop = React.useCallback(() => {
475
+ cancelAnimationFrame(run.current?.frame ?? 0);
476
+ run.current = null;
477
+ }, []);
478
+ const jump = React.useCallback((to) => {
479
+ stop();
480
+ show(to(now.current));
481
+ }, [show, stop]);
482
+ React.useEffect(() => stop, [stop]);
483
+ const glide = (to) => {
484
+ if (!animates()) return jump(to);
485
+ const end = to(run.current?.to ?? now.current);
486
+ const from = {
487
+ ...now.current,
488
+ yaw: end.yaw + remainder(now.current.yaw - end.yaw)
489
+ };
490
+ const start = performance.now();
491
+ stop();
492
+ const frame = (time) => {
493
+ const t = clamp(0, 1, (time - start) / GLIDE);
494
+ show(t < 1 ? between(from, end, 1 - (1 - t) ** 3) : end);
495
+ if (t < 1) run.current = {
496
+ to: end,
497
+ frame: requestAnimationFrame(frame)
498
+ };
499
+ else run.current = null;
500
+ };
501
+ run.current = {
502
+ to: end,
503
+ frame: requestAnimationFrame(frame)
504
+ };
505
+ };
506
+ return {
507
+ view,
508
+ jump,
509
+ glide
510
+ };
511
+ }
512
+ /** The angle `a` in the turn from -π to π. */
513
+ function remainder(a) {
514
+ return a - 2 * Math.PI * Math.round(a / (2 * Math.PI));
515
+ }
516
+ /** The view the share `k` of the way from `from` to `to`. */
517
+ function between(from, to, k) {
518
+ const mix = (a, b) => a + (b - a) * k;
519
+ const pan = from.pan ?? ZERO;
520
+ const goal = to.pan ?? ZERO;
521
+ return {
522
+ ...to,
523
+ yaw: mix(from.yaw, to.yaw),
524
+ tilt: mix(from.tilt, to.tilt),
525
+ zoom: mix(from.zoom ?? 1, to.zoom ?? 1),
526
+ pan: {
527
+ x: mix(pan.x, goal.x),
528
+ y: mix(pan.y, goal.y)
529
+ }
530
+ };
531
+ }
532
+ /**
533
+ * The view of the camera, and the handlers that move it, as far as `keys`
534
+ * has them on: a drag turns it, or pans it while Space is held, the keys
535
+ * step it, Ctrl or ⌘ with the wheel zooms it and a double click resets it.
536
+ * `data-orbiting` among them is true while the camera turns. `onDragStart`
537
+ * runs when a press becomes a drag, `onHover` on a move without one.
538
+ */
539
+ function useOrbit(defaultView, width, keys, onDragStart, onHover) {
540
+ const { view, jump, glide } = useGlide(defaultView);
245
541
  const [orbiting, setOrbiting] = React.useState(false);
246
542
  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));
543
+ const ref = useWheelZoom(jump, keys.on("wheelZoom"));
544
+ const move = (step) => jump((v) => moved(v, step, defaultView));
545
+ const step = (step) => glide((v) => moved(v, step, defaultView));
546
+ const { space, ...keyHandlers } = useKeys(keys, step, setOrbiting);
250
547
  const release = () => {
251
548
  drag.current = null;
252
549
  setOrbiting(false);
253
550
  };
254
551
  return {
255
552
  view,
256
- orbiting,
257
553
  handlers: {
258
554
  ref,
259
555
  onPointerDown: (e) => {
260
- drag.current = {
556
+ if (space.current || keys.on("dragRotate")) drag.current = {
261
557
  x: e.clientX,
262
558
  y: e.clientY,
263
559
  moved: false
@@ -290,46 +586,68 @@ function useOrbit(defaultView, width, onDragStart, onHover) {
290
586
  },
291
587
  onPointerUp: release,
292
588
  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
- }
589
+ onDoubleClick: keys.on("doubleClickReset") ? () => step({ reset: true }) : void 0,
590
+ ...keyHandlers,
591
+ "data-orbiting": orbiting
307
592
  }
308
593
  };
309
594
  }
310
595
  /**
596
+ * The handlers that step the camera with the keys of `keys`, and `space`,
597
+ * which is true while Space is held for a drag to pan. A key that turns the
598
+ * camera marks it as orbiting until it is let go.
599
+ */
600
+ function useKeys(keys, move, setOrbiting) {
601
+ const space = React.useRef(false);
602
+ const onKeyDown = (e) => {
603
+ const pan = e.key === " " && keys.on("dragPan");
604
+ if (pan) space.current = true;
605
+ const step = keys.moves[pressed(e)];
606
+ if (!step && !pan) return;
607
+ e.preventDefault();
608
+ if (!step) return;
609
+ setOrbiting(Boolean(step.yaw || step.tilt));
610
+ move(step);
611
+ };
612
+ const onKeyUp = (e) => {
613
+ if (e.key === " ") space.current = false;
614
+ setOrbiting(false);
615
+ };
616
+ return {
617
+ space,
618
+ onKeyDown,
619
+ onKeyUp
620
+ };
621
+ }
622
+ /**
311
623
  * 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.
624
+ * is also how a trackpad pinches, if `on`. The wheel alone still scrolls the page.
313
625
  */
314
- function useWheelZoom(setView) {
626
+ function useWheelZoom(jump, on) {
315
627
  return React.useCallback((svg) => {
628
+ if (!on) return;
316
629
  const onWheel = (e) => {
317
630
  if (!e.ctrlKey && !e.metaKey) return;
318
631
  e.preventDefault();
319
- setView((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
632
+ jump((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
320
633
  };
321
634
  svg?.addEventListener("wheel", onWheel, { passive: false });
322
635
  return () => svg?.removeEventListener("wheel", onWheel);
323
- }, [setView]);
636
+ }, [jump, on]);
324
637
  }
325
638
  /**
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.
639
+ * The tooltip of the mark under the pointer, the one painted last, and the
640
+ * focus on its groups. `hover` sets the tooltip only when the pointer moves
641
+ * onto another mark, and the focus only when the groups change, so a move
642
+ * within a mark does not render the chart again. Off the marks and while a
643
+ * drag turns the chart, `hide` drops the tooltip but keeps the focus, so a
644
+ * flow does not flash in the gaps beside it and can be followed from every
645
+ * side; `leave` drops both.
329
646
  */
330
- function useHover(scene, width, height, setTooltip) {
647
+ function useHover(scene, width, height, setTooltip, focus) {
331
648
  const hovered = React.useRef(void 0);
332
649
  const show = (mark) => {
650
+ if (mark?.groups) focus.set(mark.groups);
333
651
  if (mark?.key === hovered.current?.key) return;
334
652
  hovered.current = mark;
335
653
  setTooltip(mark?.tooltip ?? null);
@@ -343,9 +661,45 @@ function useHover(scene, width, height, setTooltip) {
343
661
  };
344
662
  show(hit(scene.current, at));
345
663
  },
346
- leave: () => show(void 0)
664
+ hide: () => show(void 0),
665
+ leave: () => {
666
+ show(void 0);
667
+ focus.set(void 0);
668
+ }
347
669
  };
348
670
  }
671
+ /** A store for the groups in focus, which tells its listeners only when the groups change. */
672
+ function useFocus() {
673
+ const [focus] = React.useState(() => {
674
+ let groups;
675
+ const listeners = /* @__PURE__ */ new Set();
676
+ return {
677
+ get: () => groups,
678
+ set: (next) => {
679
+ if (JSON.stringify(next) === JSON.stringify(groups)) return;
680
+ groups = next;
681
+ listeners.forEach((listener) => listener());
682
+ },
683
+ subscribe: (listener) => {
684
+ listeners.add(listener);
685
+ return () => listeners.delete(listener);
686
+ }
687
+ };
688
+ });
689
+ return focus;
690
+ }
691
+ /**
692
+ * Whether an item fades under the focus: it has groups and shares none with
693
+ * the focus, while some item of `items` does, so a focus on groups that are
694
+ * gone fades nothing.
695
+ */
696
+ function useFades(items) {
697
+ const { focus } = useChart3D();
698
+ const groups = React.useSyncExternalStore(focus.subscribe, focus.get, focus.get);
699
+ const lit = (of) => of?.some((group) => groups?.includes(group)) ?? false;
700
+ const on = items.some((item) => lit(item.groups));
701
+ return (item) => on && item.groups !== void 0 && !lit(item.groups);
702
+ }
349
703
  /** The topmost mark under `at`: the last one painted that holds it. */
350
704
  function hit(marks, at) {
351
705
  for (let i = marks.length - 1; i >= 0; i -= 1) {
@@ -448,18 +802,88 @@ function ChartCanvas3D({ renderer, canvas, width, height }) {
448
802
  style: { aspectRatio: `${width} / ${height}` }
449
803
  });
450
804
  }
805
+ const GIZMO = [
806
+ {
807
+ axis: "x",
808
+ x: 1,
809
+ y: 0,
810
+ z: 0
811
+ },
812
+ {
813
+ axis: "y",
814
+ x: 0,
815
+ y: -1,
816
+ z: 0
817
+ },
818
+ {
819
+ axis: "z",
820
+ x: 0,
821
+ y: 0,
822
+ z: 1
823
+ }
824
+ ];
825
+ const CORNERS = {
826
+ "top-left": "top-2 left-2",
827
+ "top-right": "top-2 right-2",
828
+ "bottom-left": "bottom-2 left-2",
829
+ "bottom-right": "bottom-2 right-2"
830
+ };
831
+ /**
832
+ * The axes of the world in a corner of the chart: turned with the camera,
833
+ * neither panned nor zoomed, the axis nearest the camera on top. An axis
834
+ * draws in `currentColor`, so a text color on its `data-axis` recolors it.
835
+ */
836
+ function ChartGizmo3D({ camera, corner }) {
837
+ if (!corner) return null;
838
+ const eye = camera3D().origin({
839
+ x: 24,
840
+ y: 24
841
+ }).scale(18).rotateY(camera.rotateY()).rotateX(camera.rotateX());
842
+ const ends = points3D().camera(eye).data(GIZMO).sort(sort);
843
+ return /* @__PURE__ */ jsx("svg", {
844
+ "data-slot": "chart-3d-gizmo",
845
+ viewBox: "0 0 48 48",
846
+ "aria-hidden": "true",
847
+ className: cn("pointer-events-none absolute size-12 text-muted-foreground", CORNERS[corner]),
848
+ children: ends.map(({ axis, projected: p }) => /* @__PURE__ */ jsxs("g", {
849
+ "data-axis": axis,
850
+ children: [/* @__PURE__ */ jsx("line", {
851
+ x1: 24,
852
+ y1: 24,
853
+ x2: 24 + (p.x - 24) * .6,
854
+ y2: 24 + (p.y - 24) * .6,
855
+ stroke: "currentColor",
856
+ strokeWidth: 1.5,
857
+ strokeLinecap: "round"
858
+ }), /* @__PURE__ */ jsx("text", {
859
+ x: p.x,
860
+ y: p.y,
861
+ textAnchor: "middle",
862
+ dominantBaseline: "central",
863
+ fill: "currentColor",
864
+ className: "text-[10px] font-medium",
865
+ children: axis.toUpperCase()
866
+ })]
867
+ }, axis))
868
+ });
869
+ }
451
870
  /**
452
871
  * The marks of a chart, drawn by the renderer of the container: as SVG
453
872
  * elements, or on the canvas over the chart box. The hover reads them too.
454
873
  */
455
874
  function Marks3D({ marks }) {
456
875
  const { renderer, scene, repaint } = useChart3D();
876
+ const fades = useFades(marks);
877
+ const drawn = marks.map((mark) => fades(mark) ? {
878
+ ...mark,
879
+ fill: fade(mark.fill)
880
+ } : mark);
457
881
  React.useLayoutEffect(() => {
458
- scene.current = marks;
882
+ scene.current = drawn;
459
883
  repaint();
460
884
  });
461
885
  if (renderer === "canvas") return null;
462
- return marks.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
886
+ return drawn.map(({ key, points, r, fill, stroke }) => r === void 0 ? /* @__PURE__ */ jsx("path", {
463
887
  d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
464
888
  fill,
465
889
  stroke: fill,
@@ -479,19 +903,23 @@ function Marks3D({ marks }) {
479
903
  * read on the marks. Like the labels of the box, they are SVG with every renderer.
480
904
  */
481
905
  function Labels3D({ labels }) {
906
+ const fades = useFades(labels);
482
907
  return /* @__PURE__ */ jsx("g", {
483
908
  "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))
909
+ children: labels.map((label) => {
910
+ const { x, y } = label.toward ? beside(label.at, label.toward, textWidth(label.text, 10), 10, 0) : label.at;
911
+ return /* @__PURE__ */ jsx("text", {
912
+ x,
913
+ y,
914
+ textAnchor: label.toward ? "middle" : label.anchor,
915
+ dominantBaseline: "central",
916
+ paintOrder: "stroke",
917
+ strokeWidth: 3,
918
+ strokeLinejoin: "round",
919
+ className: cn("fill-foreground stroke-background text-[10px] font-medium", fades(label) && "opacity-25"),
920
+ children: label.text
921
+ }, label.key);
922
+ })
495
923
  });
496
924
  }
497
925
  function ChartStyle({ id, config }) {
@@ -537,46 +965,64 @@ function ChartTooltip3D({ tooltip, width, height }) {
537
965
  });
538
966
  }
539
967
  /**
540
- * A button in the corner of the chart that lists `SHORTCUTS`. `?` on the
541
- * chart opens and closes the list, Escape closes it.
968
+ * A button in the corner of the chart, named `label`, that lists the rows of
969
+ * its keymap. The `help` keys on the chart open and close the list, Escape
970
+ * closes it.
542
971
  */
543
- function ChartShortcuts3D() {
972
+ function ChartShortcuts3D({ rows, help, label }) {
544
973
  const [open, setOpen] = React.useState(false);
545
974
  const ref = React.useRef(null);
975
+ const id = React.useId();
976
+ const keys = help.join("\n");
546
977
  React.useEffect(() => {
547
978
  const chart = ref.current?.parentElement;
548
979
  const onKeyDown = (e) => {
549
- if (e.key === "?") setOpen((o) => !o);
980
+ if (keys.split("\n").includes(pressed(e))) setOpen((o) => !o);
550
981
  if (e.key === "Escape") setOpen(false);
551
982
  };
552
983
  chart?.addEventListener("keydown", onKeyDown);
553
984
  return () => chart?.removeEventListener("keydown", onKeyDown);
554
- }, []);
985
+ }, [keys]);
986
+ if (!rows.length) return null;
555
987
  return /* @__PURE__ */ jsxs("div", {
556
988
  ref,
557
989
  className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
558
990
  children: [/* @__PURE__ */ jsx("button", {
559
991
  type: "button",
560
- "aria-label": "Keyboard shortcuts",
992
+ "data-slot": "chart-3d-shortcuts-trigger",
993
+ "aria-label": label,
561
994
  "aria-expanded": open,
995
+ "aria-controls": open ? id : void 0,
562
996
  onClick: () => setOpen((o) => !o),
563
997
  className: "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4",
564
998
  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", {
999
+ }), open && /* @__PURE__ */ jsx(ChartShortcutList3D, {
1000
+ id,
1001
+ rows
1002
+ })]
1003
+ });
1004
+ }
1005
+ /** The list of the shortcut panel: per row, its groups of keys and its label. */
1006
+ function ChartShortcutList3D({ id, rows }) {
1007
+ return /* @__PURE__ */ jsx("dl", {
1008
+ id,
1009
+ "data-slot": "chart-3d-shortcuts",
1010
+ className: "grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-2 shadow-xl",
1011
+ children: rows.map(({ label, groups }) => /* @__PURE__ */ jsxs(React.Fragment, { children: [/* @__PURE__ */ jsx("dt", {
1012
+ className: "flex flex-wrap gap-x-2 gap-y-1",
1013
+ children: groups.map((group, i) => /* @__PURE__ */ jsx("span", {
568
1014
  "data-slot": "kbd-group",
569
1015
  className: "flex gap-1",
570
- children: hint.map((key) => /* @__PURE__ */ jsx("kbd", {
1016
+ children: group.map((key, j) => /* @__PURE__ */ jsx("kbd", {
571
1017
  "data-slot": "kbd",
572
1018
  className: "pointer-events-none inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none",
573
1019
  children: key
574
- }, key))
575
- }), /* @__PURE__ */ jsx("span", {
576
- className: "text-muted-foreground",
577
- children: label
578
- })] }, i))
579
- })]
1020
+ }, j))
1021
+ }, i))
1022
+ }), /* @__PURE__ */ jsx("dd", {
1023
+ className: "text-muted-foreground",
1024
+ children: label
1025
+ })] }, label))
580
1026
  });
581
1027
  }
582
1028
  function ChartLegend3D({ config }) {
@@ -593,104 +1039,274 @@ function ChartLegend3D({ config }) {
593
1039
  }
594
1040
  /**
595
1041
  * 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.
1042
+ * gridlines, the line of every axis along its edge with the mark of each of
1043
+ * its ticks, and the labels of every axis on each of its four edges. Only the
1044
+ * edge the axis sits on shows them, so when the axis moves to another edge,
1045
+ * its line, its marks and its labels fade from one to the other.
597
1046
  */
598
1047
  function Axes3D({ spec, world, format = {}, titles = {} }) {
599
1048
  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));
1049
+ const box = intoWorld(axes3D().camera(camera), world);
1050
+ const { labels, lines, marks } = labelAxes(box.edges(spec), format, titles);
601
1051
  return /* @__PURE__ */ jsxs("g", {
602
1052
  "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))]
1053
+ children: [
1054
+ box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
1055
+ d: box.draw(wall),
1056
+ className: "fill-muted/40 stroke-border",
1057
+ strokeWidth: .75
1058
+ }, `${wall.axis}${wall.side}`)),
1059
+ lines.map((line) => /* @__PURE__ */ jsx(AxisLine3D, {
1060
+ line,
1061
+ ink: "stroke-foreground",
1062
+ width: 1.5
1063
+ }, line.key)),
1064
+ marks.map((mark) => /* @__PURE__ */ jsx(AxisLine3D, {
1065
+ line: mark,
1066
+ ink: "stroke-foreground/60",
1067
+ width: 1
1068
+ }, mark.key)),
1069
+ labels.map((label) => /* @__PURE__ */ jsx(AxisLabel3D, { label }, label.key))
1070
+ ]
611
1071
  });
612
1072
  }
1073
+ const GAP = 10;
1074
+ const PAD = 2;
1075
+ const FADE = 4;
1076
+ const DODGE = 12;
1077
+ const MARK = 6;
613
1078
  /**
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.
1079
+ * The labels of the axes on all their edges, the line of every axis along its
1080
+ * edge and the mark of each of its ticks, shown on the edges the axes sit on.
1081
+ * Every edge fits its own tick labels; then the labels of an axis give way to
1082
+ * those of an axis with fewer ticks, which say more each, and the titles give
1083
+ * way to the ticks.
618
1084
  */
619
- function axisLabels(edge, format, title) {
620
- const { normal: n } = edge;
1085
+ function labelAxes(edges, format, titles) {
1086
+ const axes = edges.filter((edge) => edge.length).map((edge) => edgeLabels(edge, format[edge.axis] ?? String, titles[edge.axis]));
1087
+ const shown = axes.filter((axis) => axis.chosen).sort((a, b) => a.ticks.length - b.ticks.length);
1088
+ const placed = [];
1089
+ for (const label of [...shown.flatMap((a) => a.ticks), ...shown.flatMap((a) => a.title ?? [])]) {
1090
+ label.opacity = shows(label, placed);
1091
+ placed.push(label);
1092
+ }
1093
+ for (const axis of axes) axis.marks.forEach((mark, i) => mark.opacity = axis.ticks[i].opacity);
1094
+ return {
1095
+ labels: axes.flatMap((axis) => [...axis.ticks, ...axis.title ? [axis.title] : []]),
1096
+ lines: axes.map((axis) => axis.line),
1097
+ marks: axes.flatMap((axis) => axis.marks)
1098
+ };
1099
+ }
1100
+ /**
1101
+ * The labels of one edge: a label per tick beside it, away from the box, in
1102
+ * the rows that `fit` finds, and the title of the axis beyond them. The
1103
+ * labels of an edge the axis does not sit on are there, but do not show.
1104
+ */
1105
+ function edgeLabels(edge, format, title) {
1106
+ const { axis, normal: n, chosen } = edge;
1107
+ const label = (key, at, text, h, gap) => {
1108
+ const w = textWidth(text, h);
1109
+ return {
1110
+ key,
1111
+ axis,
1112
+ at: beside(at, n, w, h, gap),
1113
+ w,
1114
+ h,
1115
+ shift: ZERO,
1116
+ text,
1117
+ opacity: 1
1118
+ };
1119
+ };
1120
+ const id = `${axis}${edge.sides.join("")}`;
1121
+ const ticks = edge.map((tick) => label(`${id}-${tick[axis]}`, tick.projected, format(tick[axis]), 10, GAP));
1122
+ const depth = Math.max(...ticks.map((t) => Math.abs(n.x) * t.w + Math.abs(n.y) * t.h));
1123
+ const row = {
1124
+ x: n.x * (depth + PAD),
1125
+ y: n.y * (depth + PAD)
1126
+ };
1127
+ const rows = fit(ticks, n, row);
621
1128
  const start = edge[0].projected;
622
1129
  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
- });
631
1130
  const middle = {
632
1131
  x: (start.x + end.x) / 2,
633
1132
  y: (start.y + end.y) / 2
634
1133
  };
1134
+ const head = title ? {
1135
+ ...label(`${id}-title`, middle, title, 12, GAP + depth + GAP),
1136
+ title: true
1137
+ } : void 0;
1138
+ if (head && rows === 2) head.shift = row;
1139
+ if (!chosen) for (const l of [...ticks, ...head ? [head] : []]) l.opacity = 0;
635
1140
  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
1141
+ chosen,
1142
+ ticks,
1143
+ title: head,
1144
+ line: {
1145
+ key: id,
1146
+ from: start,
1147
+ to: end,
1148
+ opacity: chosen ? 1 : 0
1149
+ },
1150
+ marks: edge.map((tick, i) => ({
1151
+ key: ticks[i].key,
1152
+ from: tick.projected,
1153
+ to: {
1154
+ x: tick.projected.x + n.x * MARK,
1155
+ y: tick.projected.y + n.y * MARK
1156
+ },
1157
+ opacity: ticks[i].opacity
1158
+ }))
639
1159
  };
640
1160
  }
641
1161
  /**
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.
1162
+ * Fits the tick labels of an edge into the space between its ticks: in one
1163
+ * row if they keep `PAD` apart, or slid along the edge if no label has to
1164
+ * slide further than `DODGE`, else every other label a `row` further out if
1165
+ * that is enough, else every `stride`-th label of one row, 2, 4, 8… The
1166
+ * labels of a stride are among those of the one before, so as the edge
1167
+ * shortens, a label only ever leaves and none comes in for it. Returns the rows.
645
1168
  */
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];
1169
+ function fit(ticks, normal, row) {
1170
+ if (clear(ticks, 1) || slide(ticks, normal)) return 1;
1171
+ ticks.forEach((t, i) => t.shift = i % 2 ? row : ZERO);
1172
+ if (clear(ticks, 2)) return 2;
1173
+ ticks.forEach((t) => t.shift = ZERO);
1174
+ let stride = 2;
1175
+ while (stride < ticks.length && !clear(ticks, stride, stride)) stride *= 2;
1176
+ ticks.forEach((t, i) => t.opacity = i % stride ? 0 : 1);
1177
+ return 1;
1178
+ }
1179
+ /** Whether every label from every `every`-th keeps `PAD` apart from the one `step` after it. */
1180
+ function clear(ticks, step, every = 1) {
1181
+ return ticks.every((t, i) => i % every > 0 || i + step >= ticks.length || apart(t, ticks[i + step]) > 1.999999);
1182
+ }
1183
+ /**
1184
+ * Slides the labels of an edge along it, square to its `normal`, as little
1185
+ * as they can until each has the room it needs. True if that is enough and
1186
+ * no label slides further than `DODGE`; the slide grows from nothing as the
1187
+ * labels near, so it follows the camera without a jump.
1188
+ */
1189
+ function slide(ticks, normal) {
1190
+ const t = {
1191
+ x: -normal.y,
1192
+ y: normal.x
1193
+ };
1194
+ const along = ({ at }) => at.x * t.x + at.y * t.y;
1195
+ const line = [...ticks].sort((a, b) => along(a) - along(b));
1196
+ const gaps = line.slice(1).map((b, i) => room(line[i], b, normal, t));
1197
+ const placed = dodge(line.map(along), gaps);
1198
+ line.forEach((label, i) => {
1199
+ const d = placed[i] - along(label);
1200
+ label.shift = {
1201
+ x: t.x * d,
1202
+ y: t.y * d
1203
+ };
1204
+ });
1205
+ return line.every((label, i) => Math.abs(placed[i] - along(label)) <= DODGE) && clear(ticks, 1);
1206
+ }
1207
+ /**
1208
+ * How far along `t` the label `b` has to stand from `a`, which comes before
1209
+ * it there, for their boxes to keep `PAD` apart, as far as they stand apart
1210
+ * along `normal`, square to `t`.
1211
+ */
1212
+ function room(a, b, normal, t) {
1213
+ const off = (b.at.x - a.at.x) * normal.x + (b.at.y - a.at.y) * normal.y;
1214
+ const x = ((a.w + b.w) / 2 + PAD - off * normal.x * Math.sign(t.x)) / Math.abs(t.x);
1215
+ const y = ((a.h + b.h) / 2 + PAD - off * normal.y * Math.sign(t.y)) / Math.abs(t.y);
1216
+ return Math.min(x, y);
1217
+ }
1218
+ /**
1219
+ * Places points on a line in their order, `gaps[i]` at least between the
1220
+ * i-th and the next, and moves them as little as it can: points that would
1221
+ * come too close move as one block, centred on the places they want.
1222
+ */
1223
+ function dodge(want, gaps) {
1224
+ const blocks = [];
1225
+ want.forEach((at, start) => {
1226
+ let block = {
1227
+ start,
1228
+ offsets: [0],
1229
+ at
1230
+ };
1231
+ let last = blocks.at(-1);
1232
+ while (last && last.at + last.offsets.at(-1) + gaps[block.start - 1] > block.at) {
1233
+ blocks.pop();
1234
+ const base = last.offsets.at(-1) + gaps[block.start - 1];
1235
+ const offsets = [...last.offsets, ...block.offsets.map((o) => o + base)];
1236
+ const first = last.start;
1237
+ block = {
1238
+ start: first,
1239
+ offsets,
1240
+ at: offsets.reduce((sum, o, k) => sum + want[first + k] - o, 0) / offsets.length
1241
+ };
1242
+ last = blocks.at(-1);
1243
+ }
1244
+ blocks.push(block);
1245
+ });
1246
+ return blocks.flatMap((block) => block.offsets.map((o) => block.at + o));
1247
+ }
1248
+ /**
1249
+ * How much of a label shows beside the labels placed before it: it fades as
1250
+ * it nears a label of another axis, by as much as that one shows. As this
1251
+ * follows the geometry, the label fades as smoothly as the camera turns.
1252
+ */
1253
+ function shows(label, placed) {
1254
+ 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);
1255
+ }
1256
+ /** The space between the boxes of two labels, negative where they overlap. */
1257
+ function apart(a, b) {
1258
+ const dx = Math.abs(a.at.x + a.shift.x - b.at.x - b.shift.x) - (a.w + b.w) / 2;
1259
+ const dy = Math.abs(a.at.y + a.shift.y - b.at.y - b.shift.y) - (a.h + b.h) / 2;
1260
+ return Math.max(dx, dy);
1261
+ }
1262
+ /**
1263
+ * The centre of a box `w`×`h` that lies `gap` from `at` towards `toward`, a
1264
+ * unit vector. As the vector turns, the box slides round `at` and keeps the
1265
+ * gap, where an anchor that switches from start to end would jump.
1266
+ */
1267
+ function beside(at, toward, w, h, gap) {
1268
+ const out = gap + (Math.abs(toward.x) * w + Math.abs(toward.y) * h) / 2;
664
1269
  return {
665
- left,
666
- right: left + width,
667
- top: y - size / 2,
668
- bottom: y + size / 2
1270
+ x: at.x + toward.x * out,
1271
+ y: at.y + toward.y * out
669
1272
  };
670
1273
  }
1274
+ /** The width of a label at the font size `size`, guessed from its text, as the browser lays it out only after the render. */
1275
+ function textWidth(text, size) {
1276
+ return (typeof text === "string" || typeof text === "number" ? String(text).length : 4) * size * .6;
1277
+ }
671
1278
  /**
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.
1279
+ * A line of the axes, in the ink of the kind it is: the line of an axis along
1280
+ * its edge, or the mark of one of its ticks. Its place follows the camera at
1281
+ * once; a change of edge fades it in a transition.
675
1282
  */
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
- })]
1283
+ function AxisLine3D({ line: { from, to, opacity }, ink, width }) {
1284
+ return /* @__PURE__ */ jsx("line", {
1285
+ x1: from.x,
1286
+ y1: from.y,
1287
+ x2: to.x,
1288
+ y2: to.y,
1289
+ strokeWidth: width,
1290
+ style: { opacity: Math.round(opacity * 100) / 100 },
1291
+ className: cn("transition-opacity duration-200 ease-out motion-reduce:transition-none", ink)
1292
+ });
1293
+ }
1294
+ /**
1295
+ * A label of the chart box. Its place follows the camera at once; a change
1296
+ * of edge, of row or of the labels that fit fades and moves it in a transition.
1297
+ */
1298
+ function AxisLabel3D({ label: { at, shift, opacity, text, title } }) {
1299
+ return /* @__PURE__ */ jsx("text", {
1300
+ x: at.x,
1301
+ y: at.y,
1302
+ textAnchor: "middle",
1303
+ dominantBaseline: "central",
1304
+ style: {
1305
+ opacity: Math.round(opacity * 100) / 100,
1306
+ translate: `${shift.x}px ${shift.y}px`
1307
+ },
1308
+ className: cn("transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none", title ? "fill-foreground font-medium" : "fill-muted-foreground text-[10px]"),
1309
+ children: text
694
1310
  });
695
1311
  }
696
1312
  /** A nice axis from `lo` to `hi` with about `count` ticks. */
@@ -701,6 +1317,17 @@ function axis(lo, hi, count = 5) {
701
1317
  ticks: ticks(start, stop, count)
702
1318
  };
703
1319
  }
1320
+ /**
1321
+ * The axis `spec` with its domain widened by half a `cell` on each side, so
1322
+ * the box keeps room between the outermost data and its walls. The ticks stay
1323
+ * where they were, on the data.
1324
+ */
1325
+ function pad(spec, cell) {
1326
+ return {
1327
+ domain: [spec.domain[0] - cell / 2, spec.domain[1] + cell / 2],
1328
+ ticks: spec.ticks
1329
+ };
1330
+ }
704
1331
  /** The linear map from `domain` to `range`. */
705
1332
  function linear([d0, d1], [r0, r1]) {
706
1333
  return (value) => r0 + (value - d0) / (d1 - d0 || 1) * (r1 - r0);
@@ -713,6 +1340,13 @@ function worldBox(spec, depth = 1) {
713
1340
  z: linear(spec.z.domain, [-depth, depth])
714
1341
  };
715
1342
  }
1343
+ /**
1344
+ * Hands the projection its accessors into the world box, so every chart maps
1345
+ * its shapes onto the screen the same way.
1346
+ */
1347
+ function intoWorld(projection, world) {
1348
+ return projection.x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
1349
+ }
716
1350
  const LIGHT = {
717
1351
  x: -.4,
718
1352
  y: -.8,
@@ -729,6 +1363,10 @@ function shade(face, color) {
729
1363
  const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
730
1364
  return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
731
1365
  }
1366
+ /** `color` out of focus: a quarter of it over the background, opaque, so the seams between faces stay closed. */
1367
+ function fade(color) {
1368
+ return `color-mix(in oklch, ${color} 25%, var(--background))`;
1369
+ }
732
1370
  /** The mean of the projected points, where a tooltip points at. */
733
1371
  function center(points) {
734
1372
  const sum = points.reduce((s, p) => ({
@@ -761,13 +1399,15 @@ function cubeMarks(id, faces, color, tooltip) {
761
1399
  function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
762
1400
  const { config, camera } = useChart3D();
763
1401
  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);
1402
+ const target = barValues(data, series);
1403
+ const cells = barCells(data, series, useTween(target, 0));
1404
+ const box = barBox(data.length, series.length, Object.values(target), barSize);
1405
+ const spec = {
1406
+ ...box.spec,
1407
+ y: useTweenAxes(box.spec).y
1408
+ };
1409
+ const world = worldBox(spec, box.depth);
1410
+ const { size } = box;
771
1411
  const bars = cubes3D().camera(camera).data(cells.map(({ i, j, key, value }) => Object.assign(barCorners(world, i, j, value, size), {
772
1412
  i,
773
1413
  key,
@@ -791,32 +1431,48 @@ function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
791
1431
  value: bar.value
792
1432
  })) })] });
793
1433
  }
1434
+ /** The cell of every bar: the entry `i` along x, the series `key` along z, with its tweened value. */
1435
+ function barCells(data, series, values) {
1436
+ return data.flatMap((_, i) => series.map((key, j) => ({
1437
+ i,
1438
+ j,
1439
+ key,
1440
+ value: values[`${i}-${key}`]
1441
+ })));
1442
+ }
1443
+ /**
1444
+ * The value of every cell of the chart, keyed as its mark: the entry `i`
1445
+ * along x and the series `key` along z, standing as 0 where the entry holds none.
1446
+ */
1447
+ function barValues(data, series) {
1448
+ return Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${i}-${key}`, Number(d[key]) || 0])));
1449
+ }
794
1450
  /**
795
1451
  * 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.
1452
+ * with half a cell of room between the outer rows and the walls, and the
1453
+ * size of a bar, the share `barSize` of its cell.
797
1454
  */
798
1455
  function barBox(entries, series, values, barSize) {
799
1456
  const index = (length) => Array.from({ length }, (_, i) => i);
800
1457
  const spec = {
801
- x: {
1458
+ x: pad({
802
1459
  domain: [-.5, entries - .5],
803
1460
  ticks: index(entries)
804
- },
1461
+ }, 1),
805
1462
  y: axis(Math.min(0, ...values), Math.max(0, ...values)),
806
- z: {
1463
+ z: pad({
807
1464
  domain: [-.5, series - .5],
808
1465
  ticks: index(series)
809
- }
1466
+ }, 1)
810
1467
  };
811
1468
  const depth = Math.min(1, Math.max(.25, series / entries));
812
- const size = {
813
- width: 2 / entries * barSize,
814
- depth: 2 * depth / series * barSize
815
- };
816
1469
  return {
817
1470
  spec,
818
- world: worldBox(spec, depth),
819
- size
1471
+ depth,
1472
+ size: {
1473
+ width: 2 / (spec.x.domain[1] - spec.x.domain[0]) * barSize,
1474
+ depth: 2 * depth / (spec.z.domain[1] - spec.z.domain[0]) * barSize
1475
+ }
820
1476
  };
821
1477
  }
822
1478
  /**
@@ -858,18 +1514,11 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
858
1514
  y,
859
1515
  z
860
1516
  };
861
- const read = (d, axis) => Number(d[fields[axis]]);
862
- const range = (a) => {
863
- const [lo = 0, hi = 0] = extent(data, (d) => read(d, a));
864
- return axis(lo, hi);
865
- };
866
- const spec = {
867
- x: range("x"),
868
- y: range("y"),
869
- z: range("z")
870
- };
1517
+ const values = useTween(pointValues(data, fields));
1518
+ const read = (i, axis) => values[`${axis}${i}`];
1519
+ const spec = useTweenAxes(pointAxes(data, fields));
871
1520
  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")));
1521
+ const points = points3D().camera(camera).x(({ i }) => world.x(read(i, "x"))).y(({ i }) => world.y(read(i, "y"))).z(({ i }) => world.z(read(i, "z")));
873
1522
  const fallback = Object.keys(config)[0];
874
1523
  const marks = points.data(data.map((d, i) => ({
875
1524
  d,
@@ -891,7 +1540,7 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
891
1540
  "z"
892
1541
  ].map((a) => ({
893
1542
  label: titles?.[a] ?? fields[a],
894
- value: read(p.d, a)
1543
+ value: read(p.i, a)
895
1544
  }))
896
1545
  }
897
1546
  };
@@ -903,6 +1552,26 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
903
1552
  titles
904
1553
  }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
905
1554
  }
1555
+ /** The axes that hold every entry of `data`, a nice one per field. */
1556
+ function pointAxes(data, fields) {
1557
+ const range = (a) => {
1558
+ const [lo = 0, hi = 0] = extent(data, (d) => Number(d[fields[a]]));
1559
+ return axis(lo, hi);
1560
+ };
1561
+ return {
1562
+ x: range("x"),
1563
+ y: range("y"),
1564
+ z: range("z")
1565
+ };
1566
+ }
1567
+ /** The position of every entry, keyed by its axis and index, gliding into new data. */
1568
+ function pointValues(data, fields) {
1569
+ return Object.fromEntries(data.flatMap((d, i) => [
1570
+ "x",
1571
+ "y",
1572
+ "z"
1573
+ ].map((a) => [`${a}${i}`, Number(d[fields[a]])])));
1574
+ }
906
1575
  // registry/new-york/ui/surface-chart-3d.tsx
907
1576
  /**
908
1577
  * A surface over a grid: `data[row][column]` is the height, the columns run
@@ -913,21 +1582,28 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
913
1582
  function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
914
1583
  const { config, camera } = useChart3D();
915
1584
  const [low, high = low] = colors ?? Object.keys(config);
1585
+ const tweened = useTween(gridValues(data));
1586
+ const heights = data.map((row, r) => row.map((_, c) => tweened[`${r}-${c}`]));
916
1587
  const rows = data.length;
917
1588
  const columns = data[0]?.length ?? 0;
918
1589
  const [x0, x1] = domain.x ?? [0, columns - 1];
919
1590
  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)
1591
+ const cell = {
1592
+ x: (x1 - x0) / Math.max(1, columns - 1),
1593
+ z: (z1 - z0) / Math.max(1, rows - 1)
925
1594
  };
1595
+ const [lo = 0, hi = 0] = extent(heights.flat());
1596
+ const [floor = 0, peak = 0] = extent(data.flat());
1597
+ const spec = useTweenAxes({
1598
+ x: domain.x ? axis(x0, x1) : pad(axis(x0, x1), cell.x),
1599
+ y: axis(floor, peak),
1600
+ z: domain.z ? axis(z0, z1) : pad(axis(z0, z1), cell.z)
1601
+ });
926
1602
  const world = worldBox(spec);
927
1603
  const toX = linear([0, columns - 1], [x0, x1]);
928
1604
  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) => ({
1605
+ const grid = intoWorld(gridPlanes3D().camera(camera), world).rows(Math.max(1, columns));
1606
+ const points = heights.flatMap((row, r) => row.map((y, c) => ({
931
1607
  x: toX(c),
932
1608
  y,
933
1609
  z: toZ(r)
@@ -955,10 +1631,16 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
955
1631
  titles
956
1632
  }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
957
1633
  }
1634
+ /** The height of every cell, keyed by its row and column, gliding into new data. */
1635
+ function gridValues(data) {
1636
+ return Object.fromEntries(data.flatMap((row, r) => row.map((y, c) => [`${r}-${c}`, y])));
1637
+ }
958
1638
  // registry/new-york/ui/sankey-chart-3d.tsx
959
1639
  const DEPTH = .4;
960
1640
  const RIBBON = .7;
961
1641
  const SEGMENTS = 16;
1642
+ const STEEPEST = 1.5;
1643
+ const SWING = 8;
962
1644
  /**
963
1645
  * Flows from left to right: every node a box in its column, as high as the
964
1646
  * larger of what flows in and what flows out, and every flow a band from node
@@ -967,9 +1649,13 @@ const SEGMENTS = 16;
967
1649
  */
968
1650
  function SankeyChart3D({ data, nodeWidth = .2, nodePadding = .05, format = (value) => value }) {
969
1651
  const { config, camera } = useChart3D();
970
- const { nodes, columns, ky } = sankeyLayout(data, nodePadding);
1652
+ const values = useTween(Object.fromEntries(data.map((d, i) => [String(i), d.value])), 0);
1653
+ const { nodes, columns, ky } = sankeyLayout(data.map((d, i) => ({
1654
+ ...d,
1655
+ value: values[String(i)]
1656
+ })), nodePadding);
971
1657
  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)] : []);
1658
+ const labels = pieces.flatMap(({ key, faces, node }) => node ? [nodeLabel(key, faces, node.column < (columns - 1) / 2 ? "left" : "right", style(config, node).label, links(node))] : []);
973
1659
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Marks3D, { marks: pieces.flatMap((piece) => pieceMarks(piece, config, format)) }), /* @__PURE__ */ jsx(Labels3D, { labels })] });
974
1660
  }
975
1661
  /** The label and the color of a node, from the chart config. */
@@ -980,13 +1666,22 @@ function style(config, node) {
980
1666
  };
981
1667
  }
982
1668
  /**
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.
1669
+ * The keys of the flows at a node. Its marks and its label carry them, so the
1670
+ * focus on the node lights its flows and the nodes at their other ends, and
1671
+ * the focus on a flow lights its two nodes.
1672
+ */
1673
+ function links(node) {
1674
+ return [...new Set([...node.incoming, ...node.outgoing].map((hop) => hop.flow.key))];
1675
+ }
1676
+ /**
1677
+ * The marks of a piece, each with its tooltip and its groups: the faces of a
1678
+ * node, or the faces of a slice of a flow, in the color it has turned to there.
985
1679
  */
986
1680
  function pieceMarks({ key, faces, node, flow, t }, config, format) {
987
1681
  const at = center(faces.find((face) => face.face === "top"));
988
1682
  if (node) {
989
1683
  const { label, color } = style(config, node);
1684
+ const groups = links(node);
990
1685
  return cubeMarks(key, faces, color, {
991
1686
  at,
992
1687
  items: [{
@@ -994,7 +1689,10 @@ function pieceMarks({ key, faces, node, flow, t }, config, format) {
994
1689
  value: format(node.value),
995
1690
  color
996
1691
  }]
997
- });
1692
+ }).map((mark) => ({
1693
+ ...mark,
1694
+ groups
1695
+ }));
998
1696
  }
999
1697
  const source = style(config, flow.source);
1000
1698
  const target = style(config, flow.target);
@@ -1010,7 +1708,10 @@ function pieceMarks({ key, faces, node, flow, t }, config, format) {
1010
1708
  return cubeMarks(key, faces.filter((face) => face.face !== "left" && face.face !== "right"), `color-mix(in oklch, color-mix(in oklch, ${target.color} ${t}%, ${source.color}) 75%, var(--background))`, {
1011
1709
  at,
1012
1710
  items: [item]
1013
- });
1711
+ }).map((mark) => ({
1712
+ ...mark,
1713
+ groups: [flow.key]
1714
+ }));
1014
1715
  }
1015
1716
  /** The nodes of `data`, waypoints included, stacked in their columns, and `ky`, the world units per value. */
1016
1717
  function sankeyLayout(data, nodePadding) {
@@ -1018,6 +1719,7 @@ function sankeyLayout(data, nodePadding) {
1018
1719
  const all = sankeyHops(nodes, flows);
1019
1720
  const ky = stackColumns(all, columns, nodePadding);
1020
1721
  stackHops(all, ky);
1722
+ weaveHops(all, columns, ky);
1021
1723
  return {
1022
1724
  nodes: all,
1023
1725
  columns,
@@ -1050,12 +1752,18 @@ function sankeyColumns(data) {
1050
1752
  if (!nodes.has(id)) nodes.set(id, newNode(id, 0));
1051
1753
  return nodes.get(id);
1052
1754
  };
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
- }));
1755
+ const seen = /* @__PURE__ */ new Map();
1756
+ const flows = data.filter((d) => d.value > 0).map((d) => {
1757
+ const ends = JSON.stringify([d.source, d.target]);
1758
+ const count = seen.get(ends) ?? 0;
1759
+ seen.set(ends, count + 1);
1760
+ return {
1761
+ key: count ? `${ends}#${count}` : ends,
1762
+ source: node(d.source),
1763
+ target: node(d.target),
1764
+ value: d.value
1765
+ };
1766
+ });
1059
1767
  for (let pass = 0; flows.some((f) => f.target.column <= f.source.column); pass += 1) {
1060
1768
  if (pass === nodes.size) throw new Error("SankeyChart3D: the flows form a cycle");
1061
1769
  for (const f of flows) f.target.column = Math.max(f.target.column, f.source.column + 1);
@@ -1086,7 +1794,8 @@ function sankeyHops(nodes, flows) {
1086
1794
  source,
1087
1795
  target,
1088
1796
  y0: 0,
1089
- y1: 0
1797
+ y1: 0,
1798
+ z: 0
1090
1799
  };
1091
1800
  source.outgoing.push(hop);
1092
1801
  target.incoming.push(hop);
@@ -1140,6 +1849,84 @@ function stackHops(nodes, ky) {
1140
1849
  }
1141
1850
  }
1142
1851
  /**
1852
+ * Parts the hops that cross, gap by gap. The hops of a gap that cross, directly
1853
+ * or through one another, form a knot. The knots of a gap lie one above the
1854
+ * other across the whole gap; only the hops within a knot share their depth in lanes.
1855
+ */
1856
+ function weaveHops(nodes, columns, ky) {
1857
+ for (let column = 0; column < columns - 1; column += 1) {
1858
+ const hops = nodes.filter((node) => node.column === column).flatMap((node) => node.outgoing).sort((a, b) => a.y0 - b.y0);
1859
+ const rank = new Map([...hops].sort((a, b) => a.y1 - b.y1).map((hop, i) => [hop, i]));
1860
+ let start = 0;
1861
+ let reach = 0;
1862
+ hops.forEach((hop, i) => {
1863
+ reach = Math.max(reach, rank.get(hop));
1864
+ if (reach > i) return;
1865
+ if (i > start) knotHops(hops.slice(start, i + 1), ky);
1866
+ start = i + 1;
1867
+ });
1868
+ }
1869
+ }
1870
+ /**
1871
+ * The lanes of a knot. Every hop takes the lowest lane that no hop crossing it
1872
+ * holds, which needs the fewest, and the lightest lane goes to the front. The
1873
+ * lanes share the depth of a flow, a third of a lane apart, so nothing reaches
1874
+ * out of it. The hops hold their lanes over the slices where any two of them
1875
+ * overlap, so their depth is constant there, and leave the rest for the way in
1876
+ * and out.
1877
+ */
1878
+ function knotHops(hops, ky) {
1879
+ const lanes = [];
1880
+ for (const [i, hop] of hops.entries()) {
1881
+ const taken = hops.slice(0, i).flatMap((above, j) => above.y1 > hop.y1 ? [lanes[j]] : []);
1882
+ let lane = 0;
1883
+ while (taken.includes(lane)) lane += 1;
1884
+ lanes.push(lane);
1885
+ }
1886
+ const count = Math.max(...lanes) + 1;
1887
+ const load = (lane) => hops.reduce((sum, hop, i) => sum + (lanes[i] === lane ? hop.flow.value : 0), 0);
1888
+ const front = Array.from({ length: count }, (_, lane) => lane).sort((a, b) => load(a) - load(b));
1889
+ const slices = hops.flatMap((a, i) => hops.slice(i + 1).flatMap((b) => a.y1 > b.y1 ? overlaps(a, b, ky) : []));
1890
+ const ribbon = RIBBON * DEPTH;
1891
+ const depth = 3 * ribbon / (4 * count - 1);
1892
+ const knot = {
1893
+ from: Math.min(...slices),
1894
+ to: Math.max(...slices) + 1,
1895
+ first: hops[0],
1896
+ depth
1897
+ };
1898
+ for (const [i, hop] of hops.entries()) {
1899
+ hop.z = (ribbon - depth) / 2 - front.indexOf(lanes[i]) * 4 * depth / 3;
1900
+ hop.knot = knot;
1901
+ }
1902
+ }
1903
+ /**
1904
+ * The slices in which two hops, `a` and `b`, overlap in height. Along a slice
1905
+ * their distance runs straight from one cross-section to the next, so they
1906
+ * overlap in it if they are closer than half their thicknesses at one of its
1907
+ * ends, or pass each other within it.
1908
+ */
1909
+ function overlaps(a, b, ky) {
1910
+ const apart = (k) => a.y0 - b.y0 + (a.y1 - b.y1 - (a.y0 - b.y0)) * ease(k / SEGMENTS);
1911
+ const near = (a.flow.value + b.flow.value) * ky / 2;
1912
+ return Array.from({ length: SEGMENTS }, (_, k) => k).filter((k) => Math.abs(apart(k)) < near || Math.abs(apart(k + 1)) < near || Math.sign(apart(k)) !== Math.sign(apart(k + 1)));
1913
+ }
1914
+ /** The S-curve of a flow, from 0 at t = 0 to 1 at t = 1, level at both ends. */
1915
+ function ease(t) {
1916
+ return t * t * (3 - 2 * t);
1917
+ }
1918
+ /**
1919
+ * How far a hop of `knot` has moved into its lane at the cross-section `k`:
1920
+ * not at all at a node, fully between the borders of the knot, and along the
1921
+ * S-curve in between. A knot that starts at a node starts there in its lanes.
1922
+ */
1923
+ function bump(k, knot) {
1924
+ if (!knot) return 0;
1925
+ if (k < knot.from) return ease(k / knot.from);
1926
+ if (k > knot.to) return ease((SEGMENTS - k) / (SEGMENTS - knot.to));
1927
+ return 1;
1928
+ }
1929
+ /**
1143
1930
  * The pieces of the chart: a box per node, a flat slice of its flow per
1144
1931
  * waypoint and `SEGMENTS` slices per hop. The columns stand from x = -1 to 1,
1145
1932
  * a node the share `nodeWidth` of the distance between two of them wide.
@@ -1154,6 +1941,7 @@ function sankeyPieces(nodes, columns, ky, nodeWidth) {
1154
1941
  };
1155
1942
  return nodes.flatMap((node) => {
1156
1943
  const band = x(node.column);
1944
+ const y = middle(node);
1157
1945
  const own = node.flow ? {
1158
1946
  key: `way:${node.id}`,
1159
1947
  band,
@@ -1165,87 +1953,138 @@ function sankeyPieces(nodes, columns, ky, nodeWidth) {
1165
1953
  node,
1166
1954
  t: 0
1167
1955
  };
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))];
1956
+ const d = node.flow ? RIBBON * DEPTH : DEPTH;
1957
+ const box = slab({
1958
+ x: band - half,
1959
+ y,
1960
+ z: 0,
1961
+ d
1962
+ }, {
1963
+ x: band + half,
1964
+ y,
1965
+ z: 0,
1966
+ d
1967
+ }, node.y1 - node.y0);
1968
+ return [Object.assign(box, own, {
1969
+ row: y,
1970
+ lane: 0,
1971
+ level: y
1972
+ }), ...node.outgoing.flatMap((hop) => hopPieces(hop, band + half, x(node.column + 1) - half, ky, share))];
1171
1973
  });
1172
1974
  }
1173
1975
  /**
1174
1976
  * 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
1977
+ * arrives level, so that the hops of a flow join without a kink. A hop of a
1978
+ * knot narrows into its lane and widens back along the same curve. Every hop
1176
1979
  * between the same two columns has the same slices.
1177
1980
  */
1178
1981
  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);
1982
+ const ribbon = RIBBON * DEPTH;
1983
+ const gentle = (slices, depth) => (ribbon - depth) * 1.5 / (slices * (x1 - x0) / SEGMENTS) <= STEEPEST;
1984
+ const knot = hop.knot && {
1985
+ ...hop.knot,
1986
+ from: gentle(hop.knot.from, hop.knot.depth) ? hop.knot.from : 0,
1987
+ to: gentle(SEGMENTS - hop.knot.to, hop.knot.depth) ? hop.knot.to : SEGMENTS
1988
+ };
1989
+ const at = (of, k) => {
1990
+ const into = bump(k, knot);
1991
+ return {
1992
+ x: x0 + (x1 - x0) * k / SEGMENTS,
1993
+ y: of.y0 + (of.y1 - of.y0) * ease(k / SEGMENTS),
1994
+ z: of.z * into,
1995
+ d: ribbon + ((knot?.depth ?? ribbon) - ribbon) * into
1996
+ };
1183
1997
  };
1184
1998
  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), {
1999
+ const [from, to] = [at(hop, k), at(hop, k + 1)];
2000
+ const first = knot ? [at(knot.first, k), at(knot.first, k + 1)] : [from, to];
2001
+ const band = (from.x + to.x) / 2;
2002
+ return Object.assign(slab(from, to, hop.flow.value * ky), {
1187
2003
  key: `hop:${hop.flow.key}:${hop.source.column}:${k}`,
1188
2004
  band,
1189
2005
  flow: hop.flow,
1190
- t: share(hop.flow, band)
2006
+ t: share(hop.flow, band),
2007
+ row: (first[0].y + first[1].y) / 2,
2008
+ lane: knot && k >= knot.from && k < knot.to ? hop.z : 0,
2009
+ level: (from.y + to.y) / 2
1191
2010
  });
1192
2011
  });
1193
2012
  }
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) => [
2013
+ /**
2014
+ * The corners of a slab from the cross-section around `from` to the one around
2015
+ * `to`, `height` high and each `d` deep, in the order of box(). Two rectangles
2016
+ * of one height keep the faces planar and the slab convex.
2017
+ */
2018
+ function slab(from, to, height) {
2019
+ return [1, -1].flatMap((side) => [
1197
2020
  {
1198
- x: x0,
1199
- y: y0 - height / 2,
1200
- z
2021
+ x: from.x,
2022
+ y: from.y - height / 2,
2023
+ z: from.z + side * from.d / 2
1201
2024
  },
1202
2025
  {
1203
- x: x0,
1204
- y: y0 + height / 2,
1205
- z
2026
+ x: from.x,
2027
+ y: from.y + height / 2,
2028
+ z: from.z + side * from.d / 2
1206
2029
  },
1207
2030
  {
1208
- x: x1,
1209
- y: y1 + height / 2,
1210
- z
2031
+ x: to.x,
2032
+ y: to.y + height / 2,
2033
+ z: to.z + side * to.d / 2
1211
2034
  },
1212
2035
  {
1213
- x: x1,
1214
- y: y1 - height / 2,
1215
- z
2036
+ x: to.x,
2037
+ y: to.y - height / 2,
2038
+ z: to.z + side * to.d / 2
1216
2039
  }
1217
2040
  ]);
1218
2041
  }
1219
2042
  /**
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.
2043
+ * The pieces in the order to paint them, by the first plane that parts two of
2044
+ * them: the slabs of the columns and slices lie between planes x = const; the
2045
+ * knots of a slice, one above the other; the lanes of a knot, where its hops
2046
+ * hold them, one behind the other; and the hops of a lane, or of a knot
2047
+ * outside its borders, which do not overlap there, one above the other. Each
2048
+ * key paints the part farther from the eye first, so the order is exact for
2049
+ * every eye, and keys of numbers cannot form a cycle.
1224
2050
  */
1225
2051
  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));
2052
+ const view = camera.viewDirection();
2053
+ const perspective = camera.perspective();
2054
+ const far = (at, v) => {
2055
+ const eye = v * perspective;
2056
+ return Number.isFinite(eye) ? Math.abs(at - eye) : -at * Math.sign(v);
2057
+ };
2058
+ return pieces.sort((a, b) => far(b.band, view.x) - far(a.band, view.x) || far(b.row, view.y) - far(a.row, view.y) || far(b.lane, view.z) - far(a.lane, view.z) || far(b.level, view.y) - far(a.level, view.y) || sort(a, b));
1230
2059
  }
1231
2060
  /**
1232
2061
  * 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.
2062
+ * so that it stays outside whichever way the camera turns. Seen edge on, where
2063
+ * the two sides meet and away has no direction, the label lifts over the node,
2064
+ * so it swings round to the other side instead of jumping across. It carries
2065
+ * the groups of the node, so it fades with the node.
1234
2066
  */
1235
- function nodeLabel(key, faces, outer, text) {
2067
+ function nodeLabel(key, faces, outer, text, groups) {
1236
2068
  const side = (name) => center(faces.find((face) => face.face === name));
1237
2069
  const at = side(outer);
1238
- const away = Math.sign(at.x - side(outer === "left" ? "right" : "left").x) || (outer === "left" ? -1 : 1);
2070
+ const away = at.x - side(outer === "left" ? "right" : "left").x;
2071
+ const lift = Math.max(0, SWING - Math.abs(away));
2072
+ const length = Math.hypot(away, lift);
2073
+ const toward = {
2074
+ x: away / length,
2075
+ y: -lift / length
2076
+ };
1239
2077
  return {
1240
2078
  key,
1241
2079
  at: {
1242
- x: at.x + 8 * away,
1243
- y: at.y
2080
+ x: at.x + 8 * toward.x,
2081
+ y: at.y + 8 * toward.y
1244
2082
  },
1245
2083
  text,
1246
- anchor: away < 0 ? "end" : "start"
2084
+ toward,
2085
+ groups
1247
2086
  };
1248
2087
  }
1249
- export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, linear, shade, useChart3D, worldBox };
2088
+ export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, intoWorld, linear, pad, shade, useChart3D, useTween, useTweenAxes, worldBox };
1250
2089
 
1251
2090
  //# sourceMappingURL=index.js.map