diff --git a/timeserieschart.go b/timeserieschart.go index f66536a..14f9875 100644 --- a/timeserieschart.go +++ b/timeserieschart.go @@ -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. @@ -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) } @@ -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 { @@ -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. diff --git a/timeserieschart_test.go b/timeserieschart_test.go index a96f869..ec631c8 100644 --- a/timeserieschart_test.go +++ b/timeserieschart_test.go @@ -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) + } +}