Update charts to include titles and legends

This commit is contained in:
2026-07-16 16:26:06 -04:00
parent 81f0c0624e
commit 530fdf6f75
4 changed files with 203 additions and 83 deletions

View File

@@ -62,6 +62,7 @@ type ChartProps struct {
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
@@ -71,8 +72,15 @@ type ChartProps struct {
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
@@ -100,7 +108,8 @@ var chartTokens = []string{
// }
type Chart struct {
hover *vdom.Signal[int]
width *vdom.Signal[float64] // measured container width; 0 until the first measure
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
@@ -111,7 +120,24 @@ type Chart struct {
// 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()}
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 —
@@ -165,6 +191,7 @@ func (c *Chart) onMounted() {
}
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
@@ -196,11 +223,18 @@ func (c *Chart) Render(p ChartProps) *vdom.VNode {
)
}
children := []*vdom.VNode{vdom.Svg(mods...), c.tooltipNode(p, hv)}
if chartShowLegend(p) {
children = append(children, chartLegend(p))
// 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)))
}
return vdom.Div(kids([]vdom.Mod{vdom.WithRef(c.wrapRef), vdom.Attr("class", cx("relative w-full", p.Class))}, children)...)
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() {
@@ -297,6 +331,9 @@ func swatchSpan(color string) *vdom.VNode {
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)),
@@ -427,8 +464,11 @@ func chartDomain(p ChartProps) chartScale {
if p.Stacked {
for i := 0; i < count; i++ {
pos, neg := 0.0, 0.0
for _, s := range p.Series {
v := datum(s, i)
for s := range p.Series {
if !chartShown(p, s) {
continue
}
v := datum(p.Series[s], i)
if v >= 0 {
pos += v
} else {
@@ -438,7 +478,10 @@ func chartDomain(p ChartProps) chartScale {
hi, lo = math.Max(hi, pos), math.Min(lo, neg)
}
} else {
for _, s := range p.Series {
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)
}
@@ -564,6 +607,9 @@ func chartBars(p ChartProps, w, h float64) []barMark {
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
@@ -573,6 +619,9 @@ func chartBars(p ChartProps, w, h float64) []barMark {
}
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
@@ -614,17 +663,22 @@ func chartBars(p ChartProps, w, h float64) []barMark {
}
}
} else {
nS := maxi(1, len(p.Series))
// 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 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
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})
}
@@ -662,6 +716,9 @@ func chartPaths(p ChartProps, w, h float64) []lineMark {
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++ {
@@ -842,19 +899,23 @@ func radialSlices(p ChartProps) []slice {
vals = p.Series[0].Data
}
total := 0.0
for _, v := range vals {
if v > 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 {
sweep := 0.0
if total > 0 && v > 0 {
sweep = v / total * 360
val := 0.0 // a hidden slice takes no arc
if chartShown(p, i) {
val = math.Max(0, v)
}
out = append(out, slice{i, a, a + sweep, 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
@@ -863,8 +924,8 @@ func radialSlices(p ChartProps) []slice {
func radialTotal(p ChartProps) float64 {
t := 0.0
if len(p.Series) > 0 {
for _, v := range p.Series[0].Data {
if v > 0 {
for i, v := range p.Series[0].Data {
if v > 0 && chartShown(p, i) {
t += v
}
}
@@ -986,7 +1047,10 @@ func pieWall(g radialGeo, a0, a1, depth float64) string {
// ── legend (HTML, below the chart) ──────────────────────────────────────────────
func chartLegend(p ChartProps) *vdom.VNode {
// 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 {
@@ -1013,18 +1077,27 @@ func chartLegend(p ChartProps) *vdom.VNode {
}
}
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 {
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)))
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.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))))
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)...)
}