From 03cbed3e24c75f6af1d6ec4737c196bf54000363 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 6 Sep 2026 10:45:56 +0200 Subject: [PATCH] toolkit: a chart's series can be bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host had to assign a field and hope something repainted. That is not a binding, and it is why a queue window watching a live rate had to poke the chart on a timer. The reason none of them had one is worth writing down: mvvm.Observable is constrained to COMPARABLE types, so it can skip a notification when nothing changed — and a slice is not comparable. The vehicle for a series is ObservableList, which also says what changed rather than only that something did. TimeSeriesChart.Series and LineChart.Values are that list, created on first use and seeded from the existing field. From then on the LIST is what the chart draws: two sources for one truth is how a chart comes to show last minute's data. A chart nobody binds keeps working exactly as it did, which is what the field is still for. Co-Authored-By: Claude Opus 5 --- linechart.go | 57 +++++++++++++++++++++++++++++++---------- linechart_test.go | 24 +++++++++++++++++ timeserieschart.go | 45 ++++++++++++++++++++++++++++---- timeserieschart_test.go | 49 +++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 19 deletions(-) diff --git a/linechart.go b/linechart.go index 7c9abaa..6eac52b 100644 --- a/linechart.go +++ b/linechart.go @@ -26,7 +26,9 @@ type LineChart struct { // a vertical rule at data point HoverIndex and a marker where it meets the // curve. A host sets these from ValueAt on pointer motion; the zero value // (Hover == false) draws no crosshair, so existing renders are unchanged. - hover *mvvm.Observable[bool] + hover *mvvm.Observable[bool] + // values is the bindable form of Series, created by Values(). + values *mvvm.ObservableList[float64] hoverIndex *mvvm.Observable[int] } @@ -37,6 +39,33 @@ const ChartPad = 6 // NewLineChart builds a LineChart over the given series with auto Y bounds. func NewLineChart(series []float64) *LineChart { return &LineChart{Series: series} } +// Values is the curve as a shared [mvvm.ObservableList], so a host binds its +// model to the chart instead of assigning a field and hoping something +// repaints. +// +// A LIST, not an Observable: mvvm.Observable is constrained to comparable +// types so it can skip a notification when nothing changed, and a slice is not +// comparable. That is why no chart here had a bindable series at all. +// +// Created on first use, seeded from [LineChart.Series] -- and from then on IT +// is what the chart draws, because two sources for one truth is how a chart +// comes to show last minute's data. +func (c *LineChart) Values() *mvvm.ObservableList[float64] { + if c.values == nil { + c.values = mvvm.NewObservableList(c.Series...) + } + return c.values +} + +// series is what the chart draws: the list once somebody has taken it, the +// field until then. +func (c *LineChart) series() []float64 { + if c.values != nil { + return c.values.Slice() + } + return c.Series +} + // Hover is the reactive hover-highlight toggle as a shared [mvvm.Observable]; // false draws no hover affordance. Lazily created, defaulting to off. func (c *LineChart) Hover() *mvvm.Observable[bool] { @@ -62,11 +91,11 @@ func (c *LineChart) yRange() (float64, float64) { if c.Max > c.Min { return c.Min, c.Max } - if len(c.Series) == 0 { + if len(c.series()) == 0 { return 0, 1 } - mn, mx := c.Series[0], c.Series[0] - for _, v := range c.Series[1:] { + mn, mx := c.series()[0], c.series()[0] + for _, v := range c.series()[1:] { if v < mn { mn = v } @@ -90,31 +119,31 @@ func (c *LineChart) plot() Rect { // index and value (ok=false only for an empty series). Exposed so a host can // show the underlying value on hover. func (c *LineChart) ValueAt(localX int) (index int, value float64, ok bool) { - n := len(c.Series) + n := len(c.series()) if n == 0 { return 0, 0, false } if n == 1 { - return 0, c.Series[0], true + return 0, c.series()[0], true } span := c.plot().W - 1 if span < 1 { - return 0, c.Series[0], true + return 0, c.series()[0], true } rel := localX - scaled(ChartPad) idx := clampInt((2*rel*(n-1)+span)/(2*span), 0, n-1) // nearest index - return idx, c.Series[idx], true + return idx, c.series()[idx], true } // pointAt maps series index i to a pixel in the plot area. func (c *LineChart) pointAt(i int, mn, mx float64) (int, int) { pl := c.plot() - n := len(c.Series) + n := len(c.series()) x := pl.X if n > 1 { x = pl.X + i*(pl.W-1)/(n-1) } - frac := (c.Series[i] - mn) / (mx - mn) + frac := (c.series()[i] - mn) / (mx - mn) y := pl.Y + int((1-frac)*float64(pl.H-1)) return x, y } @@ -126,16 +155,16 @@ func (c *LineChart) Draw(p painter.Painter, theme *Theme) { // L-shaped axes: left rule + bottom rule. drawLine(p, pl.X, r.Y, pl.X, pl.Y+pl.H-1, theme.Border) drawLine(p, pl.X, pl.Y+pl.H-1, r.X+r.W-1, pl.Y+pl.H-1, theme.Border) - if len(c.Series) == 0 { + if len(c.series()) == 0 { return } mn, mx := c.yRange() - if len(c.Series) == 1 { + if len(c.series()) == 1 { x, y := c.pointAt(0, mn, mx) fillRect(p, x, y, 2, 2, theme.Accent) } else { px, py := c.pointAt(0, mn, mx) - for i := 1; i < len(c.Series); i++ { + for i := 1; i < len(c.series()); i++ { x, y := c.pointAt(i, mn, mx) drawLine(p, px, py, x, y, theme.Accent) px, py = x, y @@ -148,7 +177,7 @@ func (c *LineChart) Draw(p painter.Painter, theme *Theme) { // HoverIndex plus a marker where it meets the curve — when Hover is set and // HoverIndex is in range. The marker is clamped inside Bounds. func (c *LineChart) drawHover(p painter.Painter, theme *Theme, mn, mx float64) { - if !c.Hover().Get() || c.HoverIndex().Get() < 0 || c.HoverIndex().Get() >= len(c.Series) { + if !c.Hover().Get() || c.HoverIndex().Get() < 0 || c.HoverIndex().Get() >= len(c.series()) { return } r, pl := c.Bounds(), c.plot() diff --git a/linechart_test.go b/linechart_test.go index ce3e858..cae1d20 100644 --- a/linechart_test.go +++ b/linechart_test.go @@ -109,3 +109,27 @@ func TestDrawLineDiagonalAndSteep(t *testing.T) { t.Errorf("zero-length line pixel = %+v", got) } } + +// TestALineChartsValuesCanBeBound is the same binding on the plainer chart: +// the field until somebody takes the list, the list from then on. +func TestALineChartsValuesCanBeBound(t *testing.T) { + c := NewLineChart([]float64{1, 2, 3}) + if got := len(c.series()); got != 3 { + t.Fatalf("the unbound chart draws %d values", got) + } + v := c.Values() + if v.Len() != 3 { + t.Fatalf("the list came back with %d values", v.Len()) + } + v.Append(4) + if got := len(c.series()); got != 4 { + t.Errorf("after appending, the chart draws %d values", got) + } + c.Series = nil + if got := len(c.series()); got != 4 { + t.Errorf("clearing the field changed the bound chart to %d values", got) + } + if c.Values() != v { + t.Error("Values() handed out a second list") + } +} diff --git a/timeserieschart.go b/timeserieschart.go index 63ddbf2..38742be 100644 --- a/timeserieschart.go +++ b/timeserieschart.go @@ -6,6 +6,7 @@ package toolkit import ( "fmt" + "github.com/go-widgets/mvvm" "time" "github.com/go-widgets/painter" @@ -67,6 +68,9 @@ type TimeSeriesChart struct { // whenever someone happens to open it, not only the day a point was // recorded, so a label missing its date is just a clock. FormatTime func(int64) string + + // series is the bindable form of Points, created by Series(). + series *mvvm.ObservableList[TimePoint] } // NewTimeSeriesChart builds a TimeSeriesChart over points (already in @@ -75,6 +79,36 @@ func NewTimeSeriesChart(points []TimePoint, min, max float64) *TimeSeriesChart { return &TimeSeriesChart{Points: points, Min: min, Max: max} } +// Series is the chart's points as a shared [mvvm.ObservableList], so a host +// binds its model to the chart instead of assigning a field and hoping +// something repaints. +// +// A LIST, not an Observable: mvvm.Observable is constrained to comparable +// types so it can skip a notification when nothing changed, and a slice is not +// comparable. That constraint is why no chart here had a bindable series at +// all -- the vehicle for one is ObservableList, which also says WHAT changed +// rather than only that something did. +// +// It is created on first use, seeded from [TimeSeriesChart.Points] -- and from +// then on IT is what the chart draws. Two sources for one truth is how a chart +// comes to show last minute's data; the field stays as the way to give initial +// points to a chart nobody binds, and taking the list settles which one wins. +func (c *TimeSeriesChart) Series() *mvvm.ObservableList[TimePoint] { + if c.series == nil { + c.series = mvvm.NewObservableList(c.Points...) + } + return c.series +} + +// points is what the chart draws: the observable once somebody has taken it, +// the field until then. +func (c *TimeSeriesChart) points() []TimePoint { + if c.series != nil { + return c.series.Slice() + } + return c.Points +} + func (c *TimeSeriesChart) formatValue(v float64) string { if c.FormatValue != nil { return c.FormatValue(v) @@ -131,10 +165,11 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { c.drawText(p, tx, y-gh/2, text, label) } - if len(c.Points) < 2 { + if len(c.points()) < 2 { return } - first, last := c.Points[0], c.Points[len(c.Points)-1] + pts := c.points() + first, last := pts[0], pts[len(pts)-1] span := last.At - first.At if span <= 0 { return @@ -156,8 +191,8 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { y := pl.Y + int((1-vf)*float64(pl.H-1)) return x, y } - px, py := pointAt(c.Points[0]) - for _, pt := range c.Points[1:] { + px, py := pointAt(pts[0]) + for _, pt := range pts[1:] { x, y := pointAt(pt) drawLine(p, px, py, x, y, ink) px, py = x, y @@ -175,7 +210,7 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { // use, since a full time-value series has no single "current" reading to // report the way a Gauge's RoleMeter does. func (c *TimeSeriesChart) A11y() A11yInfo { - return A11yInfo{Role: RoleImg, Value: fmt.Sprintf("%d points", len(c.Points))} + return A11yInfo{Role: RoleImg, Value: fmt.Sprintf("%d points", len(c.points()))} } var _ Accessible = (*TimeSeriesChart)(nil) diff --git a/timeserieschart_test.go b/timeserieschart_test.go index a442854..0caeff1 100644 --- a/timeserieschart_test.go +++ b/timeserieschart_test.go @@ -4,6 +4,8 @@ package toolkit +import "github.com/go-widgets/mvvm" + import "testing" func TestTimeSeriesChartEmptyDrawsAxesOnly(t *testing.T) { @@ -217,3 +219,50 @@ func TestTimeSeriesChartZeroBoundsDoesNotPanic(t *testing.T) { surf := makeSurface(120, 60) c.Draw(newP(surf, 120), DefaultLight()) // must not panic } + +// TestAChartsSeriesCanBeBound covers the binding these charts did not have. +// +// A host had to assign a field and hope something repainted. mvvm.Observable is +// constrained to comparable types so it can skip a notification when nothing +// changed, and a slice is not comparable — which is why no chart here had a +// bindable series at all. ObservableList is the vehicle, and it says WHAT +// changed rather than only that something did. +func TestAChartsSeriesCanBeBound(t *testing.T) { + c := NewTimeSeriesChart([]TimePoint{{At: 1, Value: 10}}, 0, 100) + + // Until somebody takes the list, the field is what the chart draws: a + // chart nobody binds keeps working exactly as it did. + if got := len(c.points()); got != 1 { + t.Fatalf("the unbound chart draws %d points", got) + } + + // Taking it seeds from the field, so nothing is lost at the moment of + // binding. + list := c.Series() + if list.Len() != 1 || list.At(0).Value != 10 { + t.Fatalf("the list came back as %+v", list.Slice()) + } + // And from then on the LIST is the truth. Two sources for one truth is how + // a chart comes to show last minute's data. + list.Append(TimePoint{At: 2, Value: 20}) + if got := len(c.points()); got != 2 { + t.Errorf("after appending, the chart draws %d points", got) + } + c.Points = nil + if got := len(c.points()); got != 2 { + t.Errorf("clearing the field changed the bound chart to %d points", got) + } + + // A subscriber hears about it, which is the whole reason for binding: the + // host repaints because the model changed, not because it polled. + heard := 0 + list.Subscribe(func(mvvm.ListEvent[TimePoint]) { heard++ }) + list.Append(TimePoint{At: 3, Value: 30}) + if heard == 0 { + t.Error("appending to the series told nobody") + } + // The same list every time, or two callers would bind to two charts. + if c.Series() != list { + t.Error("Series() handed out a second list") + } +}