diff --git a/timeserieschart.go b/timeserieschart.go index 38742be..434c98e 100644 --- a/timeserieschart.go +++ b/timeserieschart.go @@ -69,8 +69,22 @@ type TimeSeriesChart struct { // recorded, so a label missing its date is just a clock. FormatTime func(int64) string + // FollowPeak makes the chart choose its own Max from the data instead of + // being told one: it rises to a new peak AT ONCE and comes back down + // slowly. Min is left alone. + // + // A fixed ceiling is wrong in both directions on live data. Too low and a + // burst is drawn off the top, where nobody can measure it; too high and + // everything else is a flat line along the bottom. And a ceiling that + // simply tracked the peak would rescale the whole picture every time a + // spike scrolled off the left -- a graph whose axis keeps moving cannot be + // read at all. + FollowPeak bool + // series is the bindable form of Points, created by Series(). series *mvvm.ObservableList[TimePoint] + // ceiling is where FollowPeak has the scale now. + ceiling float64 } // NewTimeSeriesChart builds a TimeSeriesChart over points (already in @@ -145,7 +159,62 @@ func (c *TimeSeriesChart) valueAxisWidth() int { // Draw paints the value axis (gridlines + labels), the time axis (start // + end labels, only when there's a span to label), and the polyline. +// followPeak moves the scale towards what the data now needs, and reports the +// ceiling to draw against. +func (c *TimeSeriesChart) followPeak() { + if !c.FollowPeak { + return + } + high := 0.0 + for _, pt := range c.points() { + if pt.Value > high { + high = pt.Value + } + } + want := NiceCeiling(high) + switch { + case c.ceiling <= 0 || want >= c.ceiling: + c.ceiling = want + default: + // A fifth of the distance each draw, so a quiet minute lowers the + // scale and a single quiet sample does not. + next := c.ceiling - (c.ceiling-want)/5 + // Approaching by fifths never arrives: the gap halves for ever and the + // axis would read 1.0000000000000004 rather than 1. Close enough is + // there. + if next <= want*1.001 { + next = want + } + c.ceiling = next + } + c.Max = c.ceiling +} + +// NiceCeiling rounds a value up to a power of two, or to a half or three +// quarters of one: 1, 1.5, 2, 3, 4, 6, 8 and so on. +// +// Those are the gradations a quantity is spoken in -- half a mebibyte, three +// quarters of a gibibyte -- so an axis reads as a number rather than as +// whatever the peak happened to be, and two charts sharing a scale land on the +// same marks. +func NiceCeiling(v float64) float64 { + if v <= 0 { + return 1 + } + step := 1.0 + for step < v { + step *= 2 + } + for _, f := range []float64{0.5, 0.75} { + if c := step * f; c >= v { + return c + } + } + return step +} + func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { + c.followPeak() r := c.Bounds() if r.W <= 0 || r.H <= 0 { return diff --git a/timeserieschart_test.go b/timeserieschart_test.go index 0caeff1..fa2465b 100644 --- a/timeserieschart_test.go +++ b/timeserieschart_test.go @@ -266,3 +266,66 @@ func TestAChartsSeriesCanBeBound(t *testing.T) { t.Error("Series() handed out a second list") } } + +// TestAChartCanFollowItsOwnPeak covers an axis on live data. +// +// A fixed ceiling is wrong in both directions: too low and a burst is drawn off +// the top where nobody can measure it, too high and everything else is a flat +// line along the bottom. And one that simply tracked the peak would rescale the +// whole picture every time a spike scrolled off the left — a graph whose axis +// keeps moving cannot be read at all. +func TestAChartCanFollowItsOwnPeak(t *testing.T) { + c := NewTimeSeriesChart(nil, 0, 1) + c.FollowPeak = true + + // Up at once, so a burst is never drawn off the top. + c.Points = []TimePoint{{At: 1, Value: 3}} + c.followPeak() + if c.Max < 3 { + t.Fatalf("a peak of 3 left the ceiling at %v", c.Max) + } + high := c.Max + + // Down slowly: one quiet draw must not rescale the picture. + c.Points = []TimePoint{{At: 2, Value: 0}} + c.followPeak() + if c.Max >= high { + t.Errorf("the ceiling did not come down at all: %v", c.Max) + } + if c.Max <= NiceCeiling(0) { + t.Errorf("the ceiling fell all the way in one draw: %v", c.Max) + } + // And it arrives, given enough quiet draws. + for i := 0; i < 200; i++ { + c.followPeak() + } + if c.Max != NiceCeiling(0) { + t.Errorf("after two hundred quiet draws the ceiling is %v", c.Max) + } + + // A chart nobody asked to follow anything keeps the bounds it was given: + // this is opt-in, and an existing chart is unchanged. + fixed := NewTimeSeriesChart([]TimePoint{{At: 1, Value: 900}}, 0, 100) + fixed.followPeak() + if fixed.Max != 100 { + t.Errorf("a fixed chart rescaled itself to %v", fixed.Max) + } +} + +// TestNiceCeilingSpeaksInRoundNumbers covers the gradations an axis is read in. +func TestNiceCeilingSpeaksInRoundNumbers(t *testing.T) { + for _, tc := range []struct{ in, want float64 }{ + {0, 1}, {-5, 1}, {1, 1}, {3, 3}, {5, 6}, {9, 12}, {13, 16}, + } { + if got := NiceCeiling(tc.in); got != tc.want { + t.Errorf("NiceCeiling(%v) = %v, want %v", tc.in, got, tc.want) + } + } + // Never below the data: a ceiling under the peak draws a curve out of its + // own chart. + for _, v := range []float64{0.1, 7, 1000, 1 << 30} { + if got := NiceCeiling(v); got < v { + t.Errorf("NiceCeiling(%v) = %v, below the data", v, got) + } + } +}