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
58 changes: 44 additions & 14 deletions timeserieschart.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ type TimeSeriesChart struct {
// plain reference line either way, and Points stays in Ink.
OverInk RGBA

// TimeMin, TimeMax optionally pin the displayed time axis span,
// independent of Points' own extent — e.g. always showing a full
// 7-day window from the very first render, rather than a span that
// only ever covers however much data happens to have accumulated so
// far (a chart backed by a day-old history otherwise looks like a
// one-day chart, not a mostly-empty week). Ignored unless
// TimeMax > TimeMin; the zero value for both (the default)
// auto-derives the range from Points[0].At and the last point's At,
// exactly as before this field existed.
TimeMin, TimeMax int64

// series is the bindable form of Points, created by Series().
series *mvvm.ObservableList[TimePoint]
// ceiling is where FollowPeak has the scale now.
Expand Down Expand Up @@ -251,18 +262,17 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) {
c.drawText(p, tx, y-gh/2, text, label)
}

if len(c.points()) < 2 {
rangeStart, rangeEnd, ok := c.timeRange()
if !ok {
return
}
pts := c.points()
first, last := pts[0], pts[len(pts)-1]
span := last.At - first.At
span := rangeEnd - rangeStart
if span <= 0 {
return
}

for _, at := range verticalGridTicks(first.At, last.At) {
x := pl.X + int(float64(at-first.At)/float64(span)*float64(pl.W-1))
for _, at := range verticalGridTicks(rangeStart, rangeEnd) {
x := pl.X + int(float64(at-rangeStart)/float64(span)*float64(pl.W-1))
drawLine(p, x, pl.Y, x, pl.Y+pl.H-1, theme.Border)
}

Expand All @@ -272,7 +282,7 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) {
}
valueSpan := c.Max - c.Min
pointAt := func(t TimePoint) (int, int) {
frac := min(1, max(0, float64(t.At-first.At)/float64(span)))
frac := min(1, max(0, float64(t.At-rangeStart)/float64(span)))
x := pl.X + int(frac*float64(pl.W-1))
vf := 0.0
if valueSpan != 0 {
Expand All @@ -292,20 +302,40 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) {
}
}

px, py := pointAt(pts[0])
for _, pt := range pts[1:] {
x, y := pointAt(pt)
drawCurveLine(p, px, py, x, y, c.segmentInk(pt, ink))
px, py = x, y
if pts := c.points(); len(pts) >= 2 {
px, py := pointAt(pts[0])
for _, pt := range pts[1:] {
x, y := pointAt(pt)
drawCurveLine(p, px, py, x, y, c.segmentInk(pt, ink))
px, py = x, y
}
}

ty := pl.Y + pl.H - 1 + scaled(TimeSeriesChartPad)
startText := c.formatTime(first.At)
endText := c.formatTime(last.At)
startText := c.formatTime(rangeStart)
endText := c.formatTime(rangeEnd)
c.drawText(p, pl.X, ty, startText, label)
c.drawText(p, pl.X+pl.W-c.textWidth(endText), ty, endText, label)
}

// timeRange is the time axis span Draw plots against: the explicit
// TimeMin..TimeMax when TimeMax > TimeMin, so a caller can show a full
// window (a week, say) from the start even when only a few hours of
// real data exist yet. Otherwise (the default) auto-derives the range
// from Points' own first and last At, exactly as before TimeMin/TimeMax
// existed. false only when there is no data AND no explicit range to
// fall back on.
func (c *TimeSeriesChart) timeRange() (start, end int64, ok bool) {
if c.TimeMax > c.TimeMin {
return c.TimeMin, c.TimeMax, true
}
pts := c.points()
if len(pts) == 0 {
return 0, 0, false
}
return pts[0].At, pts[len(pts)-1].At, true
}

// thresholdAt returns the Threshold value in effect at at: the last
// Threshold sample at or before at, or the first sample if at precedes
// every one of them. false only when Threshold is empty.
Expand Down
65 changes: 65 additions & 0 deletions timeserieschart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,3 +677,68 @@ func TestDrawDashedLineDrawsFewerPixelsThanASolidLine(t *testing.T) {
t.Errorf("dashed line painted %d of 100 columns, solid painted %d — expected dashed to cover fewer (gaps)", dashedCols, solidCols)
}
}

func TestTimeRange(t *testing.T) {
c := &TimeSeriesChart{Points: []TimePoint{{At: 100, Value: 1}, {At: 200, Value: 2}}}
if start, end, ok := c.timeRange(); !ok || start != 100 || end != 200 {
t.Errorf("auto-derived from Points: got (%d, %d, %v), want (100, 200, true)", start, end, ok)
}

c.TimeMin, c.TimeMax = 0, 1000
if start, end, ok := c.timeRange(); !ok || start != 0 || end != 1000 {
t.Errorf("pinned range overrides Points: got (%d, %d, %v), want (0, 1000, true)", start, end, ok)
}

empty := &TimeSeriesChart{}
if _, _, ok := empty.timeRange(); ok {
t.Error("no Points and no pinned range: ok = true, want false")
}
}

// TestTimeSeriesChartPinnedRangeShowsFullWindowEvenWithSparseData is the
// actual load-bearing proof: a single real point sitting near the END
// of a much wider pinned range must still be plotted against — and
// axis-labeled with — the FULL pinned window, not a span shrunk to
// that one point's own narrow extent (a day-old history otherwise
// looks like a one-day chart, not a mostly-empty week).
func TestTimeSeriesChartPinnedRangeShowsFullWindowEvenWithSparseData(t *testing.T) {
c := NewTimeSeriesChart([]TimePoint{{At: 6*24*3600 + 1000, Value: 50}}, 0, 100)
c.TimeMin, c.TimeMax = 0, 7*24*3600
var calls []int64
c.FormatTime = func(at int64) string {
calls = append(calls, at)
return "x"
}
c.SetBounds(Rect{X: 0, Y: 0, W: 200, H: 80})
surf := makeSurface(200, 80)
c.Draw(newP(surf, 200), DefaultLight())

if len(calls) != 2 {
t.Fatalf("FormatTime called %d times, want 2 (start, end)", len(calls))
}
if calls[0] != c.TimeMin || calls[1] != c.TimeMax {
t.Errorf("axis end labels used (%d, %d), want the pinned range (%d, %d)", calls[0], calls[1], c.TimeMin, c.TimeMax)
}
}

// TestTimeSeriesChartPinnedRangeWithNoPointsStillDrawsScaffolding proves
// a pinned range draws its vertical gridlines even before any real data
// exists at all — the "empty week" scaffolding a dashboard wants from
// the very first render.
func TestTimeSeriesChartPinnedRangeWithNoPointsStillDrawsScaffolding(t *testing.T) {
empty := NewTimeSeriesChart(nil, 0, 100)
pinned := NewTimeSeriesChart(nil, 0, 100)
pinned.TimeMin, pinned.TimeMax = 0, 7*24*3600

empty.SetBounds(Rect{X: 0, Y: 0, W: 200, H: 80})
pinned.SetBounds(Rect{X: 0, Y: 0, W: 200, H: 80})
s1, s2 := makeSurface(200, 80), makeSurface(200, 80)
empty.Draw(newP(s1, 200), DefaultLight())
pinned.Draw(newP(s2, 200), DefaultLight())

base := countInk(s1, 200, 80, DefaultLight().Border)
got := countInk(s2, 200, 80, DefaultLight().Border)
if got <= base {
t.Errorf("a pinned range with zero points painted %d Border pixels, want more than the truly-empty baseline (%d) — vertical gridlines should still show the full window", got, base)
}
}