update 3d chart visualization
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1184,8 +1185,11 @@ var usCities = []ui.USHeatmapPoint{
|
|||||||
func chartsSection() func() *VNode {
|
func chartsSection() func() *VNode {
|
||||||
seed := NewSignal(0)
|
seed := NewSignal(0)
|
||||||
threeD := NewSignal(false)
|
threeD := NewSignal(false)
|
||||||
|
depth := NewSignal(16.0)
|
||||||
|
tilt := NewSignal(0.6)
|
||||||
barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
|
barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
|
||||||
horizC, stackC := ui.NewChart(), ui.NewChart()
|
horizC, stackC := ui.NewChart(), ui.NewChart()
|
||||||
|
threeDBar, threeDLine := ui.NewChart(), ui.NewChart()
|
||||||
heat := ui.NewUSHeatmap()
|
heat := ui.NewUSHeatmap()
|
||||||
|
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
@@ -1224,16 +1228,18 @@ func chartsSection() func() *VNode {
|
|||||||
row("grid gap-6 lg:grid-cols-12",
|
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-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-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-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})),
|
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",
|
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.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()) }}),
|
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",
|
demo("Horizontal bars, and stacked",
|
||||||
row("grid gap-6 lg:grid-cols-2",
|
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}),
|
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).
|
// 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 {
|
func row(class string, children ...*VNode) *VNode {
|
||||||
mods := []Mod{Attr("class", class)}
|
mods := []Mod{Attr("class", class)}
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
|
|||||||
@@ -1390,6 +1390,8 @@ const US_CITIES = [
|
|||||||
function Charts() {
|
function Charts() {
|
||||||
const [seed, setSeed] = createSignal(0);
|
const [seed, setSeed] = createSignal(0);
|
||||||
const [threeD, setThreeD] = createSignal(false);
|
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
|
// 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.
|
// 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()} />
|
<Chart kind="donut" title="Traffic by source" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} />
|
||||||
</div>
|
</div>
|
||||||
<div class="lg:col-span-7">
|
<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>
|
||||||
<div class="lg:col-span-5">
|
<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>
|
</div>
|
||||||
<div class="mt-4 flex items-center gap-3">
|
<div class="mt-4 flex items-center gap-3">
|
||||||
@@ -1441,17 +1443,59 @@ function Charts() {
|
|||||||
<span class="text-ss text-ink-muted">
|
<span class="text-ss text-ink-muted">
|
||||||
Each chart carries a <code class="font-mono">title</code>; a multi-series chart also draws a
|
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
|
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
|
and marks recompute from what's left. The <code class="font-mono">threeD</code> prop gives
|
||||||
the bars and tilts the donut (line and area stay flat — depth reads as noise on a curve).
|
every plot a 3D grid — bars extrude, a line or area draws flat over it, the donut tilts.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</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>
|
<Prose>
|
||||||
One <code class="font-mono">kind</code> prop picks the form —{" "}
|
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">"line" | "area" | "bar" | "pie" | "donut"</code> — and{" "}
|
||||||
<code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "}
|
<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">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
|
<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
|
pie/donut chart is interactive — clicking a key toggles that series or slice. The width is measured
|
||||||
|
|||||||
@@ -51,11 +51,16 @@ export interface ChartProps {
|
|||||||
// Controls border/gap between pie/doughnut segments (acts as a per-slice & full pie "stroke")
|
// Controls border/gap between pie/doughnut segments (acts as a per-slice & full pie "stroke")
|
||||||
segmentGap?: number; // value in px (default 2)
|
segmentGap?: number; // value in px (default 2)
|
||||||
|
|
||||||
// Give the chart depth: bars extrude, pie/donut tilt and gain a rim (line/area ignore
|
// Give the chart depth: bars extrude and stand in an extruded grid, pie/donut tilt and
|
||||||
// it). An embellishment — flat reads more precisely — but sometimes wanted.
|
// gain a rim (line/area ignore it). An embellishment — flat reads more precisely — but
|
||||||
|
// sometimes wanted.
|
||||||
threeD?: boolean;
|
threeD?: boolean;
|
||||||
depth?: number; // 3D extrusion depth in px (default 16).
|
depth?: number; // 3D depth in px: bar extrusion / grid sweep length, pie/donut rim (default 16).
|
||||||
tilt?: number; // 3D tilt scalar (0 -> full tilt, 1 -> fully flattened)
|
// 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.
|
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.
|
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 BAR_RADIUS = 4; // rounded data-end
|
||||||
const MARK_R = 4; // hover marker radius (8px mark)
|
const MARK_R = 4; // hover marker radius (8px mark)
|
||||||
const DEFAULT_DEPTH = 16; // 3D extrusion depth
|
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 ───────────────────────────────────────────────────
|
// ── number + geometry helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -306,11 +312,18 @@ function CartesianChart(p: SubProps): JSXElement {
|
|||||||
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
const [pointer, setPointer] = createSignal<[number, number]>([0, 0]);
|
||||||
|
|
||||||
const horiz = () => p.props.kind === "bar" && !!p.props.horizontal;
|
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.
|
// threeD draws the extruded grid (floor + back wall) for any cartesian kind. Bars also
|
||||||
const threeD = () => !!p.props.threeD && p.props.kind === "bar";
|
// 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 depth = () => p.props.depth ?? DEFAULT_DEPTH;
|
||||||
const dx = () => (threeD() ? depth() * 0.7 : 0);
|
// The depth axis (right, up), length `depth`: tilt turns it from head-on (all run, no
|
||||||
const dy = () => (threeD() ? depth() * 0.55 : 0);
|
// 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 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);
|
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;
|
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(() => {
|
const paths = createMemo(() => {
|
||||||
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[];
|
if (p.props.kind !== "line" && p.props.kind !== "area") return [] as LineMark[];
|
||||||
const count = n(), base = baseValue();
|
const count = n(), base = baseValue();
|
||||||
@@ -473,16 +489,62 @@ function CartesianChart(p: SubProps): JSXElement {
|
|||||||
const showGrid = () => p.props.grid ?? true;
|
const showGrid = () => p.props.grid ?? true;
|
||||||
const isBar = () => p.props.kind === "bar";
|
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
|
// 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.
|
// JSX marks). Recomputed when the data, the size, or the hovered index changes.
|
||||||
const body = createMemo(() => {
|
const body = createMemo(() => {
|
||||||
const l = layout(), d = domain(), hv = hover(), h = horiz();
|
const l = layout(), d = domain(), hv = hover(), h = horiz();
|
||||||
const out: string[] = [];
|
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)
|
// gridlines + value ticks (perpendicular to the value axis)
|
||||||
for (const t of d.ticks) {
|
for (const t of d.ticks) {
|
||||||
const vp = valuePos(t);
|
const vp = valuePos(t);
|
||||||
if (showGrid()) {
|
if (flatGrid) {
|
||||||
out.push(h
|
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="${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"/>`);
|
: `<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>`));
|
: `<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()) {
|
if (p.props.tooltip !== false && hv !== null && !isBar()) {
|
||||||
const c = catCenter(hv);
|
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"/>`);
|
out.push(`<line x1="${c}" x2="${c}" y1="${l.top}" y2="${l.top + l.plotH}" stroke="var(--color-line-strong)" stroke-width="1"/>`);
|
||||||
|
|||||||
@@ -62,8 +62,11 @@ type ChartProps struct {
|
|||||||
SegmentGap *float64
|
SegmentGap *float64
|
||||||
ThreeD bool // extrude bars, tilt pie/donut (ignored on line/area)
|
ThreeD bool // extrude bars, tilt pie/donut (ignored on line/area)
|
||||||
Depth float64
|
Depth float64
|
||||||
// Tilt is the 3D pie/donut squash scalar: 0 → full tilt (edge-on), 1 → flat. A pointer so
|
// Tilt is the 3D perspective scalar, read differently per kind but always "1 = flattest,
|
||||||
// an explicit 0 is distinct from unset; nil defaults to 0.85.
|
// 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
|
Tilt *float64
|
||||||
|
|
||||||
Width float64 // internal viewBox width (default 640); the SVG scales to its container
|
Width float64 // internal viewBox width (default 640); the SVG scales to its container
|
||||||
@@ -97,6 +100,7 @@ const (
|
|||||||
chartMarkR = 4.0
|
chartMarkR = 4.0
|
||||||
chartDefaultDepth = 16.0
|
chartDefaultDepth = 16.0
|
||||||
chartDefaultTilt = 0.85 // 3D pie/donut vertical squash (0 → edge-on, 1 → flat)
|
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{
|
var chartTokens = []string{
|
||||||
@@ -405,15 +409,39 @@ func chartSegGapPx(p ChartProps) float64 {
|
|||||||
}
|
}
|
||||||
return chartSegGap
|
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 {
|
func chartDX(p ChartProps) float64 {
|
||||||
if chart3D(p) {
|
if chartGrid3D(p) {
|
||||||
return chartDepth(p) * 0.7
|
return chartDepth(p) * math.Cos(cartDepthAngle(p))
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
func chartDY(p ChartProps) float64 {
|
func chartDY(p ChartProps) float64 {
|
||||||
if chart3D(p) {
|
if chartGrid3D(p) {
|
||||||
return chartDepth(p) * 0.55
|
return chartDepth(p) * math.Sin(cartDepthAngle(p))
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -731,6 +759,9 @@ func chartPaths(p ChartProps, w, h float64) []lineMark {
|
|||||||
}
|
}
|
||||||
count := chartN(p)
|
count := chartN(p)
|
||||||
base := baseValue(p, w, h)
|
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)
|
stackAcc := make([]float64, count)
|
||||||
out := make([]lineMark, 0, len(p.Series))
|
out := make([]lineMark, 0, len(p.Series))
|
||||||
for si, s := range 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
|
showGrid := !p.NoGrid
|
||||||
var b strings.Builder
|
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 {
|
for _, t := range d.ticks {
|
||||||
vp := valuePos(p, w, h, t)
|
vp := valuePos(p, w, h, t)
|
||||||
if showGrid {
|
if flatGrid {
|
||||||
if hz {
|
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"/>`)
|
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 {
|
} else {
|
||||||
@@ -817,14 +855,14 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isBar := p.Kind == ChartBar
|
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 {
|
if !p.NoTooltip && hv >= 0 && !isBar {
|
||||||
c := catCenter(p, w, h, hv)
|
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"/>`)
|
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
|
// bars
|
||||||
dx, dy := chartDX(p), chartDY(p)
|
|
||||||
for _, bm := range chartBars(p, w, h) {
|
for _, bm := range chartBars(p, w, h) {
|
||||||
op := 1.0
|
op := 1.0
|
||||||
if hv >= 0 && hv != bm.cat {
|
if hv >= 0 && hv != bm.cat {
|
||||||
@@ -866,6 +904,69 @@ func cartesianBody(p ChartProps, w, h float64, hv int) string {
|
|||||||
return b.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 {
|
func labelAt(p ChartProps, i int) string {
|
||||||
ls := chartLabels(p)
|
ls := chartLabels(p)
|
||||||
if i >= 0 && i < len(ls) {
|
if i >= 0 && i < len(ls) {
|
||||||
|
|||||||
Reference in New Issue
Block a user