update 3d chart visualization
This commit is contained in:
@@ -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"/>`);
|
||||
|
||||
Reference in New Issue
Block a user