From 5979e4861b728e35a0dec7f6d7b9249b0a5195bf Mon Sep 17 00:00:00 2001 From: Jens Feodor Nielsen Date: Mon, 16 Dec 2024 14:00:33 +0100 Subject: [PATCH] add documentation of the plotting functions --- cmd/output/plot.go | 157 ++++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 65 deletions(-) diff --git a/cmd/output/plot.go b/cmd/output/plot.go index 04dd49a..662bcd8 100644 --- a/cmd/output/plot.go +++ b/cmd/output/plot.go @@ -37,6 +37,8 @@ func NewPlotWithTerm(data timeseries.Timeseries, term terminal) *Plot { } } +// Display the plots data with each output line prefixed with `prefix`, and taking up +// `height` lines. func (p *Plot) Display(prefix string, height int) { cols, _, err := p.term.GetSize() if err != nil { @@ -47,22 +49,10 @@ func (p *Plot) Display(prefix string, height int) { cols = maxPlotLineWidth } - axes := plotAxes{ - xLabelMin: p.data.MinTimestamp().Format(time.RFC3339), - xLabelMax: p.data.MaxTimestamp().Format(time.RFC3339), - } - - if p.data.MinValue() == p.data.MaxValue() { - axes.yLabelMin = fmt.Sprintf("%.2f", p.data.MinValue()-1.0) - axes.yLabelMax = fmt.Sprintf("%.2f", p.data.MaxValue()+1.0) - } else { - axes.yLabelMin = fmt.Sprintf("%.2f", p.data.MinValue()) - axes.yLabelMax = fmt.Sprintf("%.2f", p.data.MaxValue()) - } - + axes := p.newAxes(cols, height) rasterCols := cols - len(prefix) - max(len(axes.yLabelMin), len(axes.yLabelMax)) - 1 normalized := p.data.Normalize(float64(rasterCols-1), float64(height-1)) - r := newRaster(rasterCols, height) + r := newPlotRaster(rasterCols, height) var prev *timeseries.NormalizedPoint for _, cur := range normalized { @@ -71,78 +61,112 @@ func (p *Plot) Display(prefix string, height int) { continue } - r.bresenhamLine(int(prev.X), int(prev.Y), int(cur.X), int(cur.Y)) + r.BresenhamLine(int(prev.X), int(prev.Y), int(cur.X), int(cur.Y)) prev = ref(cur) } w := p.term.Writer() - fmt.Fprint(w, p.render(r, prefix, cols, height, axes)) + fmt.Fprint(w, p.render(r, axes, prefix, height)) } -func (p *Plot) render(raster *plotRaster, prefix string, width int, height int, axes plotAxes) string { +// Render the plotted raster, and the axes with the given prefix, returning it as a +// string. +func (p *Plot) render(raster *plotRaster, axes plotAxes, prefix string, height int) string { s := "" for y := height - 1; y >= 0; y-- { - ln := prefix + p.renderLeftAxisAtHeight(y, height, axes) + ln := prefix + axes.renderYAxisAtHeight(y) ln += raster.renderRow(y) s += ln + "\n" } - s += p.renderHorizontalAxis(axes, prefix, width) + "\n" + s += axes.renderXAxis(prefix) + "\n" return s } -func (p *Plot) renderHorizontalAxis(axes plotAxes, prefix string, width int) string { - maxYLabelLen := max(len(axes.yLabelMax), len(axes.yLabelMin)) - - hz := strings.Repeat(" ", maxYLabelLen-len(axes.yLabelMin)) + axes.yLabelMin + string(boxUpLeftRight) - hzRemaining := width - utf8.RuneCountInString(hz) - len(prefix) - if hzRemaining > 0 { - hz += strings.Repeat(string(boxHorizontal), hzRemaining) - } - - xLabelLn := strings.Repeat(" ", maxYLabelLen) - xLabelSpacing := width - len(xLabelLn) - len(axes.xLabelMin) - len(axes.xLabelMax) - len(prefix) - if xLabelSpacing > 0 { - xLabelLn += axes.xLabelMin - xLabelLn += strings.Repeat(" ", xLabelSpacing) - xLabelLn += axes.xLabelMax +func (p *Plot) newAxes(width, height int) plotAxes { + axes := plotAxes{ + xLabelMin: p.data.MinTimestamp().Format(time.RFC3339), + xLabelMax: p.data.MaxTimestamp().Format(time.RFC3339), + width: width, + height: height, } - return prefix + hz + "\n" + prefix + xLabelLn -} - -func (p *Plot) renderLeftAxisAtHeight(y int, height int, axes plotAxes) string { - maxLabelLen := max(len(axes.yLabelMax), len(axes.yLabelMin)) - - if y == height-1 { - return strings.Repeat(" ", maxLabelLen-len(axes.yLabelMax)) + axes.yLabelMax + string(boxDownLeft) + if p.data.MinValue() == p.data.MaxValue() { + axes.yLabelMin = fmt.Sprintf("%.2f", p.data.MinValue()-1.0) + axes.yLabelMax = fmt.Sprintf("%.2f", p.data.MaxValue()+1.0) + } else { + axes.yLabelMin = fmt.Sprintf("%.2f", p.data.MinValue()) + axes.yLabelMax = fmt.Sprintf("%.2f", p.data.MaxValue()) } - return strings.Repeat(" ", maxLabelLen) + string(boxVertical) + return axes } +// contains the information needed to render the axes of a plot type plotAxes struct { + height int + width int yLabelMin string yLabelMax string xLabelMin string xLabelMax string } -func newRaster(width, height int) *plotRaster { - return &plotRaster{ - data: make([]rune, width*height), - width: width, - height: height, +// Renders a single line of the (vertical) y-axis of a plot, and return it as a +// string. +// For the top line it will render the y-axis maximum value. +func (pa *plotAxes) renderYAxisAtHeight(y int) string { + maxLabelLen := max(len(pa.yLabelMax), len(pa.yLabelMin)) + + if y == pa.height-1 { + return strings.Repeat(" ", maxLabelLen-len(pa.yLabelMax)) + pa.yLabelMax + string(boxDownLeft) + } + + return strings.Repeat(" ", maxLabelLen) + string(boxVertical) +} + +// Renders the (horizontal) x-axis of a plot and returns it as a string. +// It renders two lines - one containing the minimum y-value as well as the +// actual box-drawn line, and another containing the x-axis labels. +func (pa *plotAxes) renderXAxis(prefix string) string { + maxYLabelLen := max(len(pa.yLabelMax), len(pa.yLabelMin)) + + hz := strings.Repeat(" ", maxYLabelLen-len(pa.yLabelMin)) + pa.yLabelMin + string(boxUpLeftRight) + hzRemaining := pa.width - utf8.RuneCountInString(hz) - len(prefix) + if hzRemaining > 0 { + hz += strings.Repeat(string(boxHorizontal), hzRemaining) + } + + xLabelLn := strings.Repeat(" ", maxYLabelLen) + xLabelSpacing := pa.width - len(xLabelLn) - len(pa.xLabelMin) - len(pa.xLabelMax) - len(prefix) + if xLabelSpacing > 0 { + xLabelLn += pa.xLabelMin + xLabelLn += strings.Repeat(" ", xLabelSpacing) + xLabelLn += pa.xLabelMax } + + return prefix + hz + "\n" + prefix + xLabelLn } +// A raster for plotting graphs onto. +// Has related methods for drawing lines onto the raster, and for rendering the +// raster to strings. type plotRaster struct { data []rune width int height int } +func newPlotRaster(width, height int) *plotRaster { + return &plotRaster{ + data: make([]rune, width*height), + width: width, + height: height, + } +} + +// Render a single row (or line) of the raster, returning it as a string. func (r *plotRaster) renderRow(y int) string { ln := "" for x := 0; x < r.width; x++ { @@ -157,7 +181,8 @@ func (r *plotRaster) renderRow(y int) string { return ln } -// plot a data point, marking the specified cell and all cells below it +// Plot a data point, marking the specified cell and all cells below it with the +// specified rune. func (r *plotRaster) plot(x, y int, val rune) { if x < 0 || x >= r.width || y < 0 || y >= r.height { return @@ -169,6 +194,24 @@ func (r *plotRaster) plot(x, y int, val rune) { } } +// Draw a line onto the raster +// See: https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm +func (r *plotRaster) BresenhamLine(x0, y0, x1, y1 int) { + if math.Abs(float64(y1-y0)) < math.Abs(float64(x1-x0)) { + if x0 > x1 { + r.bresenhamLow(x1, y1, x0, y0) + } else { + r.bresenhamLow(x0, y0, x1, y1) + } + } else { + if y0 > y1 { + r.bresenhamHigh(x1, y1, x0, y0) + } else { + r.bresenhamHigh(x0, y0, x1, y1) + } + } +} + func (r *plotRaster) bresenhamLow(x0, y0, x1, y1 int) { dx := x1 - x0 dy := y1 - y0 @@ -213,22 +256,6 @@ func (r *plotRaster) bresenhamHigh(x0, y0, x1, y1 int) { } } -func (r *plotRaster) bresenhamLine(x0, y0, x1, y1 int) { - if math.Abs(float64(y1-y0)) < math.Abs(float64(x1-x0)) { - if x0 > x1 { - r.bresenhamLow(x1, y1, x0, y0) - } else { - r.bresenhamLow(x0, y0, x1, y1) - } - } else { - if y0 > y1 { - r.bresenhamHigh(x1, y1, x0, y0) - } else { - r.bresenhamHigh(x0, y0, x1, y1) - } - } -} - func ref[T any](v T) *T { return &v }