update 3d chart visualization

This commit is contained in:
2026-07-20 16:28:55 -04:00
parent b9864864a6
commit ee84f10cf0
4 changed files with 290 additions and 28 deletions

View File

@@ -51,11 +51,16 @@ export interface ChartProps {
// Controls border/gap between pie/doughnut segments (acts as a per-slice & full pie "stroke")
segmentGap?: number; // value in px (default 2)
// Give the chart depth: bars extrude, pie/donut tilt and gain a rim (line/area ignore
// it). An embellishment — flat reads more precisely — but sometimes wanted.
// Give the chart depth: bars extrude and stand in an extruded grid, pie/donut tilt and
// gain a rim (line/area ignore it). An embellishment — flat reads more precisely — but
// sometimes wanted.
threeD?: boolean;
depth?: number; // 3D extrusion depth in px (default 16).
tilt?: number; // 3D tilt scalar (0 -> full tilt, 1 -> fully flattened)
depth?: number; // 3D depth in px: bar extrusion / grid sweep length, pie/donut rim (default 16).
// 3D perspective scalar, read per kind but always "1 = flattest, 0 = most tilted".
// bar: the depth-axis angle — 1 extrudes head-on (grid reads almost flat), 0 rotates to
// a bird's-eye view (default 0.6). pie/donut: the disc squash — 0 edge-on, 1 flat
// (default 0.85).
tilt?: number;
height?: number; // px of the plot area (default 300). The legend adds its own height.
width?: number; // fix the width instead of measuring the container.
@@ -89,6 +94,7 @@ const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is de
const BAR_RADIUS = 4; // rounded data-end
const MARK_R = 4; // hover marker radius (8px mark)
const DEFAULT_DEPTH = 16; // 3D extrusion depth
const DEFAULT_TILT_CART = 0.6; // 3D bar depth-axis (1 → head-on/flat, 0 → bird's-eye)
// ── number + geometry helpers ───────────────────────────────────────────────────
@@ -306,11 +312,18 @@ function CartesianChart(p: SubProps): JSXElement {
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
const horiz = () => p.props.kind === "bar" && !!p.props.horizontal;
// 3D extrudes bars only; on a line/area it reads as noise, so it is a no-op there.
const threeD = () => !!p.props.threeD && p.props.kind === "bar";
// threeD draws the extruded grid (floor + back wall) for any cartesian kind. Bars also
// extrude into it; a line/area is only shifted onto the back wall so it tracks the wall's
// gridlines — the trace itself is never extruded (depth on a 1px stroke reads as noise).
const threeD = () => !!p.props.threeD;
const depth = () => p.props.depth ?? DEFAULT_DEPTH;
const dx = () => (threeD() ? depth() * 0.7 : 0);
const dy = () => (threeD() ? depth() * 0.55 : 0);
// The depth axis (right, up), length `depth`: tilt turns it from head-on (all run, no
// rise) toward bird's-eye (all rise). Bars extrude along it, the grid is swept along it,
// and a line/area is shifted onto the back wall by it, so all read as one 3D space.
const cartTilt = () => clamp(p.props.tilt ?? DEFAULT_TILT_CART, 0, 1);
const depthAngle = () => (1 - cartTilt()) * Math.PI / 2;
const dx = () => (threeD() ? depth() * Math.cos(depthAngle()) : 0);
const dy = () => (threeD() ? depth() * Math.sin(depthAngle()) : 0);
const labels = () => p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? [];
const n = () => Math.max(labels().length, ...p.props.series.map((s) => s.data.length), 0);
@@ -442,7 +455,10 @@ function CartesianChart(p: SubProps): JSXElement {
return out;
});
// Line/area paths, one per series. Stacked areas ride on the running total below.
// Line/area paths, one per series. Stacked areas ride on the running total below. In 3D
// the trace stays on the FRONT plane — it draws last, on top of the extruded grid, and
// reads against the front value axis and its front gridlines. Only the grid carries the
// depth; the stroke is never extruded.
const paths = createMemo(() => {
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[];
const count = n(), base = baseValue();
@@ -473,16 +489,62 @@ function CartesianChart(p: SubProps): JSXElement {
const showGrid = () => p.props.grid ?? true;
const isBar = () => p.props.kind === "bar";
// The extruded grid a 3D bar chart stands in: a floor swept back from the value-0
// baseline and a back wall carrying the value gridlines, connected by receding lines at
// each category boundary and each gridline's labelled end. Same (dx,-dy) depth axis the
// bars extrude along, so grid and columns read as one 3D space. Drawn before the bars,
// which paint over the parts they occlude.
const grid3D = (): string => {
const l = layout(), d = domain(), ddx = dx(), ddy = dy(), h = horiz(), count = n(), bv = baseValue();
const out: string[] = [];
const quad = (x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number, op: number) =>
out.push(`<path d="M${x1},${y1} L${x2},${y2} L${x3},${y3} L${x4},${y4} Z" fill="var(--color-line)" fill-opacity="${op}"/>`);
const seg = (x1: number, y1: number, x2: number, y2: number, sop: number) =>
out.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="var(--color-line)" stroke-width="1" stroke-opacity="${sop}"/>`);
if (h) {
const yA = l.top, yB = l.top + l.plotH, xA = l.left, xB = l.left + l.plotW;
quad(bv, yA, bv, yB, bv + ddx, yB - ddy, bv + ddx, yA - ddy, 0.09); // floor (baseline plane)
quad(xA + ddx, yA - ddy, xB + ddx, yA - ddy, xB + ddx, yB - ddy, xA + ddx, yB - ddy, 0.05); // back wall
for (const t of d.ticks) {
const vp = valuePos(t);
seg(vp + ddx, yA - ddy, vp + ddx, yB - ddy, 1); // back-wall value gridline
seg(vp, yB, vp + ddx, yB - ddy, 0.5); // connector at the labelled (bottom) edge
}
const band = l.plotH / Math.max(1, count);
for (let i = 0; i <= count; i++) { const yc = l.top + band * i; seg(bv, yc, bv + ddx, yc - ddy, 0.5); }
seg(bv + ddx, yA - ddy, bv + ddx, yB - ddy, 1); // far edge of the floor / foot of the back wall
} else {
const xA = l.left, xB = l.left + l.plotW, yT = l.top, yB = l.top + l.plotH;
quad(xA, bv, xB, bv, xB + ddx, bv - ddy, xA + ddx, bv - ddy, 0.09); // floor (baseline plane)
quad(xA + ddx, yT - ddy, xB + ddx, yT - ddy, xB + ddx, yB - ddy, xA + ddx, yB - ddy, 0.05); // back wall
for (const t of d.ticks) {
const vp = valuePos(t);
seg(xA + ddx, vp - ddy, xB + ddx, vp - ddy, 1); // back-wall value gridline
seg(xA, vp, xA + ddx, vp - ddy, 0.5); // connector at the labelled (left) edge
}
const band = l.plotW / Math.max(1, count);
for (let i = 0; i <= count; i++) { const xc = l.left + band * i; seg(xc, bv, xc + ddx, bv - ddy, 0.5); }
seg(xA + ddx, bv - ddy, xB + ddx, bv - ddy, 1); // far edge of the floor / foot of the back wall
}
return out.join("");
};
// The whole SVG interior, as a string (see the file header for why innerHTML and not
// JSX marks). Recomputed when the data, the size, or the hovered index changes.
const body = createMemo(() => {
const l = layout(), d = domain(), hv = hover(), h = horiz();
const out: string[] = [];
// The extruded grid (floor + back wall) is drawn under everything when 3D. Front
// gridlines are kept for a 3D line/area (the trace lives on the front plane and reads
// against them) but dropped for a 3D bar (bars occlude the front and meet the wall).
const flatGrid = showGrid() && (!threeD() || p.props.kind !== "bar");
if (showGrid() && threeD()) out.push(grid3D());
// gridlines + value ticks (perpendicular to the value axis)
for (const t of d.ticks) {
const vp = valuePos(t);
if (showGrid()) {
if (flatGrid) {
out.push(h
? `<line x1="${vp}" x2="${vp}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line)" stroke-width="1"/>`
: `<line x1="${l.left}" x2="${l.left + l.plotW}" y1="${vp}" y2="${vp}" stroke="var(--color-line)" stroke-width="1"/>`);
@@ -508,7 +570,8 @@ function CartesianChart(p: SubProps): JSXElement {
: `<text x="${catCenter(i)}" y="${p.height - 8}" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">${esc(lab)}</text>`));
}
// crosshair (line/area only — a bar reader aims at a bar, not a hairline)
// crosshair (line/area only — a bar reader aims at a bar, not a hairline). On the
// front plane, where the trace is.
if (p.props.tooltip !== false && hv !== null && !isBar()) {
const c = catCenter(hv);
out.push(`<line x1="${c}" x2="${c}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);