1126 lines
32 KiB
Go
1126 lines
32 KiB
Go
// 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 pie/donut squash scalar: 0 → full tilt (edge-on), 1 → flat. A pointer so
|
||
// an explicit 0 is distinct from unset; nil defaults to 0.85.
|
||
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)
|
||
)
|
||
|
||
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
|
||
}
|
||
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 := 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)
|
||
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 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)...)
|
||
}
|