Files
kjol/go/webui/chart.go

1031 lines
28 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/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
Palette []string
ValueFormat func(float64) string
NoGrid bool
NoAxes bool
NoTooltip bool
YMin *float64
YMax *float64
}
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]
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), width: vdom.NewSignal(0.0), svgRef: vdom.NewRef(), wrapRef: vdom.NewRef(), tipRef: vdom.NewRef()}
}
// 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 {
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),
)
}
children := []*vdom.VNode{vdom.Svg(mods...), c.tooltipNode(p, hv)}
if chartShowLegend(p) {
children = append(children, chartLegend(p))
}
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.wrapRef), vdom.Attr("class", cx("relative 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 {
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 {
v := datum(s, i)
if v >= 0 {
pos += v
} else {
neg += v
}
}
hi, lo = math.Max(hi, pos), math.Min(lo, neg)
}
} else {
for _, s := range p.Series {
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 {
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 {
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 {
nS := maxi(1, len(p.Series))
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 s := 0; s < nS; s++ {
v := 0.0
if s < len(p.Series) {
v = datum(p.Series[s], i)
}
off := g + float64(s)*(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 {
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 _, v := range vals {
if v > 0 {
total += v
}
}
out := make([]slice, 0, len(vals))
a := 0.0
for i, v := range vals {
sweep := 0.0
if total > 0 && v > 0 {
sweep = v / total * 360
}
out = append(out, slice{i, a, a + sweep, math.Max(0, v)})
a += sweep
}
return out
}
func radialTotal(p ChartProps) float64 {
t := 0.0
if len(p.Series) > 0 {
for _, v := range p.Series[0].Data {
if v > 0 {
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) ──────────────────────────────────────────────
func chartLegend(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})
}
}
nodes := []*vdom.VNode{}
for _, it := range items {
var key *vdom.VNode
if isLine {
key = vdom.Span(vdom.Attr("class", "inline-block h-0.5 w-4 rounded-full"),
vdom.Attr("style", "background-color:"+chartColor(p, it.i)))
} else {
key = vdom.Span(vdom.Attr("class", "inline-block h-2.5 w-2.5 rounded-xs"),
vdom.Attr("style", "background-color:"+chartColor(p, it.i)))
}
nodes = append(nodes, vdom.Div(vdom.Attr("class", "flex items-center gap-1.5"),
key, vdom.Span(vdom.Attr("class", "text-xs text-ink-soft"), 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)...)
}