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") + } +}