Skip to content

Commit

Permalink
add documentation of the plotting functions
Browse files Browse the repository at this point in the history
  • Loading branch information
jfeo committed Dec 16, 2024
1 parent 4072e48 commit 5979e48
Showing 1 changed file with 92 additions and 65 deletions.
157 changes: 92 additions & 65 deletions cmd/output/plot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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++ {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}

0 comments on commit 5979e48

Please sign in to comment.