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