Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 43 additions & 14 deletions linechart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

Expand All @@ -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] {
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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()
Expand Down
24 changes: 24 additions & 0 deletions linechart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
45 changes: 40 additions & 5 deletions timeserieschart.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package toolkit

import (
"fmt"
"github.com/go-widgets/mvvm"
"time"

"github.com/go-widgets/painter"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
49 changes: 49 additions & 0 deletions timeserieschart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

package toolkit

import "github.com/go-widgets/mvvm"

import "testing"

func TestTimeSeriesChartEmptyDrawsAxesOnly(t *testing.T) {
Expand Down Expand Up @@ -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")
}
}