// 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 's innerHTML). Pointer events // ride the 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 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 `` + body + `` } // 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(``) } else { b.WriteString(``) } } if showAxes { if hz { b.WriteString(`` + svgEsc(chartFmt(p, t)) + ``) } else { b.WriteString(`` + svgEsc(chartFmt(p, t)) + ``) } } } bv := baseValue(p, w, h) if hz { b.WriteString(``) } else { b.WriteString(``) } if showAxes { for i, lab := range chartLabels(p) { if hz { b.WriteString(`` + svgEsc(lab) + ``) } else { b.WriteString(`` + svgEsc(lab) + ``) } } } isBar := p.Kind == ChartBar // crosshair (line/area only) if !p.NoTooltip && hv >= 0 && !isBar { c := catCenter(p, w, h, hv) b.WriteString(``) } // bars dx, dy := chartDX(p), chartDY(p) for _, bm := range chartBars(p, w, h) { op := 1.0 if hv >= 0 && hv != bm.cat { 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(``) } } // areas then lines paths := chartPaths(p, w, h) for _, pth := range paths { if p.Kind == ChartArea { b.WriteString(``) } } for _, pth := range paths { b.WriteString(``) } // 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(``) } } } 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(``) } } if single { s := onlyPositive(slices)[0] b.WriteString(``) if g.rIn > 0 { b.WriteString(``) } } 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(``) } } 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)...) }