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
69 changes: 69 additions & 0 deletions timeserieschart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions timeserieschart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}