@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/NOTICE +33 -0
- package/dist/index.d.ts +92 -6
- package/dist/index.js +1149 -310
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
39
|
+
rotateRight: {
|
|
40
|
+
label: "Rotate",
|
|
41
|
+
keys: ["ArrowRight"],
|
|
42
|
+
move: { yaw: KEY_STEP }
|
|
52
43
|
},
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
"=": { zoom: ZOOM_STEP },
|
|
87
|
-
"-": { zoom: 1 / ZOOM_STEP }
|
|
88
|
-
}
|
|
92
|
+
keys: ["Plus", "="],
|
|
93
|
+
move: { zoom: ZOOM_STEP }
|
|
89
94
|
},
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
zoomOut: {
|
|
96
|
+
label: "Zoom",
|
|
97
|
+
keys: ["-"],
|
|
98
|
+
move: { zoom: 1 / ZOOM_STEP }
|
|
93
99
|
},
|
|
94
|
-
{
|
|
95
|
-
|
|
100
|
+
wheelZoom: {
|
|
101
|
+
label: "Zoom",
|
|
102
|
+
hint: ["Ctrl", "Scroll"]
|
|
103
|
+
},
|
|
104
|
+
reset: {
|
|
96
105
|
label: "Reset",
|
|
97
|
-
keys:
|
|
106
|
+
keys: ["0"],
|
|
107
|
+
move: { reset: true }
|
|
98
108
|
},
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
doubleClickReset: {
|
|
110
|
+
label: "Reset",
|
|
111
|
+
hint: ["Double-click"]
|
|
102
112
|
},
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
|
|
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
|
|
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
|
|
117
|
-
const
|
|
118
|
-
const { view,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
248
|
-
const
|
|
249
|
-
const
|
|
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: () =>
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
624
|
+
* is also how a trackpad pinches, if `on`. The wheel alone still scrolls the page.
|
|
313
625
|
*/
|
|
314
|
-
function useWheelZoom(
|
|
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
|
-
|
|
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
|
-
}, [
|
|
636
|
+
}, [jump, on]);
|
|
324
637
|
}
|
|
325
638
|
/**
|
|
326
|
-
* The tooltip of the mark under the pointer, the one painted last
|
|
327
|
-
* sets
|
|
328
|
-
*
|
|
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
|
-
|
|
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 =
|
|
882
|
+
scene.current = drawn;
|
|
459
883
|
repaint();
|
|
460
884
|
});
|
|
461
885
|
if (renderer === "canvas") return null;
|
|
462
|
-
return
|
|
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((
|
|
485
|
-
x
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
541
|
-
* chart
|
|
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 (
|
|
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
|
-
"
|
|
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(
|
|
566
|
-
|
|
567
|
-
|
|
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:
|
|
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
|
-
},
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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,
|
|
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)
|
|
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: [
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
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
|
|
620
|
-
const
|
|
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
|
-
|
|
637
|
-
ticks
|
|
638
|
-
title:
|
|
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
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
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
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
return
|
|
655
|
-
}
|
|
656
|
-
/**
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
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
|
|
677
|
-
return /* @__PURE__ */
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
862
|
-
const
|
|
863
|
-
|
|
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(({
|
|
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.
|
|
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
|
|
921
|
-
|
|
922
|
-
|
|
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))
|
|
930
|
-
const points =
|
|
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
|
|
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
|
|
984
|
-
*
|
|
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
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
|
1169
|
-
const box = slab(
|
|
1170
|
-
|
|
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.
|
|
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
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
|
1186
|
-
|
|
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
|
-
/**
|
|
1195
|
-
|
|
1196
|
-
|
|
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:
|
|
1199
|
-
y:
|
|
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:
|
|
1204
|
-
y:
|
|
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:
|
|
1209
|
-
y:
|
|
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:
|
|
1214
|
-
y:
|
|
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
|
|
1221
|
-
* slices lie between planes x = const
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
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
|
|
1227
|
-
const
|
|
1228
|
-
const far = (
|
|
1229
|
-
|
|
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 =
|
|
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 *
|
|
1243
|
-
y: at.y
|
|
2080
|
+
x: at.x + 8 * toward.x,
|
|
2081
|
+
y: at.y + 8 * toward.y
|
|
1244
2082
|
},
|
|
1245
2083
|
text,
|
|
1246
|
-
|
|
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
|