diff --git a/go/cmd/kjol-website/app/components.go b/go/cmd/kjol-website/app/components.go index 52ab1c39..ba1ad16d 100644 --- a/go/cmd/kjol-website/app/components.go +++ b/go/cmd/kjol-website/app/components.go @@ -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 { diff --git a/go/cmd/kjol-website/frontend/src/pages/Components.tsx b/go/cmd/kjol-website/frontend/src/pages/Components.tsx index 5d0ec95f..ee0fd674 100644 --- a/go/cmd/kjol-website/frontend/src/pages/Components.tsx +++ b/go/cmd/kjol-website/frontend/src/pages/Components.tsx @@ -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() {
- +
- +
@@ -1441,17 +1443,59 @@ function Charts() { Each chart carries a title; a multi-series chart also draws a legend. Click a legend key — say Errors or a donut slice — to hide it, and the scale - and marks recompute from what's left. The threeD 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 threeD prop gives + every plot a 3D grid — bars extrude, a line or area draws flat over it, the donut tilts.
+ +
+
+ +
+
+ +
+
+ + +
+
+

+ 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. +

+
+ One kind prop picks the form —{" "} "line" | "area" | "bar" | "pie" | "donut" — and{" "} stacked, horizontal,{" "} - curve, threeD,{" "} + curve, threeD{" "} + (with depth / tilt),{" "} title, palette and{" "} valueFormat 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 diff --git a/go/jsruntime/uikit/Chart.tsx b/go/jsruntime/uikit/Chart.tsx index 2522bae0..c62271db 100644 --- a/go/jsruntime/uikit/Chart.tsx +++ b/go/jsruntime/uikit/Chart.tsx @@ -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(``); + const seg = (x1: number, y1: number, x2: number, y2: number, sop: number) => + out.push(``); + 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 ? `` : ``); @@ -508,7 +570,8 @@ function CartesianChart(p: SubProps): JSXElement { : `${esc(lab)}`)); } - // 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(``); diff --git a/go/webui/chart.go b/go/webui/chart.go index 46b44f1d..54df92d9 100644 --- a/go/webui/chart.go +++ b/go/webui/chart.go @@ -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(``) } 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(``) } // 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(``) + } + // 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(``) + } + + 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) {