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

@@ -1,6 +1,7 @@
package app
import (
"strconv"
"strings"
"time"
@@ -1184,8 +1185,11 @@ var usCities = []ui.USHeatmapPoint{
func chartsSection() func() *VNode {
seed := NewSignal(0)
threeD := NewSignal(false)
depth := NewSignal(16.0)
tilt := NewSignal(0.6)
barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
horizC, stackC := ui.NewChart(), ui.NewChart()
threeDBar, threeDLine := ui.NewChart(), ui.NewChart()
heat := ui.NewUSHeatmap()
return func() *VNode {
@@ -1224,16 +1228,18 @@ func chartsSection() func() *VNode {
row("grid gap-6 lg:grid-cols-12",
Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Requests & errors this week", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})),
Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Title: "Traffic by source", Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})),
Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Title: "Requests, smoothed", Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220})),
Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})),
Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Title: "Requests, smoothed", Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220, ThreeD: threeD.Get()})),
Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220, ThreeD: threeD.Get()})),
),
row("mt-4 flex items-center gap-3",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
Span(Attr("class", "text-ss text-ink-muted"), Text("Each chart takes a Title; a multi-series chart also draws a legend. Click a legend key (Errors, or a donut slice) to hide it — the scale and marks recompute. 3D extrudes the bars and tilts the donut.")),
Span(Attr("class", "text-ss text-ink-muted"), Text("Each chart takes a Title; a multi-series chart also draws a legend. Click a legend key (Errors, or a donut slice) to hide it — the scale and marks recompute. 3D gives every plot a grid with depth — bars extrude, a line or area draws flat over it, the donut tilts.")),
),
),
chart3DDemo(threeDBar, threeDLine, depth, tilt, requests, errs),
demo("Horizontal bars, and stacked",
row("grid gap-6 lg:grid-cols-2",
horizC.Render(ui.ChartProps{Kind: ui.ChartBar, Horizontal: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260}),
@@ -1357,6 +1363,54 @@ func orElse(s, fallback string) string {
}
// row is a flex/grid container helper (appends *VNode children as Mods).
// chart3DDemo is the "3D grid" panel: an extruded bar chart and a line chart (which rides
// the back wall, un-extruded) plus depth/tilt range sliders that drive both. Called from
// inside the render closure so reading depth/tilt makes it reactive and the charts re-render
// when a slider moves.
func chart3DDemo(barChart, lineChart *ui.Chart, depth, tilt *Signal[float64], requests, errs ui.ChartSeries) *VNode {
dep, t := depth.Get(), tilt.Get()
tv := t // addressable copy for the *float64 Tilt prop (both charts read the same value)
slider := func(label, valText, min, max, step, value string, on func(Event)) *VNode {
return El("label", Attr("class", "flex flex-col gap-1.5"),
Span(Attr("class", "flex items-center justify-between text-ss text-ink-soft"),
El("code", Attr("class", "font-mono"), Text(label)),
Span(Attr("class", "font-mono tabular-nums text-ink-muted"), Text(valText)),
),
Input(Attr("type", "range"), Attr("min", min), Attr("max", max), Attr("step", step),
Attr("value", value), Attr("class", "w-full accent-accent"), OnEvent(EVENT_INPUT, on)),
)
}
setFrom := func(sig *Signal[float64]) func(Event) {
return func(e Event) {
if v, err := strconv.ParseFloat(e.Value(), 64); err == nil {
sig.Set(v)
}
}
}
return demo("3D grid — bars extrude into it, lines draw flat on the front",
row("grid items-center gap-6 lg:grid-cols-12",
Div(Attr("class", "lg:col-span-4"),
barChart.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Bars", Labels: chartDaysWasm,
Series: []ui.ChartSeries{requests}, Height: 240, ThreeD: true, Depth: dep, Tilt: &tv}),
),
Div(Attr("class", "lg:col-span-4"),
lineChart.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Lines", Labels: chartDaysWasm,
Series: []ui.ChartSeries{requests, errs}, Height: 240, ThreeD: true, Depth: dep, Tilt: &tv}),
),
Div(Attr("class", "flex flex-col justify-center gap-5 lg:col-span-4"),
slider("depth", strconv.Itoa(int(dep))+"px", "0", "40", "1", strconv.FormatFloat(dep, 'f', 2, 64), setFrom(depth)),
slider("tilt", strconv.FormatFloat(t, 'f', 2, 64), "0", "1", "0.05", strconv.FormatFloat(t, 'f', 2, 64), setFrom(tilt)),
),
),
Span(Attr("class", "mt-3 block text-ss text-ink-muted"),
Text("The same depth and tilt give the grid itself perspective: a floor recedes from the value-0 "+
"baseline into a back wall. Bars extrude into that space; a line or area stays flat on the front "+
"plane, drawn over the grid and read against the front axis — the stroke is not itself extruded, "+
"since depth on a hairline reads as noise. tilt runs 1 (head-on, near-flat) to 0 (bird's-eye); depth "+
"is the sweep length in px.")),
)
}
func row(class string, children ...*VNode) *VNode {
mods := []Mod{Attr("class", class)}
for _, c := range children {

View File

@@ -1390,6 +1390,8 @@ const US_CITIES = [
function Charts() {
const [seed, setSeed] = createSignal(0);
const [threeD, setThreeD] = createSignal(false);
const [depth, setDepth] = createSignal(16);
const [tilt, setTilt] = createSignal(0.6);
// Two series over the same week. seed() reshuffles them so you can watch the SVG
// move — no teardown, no new instance, just the marks that changed.
@@ -1425,10 +1427,10 @@ function Charts() {
<Chart kind="donut" title="Traffic by source" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} />
</div>
<div class="lg:col-span-7">
<Chart kind="area" curve="smooth" title="Requests, smoothed" labels={CHART_DAYS} series={[requests()]} height={220} />
<Chart kind="area" curve="smooth" title="Requests, smoothed" labels={CHART_DAYS} series={[requests()]} height={220} threeD={threeD()} />
</div>
<div class="lg:col-span-5">
<Chart kind="line" title="Requests & errors" labels={CHART_DAYS} series={[requests(), errors()]} height={220} />
<Chart kind="line" title="Requests & errors" labels={CHART_DAYS} series={[requests(), errors()]} height={220} threeD={threeD()} />
</div>
</div>
<div class="mt-4 flex items-center gap-3">
@@ -1441,17 +1443,59 @@ function Charts() {
<span class="text-ss text-ink-muted">
Each chart carries a <code class="font-mono">title</code>; a multi-series chart also draws a
legend. Click a legend key say <em>Errors</em> or a donut slice to hide it, and the scale
and marks recompute from what's left. The <code class="font-mono">threeD</code> prop extrudes
the bars and tilts the donut (line and area stay flat — depth reads as noise on a curve).
and marks recompute from what's left. The <code class="font-mono">threeD</code> prop gives
every plot a 3D grid — bars extrude, a line or area draws flat over it, the donut tilts.
</span>
</div>
</Panel>
<Panel title="3D grid — bars extrude into it, lines draw flat on the front">
<div class="grid items-center gap-6 lg:grid-cols-12">
<div class="lg:col-span-4">
<Chart kind="bar" title="Bars" labels={CHART_DAYS}
series={[requests()]} height={240} threeD depth={depth()} tilt={tilt()} />
</div>
<div class="lg:col-span-4">
<Chart kind="line" title="Lines" labels={CHART_DAYS}
series={[requests(), errors()]} height={240} threeD depth={depth()} tilt={tilt()} />
</div>
<div class="flex flex-col justify-center gap-5 lg:col-span-4">
<label class="flex flex-col gap-1.5">
<span class="flex items-center justify-between text-ss text-ink-soft">
<code class="font-mono">depth</code>
<span class="font-mono tabular-nums text-ink-muted">{depth()}px</span>
</span>
<input type="range" min="0" max="40" step="1" value={depth()}
class="w-full accent-accent"
oninput={(e) => setDepth(+e.currentTarget.value)} />
</label>
<label class="flex flex-col gap-1.5">
<span class="flex items-center justify-between text-ss text-ink-soft">
<code class="font-mono">tilt</code>
<span class="font-mono tabular-nums text-ink-muted">{tilt().toFixed(2)}</span>
</span>
<input type="range" min="0" max="1" step="0.05" value={tilt()}
class="w-full accent-accent"
oninput={(e) => setTilt(+e.currentTarget.value)} />
</label>
</div>
</div>
<p class="mt-3 text-ss text-ink-muted">
The same <code class="font-mono">depth</code> and <code class="font-mono">tilt</code> give the grid
itself perspective: a floor recedes from the value-0 baseline into a back wall. Bars extrude into
that space; a line or area stays flat on the front plane, drawn over the grid and read against the
front axis — the stroke is <em>not</em> itself extruded, since depth on a hairline reads as noise.{" "}
<code class="font-mono">tilt</code> runs 1 (head-on, near-flat) to 0 (bird's-eye);{" "}
<code class="font-mono">depth</code> is the sweep length in px.
</p>
</Panel>
<Prose>
One <code class="font-mono">kind</code> prop picks the form {" "}
<code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> and{" "}
<code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "}
<code class="font-mono">curve</code>, <code class="font-mono">threeD</code>,{" "}
<code class="font-mono">curve</code>, <code class="font-mono">threeD</code>{" "}
(with <code class="font-mono">depth</code> / <code class="font-mono">tilt</code>),{" "}
<code class="font-mono">title</code>, <code class="font-mono">palette</code> and{" "}
<code class="font-mono">valueFormat</code> refine it. The legend it draws for a multi-series or
pie/donut chart is interactive clicking a key toggles that series or slice. The width is measured

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"/>`);

View File

@@ -62,8 +62,11 @@ type ChartProps struct {
SegmentGap *float64
ThreeD bool // extrude bars, tilt pie/donut (ignored on line/area)
Depth float64
// Tilt is the 3D pie/donut squash scalar: 0 → full tilt (edge-on), 1 flat. A pointer so
// an explicit 0 is distinct from unset; nil defaults to 0.85.
// Tilt is the 3D perspective scalar, read differently per kind but always "1 = flattest,
// 0 = most tilted". For a bar it is the depth-axis angle — 1 extrudes head-on (barely any
// rise, the grid reads almost flat), 0 rotates to a bird's-eye view (the floor opens up);
// nil defaults to 0.6. For pie/donut it is the disc squash — 0 edge-on, 1 face-on flat;
// nil defaults to 0.85. A pointer so an explicit 0 is distinct from unset.
Tilt *float64
Width float64 // internal viewBox width (default 640); the SVG scales to its container
@@ -97,6 +100,7 @@ const (
chartMarkR = 4.0
chartDefaultDepth = 16.0
chartDefaultTilt = 0.85 // 3D pie/donut vertical squash (0 → edge-on, 1 → flat)
chartDefaultTiltC = 0.6 // 3D bar depth-axis (1 → head-on/flat, 0 → bird's-eye)
)
var chartTokens = []string{
@@ -405,15 +409,39 @@ func chartSegGapPx(p ChartProps) float64 {
}
return chartSegGap
}
// cartTilt is the 3D bar depth-axis scalar (1 → head-on, 0 → bird's-eye). It shares the
// Tilt prop with the radial squash, but a chart is only ever one kind so the two readings
// never collide.
func cartTilt(p ChartProps) float64 {
t := chartDefaultTiltC
if p.Tilt != nil {
t = *p.Tilt
}
return clampf(t, 0, 1)
}
// chartGrid3D reports whether the extruded grid (floor + back wall) is drawn: any cartesian
// kind with ThreeD set. Bars additionally extrude along the depth axis (see chart3D); a
// line/area is merely shifted onto the back wall so it tracks the wall's gridlines — the
// trace itself is never extruded, since depth on a 1px stroke reads as noise.
func chartGrid3D(p ChartProps) bool {
return p.ThreeD && !radial(p.Kind)
}
// chartDX/chartDY are the (right, up) components of the 3D depth axis — the vector bars
// extrude along, the grid is swept along, and a line/area is shifted onto the back wall by,
// so they all read as one 3D space. Its length is Depth; Tilt turns it from head-on (all
// run, no rise) toward bird's-eye (all rise, no run).
func cartDepthAngle(p ChartProps) float64 { return (1 - cartTilt(p)) * (math.Pi / 2) }
func chartDX(p ChartProps) float64 {
if chart3D(p) {
return chartDepth(p) * 0.7
if chartGrid3D(p) {
return chartDepth(p) * math.Cos(cartDepthAngle(p))
}
return 0
}
func chartDY(p ChartProps) float64 {
if chart3D(p) {
return chartDepth(p) * 0.55
if chartGrid3D(p) {
return chartDepth(p) * math.Sin(cartDepthAngle(p))
}
return 0
}
@@ -731,6 +759,9 @@ func chartPaths(p ChartProps, w, h float64) []lineMark {
}
count := chartN(p)
base := baseValue(p, w, h)
// The trace stays on the FRONT plane in 3D — it draws last, on top of the extruded grid,
// and aligns with the front value axis and its front gridlines. Only the grid (floor +
// back wall) carries the depth; the stroke is never extruded.
stackAcc := make([]float64, count)
out := make([]lineMark, 0, len(p.Series))
for si, s := range p.Series {
@@ -781,9 +812,16 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
showGrid := !p.NoGrid
var b strings.Builder
// 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 back wall).
flatGrid := showGrid && (!chartGrid3D(p) || p.Kind != ChartBar)
if showGrid && chartGrid3D(p) {
b.WriteString(cartesian3DGrid(p, w, h))
}
for _, t := range d.ticks {
vp := valuePos(p, w, h, t)
if showGrid {
if flatGrid {
if hz {
b.WriteString(`<line x1="` + num(vp) + `" x2="` + num(vp) + `" y1="` + num(l.top) + `" y2="` + num(l.top+l.plotH) + `" stroke="var(--color-line)" stroke-width="1"/>`)
} else {
@@ -817,14 +855,14 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
}
isBar := p.Kind == ChartBar
// crosshair (line/area only)
dx, dy := chartDX(p), chartDY(p)
// crosshair (line/area only) — on the front plane, where the trace is.
if !p.NoTooltip && hv >= 0 && !isBar {
c := catCenter(p, w, h, hv)
b.WriteString(`<line x1="` + num(c) + `" x2="` + num(c) + `" y1="` + num(l.top) + `" y2="` + num(l.top+l.plotH) + `" stroke="var(--color-line-strong)" stroke-width="1"/>`)
}
// bars
dx, dy := chartDX(p), chartDY(p)
for _, bm := range chartBars(p, w, h) {
op := 1.0
if hv >= 0 && hv != bm.cat {
@@ -866,6 +904,69 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
return b.String()
}
// cartesian3DGrid draws 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. It uses the SAME
// (dx,-dy) depth axis the bars extrude along, so the grid and the columns read as one 3D
// space instead of flat rules behind floating blocks. Drawn before the bars, which paint
// over the parts of the floor/wall they occlude.
func cartesian3DGrid(p ChartProps, w, h float64) string {
l := chartLayout(p, w, h)
d := chartDomain(p)
dx, dy := chartDX(p), chartDY(p)
hz := chartHoriz(p)
n := chartN(p)
bv := baseValue(p, w, h)
var b strings.Builder
// a filled parallelogram (a plane), faint and themed so it grounds without adding lines
quad := func(x1, y1, x2, y2, x3, y3, x4, y4 float64, op string) {
b.WriteString(`<path d="M` + num(x1) + `,` + num(y1) + ` L` + num(x2) + `,` + num(y2) +
` L` + num(x3) + `,` + num(y3) + ` L` + num(x4) + `,` + num(y4) +
` Z" fill="var(--color-line)" fill-opacity="` + op + `"/>`)
}
// a grid line at a given stroke-opacity (receding edges are drawn fainter than the wall)
seg := func(x1, y1, x2, y2 float64, sop string) {
b.WriteString(`<line x1="` + num(x1) + `" y1="` + num(y1) + `" x2="` + num(x2) + `" y2="` + num(y2) +
`" stroke="var(--color-line)" stroke-width="1" stroke-opacity="` + sop + `"/>`)
}
if hz {
yA, yB := l.top, l.top+l.plotH
xA, xB := l.left, l.left+l.plotW
quad(bv, yA, bv, yB, bv+dx, yB-dy, bv+dx, yA-dy, "0.09") // floor (baseline plane)
quad(xA+dx, yA-dy, xB+dx, yA-dy, xB+dx, yB-dy, xA+dx, yB-dy, "0.05") // back wall
for _, t := range d.ticks {
vp := valuePos(p, w, h, t)
seg(vp+dx, yA-dy, vp+dx, yB-dy, "1") // back-wall value gridline
seg(vp, yB, vp+dx, yB-dy, "0.5") // connector at the labelled (bottom) edge
}
band := l.plotH / maxf(1, float64(n))
for i := 0; i <= n; i++ {
yc := l.top + band*float64(i)
seg(bv, yc, bv+dx, yc-dy, "0.5") // floor line receding at each category boundary
}
seg(bv+dx, yA-dy, bv+dx, yB-dy, "1") // far edge of the floor / foot of the back wall
} else {
xA, xB := l.left, l.left+l.plotW
yT, yB := l.top, l.top+l.plotH
quad(xA, bv, xB, bv, xB+dx, bv-dy, xA+dx, bv-dy, "0.09") // floor (baseline plane)
quad(xA+dx, yT-dy, xB+dx, yT-dy, xB+dx, yB-dy, xA+dx, yB-dy, "0.05") // back wall
for _, t := range d.ticks {
vp := valuePos(p, w, h, t)
seg(xA+dx, vp-dy, xB+dx, vp-dy, "1") // back-wall value gridline
seg(xA, vp, xA+dx, vp-dy, "0.5") // connector at the labelled (left) edge
}
band := l.plotW / maxf(1, float64(n))
for i := 0; i <= n; i++ {
xc := l.left + band*float64(i)
seg(xc, bv, xc+dx, bv-dy, "0.5") // floor line receding at each category boundary
}
seg(xA+dx, bv-dy, xB+dx, bv-dy, "1") // far edge of the floor / foot of the back wall
}
return b.String()
}
func labelAt(p ChartProps, i int) string {
ls := chartLabels(p)
if i >= 0 && i < len(ls) {