@niekes/charts-3d 0.5.0 → 0.6.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/README.md +1 -0
- package/dist/index.d.ts +69 -6
- package/dist/index.js +362 -84
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from "react";
|
|
3
|
-
import { extent, nice, ticks } from "d3-array";
|
|
4
|
-
import { axes3D, box, camera3D, cubes3D, gridPlanes3D, normal, points3D, sort } from "d3-3d";
|
|
3
|
+
import { extent, nice, range, ticks } from "d3-array";
|
|
4
|
+
import { axes3D, box, camera3D, cubes3D, gridPlanes3D, normal, points3D, polygons3D, sort } from "d3-3d";
|
|
5
5
|
import { KeyboardIcon } from "lucide-react";
|
|
6
6
|
import { clsx } from "clsx";
|
|
7
7
|
import { twMerge } from "tailwind-merge";
|
|
@@ -239,10 +239,9 @@ function keymapOf(keymap, panel) {
|
|
|
239
239
|
function ChartContainer3D({ id, config, defaultView = {
|
|
240
240
|
yaw: Math.PI / 6,
|
|
241
241
|
tilt: Math.PI / 6
|
|
242
|
-
}, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", className, children, "aria-label": ariaLabel, ...props }) {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
const { scene, renderer, canvas, repaint, hover, hide, leave, tooltip, focus } = useScene(requested, width, height);
|
|
242
|
+
}, width = 640, height = 400, animationDuration, legend = false, renderer: requested = "svg", shortcuts = true, keymap = {}, gizmo = "bottom-left", tooltip = "top-right", className, children, "aria-label": ariaLabel, ...props }) {
|
|
243
|
+
const chartId = chartIdOf(id, React.useId());
|
|
244
|
+
const { scene, renderer, canvas, repaint, hover, hide, leave, tip, focus } = useScene(requested, width, height, tooltip);
|
|
246
245
|
const keys = keymapOf(keymap, shortcuts);
|
|
247
246
|
const { view, handlers } = useOrbit(defaultView, width, keys, hide, hover);
|
|
248
247
|
const camera = useCamera(view, width, height);
|
|
@@ -296,11 +295,7 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
296
295
|
camera,
|
|
297
296
|
corner: gizmo
|
|
298
297
|
}),
|
|
299
|
-
/* @__PURE__ */ jsx(ChartTooltip3D, {
|
|
300
|
-
tooltip,
|
|
301
|
-
width,
|
|
302
|
-
height
|
|
303
|
-
})
|
|
298
|
+
/* @__PURE__ */ jsx(ChartTooltip3D, { tip })
|
|
304
299
|
]
|
|
305
300
|
}),
|
|
306
301
|
/* @__PURE__ */ jsx(ChartShortcuts3D, { ...keys }),
|
|
@@ -308,6 +303,10 @@ function ChartContainer3D({ id, config, defaultView = {
|
|
|
308
303
|
]
|
|
309
304
|
});
|
|
310
305
|
}
|
|
306
|
+
/** The id the colors of a chart are scoped to: its own `id`, or the one React made up. */
|
|
307
|
+
function chartIdOf(id, uniqueId) {
|
|
308
|
+
return `chart-${id || uniqueId.replace(/:/g, "")}`;
|
|
309
|
+
}
|
|
311
310
|
/**
|
|
312
311
|
* What the svg says about its keymap: a label that names the drag and the
|
|
313
312
|
* help key if they are on, the keys, the focus only if a key does something
|
|
@@ -332,13 +331,13 @@ function keymapProps(keys, label) {
|
|
|
332
331
|
* The marks of the chart and what reads them: the renderer that draws them,
|
|
333
332
|
* the canvas it paints them on and the hover that shows their tooltips.
|
|
334
333
|
*/
|
|
335
|
-
function useScene(requested, width, height) {
|
|
334
|
+
function useScene(requested, width, height, placement) {
|
|
336
335
|
const scene = React.useRef([]);
|
|
337
|
-
const
|
|
336
|
+
const tip = useTip(placement);
|
|
338
337
|
const focus = useFocus();
|
|
339
338
|
const renderer = useRenderer(requested);
|
|
340
339
|
const { canvas, repaint } = useCanvas(scene, width, height, renderer);
|
|
341
|
-
const { hover, hide, leave } = useHover(scene, width, height,
|
|
340
|
+
const { hover, hide, leave } = useHover(scene, width, height, tip, focus);
|
|
342
341
|
return {
|
|
343
342
|
scene,
|
|
344
343
|
renderer,
|
|
@@ -347,10 +346,65 @@ function useScene(requested, width, height) {
|
|
|
347
346
|
hover,
|
|
348
347
|
hide,
|
|
349
348
|
leave,
|
|
350
|
-
|
|
349
|
+
tip,
|
|
351
350
|
focus
|
|
352
351
|
};
|
|
353
352
|
}
|
|
353
|
+
const TIP_GAP = 12;
|
|
354
|
+
const shift = (isBefore) => isBefore ? `calc(-100% - ${TIP_GAP}px)` : `${TIP_GAP}px`;
|
|
355
|
+
/**
|
|
356
|
+
* Whether a tooltip of `size` goes before the pointer at `at`, on an axis of
|
|
357
|
+
* `length`: as `preferred`, unless it would leave the chart there and the
|
|
358
|
+
* other side has more room.
|
|
359
|
+
*/
|
|
360
|
+
function before(at, size, length, preferred) {
|
|
361
|
+
const room = preferred ? at : length - at;
|
|
362
|
+
return room < size + TIP_GAP && length - room > room ? !preferred : preferred;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The tooltip of the hovered mark and where it sits: beside the pointer, in
|
|
366
|
+
* the pixels of the chart, at `placement`. `follow` moves it with the pointer
|
|
367
|
+
* straight in the DOM, so a move within a mark renders nothing; `place` puts
|
|
368
|
+
* it there again once it shows, or its content changes its size.
|
|
369
|
+
*/
|
|
370
|
+
function useTip(placement) {
|
|
371
|
+
const [tooltip, set] = React.useState(null);
|
|
372
|
+
const ref = React.useRef(null);
|
|
373
|
+
const pointer = React.useRef({
|
|
374
|
+
x: 0,
|
|
375
|
+
y: 0,
|
|
376
|
+
width: 0,
|
|
377
|
+
height: 0
|
|
378
|
+
});
|
|
379
|
+
const place = React.useCallback(() => {
|
|
380
|
+
const el = ref.current;
|
|
381
|
+
if (!el) return;
|
|
382
|
+
const shows = !el.dataset.placement;
|
|
383
|
+
if (shows) el.style.transition = "none";
|
|
384
|
+
const { x, y, width, height } = pointer.current;
|
|
385
|
+
const top = before(y, el.offsetHeight, height, placement.startsWith("top"));
|
|
386
|
+
const left = before(x, el.offsetWidth, width, placement.endsWith("left"));
|
|
387
|
+
el.style.left = `${x}px`;
|
|
388
|
+
el.style.top = `${y}px`;
|
|
389
|
+
el.style.translate = `${shift(left)} ${shift(top)}`;
|
|
390
|
+
el.dataset.placement = `${top ? "top" : "bottom"}-${left ? "left" : "right"}`;
|
|
391
|
+
if (shows) {
|
|
392
|
+
el.getBoundingClientRect();
|
|
393
|
+
el.style.transition = "";
|
|
394
|
+
}
|
|
395
|
+
}, [placement]);
|
|
396
|
+
const follow = (at) => {
|
|
397
|
+
pointer.current = at;
|
|
398
|
+
place();
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
tooltip,
|
|
402
|
+
set,
|
|
403
|
+
ref,
|
|
404
|
+
place,
|
|
405
|
+
follow
|
|
406
|
+
};
|
|
407
|
+
}
|
|
354
408
|
/** The camera of a view. The shapes of the chart hang on it, so a new view re-projects them all. */
|
|
355
409
|
function useCamera(view, width, height) {
|
|
356
410
|
return React.useMemo(() => camera3D().origin({
|
|
@@ -740,26 +794,33 @@ function useWheelZoom(jump, on) {
|
|
|
740
794
|
* drops the tooltip and keeps the focus, so a flow does not flash away and
|
|
741
795
|
* can be followed from every side.
|
|
742
796
|
*/
|
|
743
|
-
function useHover(scene, width, height,
|
|
797
|
+
function useHover(scene, width, height, tip, focus) {
|
|
744
798
|
const hovered = React.useRef(void 0);
|
|
745
799
|
const show = (mark) => {
|
|
746
800
|
focus.set(mark?.groups);
|
|
747
801
|
if (mark?.key === hovered.current?.key) return;
|
|
748
802
|
hovered.current = mark;
|
|
749
|
-
|
|
803
|
+
tip.set(mark?.tooltip ?? null);
|
|
750
804
|
};
|
|
751
805
|
return {
|
|
752
806
|
hover: (e) => {
|
|
753
807
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
808
|
+
const x = e.clientX - rect.left;
|
|
809
|
+
const y = e.clientY - rect.top;
|
|
810
|
+
tip.follow({
|
|
811
|
+
x,
|
|
812
|
+
y,
|
|
813
|
+
width: rect.width,
|
|
814
|
+
height: rect.height
|
|
815
|
+
});
|
|
816
|
+
show(hit(scene.current, {
|
|
817
|
+
x: x / rect.width * width,
|
|
818
|
+
y: y / rect.height * height
|
|
819
|
+
}));
|
|
759
820
|
},
|
|
760
821
|
hide: () => {
|
|
761
822
|
hovered.current = void 0;
|
|
762
|
-
|
|
823
|
+
tip.set(null);
|
|
763
824
|
},
|
|
764
825
|
leave: () => show(void 0)
|
|
765
826
|
};
|
|
@@ -919,7 +980,7 @@ function colorBytes(color, pixel, cache) {
|
|
|
919
980
|
}
|
|
920
981
|
/**
|
|
921
982
|
* Paints `marks` on a webgl `canvas`, from the view box of `width`×`height` to
|
|
922
|
-
* the pixels of the canvas: a
|
|
983
|
+
* the pixels of the canvas: a polygon ear by ear, a fan of 12 segments per
|
|
923
984
|
* circle, in paint order and without a depth test, the color of each as bytes.
|
|
924
985
|
*/
|
|
925
986
|
function paintGL(canvas, marks, width, height, color) {
|
|
@@ -961,23 +1022,47 @@ const ringOf = (c, r) => Array.from({ length: 12 }, (_, i) => ({
|
|
|
961
1022
|
x: c.x + r * Math.cos(i / 12 * 2 * Math.PI),
|
|
962
1023
|
y: c.y + r * Math.sin(i / 12 * 2 * Math.PI)
|
|
963
1024
|
}));
|
|
964
|
-
/** The triangles of `mark` as view-box points:
|
|
1025
|
+
/** The triangles of `mark` as view-box points: its polygon ear by ear, a fan of 12 segments per circle. */
|
|
965
1026
|
function trianglesOf(mark) {
|
|
966
1027
|
const { points, r } = mark;
|
|
967
|
-
if (r
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
points[0],
|
|
971
|
-
p,
|
|
972
|
-
ring[(i + 1) % 12]
|
|
973
|
-
]);
|
|
974
|
-
}
|
|
975
|
-
return points.slice(1, -1).flatMap((p, i) => [
|
|
1028
|
+
if (r === void 0) return earsOf(points);
|
|
1029
|
+
const ring = ringOf(points[0], r);
|
|
1030
|
+
return ring.flatMap((p, i) => [
|
|
976
1031
|
points[0],
|
|
977
1032
|
p,
|
|
978
|
-
|
|
1033
|
+
ring[(i + 1) % 12]
|
|
979
1034
|
]);
|
|
980
1035
|
}
|
|
1036
|
+
/** Twice the signed area of the triangle `a`, `b`, `c`: its sign tells which way it turns, 0 on a line. */
|
|
1037
|
+
const turn = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
1038
|
+
/**
|
|
1039
|
+
* The triangles of a polygon, cut off ear by ear, so a concave one - the
|
|
1040
|
+
* front of a ribbon - fills as on the canvas, where a fan would spill over
|
|
1041
|
+
* its dents. An ear is a corner that turns the way of the polygon, or not at
|
|
1042
|
+
* all, with no other corner inside it; a simple polygon always has one. One
|
|
1043
|
+
* that crosses itself may have none, and then loses its first corner.
|
|
1044
|
+
*/
|
|
1045
|
+
function earsOf(points) {
|
|
1046
|
+
const left = [...points];
|
|
1047
|
+
const way = Math.sign(left.reduce((sum, p, i) => sum + turn(left[0], p, left[i + 1] ?? p), 0));
|
|
1048
|
+
const triangles = [];
|
|
1049
|
+
while (left.length > 3) {
|
|
1050
|
+
const corner = (i) => [
|
|
1051
|
+
left.at(i - 1),
|
|
1052
|
+
left[i],
|
|
1053
|
+
left[(i + 1) % left.length]
|
|
1054
|
+
];
|
|
1055
|
+
const ear = left.findIndex((_, i) => {
|
|
1056
|
+
const [a, b, c] = corner(i);
|
|
1057
|
+
if (way * turn(a, b, c) < 0) return false;
|
|
1058
|
+
return !left.some((p) => way * turn(a, b, p) > 0 && way * turn(b, c, p) > 0 && way * turn(c, a, p) > 0);
|
|
1059
|
+
});
|
|
1060
|
+
const i = Math.max(0, ear);
|
|
1061
|
+
triangles.push(...corner(i));
|
|
1062
|
+
left.splice(i, 1);
|
|
1063
|
+
}
|
|
1064
|
+
return [...triangles, ...left];
|
|
1065
|
+
}
|
|
981
1066
|
/** The outline of `mark` as view-box points: its polygon, or the ring of its circle. */
|
|
982
1067
|
const outlineOf = (mark) => mark.r === void 0 ? mark.points : ringOf(mark.points[0], mark.r);
|
|
983
1068
|
const programs = /* @__PURE__ */ new WeakMap();
|
|
@@ -1170,15 +1255,14 @@ ${colorConfig.map(([key, item]) => {
|
|
|
1170
1255
|
}
|
|
1171
1256
|
`)].join("\n") } });
|
|
1172
1257
|
}
|
|
1173
|
-
function ChartTooltip3D({
|
|
1258
|
+
function ChartTooltip3D({ tip }) {
|
|
1259
|
+
const { tooltip, ref, place } = tip;
|
|
1260
|
+
React.useLayoutEffect(place, [tooltip, place]);
|
|
1174
1261
|
if (!tooltip) return null;
|
|
1175
1262
|
return /* @__PURE__ */ jsxs("div", {
|
|
1263
|
+
ref,
|
|
1176
1264
|
"data-slot": "chart-3d-tooltip",
|
|
1177
|
-
className: "pointer-events-none absolute z-10 grid min-w-32
|
|
1178
|
-
style: {
|
|
1179
|
-
left: `${tooltip.at.x / width * 100}%`,
|
|
1180
|
-
top: `${tooltip.at.y / height * 100}%`
|
|
1181
|
-
},
|
|
1265
|
+
className: "pointer-events-none absolute z-10 grid w-max min-w-32 gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl motion-safe:transition-[translate]",
|
|
1182
1266
|
children: [tooltip.title && /* @__PURE__ */ jsx("div", {
|
|
1183
1267
|
className: "font-medium",
|
|
1184
1268
|
children: tooltip.title
|
|
@@ -1706,18 +1790,36 @@ function faded(color, opacity) {
|
|
|
1706
1790
|
const percent = Math.round(opacity * 100);
|
|
1707
1791
|
return percent >= 100 ? color : `color-mix(in oklab, ${color} ${percent}%, transparent)`;
|
|
1708
1792
|
}
|
|
1709
|
-
/**
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1793
|
+
/**
|
|
1794
|
+
* The entries of `data` and their series, for a chart that lays the entries
|
|
1795
|
+
* out along x and the series along z, as the bar and the area chart do. The
|
|
1796
|
+
* name of every entry comes from its `index` field, the series are `keys` or
|
|
1797
|
+
* the keys of the config, and the `target` of every cell is keyed by the name
|
|
1798
|
+
* of its entry and its series, so it follows its entry when the data
|
|
1799
|
+
* reorders; a value that is not a number stands as 0. Each glides by name:
|
|
1800
|
+
* `values` from 0, `rows` - the place of every entry - to where it stands, and
|
|
1801
|
+
* `presence` from 0 to 1 as an entry comes and back as it goes, the gone ones
|
|
1802
|
+
* still among them. `labels` name the ticks: the entry at a tick of x, through
|
|
1803
|
+
* `format.x` - a tick of a gone entry fades out unnamed - and the series at a
|
|
1804
|
+
* tick of z.
|
|
1805
|
+
*/
|
|
1806
|
+
function useSeries({ data, index, keys, format = {} }) {
|
|
1807
|
+
const { config } = useChart3D();
|
|
1808
|
+
const series = keys ?? Object.keys(config);
|
|
1809
|
+
const names = data.map((d) => String(d[index] ?? ""));
|
|
1810
|
+
const target = Object.fromEntries(data.flatMap((d, i) => series.map((key) => [`${names[i]}-${key}`, Number(d[key]) || 0])));
|
|
1811
|
+
const labels = {
|
|
1812
|
+
x: (i) => (format.x ?? String)(names[i] ?? ""),
|
|
1813
|
+
y: format.y,
|
|
1814
|
+
z: (j) => config[series[j]]?.label ?? series[j]
|
|
1815
|
+
};
|
|
1718
1816
|
return {
|
|
1719
|
-
|
|
1720
|
-
|
|
1817
|
+
series,
|
|
1818
|
+
target,
|
|
1819
|
+
values: useTween(target, 0),
|
|
1820
|
+
rows: useTween(Object.fromEntries(names.map((name, i) => [name, i]))),
|
|
1821
|
+
presence: useTween(Object.fromEntries(names.map((name) => [name, 1])), 0),
|
|
1822
|
+
labels
|
|
1721
1823
|
};
|
|
1722
1824
|
}
|
|
1723
1825
|
/** The marks of a cube: its faces that look at the camera, shaded in `color`, each with `tooltip` on hover. */
|
|
@@ -1729,20 +1831,214 @@ function cubeMarks(id, faces, color, tooltip) {
|
|
|
1729
1831
|
tooltip
|
|
1730
1832
|
}));
|
|
1731
1833
|
}
|
|
1834
|
+
// registry/new-york/ui/area-chart-3d.tsx
|
|
1835
|
+
const RIBBON = 2 / 3;
|
|
1836
|
+
/**
|
|
1837
|
+
* Areas pushed back into ribbons: the entries of `data` along x, one ribbon
|
|
1838
|
+
* per series along z, the area under its line reaching down to the floor of
|
|
1839
|
+
* the box. The series are the keys of the chart config unless `keys` names
|
|
1840
|
+
* them.
|
|
1841
|
+
*/
|
|
1842
|
+
function AreaChart3D({ data, index, keys, format = {}, titles }) {
|
|
1843
|
+
const { config, camera } = useChart3D();
|
|
1844
|
+
const { series, target, values, rows, presence, labels } = useSeries({
|
|
1845
|
+
data,
|
|
1846
|
+
index,
|
|
1847
|
+
keys,
|
|
1848
|
+
format
|
|
1849
|
+
});
|
|
1850
|
+
const spec = useTweenAxes(areaAxes(data.length, series.length, Object.values(target)));
|
|
1851
|
+
const depth = useTween({ depth: Math.min(1, Math.max(.25, series.length / 6)) }).depth;
|
|
1852
|
+
const world = worldBox(spec, depth);
|
|
1853
|
+
const polygons = intoWorld(polygons3D().camera(camera), world);
|
|
1854
|
+
const ends = [spec.x.domain[0] + .5, spec.x.domain[1] - .5];
|
|
1855
|
+
const toward = Math.sign(camera.viewDirection().x);
|
|
1856
|
+
const marks = series.flatMap((key, j) => {
|
|
1857
|
+
const line = linePoints(rows, values, presence, key, ends);
|
|
1858
|
+
if (!line.length) return [];
|
|
1859
|
+
const z = [j - RIBBON / 2, j + RIBBON / 2];
|
|
1860
|
+
return [{
|
|
1861
|
+
key,
|
|
1862
|
+
line,
|
|
1863
|
+
faces: ribbonFaces(polygons, line, spec.y.domain[0], z)
|
|
1864
|
+
}];
|
|
1865
|
+
}).sort((a, b) => sort(a.faces[4], b.faces[4])).flatMap(({ key, line, faces }) => ribbonMarks(key, painted(faces, toward), line, config[key]?.label ?? key, format.x ?? String));
|
|
1866
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Axes3D, {
|
|
1867
|
+
spec,
|
|
1868
|
+
world,
|
|
1869
|
+
titles,
|
|
1870
|
+
format: labels
|
|
1871
|
+
}), /* @__PURE__ */ jsx(Marks3D, { marks })] });
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* The points of the line of the series `key`, in order along x: every entry,
|
|
1875
|
+
* the ones still gliding away among them, at its gliding row and value. An
|
|
1876
|
+
* entry that comes or goes unfolds from the point before it - the one after
|
|
1877
|
+
* it, at the start of the line - and folds back into it, as far as its
|
|
1878
|
+
* `presence`, so the ribbon opens and closes without a step. Its value comes
|
|
1879
|
+
* from nothing as its presence grows - the two glide in step - so the part of
|
|
1880
|
+
* its neighbour it has not yet left is added: the point heads straight from
|
|
1881
|
+
* the neighbour to its own, never down towards the floor. Every
|
|
1882
|
+
* point stays within the gliding `ends` of the x axis, so a line with no
|
|
1883
|
+
* neighbour to unfold from - one that was empty - grows inside the box
|
|
1884
|
+
* instead of standing beyond its walls. A point only one of the tweens still
|
|
1885
|
+
* knows is on its way out and stays unseen.
|
|
1886
|
+
*/
|
|
1887
|
+
function linePoints(rows, values, presence, key, [lo, hi]) {
|
|
1888
|
+
const own = Object.entries(rows).sort(([, a], [, b]) => a - b).flatMap(([name, x]) => {
|
|
1889
|
+
const value = values[`${name}-${key}`];
|
|
1890
|
+
return value === void 0 ? [] : [{
|
|
1891
|
+
name,
|
|
1892
|
+
x,
|
|
1893
|
+
value,
|
|
1894
|
+
seen: presence[name]
|
|
1895
|
+
}];
|
|
1896
|
+
});
|
|
1897
|
+
let anchor = own.find((p) => p.seen === 1);
|
|
1898
|
+
return own.map((p) => {
|
|
1899
|
+
if (p.seen === 1) anchor = p;
|
|
1900
|
+
const { x } = anchor ?? p;
|
|
1901
|
+
return {
|
|
1902
|
+
name: p.name,
|
|
1903
|
+
x: Math.min(hi, Math.max(lo, x + p.seen * (p.x - x))),
|
|
1904
|
+
value: p.value + (1 - p.seen) * (anchor?.value ?? 0)
|
|
1905
|
+
};
|
|
1906
|
+
}).sort((a, b) => a.x - b.x);
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* The faces of the ribbon of `line`, projected: the area under the line,
|
|
1910
|
+
* down to `floor`, pushed back from `z[0]` to `z[1]`. Each is wound
|
|
1911
|
+
* counter-clockwise as seen from outside, so `ccw` tells whether it faces the
|
|
1912
|
+
* camera. The first five lie on its bounding box - the front and the back,
|
|
1913
|
+
* one polygon each however many points the line has, the two ends and the
|
|
1914
|
+
* bottom - the rest are its top, one quad per segment of the line.
|
|
1915
|
+
*/
|
|
1916
|
+
function ribbonFaces(polygons, line, floor, [back, front]) {
|
|
1917
|
+
const on = (p, z) => ({
|
|
1918
|
+
x: p.x,
|
|
1919
|
+
y: p.value,
|
|
1920
|
+
z
|
|
1921
|
+
});
|
|
1922
|
+
const under = (p, z) => ({
|
|
1923
|
+
x: p.x,
|
|
1924
|
+
y: floor,
|
|
1925
|
+
z
|
|
1926
|
+
});
|
|
1927
|
+
const face = (name, points, segment = -1) => Object.assign(points, {
|
|
1928
|
+
face: name,
|
|
1929
|
+
segment
|
|
1930
|
+
});
|
|
1931
|
+
const [a, o] = [line[0], line[line.length - 1]];
|
|
1932
|
+
return polygons.data([
|
|
1933
|
+
face("front", [
|
|
1934
|
+
on(a, front),
|
|
1935
|
+
under(a, front),
|
|
1936
|
+
under(o, front),
|
|
1937
|
+
...line.slice(1).reverse().map((p) => on(p, front))
|
|
1938
|
+
]),
|
|
1939
|
+
face("back", [
|
|
1940
|
+
...line.map((p) => on(p, back)),
|
|
1941
|
+
under(o, back),
|
|
1942
|
+
under(a, back)
|
|
1943
|
+
]),
|
|
1944
|
+
face("left", [
|
|
1945
|
+
on(a, back),
|
|
1946
|
+
under(a, back),
|
|
1947
|
+
under(a, front),
|
|
1948
|
+
on(a, front)
|
|
1949
|
+
]),
|
|
1950
|
+
face("right", [
|
|
1951
|
+
on(o, front),
|
|
1952
|
+
under(o, front),
|
|
1953
|
+
under(o, back),
|
|
1954
|
+
on(o, back)
|
|
1955
|
+
]),
|
|
1956
|
+
face("bottom", [
|
|
1957
|
+
under(a, front),
|
|
1958
|
+
under(a, back),
|
|
1959
|
+
under(o, back),
|
|
1960
|
+
under(o, front)
|
|
1961
|
+
]),
|
|
1962
|
+
...line.slice(1).map((p, i) => face("top", [
|
|
1963
|
+
on(line[i], front),
|
|
1964
|
+
on(p, front),
|
|
1965
|
+
on(p, back),
|
|
1966
|
+
on(line[i], back)
|
|
1967
|
+
], i))
|
|
1968
|
+
]);
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* The faces of a ribbon that face the camera, in paint order. Every segment
|
|
1972
|
+
* of the top is a slab, and the planes x = const separate them: the top is
|
|
1973
|
+
* painted from the end of the ribbon away from the camera, which `toward`,
|
|
1974
|
+
* the sign of x in the view direction, names. The faces of the bounding box
|
|
1975
|
+
* come last: the ribbon lies wholly behind each of them.
|
|
1976
|
+
*/
|
|
1977
|
+
function painted(faces, toward) {
|
|
1978
|
+
const shown = faces.filter((f) => f.ccw);
|
|
1979
|
+
return [...shown.filter((f) => f.face === "top").sort((a, b) => toward * (a.segment - b.segment)), ...shown.filter((f) => f.face !== "top")];
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* The marks of one ribbon, shaded in the color of its series. Each face is a
|
|
1983
|
+
* group of its own, so hovering highlights the face under the pointer alone.
|
|
1984
|
+
* A segment of the top is keyed by the entry it starts at, so it keeps its
|
|
1985
|
+
* mark when entries come or go before it. Every face names the series; a
|
|
1986
|
+
* segment of the top tells the values at its two ends.
|
|
1987
|
+
*/
|
|
1988
|
+
function ribbonMarks(key, faces, line, label, name) {
|
|
1989
|
+
const color = `var(--color-${key})`;
|
|
1990
|
+
const spans = (f) => f.face === "top" ? [line[f.segment], line[f.segment + 1]] : [];
|
|
1991
|
+
return faces.map((f) => {
|
|
1992
|
+
const id = f.face === "top" ? `${key}-top-${line[f.segment].name}` : `${key}-${f.face}`;
|
|
1993
|
+
return {
|
|
1994
|
+
key: id,
|
|
1995
|
+
points: f.map((p) => p.projected),
|
|
1996
|
+
fill: shade(f, color),
|
|
1997
|
+
groups: [id],
|
|
1998
|
+
tooltip: {
|
|
1999
|
+
title: label,
|
|
2000
|
+
items: spans(f).map((p) => ({
|
|
2001
|
+
label: name(p.name),
|
|
2002
|
+
value: p.value,
|
|
2003
|
+
color
|
|
2004
|
+
}))
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* The axes of an area chart: the entries along x, with half a row of room
|
|
2011
|
+
* between the first and the last and the walls, a row per series along z.
|
|
2012
|
+
* A line has many entries, so x ticks about ten of them, not every one.
|
|
2013
|
+
*/
|
|
2014
|
+
function areaAxes(entries, series, values) {
|
|
2015
|
+
const every = entries ? ticks(0, entries - 1, 10).filter(Number.isInteger) : [];
|
|
2016
|
+
return {
|
|
2017
|
+
x: pad({
|
|
2018
|
+
domain: [0, Math.max(0, entries - 1)],
|
|
2019
|
+
ticks: every
|
|
2020
|
+
}, 1),
|
|
2021
|
+
y: axis(Math.min(0, ...values), Math.max(0, ...values)),
|
|
2022
|
+
z: pad({
|
|
2023
|
+
domain: [0, Math.max(0, series - 1)],
|
|
2024
|
+
ticks: range(series)
|
|
2025
|
+
}, 1)
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
1732
2028
|
// registry/new-york/ui/bar-chart-3d.tsx
|
|
1733
2029
|
/**
|
|
1734
2030
|
* Bars on a floor: one row per entry of `data` along x, one per series
|
|
1735
2031
|
* along z, the value as the height. The series are the keys of the chart
|
|
1736
2032
|
* config unless `keys` names them.
|
|
1737
2033
|
*/
|
|
1738
|
-
function BarChart3D({ data, index, keys, barSize = .6, format
|
|
2034
|
+
function BarChart3D({ data, index, keys, barSize = .6, format, titles }) {
|
|
1739
2035
|
const { config, camera } = useChart3D();
|
|
1740
|
-
const series
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
2036
|
+
const { series, target, values, rows, presence, labels } = useSeries({
|
|
2037
|
+
data,
|
|
2038
|
+
index,
|
|
2039
|
+
keys,
|
|
2040
|
+
format
|
|
2041
|
+
});
|
|
1746
2042
|
const cells = barCells(rows, series, values);
|
|
1747
2043
|
const box = barBox(data.length, series.length, Object.values(target));
|
|
1748
2044
|
const spec = useTweenAxes(box.spec);
|
|
@@ -1753,11 +2049,7 @@ function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
|
|
|
1753
2049
|
spec,
|
|
1754
2050
|
world,
|
|
1755
2051
|
titles,
|
|
1756
|
-
format:
|
|
1757
|
-
x: (i) => (format.x ?? String)(String(data[i]?.[index] ?? "")),
|
|
1758
|
-
y: format.y,
|
|
1759
|
-
z: (j) => config[series[j]]?.label ?? series[j]
|
|
1760
|
-
}
|
|
2052
|
+
format: labels
|
|
1761
2053
|
}), /* @__PURE__ */ jsx(Marks3D, { marks: bars.flatMap((bar) => barMarks({
|
|
1762
2054
|
id: `${bar.name}-${bar.key}`,
|
|
1763
2055
|
faces: bar.faces,
|
|
@@ -1765,7 +2057,7 @@ function BarChart3D({ data, index, keys, barSize = .6, format = {}, titles }) {
|
|
|
1765
2057
|
title: bar.name,
|
|
1766
2058
|
label: config[bar.key]?.label ?? bar.key,
|
|
1767
2059
|
value: bar.value,
|
|
1768
|
-
opacity:
|
|
2060
|
+
opacity: presence[bar.name]
|
|
1769
2061
|
})) })] });
|
|
1770
2062
|
}
|
|
1771
2063
|
/** The cubes of the cells, projected by `camera` and painted back to front by their footprint, as the bars stand on one floor. */
|
|
@@ -1794,15 +2086,6 @@ function barCells(rows, series, values) {
|
|
|
1794
2086
|
}));
|
|
1795
2087
|
}
|
|
1796
2088
|
/**
|
|
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
|
-
}
|
|
1805
|
-
/**
|
|
1806
2089
|
* The box of a bar chart: a row per entry along x, a row per series along z,
|
|
1807
2090
|
* with half a cell of room between the outer rows and the walls.
|
|
1808
2091
|
*/
|
|
@@ -1851,7 +2134,6 @@ function barCorners(world, x, z, value, size) {
|
|
|
1851
2134
|
/** 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
2135
|
function barMarks({ id, faces, color, title, label, value, opacity }) {
|
|
1853
2136
|
return cubeMarks(id, faces, color, {
|
|
1854
|
-
at: center(faces.find((face) => face.face === "top")),
|
|
1855
2137
|
title,
|
|
1856
2138
|
items: [{
|
|
1857
2139
|
label,
|
|
@@ -1889,7 +2171,6 @@ function ScatterChart3D({ data, x, y, z, id, series, radius = 4, format, titles
|
|
|
1889
2171
|
groups: [p.name],
|
|
1890
2172
|
stroke: faded("var(--background)", opacity[p.name]),
|
|
1891
2173
|
tooltip: {
|
|
1892
|
-
at: p.projected,
|
|
1893
2174
|
title: series ? config[key(p.name)]?.label ?? key(p.name) : void 0,
|
|
1894
2175
|
items: [
|
|
1895
2176
|
"x",
|
|
@@ -2055,13 +2336,10 @@ function quadMark(quad, fade, ramp, title) {
|
|
|
2055
2336
|
points: quad.map((p) => p.projected),
|
|
2056
2337
|
fill: seen < 1 ? `color-mix(in srgb, ${shaded} ${Math.round(seen * 100)}%, transparent)` : shaded,
|
|
2057
2338
|
groups: [quad.plane],
|
|
2058
|
-
tooltip: {
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
value: height
|
|
2063
|
-
}]
|
|
2064
|
-
}
|
|
2339
|
+
tooltip: { items: [{
|
|
2340
|
+
label: title ?? "value",
|
|
2341
|
+
value: height
|
|
2342
|
+
}] }
|
|
2065
2343
|
};
|
|
2066
2344
|
}
|
|
2067
2345
|
/** The height of every cell, keyed by its row and column, gliding into new data. */
|
|
@@ -2083,6 +2361,6 @@ function gridHeights(tweened, floor) {
|
|
|
2083
2361
|
}
|
|
2084
2362
|
return Array.from({ length: rows }, (_, r) => Array.from({ length: columns }, (_, c) => tweened[`${r}-${c}`] ?? floor));
|
|
2085
2363
|
}
|
|
2086
|
-
export { Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis,
|
|
2364
|
+
export { AreaChart3D, Axes3D, BarChart3D, ChartContainer3D, ChartLegend3D, Labels3D, Marks3D, ScatterChart3D, SurfaceChart3D, axis, cubeMarks, faded, intoWorld, linear, pad, shade, useChart3D, useSeries, useTween, useTweenAxes, worldBox };
|
|
2087
2365
|
|
|
2088
2366
|
//# sourceMappingURL=index.js.map
|