Update charts to include titles and legends
This commit is contained in:
@@ -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)...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user