@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/NOTICE +33 -0
- package/README.md +3 -0
- package/dist/index.d.ts +133 -4
- package/dist/index.js +1454 -239
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
|
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
|
|
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
|
|
116
|
-
const
|
|
117
|
-
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);
|
|
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__ */
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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
|
|
241
|
-
const
|
|
242
|
-
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);
|
|
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: () =>
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
624
|
+
* is also how a trackpad pinches, if `on`. The wheel alone still scrolls the page.
|
|
306
625
|
*/
|
|
307
|
-
function useWheelZoom(
|
|
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
|
-
|
|
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
|
-
}, [
|
|
636
|
+
}, [jump, on]);
|
|
317
637
|
}
|
|
318
638
|
/**
|
|
319
|
-
* The tooltip of the mark under the pointer, the one painted last
|
|
320
|
-
* sets
|
|
321
|
-
*
|
|
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
|
-
|
|
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 =
|
|
882
|
+
scene.current = drawn;
|
|
454
883
|
repaint();
|
|
455
884
|
});
|
|
456
885
|
if (renderer === "canvas") return null;
|
|
457
|
-
return
|
|
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
|
|
516
|
-
* 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.
|
|
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 (
|
|
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
|
-
"
|
|
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(
|
|
541
|
-
|
|
542
|
-
|
|
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:
|
|
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
|
-
},
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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,
|
|
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)
|
|
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: [
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
589
|
-
*
|
|
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
|
|
592
|
-
|
|
593
|
-
const
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
676
|
-
const
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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]
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
777
|
-
const
|
|
778
|
-
|
|
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(({
|
|
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.
|
|
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
|
|
836
|
-
|
|
837
|
-
|
|
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)
|
|
845
|
-
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) => ({
|
|
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
|
-
|
|
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
|