Help us shape the future of Go! Take the 2026 Go Developer Survey

plot

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 6 Imported by: 0

README

plot-go

A Go port of annacrombie/plot — line graphs on the command line — built on asciigraph for the drawing.

Live demo — two counters the tab actually has, followed and redrawn in a terminal. The pipeline comes from the query: ?p=roc:5, ?p=avg:5|roc:5, ?p=cma.

plot-go in the browser

There is no /sys in a browser, so the demo follows what a tab does have: the gap between animation frames, and how late a zero-millisecond timeout runs. Both climb when the event loop is busy, which is the same reason plotnet watches rx_packets and tx_packets. It draws into xterm-go — the terminal websh runs on — because asciigraph's output is a terminal frame: the colors are SGR escapes and the redraw is a cursor-home and a clear.

$ seq 0 30 | awk '{print $1*$1*3 + 1000}' | plot-go -d 4
 3700.00 ┤                          ╭───
 3025.00 ┤                     ╭────╯
 2350.00 ┤               ╭─────╯
 1675.00 ┤  ╭────────────╯
 1000.00 ┼──╯

$ seq 0 30 | awk '{print $1*$1*3 + 1000}' | plot-go -d 4 -p roc:1
 177.00 ┤                          ╭───
 132.75 ┤                  ╭───────╯
  88.50 ┤           ╭──────╯
  44.25 ┤    ╭──────╯
   0.00 ┼────╯

Same numbers both times. The second one is the point of this repository.

What it is for

asciigraph already draws a series well. What it has no answer for is a series that is not worth drawing yet — a monotonic counter, a noisy signal, more samples than columns. plot's answer is a processing pipeline that runs before anything is rendered, and that is what has been ported here.

A counter on a network interface is a line going up and to the right no matter what the traffic does. Through roc it is throughput.

Pipelines

-p takes processors separated by |, each with an optional :argument. The output of one is the input of the next, so avg:5|roc:5 averages every five samples and then reports the rate of change of those averages.

argument what it does
avg n averages every n samples into one
sma n, odd simple moving average over n samples — a boxcar filter
cma none cumulative average of everything so far
roc interval rate of change: (value - previous) / interval

The names and syntax are the original's, so a command line written for C plot means the same thing here.

Input

Numbers are picked out of whatever the input happens to be — the scanner looks for a digit and reads a number from there, skipping anything else. load: 1.5 ok is a data point.

That leniency has one edge worth knowing: a hyphen in front of digits is a sign, so [2026-01-01] reads as 2026, -1, -1. The original does the same.

Sources are named with -i, or left out to read standard input. Each may carry flags after a colon:

plot-go -i /sys/class/net/eno1/statistics/rx_bytes:r -p roc:1 -f

:r re-reads the file from the start on every poll. It is the flag that makes following a counter work at all, and the reason is worth stating: a log grows, so following it means waiting at the end for more; a counter under /sys is a single number that changes in place, so following it means going back to the top. -f does the first, :r the second, and a network counter needs both.

-A follows as -f does but stops when the input ends, rather than waiting there.

Several sources at once

-i may be repeated. -c and -p bind to the source that comes after them, so each series gets its own color and its own processing:

plot-go -f -S 500 -p roc:1 \
  -c l -i /sys/class/net/eno1/statistics/rx_packets:r \
  -c r -i /sys/class/net/eno1/statistics/tx_packets:r
 576.00 ┤  ╭─╮
 480.00 ┤  │ │
 384.00 ┤  │ │
 288.00 ┤╭───╮
 192.00 ┤│   ╰
  96.00 ┤│
   0.00 ┼╯

         ■ /sys/class/net/eno1/statistics/rx_packets   ■ /sys/class/net/eno1/statistics/tx_packets

A -p before any -i, as above, is the default for every source; a -p after one belongs to the next source and is appended to the default.

This ordering is why the flags are parsed by hand rather than with the flag package, which does not preserve the order flags were given in.

Portability

No cgo and no syscalls beyond os, so it runs where Go runs. The same test suite is run four ways:

make check          # host
make test-wasm      # js/wasm, under Node
make test-browser   # js/wasm, in a headless browser
make test-tinygo    # TinyGo, plus the wasip1 and wasm builds

TinyGo compiles it to a 1.1 MB wasm module, and to a native binary that polls a /sys counter exactly as the Go build does.

Only OpenSource touches the host at all — files and standard input. Everything above it (Splitter, Pipeline, the processors, and NewSource over any io.Reader) is arithmetic, so a browser can feed it numbers from anywhere.

Differences from the original

The moving average keeps a flat signal flat. C plot divides the warm-up sum by one less than the number of samples in it, so a constant series of 10 with sma:5 comes out as 15, 13.33, 12.5, 10 — a spike that is not in the data. Here it is 10 throughout. The emission schedule is unchanged: output still starts once the window is more than half full.

Chunking cannot change the result. The processors keep their own state rather than leaving samples in the caller's buffer to be offered again, so reading a file in one go and polling a counter one sample at a time produce the same numbers. In the original a window spanning two reads is counted twice; it does not show up because a read usually fills the whole buffer in one go.

The end of the data is not dropped. C plot emits a block only once a sample beyond it has arrived — its loop runs while i + n < len — so the last block of a finished stream is never emitted. seq 1 10 | plot -p avg:5 draws one point where there are two. Here the stream is flushed when it ends, so both come out, and a final block shorter than n is averaged over what it has.

-w sets the plot width rather than the data's. asciigraph resamples a series to the width it is given; the original pads instead, drawing six points in six columns of a forty-column plot. Same numbers, wider line.

Crossings and x labels

-m merges the junctions where two series cross. Without it whichever line is drawn second wins the cell, and a crossing reads as a break in the one underneath:

 19.00 ┼────╮      ╰─╮    ╭──────────╮      ╰─╮      # plain
 14.50 ┤    ╰───╮    ╰╭───╯──╯       ╰───╮    ╰

 19.00 ┼────╮      ╰─╮    ╭──┬──┴────╮      ╰─╮      # -m
 14.50 ┤    ╰───╮    ╰┬───┴──╯       ╰───╮    ╰

This needed a change in asciigraph, which had no way to express it. It is MergeSeries() on a branch of the fork, which this repository depends on by replace until it lands upstream.

-x every:offset:mod labels the x axis: one label every N columns, counting from an offset, wrapping at a modulus — so -x 10:0:60 counts seconds up to a minute and starts again.

$ seq 1 41 | plot-go -d 3 -x 10
       └┬─────────┬─────────┬─────────┬─────────┬
        0        10        20        30        40

Flags the original has that mean less here

-y is accepted in its full width:prec:side form but only the precision applies: asciigraph sizes the labels itself and puts them on the left.

-x takes only the first three of its five fields. The side the labels go on and the color of the wrap point have no equivalent — asciigraph draws one labeled axis, below the plot, in one color — so asking for them is an error rather than being quietly given something else.

The tick spacing lands exactly every N columns when N divides the width, and as near as the columns allow when it does not.

License

MIT, as the original is. See LICENSE.

Dependency Graph

Made with goda:

go run github.com/loov/goda@latest graph github.com/0magnet/plot-go/... | dot -Tsvg -o docs/plot-go-goda-graph.svg

Dependency Graph

Lines of Code

Made with gocloc (excludes vendor/, node_modules/, .git/):

gocloc --not-match-d='(vendor|node_modules|\.git)' .
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Go                              10            277            415           2144
Markdown                         1             57              0            168
YAML                             1              0              7             98
Makefile                         1             21             33             94
Bourne Shell                     1              8             16             30
-------------------------------------------------------------------------------
TOTAL                           14            363            471           2534
-------------------------------------------------------------------------------

Documentation

Overview

Package plot reads sequences of numbers and draws them as line graphs.

It is a Go port of https://github.com/annacrombie/plot (MIT, Stone Tickle). This file is the data-processing half: the transformations that run over the numbers before anything is drawn, which is what separates plot from a plain graph renderer. A monotonic counter — bytes on an interface, say — is a ramp until Rate turns it into a rate.

The processors are streaming by construction. Each one is handed whatever samples have arrived so far, emits what it can, and reports how many it consumed; the rest stays buffered for the next read. That is what lets the same code serve a file read once and a counter polled forever.

Index

Constants

View Source
const BufferSize = 128

BufferSize bounds a processor's window, matching PLOT_DBUF_SIZE in the original. It is not a limit on how much data can be plotted — samples stream through — only on how far back a single processor may look.

Variables

This section is empty.

Functions

func Scan

func Scan(r io.Reader, fn func(float64)) error

Scan reads numbers out of r until it ends, ignoring everything else.

Types

type Block

type Block struct {
	// contains filtered or unexported fields
}

Block averages every n samples into one, which is the plain decimation used to make a long series fit a short plot.

Mid-stream it emits a block only once a sample beyond that block has arrived, as the original does — its loop runs while i+n < len rather than <=. At the end of the stream the samples still held are emitted by Flush, so nothing is lost; in the original they are dropped, which costs a series of ten with avg:5 half its output.

func NewBlock

func NewBlock(n int) (*Block, error)

NewBlock returns a Block averaging every n samples.

func (*Block) Flush

func (b *Block) Flush(dst, in []float64) []float64

Flush emits the blocks still held at the end of the stream, including a last one shorter than n — an average of four samples where five were asked for is still what those samples say, and dropping it would lose the end of the data.

func (*Block) Process

func (b *Block) Process(dst, in []float64) ([]float64, int)

type Cumulative

type Cumulative struct {
	// contains filtered or unexported fields
}

Cumulative is the running average of everything seen so far. Unlike Simple it never forgets, so it settles rather than tracks.

func NewCumulative

func NewCumulative() *Cumulative

NewCumulative returns a Cumulative moving average.

func (*Cumulative) Process

func (c *Cumulative) Process(dst, in []float64) ([]float64, int)

type Flusher

type Flusher interface {
	Flush(dst, in []float64) []float64
}

Flusher is a Proc that may still be holding samples it would only emit once more arrived. Flush is called when the stream has ended and no more will: whatever is held is all there will ever be, so it is emitted rather than dropped. in is the samples the processor left unconsumed.

type Pipeline

type Pipeline struct {
	// contains filtered or unexported fields
}

Pipeline is a chain of processors. The output of each is the input of the next, so "avg:5|roc:5" first averages every five samples into one and then reports the rate of change of those averages.

A Pipeline holds the buffered tail of every stage, so like the processors in it, one belongs to a single stream.

func NewPipeline

func NewPipeline(procs ...Proc) *Pipeline

NewPipeline chains the given processors.

func ParsePipeline

func ParsePipeline(spec string) (*Pipeline, error)

ParsePipeline reads the pipeline syntax used on the command line: processors separated by "|", each optionally taking an argument after ":".

avg:5|roc:5

The names are those of the original, so a command line written for C plot means the same thing here.

func (*Pipeline) Flush

func (p *Pipeline) Flush() []float64

Flush drains every stage, for when the stream has ended. A stage holding a window that will never fill emits what it has rather than losing it, and each stage's last output still passes through the ones after it.

Call it once, at the end. A pipeline that has been flushed can still be given more samples, but the stages that held a partial window have started over.

func (*Pipeline) Len

func (p *Pipeline) Len() int

Len reports how many stages the pipeline has.

func (*Pipeline) Process

func (p *Pipeline) Process(in []float64) []float64

Process runs samples through every stage and returns what came out the far end. The slice is only valid until the next call.

An empty pipeline passes its input through, which is what makes a pipeline the single path for data whether or not any processing was asked for.

type Proc

type Proc interface {
	Process(dst, in []float64) (out []float64, consumed int)
}

Proc transforms a stream of samples.

Process appends its output to dst and returns the extended slice along with the number of samples it consumed from the front of in. Samples left unconsumed are offered again, with more appended, on the next call — so a processor that needs a longer window than it has been given may consume nothing and emit nothing, and that is not an error.

Implementations hold their own state, so a Proc belongs to one stream and cannot be shared between two.

type Rate

type Rate struct {
	// contains filtered or unexported fields
}

Rate reports how fast a value is changing rather than what it is: each sample becomes (value - previous) / interval.

This is the one that makes a counter readable. Interface byte counts, uptime, anything monotonic, plotted raw, is a line going up and to the right and says nothing about the traffic; through Rate it is throughput.

The first sample has nothing to compare against, so it comes out as zero rather than as a spike from nowhere.

func NewRate

func NewRate(interval float64) (*Rate, error)

NewRate returns a Rate over the given interval, which is the time between samples in whatever unit the caller wants the rate expressed in.

func (*Rate) Process

func (r *Rate) Process(dst, in []float64) ([]float64, int)

type Simple

type Simple struct {
	// contains filtered or unexported fields
}

Simple is a simple moving average over n samples — a boxcar filter, for taking the noise out of a signal without moving its features along the axis.

n must be odd, so that the window has a middle sample to be centered on.

While the window is still filling it emits the average of what it has, but only once it holds more than half a window. Before that the average would say more about how recently the stream started than about the signal.

This differs from the original deliberately. C plot divides the warm-up sum by one less than the number of samples in it, so a constant signal comes out as a spike that decays into the true value — feeding it a flat series of 10 with a window of 5 gives 15, 13.33, 12.5, 10. Here the divisor is the count, so a flat signal is flat. The emission schedule is unchanged: output still begins once the window is more than half full.

func NewSimple

func NewSimple(n int) (*Simple, error)

NewSimple returns a Simple moving average over n samples. n must be odd.

func (*Simple) Process

func (s *Simple) Process(dst, in []float64) ([]float64, int)

Process consumes everything it is given, keeping the window in its own state rather than in the caller's buffer.

The original keeps a running sum but leaves the samples for the caller to re-offer, which double-counts them if a window spans more than one read. It does not show up there because a read usually fills the whole buffer, so the window fills in one go — but it makes the result depend on how the input happened to be chopped up, which is exactly what a stream cannot promise.

type Source

type Source struct {
	// Name is what the source was opened as, for error messages.
	Name string

	// Rewind makes the source start over when it reaches the end, instead of
	// stopping there.
	//
	// This is what makes polling a counter work. A file under /sys holds one
	// number that changes; it does not grow. Reading to the end and waiting
	// for more, which is what following a log means, would wait forever — the
	// value has to be read again from the top each time.
	Rewind bool

	// Infinite keeps the source open when it reaches the end, so that data
	// appended later is picked up — following a log, as tail -f does.
	//
	// It is separate from Rewind because the two answer different questions. A
	// log grows, so following it means waiting at the end; a counter changes in
	// place, so following it means going back to the start. The original has
	// both for the same reason: -f sets this, and :r sets Rewind.
	Infinite bool
	// contains filtered or unexported fields
}

Source is one stream of numbers: somewhere to read from, the flags that say how, and the pipeline the numbers go through on the way out.

func NewSource

func NewSource(name string, r io.Reader, pipe *Pipeline) *Source

NewSource wraps an already-open reader.

func OpenSource

func OpenSource(spec string, pipe *Pipeline) (*Source, error)

OpenSource opens spec, which is a filename optionally followed by flags:

/sys/class/net/eno1/statistics/rx_bytes:r

The filename "-" is standard input. The only flag is "r" for rewind; the original also has "n" for a nonblocking open, which is not needed here because a poll that finds nothing simply reads nothing.

The pipeline may be nil, in which case numbers pass through untouched.

func (*Source) Close

func (s *Source) Close() error

Close releases the source if it owns anything.

func (*Source) Done

func (s *Source) Done() bool

Done reports whether the source has ended and will produce nothing more. A rewinding source is never done.

func (*Source) Pipeline

func (s *Source) Pipeline() *Pipeline

Pipeline is the processing the numbers go through on their way out. It is never nil: a source with no pipeline has an empty one.

func (*Source) Read

func (s *Source) Read() ([]float64, error)

Read takes whatever is available now and returns the numbers it produced, after the pipeline. It does not wait for more.

The returned slice is only valid until the next call.

func (*Source) ReadAll

func (s *Source) ReadAll() ([]float64, error)

ReadAll drains the source, which is what a source that is not being followed wants. Rewind and Infinite are both ignored here: either one would mean the source never ends, and this has to.

type Splitter

type Splitter struct {
	// contains filtered or unexported fields
}

Splitter turns a byte stream into numbers, one chunk at a time.

The original's parser is deliberately lenient: it scans for something that could start a number, tries to read one from there, and skips the byte if that fails. That is what lets plot be pointed at a log line, a CSV or /proc/loadavg without anything in between — the numbers are picked out of whatever the text happens to be.

A number split across two reads is the reason this is a type rather than a function. The tail of a chunk may be half of a number whose other half has not arrived, so it is held until the next Write says how it ends.

func (*Splitter) Flush

func (s *Splitter) Flush(fn func(float64))

Flush emits a number left at the end of the stream. Call it at EOF, and only then: a file often ends without a trailing newline, and that last number is as real as any other.

func (*Splitter) Write

func (s *Splitter) Write(p []byte, fn func(float64))

Write feeds a chunk in, calling fn for every complete number in it.

A number at the very end of the chunk is not complete — more digits may follow in the next one — so it is held back until Write is called again or Flush says the stream has ended.

Directories

Path Synopsis
cmd
plot-go command
Command plot-go reads numbers and draws them as a line graph.
Command plot-go reads numbers and draws them as a line graph.
Command serve is a static file server for the demo page, so the wasm can be loaded over http rather than file:// (which blocks instantiateStreaming).
Command serve is a static file server for the demo page, so the wasm can be loaded over http rather than file:// (which blocks instantiateStreaming).
Command web is the plot-go demo: live counters, plotted as they arrive.
Command web is the plot-go demo: live counters, plotted as they arrive.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL