// 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/vdom"
	"kjol/wasmruntime"
)

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
	ThreeD     bool // extrude bars, tilt pie/donut (ignored on line/area)
	Depth      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
)

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-xs 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
}
func chartDX(p ChartProps) float64 {
	if chart3D(p) {
		return chartDepth(p) * 0.7
	}
	return 0
}
func chartDY(p ChartProps) float64 {
	if chart3D(p) {
		return chartDepth(p) * 0.55
	}
	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 := chartSegGap
				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))
		groupSize := math.Min(bf*0.72, (chartBarMaxW+chartSegGap)*float64(nS))
		each := math.Max(1, math.Min(chartBarMaxW, groupSize/float64(nS)-chartSegGap))
		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)
	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

	for _, t := range d.ticks {
		vp := valuePos(p, w, h, t)
		if showGrid {
			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
	// crosshair (line/area only)
	if !p.NoTooltip && hv >= 0 && !isBar {
		c := catCenter(p, w, h, hv)
		b.WriteString(`<line x1="` + num(c) + `" x2="` + num(c) + `" y1="` + num(l.top) + `" y2="` + num(l.top+l.plotH) + `" stroke="var(--color-line-strong)" stroke-width="1"/>`)
	}

	// bars
	dx, dy := chartDX(p), chartDY(p)
	for _, bm := range chartBars(p, w, h) {
		op := 1.0
		if hv >= 0 && hv != bm.cat {
			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()
}

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 0.62
	}
	return 1
}

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(chartSegGap) + `"/>`)
		}
	}

	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-xs text-ink-soft"
		if off {
			swatchStyle += ";opacity:0.35"
			labelClass = "text-xs 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)...)
}
