516 lines
12 KiB
Go
516 lines
12 KiB
Go
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}
|
|
}
|