Files
kjol/go/webui/chart.go

1227 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Port of jsruntime/uikit/Chart.tsx — a dependency-free SVG chart, in Go, drawn by the
// WebAssembly. Same forms (line/area/bar/pie/donut, horizontal, stacked, 3D), same
// geometry, and the same theme tokens (var(--color-chart-1) …), so a chart reads the
// same on the /wasm and /js sides of the site.
//
// The marks are one SVG STRING inserted with vdom.Raw. That is the whole rendering
// strategy: the geometry is pure math that produces path/rect/text markup, and Raw hands
// it to the browser to parse in the SVG namespace (an <svg>'s innerHTML). Pointer events
// ride the <svg> element; a pointermove maps to a category/slice index via one Measure
// (getBoundingClientRect + viewBox scale, exactly SignaturePad.point), and only writes the
// hover signal when the index CHANGES — so most moves cost a measurement and nothing else,
// and a re-render happens once per band crossing, not once per pixel. The tooltip is an HTML
// overlay whose CONTENT comes from that hover signal but whose POSITION is written with
// SetStyle on every move, so it follows the cursor without a per-pixel re-render — the same
// behaviour as the Solid kit's tooltip.
//
// The geometry helpers are free functions of (props, width, height): the body builder and
// the hit-test both call them, so what the pointer lands on can never drift from what was
// drawn.
package webui
import (
"math"
"strconv"
"strings"
"kjol/wasmruntime"
"kjol/wasmruntime/vdom"
)
type ChartKind string
const (
ChartLine ChartKind = "line"
ChartArea ChartKind = "area"
ChartBar ChartKind = "bar"
ChartPie ChartKind = "pie"
ChartDonut ChartKind = "donut"
)
// ChartSeries is one series. For pie/donut only Series[0] is used, and Labels name the
// slices. Color overrides the palette slot; leave it empty to take slot N.
type ChartSeries struct {
Name string
Data []float64
Color string
}
// ChartProps configures a Chart. Zero values are sensible defaults (see the constants).
type ChartProps struct {
Kind ChartKind
Labels []string
Series []ChartSeries
Stacked bool // bar/area: stack the series
Horizontal bool // bar: categories down the y-axis, values along x
Smooth bool // line/area: Catmull-Rom spline
DonutRatio float64
// SegmentGap is the px gap/stroke between touching marks — pie/donut slices (a per-slice
// and full-pie stroke) and stacked/grouped bar segments. A pointer so an explicit 0 (no
// gap) is distinct from unset; nil defaults to 2.
SegmentGap *float64
ThreeD bool // extrude bars, tilt pie/donut (ignored on line/area)
Depth float64
// 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
Height float64 // default 300
Class string
Title string // a caption centred above the plot
Palette []string
ValueFormat func(float64) string
NoGrid bool
NoAxes bool
NoTooltip bool
YMin *float64
YMax *float64
// hidden is the set of legend indices toggled off (series index for a cartesian chart,
// slice index for a pie/donut). The controller injects its live set before each render;
// the geometry functions skip whatever it names. Not a caller-facing prop.
hidden map[int]bool
}
func chartShown(p ChartProps, i int) bool { return p.hidden == nil || !p.hidden[i] }
const (
chartDefaultWidth = 640.0
chartDefaultHeight = 300.0
chartBarMaxW = 24.0
chartBarRadius = 4.0
chartSegGap = 2.0
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{
"var(--color-chart-1)", "var(--color-chart-2)", "var(--color-chart-3)", "var(--color-chart-4)",
"var(--color-chart-5)", "var(--color-chart-6)", "var(--color-chart-7)", "var(--color-chart-8)",
}
// ── the controller ────────────────────────────────────────────────────────────────
// Chart is a CONTROLLER (it holds the hover signal and the SVG ref, which must survive
// across renders). Build it once next to your signals and call Render(props) each render
// so the data can change:
//
// chart := webui.NewChart()
// return func() *vdom.VNode {
// return chart.Render(webui.ChartProps{Kind: webui.ChartBar, Labels: days, Series: series})
// }
type Chart struct {
hover *vdom.Signal[int]
hidden *vdom.Signal[map[int]bool] // legend toggles; a new map each Set so it re-renders
width *vdom.Signal[float64] // measured container width; 0 until the first measure
svgRef *vdom.Ref
wrapRef *vdom.Ref
tipRef *vdom.Ref // the HTML tooltip, positioned imperatively so it follows the cursor
props ChartProps // last rendered, so the pointer handler hit-tests the current geometry
mounted bool
unsub wasmruntime.Unsub
}
// NewChart creates a chart controller. Call it once, OUTSIDE the render function.
func NewChart() *Chart {
return &Chart{hover: vdom.NewSignal(-1), hidden: vdom.NewSignal(map[int]bool{}), width: vdom.NewSignal(0.0), svgRef: vdom.NewRef(), wrapRef: vdom.NewRef(), tipRef: vdom.NewRef()}
}
// toggle flips a legend item's visibility. It Sets a fresh map (never mutates the current
// one) so the signal fires and the whole chart re-renders from the new set.
func (c *Chart) toggle(i int) {
next := map[int]bool{}
for k, v := range c.hidden.Get() {
if v {
next[k] = true
}
}
if next[i] {
delete(next, i)
} else {
next[i] = true
}
c.hidden.Set(next)
}
// ChartSVG renders a static, non-interactive chart as one complete <svg> element string —
// for server-side use (a server component, an SSR'd fragment) where a live controller and
// its hover state are unnecessary. For an interactive chart use the Chart controller.
func ChartSVG(p ChartProps) string {
w, h := chartW(p), chartH(p)
var body string
if radial(p.Kind) {
body = radialBody(p, w, h, -1)
} else {
body = cartesianBody(p, w, h, -1)
}
return `<svg width="` + num(w) + `" height="` + num(h) + `" viewBox="0 0 ` + num(w) + ` ` + num(h) + `" class="block max-w-full overflow-visible" role="img">` + body + `</svg>`
}
// Dispose drops the resize observer. Call it when the chart goes away.
func (c *Chart) Dispose() {
if c.unsub != nil {
c.unsub()
c.unsub = nil
}
}
// effW is the width to draw at: an explicit Width prop, else the measured container
// width, else the default. onMove and Render must agree, or the hit-test drifts.
func (c *Chart) effW() float64 {
if c.props.Width > 0 {
return c.props.Width
}
if w := c.width.Get(); w > 0 {
return w
}
return chartDefaultWidth
}
// measure reads the wrapper's width and, if it changed, re-renders at it — so the SVG is
// drawn 1:1 (crisp text) rather than a fixed viewBox scaled by CSS.
func (c *Chart) measure() {
r := wasmruntime.Measure(c.wrapRef)
if r.Width > 0 && math.Abs(r.Width-c.width.Get()) > 0.5 {
c.width.Set(r.Width)
}
}
func (c *Chart) onMounted() {
c.measure()
if c.unsub == nil {
c.unsub = wasmruntime.ObserveResize(c.wrapRef, c.measure)
}
}
func (c *Chart) Render(p ChartProps) *vdom.VNode {
p.hidden = c.hidden.Get() // so the geometry (and onMove, via c.props) skips hidden items
c.props = p
if !c.mounted {
c.mounted = true
wasmruntime.AfterRender(c.onMounted) // measure + observe once the DOM exists
}
w, h := c.effW(), chartH(p)
hv := c.hover.Get()
var body string
if radial(p.Kind) {
body = radialBody(p, w, h, hv)
} else {
body = cartesianBody(p, w, h, hv)
}
mods := []vdom.Mod{
vdom.WithRef(c.svgRef),
vdom.Attr("width", num(w)),
vdom.Attr("height", num(h)),
vdom.Attr("viewBox", "0 0 "+num(w)+" "+num(h)),
vdom.Attr("class", "block max-w-full overflow-visible"),
vdom.Attr("role", "img"),
vdom.Raw(body),
}
if !p.NoTooltip {
mods = append(mods,
vdom.OnEvent(vdom.EVENT_POINTERMOVE, c.onMove),
vdom.On(vdom.EVENT_POINTERLEAVE, c.onLeave),
)
}
// The plot + its imperatively-positioned tooltip share one relative box; a title or
// legend sits OUTSIDE it, so neither shifts the coordinate frame onMove writes into.
plot := vdom.Div(vdom.Attr("class", "relative w-full"), vdom.Svg(mods...), c.tooltipNode(p, hv))
children := []*vdom.VNode{}
if p.Title != "" {
children = append(children, vdom.Div(vdom.Attr("class", "mb-2 text-center text-sm font-medium text-ink"), vdom.Text(p.Title)))
}
children = append(children, plot)
if chartShowLegend(p) {
children = append(children, c.legend(p))
}
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.wrapRef), vdom.Attr("class", cx("w-full", p.Class))}, children)...)
}
func (c *Chart) onLeave() {
if c.hover.Get() != -1 {
c.hover.Set(-1)
}
}
func (c *Chart) onMove(e vdom.Event) {
if c.props.NoTooltip {
return
}
p := c.props
r := wasmruntime.Measure(c.svgRef)
if r.Width == 0 || r.Height == 0 {
return
}
w, h := c.effW(), chartH(p)
// The svg sits at the wrapper's top-left, so pointer-minus-svg-rect is both the DISPLAY
// position within the svg AND the tooltip's left/top in the wrapper.
dx := float64(e.ClientX()) - r.X
dy := float64(e.ClientY()) - r.Y
sx, sy := r.Width/w, r.Height/h // internal → display scale
lx, ly := dx/sx, dy/sy // pointer in INTERNAL coords, for hit-testing
if radial(p.Kind) {
idx := radialHit(p, w, h, lx, ly)
if idx != c.hover.Get() {
c.hover.Set(idx)
}
if idx >= 0 { // pie/donut: the tooltip follows the pointer
c.positionTip(clampf(dx, 8, r.Width-8), clampf(dy, 8, r.Height-8), "translate(-50%, calc(-100% - 12px))")
}
return
}
lay := chartLayout(p, w, h)
n := chartN(p)
var along, band float64
if chartHoriz(p) {
along, band = ly-lay.top, lay.plotH/maxf(1, float64(n))
} else {
along, band = lx-lay.left, lay.plotW/maxf(1, float64(n))
}
idx := clampi(int(math.Floor(along/band)), 0, maxi(0, n-1))
if idx != c.hover.Get() {
c.hover.Set(idx)
}
// snap the category axis to the band centre, follow the pointer on the value axis —
// the same anchoring as the Solid CartesianTooltip.
var ax, ay float64
if chartHoriz(p) {
ax, ay = dx, catCenter(p, w, h, idx)*sy
} else {
ax, ay = catCenter(p, w, h, idx)*sx, dy
}
tf := "translate(12px, -50%)"
if ax > r.Width/2 {
tf = "translate(calc(-100% - 12px), -50%)"
}
c.positionTip(ax, clampf(ay, 8, r.Height-8), tf)
}
func (c *Chart) positionTip(left, top float64, transform string) {
wasmruntime.SetStyle(c.tipRef, "left", num(left)+"px")
wasmruntime.SetStyle(c.tipRef, "top", num(top)+"px")
wasmruntime.SetStyle(c.tipRef, "transform", transform)
}
// tooltipNode is the HTML tooltip (absolute, pointer-events-none). It is rendered every
// pass so its ref stays valid; onMove positions it imperatively. When there is no hover it
// is present but hidden — the same slot each render keeps the reconciler's diff stable.
func (c *Chart) tooltipNode(p ChartProps, hv int) *vdom.VNode {
base := "pointer-events-none absolute z-10 max-w-64 rounded-default border border-line bg-surface px-3 py-2 text-ss shadow-lg"
if p.NoTooltip || hv < 0 || hv >= chartN(p) {
return vdom.Div(vdom.WithRef(c.tipRef), vdom.Attr("class", cx(base, "hidden")))
}
var content []*vdom.VNode
if radial(p.Kind) {
content = radialTipContent(p, hv)
base = cx(base, "min-w-28")
} else {
content = cartesianTipContent(p, hv)
base = cx(base, "min-w-32")
}
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.tipRef), vdom.Attr("class", base)}, content)...)
}
func swatchSpan(color string) *vdom.VNode {
return vdom.Span(vdom.Attr("class", "inline-block h-2.5 w-2.5 shrink-0 rounded-xs"),
vdom.Attr("style", "background-color:"+color))
}
func cartesianTipContent(p ChartProps, hv int) []*vdom.VNode {
out := []*vdom.VNode{vdom.Div(vdom.Attr("class", "mb-1 font-medium text-ink"), vdom.Text(labelAt(p, hv)))}
for i, s := range p.Series {
if !chartShown(p, i) {
continue
}
out = append(out, vdom.Div(vdom.Attr("class", "flex items-center gap-2 leading-relaxed"),
swatchSpan(chartColor(p, i)),
vdom.Span(vdom.Attr("class", "text-ink-muted"), vdom.Text(s.Name)),
vdom.Span(vdom.Attr("class", "ml-auto font-semibold text-ink"), vdom.Attr("style", "font-variant-numeric:tabular-nums"),
vdom.Text(chartFmt(p, datum(s, hv)))),
))
}
return out
}
func radialTipContent(p ChartProps, hv int) []*vdom.VNode {
total := radialTotal(p)
v := 0.0
if len(p.Series) > 0 && hv < len(p.Series[0].Data) {
v = p.Series[0].Data[hv]
}
pct := ""
if total > 0 {
pct = strconv.FormatFloat(math.Max(0, v)/total*100, 'f', 1, 64) + "%"
}
return []*vdom.VNode{
vdom.Div(vdom.Attr("class", "flex items-center gap-2"),
swatchSpan(chartColor(p, hv)),
vdom.Span(vdom.Attr("class", "text-ink-muted"), vdom.Text(radialLabel(p, hv)))),
vdom.Div(vdom.Attr("class", "mt-1 flex items-baseline gap-2"),
vdom.Span(vdom.Attr("class", "font-semibold text-ink"), vdom.Attr("style", "font-variant-numeric:tabular-nums"), vdom.Text(chartFmt(p, v))),
vdom.Span(vdom.Attr("class", "text-ink-faint"), vdom.Text(pct))),
}
}
// ── geometry (free functions of props + size, shared by body builders and hit-test) ──
func radial(k ChartKind) bool { return k == ChartPie || k == ChartDonut }
func chartW(p ChartProps) float64 {
if p.Width > 0 {
return p.Width
}
return chartDefaultWidth
}
func chartH(p ChartProps) float64 {
if p.Height > 0 {
return p.Height
}
return chartDefaultHeight
}
func chartHoriz(p ChartProps) bool { return p.Kind == ChartBar && p.Horizontal }
func chart3D(p ChartProps) bool { return p.ThreeD && p.Kind == ChartBar }
func chartDepth(p ChartProps) float64 {
if p.Depth > 0 {
return p.Depth
}
return chartDefaultDepth
}
// chartSegGapPx is the surface gap between touching marks: the SegmentGap prop when set
// (including an explicit 0), else the 2px default.
func chartSegGapPx(p ChartProps) float64 {
if p.SegmentGap != nil {
return *p.SegmentGap
}
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 chartGrid3D(p) {
return chartDepth(p) * math.Cos(cartDepthAngle(p))
}
return 0
}
func chartDY(p ChartProps) float64 {
if chartGrid3D(p) {
return chartDepth(p) * math.Sin(cartDepthAngle(p))
}
return 0
}
func chartLabels(p ChartProps) []string {
if p.Labels != nil {
return p.Labels
}
n := 0
if len(p.Series) > 0 {
n = len(p.Series[0].Data)
}
out := make([]string, n)
for i := range out {
out[i] = strconv.Itoa(i + 1)
}
return out
}
func chartN(p ChartProps) int {
n := len(chartLabels(p))
for _, s := range p.Series {
if len(s.Data) > n {
n = len(s.Data)
}
}
return n
}
func chartColor(p ChartProps, i int) string {
if i < len(p.Series) && p.Series[i].Color != "" {
return p.Series[i].Color
}
if len(p.Palette) > 0 {
return p.Palette[i%len(p.Palette)]
}
return chartTokens[i%len(chartTokens)]
}
func chartFmt(p ChartProps, v float64) string {
if p.ValueFormat != nil {
return p.ValueFormat(v)
}
return groupNum(v)
}
func chartShowLegend(p ChartProps) bool { return radial(p.Kind) || len(p.Series) > 1 }
func datum(s ChartSeries, i int) float64 {
if i < len(s.Data) {
return s.Data[i]
}
return 0
}
type chartScale struct {
min, max float64
ticks []float64
}
func chartDomain(p ChartProps) chartScale {
count := chartN(p)
includeZero := p.Kind == ChartBar || p.Kind == ChartArea
lo, hi := math.Inf(1), math.Inf(-1)
if p.Stacked {
for i := 0; i < count; i++ {
pos, neg := 0.0, 0.0
for s := range p.Series {
if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i)
if v >= 0 {
pos += v
} else {
neg += v
}
}
hi, lo = math.Max(hi, pos), math.Min(lo, neg)
}
} else {
for si, s := range p.Series {
if !chartShown(p, si) {
continue
}
for _, v := range s.Data {
hi, lo = math.Max(hi, v), math.Min(lo, v)
}
}
}
if includeZero {
lo, hi = math.Min(lo, 0), math.Max(hi, 0)
}
loIn, hiIn := lo, hi
if p.YMin != nil {
loIn = *p.YMin
}
if p.YMax != nil {
hiIn = *p.YMax
}
mn, mx, ticks := niceScale(loIn, hiIn, 5)
if p.YMin != nil {
mn = *p.YMin
}
if p.YMax != nil {
mx = *p.YMax
}
return chartScale{mn, mx, ticks}
}
type chartBox struct{ left, top, right, bottom, plotW, plotH float64 }
func chartLayout(p ChartProps, w, h float64) chartBox {
d := chartDomain(p)
showAxes := !p.NoAxes
valTickW := maxf(1, float64(maxLen(fmtAll(p, d.ticks)))) * 7 * 1
catLabelW := maxf(1, float64(maxLen(chartLabels(p)))) * 7
valTickW = valTickW + 12
catLabelW = catLabelW + 12
var left, bottom float64
if chartHoriz(p) {
if showAxes {
left = math.Max(28, catLabelW)
} else {
left = 8
}
} else {
if showAxes {
left = math.Max(28, valTickW)
} else {
left = 8
}
}
if showAxes {
bottom = 28
} else {
bottom = 8
}
top := 12 + chartDY(p)
right := 12 + chartDX(p)
return chartBox{left, top, right, bottom,
math.Max(0, w-left-right), math.Max(0, h-top-bottom)}
}
// valuePos: pixel along the value axis (y for vertical, x for horizontal).
func valuePos(p ChartProps, w, h, v float64) float64 {
d := chartDomain(p)
l := chartLayout(p, w, h)
rng := d.max - d.min
if rng == 0 {
rng = 1
}
t := (v - d.min) / rng
if chartHoriz(p) {
return l.left + l.plotW*t
}
return l.top + l.plotH*(1-t)
}
func bandFull(p ChartProps, w, h float64) float64 {
l := chartLayout(p, w, h)
if chartHoriz(p) {
return l.plotH / maxf(1, float64(chartN(p)))
}
return l.plotW / maxf(1, float64(chartN(p)))
}
func catStart(p ChartProps, w, h float64) float64 {
l := chartLayout(p, w, h)
if chartHoriz(p) {
return l.top
}
return l.left
}
func catCenter(p ChartProps, w, h float64, i int) float64 {
return catStart(p, w, h) + bandFull(p, w, h)*(float64(i)+0.5)
}
func baseValue(p ChartProps, w, h float64) float64 {
d := chartDomain(p)
return valuePos(p, w, h, clampf(0, d.min, d.max))
}
type barMark struct {
x, y, w, h float64
side string // top|bottom|left|right — the rounded data-end
series int
cat int
value float64
round bool
}
func chartBars(p ChartProps, w, h float64) []barMark {
if p.Kind != ChartBar {
return nil
}
var out []barMark
count := chartN(p)
bf := bandFull(p, w, h)
base := baseValue(p, w, h)
hz := chartHoriz(p)
rect := func(off, thick, va, vb float64) (x, y, ww, hh float64) {
if hz {
return math.Min(va, vb), off, math.Abs(vb - va), thick
}
return off, math.Min(va, vb), thick, math.Abs(vb - va)
}
if p.Stacked {
thick := math.Min(chartBarMaxW, bf*0.72)
for i := 0; i < count; i++ {
off := catStart(p, w, h) + bf*float64(i) + (bf-thick)/2
lastPos, lastNeg := -1, -1
for s := range p.Series {
if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i)
if v > 0 {
lastPos = s
} else if v < 0 {
lastNeg = s
}
}
accPos, accNeg := 0.0, 0.0
for s := range p.Series {
if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i)
if v == 0 {
continue
}
var from float64
if v >= 0 {
from = accPos
} else {
from = accNeg
}
to := from + v
if v >= 0 {
accPos = to
} else {
accNeg = to
}
isEnd := (v > 0 && s == lastPos) || (v < 0 && s == lastNeg)
inset := chartSegGapPx(p)
if isEnd {
inset = 0
}
vFrom := valuePos(p, w, h, from)
vTo := valuePos(p, w, h, to)
if hz {
if v >= 0 {
vTo -= inset
} else {
vTo += inset
}
} else {
if v >= 0 {
vTo += inset
} else {
vTo -= inset
}
}
x, y, ww, hh := rect(off, thick, vFrom, vTo)
out = append(out, barMark{x, y, ww, hh, barSide(hz, v), s, i, v, isEnd})
}
}
} else {
// Grouped bars re-flow around hidden series: only shown ones take a slot, so the
// group re-centres rather than leaving a gap. Colour still keys off the real index.
var vis []int
for s := range p.Series {
if chartShown(p, s) {
vis = append(vis, s)
}
}
nS := maxi(1, len(vis))
gap := chartSegGapPx(p)
groupSize := math.Min(bf*0.72, (chartBarMaxW+gap)*float64(nS))
each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-gap))
for i := 0; i < count; i++ {
g := catStart(p, w, h) + bf*float64(i) + (bf-groupSize)/2
for j, s := range vis {
v := datum(p.Series[s], i)
off := g + float64(j)*(groupSize/float64(nS)) + (groupSize/float64(nS)-each)/2
x, y, ww, hh := rect(off, each, base, valuePos(p, w, h, v))
out = append(out, barMark{x, y, ww, hh, barSide(hz, v), s, i, v, true})
}
}
}
return out
}
func barSide(hz bool, v float64) string {
if hz {
if v >= 0 {
return "right"
}
return "left"
}
if v >= 0 {
return "top"
}
return "bottom"
}
type lineMark struct {
series int
line string
area string
pts [][2]float64
}
func chartPaths(p ChartProps, w, h float64) []lineMark {
if p.Kind != ChartLine && p.Kind != ChartArea {
return nil
}
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 {
if !chartShown(p, si) { // a hidden series draws nothing and doesn't lift the stack
continue
}
pts := make([][2]float64, 0, count)
lower := make([][2]float64, 0, count)
for i := 0; i < count; i++ {
v := datum(s, i)
yTop := v
yBot := 0.0
if p.Stacked {
yTop = stackAcc[i] + v
yBot = stackAcc[i]
}
pts = append(pts, [2]float64{catCenter(p, w, h, i), valuePos(p, w, h, yTop)})
lb := base
if p.Stacked {
lb = valuePos(p, w, h, yBot)
}
lower = append(lower, [2]float64{catCenter(p, w, h, i), lb})
if p.Stacked {
stackAcc[i] = yTop
}
}
line := linePathD(pts)
if p.Smooth {
line = smoothPathD(pts)
}
rev := make([][2]float64, len(lower))
for i := range lower {
rev[i] = lower[len(lower)-1-i]
}
area := line + " L" + strings.TrimPrefix(linePathD(rev), "M") + " Z"
out = append(out, lineMark{si, line, area, pts})
}
return out
}
// ── cartesian body ──────────────────────────────────────────────────────────────
func cartesianBody(p ChartProps, w, h float64, hv int) string {
l := chartLayout(p, w, h)
d := chartDomain(p)
hz := chartHoriz(p)
showAxes := !p.NoAxes
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 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 {
b.WriteString(`<line x1="` + num(l.left) + `" x2="` + num(l.left+l.plotW) + `" y1="` + num(vp) + `" y2="` + num(vp) + `" stroke="var(--color-line)" stroke-width="1"/>`)
}
}
if showAxes {
if hz {
b.WriteString(`<text x="` + num(vp) + `" y="` + num(h-8) + `" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">` + svgEsc(chartFmt(p, t)) + `</text>`)
} else {
b.WriteString(`<text x="` + num(l.left-8) + `" y="` + num(vp) + `" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px;font-variant-numeric:tabular-nums">` + svgEsc(chartFmt(p, t)) + `</text>`)
}
}
}
bv := baseValue(p, w, h)
if hz {
b.WriteString(`<line x1="` + num(bv) + `" x2="` + num(bv) + `" y1="` + num(l.top) + `" y2="` + num(l.top+l.plotH) + `" stroke="var(--color-line-strong)" stroke-width="1"/>`)
} else {
b.WriteString(`<line x1="` + num(l.left) + `" x2="` + num(l.left+l.plotW) + `" y1="` + num(bv) + `" y2="` + num(bv) + `" stroke="var(--color-line-strong)" stroke-width="1"/>`)
}
if showAxes {
for i, lab := range chartLabels(p) {
if hz {
b.WriteString(`<text x="` + num(l.left-8) + `" y="` + num(catCenter(p, w, h, i)) + `" text-anchor="end" dominant-baseline="middle" fill="var(--color-ink-faint)" style="font-size:11px">` + svgEsc(lab) + `</text>`)
} else {
b.WriteString(`<text x="` + num(catCenter(p, w, h, i)) + `" y="` + num(h-8) + `" text-anchor="middle" fill="var(--color-ink-faint)" style="font-size:11px">` + svgEsc(lab) + `</text>`)
}
}
}
isBar := p.Kind == ChartBar
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
for _, bm := range chartBars(p, w, h) {
op := 1.0
if hv >= 0 && hv != bm.cat {
op = 0.5
}
col := chartColor(p, bm.series)
if chart3D(p) {
b.WriteString(bar3D(bm.x, bm.y, bm.w, bm.h, svgEsc(col), op, dx, dy))
} else {
r := 0.0
if bm.round {
r = chartBarRadius
}
b.WriteString(`<path d="` + roundRectPath(bm.x, bm.y, bm.w, bm.h, r, bm.side) + `" fill="` + svgEsc(col) + `" fill-opacity="` + num(op) + `"/>`)
}
}
// areas then lines
paths := chartPaths(p, w, h)
for _, pth := range paths {
if p.Kind == ChartArea {
b.WriteString(`<path d="` + pth.area + `" fill="` + svgEsc(chartColor(p, pth.series)) + `" fill-opacity="0.1"/>`)
}
}
for _, pth := range paths {
b.WriteString(`<path d="` + pth.line + `" fill="none" stroke="` + svgEsc(chartColor(p, pth.series)) + `" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`)
}
// hover markers on line/area
if !p.NoTooltip && hv >= 0 && !isBar {
for _, pth := range paths {
if hv < len(pth.pts) {
pt := pth.pts[hv]
b.WriteString(`<circle cx="` + num(pt[0]) + `" cy="` + num(pt[1]) + `" r="` + num(chartMarkR) + `" fill="` + svgEsc(chartColor(p, pth.series)) + `" stroke="var(--color-surface)" stroke-width="2"/>`)
}
}
}
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) {
return ls[i]
}
return ""
}
// ── radial body ─────────────────────────────────────────────────────────────────
func radialTilt(p ChartProps) float64 {
if !p.ThreeD {
return 1 // no tilt: a flat, face-on disc
}
t := chartDefaultTilt
if p.Tilt != nil {
t = *p.Tilt
}
return clampf(t, 0, 1) // vertical squash of the disc when tilted
}
type radialGeo struct{ cx, cy, rOut, rIn, k float64 }
func radialGeom(p ChartProps, w, h float64) radialGeo {
k := radialTilt(p)
depth := 0.0
if p.ThreeD {
depth = chartDepth(p)
}
rOut := math.Max(0, math.Min(w, h-depth)/2-8)
cx := w / 2
cy := h/2 - depth/2
ratio := p.DonutRatio
if ratio == 0 {
ratio = 0.6
}
rIn := 0.0
if p.Kind == ChartDonut {
rIn = rOut * ratio
}
return radialGeo{cx, cy, rOut, rIn, k}
}
type slice struct {
idx int
a0, a1 float64
value float64
}
func radialSlices(p ChartProps) []slice {
vals := []float64(nil)
if len(p.Series) > 0 {
vals = p.Series[0].Data
}
total := 0.0
for i, v := range vals {
if v > 0 && chartShown(p, i) {
total += v
}
}
out := make([]slice, 0, len(vals))
a := 0.0
for i, v := range vals {
val := 0.0 // a hidden slice takes no arc
if chartShown(p, i) {
val = math.Max(0, v)
}
sweep := 0.0
if total > 0 && val > 0 {
sweep = val / total * 360
}
out = append(out, slice{i, a, a + sweep, val})
a += sweep
}
return out
}
func radialTotal(p ChartProps) float64 {
t := 0.0
if len(p.Series) > 0 {
for i, v := range p.Series[0].Data {
if v > 0 && chartShown(p, i) {
t += v
}
}
}
return t
}
func radialLabel(p ChartProps, i int) string {
if p.Labels != nil && i < len(p.Labels) {
return p.Labels[i]
}
return strconv.Itoa(i + 1)
}
func radialHit(p ChartProps, w, h, lx, ly float64) int {
g := radialGeom(p, w, h)
dx := lx - g.cx
dy := (ly - g.cy) / g.k
dist := math.Hypot(dx, dy)
if dist > g.rOut || (g.rIn > 0 && dist < g.rIn) {
return -1
}
deg := math.Mod(math.Atan2(dy, dx)*180/math.Pi+90+360, 360)
for _, s := range radialSlices(p) {
if s.value > 0 && deg >= s.a0 && deg < s.a1 {
return s.idx
}
}
return -1
}
func radialBody(p ChartProps, w, h float64, hv int) string {
g := radialGeom(p, w, h)
slices := radialSlices(p)
positive := 0
for _, s := range slices {
if s.value > 0 {
positive++
}
}
single := positive == 1
var b strings.Builder
// 3D: draw the extruded rim under every visible slice first.
if p.ThreeD {
list := slices
if single {
list = onlyPositive(slices)
}
for _, s := range list {
if s.a1 <= s.a0 {
continue
}
a0, a1 := s.a0, s.a1
if single {
a0, a1 = 90, 270
}
d := pieWall(g, a0, a1, chartDepth(p))
if d == "" {
continue
}
col := svgEsc(chartColor(p, s.idx))
b.WriteString(`<path d="` + d + `" fill="` + col + `"/><path d="` + d + `" fill="#000" fill-opacity="0.3"/>`)
}
}
if single {
s := onlyPositive(slices)[0]
b.WriteString(`<path d="` + slicePathD(g.cx, g.cy, g.rOut, 0, 0, 359.999, g.k) + `" fill="` + svgEsc(chartColor(p, s.idx)) + `"/>`)
if g.rIn > 0 {
b.WriteString(`<ellipse cx="` + num(g.cx) + `" cy="` + num(g.cy) + `" rx="` + num(g.rIn) + `" ry="` + num(g.k*g.rIn) + `" fill="var(--color-surface)"/>`)
}
} else {
for _, s := range slices {
if s.a1 <= s.a0 {
continue
}
op := 1.0
if hv >= 0 && hv != s.idx {
op = 0.55
}
b.WriteString(`<path d="` + slicePathD(g.cx, g.cy, g.rOut, g.rIn, s.a0, s.a1, g.k) + `" fill="` + svgEsc(chartColor(p, s.idx)) + `" fill-opacity="` + num(op) + `" stroke="var(--color-surface)" stroke-width="` + num(chartSegGapPx(p)) + `"/>`)
}
}
return b.String()
}
func onlyPositive(ss []slice) []slice {
var out []slice
for _, s := range ss {
if s.value > 0 {
out = append(out, s)
}
}
return out
}
// pieWall is the extruded rim under one slice: the front-facing part of its outer arc
// (90°270°, where the tilted edge dips below centre) swept down by depth.
func pieWall(g radialGeo, a0, a1, depth float64) string {
w0 := math.Max(a0, 90)
w1 := math.Min(a1, 270)
if w1 <= w0 {
return ""
}
x0, y0 := tiltPoint(g.cx, g.cy, g.rOut, w0, g.k)
x1, y1 := tiltPoint(g.cx, g.cy, g.rOut, w1, g.k)
large := "0"
if w1-w0 > 180 {
large = "1"
}
ry := g.k * g.rOut
return "M" + num(x0) + "," + num(y0) +
" A" + num(g.rOut) + "," + num(ry) + " 0 " + large + " 1 " + num(x1) + "," + num(y1) +
" L" + num(x1) + "," + num(y1+depth) +
" A" + num(g.rOut) + "," + num(ry) + " 0 " + large + " 0 " + num(x0) + "," + num(y0+depth) + " Z"
}
// ── legend (HTML, below the chart) ──────────────────────────────────────────────
// legend is a method (not a free function) because each key is a button that calls back
// into the controller to toggle its series/slice. A toggled-off key greys its swatch and
// strikes its label; the chart recomputes without it.
func (c *Chart) legend(p ChartProps) *vdom.VNode {
isRadial := radial(p.Kind)
isLine := p.Kind == ChartLine
type item struct {
name string
i int
}
var items []item
if isRadial {
n := 0
if len(p.Series) > 0 {
n = len(p.Series[0].Data)
}
labels := chartLabels(p)
for i := 0; i < n; i++ {
nm := strconv.Itoa(i + 1)
if i < len(labels) {
nm = labels[i]
}
items = append(items, item{nm, i})
}
} else {
for i, s := range p.Series {
items = append(items, item{s.Name, i})
}
}
swatchClass := "inline-block h-2.5 w-2.5 rounded-xs"
if isLine {
swatchClass = "inline-block h-0.5 w-4 rounded-full"
}
nodes := []*vdom.VNode{}
for _, it := range items {
it := it // capture per iteration for the click closure
off := !chartShown(p, it.i)
swatchStyle := "background-color:" + chartColor(p, it.i)
labelClass := "text-ss text-ink-soft"
if off {
swatchStyle += ";opacity:0.35"
labelClass = "text-ss text-ink-faint line-through"
}
nodes = append(nodes, vdom.El("button",
vdom.Attr("type", "button"),
vdom.Attr("class", "flex cursor-pointer select-none items-center gap-1.5"),
vdom.On(vdom.EVENT_CLICK, func() { c.toggle(it.i) }),
vdom.Span(vdom.Attr("class", swatchClass), vdom.Attr("style", swatchStyle)),
vdom.Span(vdom.Attr("class", labelClass), vdom.Text(it.name)),
))
}
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5")}, nodes)...)
}