package gui import ( "image" "image/color" "math" "time" "gioui.org/f32" "gioui.org/io/event" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" ) // ChartPoint is one sample. A point with OK false is a failed probe: the line // breaks there rather than being interpolated across, because pretending a // dropped ping was a slow one hides exactly the problem the user opened this // panel to find. type ChartPoint struct { At time.Time Value float64 // milliseconds OK bool } // ChartSeries is one line on the chart. type ChartSeries struct { Name string Color color.NRGBA Points []ChartPoint Hidden bool // Subtitle appears under the name in the legend, typically the peer's route. Subtitle string } // ChartStyle configures the plot. type ChartStyle struct { Height unit.Dp // Window is how far back the x axis reaches. Window time.Duration // Now anchors the right edge. Now time.Time // Unit labels the y axis. Unit string // FillSingle draws a soft gradient under the line when exactly one series // is visible, which reads better than a lone stroke on a big canvas. FillSingle bool } // Chart is the stateful part of the plot: which point the pointer is near. type Chart struct { hover f32.Point hovering bool // plot is the last plotted rectangle, used to map hover x back to a time. plot image.Rectangle } // HoverIndex returns the sample index the pointer is nearest within s, or -1. func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int { if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 { return -1 } frac := float64(c.hover.X-float32(c.plot.Min.X)) / float64(c.plot.Dx()) if frac < 0 || frac > 1 { return -1 } target := st.Now.Add(-st.Window).Add(time.Duration(frac * float64(st.Window))) best, bestDelta := -1, time.Duration(math.MaxInt64) for i, p := range series.Points { d := p.At.Sub(target) if d < 0 { d = -d } if d < bestDelta { best, bestDelta = i, d } } // Only report a match when the nearest sample is genuinely close, so the // crosshair does not snap to a distant point in a sparse series. if bestDelta > st.Window/20 { return -1 } return best } // Layout draws the chart. func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D { if st.Window <= 0 { st.Window = 20 * time.Minute } if st.Now.IsZero() { st.Now = gtx.Now } h := gtx.Dp(st.Height) if h <= 0 { h = gtx.Dp(180) } w := gtx.Constraints.Max.X size := image.Pt(w, h) gutterL := gtx.Dp(44) gutterB := gtx.Dp(18) plot := image.Rect(gutterL, gtx.Dp(6), w-gtx.Dp(6), h-gutterB) c.plot = plot if plot.Dx() <= 0 || plot.Dy() <= 0 { return D{Size: size} } // Pointer tracking over the plot area. c.update(gtx, size) yMax := niceMax(maxVisible(series)) tMin := st.Now.Add(-st.Window) c.drawGrid(t, gtx, plot, yMax, st) for _, s := range series { if s.Hidden || len(s.Points) == 0 { continue } c.drawSeries(t, gtx, plot, s, tMin, st.Now, yMax, st.FillSingle && visibleCount(series) == 1) } c.drawCrosshair(t, gtx, plot, series, st, tMin, yMax) return D{Size: size} } func (c *Chart) update(gtx C, size image.Point) { defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() event.Op(gtx.Ops, c) for { ev, ok := gtx.Event(pointer.Filter{ Target: c, Kinds: pointer.Move | pointer.Enter | pointer.Leave | pointer.Drag, }) if !ok { break } pe, ok := ev.(pointer.Event) if !ok { continue } switch pe.Kind { case pointer.Leave, pointer.Cancel: c.hovering = false default: c.hovering = true c.hover = pe.Position } } } func maxVisible(series []ChartSeries) float64 { m := 0.0 for _, s := range series { if s.Hidden { continue } for _, p := range s.Points { if p.OK && p.Value > m { m = p.Value } } } return m } func visibleCount(series []ChartSeries) int { n := 0 for _, s := range series { if !s.Hidden && len(s.Points) > 0 { n++ } } return n } // niceMax rounds an axis maximum up to a 1/2/5 x 10^n step so the gridlines // land on numbers a human reads without effort. func niceMax(v float64) float64 { if v <= 0 { return 50 } v *= 1.15 // headroom so the peak is not glued to the top edge exp := math.Floor(math.Log10(v)) base := math.Pow(10, exp) switch f := v / base; { case f <= 1: return base case f <= 2: return 2 * base case f <= 5: return 5 * base default: return 10 * base } } func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st ChartStyle) { const rows = 4 lineCol := WithAlpha(t.P.Border, 0.9) for i := 0; i <= rows; i++ { frac := float64(i) / rows y := plot.Max.Y - int(frac*float64(plot.Dy())) paint.FillShape(gtx.Ops, lineCol, clip.Rect{ Min: image.Pt(plot.Min.X, y), Max: image.Pt(plot.Max.X, y+1), }.Op()) val := frac * yMax lbl := t.MonoLabel(SizeCaption, t.P.TextDim, trimZero(val, 0)) lbl.Alignment = text.End off := op.Offset(image.Pt(0, y-gtx.Dp(7))).Push(gtx.Ops) lgtx := gtx lgtx.Constraints.Max.X = plot.Min.X - gtx.Dp(6) lgtx.Constraints.Min.X = lgtx.Constraints.Max.X lbl.Layout(lgtx) off.Pop() } // X axis: three labels, oldest to newest. labels := []struct { frac float64 txt string }{ {0, "-" + FormatDuration(st.Window)}, {0.5, "-" + FormatDuration(st.Window/2)}, {1, "now"}, } if t.Lang == LangZH { labels[2].txt = "现在" } for _, l := range labels { x := plot.Min.X + int(l.frac*float64(plot.Dx())) lbl := t.Text(SizeCaption, t.P.TextDim, l.txt) switch { case l.frac == 0: lbl.Alignment = text.Start case l.frac == 1: lbl.Alignment = text.End default: lbl.Alignment = text.Middle } wide := gtx.Dp(70) ox := x - wide/2 if l.frac == 0 { ox = x } if l.frac == 1 { ox = x - wide } off := op.Offset(image.Pt(ox, plot.Max.Y+gtx.Dp(3))).Push(gtx.Ops) lgtx := gtx lgtx.Constraints.Max.X = wide lgtx.Constraints.Min.X = wide lbl.Layout(lgtx) off.Pop() } } // pos maps a sample onto plot coordinates. func pos(plot image.Rectangle, tMin, tMax time.Time, yMax float64, p ChartPoint) f32.Point { span := tMax.Sub(tMin) if span <= 0 { span = time.Second } fx := float64(p.At.Sub(tMin)) / float64(span) fx = math.Max(0, math.Min(1, fx)) fy := p.Value / yMax fy = math.Max(0, math.Min(1, fy)) return f32.Pt( float32(plot.Min.X)+float32(fx)*float32(plot.Dx()), float32(plot.Max.Y)-float32(fy)*float32(plot.Dy()), ) } func (c *Chart) drawSeries(t *Theme, gtx C, plot image.Rectangle, s ChartSeries, tMin, tMax time.Time, yMax float64, fill bool) { defer clip.Rect(plot).Push(gtx.Ops).Pop() // Optional area fill, drawn first so the stroke sits on top. if fill { var ap clip.Path ap.Begin(gtx.Ops) started := false var lastX float32 for _, p := range s.Points { if !p.OK { continue } pt := pos(plot, tMin, tMax, yMax, p) if !started { ap.MoveTo(f32.Pt(pt.X, float32(plot.Max.Y))) ap.LineTo(pt) started = true } else { ap.LineTo(pt) } lastX = pt.X } if started { ap.LineTo(f32.Pt(lastX, float32(plot.Max.Y))) ap.Close() paint.FillShape(gtx.Ops, WithAlpha(s.Color, 0.13), clip.Outline{Path: ap.End()}.Op()) } } var p clip.Path p.Begin(gtx.Ops) pen := false for _, sp := range s.Points { if !sp.OK { pen = false // break the line across a dropped probe continue } pt := pos(plot, tMin, tMax, yMax, sp) if !pen { p.MoveTo(pt) pen = true } else { p.LineTo(pt) } } paint.FillShape(gtx.Ops, s.Color, clip.Stroke{Path: p.End(), Width: float32(gtx.Dp(1.6))}.Op()) // Mark failures with a small tick on the baseline so loss is visible even // when the surrounding samples are fine. for _, sp := range s.Points { if sp.OK { continue } pt := pos(plot, tMin, tMax, yMax, ChartPoint{At: sp.At, Value: 0, OK: true}) x := int(pt.X) paint.FillShape(gtx.Ops, WithAlpha(t.P.Fail, 0.75), clip.Rect{ Min: image.Pt(x, plot.Max.Y-gtx.Dp(5)), Max: image.Pt(x+max(gtx.Dp(1.5), 1), plot.Max.Y), }.Op()) } // A dot on the most recent successful sample anchors the eye to "now". for i := len(s.Points) - 1; i >= 0; i-- { if !s.Points[i].OK { continue } pt := pos(plot, tMin, tMax, yMax, s.Points[i]) d := gtx.Dp(5) off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops) Circle(gtx, d, s.Color) off.Pop() break } } func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, st ChartStyle, tMin time.Time, yMax float64) { if !c.hovering { return } x := int(c.hover.X) if x < plot.Min.X || x > plot.Max.X { return } paint.FillShape(gtx.Ops, WithAlpha(t.P.TextDim, 0.5), clip.Rect{ Min: image.Pt(x, plot.Min.Y), Max: image.Pt(x+1, plot.Max.Y), }.Op()) for _, s := range series { if s.Hidden { continue } i := c.HoverIndex(s, st) if i < 0 || !s.Points[i].OK { continue } pt := pos(plot, tMin, st.Now, yMax, s.Points[i]) d := gtx.Dp(7) off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops) Circle(gtx, d, s.Color) inner := gtx.Dp(3) off2 := op.Offset(image.Pt((d-inner)/2, (d-inner)/2)).Push(gtx.Ops) Circle(gtx, inner, t.P.Bg) off2.Pop() off.Pop() } } // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- // LegendEntry is one row of the chart legend. type LegendEntry struct { Name string Subtitle string Color color.NRGBA Value string Hidden bool } // Legend renders the chart legend as a wrapping row of toggles. The caller // supplies a clickable per entry so hiding a noisy peer is one click away. func (t *Theme) Legend(gtx C, entries []LegendEntry, click func(i int) layout.Widget) D { if len(entries) == 0 { return D{} } children := make([]layout.FlexChild, 0, len(entries)) for i := range entries { children = append(children, layout.Rigid(click(i))) } return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...) } // LegendChip draws one legend entry. func (t *Theme) LegendChip(gtx C, e LegendEntry, hovered bool) D { fg := t.P.TextSec swatch := e.Color if e.Hidden { fg = WithAlpha(t.P.TextDim, 0.7) swatch = WithAlpha(e.Color, 0.3) } if hovered { fg = t.P.TextPri } return layout.Inset{Right: SpaceMD, Top: 3, Bottom: 3}.Layout(gtx, func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Inset{Right: 6}.Layout(gtx, func(gtx C) D { h := gtx.Dp(3) w := gtx.Dp(12) FillRRect(gtx, image.Pt(w, h), RadiusPill, swatch) return D{Size: image.Pt(w, h)} }) }), layout.Rigid(OneLine(t.Text(SizeCaption, fg, e.Name)).Layout), layout.Rigid(func(gtx C) D { if e.Value == "" { return D{} } return layout.Inset{Left: 5}.Layout(gtx, t.MonoLabel(SizeCaption, WithAlpha(fg, 0.8), e.Value).Layout) }), ) }) } // --------------------------------------------------------------------------- // Sparkline // --------------------------------------------------------------------------- // Sparkline draws a compact latency trace for a table row: no axes, no labels, // just the shape of the last few minutes. func (t *Theme) Sparkline(gtx C, points []ChartPoint, col color.NRGBA, w, h unit.Dp) D { width, height := gtx.Dp(w), gtx.Dp(h) size := image.Pt(width, height) if len(points) < 2 || width <= 0 || height <= 0 { // A flat hairline is a clearer "no data yet" than empty space. paint.FillShape(gtx.Ops, WithAlpha(t.P.Border, 0.8), clip.Rect{ Min: image.Pt(0, height/2), Max: image.Pt(width, height/2+1), }.Op()) return D{Size: size} } yMax := 0.0 for _, p := range points { if p.OK && p.Value > yMax { yMax = p.Value } } if yMax <= 0 { yMax = 1 } yMax *= 1.2 plot := image.Rect(0, 1, width, height-1) tMin, tMax := points[0].At, points[len(points)-1].At if !tMax.After(tMin) { tMax = tMin.Add(time.Second) } defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() var p clip.Path p.Begin(gtx.Ops) pen := false for _, sp := range points { if !sp.OK { pen = false continue } pt := pos(plot, tMin, tMax, yMax, sp) if !pen { p.MoveTo(pt) pen = true } else { p.LineTo(pt) } } paint.FillShape(gtx.Ops, col, clip.Stroke{Path: p.End(), Width: float32(gtx.Dp(1.3))}.Op()) for _, sp := range points { if sp.OK { continue } pt := pos(plot, tMin, tMax, yMax, ChartPoint{At: sp.At, Value: 0, OK: true}) x := int(pt.X) paint.FillShape(gtx.Ops, WithAlpha(t.P.Fail, 0.8), clip.Rect{ Min: image.Pt(x, plot.Max.Y-gtx.Dp(3)), Max: image.Pt(x+1, plot.Max.Y), }.Op()) } return D{Size: size} }