stat

package
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package stat aggregates data before it is drawn.

Two families live here and they answer different questions.

The first is reduction: a column has more rows than the plot has pixels, so which rows actually decide what the reader sees? The answers differ by mark. A line wants the rows that preserve its shape; a signal envelope wants the extremes of every pixel column, because a spike one sample wide is the reason someone opened the chart; a point cloud wants no rows at all but a count per cell, drawn as an image. See LTTB, MinMax and Grid.

The second is distribution: the rows are not too many, they are the wrong shape. A histogram, a density estimate, an ECDF and a smoothing fit all replace the observations with a summary of where they are — which is a different reading of the same column rather than a cheaper one. See Bin, KDE, ECDF, Loess and Hex.

Neither family changes a scale's domain by itself. A geom decides what its axis describes: a decimating layer trains on every row and reduces only when it draws, so the axis reports what the data holds rather than what survived; a histogram trains on the counts, because the counts are what it draws.

Purity and determinism

Everything here is a pure function of its arguments. Nothing reads a clock, a map iteration order or math/rand: a parallel render has to be byte identical to a serial one (docs/adr/0012-parallel-panels.md), and a reduction that reached for a random number would quietly end that. Every function has a test that runs it twice and compares.

The functions come in pairs: LTTB and AppendLTTB, Bin and AppendBin. The Append forms write into a caller-owned slice, which is how a chart redrawn every frame keeps its per-frame allocations flat.

There is no Stat interface

Deliberately. A stat here is a function a geom calls in its Train, not a stage between the data and the layer that a caller plugs a different one into. A pluggable stat would have to know which axis it decides, how the layer treats a missing value and what the theme wants — which is exactly what this package must not know. A caller with a summary of their own writes a geom that calls it (see docs/adr/0028-distribution-stats.md), and the function itself belongs here only if it is numbers in and numbers out.

Index

Constants

View Source
const DefaultKDEPoints = 128

DefaultKDEPoints is how finely a density is evaluated when the caller names no resolution. It is well above the number of pixels a violin is drawn across, so the curve is limited by the bandwidth rather than by the sampling.

View Source
const DefaultLoessPoints = 64

DefaultLoessPoints is how many abscissae a fit is evaluated at when the caller names no resolution.

View Source
const DefaultSpan = 0.75

DefaultSpan is the fraction of the data one local fit sees when the caller names none. Three quarters is Cleveland's own default and is the right starting point for a trend line: enough neighbours that the fit is a trend rather than an interpolation, few enough that it still bends.

View Source
const NoParent = -1

NoParent is the parent of a root. Any negative index means the same thing; this is the one to write.

View Source
const SankeySweeps = 6

SankeySweeps is how many times a Sankey relaxes its node positions.

It is a constant rather than a tolerance, and that is the whole point. A relaxation that ran "until it settled" would make the picture depend on floating-point noise, and a chart whose panels are built on separate goroutines has to be byte-identical to one built serially (docs/adr/0012-parallel-panels.md). Six sweeps is where the arrangement stops visibly improving on the flows this was tested against; a seventh moves nothing a reader can see.

Variables

This section is empty.

Functions

func AppendDepth

func AppendDepth(dst []int, parent []int) []int

AppendDepth is Depth writing into dst, which it truncates and grows as needed.

func AppendLTTB

func AppendLTTB[F Float](dst []int, x, y []F, threshold int) []int

AppendLTTB is LTTB appending into dst.

func AppendMinMax

func AppendMinMax[F Float](dst []int, x []F, columns int, ys ...[]F) []int

AppendMinMax is MinMax appending into dst.

func AppendPartition

func AppendPartition(lo, hi []float64, total []float64, parent, depth []int) ([]float64, []float64)

AppendPartition is Partition writing into lo and hi, which it truncates and grows as needed.

func AppendRollup

func AppendRollup(dst []float64, value []float64, parent, depth []int) []float64

AppendRollup is Rollup writing into dst, which it truncates and grows as needed. It is the form a geom calls, because a chart redrawn every frame should not allocate a total per node per frame.

func AppendStackOffsets

func AppendStackOffsets(dst []float64, mode StackMode, series [][]float64) []float64

AppendStackOffsets is StackOffsets writing into dst, which it truncates and grows as needed. It is the form a geom calls, because a chart redrawn every frame should not allocate a baseline per frame.

func Depth

func Depth(parent []int) []int

Depth returns how far each node is from a root: zero for a root, one more than its parent for everyone else.

parent[i] is the index of i's parent, or NoParent — or any index outside the list, which means the same. A node that cannot be reached from a root is on a cycle, and comes back as -1 rather than as a guess or a panic: a hierarchy with a cycle is not a hierarchy, and the caller is the one that can say so in terms of its own data.

It sweeps one level at a time rather than recursing, so a hierarchy deep enough to blow a stack does not, and it costs one pass per level. That is a bounded traversal rather than an iteration to convergence: it visits every reachable node exactly once and stops when a level adds nobody, which makes it a pure function of its input in the sense ADR 0012 requires.

func FreedmanDiaconis

func FreedmanDiaconis(sorted []float64) int

FreedmanDiaconis returns the bin count the Freedman–Diaconis rule chooses for an ascending column: the interval divided by 2·IQR·n^(-1/3).

It reports 0 when the rule has no answer — fewer than two values, or an interquartile range of zero, which is what a column of mostly one value has. A caller falls back to Sturges there rather than dividing by nothing.

The column must be sorted ascending, for the reason Quantile is: sorting here would mean either mutating the caller's data or allocating a copy of it on every frame.

func LTTB

func LTTB[F Float](x, y []F, threshold int) []int

LTTB reduces a series to at most threshold rows with the largest-triangle-three-buckets algorithm, returning the row numbers it kept in ascending order.

LTTB splits the rows into equal-count buckets and keeps, from each, the row forming the largest triangle with the row kept before it and the mean of the bucket after it. Area is a proxy for "how much of the line's shape this row carries", which is why peaks and inflections survive a reduction that drops nine points in ten while a running average flattens them.

It is lossy, and honestly so: the result is a subset of real rows, never an invented one, so every vertex the reader sees is a measurement that was taken. The first and last rows are always kept.

Rows are assumed to be ordered along x. That is not an extra condition — a line geom already connects consecutive rows, so a series it can draw is a series this can bucket.

func MinMax

func MinMax[F Float](x []F, columns int, ys ...[]F) []int

MinMax reduces a series to the rows that decide what each column of pixels looks like, returning the row numbers it kept in ascending order.

Each column keeps four rows at most: the one that entered it, the smallest and the largest value in it, and the one that left. Keeping the extremes is what makes this reduction visually lossless for a signal — a spike one sample wide still reaches full height, where LTTB would weigh it against its neighbours and might drop it. Keeping the entry and the exit is what makes the segments between columns land where the data actually crosses them.

ys is every value column the mark occupies: one for a line, two for a band, so that the kept rows bound the whole shape rather than one edge of it.

Rows are assumed to be ordered along x, as in LTTB.

func NormalQuantile

func NormalQuantile(p float64) float64

NormalQuantile is the inverse standard normal CDF. It returns infinities at 0 and 1, and NaN outside [0,1]. A QQ plot uses interior plotting positions.

func Partition

func Partition(total []float64, parent, depth []int) (lo, hi []float64)

Partition returns each node's half-open span [lo, hi) of the unit interval: the fraction of the whole hierarchy its subtree occupies, and where that fraction sits.

It is the layout an icicle and a sunburst draw directly — a node's span across, its depth out — and the ranges a treemap hands to Squarify one sibling group at a time. total comes from Rollup.

Siblings are laid out in index order, which is the order their rows appeared in the source table. Children fill their parent's span from its start; a parent that carries a value of its own beyond its children's keeps the remainder, which is what makes an unaccounted-for share visible rather than invisible.

Every span is zero when the totals sum to nothing, rather than a division by zero: a hierarchy of zeroes has no shape, and the caller draws nothing.

func Quantile

func Quantile(sorted []float64, p float64) float64

Quantile returns the p'th quantile of an ascending slice by linear interpolation between the two closest order statistics.

This is the definition R calls type 7 and NumPy uses by default. Choosing it deliberately matters: the nine standard definitions disagree by a visible amount on the small samples a boxplot or a bandwidth rule is computed from, and a box that does not match the reader's own analysis is worse than no box.

The slice must already be sorted ascending; sorting it here would mean either mutating the caller's column or allocating a copy of it per frame.

func Rollup

func Rollup(value []float64, parent, depth []int) []float64

Rollup returns each node's total: its own value plus every descendant's.

value[i] is what row i carries on its own, which for the usual hierarchy — where only the leaves are measured — is zero for every internal node. depth comes from Depth; a node it left at -1 is on a cycle and contributes to nothing, including itself.

A negative value is summed as it stands rather than clamped. A tree that mixes signs has no sensible area to draw and the caller is where that is worth saying; silently taking the absolute value would draw a picture the numbers do not support.

func Silverman

func Silverman(sd, iqr float64, n int) float64

Silverman returns the bandwidth Silverman's rule of thumb chooses: 0.9·min(σ, IQR/1.349)·n^(-1/5).

Both spread measures are the caller's to supply, and iqr may be 0 to use the standard deviation alone. That is not a shortcut — computing an interquartile range means sorting, and a chart redrawn every frame sorts into a buffer it keeps rather than into one this package would allocate. A caller with a sorted column passes Quantile of it; one without passes 0 and gets the σ-only form, which is the rule as Silverman first wrote it.

The robust minimum matters on real data: a single outlier inflates σ without moving the IQR, and a bandwidth taken from σ alone then smooths the whole distribution flat because one row was far away.

func StackOffsets

func StackOffsets(mode StackMode, series [][]float64) []float64

StackOffsets returns the baseline of each column of a stack.

series[g][p] is group g's value at position p; every group must have the same number of positions, and the positions must be in axis order, because StackWiggle reads each column against the one before it. The result has one baseline per position: the first group's segment runs from base[p] to base[p] + series[0][p], the second from there, and so on.

It is a pure function of its input. Nothing here reaches for a map or for math/rand, so a parallel render stays byte-identical to a serial one — see docs/adr/0012-parallel-panels.md.

func StdDev

func StdDev(vs []float64) float64

StdDev returns the sample standard deviation of vs, ignoring NaN and infinities. It is 0 for fewer than two usable values.

The two-pass form is deliberate. The textbook single-pass identity E[x²]-E[x]² cancels catastrophically on a column whose values are large and whose spread is small — a timestamp column in nanoseconds is exactly that — and a bandwidth rule fed a negative variance produces a density of NaN.

func Sturges

func Sturges(n int) int

Sturges returns the bin count Sturges's rule chooses for n observations: ceil(log2 n) + 1.

It is the default because it needs nothing but the count — no sort, no spread — so a histogram redrawn every frame costs a pass over the column rather than a copy of it. It assumes roughly normal data and under-bins a large or a skewed sample; FreedmanDiaconis is the answer to that, and it is a separate function because it asks the caller for a sorted column.

Types

type Bucket

type Bucket struct {
	Lo, Hi float64
	Count  int
}

Bucket is one bin of a histogram: the interval it covers and how many observations fell in it.

The interval is half open, [Lo, Hi), except for the last bucket of a run, which includes its upper bound — otherwise the largest observation in a column would fall in no bucket at all, which is the one value a reader is certain to look for.

func AppendBin

func AppendBin(dst []Bucket, vs []float64, lo, hi float64, n int) []Bucket

AppendBin is Bin writing into a caller-owned slice. dst is truncated first, so a chart redrawn every frame bins into the same memory.

func Bin

func Bin(vs []float64, lo, hi float64, n int) []Bucket

Bin counts vs into n equal-width buckets over [lo, hi].

Pass lo >= hi to take the interval from the data, and n <= 0 to let Sturges choose the count. Values outside the interval are not counted: a histogram over an explicit range is a statement about that range, and silently folding the tails into the end buckets would misreport both.

func (Bucket) Mid

func (b Bucket) Mid() float64

Mid is the bucket's centre, which is where a histogram bar is positioned.

type Cell

type Cell struct {
	Col, Row int
	X, Y     float64
	Count    uint32
}

Cell is one populated hexagon: where it is and how many rows landed in it.

type Chord

type Chord struct {
	// Arcs has one span per node, in node order. A node no edge touches gets an
	// empty span rather than being dropped, so a caller can index Arcs by node.
	Arcs []Span
	// Ribbons has one entry per edge, in the order the edges were given.
	Ribbons []Ribbon
	// contains filtered or unexported fields
}

Chord lays an edge list out around the unit interval: one arc per node, sized by the traffic through it, and one ribbon per edge joining a slice of one arc to a slice of another.

Wrapped round a circle by a polar coord this is a chord diagram; left straight under a Cartesian one it is an arc diagram, whose edges rise off a rail instead of crossing a disc. The layout does not know which — it works in the unit interval and the coordinate system decides what that looks like.

It is a struct with a Chord.Reset rather than a pair of functions for the reason Sankey and Hex are: the layout keeps a cursor per node, and a chart redrawn every frame should reuse it rather than allocate it again.

Order

Arcs go round in node order and ribbons leave them in edge order, both of which are the order the caller's rows appeared in. Nothing here sorts, and nothing here reads a map: the picture is a pure function of the table (docs/adr/0012-parallel-panels.md). A caller that wants the busiest node first sorts its own rows.

The zero Chord is unusable; call Chord.Reset first.

func (*Chord) Reset

func (c *Chord) Reset(from, to []int, value []float64, nodes int, pad float64)

Reset lays out the edge list from[i] → to[i] carrying value[i], over the given number of nodes, leaving pad of the unit interval between adjacent arcs.

An edge naming a node outside [0, nodes), or carrying a value that is not positive, takes no room and gets an empty ribbon. A self-edge is not a special case: it takes a slice of its node's arc at each end, which draws as a loop.

type Float

type Float interface{ ~float32 | ~float64 }

Float is the coordinate type these functions accept.

Both widths are here because both are real: a geom decimates in device space, where coordinates are float32 and a pixel is the unit that matters, while a caller aggregating before it ever reaches a chart has float64 data. Converting one to the other to cross this boundary would cost a copy of the whole column.

type Grid

type Grid struct {
	// Cols and Rows are the grid's shape.
	Cols, Rows int
	// X0, Y0, X1, Y1 are the region the grid covers. X0 may exceed X1, and Y0
	// may exceed Y1: a device-space Y axis runs downwards, and a grid over it
	// has to bin the same way round as the axis it covers.
	X0, Y0, X1, Y1 float64

	// Counts holds Cols*Rows cells in row-major order.
	Counts []uint32
	// Max is the busiest cell's count, and N the number of rows binned.
	Max uint32
	N   int
}

Grid is a two-dimensional histogram: how many rows fall in each cell of a regular grid over a rectangle.

It is the aggregate behind the density raster. A scatter of ten million points has no honest drawing as ten million markers — they overplot, the last one drawn wins, and the picture says more about row order than about the data. Counting per cell and painting the counts says how many rows are there, which is the thing the marks were standing in for.

The zero Grid is unusable; call Grid.Reset first. Reset keeps the count buffer, so a chart redrawn every frame bins into the same memory.

func BinGrid

func BinGrid[F Float](g *Grid, xs, ys []F) *Grid

BinGrid counts every row of xs against ys into g, returning it.

The coordinates are whatever g's rectangle is in — device space for a geom binning what it was about to draw, data space for a caller aggregating before it builds a chart.

It is BinGrid rather than Bin because Bin is the one-dimensional histogram, which is what "bin" means without a qualifier. The three binners are named after what they fill: BinGrid, BinHex and Bin itself.

func (*Grid) Add

func (g *Grid) Add(x, y float64) bool

Add counts one position, reporting whether it landed inside the grid.

func (*Grid) At

func (g *Grid) At(col, row int) uint32

At returns the count in one cell.

func (*Grid) Cell

func (g *Grid) Cell(x, y float64) (col, row int, ok bool)

Cell returns the cell a position falls in, and whether it is inside the grid. A point on the far edge belongs to the last cell rather than to a cell that does not exist.

func (*Grid) Fraction

func (g *Grid) Fraction(count uint32, s Scaling) float64

Fraction maps a count onto [0, 1] against the busiest cell. An empty cell is 0 under every scaling, which is what keeps the background of a density raster empty rather than faintly painted.

func (*Grid) Raster

func (g *Grid) Raster(dst *image.NRGBA, s Scaling, paint func(t float64) color.NRGBA) *image.NRGBA

Raster paints the grid, one pixel per cell, and returns the image.

paint is called once per non-empty cell with that cell's Grid.Fraction; empty cells are left fully transparent so the plot's own background and grid read through. dst is reused when it is large enough, so a chart redrawn every frame paints into the same pixels; pass nil to have one allocated.

func (*Grid) Reset

func (g *Grid) Reset(cols, rows int, x0, y0, x1, y1 float64)

Reset prepares g for cols by rows cells over the given rectangle, clearing any previous counts and reusing the existing buffer when it is large enough.

type Hex

type Hex struct {
	// Radius is a cell's circumradius: the distance from its centre to a
	// vertex, and half its height.
	Radius float64
	// Cols and Rows are the lattice's shape.
	Cols, Rows int
	// X0 and Y0 are the centre of cell (0, 0).
	X0, Y0 float64

	// MinX, MinY, MaxX and MaxY are the rectangle the lattice covers,
	// normalised so that the minimum is the lower corner on both axes. A
	// device-space Y axis runs downwards and a caller may hand the two corners
	// over in either order; a hexagonal lattice is symmetric about both, so
	// which way round it was given changes nothing but which points are inside.
	MinX, MinY, MaxX, MaxY float64

	// Counts holds Cols*Rows cells in row-major order.
	Counts []uint32
	// Max is the busiest cell's count, and N the number of rows binned.
	Max uint32
	N   int
}

Hex is a hexagonal lattice over a rectangle: how many rows fall in each hexagonal cell.

It is Grid with a better tiling. A square grid has four neighbours at one distance and four at another, so a cloud binned into it grows faint crosses and diagonal seams that are artefacts of the bins rather than features of the data; a hexagon has six neighbours all the same distance away, which is why this is the aggregate a hexbin chart is made of. The other difference is what is drawn: a density raster paints one pixel per cell, and a hexbin draws a mark per cell, so the cells are counted in the hundreds rather than in the hundreds of thousands.

The lattice is pointy-topped: a cell is 2·Radius tall and √3·Radius wide, rows are 1.5·Radius apart, and odd rows are offset half a cell to the right.

The zero Hex is unusable; call Hex.Reset first. Reset keeps the count buffer, so a chart redrawn every frame bins into the same memory.

func BinHex

func BinHex[F Float](h *Hex, xs, ys []F) *Hex

BinHex counts every row of xs against ys into h, returning it.

The coordinates are whatever h's rectangle is in — device space for a geom binning what it was about to draw, which is what makes the cells regular hexagons on screen rather than hexagons stretched by the axes.

func (*Hex) Add

func (h *Hex) Add(x, y float64) bool

Add counts one position, reporting whether it landed inside the lattice.

func (*Hex) At

func (h *Hex) At(col, row int) uint32

At returns the count in one cell.

func (*Hex) Cell

func (h *Hex) Cell(x, y float64) (col, row int, ok bool)

Cell returns the cell a position falls in, and whether it is inside the lattice.

The nearest centre is found the way d3-hexbin finds it: round to the nearest row, round to the nearest column of that row, and then — only when the point is in the band where two rows' cells interlock — compare the candidate against the one diagonally across from it. That band is the middle third of a row's height, which is where |py - pj|·3 > 1.

The comparison is not d3's, and the difference is load-bearing. Both work in lattice coordinates, where a column step is 1 and a row step is 1 — but a column step is √3·Radius on screen and a row step is 1.5·Radius, so comparing px² + py² there measures a distance in two different units and hands some points to a cell that is not their nearest. The vertical term therefore carries (dy/dx)², which is exactly 3/4, and [TestEveryHexPointLandsInItsNearestCell] is what holds it: without it the picture grows seams along every second row.

func (*Hex) Cells

func (h *Hex) Cells(dst []Cell) []Cell

Cells appends the populated cells to dst, in row-major order, and returns it. dst is truncated first.

Row-major and not "whichever order a map produced them in": a parallel render has to be byte identical to a serial one, so the order marks are emitted in cannot depend on hashing. See docs/adr/0012-parallel-panels.md.

func (*Hex) Center

func (h *Hex) Center(col, row int) (x, y float64)

Center returns the centre of one cell.

func (*Hex) Fraction

func (h *Hex) Fraction(count uint32, s Scaling) float64

Fraction maps a count onto [0, 1] against the busiest cell, exactly as Grid.Fraction does.

func (*Hex) Reset

func (h *Hex) Reset(radius, x0, y0, x1, y1 float64)

Reset prepares h for cells of the given radius over the rectangle with corners (x0, y0) and (x1, y1), clearing any previous counts and reusing the existing buffer when it is large enough.

A radius of zero or less is meaningless and is replaced with one, which draws a lattice of unit cells rather than dividing by nothing.

type Point

type Point struct{ X, Y float64 }

Point is a position a distribution stat computed: a bin's centre and its count, a density's argument and its value, a fit's abscissa and its estimate.

It is float64 and not ir.Point because none of it is a device coordinate — stat knows about numbers and nothing else, and a density evaluated in float32 would lose the tail of a narrow kernel.

func AppendECDF

func AppendECDF(dst []Point, sorted []float64) []Point

AppendECDF is ECDF writing into a caller-owned slice. dst is truncated first.

One point per *distinct* value, not per row: a column with a thousand copies of one number is one step of height 1000/n, and emitting a thousand points at one X would draw a thousand identical vertices and report a thousand marks to a hit test.

func AppendKDE

func AppendKDE(dst []Point, vs []float64, bw, lo, hi float64, n int) []Point

AppendKDE is KDE writing into a caller-owned slice. dst is truncated first, so a chart redrawn every frame estimates into the same memory.

The cost is one exponential per observation per evaluation point. That is fine for the columns a violin or a ridgeline is drawn from — a group of a few thousand rows over a hundred points — and it is why nothing here reaches for a density estimate on a million-row column, where Grid is the honest answer.

func AppendLoess

func AppendLoess(dst []Point, xs, ys []float64, span float64, n int) []Point

AppendLoess is Loess writing into a caller-owned slice. dst is truncated first, so a chart redrawn every frame fits into the same memory.

The window is walked forward rather than searched for: the abscissae ascend, so the neighbourhood of the next one starts at or after this one's. That is what keeps a fit over a long column linear in it rather than quadratic.

func AppendQQ

func AppendQQ(dst []Point, sorted []float64, quantile func(float64) float64) []Point

AppendQQ is QQ reusing dst, which is truncated before appending. A quantile function returning a non-finite value omits that point without changing the plotting positions of the other observations.

func ECDF

func ECDF(sorted []float64) []Point

ECDF returns the empirical cumulative distribution of an ascending column: one point per distinct value, whose Y is the fraction of observations at or below it.

It is the distribution plot that invents nothing. A histogram picks bin edges and a density picks a bandwidth, and both choices change the picture; an ECDF has no parameter at all, so two of them drawn together are two datasets compared rather than two smoothing choices compared. That is what makes it worth having beside Bin and KDE rather than instead of either.

The column must be sorted ascending, for the reason Quantile is: sorting it here would mean either mutating the caller's data or allocating a copy of it on every frame. NaN and infinities are ignored, which is why the fractions are taken against the number of usable values rather than against len.

func KDE

func KDE(vs []float64, bw, lo, hi float64, n int) []Point

KDE evaluates a Gaussian kernel density estimate of vs at n equally spaced points across [lo, hi].

A histogram answers "how many are here" and depends on where the bin edges happen to fall; a density estimate answers "how thick is the data here" and does not. That is the whole reason a violin is a violin rather than two histograms back to back: the shape must not change when the first bin edge moves half a bin.

bw is the kernel bandwidth in the data's own units, and it is the one number that decides what the answer looks like — too small and the estimate is a comb of the observations, too large and every distribution is a hump. Pass bw <= 0 to have Silverman choose it. Pass lo >= hi to take the interval from the data, and n <= 0 for DefaultKDEPoints.

The result is normalised as a density: it integrates to 1 over the real line, not over [lo, hi], so two groups drawn on one axis are comparable in the way their areas are.

func Loess

func Loess(xs, ys []float64, span float64, n int) []Point

Loess fits a locally weighted linear regression of ys on xs and evaluates it at n equally spaced points across the data.

It is the trend line that does not assume a shape. A straight fit answers "is this rising", which is a question about the whole column at once; loess answers "what is it doing here", by fitting a line through the neighbours of each abscissa and weighting them by how near they are. The span decides how many neighbours that is, as a fraction of the rows in (0, 1]: small spans follow the data, large ones flatten it, and the choice is the reader's rather than this function's.

What the columns must be

xs ascending, both columns finite, and the same length — the shorter one wins if they are not. That is a narrower contract than the rest of this package takes and it is deliberate: sorting and hole-filling both need a buffer, a caller redrawing a chart every frame already keeps one, and doing it here would allocate a copy of the column per frame to save the caller a loop. See github.com/timzifer/figure/geom.Trend, which is that caller.

Pass span <= 0 for DefaultSpan and n <= 0 for DefaultLoessPoints.

The fit is locally *linear* — degree one, not degree two. A quadratic local fit tracks a peak more faithfully and overshoots at the ends of the data, where a trend line is read most confidently and is least supported; the linear form is the conservative half of that trade.

func QQ

func QQ(sorted []float64, quantile func(float64) float64) []Point

QQ compares ascending observations with a theoretical quantile function. X holds theoretical quantiles and Y holds observations, including ties. Plotting positions are (i+0.5)/n, strictly inside (0,1), so distributions with infinite endpoints still give finite tail positions. NaN and infinities in the sample are ignored. A nil quantile uses the standard normal.

Input must already be sorted. The function never edits the caller's sample.

func Vertices

func Vertices(dst []Point, cx, cy, radius float64) []Point

Vertices writes the six corners of a cell of the given radius, centred on (cx, cy), into dst — which is truncated first and returned.

It is here rather than in a geom because the vertices are the lattice's own geometry: a cell drawn a degree out of phase with the one it was counted in would tile with gaps. The first vertex is the top one, and they run clockwise in a coordinate system whose Y axis points down.

type Ribbon

type Ribbon struct {
	Src, Dst Span
}

Ribbon is one edge's two ends: the span it takes on its source's arc and the span it takes on its target's. The two are the same width, because they are the same quantity read twice — which is exactly what a chord diagram asserts and what tells it apart from two bar charts side by side.

type Sankey

type Sankey struct {
	// Nodes has one entry per node, indexed as the caller's edge list indexes
	// them.
	Nodes []SankeyNode
	// Flows has one entry per link, in the order the links were given.
	Flows []SankeyFlow

	// Layers is how many columns the diagram has, and Cyclic reports an edge
	// list that is not a DAG — in which case Nodes and Flows are empty, because
	// a flow that returns to where it came from has no column to stand in.
	Layers int
	Cyclic bool
	// contains filtered or unexported fields
}

Sankey lays an edge list out as a flow diagram: nodes in columns, links as bands whose thickness is their value.

It is a struct with a Sankey.Reset rather than a pair of functions because the layout has working state the size of the data — a value per node, a cursor per node — and a chart redrawn every frame should reuse it rather than allocate it again. That is the shape Hex has, for the same reason.

What it decides, and what it does not

It decides two things: which column each node stands in, and how far down each node and each band sits. It does **not** decide the order of the nodes within a column — that is the order they were given in, which is the order their rows appeared in the source table. Reordering nodes to reduce crossings would mean a sort per sweep, and a sort is where a layout stops being a pure function of its input and starts depending on how a tie was broken. A caller that wants a different order sorts its own rows and hands them over that way.

The zero Sankey is unusable; call Sankey.Reset first.

func (*Sankey) Reset

func (s *Sankey) Reset(from, to []int, value []float64, nodes int, pad float64)

Reset lays out the edge list from[i] → to[i] carrying value[i], over the given number of nodes, leaving pad of the unit interval between adjacent nodes in a column.

pad is a fraction rather than a length because everything here is in the unit square: the geom knows how wide the panel is and this does not.

An edge naming a node outside [0, nodes) is skipped, and an edge list with a cycle sets Sankey.Cyclic and lays nothing out.

type SankeyFlow

type SankeyFlow struct {
	SrcLo, SrcHi float64
	DstLo, DstHi float64
}

SankeyFlow is one link's band, given as the span it occupies where it leaves its source and the span it occupies where it enters its target. The two differ, which is what makes the band a ribbon rather than a rectangle.

type SankeyNode

type SankeyNode struct {
	// Layer is the node's column, counting from zero at the sources.
	Layer int
	// Lo and Hi are the node's near and far edge, in [0, 1].
	Lo, Hi float64
	// Value is the flow through the node: the greater of what enters it and
	// what leaves it.
	Value float64
}

SankeyNode is one node's place in a flow diagram: which column it stands in, and the span of the unit interval it fills.

type Scaling

type Scaling uint8

Scaling is how a cell's count becomes a fraction of the busiest cell.

const (
	Log Scaling = iota
	Sqrt
	Linear
)

The scalings. Log is the default because it is the one that shows anything: counts over a real point cloud span orders of magnitude, and under a linear mapping every cell but the densest few rounds to the background.

type Span

type Span struct {
	Lo, Hi float64
}

Span is a half-open interval [Lo, Hi) of the unit interval.

func (Span) Width

func (s Span) Width() float64

Width is the span's extent, which is what a caller comparing two of them actually wants to read.

type StackMode

type StackMode uint8

StackMode is where the bottom of a stack sits.

Stacking itself is a running sum and needs no help. What differs between a stacked bar chart, a 100 % chart and a streamgraph is only the *baseline* each column of the stack is measured from, which is what this package computes: numbers in, numbers out, no scales and no geometry.

const (
	// StackZero puts the bottom of every stack on zero. It is the ordinary
	// stacked bar or area.
	StackZero StackMode = iota

	// StackFill normalises each column to sum to one, so the stack fills the
	// axis and the chart reads as proportions. The baseline is still zero;
	// the caller scales the values.
	StackFill

	// StackSilhouette centres each column on zero, which is the symmetric
	// baseline a ThemeRiver is drawn about.
	StackSilhouette

	// StackWiggle minimises the total slope of the interior boundaries, which
	// is what makes a streamgraph readable: the eye follows a band by its
	// thickness, and a band that is also climbing steeply is hard to follow.
	// Byron & Wattenberg, "Stacked Graphs — Geometry & Aesthetics" (2008).
	StackWiggle
)

The stack baselines.

type Tile

type Tile struct {
	X0, Y0, X1, Y1 float64
}

Tile is one rectangle of a treemap: the box a value was given.

It is four float64s rather than an ir.Rect because stat does not import ir, and it is a value rather than a pointer because a treemap of a hundred thousand nodes is a slice of these and nothing else.

func AppendSquarify

func AppendSquarify(dst []Tile, values []float64, x0, y0, x1, y1 float64) []Tile

AppendSquarify is Squarify writing into dst, which it truncates and grows as needed. It is the form a geom calls, because a chart redrawn every frame should not allocate a tile per node per frame.

func Squarify

func Squarify(values []float64, x0, y0, x1, y1 float64) []Tile

Squarify packs values into a rectangle, one tile per value, each with an area proportional to its value and an aspect ratio as close to square as the packing allows.

It is the squarified treemap of Bruls, Huizing and van Wijk (2000): tiles are gathered into a row along the rectangle's shorter side for as long as adding one improves the row's worst aspect ratio, then the row is laid out and the rest of the rectangle is packed the same way. The alternative — slicing the rectangle in one direction — produces slivers that a reader cannot compare, which is the whole reason this algorithm exists.

It lays one level out: the values are one node's children, and a hierarchy is drawn by calling it once per sibling group with the box that group's parent was given. Recursing here would make it a tree walker in a package that knows about numbers and nothing else.

Order is the caller's

Values are packed in the order they are given, and that order is what decides which tiles end up beside which. Descending order gives the best aspect ratios and is what a treemap usually wants; source order keeps the picture stable as the numbers move, which is what an animated one wants. This function sorts neither way — sorting would mean mutating the caller's column or allocating a copy of it per frame, and the caller already has a buffer (see CONTRIBUTING, "Take ordered input rather than sorting").

A value that is zero or negative gets an empty tile where it falls, rather than being dropped: the result has one tile per value, at the same index, so a caller can read a tile against the row it came from without a second mapping.

Jump to

Keyboard shortcuts

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