diff --git a/timeserieschart.go b/timeserieschart.go index 434c98e..967a416 100644 --- a/timeserieschart.go +++ b/timeserieschart.go @@ -6,9 +6,10 @@ package toolkit import ( "fmt" - "github.com/go-widgets/mvvm" + "math" "time" + "github.com/go-widgets/mvvm" "github.com/go-widgets/painter" ) @@ -81,6 +82,22 @@ type TimeSeriesChart struct { // read at all. FollowPeak bool + // Threshold optionally overlays a second reference line at the same + // time/value scale as Points — a ceiling a caller wants Points to + // stay under (a sustainable pace, a budget, a capacity limit). It + // need not share Points' own timestamps: the value in effect at any + // time t is Threshold's last sample at or before t (or its first + // sample, if t precedes every one of them) — a step function, not + // interpolation. Drawn as a dashed line in theme.Border; nil draws + // nothing extra. Fewer than two points draws nothing (matches + // Points' own "nothing to connect" convention). + Threshold []TimePoint + // OverInk is the color a Points segment switches to for any stretch + // ending above the Threshold in effect at that time. The zero value + // disables recoloring entirely — Threshold, if set, still draws as a + // plain reference line either way, and Points stays in Ink. + OverInk RGBA + // series is the bindable form of Points, created by Series(). series *mvvm.ObservableList[TimePoint] // ceiling is where FollowPeak has the scale now. @@ -244,13 +261,18 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { return } + for _, at := range verticalGridTicks(first.At, last.At) { + x := pl.X + int(float64(at-first.At)/float64(span)*float64(pl.W-1)) + drawLine(p, x, pl.Y, x, pl.Y+pl.H-1, theme.Border) + } + ink := c.Ink if ink == (RGBA{}) { ink = theme.Accent } valueSpan := c.Max - c.Min pointAt := func(t TimePoint) (int, int) { - frac := float64(t.At-first.At) / float64(span) + frac := min(1, max(0, float64(t.At-first.At)/float64(span))) x := pl.X + int(frac*float64(pl.W-1)) vf := 0.0 if valueSpan != 0 { @@ -260,10 +282,26 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { y := pl.Y + int((1-vf)*float64(pl.H-1)) return x, y } + + if len(c.Threshold) >= 2 { + tx, ty := pointAt(c.Threshold[0]) + for _, t := range c.Threshold[1:] { + x, y := pointAt(t) + drawDashedLine(p, tx, ty, x, y, theme.Border) + tx, ty = x, y + } + } + px, py := pointAt(pts[0]) for _, pt := range pts[1:] { x, y := pointAt(pt) - drawLine(p, px, py, x, y, ink) + segInk := ink + if c.OverInk != (RGBA{}) { + if v, ok := c.thresholdAt(pt.At); ok && pt.Value > v { + segInk = c.OverInk + } + } + drawLine(p, px, py, x, y, segInk) px, py = x, y } @@ -274,6 +312,116 @@ func (c *TimeSeriesChart) Draw(p painter.Painter, theme *Theme) { c.drawText(p, pl.X+pl.W-c.textWidth(endText), ty, endText, label) } +// 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. +func (c *TimeSeriesChart) thresholdAt(at int64) (float64, bool) { + if len(c.Threshold) == 0 { + return 0, false + } + v := c.Threshold[0].Value + for _, t := range c.Threshold { + if t.At > at { + break + } + v = t.Value + } + return v, true +} + +// niceTimeIntervals are the calendar-shaped step sizes vertical +// gridlines choose from, smallest first — hours for a short span, days +// for a long one, so "a bar per hour" and "a bar per day" are the same +// mechanism at two different scales rather than two separate features. +var niceTimeIntervals = []time.Duration{ + time.Hour, 2 * time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, + 24 * time.Hour, 2 * 24 * time.Hour, 7 * 24 * time.Hour, +} + +// maxVerticalGridlines caps how many vertical gridlines verticalGridTicks +// draws, so a long span (a week at hourly ticks) doesn't turn into solid +// ink instead of a readable set of reference lines. +const maxVerticalGridlines = 8 + +// verticalGridInterval picks the smallest niceTimeIntervals entry that +// keeps span's own gridline count at or under maxVerticalGridlines, +// falling back to the coarsest entry for a span too long for even that. +func verticalGridInterval(span time.Duration) time.Duration { + for _, d := range niceTimeIntervals { + if span/d <= maxVerticalGridlines { + return d + } + } + return niceTimeIntervals[len(niceTimeIntervals)-1] +} + +// verticalGridTicks returns the "nice" time boundaries between first and +// last (inclusive) at verticalGridInterval's chosen granularity. Hour-ish +// intervals truncate to the hour; day-ish ones align to LOCAL midnight +// via calendar-day arithmetic (AddDate) rather than adding 24*time.Hour, +// so a gridline lands on midnight across a DST transition instead of +// drifting by an hour. +func verticalGridTicks(first, last int64) []int64 { + span := time.Duration(last-first) * time.Second + if span <= 0 { + return nil + } + interval := verticalGridInterval(span) + + var ticks []int64 + if interval < 24*time.Hour { + t := time.Unix(first, 0).Truncate(interval) + for t.Unix() < first { + t = t.Add(interval) + } + for at := t.Unix(); at <= last; { + ticks = append(ticks, at) + t = t.Add(interval) + at = t.Unix() + } + return ticks + } + + days := int(interval / (24 * time.Hour)) + start := time.Unix(first, 0) + t := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) + for t.Unix() < first { + t = t.AddDate(0, 0, days) + } + for at := t.Unix(); at <= last; { + ticks = append(ticks, at) + t = t.AddDate(0, 0, days) + at = t.Unix() + } + return ticks +} + +// drawDashedLine draws segment (x0,y0)-(x1,y1) as alternating dash/gap +// stretches — a caller's visual cue that this is a REFERENCE line, not +// sampled data, without inventing a second line style in Theme. +func drawDashedLine(p painter.Painter, x0, y0, x1, y1 int, color RGBA) { + const dash, gap = 4.0, 3.0 + dx, dy := float64(x1-x0), float64(y1-y0) + length := math.Hypot(dx, dy) + if length == 0 { + return + } + ux, uy := dx/length, dy/length + on := true + for d := 0.0; d < length; { + step := dash + if !on { + step = gap + } + next := math.Min(d+step, length) + if on { + drawLine(p, x0+int(d*ux), y0+int(d*uy), x0+int(next*ux), y0+int(next*uy), color) + } + d = next + on = !on + } +} + // A11y reports the TimeSeriesChart as an img carrying its point count — // the same convention Sparkline (its inline-sized sibling) and LineChart // use, since a full time-value series has no single "current" reading to diff --git a/timeserieschart_test.go b/timeserieschart_test.go index fa2465b..1035ad2 100644 --- a/timeserieschart_test.go +++ b/timeserieschart_test.go @@ -4,9 +4,12 @@ package toolkit -import "github.com/go-widgets/mvvm" +import ( + "testing" + "time" -import "testing" + "github.com/go-widgets/mvvm" +) func TestTimeSeriesChartEmptyDrawsAxesOnly(t *testing.T) { c := NewTimeSeriesChart(nil, 0, 100) @@ -329,3 +332,233 @@ func TestNiceCeilingSpeaksInRoundNumbers(t *testing.T) { } } } + +// borderCount draws c and counts theme.Border pixels — the diagnostic +// used below to prove the Threshold line adds ink, since gridlines +// already use the same color and would otherwise mask the comparison. +func borderCount(t *testing.T, c *TimeSeriesChart, w, h int) int { + t.Helper() + c.SetBounds(Rect{X: 0, Y: 0, W: w, H: h}) + surf := makeSurface(w, h) + c.Draw(newP(surf, w), DefaultLight()) + return countInk(surf, w, h, DefaultLight().Border) +} + +func TestTimeSeriesChartThresholdDrawnAsReferenceLine(t *testing.T) { + points := []TimePoint{{At: 0, Value: 50}, {At: 7200, Value: 50}} + without := NewTimeSeriesChart(points, 0, 100) + withThreshold := NewTimeSeriesChart(points, 0, 100) + withThreshold.Threshold = []TimePoint{{At: 0, Value: 0}, {At: 7200, Value: 100}} + + base := borderCount(t, without, 120, 60) + got := borderCount(t, withThreshold, 120, 60) + if got <= base { + t.Fatalf("Border pixel count with Threshold set (%d) did not exceed without (%d)", got, base) + } +} + +func TestTimeSeriesChartThresholdSinglePointDrawsNothing(t *testing.T) { + points := []TimePoint{{At: 0, Value: 50}, {At: 7200, Value: 50}} + without := NewTimeSeriesChart(points, 0, 100) + withOne := NewTimeSeriesChart(points, 0, 100) + withOne.Threshold = []TimePoint{{At: 3600, Value: 50}} + + base := borderCount(t, without, 120, 60) + got := borderCount(t, withOne, 120, 60) + if got != base { + t.Fatalf("a single-point Threshold drew %d Border pixels, want exactly %d (no line, nothing to connect)", got, base) + } +} + +func TestTimeSeriesChartOverInkRecolorsSegmentsAboveThreshold(t *testing.T) { + c := NewTimeSeriesChart([]TimePoint{ + {At: 0, Value: 10}, + {At: 3600, Value: 90}, + }, 0, 100) + c.Threshold = []TimePoint{{At: 0, Value: 50}, {At: 3600, Value: 50}} + over := RGB(0xFF, 0x00, 0x00) + c.OverInk = over + c.SetBounds(Rect{X: 0, Y: 0, W: 120, H: 60}) + surf := makeSurface(120, 60) + c.Draw(newP(surf, 120), DefaultLight()) + + if got := countInk(surf, 120, 60, over); got == 0 { + t.Error("no OverInk pixels drawn for a segment ending above Threshold") + } +} + +func TestTimeSeriesChartOverInkZeroValueDisablesRecoloring(t *testing.T) { + newChart := func() *TimeSeriesChart { + c := NewTimeSeriesChart([]TimePoint{ + {At: 0, Value: 10}, + {At: 3600, Value: 90}, + }, 0, 100) + c.Threshold = []TimePoint{{At: 0, Value: 50}, {At: 3600, Value: 50}} + return c + } + over := RGB(0xFF, 0x00, 0x00) + + withOverInk := newChart() + withOverInk.OverInk = over + withOverInk.SetBounds(Rect{X: 0, Y: 0, W: 120, H: 60}) + s1 := makeSurface(120, 60) + withOverInk.Draw(newP(s1, 120), DefaultLight()) + if countInk(s1, 120, 60, over) == 0 { + t.Fatal("setup: expected some OverInk pixels when OverInk is set") + } + + withoutOverInk := newChart() + withoutOverInk.SetBounds(Rect{X: 0, Y: 0, W: 120, H: 60}) + s2 := makeSurface(120, 60) + withoutOverInk.Draw(newP(s2, 120), DefaultLight()) + if got := countInk(s2, 120, 60, over); got != 0 { + t.Errorf("OverInk left at its zero value still drew %d pixels in that color", got) + } +} + +func TestTimeSeriesChartOverInkAllBelowThresholdStaysInk(t *testing.T) { + c := NewTimeSeriesChart([]TimePoint{ + {At: 0, Value: 5}, + {At: 3600, Value: 10}, + }, 0, 100) + c.Threshold = []TimePoint{{At: 0, Value: 50}, {At: 3600, Value: 50}} + over := RGB(0xFF, 0x00, 0x00) + c.OverInk = over + c.SetBounds(Rect{X: 0, Y: 0, W: 120, H: 60}) + surf := makeSurface(120, 60) + c.Draw(newP(surf, 120), DefaultLight()) + + if got := countInk(surf, 120, 60, over); got != 0 { + t.Errorf("a series entirely below Threshold drew %d OverInk pixels, want 0", got) + } +} + +func TestThresholdAt(t *testing.T) { + c := &TimeSeriesChart{Threshold: []TimePoint{ + {At: 100, Value: 1}, + {At: 200, Value: 2}, + {At: 300, Value: 3}, + }} + cases := []struct { + name string + at int64 + want float64 + }{ + {"before first", 50, 1}, + {"exact match", 200, 2}, + {"between samples", 250, 2}, + {"after last", 1000, 3}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := c.thresholdAt(tc.at) + if !ok { + t.Fatalf("thresholdAt(%d): ok = false, want true", tc.at) + } + if got != tc.want { + t.Errorf("thresholdAt(%d) = %v, want %v", tc.at, got, tc.want) + } + }) + } +} + +func TestThresholdAtEmptyReturnsFalse(t *testing.T) { + c := &TimeSeriesChart{} + if _, ok := c.thresholdAt(123); ok { + t.Fatal("thresholdAt on an empty Threshold: ok = true, want false") + } +} + +func TestVerticalGridInterval(t *testing.T) { + cases := []struct { + name string + span time.Duration + want time.Duration + }{ + {"5 hours picks hourly", 5 * time.Hour, time.Hour}, + {"7 days picks daily", 7 * 24 * time.Hour, 24 * time.Hour}, + {"100 days falls back to the coarsest interval", 100 * 24 * time.Hour, 7 * 24 * time.Hour}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := verticalGridInterval(tc.span); got != tc.want { + t.Errorf("verticalGridInterval(%v) = %v, want %v", tc.span, got, tc.want) + } + }) + } +} + +func TestVerticalGridTicksNonPositiveSpanReturnsNil(t *testing.T) { + if got := verticalGridTicks(1000, 1000); got != nil { + t.Errorf("verticalGridTicks(equal first/last) = %v, want nil", got) + } + if got := verticalGridTicks(2000, 1000); got != nil { + t.Errorf("verticalGridTicks(last before first) = %v, want nil", got) + } +} + +// TestVerticalGridTicksHourAligned proves hour-granularity ticks land on +// the hour (Unix time divisible by 3600, true regardless of the test +// machine's local timezone since Truncate operates on the absolute +// instant) even when first itself is not hour-aligned. +func TestVerticalGridTicksHourAligned(t *testing.T) { + first := time.Date(2026, 1, 1, 10, 17, 0, 0, time.UTC).Unix() + last := first + int64(5*time.Hour/time.Second) + ticks := verticalGridTicks(first, last) + if len(ticks) == 0 { + t.Fatal("no ticks returned for a 5-hour span") + } + for _, tk := range ticks { + if tk < first || tk > last { + t.Errorf("tick %d falls outside [first, last] = [%d, %d]", tk, first, last) + } + if tk%3600 != 0 { + t.Errorf("tick %d is not hour-aligned", tk) + } + } +} + +// TestVerticalGridTicksDayAligned proves day-granularity ticks land on +// local midnight. +func TestVerticalGridTicksDayAligned(t *testing.T) { + first := time.Date(2026, 1, 1, 15, 0, 0, 0, time.Local).Unix() + last := first + int64(7*24*time.Hour/time.Second) + ticks := verticalGridTicks(first, last) + if len(ticks) == 0 { + t.Fatal("no ticks returned for a 7-day span") + } + for _, tk := range ticks { + if tk < first || tk > last { + t.Errorf("tick %d falls outside [first, last] = [%d, %d]", tk, first, last) + } + lt := time.Unix(tk, 0) + if lt.Hour() != 0 || lt.Minute() != 0 || lt.Second() != 0 { + t.Errorf("tick %v is not local midnight", lt) + } + } +} + +func TestDrawDashedLineZeroLengthDrawsNothing(t *testing.T) { + surf := makeSurface(20, 20) + drawDashedLine(newP(surf, 20), 5, 5, 5, 5, RGB(0xFF, 0, 0)) + if got := countInk(surf, 20, 20, RGB(0xFF, 0, 0)); got != 0 { + t.Errorf("a zero-length dashed line drew %d pixels, want 0", got) + } +} + +func TestDrawDashedLineDrawsFewerPixelsThanASolidLine(t *testing.T) { + color := RGB(0xFF, 0, 0) + dashed := makeSurface(100, 20) + drawDashedLine(newP(dashed, 100), 0, 10, 99, 10, color) + solid := makeSurface(100, 20) + drawLine(newP(solid, 100), 0, 10, 99, 10, color) + + dashedCount := countInk(dashed, 100, 20, color) + solidCount := countInk(solid, 100, 20, color) + if dashedCount == 0 { + t.Fatal("a dashed line over a 100px span drew 0 pixels") + } + if dashedCount >= solidCount { + t.Errorf("dashed line drew %d pixels, solid drew %d — expected dashed to draw fewer (gaps)", dashedCount, solidCount) + } +}