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() {
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.
+
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(`