@niekes/charts-3d 0.2.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,107 +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));
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
+ }
109
220
  function ChartContainer3D({ id, config, defaultView = {
110
221
  yaw: Math.PI / 6,
111
222
  tilt: Math.PI / 6
112
- }, 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 }) {
113
224
  const uniqueId = React.useId();
114
225
  const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
115
- const [tooltip, setTooltip] = React.useState(null);
116
- const { scene, renderer, canvas, repaint, hover, leave } = useScene(requested, width, height, setTooltip);
117
- 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);
118
229
  const camera = useCamera(view, width, height);
119
230
  const context = React.useMemo(() => ({
120
231
  config,
121
232
  camera,
122
233
  renderer,
123
234
  scene,
124
- repaint
235
+ repaint,
236
+ focus,
237
+ animationDuration
125
238
  }), [
126
239
  config,
127
240
  camera,
128
241
  renderer,
129
- repaint
242
+ repaint,
243
+ focus,
244
+ animationDuration
130
245
  ]);
131
246
  return /* @__PURE__ */ jsxs("div", {
132
247
  "data-slot": "chart-3d",
@@ -138,52 +253,83 @@ function ChartContainer3D({ id, config, defaultView = {
138
253
  id: chartId,
139
254
  config
140
255
  }),
141
- /* @__PURE__ */ jsx("svg", {
142
- viewBox: `0 0 ${width} ${height}`,
143
- role: "img",
144
- "aria-label": ariaLabel,
145
- tabIndex: 0,
146
- className: "block h-auto w-full cursor-grab touch-none rounded-lg outline-hidden select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing",
147
- "data-orbiting": orbiting,
148
- onPointerLeave: leave,
149
- ...handlers,
150
- children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
151
- value: context,
152
- children
153
- })
154
- }),
155
- /* @__PURE__ */ jsx(ChartCanvas3D, {
156
- renderer,
157
- canvas,
158
- width,
159
- height
256
+ /* @__PURE__ */ jsxs("div", {
257
+ className: "relative rounded-lg has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50",
258
+ children: [
259
+ /* @__PURE__ */ jsx("svg", {
260
+ viewBox: `0 0 ${width} ${height}`,
261
+ role: "img",
262
+ ...keymapProps(keys, ariaLabel),
263
+ onPointerLeave: leave,
264
+ ...handlers,
265
+ children: /* @__PURE__ */ jsx(Chart3DContext.Provider, {
266
+ value: context,
267
+ children
268
+ })
269
+ }),
270
+ /* @__PURE__ */ jsx(ChartCanvas3D, {
271
+ renderer,
272
+ canvas,
273
+ width,
274
+ height
275
+ }),
276
+ /* @__PURE__ */ jsx(ChartGizmo3D, {
277
+ camera,
278
+ corner: gizmo
279
+ }),
280
+ /* @__PURE__ */ jsx(ChartTooltip3D, {
281
+ tooltip,
282
+ width,
283
+ height
284
+ })
285
+ ]
160
286
  }),
161
- /* @__PURE__ */ jsx(ChartTooltip3D, {
162
- tooltip,
163
- width,
164
- height
165
- }),
166
- shortcuts && /* @__PURE__ */ jsx(ChartShortcuts3D, {}),
287
+ /* @__PURE__ */ jsx(ChartShortcuts3D, { ...keys }),
167
288
  legend && /* @__PURE__ */ jsx(ChartLegend3D, { config })
168
289
  ]
169
290
  });
170
291
  }
171
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
+ /**
172
313
  * The marks of the chart and what reads them: the renderer that draws them,
173
314
  * the canvas it paints them on and the hover that shows their tooltips.
174
315
  */
175
- function useScene(requested, width, height, setTooltip) {
316
+ function useScene(requested, width, height) {
176
317
  const scene = React.useRef([]);
318
+ const [tooltip, setTooltip] = React.useState(null);
319
+ const focus = useFocus();
177
320
  const renderer = useRenderer(requested);
178
321
  const { canvas, repaint } = useCanvas(scene, width);
179
- const { hover, leave } = useHover(scene, width, height, setTooltip);
322
+ const { hover, hide, leave } = useHover(scene, width, height, setTooltip, focus);
180
323
  return {
181
324
  scene,
182
325
  renderer,
183
326
  canvas,
184
327
  repaint,
185
328
  hover,
186
- leave
329
+ hide,
330
+ leave,
331
+ tooltip,
332
+ focus
187
333
  };
188
334
  }
189
335
  /** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
@@ -197,6 +343,11 @@ function useCamera(view, width, height) {
197
343
  height
198
344
  ]);
199
345
  }
346
+ const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
347
+ const ZERO = {
348
+ x: 0,
349
+ y: 0
350
+ };
200
351
  /** `view` moved by one step. The tilt stops at the zenith and the nadir, the zoom at 1/4 and 8. */
201
352
  function moved(view, move, initial) {
202
353
  if (move.reset) return initial;
@@ -214,7 +365,6 @@ function moved(view, move, initial) {
214
365
  x: 0,
215
366
  y: 0
216
367
  };
217
- const clamp = (lo, hi, value) => Math.max(lo, Math.min(hi, value));
218
368
  return {
219
369
  ...view,
220
370
  yaw: view.yaw + step.yaw,
@@ -227,30 +377,183 @@ function moved(view, move, initial) {
227
377
  };
228
378
  }
229
379
  /**
230
- * The view of the camera, and the handlers that move it: a drag turns it,
231
- * or pans it while Space is held, the keys of `SHORTCUTS` step it, Ctrl or ⌘
232
- * with the wheel zooms it and a double click resets it. `orbiting` is true
233
- * while the camera turns. `onDragStart` runs when a press becomes a drag,
234
- * `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.
235
392
  */
236
- 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) {
237
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);
238
541
  const [orbiting, setOrbiting] = React.useState(false);
239
542
  const drag = React.useRef(null);
240
- const space = React.useRef(false);
241
- const ref = useWheelZoom(setView);
242
- 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);
243
547
  const release = () => {
244
548
  drag.current = null;
245
549
  setOrbiting(false);
246
550
  };
247
551
  return {
248
552
  view,
249
- orbiting,
250
553
  handlers: {
251
554
  ref,
252
555
  onPointerDown: (e) => {
253
- drag.current = {
556
+ if (space.current || keys.on("dragRotate")) drag.current = {
254
557
  x: e.clientX,
255
558
  y: e.clientY,
256
559
  moved: false
@@ -283,46 +586,68 @@ function useOrbit(defaultView, width, onDragStart, onHover) {
283
586
  },
284
587
  onPointerUp: release,
285
588
  onPointerCancel: release,
286
- onDoubleClick: () => move({ reset: true }),
287
- onKeyDown: (e) => {
288
- if (e.key === " ") space.current = true;
289
- const step = e.shiftKey && KEYS[`Shift+${e.key}`] || KEYS[e.key];
290
- if (!step && e.key !== " ") return;
291
- e.preventDefault();
292
- if (!step) return;
293
- setOrbiting(Boolean(step.yaw || step.tilt));
294
- move(step);
295
- },
296
- onKeyUp: (e) => {
297
- if (e.key === " ") space.current = false;
298
- setOrbiting(false);
299
- }
589
+ onDoubleClick: keys.on("doubleClickReset") ? () => step({ reset: true }) : void 0,
590
+ ...keyHandlers,
591
+ "data-orbiting": orbiting
300
592
  }
301
593
  };
302
594
  }
303
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
+ /**
304
623
  * A ref for the svg that zooms the view with Ctrl or ⌘ and the wheel, which
305
- * 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.
306
625
  */
307
- function useWheelZoom(setView) {
626
+ function useWheelZoom(jump, on) {
308
627
  return React.useCallback((svg) => {
628
+ if (!on) return;
309
629
  const onWheel = (e) => {
310
630
  if (!e.ctrlKey && !e.metaKey) return;
311
631
  e.preventDefault();
312
- setView((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
632
+ jump((v) => moved(v, { zoom: 2 ** (-e.deltaY / 200) }, v));
313
633
  };
314
634
  svg?.addEventListener("wheel", onWheel, { passive: false });
315
635
  return () => svg?.removeEventListener("wheel", onWheel);
316
- }, [setView]);
636
+ }, [jump, on]);
317
637
  }
318
638
  /**
319
- * The tooltip of the mark under the pointer, the one painted last. `hover`
320
- * sets it only when the pointer moves onto another mark, so a move within
321
- * 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.
322
646
  */
323
- function useHover(scene, width, height, setTooltip) {
647
+ function useHover(scene, width, height, setTooltip, focus) {
324
648
  const hovered = React.useRef(void 0);
325
649
  const show = (mark) => {
650
+ if (mark?.groups) focus.set(mark.groups);
326
651
  if (mark?.key === hovered.current?.key) return;
327
652
  hovered.current = mark;
328
653
  setTooltip(mark?.tooltip ?? null);
@@ -336,9 +661,45 @@ function useHover(scene, width, height, setTooltip) {
336
661
  };
337
662
  show(hit(scene.current, at));
338
663
  },
339
- leave: () => show(void 0)
664
+ hide: () => show(void 0),
665
+ leave: () => {
666
+ show(void 0);
667
+ focus.set(void 0);
668
+ }
340
669
  };
341
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
+ }
342
703
  /** The topmost mark under `at`: the last one painted that holds it. */
343
704
  function hit(marks, at) {
344
705
  for (let i = marks.length - 1; i >= 0; i -= 1) {
@@ -392,7 +753,6 @@ function useCanvas(scene, width) {
392
753
  return {
393
754
  canvas: React.useCallback((node) => {
394
755
  element.current = node;
395
- if (!node) return;
396
756
  repaint();
397
757
  const resize = new ResizeObserver(repaint);
398
758
  resize.observe(node);
@@ -415,7 +775,6 @@ function paint(canvas, marks, width, color) {
415
775
  canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
416
776
  canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
417
777
  const ctx = canvas.getContext("2d");
418
- if (!ctx) return;
419
778
  const scale = canvas.width / width;
420
779
  ctx.setTransform(scale, 0, 0, scale, 0, 0);
421
780
  ctx.lineJoin = "round";
@@ -439,22 +798,92 @@ function ChartCanvas3D({ renderer, canvas, width, height }) {
439
798
  if (renderer !== "canvas") return null;
440
799
  return /* @__PURE__ */ jsx("canvas", {
441
800
  ref: canvas,
442
- className: "pointer-events-none absolute top-0 left-0 w-full",
801
+ className: "pointer-events-none absolute top-0 left-0 w-full mask-y-from-96%",
443
802
  style: { aspectRatio: `${width} / ${height}` }
444
803
  });
445
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
+ }
446
870
  /**
447
871
  * The marks of a chart, drawn by the renderer of the container: as SVG
448
872
  * elements, or on the canvas over the chart box. The hover reads them too.
449
873
  */
450
874
  function Marks3D({ marks }) {
451
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);
452
881
  React.useLayoutEffect(() => {
453
- scene.current = marks;
882
+ scene.current = drawn;
454
883
  repaint();
455
884
  });
456
885
  if (renderer === "canvas") return null;
457
- 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", {
458
887
  d: `M${points.map((p) => `${+p.x.toFixed(2)},${+p.y.toFixed(2)}`).join("L")}Z`,
459
888
  fill,
460
889
  stroke: fill,
@@ -469,6 +898,30 @@ function Marks3D({ marks }) {
469
898
  strokeWidth: stroke ? 1 : void 0
470
899
  }, key));
471
900
  }
901
+ /**
902
+ * The labels of the marks of a chart, ringed by the background so that they
903
+ * read on the marks. Like the labels of the box, they are SVG with every renderer.
904
+ */
905
+ function Labels3D({ labels }) {
906
+ const fades = useFades(labels);
907
+ return /* @__PURE__ */ jsx("g", {
908
+ "data-slot": "chart-3d-labels",
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
+ })
923
+ });
924
+ }
472
925
  function ChartStyle({ id, config }) {
473
926
  const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
474
927
  if (!colorConfig.length) return null;
@@ -512,44 +965,64 @@ function ChartTooltip3D({ tooltip, width, height }) {
512
965
  });
513
966
  }
514
967
  /**
515
- * A button in the corner of the chart that lists `SHORTCUTS`. `?` on the
516
- * 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.
517
971
  */
518
- function ChartShortcuts3D() {
972
+ function ChartShortcuts3D({ rows, help, label }) {
519
973
  const [open, setOpen] = React.useState(false);
520
974
  const ref = React.useRef(null);
975
+ const id = React.useId();
976
+ const keys = help.join("\n");
521
977
  React.useEffect(() => {
522
978
  const chart = ref.current?.parentElement;
523
979
  const onKeyDown = (e) => {
524
- if (e.key === "?") setOpen((o) => !o);
980
+ if (keys.split("\n").includes(pressed(e))) setOpen((o) => !o);
525
981
  if (e.key === "Escape") setOpen(false);
526
982
  };
527
983
  chart?.addEventListener("keydown", onKeyDown);
528
984
  return () => chart?.removeEventListener("keydown", onKeyDown);
529
- }, []);
985
+ }, [keys]);
986
+ if (!rows.length) return null;
530
987
  return /* @__PURE__ */ jsxs("div", {
531
988
  ref,
532
989
  className: "absolute top-2 right-2 z-10 flex flex-col items-end gap-1",
533
990
  children: [/* @__PURE__ */ jsx("button", {
534
991
  type: "button",
535
- "aria-label": "Keyboard shortcuts",
992
+ "data-slot": "chart-3d-shortcuts-trigger",
993
+ "aria-label": label,
536
994
  "aria-expanded": open,
995
+ "aria-controls": open ? id : void 0,
537
996
  onClick: () => setOpen((o) => !o),
538
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",
539
998
  children: /* @__PURE__ */ jsx(KeyboardIcon, {})
540
- }), open && /* @__PURE__ */ jsx("div", {
541
- 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",
542
- 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", {
1014
+ "data-slot": "kbd-group",
543
1015
  className: "flex gap-1",
544
- children: hint.map((key) => /* @__PURE__ */ jsx("kbd", {
1016
+ children: group.map((key, j) => /* @__PURE__ */ jsx("kbd", {
1017
+ "data-slot": "kbd",
545
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",
546
1019
  children: key
547
- }, key))
548
- }), /* @__PURE__ */ jsx("span", {
549
- className: "text-muted-foreground",
550
- children: label
551
- })] }, i))
552
- })]
1020
+ }, j))
1021
+ }, i))
1022
+ }), /* @__PURE__ */ jsx("dd", {
1023
+ className: "text-muted-foreground",
1024
+ children: label
1025
+ })] }, label))
553
1026
  });
554
1027
  }
555
1028
  function ChartLegend3D({ config }) {
@@ -566,51 +1039,274 @@ function ChartLegend3D({ config }) {
566
1039
  }
567
1040
  /**
568
1041
  * The walls of the chart box that face away from the camera, with their
569
- * 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.
570
1046
  */
571
1047
  function Axes3D({ spec, world, format = {}, titles = {} }) {
572
1048
  const { camera } = useChart3D();
573
- 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);
574
1051
  return /* @__PURE__ */ jsxs("g", {
575
1052
  "data-slot": "chart-3d-axes",
576
- children: [box.data(spec).filter((wall) => !wall.ccw).map((wall) => /* @__PURE__ */ jsx("path", {
577
- d: box.draw(wall),
578
- className: "fill-muted/40 stroke-border",
579
- strokeWidth: .75
580
- }, `${wall.axis}${wall.side}`)), box.axes(spec).map((edge) => /* @__PURE__ */ jsx(AxisEdge3D, {
581
- edge,
582
- format: format[edge.axis] ?? String,
583
- title: titles[edge.axis]
584
- }, edge.axis))]
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
+ ]
585
1071
  });
586
1072
  }
1073
+ const GAP = 10;
1074
+ const PAD = 2;
1075
+ const FADE = 4;
1076
+ const DODGE = 12;
1077
+ const MARK = 6;
1078
+ /**
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.
1084
+ */
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
+ }
587
1100
  /**
588
- * The tick labels of one axis on the outline of the box, pushed away from the
589
- * box along the normal of the edge, and the title of the axis further out.
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.
590
1104
  */
591
- function AxisEdge3D({ edge, format, title }) {
592
- if (!edge.length) return null;
593
- const { normal: n } = edge;
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);
594
1128
  const start = edge[0].projected;
595
1129
  const end = edge[edge.length - 1].projected;
596
- const anchor = n.x > .3 ? "start" : n.x < -.3 ? "end" : "middle";
597
- return /* @__PURE__ */ jsxs("g", {
598
- className: "transition-opacity delay-300 duration-300 in-data-[orbiting=true]:opacity-0 in-data-[orbiting=true]:delay-0 in-data-[orbiting=true]:duration-150",
599
- children: [edge.map((tick, i) => /* @__PURE__ */ jsx("text", {
600
- x: tick.projected.x + n.x * 10,
601
- y: tick.projected.y + n.y * 10,
602
- textAnchor: anchor,
603
- dominantBaseline: "central",
604
- className: "fill-muted-foreground text-[10px]",
605
- children: format(tick[edge.axis])
606
- }, i)), title && /* @__PURE__ */ jsx("text", {
607
- x: (start.x + end.x) / 2 + n.x * 44,
608
- y: (start.y + end.y) / 2 + n.y * 44,
609
- textAnchor: anchor,
610
- dominantBaseline: "central",
611
- className: "fill-foreground font-medium",
612
- children: title
613
- })]
1130
+ const middle = {
1131
+ x: (start.x + end.x) / 2,
1132
+ y: (start.y + end.y) / 2
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;
1140
+ return {
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
+ }))
1159
+ };
1160
+ }
1161
+ /**
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.
1168
+ */
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;
1269
+ return {
1270
+ x: at.x + toward.x * out,
1271
+ y: at.y + toward.y * out
1272
+ };
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
+ }
1278
+ /**
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.
1282
+ */
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
614
1310
  });
615
1311
  }
616
1312
  /** A nice axis from `lo` to `hi` with about `count` ticks. */
@@ -621,6 +1317,17 @@ function axis(lo, hi, count = 5) {
621
1317
  ticks: ticks(start, stop, count)
622
1318
  };
623
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
+ }
624
1331
  /** The linear map from `domain` to `range`. */
625
1332
  function linear([d0, d1], [r0, r1]) {
626
1333
  return (value) => r0 + (value - d0) / (d1 - d0 || 1) * (r1 - r0);
@@ -633,6 +1340,13 @@ function worldBox(spec, depth = 1) {
633
1340
  z: linear(spec.z.domain, [-depth, depth])
634
1341
  };
635
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
+ }
636
1350
  const LIGHT = {
637
1351
  x: -.4,
638
1352
  y: -.8,
@@ -649,6 +1363,10 @@ function shade(face, color) {
649
1363
  const light = Math.max(0, side * (n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z));
650
1364
  return `color-mix(in oklch, ${color} ${Math.round(65 + 35 * light)}%, black)`;
651
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
+ }
652
1370
  /** The mean of the projected points, where a tooltip points at. */
653
1371
  function center(points) {
654
1372
  const sum = points.reduce((s, p) => ({
@@ -663,6 +1381,15 @@ function center(points) {
663
1381
  y: sum.y / points.length
664
1382
  };
665
1383
  }
1384
+ /** The marks of a cube: its faces that look at the camera, shaded in `color`, each with `tooltip` on hover. */
1385
+ function cubeMarks(id, faces, color, tooltip) {
1386
+ return faces.filter((face) => face.ccw).map((face) => ({
1387
+ key: `${id}-${face.face}`,
1388
+ points: face.map((p) => p.projected),
1389
+ fill: shade(face, color),
1390
+ tooltip
1391
+ }));
1392
+ }
666
1393
  // registry/new-york/ui/bar-chart-3d.tsx
667
1394
  /**
668
1395
  * Bars on a floor: one row per entry of `data` along x, one per series
@@ -672,22 +1399,26 @@ function center(points) {
672
1399
  function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
673
1400
  const { config, camera } = useChart3D();
674
1401
  const series = keys ?? Object.keys(config);
675
- const values = data.flatMap((d) => series.map((key) => Number(d[key]) || 0));
676
- const { spec, world, size } = barBox(data.length, series.length, values, barSize);
677
- const bars = cubes3D().camera(camera).data(data.flatMap((d, i) => series.map((key, j) => {
678
- const value = Number(d[key]) || 0;
679
- return Object.assign(barCorners(world, i, j, value, size), {
680
- i,
681
- key,
682
- value
683
- });
1402
+ const target = barValues(data, series);
1403
+ const cells = barCells(data, series, useTween(target, 0));
1404
+ const box = barBox(data.length, series.length, Object.values(target), barSize);
1405
+ const spec = {
1406
+ ...box.spec,
1407
+ y: useTweenAxes(box.spec).y
1408
+ };
1409
+ const world = worldBox(spec, box.depth);
1410
+ const { size } = box;
1411
+ const bars = cubes3D().camera(camera).data(cells.map(({ i, j, key, value }) => Object.assign(barCorners(world, i, j, value, size), {
1412
+ i,
1413
+ key,
1414
+ value
684
1415
  }))).map((bar) => Object.assign(bar, { bottom: bar.faces.find((f) => f.face === "bottom") })).sort((a, b) => sort(a.bottom, b.bottom));
685
1416
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
686
1417
  spec,
687
1418
  world,
688
1419
  titles,
689
1420
  format: {
690
- x: (i) => (format.x ?? String)(String(data[i]?.[index] ?? "")),
1421
+ x: (i) => (format.x ?? String)(String(data[i][index] ?? "")),
691
1422
  y: format.y,
692
1423
  z: (j) => config[series[j]]?.label ?? series[j]
693
1424
  }
@@ -700,32 +1431,48 @@ function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
700
1431
  value: bar.value
701
1432
  })) })] });
702
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
+ }
703
1450
  /**
704
1451
  * The box of a bar chart: a row per entry along x, a row per series along z,
705
- * 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.
706
1454
  */
707
1455
  function barBox(entries, series, values, barSize) {
708
1456
  const index = (length) => Array.from({ length }, (_, i) => i);
709
1457
  const spec = {
710
- x: {
1458
+ x: pad({
711
1459
  domain: [-.5, entries - .5],
712
1460
  ticks: index(entries)
713
- },
1461
+ }, 1),
714
1462
  y: axis(Math.min(0, ...values), Math.max(0, ...values)),
715
- z: {
1463
+ z: pad({
716
1464
  domain: [-.5, series - .5],
717
1465
  ticks: index(series)
718
- }
1466
+ }, 1)
719
1467
  };
720
1468
  const depth = Math.min(1, Math.max(.25, series / entries));
721
- const size = {
722
- width: 2 / entries * barSize,
723
- depth: 2 * depth / series * barSize
724
- };
725
1469
  return {
726
1470
  spec,
727
- world: worldBox(spec, depth),
728
- 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
+ }
729
1476
  };
730
1477
  }
731
1478
  /**
@@ -745,7 +1492,7 @@ function barCorners(world, x, z, value, size) {
745
1492
  }
746
1493
  /** The marks of one bar: the faces that look at the camera, shaded, each with its entry and value on hover. */
747
1494
  function barMarks({ id, faces, color, title, label, value }) {
748
- const tooltip = {
1495
+ return cubeMarks(id, faces, color, {
749
1496
  at: center(faces.find((face) => face.face === "top")),
750
1497
  title,
751
1498
  items: [{
@@ -753,13 +1500,7 @@ function barMarks({ id, faces, color, title, label, value }) {
753
1500
  value,
754
1501
  color
755
1502
  }]
756
- };
757
- return faces.filter((face) => face.ccw).map((face) => ({
758
- key: `${id}-${face.face}`,
759
- points: face.map((p) => p.projected),
760
- fill: shade(face, color),
761
- tooltip
762
- }));
1503
+ });
763
1504
  }
764
1505
  // registry/new-york/ui/scatter-chart-3d.tsx
765
1506
  /**
@@ -773,18 +1514,11 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
773
1514
  y,
774
1515
  z
775
1516
  };
776
- const read = (d, axis) => Number(d[fields[axis]]);
777
- const range = (a) => {
778
- const [lo = 0, hi = 0] = extent(data, (d) => read(d, a));
779
- return axis(lo, hi);
780
- };
781
- const spec = {
782
- x: range("x"),
783
- y: range("y"),
784
- z: range("z")
785
- };
1517
+ const values = useTween(pointValues(data, fields));
1518
+ const read = (i, axis) => values[`${axis}${i}`];
1519
+ const spec = useTweenAxes(pointAxes(data, fields));
786
1520
  const world = worldBox(spec);
787
- 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")));
788
1522
  const fallback = Object.keys(config)[0];
789
1523
  const marks = points.data(data.map((d, i) => ({
790
1524
  d,
@@ -806,7 +1540,7 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
806
1540
  "z"
807
1541
  ].map((a) => ({
808
1542
  label: titles?.[a] ?? fields[a],
809
- value: read(p.d, a)
1543
+ value: read(p.i, a)
810
1544
  }))
811
1545
  }
812
1546
  };
@@ -818,6 +1552,26 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
818
1552
  titles
819
1553
  }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
820
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
+ }
821
1575
  // registry/new-york/ui/surface-chart-3d.tsx
822
1576
  /**
823
1577
  * A surface over a grid: `data[row][column]` is the height, the columns run
@@ -828,21 +1582,28 @@ function ScatterChart3D({ data, x, y, z, series, radius = 4, format, titles }) {
828
1582
  function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
829
1583
  const { config, camera } = useChart3D();
830
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}`]));
831
1587
  const rows = data.length;
832
1588
  const columns = data[0]?.length ?? 0;
833
1589
  const [x0, x1] = domain.x ?? [0, columns - 1];
834
1590
  const [z0, z1] = domain.z ?? [0, rows - 1];
835
- const [lo = 0, hi = 0] = extent(data.flat());
836
- const spec = {
837
- x: axis(x0, x1),
838
- y: axis(lo, hi),
839
- 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)
840
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
+ });
841
1602
  const world = worldBox(spec);
842
1603
  const toX = linear([0, columns - 1], [x0, x1]);
843
1604
  const toZ = linear([0, rows - 1], [z0, z1]);
844
- const grid = gridPlanes3D().camera(camera).rows(columns).x((p) => world.x(p.x)).y((p) => world.y(p.y)).z((p) => world.z(p.z));
845
- 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) => ({
846
1607
  x: toX(c),
847
1608
  y,
848
1609
  z: toZ(r)
@@ -870,6 +1631,460 @@ function SurfaceChart3D({ data, domain = {}, colors, format, titles }) {
870
1631
  titles
871
1632
  }), /* @__PURE__ */ jsx(Marks3D, { marks })] });
872
1633
  }
873
- export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis, center, linear, shade, useChart3D, worldBox };
1634
+ /** The height of every cell, keyed by its row and column, gliding into new data. */
1635
+ function gridValues(data) {
1636
+ return Object.fromEntries(data.flatMap((row, r) => row.map((y, c) => [`${r}-${c}`, y])));
1637
+ }
1638
+ // registry/new-york/ui/sankey-chart-3d.tsx
1639
+ const DEPTH = .4;
1640
+ const RIBBON = .7;
1641
+ const SEGMENTS = 16;
1642
+ const STEEPEST = 1.5;
1643
+ const SWING = 8;
1644
+ /**
1645
+ * Flows from left to right: every node a box in its column, as high as the
1646
+ * larger of what flows in and what flows out, and every flow a band from node
1647
+ * to node, as thick as its value, that turns from the color of its source to
1648
+ * the color of its target.
1649
+ */
1650
+ function SankeyChart3D({ data, nodeWidth = .2, nodePadding = .05, format = (value) => value }) {
1651
+ const { config, camera } = useChart3D();
1652
+ const values = useTween(Object.fromEntries(data.map((d, i) => [String(i), d.value])), 0);
1653
+ const { nodes, columns, ky } = sankeyLayout(data.map((d, i) => ({
1654
+ ...d,
1655
+ value: values[String(i)]
1656
+ })), nodePadding);
1657
+ const pieces = depthSort(cubes3D().camera(camera).data(sankeyPieces(nodes, columns, ky, nodeWidth)), camera);
1658
+ const labels = pieces.flatMap(({ key, faces, node }) => node ? [nodeLabel(key, faces, node.column < (columns - 1) / 2 ? "left" : "right", style(config, node).label, links(node))] : []);
1659
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Marks3D, { marks: pieces.flatMap((piece) => pieceMarks(piece, config, format)) }), /* @__PURE__ */ jsx(Labels3D, { labels })] });
1660
+ }
1661
+ /** The label and the color of a node, from the chart config. */
1662
+ function style(config, node) {
1663
+ return {
1664
+ label: config[node.id]?.label ?? node.id,
1665
+ color: config[node.id] ? `var(--color-${node.id})` : "var(--muted-foreground)"
1666
+ };
1667
+ }
1668
+ /**
1669
+ * The keys of the flows at a node. Its marks and its label carry them, so the
1670
+ * focus on the node lights its flows and the nodes at their other ends, and
1671
+ * the focus on a flow lights its two nodes.
1672
+ */
1673
+ function links(node) {
1674
+ return [...new Set([...node.incoming, ...node.outgoing].map((hop) => hop.flow.key))];
1675
+ }
1676
+ /**
1677
+ * The marks of a piece, each with its tooltip and its groups: the faces of a
1678
+ * node, or the faces of a slice of a flow, in the color it has turned to there.
1679
+ */
1680
+ function pieceMarks({ key, faces, node, flow, t }, config, format) {
1681
+ const at = center(faces.find((face) => face.face === "top"));
1682
+ if (node) {
1683
+ const { label, color } = style(config, node);
1684
+ const groups = links(node);
1685
+ return cubeMarks(key, faces, color, {
1686
+ at,
1687
+ items: [{
1688
+ label,
1689
+ value: format(node.value),
1690
+ color
1691
+ }]
1692
+ }).map((mark) => ({
1693
+ ...mark,
1694
+ groups
1695
+ }));
1696
+ }
1697
+ const source = style(config, flow.source);
1698
+ const target = style(config, flow.target);
1699
+ const item = {
1700
+ label: /* @__PURE__ */ jsxs(Fragment, { children: [
1701
+ source.label,
1702
+ " → ",
1703
+ target.label
1704
+ ] }),
1705
+ value: format(flow.value),
1706
+ color: source.color
1707
+ };
1708
+ return cubeMarks(key, faces.filter((face) => face.face !== "left" && face.face !== "right"), `color-mix(in oklch, color-mix(in oklch, ${target.color} ${t}%, ${source.color}) 75%, var(--background))`, {
1709
+ at,
1710
+ items: [item]
1711
+ }).map((mark) => ({
1712
+ ...mark,
1713
+ groups: [flow.key]
1714
+ }));
1715
+ }
1716
+ /** The nodes of `data`, waypoints included, stacked in their columns, and `ky`, the world units per value. */
1717
+ function sankeyLayout(data, nodePadding) {
1718
+ const { nodes, flows, columns } = sankeyColumns(data);
1719
+ const all = sankeyHops(nodes, flows);
1720
+ const ky = stackColumns(all, columns, nodePadding);
1721
+ stackHops(all, ky);
1722
+ weaveHops(all, columns, ky);
1723
+ return {
1724
+ nodes: all,
1725
+ columns,
1726
+ ky
1727
+ };
1728
+ }
1729
+ function newNode(id, column, flow) {
1730
+ return {
1731
+ id,
1732
+ column,
1733
+ value: 0,
1734
+ y0: 0,
1735
+ y1: 0,
1736
+ incoming: [],
1737
+ outgoing: [],
1738
+ flow
1739
+ };
1740
+ }
1741
+ function middle(node) {
1742
+ return (node.y0 + node.y1) / 2;
1743
+ }
1744
+ /**
1745
+ * The nodes of `data` in the order they first appear, and the flows between
1746
+ * them, without the ones of no value. The column of a node is the longest
1747
+ * path to it from a source; the sinks move to the last column.
1748
+ */
1749
+ function sankeyColumns(data) {
1750
+ const nodes = /* @__PURE__ */ new Map();
1751
+ const node = (id) => {
1752
+ if (!nodes.has(id)) nodes.set(id, newNode(id, 0));
1753
+ return nodes.get(id);
1754
+ };
1755
+ const seen = /* @__PURE__ */ new Map();
1756
+ const flows = data.filter((d) => d.value > 0).map((d) => {
1757
+ const ends = JSON.stringify([d.source, d.target]);
1758
+ const count = seen.get(ends) ?? 0;
1759
+ seen.set(ends, count + 1);
1760
+ return {
1761
+ key: count ? `${ends}#${count}` : ends,
1762
+ source: node(d.source),
1763
+ target: node(d.target),
1764
+ value: d.value
1765
+ };
1766
+ });
1767
+ for (let pass = 0; flows.some((f) => f.target.column <= f.source.column); pass += 1) {
1768
+ if (pass === nodes.size) throw new Error("SankeyChart3D: the flows form a cycle");
1769
+ for (const f of flows) f.target.column = Math.max(f.target.column, f.source.column + 1);
1770
+ }
1771
+ const last = Math.max(0, ...flows.map((f) => f.target.column));
1772
+ const sources = new Set(flows.map((f) => f.source));
1773
+ for (const n of nodes.values()) if (!sources.has(n)) n.column = last;
1774
+ return {
1775
+ nodes: [...nodes.values()],
1776
+ flows,
1777
+ columns: last + 1
1778
+ };
1779
+ }
1780
+ /**
1781
+ * Splits every flow into hops from one column to the next. A flow that skips a
1782
+ * column passes it at a waypoint, a node of its own, so it has a slot in every
1783
+ * column it crosses and never runs through another node.
1784
+ */
1785
+ function sankeyHops(nodes, flows) {
1786
+ const all = [...nodes];
1787
+ for (const flow of flows) {
1788
+ let source = flow.source;
1789
+ for (let column = source.column + 1; column <= flow.target.column; column += 1) {
1790
+ const target = column < flow.target.column ? newNode(`${flow.key}:${column}`, column, flow) : flow.target;
1791
+ if (target.flow) all.push(target);
1792
+ const hop = {
1793
+ flow,
1794
+ source,
1795
+ target,
1796
+ y0: 0,
1797
+ y1: 0,
1798
+ z: 0
1799
+ };
1800
+ source.outgoing.push(hop);
1801
+ target.incoming.push(hop);
1802
+ source = target;
1803
+ }
1804
+ }
1805
+ return all;
1806
+ }
1807
+ /**
1808
+ * Sizes the nodes to the largest scale at which every column fits, and stacks
1809
+ * each column from the top, centered. A column after the first orders its nodes
1810
+ * by the mean height of their sources, which uncrosses most flows. Returns the
1811
+ * scale, world units per value.
1812
+ */
1813
+ function stackColumns(nodes, columns, nodePadding) {
1814
+ const total = (hops) => hops.reduce((sum, hop) => sum + hop.flow.value, 0);
1815
+ for (const node of nodes) node.value = Math.max(total(node.incoming), total(node.outgoing));
1816
+ const stacks = Array.from({ length: columns }, (_, c) => nodes.filter((n) => n.column === c));
1817
+ const sum = (stack) => stack.reduce((s, n) => s + n.value, 0);
1818
+ const pad = Math.min(2 * nodePadding, 2 / Math.max(1, ...stacks.map((s) => s.length)));
1819
+ const ky = Math.min(...stacks.map((s) => (2 - pad * (s.length - 1)) / sum(s)));
1820
+ const mean = (node) => node.incoming.reduce((s, hop) => s + hop.flow.value * middle(hop.source), 0) / total(node.incoming);
1821
+ for (const [c, stack] of stacks.entries()) {
1822
+ if (c > 0) stack.sort((a, b) => mean(a) - mean(b));
1823
+ let y = -(sum(stack) * ky + pad * (stack.length - 1)) / 2;
1824
+ for (const node of stack) {
1825
+ node.y0 = y;
1826
+ node.y1 = y + node.value * ky;
1827
+ y = node.y1 + pad;
1828
+ }
1829
+ }
1830
+ return ky;
1831
+ }
1832
+ /**
1833
+ * Stacks the hops at every node from its top: the ones that leave it in the
1834
+ * order of their targets, the ones that arrive in the order of their sources,
1835
+ * so that the hops of a node do not cross.
1836
+ */
1837
+ function stackHops(nodes, ky) {
1838
+ for (const node of nodes) {
1839
+ let y = node.y0;
1840
+ for (const hop of node.outgoing.sort((a, b) => middle(a.target) - middle(b.target))) {
1841
+ hop.y0 = y + hop.flow.value * ky / 2;
1842
+ y += hop.flow.value * ky;
1843
+ }
1844
+ y = node.y0;
1845
+ for (const hop of node.incoming.sort((a, b) => middle(a.source) - middle(b.source))) {
1846
+ hop.y1 = y + hop.flow.value * ky / 2;
1847
+ y += hop.flow.value * ky;
1848
+ }
1849
+ }
1850
+ }
1851
+ /**
1852
+ * Parts the hops that cross, gap by gap. The hops of a gap that cross, directly
1853
+ * or through one another, form a knot. The knots of a gap lie one above the
1854
+ * other across the whole gap; only the hops within a knot share their depth in lanes.
1855
+ */
1856
+ function weaveHops(nodes, columns, ky) {
1857
+ for (let column = 0; column < columns - 1; column += 1) {
1858
+ const hops = nodes.filter((node) => node.column === column).flatMap((node) => node.outgoing).sort((a, b) => a.y0 - b.y0);
1859
+ const rank = new Map([...hops].sort((a, b) => a.y1 - b.y1).map((hop, i) => [hop, i]));
1860
+ let start = 0;
1861
+ let reach = 0;
1862
+ hops.forEach((hop, i) => {
1863
+ reach = Math.max(reach, rank.get(hop));
1864
+ if (reach > i) return;
1865
+ if (i > start) knotHops(hops.slice(start, i + 1), ky);
1866
+ start = i + 1;
1867
+ });
1868
+ }
1869
+ }
1870
+ /**
1871
+ * The lanes of a knot. Every hop takes the lowest lane that no hop crossing it
1872
+ * holds, which needs the fewest, and the lightest lane goes to the front. The
1873
+ * lanes share the depth of a flow, a third of a lane apart, so nothing reaches
1874
+ * out of it. The hops hold their lanes over the slices where any two of them
1875
+ * overlap, so their depth is constant there, and leave the rest for the way in
1876
+ * and out.
1877
+ */
1878
+ function knotHops(hops, ky) {
1879
+ const lanes = [];
1880
+ for (const [i, hop] of hops.entries()) {
1881
+ const taken = hops.slice(0, i).flatMap((above, j) => above.y1 > hop.y1 ? [lanes[j]] : []);
1882
+ let lane = 0;
1883
+ while (taken.includes(lane)) lane += 1;
1884
+ lanes.push(lane);
1885
+ }
1886
+ const count = Math.max(...lanes) + 1;
1887
+ const load = (lane) => hops.reduce((sum, hop, i) => sum + (lanes[i] === lane ? hop.flow.value : 0), 0);
1888
+ const front = Array.from({ length: count }, (_, lane) => lane).sort((a, b) => load(a) - load(b));
1889
+ const slices = hops.flatMap((a, i) => hops.slice(i + 1).flatMap((b) => a.y1 > b.y1 ? overlaps(a, b, ky) : []));
1890
+ const ribbon = RIBBON * DEPTH;
1891
+ const depth = 3 * ribbon / (4 * count - 1);
1892
+ const knot = {
1893
+ from: Math.min(...slices),
1894
+ to: Math.max(...slices) + 1,
1895
+ first: hops[0],
1896
+ depth
1897
+ };
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
+ /**
1930
+ * The pieces of the chart: a box per node, a flat slice of its flow per
1931
+ * waypoint and `SEGMENTS` slices per hop. The columns stand from x = -1 to 1,
1932
+ * a node the share `nodeWidth` of the distance between two of them wide.
1933
+ */
1934
+ function sankeyPieces(nodes, columns, ky, nodeWidth) {
1935
+ const step = 2 / Math.max(1, columns - 1);
1936
+ const x = (column) => -1 + column * step;
1937
+ const half = nodeWidth * step / 2;
1938
+ const share = (flow, at) => {
1939
+ const from = x(flow.source.column) + half;
1940
+ return Math.round((at - from) / (x(flow.target.column) - half - from) * 100);
1941
+ };
1942
+ return nodes.flatMap((node) => {
1943
+ const band = x(node.column);
1944
+ const y = middle(node);
1945
+ const own = node.flow ? {
1946
+ key: `way:${node.id}`,
1947
+ band,
1948
+ flow: node.flow,
1949
+ t: share(node.flow, band)
1950
+ } : {
1951
+ key: `node:${node.id}`,
1952
+ band,
1953
+ node,
1954
+ t: 0
1955
+ };
1956
+ const d = node.flow ? RIBBON * DEPTH : DEPTH;
1957
+ const box = slab({
1958
+ x: band - half,
1959
+ y,
1960
+ z: 0,
1961
+ d
1962
+ }, {
1963
+ x: band + half,
1964
+ y,
1965
+ z: 0,
1966
+ d
1967
+ }, node.y1 - node.y0);
1968
+ return [Object.assign(box, own, {
1969
+ row: y,
1970
+ lane: 0,
1971
+ level: y
1972
+ }), ...node.outgoing.flatMap((hop) => hopPieces(hop, band + half, x(node.column + 1) - half, ky, share))];
1973
+ });
1974
+ }
1975
+ /**
1976
+ * The slices of a hop from `x0` to `x1`, along an S-curve that leaves and
1977
+ * arrives level, so that the hops of a flow join without a kink. A hop of a
1978
+ * knot narrows into its lane and widens back along the same curve. Every hop
1979
+ * between the same two columns has the same slices.
1980
+ */
1981
+ function hopPieces(hop, x0, x1, ky, share) {
1982
+ const ribbon = RIBBON * DEPTH;
1983
+ const gentle = (slices, depth) => (ribbon - depth) * 1.5 / (slices * (x1 - x0) / SEGMENTS) <= STEEPEST;
1984
+ const knot = hop.knot && {
1985
+ ...hop.knot,
1986
+ from: gentle(hop.knot.from, hop.knot.depth) ? hop.knot.from : 0,
1987
+ to: gentle(SEGMENTS - hop.knot.to, hop.knot.depth) ? hop.knot.to : SEGMENTS
1988
+ };
1989
+ const at = (of, k) => {
1990
+ const into = bump(k, knot);
1991
+ return {
1992
+ x: x0 + (x1 - x0) * k / SEGMENTS,
1993
+ y: of.y0 + (of.y1 - of.y0) * ease(k / SEGMENTS),
1994
+ z: of.z * into,
1995
+ d: ribbon + ((knot?.depth ?? ribbon) - ribbon) * into
1996
+ };
1997
+ };
1998
+ return Array.from({ length: SEGMENTS }, (_, k) => {
1999
+ const [from, to] = [at(hop, k), at(hop, k + 1)];
2000
+ const first = knot ? [at(knot.first, k), at(knot.first, k + 1)] : [from, to];
2001
+ const band = (from.x + to.x) / 2;
2002
+ return Object.assign(slab(from, to, hop.flow.value * ky), {
2003
+ key: `hop:${hop.flow.key}:${hop.source.column}:${k}`,
2004
+ band,
2005
+ flow: hop.flow,
2006
+ t: share(hop.flow, band),
2007
+ row: (first[0].y + first[1].y) / 2,
2008
+ lane: knot && k >= knot.from && k < knot.to ? hop.z : 0,
2009
+ level: (from.y + to.y) / 2
2010
+ });
2011
+ });
2012
+ }
2013
+ /**
2014
+ * The corners of a slab from the cross-section around `from` to the one around
2015
+ * `to`, `height` high and each `d` deep, in the order of box(). Two rectangles
2016
+ * of one height keep the faces planar and the slab convex.
2017
+ */
2018
+ function slab(from, to, height) {
2019
+ return [1, -1].flatMap((side) => [
2020
+ {
2021
+ x: from.x,
2022
+ y: from.y - height / 2,
2023
+ z: from.z + side * from.d / 2
2024
+ },
2025
+ {
2026
+ x: from.x,
2027
+ y: from.y + height / 2,
2028
+ z: from.z + side * from.d / 2
2029
+ },
2030
+ {
2031
+ x: to.x,
2032
+ y: to.y + height / 2,
2033
+ z: to.z + side * to.d / 2
2034
+ },
2035
+ {
2036
+ x: to.x,
2037
+ y: to.y - height / 2,
2038
+ z: to.z + side * to.d / 2
2039
+ }
2040
+ ]);
2041
+ }
2042
+ /**
2043
+ * The pieces in the order to paint them, by the first plane that parts two of
2044
+ * them: the slabs of the columns and slices lie between planes x = const; the
2045
+ * knots of a slice, one above the other; the lanes of a knot, where its hops
2046
+ * hold them, one behind the other; and the hops of a lane, or of a knot
2047
+ * outside its borders, which do not overlap there, one above the other. Each
2048
+ * key paints the part farther from the eye first, so the order is exact for
2049
+ * every eye, and keys of numbers cannot form a cycle.
2050
+ */
2051
+ function depthSort(pieces, camera) {
2052
+ const view = camera.viewDirection();
2053
+ const perspective = camera.perspective();
2054
+ const far = (at, v) => {
2055
+ const eye = v * perspective;
2056
+ return Number.isFinite(eye) ? Math.abs(at - eye) : -at * Math.sign(v);
2057
+ };
2058
+ return pieces.sort((a, b) => far(b.band, view.x) - far(a.band, view.x) || far(b.row, view.y) - far(a.row, view.y) || far(b.lane, view.z) - far(a.lane, view.z) || far(b.level, view.y) - far(a.level, view.y) || sort(a, b));
2059
+ }
2060
+ /**
2061
+ * The label of a node beside its `outer` face, pushed away from the other side,
2062
+ * so that it stays outside whichever way the camera turns. Seen edge on, where
2063
+ * the two sides meet and away has no direction, the label lifts over the node,
2064
+ * so it swings round to the other side instead of jumping across. It carries
2065
+ * the groups of the node, so it fades with the node.
2066
+ */
2067
+ function nodeLabel(key, faces, outer, text, groups) {
2068
+ const side = (name) => center(faces.find((face) => face.face === name));
2069
+ const at = side(outer);
2070
+ const away = at.x - side(outer === "left" ? "right" : "left").x;
2071
+ const lift = Math.max(0, SWING - Math.abs(away));
2072
+ const length = Math.hypot(away, lift);
2073
+ const toward = {
2074
+ x: away / length,
2075
+ y: -lift / length
2076
+ };
2077
+ return {
2078
+ key,
2079
+ at: {
2080
+ x: at.x + 8 * toward.x,
2081
+ y: at.y + 8 * toward.y
2082
+ },
2083
+ text,
2084
+ toward,
2085
+ groups
2086
+ };
2087
+ }
2088
+ export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, SankeyChart3D, ScatterChart3D, SurfaceChart3D, axis, center, cubeMarks, intoWorld, linear, pad, shade, useChart3D, useTween, useTweenAxes, worldBox };
874
2089
 
875
2090
  //# sourceMappingURL=index.js.map