refract

package module
v1.8.0 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: MIT Imports: 20 Imported by: 0

README

A Go gopher holding a prism that splits a white beam into a spectrum of charts

refract

This library is now github.com/timzifer/figure

Development continues under a new name and a new import path. v1.8.0 is the last release here — it is v1.7.0 plus this notice.

go get github.com/timzifer/figure

Everything under this path keeps working and keeps its tags. It receives no fixes and no features.

Migrating is an import-path change and a compiler pass. Beyond the path, what moved is the shape of the seams a caller implements, and data.Source. The figure v0.8.0 release notes carry the table; the compiler finds every site.

Why: the old name argued a thesis about prisms the library had outgrown, and the three seams a third dimension breaks were cheaper to correct under a new import path than under a major version carrying a /v2 suffix for the rest of the library's life. The reasoning is ADR 0059.

CI Coverage Go Reference

A grammar-driven plotting library for Go: one model, many backends, runs everywhere — built on the GoGPU stack.

Status: v1.7.0, released. Every milestone through v1.0 has shipped, and the v1 API audit is in: what it asked to change before the freeze has changed. The API was frozen at the v1.0.0 tag and follows semver from here, so a breaking change means a major version and a deprecation cycle precedes it. Three milestones landed after v1.2.0 and its coord.Smith, and each is additive: v1.3.0, the six gaps that were not chart types — a null that is a missing value in a text or temporal column, a tick format and a language a document can choose, an interval mark, a second axis in either direction, and a PDF that carries the font its labels need; v1.4.0, bucket E, the last one in the catalogue: geom.Treemap, geom.Icicle, geom.Sankey and geom.Arc, which are also a sunburst and a chord diagram once the coordinate stage has had them (ADR 0039); v1.5.0, two diagnostics — geom.AvoidOverlap for panel-local label placement (ADR 0040) and geom.QQ for normal quantile-quantile plots (ADR 0041); and v1.6.0, colour ramps that compress their domain (scale.ColorLog) or cut it into classes (scale.Threshold, scale.Quantize, scale.Quantile), which is what a heatmap over counts spanning orders of magnitude needed (ADR 0042); and v1.7.0, identity, transitions, and guides that answer to a pointergeom.KeyBy names the column that says which row is which, which is what animation had been blocked on for six milestones (ADR 0043, ADR 0044); the host wires one chart to another and refract supplies the two ends of the wire (ADR 0045); the chart owns an overlay — a crosshair, a highlight, a brush, a tooltip (ADR 0046); and a legend, a colourbar and a size key can be pointed at and clicked (ADR 0047, ADR 0048). v1.3.0 and v1.4.0 tag the core alone; the nested modules are tagged at v1.5.0, v1.6.0 and v1.7.0 with it: backend/gg and backend/window share the core's version, the opt-in GPU tier is at v0.3.0, and the Arrow adapter at arrow/v18.0.4, whose major is Arrow's. See CONCEPT.md for the design and the road ahead.

The name is the thesis: one beam enters a prism, a spectrum comes out. One chart specification enters refract, a spectrum of output formats comes out.

A damped sine over a time axis, dark theme


What it is

One declarative chart specification, rendered through interchangeable backends. The core is pure Go with no dependencies at all — not "no cgo", literally nothing outside the standard library — and emits both vector formats, SVG and PDF. Add one module and the same specification renders to PNG and JPEG through gogpu/gg, still with CGO_ENABLED=0.

Dependencies Output
github.com/timzifer/refract stdlib only SVG, PDF, browser canvas
github.com/timzifer/refract/backend/gg GoGPU (gg), x/image — zero CGO PNG, JPEG, an in-memory surface
github.com/timzifer/refract/backend/window GoGPU (gogpu, gg) — zero CGO a native window
github.com/timzifer/refract/backend/gg/gpu GoGPU (gg/gpu, wgpu) — zero CGO — (switches the GPU tier on)
github.com/timzifer/refract/arrow/v18 apache/arrow-go — zero CGO — (a data source)

The browser is in the core too, because it needs nothing to be: a canvas 2D context is reached through syscall/js, which is the standard library (ADR 0017). Everything else is behind the same ir.Backend interface, in a module of its own, so what a program links is what it asked for: a server that renders SVG links nothing but the standard library, and a desktop program that opens a window links a window layer.

Install

The release check in CONTRIBUTING.md verifies each module outside the development workspace before it is tagged, so a published require line names a core that exists.

go get github.com/timzifer/refract                  # core: SVG and PDF, stdlib only
go get github.com/timzifer/refract/backend/gg       # raster: PNG and JPEG
go get github.com/timzifer/refract/backend/window   # a native window
go get github.com/timzifer/refract/backend/gg/gpu   # optional: the GPU tier
go get github.com/timzifer/refract/arrow/v18        # optional: plot Arrow data

Go 1.25 or newer (why).

Quick start

package main

import (
	"log"
	"math"

	"github.com/timzifer/refract"
	"github.com/timzifer/refract/geom"
	"github.com/timzifer/refract/palette"
	"github.com/timzifer/refract/scale"
	"github.com/timzifer/refract/theme"
)

func main() {
	xs := make([]float64, 200)
	ys := make([]float64, 200)
	for i := range xs {
		xs[i] = float64(i) / 20
		ys[i] = math.Sin(xs[i])
	}

	p := refract.New(
		refract.Theme(theme.Dark),
		refract.Size(800, 400),
		refract.Title("Signal"),
		refract.YTitle("amplitude"),
	)
	p.X(scale.Linear(scale.Nice()))
	p.Y(scale.Linear(scale.Nice()))
	p.Add(geom.Line(
		refract.Float64Columns(map[string][]float64{"x": xs, "y": ys}),
		geom.X("x"), geom.Y("y"),
		geom.Color(palette.Blue),
		geom.Tension(0.4),
	))

	if err := p.Render(refract.SVG("signal.svg")); err != nil {
		log.Fatal(err)
	}
}

For PDF or raster, swap the target — nothing else changes:

err := p.Render(refract.PDF("signal.pdf"))          // still stdlib only

import ggbackend "github.com/timzifer/refract/backend/gg"
err := p.Render(ggbackend.PNG("signal.png"))

A runnable version is in examples/signal.

Every figure below is rendered by backend/gg/cmd/gallery and re-checked in CI, so a picture here cannot drift away from the code that produced it.

Three series with a legend Two groups of scattered points
A response time histogram A damped sine on a time axis
An estimate with a shaded interval A step chart of replica counts
Bars by region, coloured by value with a colourbar Latency distributions as boxplots
Two growth curves on a log axis A series read against thresholds and a shaded window
Throughput faceted into one panel per region Four subplots on one dark canvas
A quarter of a million samples drawn as a clean line A million points drawn as a density raster
Standard error curves labelled with typeset notation Revenue stacked by product, one layer over a long table
Traffic by channel as a streamgraph Calls per hour as a heatmap of coloured cells
Browser share as a donut Two designs compared on five axes as a radar chart
Spend by team as a donut whose slices reach as far as each team used of its budget, with the team that went over broken out of the ring Request latency as a histogram
Latency by service as violins, one per region within each service A year of daily maxima as a ridgeline, one density per month
Scores by cohort as a beeswarm, every observation placed Scores by cohort as three empirical CDFs on one axis
Fifty thousand observations binned into hexagons with a loess trend through them Income against life expectancy as bubbles sized by population, with a size key beside the legend
Revenue as bars against a left axis and margin as a percentage line against a right one
An oven temperature curve read against elapsed minutes along the bottom and cycle number along the top A patch antenna's reflection swept across its band, on a Smith chart
Disk usage by directory as a treemap, one rectangle per file sized by its share The same directory tree as a sunburst, the root at the middle and the files at the rim
Requests per second through a service, drawn as a sankey diagram The same traffic as an arc diagram, each service a segment of the rail and each route a band arcing over it
The same traffic again as a chord diagram, each service an arc and each route a ribbon crossing the disc

What it does

  • Scales — linear, time, log, symlog and ordinal/categorical. Linear tick placement uses extended Wilkinson (Talbot, Lin & Hanrahan 2010), so axis labels come out round rather than merely evenly spaced. Time ticks step in calendar units. Log and symlog subdivide each decade with unlabelled minor ticks; symlog is linear near zero, so signed data spanning orders of magnitude is plottable at all. scale.TickValues pins the sequence outright, for an axis whose ticks are a convention rather than a reading — the 0.2 / 0.5 / 1 / 2 / 5 of a Smith chart, the five points of a Likert item.

  • GeomsLine (optionally tension-smoothed), Scatter (six marker shapes), Bar, Area (to a baseline, or a band between two series), Step (pre/mid/post), Boxplot (Tukey whiskers, type-7 quartiles, outliers), and Rect — one box per row, bounded by the row rather than by a baseline, which is what a heatmap, a gantt bar, a candle and a waterfall step all are.

  • Distribution marksHistogram, Violin, Ridgeline, Hexbin, Beeswarm, ECDF and Trend. Each is a pure function in stat/ — a 1-D binner, a Gaussian KDE with Silverman's bandwidth rule, a hexagonal lattice, an empirical CDF, locally weighted regression — with a determinism test, drawn by a mark that trains its axis on the summary rather than on the rows (ADR 0028).

  • Relational and hierarchical marksTreemap, Icicle, Sankey and Arc, which read an edge table rather than a pair of axes: geom.From/geom.To for a flow, geom.ID/geom.Parent for a hierarchy, and geom.Value for the magnitude of either. Each places its own layout in the unit square and hands it to the coordinate stage, so an Icicle under coord.Polar is a sunburst and an Arc under one is a chord diagram — four marks, six charts, and no second implementation of anything (ADR 0039).

  • Series in one layergeom.GroupBy splits a long table into N series drawn by one layer, each with its own colour and its own legend entry.

  • Position adjustmentsgeom.Stack (from zero, to 100 %, about a silhouette, or with the streamgraph's wiggle) and geom.Dodge (side by side). The offsets are derived while the scales are trained, so a stacked axis reaches the total rather than the tallest single value (ADR 0019).

  • Colour — a qualitative palette per chart, plus colour scales bound to a column by geom.ColorBy: a sequential or diverging ramp for a quantity, or scale.Qualitative for categories. Which guide the layer contributes follows from which it was handed — a ramp gets a colourbar, a palette gets one legend entry per category (ADR 0020) — and a chart of one layer painted from categories shows that legend by default, because the colours are the only thing naming them. scale.Named colours categories the caller enumerates — RUN green, FAULT red — rather than in order of first appearance, so the colour of a state does not depend on which window of a stream is on screen. Ramps interpolate in linear light, so a gradient has no dark band through its middle. A ramp can run logarithmically across its domain (scale.ColorLog, scale.ColorSymLog) — without it a heatmap over counts spanning orders of magnitude rounds every cell but the densest few to one end — or be cut into classes so that a colour names an interval rather than a shade to estimate: scale.Threshold for boundaries that come from outside the data, scale.Quantize for equal ones, scale.Quantile for equally many observations in each. A classed scale's colourbar is drawn in bands and labelled at the boundaries.

  • Paths that change colourLine and Step take ColorBy too, and draw the path in stretches of one colour. Where a stretch ends follows from the scale rather than from an option: a classed scale puts the corner on the threshold, interpolated between the two rows, so the chart says when the limit was passed; a discrete scale puts it on the row where the new category was first seen, because nothing was measured in between and a machine's state has no halfway. A continuous ramp on a path is refused — a stroke carries one colour and no stops (ADR 0049).

  • Coordinate systemscoord.Cartesian is the identity and the default; coord.Polar wraps one axis around a circle and reads the other as a radius, which turns the marks that already exist into pie, donut, radar, rose, wind rose and gauge. A scale still maps a value into an interval — the coord decides what the interval means — so no geom and no scale changed shape for it, and the arcs are cubics because the IR has always had those (ADR 0018). A slice's inner and outer radius are columns like its share is (geom.X and geom.X2), and geom.ExplodeBy breaks one out of the ring without changing what it says (ADR 0026). coord.Smith is the third one: it reads the pair as a complex impedance and maps it through Γ = (z−1)/(z+1) onto the unit disc, which is the chart every RF engineer works on and almost no plotting library draws. Its grid is the two axes' own ticks — constant-resistance circles from X, constant-reactance arcs from Y — so render was not touched for it (ADR 0033).

  • Sizegeom.SizeBy reads a column through scale.Size: the bubble chart. The mapping is by area, not radius, so doubling a value multiplies the diameter by √2 and two bubbles compare the way a reader already reads them. The layer contributes a third guide kind — a ladder of sample marks — beside the legend and the colourbar (ADR 0027).

  • Missing data — one explicit policy per layer (gap, interpolate, error), covering both NaN/Inf and values a scale has no position for, such as zero on a log axis.

  • AnnotationsHLine, VLine, HBand, VBand, Segment, Region and Note. They take values rather than a data source, because there is no column behind "the SLO is 200ms", and they extend the axis so the threshold is in view even when the data is nowhere near it.

  • Small multiples and subplotsfacet.Wrap and facet.Grid split one plot by a column; refract.NewGrid puts different plots on one canvas. Both go through one constraint solver, so panels are the same size and their axes line up (ADR 0010).

  • Chart furniture — axes, grid, tick labels with collision avoidance, chart and axis titles, and one guide column carrying a legend, colourbars and size keys, stacked in that order and measured by one solver.

  • Themes — light and dark, built from a dozen tokens rather than fifty fields, with a colourblind-safe (Okabe-Ito) default palette and perceptually uniform sequential ramps (Viridis, Cividis, Magma). Theme.With edits one; theme.Register and theme.ByName resolve one from a config file.

  • Big data — a layer with more rows than the plot has pixels reduces itself before it draws: stat.LTTB for a line, min/max per pixel column for a staircase or a band, density binning to an image for a point cloud, or a geom.Hexbin when the counts themselves are the answer. It happens when the chart is drawn, never when the scales are trained, so the axes still report the data rather than the subset that survived (ADR 0011).

  • Parallel panels — a facet or a grid builds its panels on separate goroutines and replays them in panel order, so the output is byte-identical to a serial render (ADR 0012).

  • Data — columnar and batch-oriented, carrying numeric, time and categorical columns. A []float64-backed source is borrowed, never copied, and so is a null-free float64 column read straight out of an Apache Arrow record through the optional refract/arrow/v18 module.

  • InteractionPlot.On registers handlers for hover, click, zoom and pan; Plot.Live draws into a surface that can be redrawn; refract.Input is the state machine that turns raw pointer input into those, and Live.Bind drives it from a DOM element. Hit-testing runs over the marks a render emitted rather than over a second copy of every geom's projection, and Live.TrackRows makes a hit name the source row behind the mark (ADR 0015).

  • Live datadata.Stream is appended to from any goroutine and frozen between frames, and a redraw repaints only what changed (ADR 0016).

  • A chart as JSON — a *Plot marshals to a Vega-Lite-shaped document and reads back as the same chart (ADR 0014).

  • Accessibility — a chart's title becomes an SVG <title> with role="img", a PDF document title and a canvas aria-label; Plot.Describe writes the <desc> a screen reader announces after it; Plot.DataTable writes the rows as an HTML table; and theme.Redundant tells layers apart by dash and shape as well as by colour (ADR 0024).

  • Notation in labels — optional and pluggable, with a TeX subset built in. A label is measured as it will be drawn, in every place a chart writes one (ADR 0023).

  • Responsive chartsrefract.Responsive scales a theme with the size the chart is drawn at, and Live.Resize is how a surface says its size changed (ADR 0025).

  • Backends — three built-in emitters — SVG, PDF and a browser canvas — the gg raster adapter, a native window, and an opt-in GPU tier.

  • Identity and transitionsgeom.KeyBy names the column that says which row is which, so a hover in one chart can be acted on in another, and two states of a table can be blended into a movement between them. The blend is in data space, before the scales, and refract owns no clock: At(f) is the whole primitive (ADR 0043, ADR 0044).

  • An overlay the chart ownsrefract.Crosshair, Highlight, Brush and Tooltip paint over the finished chart, after the guides and clipped by nothing. What an overlay draws is not hit-testable, because a tooltip a pointer can hit is a tooltip that flickers (ADR 0046).

  • Guides you can click — a hit on a legend row reports which series it stands for, and Live.Toggle puts that series away and brings it back; the row stays, dimmed, and the axes do not move (ADR 0047). A colourbar and a size key report a quantity instead, because neither is a series: a classed band gives the interval it covers, a continuous ramp the value under the pointer (ADR 0048).

Deliberately not here: geographic projections, node-link and Venn diagrams, contour plots, 3D, and any engine that links two charts together — a link is a statement about two charts and this model is about one, so the host is the link (ADR 0045). The rest are past v1.0 in CONCEPT.md §14, and docs/chart-types.md says what each one would need.

Categories, distributions and orders of magnitude

src := refract.NewTable().
    String("region", []string{"north", "south", "east", "west"}).
    Float64("sales", []float64{18, 42, 31, 25})

p := refract.New(refract.Size(700, 400), refract.Title("Sales by region"))
p.X(scale.Ordinal())                        // equal slots, one per category
p.Y(scale.Linear(scale.Nice(), scale.Zero()))
p.Add(geom.Bar(src,
    geom.X("region"), geom.Y("sales"),
    geom.ColorBy("sales", scale.Sequential(palette.Viridis)),
))

An ordinal axis is a band scale: it tells the bar how wide to be, rather than the bar guessing from the spacing of the data. The same applies to geom.Boxplot. For data that spans decades, swap in scale.Log(scale.LogNice()) — or scale.SymLog() when it also crosses zero.

A runnable version, together with a boxplot over the same kind of data, is in examples/categories.

Series in one layer, stacked or side by side

A table with a series column is one layer, not N. geom.GroupBy splits it, and the position adjustments are defined over the groups it makes:

// quarter, product, revenue — twelve rows, one per (quarter, product) pair
p.X(scale.Ordinal())
p.Y(scale.Linear(scale.Nice(), scale.Zero()))
p.Add(geom.Bar(src,
    geom.X("quarter"), geom.Y("revenue"),
    geom.GroupBy("product"),
    geom.ColorBy("product", scale.Qualitative(palette.OkabeIto)),
))

A grouped bar stacks from the baseline up, because that is what a bar chart with a series column means. geom.Dodge(0.1) puts the products side by side instead, geom.Stack(geom.StackFill) makes it a 100 % chart, and geom.Stack(geom.StackWiggle) over geom.Area is a streamgraph. The axis is trained on what will be drawn rather than on the column, so a stacked axis reaches the total; each segment is its own shape, so a pointer lands on the segment and Live.TrackRows names the row behind it.

The legend names every series. One swatch per layer could not, which is why a layer contributes as many entries as it has to (ADR 0020).

A pie is a stacked bar in a different coordinate system

A scale maps a value into an interval; a coord decides what that interval means. coord.Cartesian — the default — says it is a distance along an edge of the plot. coord.Polar says one of the two intervals is an angle and the other a radius, and the marks that were already there draw the family that was missing:

p := refract.New(
    refract.Coord(coord.Polar(coord.Theta(coord.FromY), coord.Hole(0.45))),
    refract.Theme(theme.Light.With(
        theme.Grid(false, false), theme.AxisLines(false, false), theme.Ticks(false, false))),
)
p.X(scale.Linear())          // one slot, filling the radius
p.Y(scale.Linear())          // the stacked total, filling the circle
p.Add(geom.Bar(src, geom.X("all"), geom.Y("share"),
    geom.GroupBy("browser"),
    geom.ColorBy("browser", scale.Qualitative(palette.OkabeIto))))

That is the whole of the donut above: the same geom.Bar layer that draws a stacked bar chart in Cartesian, with θ taken from the Y axis instead. The ring closes into a full circle because a stacked domain ends at the total — which is why neither scale is niced — and the hole is where the radial scale starts, so it is an annulus rather than a circle of background painted over the middle.

A slice's radii are dimensions, and a slice can leave the ring

The donut above carries one number per slice: its share, which is the angle. Two more are already there for the taking, because the radial axis is an axis like any other. geom.X and geom.X2 name a mark's two edges on it — the pair a gantt bar has used since v0.7 — so a slice starts and stops where its row says:

p := refract.New(refract.Coord(coord.Pie(coord.Radius(0.95))), refract.Theme(bare))
p.X(scale.Linear(scale.Domain(0, 1)))   // the radius: 1 is the whole budget
p.Y(scale.Linear())                     // the angle: the stacked share
p.Add(geom.Bar(src,
    geom.X("floor"), geom.X2("used"),   // where the slice starts and stops
    geom.Y("share"),                    // how far round it goes
    geom.GroupBy("team"),
    geom.ExplodeBy("pull"),             // and which of them leaves the ring
    geom.ColorBy("team", scale.Qualitative(palette.OkabeIto))))

That is the figure above: three numbers per slice, one layer, no new mark. coord.Pie() and coord.Donut(f) are sugar for the polar recipe and describe themselves as the polar coord they are.

geom.Explode(f) breaks every mark of a layer out of the middle by a fraction of the outer radius; geom.ExplodeBy(col) reads that fraction per row, which is what pulls one slice out and leaves the rest where they were. It is a displacement rather than a longer radius, and that is the whole point: the slice still says what it said, the gap shows where it came from, and a pointer follows it out — the path a geom hands the backend is the path that gets indexed, so a hit in the gap finds nothing. A coord with no middle to move away from — coord.Cartesian — ignores it rather than inventing a direction, which is why every golden file in the repository is unchanged by an option every geom now accepts (ADR 0026).

A radar is geom.Line or geom.Area over an ordinal angular axis, with coord.Chord() for sides that are straight and geom.Closed(true) for a contour that comes back to the first axis. A rose, a wind rose and a gauge are bars; a polar boxplot is a boxplot. None of them is a new geom, which is the point of having a stage rather than a shape (ADR 0018).

The stage costs an existing chart nothing: Cartesian is the identity, and every golden file and every figure in this README is unchanged by it. What it does change is what a pointer can be told — a hit is inverted back through the coord before the scales see it, so a pointer over a slice reports the value the slice stands for rather than a pixel, and Live.TrackRows names the row. Concentric rings replace horizontal grid lines and the tick labels go round the outside; the coord reports that geometry and render still strokes it, because render is the only package that knows the drawing order of a chart.

Decimation is deliberately off under a polar coord. stat.LTTB buckets by pixel column and a bucket of equal angle is not a bucket of equal width, so the coord reports that it does not decimate rather than have a reduction measure something it was not designed for. Nothing polar is a big-data chart, so this costs nothing real.

A runnable version of the donut, the radar, a wind rose, a gauge and the broken-out donut above is in examples/polar.

A Smith chart is a coordinate system too

Almost no general-purpose plotting library draws a Smith chart, because a Smith chart is not a mark. It is a conformal map of the impedance half-plane onto the unit disc — Γ = (z−1)/(z+1) — and a library whose coordinate stage is hard-coded Cartesian cannot express it at any price. refract's stage can, and coord.Smith is the third one:

p := refract.New(refract.Coord(coord.Smith()))

// The two columns are the normalised impedance: r = R/Z₀ and x = X/Z₀.
// TickValues asks for the grid a paper chart is printed at — six values every
// RF engineer expects in those places, which no tick-choosing algorithm
// produces because they are not evenly spaced and are not meant to be.
p.X(scale.Linear(scale.Domain(0, 50), scale.TickValues(0, 0.2, 0.5, 1, 2, 5)))
p.Y(scale.Linear(scale.Domain(-50, 50),
    scale.TickValues(-5, -2, -1, -0.5, -0.2, 0.2, 0.5, 1, 2, 5)))

p.Add(geom.Line(sweep, geom.X("r"), geom.Y("x")))

A patch antenna's reflection swept across its band, on a Smith chart

There is no Smith geom and no Smith mark: that is a geom.Line from v0.1. And there is nothing drawing the grid, either — the constant-resistance circles are what the X ticks look like once the coord has had them, and the constant-reactance arcs are the Y ticks, so render draws this with the same two loops it draws a Cartesian grid with. Not one line of render/ changed for it; see ADR 0033, which argues why this is the same seam polar is and not the wider one a map projection needs.

An instrument reports S₁₁ as a reflection coefficient rather than as an impedance, so a measured sweep is one line at the call site:

r[i], x[i] = coord.SmithZ(re[i], im[i])   // z = (1+Γ)/(1−Γ)

Three more things are worth knowing. Both axes are linear, and the domains are pinned rather than trained: the chart's extent is the whole disc whatever the data does, and a near-open reflection is a resistance in the thousands that would otherwise drag every tick into the last pixel before the rim. An edge is a chord by default, because a line between two measured samples asserting a linear sweep in impedance is an assertion the instrument did not make; coord.SmithArc draws the exact image for a locus that genuinely is straight in impedance, which is what a matching network's steps are. And coord.SmithAdmittance turns the disc through half a turn and reads the pair as a conductance and a susceptance — the Y chart a shunt element is read on, and the same physical reflection in the same place, against the other grid.

Not drawn: constant-|Γ| circles, constant-Q arcs and a combined ZY overlay. Each is a third grid family, and a coord may draw one grid line per tick a scale emits — the same constraint that makes the columns an impedance in the first place.

A runnable version of the sweep above, a two-element matching network and the admittance chart is in examples/smith.

An edge table is a chart too

The last family of charts refract could not draw read neither a pair of axes nor a summary of a column: they read a relationship. A treemap and a sunburst read a hierarchy, (id, parent, value); a sankey and a chord diagram read an edge list, (from, to, value). Both are ordinary columns, which is why data.Source did not change to accommodate them.

p := refract.New(refract.Size(700, 420), refract.Theme(bare))
p.X(scale.Linear())
p.Y(scale.Linear())
p.Add(geom.Treemap(src,
    geom.ID("path"), geom.Parent("under"), geom.Value("kb"),
    geom.Padding(0.006)))

Disk usage by directory as a treemap

Each mark lays its own geometry out in the unit square — a span across, a height out — and hands it to the coordinate stage. Which means the polar half of this family is not new drawing code at all. An icicle is a hierarchy's span across and its depth out; wrapped round a circle, the root is at the middle and the leaves are at the rim, and that is a sunburst:

p := refract.New(refract.Size(520, 460), refract.Theme(bare),
    refract.Coord(coord.Polar(coord.Hole(0.12))))
p.X(scale.Linear())
p.Y(scale.Linear())
p.Add(geom.Icicle(src, geom.ID("path"), geom.Parent("under"), geom.Value("kb")))

The same directory tree as a sunburst

It is coord.Polar and not coord.Pie, because a pie sweeps the Y axis round the circle and this chart's Y is its depth.

A sankey reads the other shape. Nothing declares a node: a node exists because a row mentioned it, it stands one column past the deepest source that reaches it, and it is as thick as the greater of what enters and what leaves.

p.Add(geom.Sankey(src, geom.From("from"), geom.To("to"), geom.Value("rps")))

Requests per second through a service, as a sankey diagram

And the same trick again: geom.Arc puts the nodes on a rail with the ribbons rising off it, which is an arc diagram. Each band is as thick as what it carries and arcs as high as it reaches, so the height reads as distance:

p.Add(geom.Arc(src, geom.From("from"), geom.To("to"), geom.Value("rps")))

The same traffic as an arc diagram

Move the rail to the rim and wrap it round a circle, and the ribbons cross the middle — a chord diagram, from the same layer with one option and one coord different.

p := refract.New(refract.Size(520, 460), refract.Theme(bare),
    refract.Coord(coord.Polar()))
p.Add(geom.Arc(src, geom.From("from"), geom.To("to"), geom.Value("rps"),
    geom.Baseline(1)))

The same traffic as a chord diagram

Four marks, six charts, and no second implementation of anything — the same thing the coordinate stage bought for the pie, one bucket later. The layouts themselves are pure functions in stat/, each with a determinism test: node order comes from the order the rows first named them and never from a map, and the sankey's relaxation runs a fixed number of sweeps rather than to convergence, so a chart whose panels are built on several goroutines draws exactly what a serial one draws (ADR 0039).

Both axes describe the unit square, which is nothing a reader needs to see — so these charts want the same bare theme a pie does. A runnable version of all six is in examples/relational.

Boxes bounded by their own row

geom.Rect occupies an arbitrary [x0,x1] × [y0,y1] per row — the mark a bar is not, because a bar always touches the baseline. An edge no column names is the slot the axis implies, so a heatmap is a rect and a ramp:

p.X(scale.Ordinal(scale.OrdinalPadding(0)))
p.Y(scale.Ordinal(scale.OrdinalPadding(0)))
p.Add(geom.Rect(src, geom.X("day"), geom.Y("hour"),
    geom.ColorBy("calls", scale.Sequential(palette.Viridis))))

and a gantt bar, which knows where it starts and stops, names both:

p.X(scale.Time())
p.Y(scale.Ordinal())
p.Add(geom.Rect(src, geom.X("from"), geom.X2("to"), geom.Y("task")))

Candlestick, waterfall, waffle and calendar are the same mark with different columns — see docs/chart-types.md.

A runnable version of all four charts is in examples/groups.

Distributions

Seven marks that summarise a column rather than plotting it. Each is a pure function in stat/ with a determinism test, and each trains its axis on the summary — a histogram's Y axis holds counts that appear nowhere in the table, an ECDF's holds a fraction it computed (ADR 0028).

p.Add(geom.Histogram(src, geom.X("latency")))              // bins chosen by Freedman–Diaconis
p.Add(geom.Histogram(src, geom.X("latency"), geom.Bins(40), geom.BinRange(0, 500)))

A violin draws the shape a boxplot summarises away, one per slot and — given a series column — one per series within it:

p.X(scale.Ordinal())
p.Add(geom.Violin(src, geom.X("service"), geom.Y("latency"), geom.GroupBy("region")))

A ridgeline is the same estimate laid out down a categorical axis, overlapping on purpose: twenty little density panels are twenty comparisons a reader has to carry between them, and twenty ridges are one picture.

p.Y(scale.Ordinal())
p.Add(geom.Ridgeline(src, geom.X("temperature"), geom.Y("month"), geom.Overlap(2)))

A swarm shows every observation and hides none of them, deterministically — no jitter, so the same data draws the same picture on every machine and every frame. An ECDF shows a distribution with no parameter in it at all, and takes a series column so several can be compared without overplotting:

p.Add(geom.Beeswarm(src, geom.X("cohort"), geom.Y("score")))
p.Add(geom.ECDF(src, geom.X("score"), geom.GroupBy("cohort")))

A hexbin is the third answer to overplotting, beside decimation and the density raster: a hexagon has six neighbours all the same distance away, so a cloud binned into one grows none of the crosses and seams a square grid does.

p.Add(geom.Hexbin(src, geom.X("x"), geom.Y("y"), geom.DensityCells(8)))

And a trend line goes on top of a scatter — locally weighted by default, so it follows the data rather than assuming a shape:

p.Add(
    geom.Scatter(src, geom.X("x"), geom.Y("y")),
    geom.Trend(src, geom.X("x"), geom.Y("y"), geom.Span(0.4)),
    geom.Trend(src, geom.X("x"), geom.Y("y"), geom.Smooth(geom.LinearFit)),
)

All seven, over samples that make the point, are in examples/distributions.

Bubbles: a third channel

geom.SizeBy gives every mark its size from a column. The scale maps by area rather than by radius, because a reader compares two circles by how much ink is in them — so a value twice another's is drawn with twice the ink and √2 times the diameter, and the layer contributes a key of sample marks beside the legend and the colourbar:

p.Add(geom.Scatter(src,
    geom.X("gdp_per_capita"), geom.Y("life_expectancy"),
    geom.SizeBy("population", scale.Size()),
    geom.ColorBy("continent", scale.Qualitative(palette.OkabeIto)),
))

A sized layer draws circles rather than markers, and that is the IR's doing rather than a preference: ir.Backend.Markers carries one style per drawing call, so a per-row size would be a call per row. One path per colour with a circle per subpath is one call per colour — and it gives a pointer the bubble it is actually inside rather than the nearest centre (ADR 0027).

The chart is in examples/distributions too.

Small multiples

p := refract.New(refract.Size(900, 520), refract.Title("Throughput by region"))
p.Add(
    geom.Line(src, geom.X("hour"), geom.Y("rps"), geom.Label("throughput")),
    geom.HLine(60, geom.Label("target")),          // no data: drawn on every panel
)
p.Facet(facet.Wrap("region", facet.Columns(3)))

Panels share their scales by default, which is what makes small multiples comparable at a glance. facet.FreeX, facet.FreeY and facet.Free give each panel its own — a deliberate choice, because a reader who does not notice the axes changed will read the panels as comparable when they are not.

For unrelated charts on one canvas, build a grid of plots instead:

g := refract.NewGrid(2, refract.GridSize(900, 560), refract.GridTitle("Fleet"))
g.Add(latency, throughput, errors, saturation)
err := g.Render(refract.PDF("overview.pdf"))

A runnable version of both, with annotations and PDF output, is in examples/dashboard.

A band at the edge, on the same axis

Some of what a chart shows is not on its other axis at all: a strip of machine states under a speed trace, a rug of event times, a ribbon of shifts, a key or a marginal distribution beside the panel. A track is a band at an edge of the plot area that shares the axis it runs along and carries a scale of its own across it — an ordinal one under a linear panel, which is the case it exists for.

p := refract.New(refract.Size(900, 480), refract.Title("Line 3"))
p.X(scale.Time()).Y(scale.Linear(scale.Zero()))
p.Add(geom.Line(speed, geom.X("t"), geom.Y("speed")))

p.Track(refract.Bottom, refract.TrackSize(48)).
    Add(geom.Rect(states, geom.X("start"), geom.X2("end"), geom.Y("state"),
        geom.ColorBy("state", scale.Qualitative(palette.Default))))

refract.Bottom and refract.Top are grid rows and share the plot's X; refract.Left and refract.Right are grid columns and share its Y. Bands on two edges at once are fine — the corner between them is simply empty.

The thickness comes out of the panel, not out of the panel's domain: the axis the track does not share is identical with the track and without it, so scale.Zero() still means what it says. The axis it does share is trained by both, because it is one axis — a rug of event times widens the time axis to cover the events, which is the reason to draw them against it.

The track and the panel hold the same scale object for that axis, so a zoom is one zoom rather than two that agree, and a pointer over a state bar reports its layer and its row like any other mark. Its lanes do not zoom, because half a category is not a view of anything.

For the same shape across separate plots, stack them in a one-column grid on one scale object — refract.GridRowHeights(0, 48) makes the second row a strip and refract.GridSharedX(true) writes the time axis once, under the bottom row, with GridColWidths and GridSharedY the same turned a quarter turn. That path renders; interaction is what a track is for.

A million rows

p := refract.New(refract.Size(800, 500), refract.Title("A million samples"))
p.Add(geom.Line(src, geom.X("i"), geom.Y("v")))   // nothing else needed

That renders in about 60 ms into under 30 kB of SVG. Drawing every row takes six times as long and produces 15 MB — of a picture that is 800 pixels wide, so the extra 999,000 vertices land on top of each other.

The layer sees how many rows it has against how wide the plot is and reduces itself accordingly: LTTB for a line, min/max per pixel column for a step or a band, a density raster for a scatter dense enough that its markers would bury one another. Override it per layer when the default is not what you want:

geom.Line(src, geom.X("i"), geom.Y("v"), geom.Decimate(geom.MinMax))    // keep every spike
geom.Line(src, geom.X("i"), geom.Y("v"), geom.Decimate(geom.NoDecimation)) // every row
geom.Scatter(src, geom.X("x"), geom.Y("y"), geom.Budget(4000))          // at most 4000 marks

The reduction happens when the chart is drawn, not when its scales are trained, so the axes are the data's either way — a spike survives the reduction and the axis still reaches it.

The same milestone made a redrawn chart cheap: everything sized by the data comes from a pool, so a steady-state frame over a million rows costs the same handful of allocations as one over a thousand. There is a test that fails if that stops being true.

A runnable version — two million samples with a spike and a dropout in them, and a million-point cloud — is in examples/bigdata.

Interactive, in a browser

The same model that renders SVG on a server draws on a <canvas>, with the pointer reporting what it is over:

//go:build js && wasm

p.On(refract.Hover, func(ev refract.Event) {
	if ev.Found {
		readout.Set("textContent", fmt.Sprintf("%s: %.2f, %.2f", ev.Series(), ev.Hit.X, ev.Hit.Y))
	}
})

live, err := p.Live(canvas.Element(el))   // a surface, redrawn
defer live.Close()
live.Draw()
defer live.Bind(el)()                     // pointer, wheel zoom, drag pan, double-click reset

Hit-testing works over the marks the render actually emitted, so it is right for every geom — including a decimated one, where the rows you can point at are exactly the rows on screen (ADR 0015). Zoom and pan are arithmetic on the scales, so the value under the pointer stays under the pointer on a log or a time axis as much as on a linear one.

Turn on row identity when a hit has to name a row rather than describe a point — highlighting the matching row of a table beside the chart is the case:

live.TrackRows(true)

p.On(refract.Hover, func(ev refract.Event) {
	if ev.Found && ev.Hit.Row >= 0 {
		highlightTableRow(ev.Hit.Row)   // a row of the table you handed in
	}
})

It is off by default and costs a position and a row number per mark; it does not cost per-frame allocations, and CI pins that. Decimation is not in the way — LTTB and min/max keep real rows — and neither is faceting, whose per-panel cuts are resolved back to the table you passed. A mark that no single row is behind — a boxplot's box, a density raster, an interpolated point across a gap — reports -1 rather than a plausible neighbour.

A runnable version is in examples/web:

GOOS=js GOARCH=wasm go build -o examples/web/chart.wasm ./examples/web
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" examples/web/

Interactive, in a window

The same model again, on the desktop:

import (
	"github.com/timzifer/refract/backend/window"
	"github.com/timzifer/refract/backend/window/show"
)

func main() {
	p := refract.New(refract.Responsive(true), refract.Title("Signal"))
	p.Add(geom.Line(src, geom.X("t"), geom.Y("y")))

	// Hover, drag to pan, wheel to zoom about the pointer, double click to reset.
	log.Fatal(show.Plot(p, window.Title("Signal"), window.Size(900, 560)))
}

The window comes from gogpu/gogpu — Windows, macOS, X11 and Wayland, no cgo — and the chart is drawn by the same CPU rasterizer that writes your PNGs, presented as one texture per changed frame. So a window shows exactly what a file would; there is one implementation of every mark rather than two that disagree (ADR 0021).

It is also cheap when nothing is happening: the loop blocks on the operating system's event queue, refract paints nothing when a frame is identical to the last, and the window uploads no texture when the pixels have not changed.

The steering — is this move a hover or a drag, was that release a click — is refract.Input, in the core, and it is the same state machine Live.Bind uses in a browser. Drive it yourself if you want different controls:

in := live.Input()
in.Down(x, y); in.Move(x, y); in.Up(x, y)   // press, pan, release
in.Wheel(x, y, deltaY)                      // zoom about the pointer
in.Resize(w, h)                             // lay out again at a new size

A runnable version is backend/window/cmd/demo:

cd backend/window && go run ./cmd/demo
The GPU tier, opt-in
import _ "github.com/timzifer/refract/backend/gg/gpu"

That import registers gg's GPU accelerator, and every chart rasterized afterwards — in a window or into a file — uses it. It is a module of its own so that the import is the opt-in: backend/gg never links wgpu, and a program that wants a PNG on a server links no GPU stack at all (ADR 0022). On a machine with no usable device the registration fails quietly and gg falls back to the CPU, so the chart still renders; gpu.Enabled() says which way it went.

It stays opt-in beta past v1.0. For server-side stills the CPU rasterizer and the vector emitters are the supported path.

Labels that are notation

p := refract.New(
	refract.Math(mathtext.TeX()),
	refract.YTitle(`flux $F_\nu$ ($\mathrm{W\,m^{-2}\,Hz^{-1}}$)`),
	refract.Title(`decay of $N_0e^{-\lambda t}$`),
)

Standard error curves, with a fraction over a radical as the y title

$…$ is set as notation and everything around it as text. The subset is the one a chart label actually needs — scripts, \frac, \sqrt, \bar, \mathrm, the spacing commands, and a table of symbols — and a single letter is set italic because it is a variable while a run of letters is a name. Operators and relations get TeX's own spacing, so $\sigma = 1$ reads as an equation rather than as a filename.

A typesetter is installed by wrapping the backend, so it reaches every label the chart has: the title, the axis titles, the ticks, the legend, a facet's strip, a geom's own note. That also means a label is measured as it will be drawn, so the margin left for a fraction is the height of the fraction rather than the width of its markup (ADR 0023). Notation it cannot parse is drawn exactly as written — a chart never fails to render because of a label.

mathtext.Typesetter is the seam if you have a real engine to plug in.

Charts that can be read without being seen

Three channels, because they fail for three different readers (ADR 0024):

p := refract.New(
	refract.Title("Signal against model"),
	refract.Theme(theme.Light.With(theme.Redundant(true))),  // dashes and shapes, not colour alone
)
p.Add(/* ... */)

p.Describe()                      // read the data; write a description
p.Render(refract.SVG("chart.svg"))
p.DataTable(w)                    // the same data as an HTML table

The title alone costs nothing and is always written: an SVG gets <title>, role="img" and aria-labelledby, a PDF gets a document title, a canvas gets role and aria-label. Describe costs a pass over the data — it reports how many rows there are and over what range — so it is a call rather than something every render pays for, and it fills in the <desc> a screen reader announces next:

Signal against model. 3 layers with line marks. Axes: sample horizontally,
σ/√n (mV) vertically. measured, a line of 24 rows, sample from 0 to 23,
measured from 6.16 to 20.9. …

Notation in a title is read aloud rather than spelled out, because "dollar backslash frac" is not a description of anything.

theme.Redundant(true) gives each layer a dash pattern and a marker shape alongside its palette colour — the chart survives a greyscale printout and the readers who cannot separate its first two colours — and it leaves a layer that named its own geom.Dash or geom.Shape alone.

See examples/accessible, which writes the picture, the page and the description as three files.

Charts that follow their surface

p := refract.New(refract.Size(800, 500), refract.Responsive(true))
// ...
live.Resize(400, 250)   // half the size: half the type, half the strokes

A plot is designed at one size and often drawn at another. Responsive scales the theme — type, strokes, spacings, markers, margins — by how much smaller or larger the drawing is, so a chart at a third of its design size is the chart rather than a photograph of it. At the design size the factor is exactly 1, so turning it on cannot change a still you already have (ADR 0025).

Live.Resize is what a window's resize event and a reflowed canvas call. The scales keep whatever they were zoomed to: a reader who dragged a view into place has not asked to leave it.

For a still at another size — a thumbnail of a chart designed larger — name the design explicitly:

refract.New(refract.Size(200, 125), refract.ResponsiveFrom(800, 500))

Nanoseconds at any zoom

A Unix nanosecond count in this century needs 61 bits, and a float64 has 53. Two instants a nanosecond apart are therefore the same number, and an axis zoomed to a microsecond window has nothing left to separate them with.

p.X(scale.Time(scale.Origin(runStart)))   // the domain is nanoseconds since runStart

With an origin near the data, the subtraction happens in int64 and the axis keeps whole nanoseconds for the hundred days either side of it that a float64 counts exactly. A geom reading a time column goes through the axis's own space, so nothing needs converting by hand, and the JSON spec carries the origin so a document reads back as the same axis.

Live data

A data.Stream is appended to from one goroutine and frozen for the renderer on another. It is deliberately not a Source: a table being appended to between two column reads is a table that disagrees with itself.

st := data.NewStream("t", "y").Window(2000)
p.Add(geom.Line(st.Source(), geom.X("t"), geom.Y("y")))

go func() {
	for s := range samples {
		st.Append(scale.Nanos(s.At), s.Value)   // any goroutine, any time
	}
}()

for range ticker.C {
	st.Snapshot()   // freeze what has arrived
	live.Draw()     // draw the frozen view
}

Each Draw compares the frame with the last one and repaints only where they differ; a frame identical to the last is not painted at all. A backend says it can do that by implementing ir.Partial, and one that cannot gets the whole frame as before (ADR 0016). Appending a row and freezing a view both allocate nothing in the steady state, and the benchmark gate keeps it that way.

See examples/stream.

Label placement and QQ plots

// The renderer places participating point labels, dropping those that still
// collide after trying nearby positions. Labels in boxes are never moved.
p.Add(geom.Text(src, geom.X("x"), geom.Y("y"), geom.TextBy("name"),
    geom.AvoidOverlap(true)))

// X names the sample column. Display axes are theoretical normal quantiles
// horizontally and ordered observations vertically, without standardisation.
q := refract.New(refract.XTitle("Standard normal quantile"),
    refract.YTitle("Observed value"))
q.Add(geom.QQ(src, geom.X("value")))

GroupBy compares several samples; facets split them as usual. Other theoretical distributions use stat.QQ(sorted, quantile) and geom.Scatter. See the runnable diagnostics example, label placement decision and QQ decision.

Normal QQ plot Label placement
Ordered observations against normal quantiles Nearby labels placed without overlapping one another

A chart as JSON

doc, err := p.MarshalJSON()      // indented; json.Marshal(p) compacts it
q, err := refract.ParseJSON(doc) // and reads back as the same chart

The document is Vega-Lite-shaped: data.values, mark.type, encoding.x.field, scale.type, facet and resolve mean what they mean in Vega-Lite, so anyone who knows that vocabulary can read one. It is not a Vega-Lite subset and does not claim to be — refract has marks and options Vega-Lite has no name for, and naming them plainly beats smuggling them through a borrowed name. What is guaranteed is the round trip through refract, and there is a test per mark and per scale that renders both and compares the primitives (ADR 0014).

{
  "$schema": "https://github.com/timzifer/refract/spec/v1",
  "width": 640,
  "height": 400,
  "title": "Throughput",
  "data": {
    "values": [{"x": 0, "y": 2}],
    "format": {"parse": {"x": "number", "y": "number"}}
  },
  "encoding": {
    "x": {"type": "quantitative", "scale": {"type": "linear", "nice": true}},
    "y": {"type": "quantitative", "scale": {"type": "linear", "nice": true}}
  },
  "layer": [
    {
      "mark": {"type": "line", "color": "#0072b2"},
      "encoding": {"x": {"field": "x"}, "y": {"field": "y"}}
    }
  ],
  "config": {"theme": "light"}
}

(shown with the objects folded up; the real output puts every field on its own line)

Plotting Arrow data

import "github.com/timzifer/refract/arrow/v18"

src := arrow.Source(rec)      // rec is an arrow.Record
p.Add(geom.Line(src, geom.X("t"), geom.Y("p99")))

A float64 column with no nulls is Arrow's own buffer — no copy, no conversion. Everything else (integers, float32, timestamps, dictionary-encoded strings) converts once on first use and is cached, so a record with forty columns and a chart that plots two pays for two. An Arrow null becomes NaN, which means the missing-data policy you already set covers it (ADR 0013).

How it fits together

   Your spec  ──►  Model  ──►  IR  ──►  Backend  ──►  output
   ─────────      ─────      ────      ───────       ──────
   geoms          scales     ~8        backend/svg     SVG
   scales         coords     drawing   backend/pdf     PDF
   coords         layout     ops       backend/canvas  browser canvas
   theme          ticks                backend/gg      PNG / JPEG / a surface
   facets         panels               backend/window  a native window

The ir.Backend interface is the seam. Geoms never touch a renderer; a renderer never knows what a scale is. That is what lets refract stand on a young, fast-moving graphics stack without being welded to it — the whole gg adapter is about 300 lines (why that matters).

Things ride on that seam without widening it. A render can be watched, so that a pointer can be told which layer drew what it is over (ADR 0015); two frames can be compared, so that a surface repaints only what moved (ADR 0016); and a backend that can carry words, resize itself, or repaint part of a frame says so through an optional interface — ir.Semantics, ir.Resizer, ir.Partial — rather than through a method every backend would have to implement. No identity channel and no damage channel went into the drawing interface.

The native window is the same argument once more: it is a surface that draws with the raster backend and presents the result, so there is one implementation of every mark and a window shows what a file would (ADR 0021).

Documentation

  • CONCEPT.md — the design document: motivation, positioning, architecture, roadmap.
  • docs/adr — why the open questions were answered the way they were.
  • docs/v1-api-audit.md — every exported identifier with a verdict before the API freeze: freeze, change before v1, or defer.
  • docs/chart-types.md — every chart form, what draws it today, and what the missing ones would cost.
  • docs/benchmarks.md — the benchmark suite: what each benchmark measures, which numbers are gated, and the latest results.
  • pkg.go.dev — the API reference, generated from the doc comments.
  • CONTRIBUTING.md — building a five-module repository, how to regenerate golden files and figures, and how a release is tagged.
  • SECURITY.md — which versions get fixes, and how to report a vulnerability privately.
  • CODE_OF_CONDUCT.md — what participating here looks like.

A note on how this was built

Much of refract was written with an AI assistant — Claude, in Claude Code — in the loop, under human direction and review. The design decisions and the arguments in the ADRs are the ones a human signed off on; a good deal of the typing was not. What makes that workable is the same thing the rest of this README describes: every claim here is held up by a golden file, a test or a benchmark that CI runs on every commit, so the code is checked against the behaviour rather than against a plausible-sounding explanation of it.

License

MIT. The core links nothing; backend/gg links only permissively licensed code (gg is MIT, x/image is BSD-3-Clause). That is a requirement rather than a preference — refract must be embeddable by downstream projects under any license.

Documentation

Overview

Package refract turns one declarative chart specification into any output you need — SVG, PDF and a browser canvas today, raster through one more module, GPU and a native window through later ones — from the same model, with the same geometry.

This library is now github.com/timzifer/figure

Development continues under a new name and a new import path. v1.8.0 is the last release here; it is v1.7.0 plus this notice, and nothing else changed.

go get github.com/timzifer/figure

Everything under this path keeps working and keeps its tags. It receives no fixes and no features.

Migrating is an import-path change and a compiler pass. Beyond the path, what moved is the shape of the seams a caller implements — Geom.Train, Observer.Panel and Layer, Coord.Frame and Furniture, Target.Open, Typesetter.Typeset, Rows.Marks and Scale.Ticks each take one growable struct now instead of positional arguments — and data.Source, which answers one Column call rather than three typed ones. The release notes for figure v0.8.0 carry the table, and the compiler finds every site:

https://github.com/timzifer/figure/releases/tag/v0.8.0

The reason for the rename is written down rather than left to guess: the old name argued a thesis about prisms that the library had outgrown, and the three seams a third dimension breaks were cheaper to correct under a new import path than under a major version that would have carried a /v2 suffix for the rest of the library's life.

What it is

The core module is pure Go and depends on nothing but the standard library. Both vector emitters are built in and need no rendering engine and no font stack, so a server that wants a chart as SVG or a report generator that wants one as PDF links nothing native and nothing young. The browser backend is built in for the same reason: a canvas 2D context is reached through syscall/js. Raster output lives in a separate module, github.com/timzifer/refract/backend/gg, which is still CGO-free.

Shape of the API

Build a plot, give it scales, add layers, render it to a target:

src := refract.Float64Columns(map[string][]float64{"t": times, "y": values})

p := refract.New(
    refract.Theme(theme.Dark),
    refract.Size(800, 500),
    refract.Title("Signal"),
)
p.X(scale.Time())
p.Y(scale.Linear(scale.Nice()))
p.Add(geom.Line(src, geom.X("t"), geom.Y("y"), geom.Color(palette.Blue)))

err := p.Render(refract.SVG("signal.svg"))

Scales cover linear, time, log, symlog and ordinal/categorical axes; geoms cover lines, scatters, bars, areas, steps, boxplots and rects. A mark's colour can come from the data through scale.Sequential or scale.Diverging and geom.ColorBy, which contributes a colourbar beside the plot, and its size through scale.Size and geom.SizeBy, which contributes a key of sample marks — the bubble chart.

Distributions

geom.Histogram, geom.Violin, geom.Ridgeline, geom.Hexbin, geom.Beeswarm, geom.ECDF and geom.Trend summarise a column rather than plotting it. Each is a pure function in package stat with a determinism test, and each trains its axis on the summary: a histogram's Y axis holds counts that are nowhere in the data.

p.X(scale.Ordinal())
p.Add(geom.Violin(src, geom.X("service"), geom.Y("latency"),
    geom.GroupBy("region")))

Annotations

geom.HLine, geom.VLine, geom.HBand, geom.VBand, geom.Segment, geom.Region and geom.Note add the marks that are not data — a threshold, a shaded window, a label pointing at what happened. They take values rather than a data source.

Many panels

Plot.Facet splits one plot into small multiples, one panel per value of a column; NewGrid puts several different plots on one canvas. Both lay their panels out with the same solver, so the axes line up either way.

p.Facet(facet.Wrap("region", facet.Columns(3)))

Interaction

Plot.On registers a handler for hover, click, zoom or pan, and Plot.Live draws the chart into a surface that can be redrawn, pointed at, panned and zoomed. Each redraw repaints only what changed, and a frame identical to the last is not painted at all. In a browser, [Live.Bind] wires a DOM element to all of it; see package backend/canvas.

p.On(refract.Hover, func(ev refract.Event) {
    if ev.Found {
        tooltip(ev.Series(), ev.Hit.X, ev.Hit.Y)
    }
})

live, err := p.Live(canvas.Element(el))

Live data

data.Stream is a table a producer appends to from one goroutine while the renderer draws a frozen snapshot on another.

A chart as JSON

A Plot marshals to a Vega-Lite-shaped document and reads back as the same chart — see Plot.Spec, ParseJSON and package spec.

Status

Closed. v1.8.0 is the last release under this path; the API is frozen where v1.0.0 froze it and stays that way, because nothing further will be built here. See github.com/timzifer/figure for the library that continues.

Index

Constants

View Source
const (
	Hover = interact.Hover
	Leave = interact.Leave
	Click = interact.Click
	Zoom  = interact.Zoom
	Pan   = interact.Pan
	// Select is a region the reader dragged out. See [Live.Select].
	Select = interact.Select
)

The event kinds. See interact.EventKind.

View Source
const (
	// Vertex is a point a layer drew. See [interact.Vertex].
	Vertex = interact.Vertex
	// Area is a filled shape. See [interact.Area].
	Area = interact.Area
	// Label is text a layer drew. See [interact.Label].
	Label = interact.Label
	// LegendRow is a row of the legend, which is furniture a reader can act
	// on. See [interact.LegendRow] and [Live.Toggle].
	//
	// It is spelled with the Row because [Legend] is already the option that
	// asks a plot for one.
	LegendRow = interact.LegendRow
	// Colorbar is a colourbar, or one band of a classed one. See
	// [interact.Colorbar].
	Colorbar = interact.Colorbar
	// SizeKey is a row of a size key. See [interact.SizeKey].
	SizeKey = interact.SizeKey
)

The mark kinds a hit can report. See interact.Kind.

View Source
const DefaultClickSlop = 3

DefaultClickSlop is how far the pointer may travel between press and release and still count as a click rather than a drag, in device-independent pixels.

It is not zero because a pointer never is: a hand on a mouse moves a pixel or two during a click, and a finger on a trackpad more. Three pixels is the figure the desktop toolkits settled on, and it is well below the distance a deliberate pan covers.

View Source
const DefaultDuration = 250 * time.Millisecond

DefaultDuration is how long a transition takes when nobody says. A quarter of a second is long enough to be followed by eye and short enough that a reader clicking through states is not waiting for the chart.

View Source
const DefaultTrackSize = 48

DefaultTrackSize is the thickness of a track that was given none, in device-independent pixels. It is about three lanes' worth.

Variables

View Source
var ErrEmptyGrid = errors.New("refract: grid has no plots")

ErrEmptyGrid reports a render of a grid with no plots in it.

View Source
var ErrNoLayers = errors.New("refract: plot has no layers and no scales")

ErrNoLayers reports a render of a plot with nothing in it. Rendering empty axes is a legitimate thing to want, so this is only returned when there is also no scale configured — that combination is always a mistake.

View Source
var ErrNoTweens = errors.New("refract: a transition needs at least one tween")

ErrNoTweens reports a transition with nothing to move.

View Source
var ErrTrackWithFacet = errors.New("refract: a plot cannot have both a track and a facet")

ErrTrackWithFacet reports a plot that has both a track and a facet.

A facet owns the grid's rows and columns — it is what decides how many there are and what each one means — and a track needs a row of that grid to live in. A band spanning a facet is a different feature with different questions to answer, so this is refused rather than guessed at.

Functions

func EaseIn added in v1.7.0

func EaseIn(f float64) float64

EaseIn starts slowly and arrives at speed. It is rarely what a chart wants: a movement that ends abruptly reads as an interruption rather than as an arrival.

func EaseInOut added in v1.7.0

func EaseInOut(f float64) float64

EaseInOut starts slowly, moves, and settles. It is the default because it is the curve that reads as one movement rather than as a start and a stop.

func EaseLinear added in v1.7.0

func EaseLinear(f float64) float64

EaseLinear is no easing at all: the fraction unchanged.

func EaseOut added in v1.7.0

func EaseOut(f float64) float64

EaseOut leaves at speed and settles. It is what one thing moving to one new place wants.

func NewTable

func NewTable() *data.Table

NewTable returns an empty table that can mix numeric and time columns. See data.NewTable.

func WheelFactor added in v0.6.0

func WheelFactor(delta float64) float64

WheelFactor turns a raw scroll delta into a zoom factor.

The exponential keeps a fast scroll from inverting: any delta maps into (0, ∞) and never through zero, so holding the wheel down zooms smoothly rather than jumping. The rate is chosen so that one notch on a mouse — a hundred units in the browser's pixel mode — is about ten percent, which is the step a reader expects from a map.

Types

type Backend

type Backend = ir.Backend

Backend is a renderer. See package ir.

type Brush added in v1.7.0

type Brush struct {
	// Rect is the region, in device space. An empty one draws nothing.
	Rect ir.Rect
	// Fill and Stroke override the theme. A zero Fill takes the theme's axis
	// colour at a tenth opacity and a zero Stroke takes it at half.
	Fill   ir.Color
	Stroke ir.Color
	// Width is the outline's width. Zero takes one device unit.
	Width float32
}

Brush is the rectangle a reader is dragging out, drawn as feedback while they drag it.

It is the visible half of DragSelects and DragZooms: Input.Dragged reports the rectangle, and this draws it. The two are separate because a surface may want to draw its own, and because a selection means nothing until it is released.

The zero value draws nothing: an empty rectangle is not a selection.

func (*Brush) DrawOverlay added in v1.7.0

func (br *Brush) DrawOverlay(b ir.Backend, f OverlayFrame)

DrawOverlay implements render.Overlay.

type Crosshair added in v1.7.0

type Crosshair struct {
	// At is where the lines cross, in device space.
	At ir.Point
	// Show is whether to draw at all. A crosshair that is not shown draws
	// nothing — which changes the frame's call count and therefore makes that
	// one frame a full repaint. Moving one does not.
	Show bool

	// Panel is which panel to draw in, or -1 to use the one containing At.
	// It is a field rather than always inferred so that a chart linked to
	// another can put a crosshair in a panel the pointer is not in.
	Panel int

	// Color, Width and Dash override the theme. A zero Color takes the
	// theme's axis colour at half opacity, a zero Width takes one device
	// unit, and a nil Dash is the theme's grid dash.
	Color ir.Color
	Width float32
	Dash  []float32

	// Vertical and Horizontal turn each rule off. Both are drawn by default,
	// which is what "crosshair" means; a chart against a time axis often
	// wants only the vertical one.
	NoVertical   bool
	NoHorizontal bool
}

Crosshair is a pair of rules through a point, confined to the panel it is in.

It is the cheapest thing a reader can be given for "which value is this": two lines meeting where the pointer is, so that a point in the middle of a scatter can be read off both axes at once.

The zero value draws nothing. Set Crosshair.At and Crosshair.Show from a hover handler; the colours default to the chart's own axis colour, so a crosshair over a dark theme is legible without being told.

func (*Crosshair) DrawOverlay added in v1.7.0

func (c *Crosshair) DrawOverlay(b ir.Backend, f OverlayFrame)

DrawOverlay implements render.Overlay.

type Drag added in v1.7.0

type Drag uint8

Drag is what dragging the pointer across the chart does.

const (
	// DragPans moves the view under the pointer, which is what a drag has
	// always done and what a reader of a map expects.
	DragPans Drag = iota
	// DragSelects drags out a rectangle and fires [Select] with the rows
	// under it on release. The view does not move.
	DragSelects
	// DragZooms drags out a rectangle and zooms to it on release, which is
	// the other thing a rubber band conventionally means.
	DragZooms
)

The drag modes.

func (Drag) String added in v1.7.0

func (d Drag) String() string

String names the mode, for tests and error messages.

type Easing added in v1.7.0

type Easing func(f float64) float64

Easing reshapes a fraction in [0, 1]. It is what makes a movement look like something starting and stopping rather than a thing dragged at a constant rate.

type Edge added in v1.1.0

type Edge int

Edge names a side of the plot area.

const (
	// Bottom puts the track below the panel. Several bottom tracks stack
	// downwards in the order they were added, and the last of them carries
	// the shared X axis.
	Bottom Edge = iota
	// Top puts the track above the panel, below the chart title. Several top
	// tracks stack upwards: the first added sits nearest the panel.
	Top
	// Left puts the track beside the panel, to its left. Several left tracks
	// stack leftwards — the first added sits nearest the panel — and the
	// outermost carries the shared Y axis.
	Left
	// Right puts the track beside the panel, to its right. Several right
	// tracks stack rightwards: the first added sits nearest the panel.
	Right
)

The edges a track can be attached to.

Which edge a track is on decides which of the plot's scales it shares. Bottom and Top are grid rows and share the plot's X; Left and Right are grid columns and share its Y. That is the same statement twice, turned a quarter turn: a track shares the axis it runs along, and brings its own for the one it is thick in.

func (Edge) String added in v1.1.0

func (e Edge) String() string

String names the edge. It is what a spec document writes down.

type Event added in v0.5.0

type Event = interact.Event

Event is one thing that happened to a chart. See interact.Event.

type EventKind added in v0.5.0

type EventKind = interact.EventKind

EventKind is what happened. See interact.EventKind.

type Grid added in v0.3.0

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

Grid renders several plots together in one image, with their axes aligned.

It is the other half of the multi-panel story: Plot.Facet splits one plot by a column, and a Grid puts different plots side by side. Both go through the same solver, so the panels line up either way.

g := refract.NewGrid(2, refract.GridSize(900, 600), refract.GridTitle("Fleet"))
g.Add(latency, throughput, errors, saturation)
err := g.Render(refract.SVG("fleet.svg"))

A member plot contributes its layers, its scales and its title, which becomes the label above its panel. The canvas is the grid's: its size, theme, chart title and axis titles are the ones used, and a member plot's own size, theme and axis titles are not. That is the price of one image — two panels cannot disagree about the colour of the paper they are printed on.

func NewGrid added in v0.3.0

func NewGrid(cols int, opts ...GridOption) *Grid

NewGrid creates a grid that flows plots into rows of cols panels.

func (*Grid) Add added in v0.3.0

func (g *Grid) Add(ps ...*Plot) *Grid

Add appends plots, filling the grid left to right and wrapping.

func (*Grid) At added in v0.3.0

func (g *Grid) At(row, col int, p *Plot) *Grid

At places a plot in a specific cell, replacing whatever was there. Cells left empty stay empty, which is how a grid is given a deliberate hole.

func (*Grid) Render added in v0.3.0

func (g *Grid) Render(t Target) (err error)

Render draws the grid into t.

type GridOption added in v0.3.0

type GridOption func(*Grid)

GridOption configures a Grid at construction.

func GridAxisTitles added in v0.3.0

func GridAxisTitles(x, y string) GridOption

GridAxisTitles labels the shared axes, once for the grid. Panels keep their own scales; these name what those scales measure.

func GridColWidths added in v1.1.0

func GridColWidths(w ...float32) GridOption

GridColWidths fixes the width of each column in device-independent pixels, as GridRowHeights does for rows. A zero entry, or a column past the end of the list, is left to the solver.

func GridDPR added in v0.3.0

func GridDPR(r float64) GridOption

GridDPR sets the device pixel ratio. See DPR.

func GridDescription added in v0.6.0

func GridDescription(title, detail string) GridOption

GridDescription attaches an accessible description to the grid. See Description.

There is no `Grid.Describe`: a grid is several charts, and the honest description of one is written by whoever knows why they are on the same page. The grid's title is carried into the output either way, as a chart's is.

func GridLegend added in v0.3.0

func GridLegend(show bool) GridOption

GridLegend forces the legend on or off. By default it appears when any panel would have shown one.

func GridMath added in v0.6.0

func GridMath(ts mathtext.Typesetter) GridOption

GridMath typesets the notation in the grid's labels — its title, its axis titles, and the title of every panel in it. See Math and package mathtext.

A member plot's own typesetter is not used, for the same reason its theme is not: the canvas is the grid's, and two panels cannot disagree about how a label is set.

func GridParallel added in v0.4.0

func GridParallel(on bool) GridOption

GridParallel controls whether the panels are built concurrently. See Parallel; a grid is the shape that benefits most, because its panels are different charts over different data.

func GridRowHeights added in v1.1.0

func GridRowHeights(h ...float32) GridOption

GridRowHeights fixes the height of each row in device-independent pixels. A zero entry, or a row past the end of the list, is left to the solver, and the rows left to it share what the fixed rows leave, equally.

It is what makes a grid of plots express the shape a track expresses inside one plot: a full-height plot with a short strip under it.

refract.NewGrid(1, refract.GridRowHeights(0, 48), refract.GridSharedX(true))

func GridSharedX added in v1.1.0

func GridSharedX(on bool) GridOption

GridSharedX writes the X tick labels only under the bottom row, instead of under every panel.

It is half of what stacked plots on one domain need; the other half is giving those plots the same scale.Scale object, which shares their domain, their nicing and — for a Live chart — their zoom, because a zoom reaches a scale and there is only one scale to reach:

t := scale.Time()
speed := refract.New().X(t).Y(scale.Linear())
states := refract.New().X(t).Y(scale.Ordinal())

Turn it on only when the plots really do share a domain. Labels under one axis and different numbers on another is the misreading this exists to prevent, and enabling it cannot make two unrelated domains agree.

A Grid renders; it has no Plot.Live. Interaction on stacked plots is what a track inside one plot is for — see Plot.Track.

func GridSharedY added in v1.1.0

func GridSharedY(on bool) GridOption

GridSharedY writes the Y tick labels only beside the first column, as GridSharedX does for the bottom row. The same caution applies: turn it on only when the plots in a row really do share a domain, which they do when they were given the same scale.Scale object.

func GridSize added in v0.3.0

func GridSize(w, h int) GridOption

GridSize sets the output size in device-independent pixels. The default is 900x600, which is a grid's worth rather than a single chart's.

func GridTheme added in v0.3.0

func GridTheme(t themepkg.Theme) GridOption

GridTheme sets the visual tokens for the whole grid.

func GridTitle added in v0.3.0

func GridTitle(s string) GridOption

GridTitle sets the title above the grid.

type Highlight added in v1.7.0

type Highlight struct {
	// At are the points to ring, in device space.
	At []ir.Point
	// Radius is the ring's radius in device units. Zero takes six, which is
	// a little larger than a default scatter marker.
	Radius float32
	// Color and Width override the theme. A zero Color takes the theme's
	// label colour and a zero Width takes two device units — a ring wants to
	// read as an annotation rather than as data.
	Color ir.Color
	Width float32
	// Panel confines the rings to one panel, or -1 to draw each in whichever
	// panel contains it. A point in no panel is not drawn.
	Panel int
}

Highlight rings a set of marks, to say "these ones".

It is what a chart linked to another draws: the first chart reports which row the pointer is on, the host finds where that row landed here with interact.Index.Locate, and this puts a ring round it. Nothing about the layer changes, so the highlight cannot disturb the reading.

The zero value draws nothing.

func (*Highlight) DrawOverlay added in v1.7.0

func (h *Highlight) DrawOverlay(b ir.Backend, f OverlayFrame)

DrawOverlay implements render.Overlay.

type Hit added in v0.5.0

type Hit = interact.Hit

Hit is the mark under a pointer. See interact.Hit.

type Input added in v0.6.0

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

Input turns a surface's raw pointer input into chart interaction.

Live takes deliberate instructions — hover here, zoom about there by this much, pan by that. A surface reports something rawer: a button went down, the pointer moved, the wheel turned by ninety-six of whatever units this platform counts in. The translation between the two is a small state machine — is this move a hover or a drag, was that release a click or the end of a pan — and it is the same state machine on every surface.

So it lives here, once, rather than being written again in every backend. [Live.Bind] is this driving a DOM element; a native window drives it from its own event loop; a test drives it directly. A backend consumes IR and must not know what a panel or a scale is, which is why none of this is in one.

in := live.Input()
// from the surface's event loop:
in.Down(x, y)
in.Move(x, y)   // pans, because a button is down
in.Up(x, y)     // clicks, because the pointer barely moved
in.Wheel(x, y, deltaY)

An Input is not safe for concurrent use, and neither is the Live behind it.

func (*Input) ClickSlop added in v0.6.0

func (i *Input) ClickSlop(px float64) *Input

ClickSlop sets how far the pointer may move between press and release and still count as a click. It returns i so the call can be chained.

func (*Input) DoubleClick added in v0.6.0

func (i *Input) DoubleClick() error

DoubleClick resets the view, releasing every zoom and pan, and redraws. It is the one control a reader looks for first.

func (*Input) Down added in v0.6.0

func (i *Input) Down(x, y float64) error

Down reports a button pressed at a device position. It starts a drag, which becomes a pan once the pointer has moved past the click slop.

func (*Input) Drag added in v1.7.0

func (i *Input) Drag(d Drag) *Input

Drag sets what a drag does and returns i, so the call can be chained onto Live.Input.

It is a mode rather than a modifier key because a modifier is a fact about a keyboard and this package has never seen one: a browser reports shift on its own events, a window reports it on its own, and a touch screen has none at all. A surface that wants shift-to-select reads its own event and sets the mode; a surface that wants a toolbar button sets it from the button. Either way the state machine is the same one.

Changing it mid-drag takes effect on the next press, so a mode switched under a held button does not turn half a pan into half a selection.

A drag that *starts on a colourbar* ignores this and selects a range of values along the bar, whatever the mode says. There is no view to pan on a bar and no rectangle to zoom to, so a drag over one has exactly one sensible reading — see Live.Select and interact.Colorbar.

func (*Input) Dragged added in v1.7.0

func (i *Input) Dragged() (ir.Rect, bool)

Dragged reports the rectangle a rubber-band drag currently covers, and whether there is one. A surface draws it as feedback; Input.Dragging is the same question without the geometry.

It is empty in DragPans, where a drag moves the chart rather than marking out part of it.

func (*Input) Dragging added in v0.6.0

func (i *Input) Dragging() bool

Dragging reports whether a drag is in progress — a button is held and the pointer has moved past the click slop. A surface uses it to decide what cursor to show.

func (*Input) Leave added in v0.6.0

func (i *Input) Leave() error

Leave reports the pointer leaving the surface. It cancels any drag in progress and fires Leave, so that a tooltip opened on a hover closes.

func (*Input) Live added in v0.6.0

func (i *Input) Live() *Live

Live returns the chart this input drives.

func (*Input) Move added in v0.6.0

func (i *Input) Move(x, y float64) error

Move reports the pointer at a device position.

With no button held it hovers, which fires Hover and reports the mark under the pointer. With a button held it pans, which drags the data under the pointer and redraws — so the value the reader grabbed stays under their finger, which is the whole reason a drag pans in the direction it does.

func (*Input) Rescale added in v0.6.0

func (i *Input) Rescale(dpr float64) error

Rescale reports the surface's new device pixel ratio and redraws. See Live.Rescale.

func (*Input) Resize added in v0.6.0

func (i *Input) Resize(w, h int) error

Resize reports the surface's new size and redraws. See Live.Resize.

func (*Input) Up added in v0.6.0

func (i *Input) Up(x, y float64) error

Up reports the button released at a device position.

A release that never moved past the click slop is a click, and fires Click; one that did is the end of a pan and fires nothing, because every step of it has already fired Pan. That is what keeps a dragged chart from also selecting whatever the pointer happened to land on.

A release with no press behind it fires nothing either. A surface has more ways to lose a press than to report one — a double click consumed by Input.DoubleClick, a press that started outside the chart, a window that took the pointer away — and inventing a click for each of them would put a tooltip on screen every time a reader reset the view.

func (*Input) Wheel added in v0.6.0

func (i *Input) Wheel(x, y, delta float64) error

Wheel zooms about a device position by a raw scroll delta, and redraws.

The delta is in the browser's pixel convention, which is the one every platform can be converted into: positive scrolls the content away from the reader and zooms out, and one notch of a mouse wheel is about a hundred. WheelFactor is the curve it goes through.

type Kind added in v1.7.0

type Kind = interact.Kind

Kind is what sort of thing a hit landed on. See interact.Kind.

type Live added in v0.5.0

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

Live is a chart drawn into a surface that can be redrawn, pointed at, panned and zoomed.

It is the interactive half of a plot: Plot.Render draws once into a file, Live draws over and over into something that stays open — a browser canvas, a window, a test.

live, err := p.Live(canvas.Element(el))
defer live.Close()
live.Draw()

// from the surface's event loop
live.Move(x, y)
live.Wheel(x, y, dy)

What a redraw costs

Each Draw records the frame, compares it with the last one and repaints only where the two differ — see ir.Damage. A backend that cannot repaint part of a frame gets the whole one, and a frame that is identical to the last is not painted at all.

The chart is built once

Live resolves the plot into panels when it is created, so that a zoom lands on scales that are still there next frame. Adding a layer, changing the facet or replacing a scale afterwards needs Live.Rebuild, which starts again from the plot as it now stands and puts the reader's view back onto the axes that survive.

Two kinds of method

Live.Move, Live.Click and Live.Leave return the Event they fired and no error, because they do not draw: a hover reads the index and reports. Live.Wheel, Live.PanBy, Live.ZoomTo and Live.Autoscale return an error and no event, because each changes the view and redraws it, and a redraw can fail. The split is the difference between asking the chart something and changing it.

A Live is not safe for concurrent use.

func (*Live) Autoscale added in v0.5.0

func (l *Live) Autoscale() error

Autoscale releases every zoom and pan, so the axes come from the data again, and redraws. It is the "reset view" every interactive chart needs.

func (*Live) Click added in v0.5.0

func (l *Live) Click(x, y float64) Event

Click reports a click at a device position and fires Click.

func (*Live) Close added in v0.5.0

func (l *Live) Close() error

Close finalises the target. The last frame drawn is what it holds.

func (*Live) CurrentOverlay added in v1.7.0

func (l *Live) CurrentOverlay() Overlay

CurrentOverlay reports what is painting over the chart, or nil.

func (*Live) DPR added in v0.6.0

func (l *Live) DPR() float64

DPR reports the surface's current device pixel ratio.

func (*Live) Draw added in v0.5.0

func (l *Live) Draw() error

Draw renders the current state of the plot.

It returns nil having painted nothing when the frame is identical to the last one, which is the common case for a pointer moving over a chart that is not being zoomed.

func (*Live) Hide added in v1.7.0

func (l *Live) Hide(layer int, hide bool) error

Hide turns a layer off, or back on, and redraws.

A hidden layer is not drawn. It still trains its scales, and it still appears in the legend — dimmed, so that a reader can see what they have put away and bring it back.

The axes deliberately do not move. A toggle is a reading aid — let me see this one without that one on top — and an axis that rescaled every time one was clicked would make the two readings incomparable, which is the thing the toggle was for. A caller who wants the axes to follow what is left is making a different statement about the chart, and makes it with Plot.SetLayers and Live.Rebuild.

The index is redrawn with it, so a hidden layer's marks are no longer under the pointer: a tooltip for something invisible would be a tooltip for nothing.

A layer index outside the chart's layers is ignored and redraws nothing.

func (*Live) Index added in v0.5.0

func (l *Live) Index() *interact.Index

Index returns the hit index of the last frame, for a caller drawing its own tooltip or crosshair.

func (*Live) Input added in v0.6.0

func (l *Live) Input() *Input

Input returns a driver for this chart's surface input.

Each call returns a fresh driver with no button held. A surface wants one, made once and kept for as long as the Live is open.

func (*Live) IsHidden added in v1.7.0

func (l *Live) IsHidden(layer int) bool

IsHidden reports whether a layer is currently turned off.

func (*Live) Leave added in v0.5.0

func (l *Live) Leave() Event

Leave reports the pointer leaving the surface and fires Leave.

func (*Live) Move added in v0.5.0

func (l *Live) Move(x, y float64) Event

Move reports the pointer at a device position and fires Hover.

The event carries the mark under the pointer, if there is one within interact.DefaultTolerance, and the panel the pointer is in, or -1 for a point in the margins.

The one move that fires something else is the move that leaves the last panel: that fires Leave instead, once, so that a tooltip opened on a hover has a matching event to close on. Moving around in the margins after that goes on firing Hover with no hit.

func (*Live) Overlay added in v1.7.0

func (l *Live) Overlay(o Overlay) *Live

Overlay installs something to paint over the chart, and returns l so the call can be chained onto Plot.Live. Passing nil removes whatever was there.

It takes effect on the next Live.Draw. An overlay is a *pointer* to a struct whose fields the caller then moves — a crosshair's position, a tooltip's lines — so installing it once and mutating it per event is the intended shape, and there is nothing to re-install.

cross := &refract.Crosshair{}
live.Overlay(cross)
p.On(refract.Hover, func(ev refract.Event) {
	cross.At, cross.Show = ev.Point, ev.Panel >= 0
})

A hover does not redraw by itself — Live.Move answers a question and does not change the chart — but Input.Move does when an overlay is installed, which is what makes the crosshair follow the pointer on a real surface. A frame identical to the last is still not painted, so a pointer moving over a chart with no overlay costs exactly what it did before.

func (*Live) PanBy added in v0.5.0

func (l *Live) PanBy(dx, dy float64) error

PanBy moves the view by a device-space delta and redraws. It is what a drag does: the data follows the pointer, so dragging right shows earlier data.

func (*Live) Rebuild added in v0.5.0

func (l *Live) Rebuild() error

Rebuild resolves the plot again, picking up layers, scales or a facet added since the Live was created, and keeps the view the reader had established.

Keeping the view is the whole point of the method rather than a courtesy. The thing a caller rebuilds for is usually a reaction to something the reader did — a hover in one chart that adds a highlight layer to this one — and throwing away their zoom as a side effect of answering them is a chart that fights back. Live.View and Live.SetView are the same capability spelled out, for a caller who wants it across something wider than a rebuild; a caller who genuinely wants a fresh start has Live.Autoscale.

A view can only be put back onto axes that exist. A facet whose free axes belonged to panels the new plot does not have loses those, because a domain from a panel that is gone describes nothing — and a rebuild that changes the panel count keeps nothing at all, for the same reason.

It does not paint. Restoring the view needs the new panels, and the panels are what a render announces, so this renders once into its own recording to find them — which draws nothing, and is why a rebuild costs a frame that nobody sees. The frame the reader sees is the caller's next Live.Draw.

func (*Live) Rescale added in v0.6.0

func (l *Live) Rescale(dpr float64) error

Rescale tells the chart its surface's device pixel ratio has changed, and redraws it.

It is what a window dragged onto a display with a different one calls. The chart is not laid out differently — a device pixel ratio is not a size, and coordinates stay in device-independent units either way — but the surface behind it wants more pixels, and a backend that can provide them is told to. A backend that cannot is left alone and the frame is redrawn as it was.

Rescaling to the ratio it already has is not an error and draws nothing.

func (*Live) Resize added in v0.6.0

func (l *Live) Resize(w, h int) error

Resize tells the chart its surface has changed size, and redraws it.

It is what a window's resize event and a reflowed canvas element call. The scales keep whatever they were zoomed or panned to — a reader who has dragged a view into place has not asked to leave it — and the chart is laid out again at the new size, so the margins, the tick count and the legend follow. A Responsive plot also rescales its type and stroke weights here.

The backend is told too, if it can be: a surface that implements ir.Resizer is resized in place rather than reopened, which is what keeps the frame on screen and the zoom in the scales. One that cannot is redrawn at the new logical size into the surface it has, which is the best available answer and is what a document target would do.

Resizing to the size it already has is not an error and draws nothing.

func (*Live) Select added in v1.7.0

func (l *Live) Select(r ir.Rect) []Event

Select reports the rows under a device-space rectangle, firing one Select event per layer the rectangle touched.

It is the read half of a brush: the rectangle comes from wherever the caller got one — a drag through Input with Input.Drag set to DragSelects, a region computed from a value, a test — and what comes back is the rows, not a decision about them. What a selection *means* is the caller's: highlight them here, filter another chart by them, put them in a table beside the plot. refract does not remember which rows are selected, because a library that did would have to answer whose selection it was when two charts disagreed.

The events are returned as well as fired, so a caller driving this directly need not register a handler to see the answer. They come in layer order within a panel, and panel order across the chart.

It reports nothing without row tracking — see Live.TrackRows. A rectangle over marks whose rows are unknown is a rectangle over an unanswered question, and reporting the marks instead would be answering a different one. It does not draw: like Live.Move and Live.Click, asking the chart something does not change it.

func (*Live) SetView added in v1.7.0

func (l *Live) SetView(v View) error

SetView puts a view back and redraws.

It is the counterpart of Live.View and the two are meant to bracket something that would otherwise lose the reader's place. A view of a different shape — taken from a chart with a different number of panels — is ignored rather than applied partly, and an empty view does nothing; both return nil and redraw, because "the view did not change" is not a failure.

An axis that cannot be pinned is left alone. scale.Zoomer is what a scale implements to have its domain set, and a scale that does not is a scale that does not zoom either — so there was nothing for a reader to establish and nothing to put back.

func (*Live) ShowAll added in v1.7.0

func (l *Live) ShowAll() error

ShowAll turns every layer back on and redraws. It is what a "reset" control calls, and what Input.DoubleClick would call if hiding were a view state — it is not, because a hidden series is a statement about what the reader wants to see rather than about where they are looking.

func (*Live) Size added in v0.6.0

func (l *Live) Size() (w, h int)

Size reports the surface's current size in device-independent pixels.

func (*Live) Toggle added in v1.7.0

func (l *Live) Toggle(layer int) error

Toggle turns a layer off if it is on, and on if it is off, and redraws.

It is what a click on a legend row calls:

p.On(refract.Click, func(ev refract.Event) {
	if ev.Hit.Kind == refract.LegendRow {
		live.Toggle(ev.Hit.Layer)
	}
})

That the wiring is four lines in the caller rather than a mode on the chart is deliberate, and is the same answer this library gives everywhere a pointer means something: refract says what was clicked, and what it means is the program's. A legend that always toggled would be wrong for a chart whose legend selects rather than filters, or one where clicking a series should open something.

A Colorbar or a SizeKey hit has no Toggle: neither stands for a layer, so what a click on one means is a range of values or a magnitude rather than a series to put away. See [Hit.Lo], [Hit.Hi] and [Hit.Value].

func (*Live) TrackRows added in v0.5.0

func (l *Live) TrackRows(on bool) *Live

TrackRows turns row identity on or off and returns l, so the call can be chained onto Plot.Live.

live, err := p.Live(canvas.Element(el))
live.TrackRows(true)
// ...
p.On(refract.Hover, func(ev refract.Event) {
    if ev.Found && ev.Hit.Row >= 0 {
        highlightTableRow(ev.Hit.Row)
    }
})

It is off by default because it is not free. With it on, every layer that can report its rows records where each one landed, and the hit index keeps a position and a row number per mark on top of the marks it already keeps — memory proportional to the marks on screen, which after decimation is thousands rather than millions, but not nothing. Without it, [Hit.Row] is -1 and a hit still reports the data values under the pointer.

It takes effect on the next Live.Draw.

Not every mark has a row to report. A boxplot's box aggregates many rows, a density raster is not a mark at all, an interpolated point across a gap was never measured, and a third-party geom that does not report its rows has none to report; all of those leave [Hit.Row] at -1 rather than guessing a nearby one.

func (*Live) Transition added in v1.7.0

func (l *Live) Transition(tweens ...*data.Tween) (*Transition, error)

Transition prepares a move between the state its tweens start at and the state they end at.

It positions the tweens at the start and draws nothing: the first frame is the caller's first Transition.At or Transition.Advance.

func (*Live) View added in v1.7.0

func (l *Live) View() View

View reports where the chart is currently looking.

It is taken from the frame last drawn: the panels and their scales are what the render announced, so a View from a chart that has not been drawn is empty. Every axis is read, including the secondary ones, because a view that restored one direction and not the other would slide two series apart — which is the same reason Live.Wheel moves all four.

func (*Live) Wheel added in v0.5.0

func (l *Live) Wheel(x, y, factor float64) error

Wheel zooms about a device position by factor, and redraws.

factor below 1 zooms in and above 1 zooms out: it multiplies the width of the view, so 0.8 shows four fifths of what was there. A wheel notch is usually turned into 0.9 or 1.1 by the surface.

Both axes zoom, about the pointer, so that the value under the cursor stays under the cursor. A scale that cannot be zoomed — an ordinal axis, where half a category is not a view of anything — is left alone, and a wheel over a chart with two such axes does nothing at all.

func (*Live) ZoomTo added in v0.5.0

func (l *Live) ZoomTo(r ir.Rect) error

ZoomTo zooms into a device-space rectangle — a rubber-band selection — and redraws.

type Option

type Option func(*Plot)

Option configures a Plot at construction.

func Coord added in v0.8.0

func Coord(c coordpkg.Coord) Option

Coord sets the coordinate system: what the interval a scale maps into means.

The default is [coord.Cartesian], the identity, where the interval is a distance along an edge of the plot. [coord.Polar] wraps one axis around a circle and reads the other as a radius, which is all a pie, a donut, a radar, a rose or a gauge is — the marks are the ones that were already there:

p := refract.New(refract.Coord(coord.Donut(0.45)))
p.X(scale.Linear())
p.Y(scale.Linear())
p.Add(geom.Bar(src, geom.X("one"), geom.Y("share"), geom.GroupBy("browser")))

[coord.Pie] and [coord.Donut] are that recipe named; neither scale is niced, because a pie's ring closes on the stacked total. A slice can also name its own inner and outer radius with geom.X and geom.X2 and be broken out of the ring with geom.ExplodeBy, neither of which is a new mark.

[coord.Smith] is the third one, and the same idea over a different map: it reads the pair as a normalised impedance and carries it through Γ = (z − 1)/(z + 1) onto the unit disc, which is the Smith chart. Its grid is the two axes' own ticks — a circle per resistance, an arc per reactance — so again the mark is one that was already there:

p := refract.New(refract.Coord(coord.Smith()))
p.X(scale.Linear(scale.Domain(0, 50), scale.TickValues(0, 0.2, 0.5, 1, 2, 5)))
p.Y(scale.Linear(scale.Domain(-50, 50), scale.TickValues(-5, -1, -0.5, 0.5, 1, 5)))
p.Add(geom.Line(sweep, geom.X("r"), geom.Y("x")))

A coord belongs to the chart rather than to a panel, so the panels of a facet all share it.

func DPR

func DPR(r float64) Option

DPR sets the device pixel ratio. Backends that rasterize multiply the pixel buffer by it; coordinates stay in device-independent units either way. The default is 1.

func Description added in v0.6.0

func Description(title, detail string) Option

Description attaches an accessible description to the chart.

title is the short label — an SVG's <title>, a PDF's document title, a canvas element's aria-label. detail is the long reading, which an SVG puts in a <desc> and a screen reader reads after the title. Either may be empty.

A chart with a Title already has a short label and needs no option to get one: the title is written into the output as a matter of course, because a picture with no accessible name is the one thing every accessibility guideline agrees about. This option is for saying something *other* than the title, and for the paragraph a title cannot hold.

Plot.Describe writes both from the data instead, which is what to reach for when the chart is built by a program rather than by a person.

func Legend

func Legend(show bool) Option

Legend forces the legend on or off. By default a legend appears once a plot has more than one layer: one series does not need to be told apart from anything.

func Locale added in v1.3.0

func Locale(l *scale.Locale) Option

Locale sets the language every axis of this plot writes its tick labels in: the decimal and group separators of a number, the percent sign, and the month and weekday names of a time axis.

It reaches the scales through scale.Localizer, which every scale in the scale package implements except the ordinal one — an ordinal axis labels its ticks with the caller's own categories, and translating those would be inventing data. A scale from somewhere else that does not implement it is left alone rather than refused.

It is a plot option rather than a scale one because a chart is in one language: setting it per scale means saying it once per axis and once per track, and forgetting it somewhere is a chart with two languages in it.

The default is scale.English, which is what every chart drew before this option existed.

p := refract.New(refract.Locale(scale.LocaleDE))
p.X(scale.Time()).Y(scale.Linear(scale.NumberFormat("#,.1")))
// → "1.234,5" on the Y axis and "Mär 2026" on the X one

func Math added in v0.6.0

func Math(ts mathtext.Typesetter) Option

Math typesets the notation in the chart's labels.

It applies to every label the chart draws — the title, the axis titles, the tick labels, the legend, a facet's strip, a geom's own note — because a typesetter is installed by wrapping the backend rather than by being consulted at each place text is written.

p := refract.New(
    refract.Math(mathtext.TeX()),
    refract.YTitle(`flux density $F_\nu$ ($\mathrm{W\,m^{-2}\,Hz^{-1}}$)`),
)

Passing nil turns it off, which is the default: a chart with no typesetter draws its labels exactly as they were written, and pays nothing for the notation it does not have. See package mathtext.

func Parallel added in v0.4.0

func Parallel(on bool) Option

Parallel controls whether a multi-panel chart builds its panels concurrently. It is on by default and produces identical output either way: each panel is recorded on its own goroutine and the recordings are replayed in panel order.

Turn it off to keep a render on one goroutine — inside a benchmark that is measuring something else, or in a process that has already committed its cores elsewhere. It has no effect on a chart with a single panel, which has nothing to overlap.

func Responsive added in v0.6.0

func Responsive(on bool) Option

Responsive scales the theme with the size the chart is drawn at.

A plot is designed at one size — the one Size gave it, or the default 800x500 — and a responsive one keeps its proportions when it is drawn at another: half the width and half the height means half-size type, half-width strokes, half the margins. Without it a chart shrunk to a third of its design size keeps 12pt labels, and they eat the plot area.

It matters for a window, which the reader resizes, and for a browser canvas in a fluid layout. It does nothing at all to a chart rendered once at the size it was built with — the factor is exactly 1 — so turning it on cannot change an existing still.

The factor is the smaller of the two ratios, so that a chart stretched wide scales to what still fits its height, and it is clamped to the range a chart stays legible over. Colours do not scale; see theme.Scaled for what does.

func ResponsiveFrom added in v0.6.0

func ResponsiveFrom(w, h int) Option

ResponsiveFrom is Responsive with the design size given explicitly rather than taken from Size.

It is what a still rendered at another size needs: a thumbnail of a chart designed at 800x500 is `Size(200, 125)` with `ResponsiveFrom(800, 500)`, and it comes out as the chart at a quarter of the size rather than as the chart with four times the type in it. A Live surface needs neither, because the size it was built with is already the design.

func Size

func Size(w, h int) Option

Size sets the output size in device-independent pixels. The default is 800x500.

func Theme

func Theme(t themepkg.Theme) Option

Theme sets the visual tokens. The default is theme.Light.

func Title

func Title(s string) Option

Title sets the chart title.

func X2Title added in v1.3.0

func X2Title(s string) Option

X2Title sets the title of the secondary horizontal axis, written along the chart's top. It is ignored by a chart with no Plot.X2.

func XTitle

func XTitle(s string) Option

XTitle sets the horizontal axis title.

func Y2Title added in v1.3.0

func Y2Title(s string) Option

Y2Title sets the title of the secondary vertical axis, written down the chart's right-hand side. It is ignored by a chart with no Plot.Y2.

func YTitle

func YTitle(s string) Option

YTitle sets the vertical axis title.

type Overlay added in v1.7.0

type Overlay = render.Overlay

Overlay paints over a finished chart. See render.Overlay.

type OverlayFrame added in v1.7.0

type OverlayFrame = render.OverlayFrame

OverlayFrame is what an overlay is told. See render.OverlayFrame.

type OverlayPanel added in v1.7.0

type OverlayPanel = render.OverlayPanel

OverlayPanel is one panel of that frame. See render.OverlayPanel.

type Overlays added in v1.7.0

type Overlays []Overlay

Overlays draws several overlays in order, so that a chart can have a crosshair and a tooltip at once.

The order is the drawing order: later ones are on top, which is why a tooltip belongs after a crosshair rather than before it. A nil member is skipped, so a caller may keep a fixed-length list and switch one off by clearing it.

func (Overlays) DrawOverlay added in v1.7.0

func (os Overlays) DrawOverlay(b ir.Backend, f OverlayFrame)

DrawOverlay implements render.Overlay.

type Plot

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

Plot is a chart specification: size, theme, scales and layers.

A Plot is not safe for concurrent modification. Rendering the same Plot twice is supported and produces the same result, provided the underlying data has not changed.

func FromSpec added in v0.5.0

func FromSpec(s spec.Spec) (*Plot, error)

FromSpec builds a plot from a document.

func New

func New(opts ...Option) *Plot

New creates a Plot.

func ParseJSON added in v0.5.0

func ParseJSON(b []byte) (*Plot, error)

ParseJSON builds a plot from a spec document.

p, err := refract.ParseJSON(b)
if err == nil {
    err = p.Render(refract.SVG("chart.svg"))
}

It is the whole web workflow in two calls: a browser or a config file hands over a chart, and the same model that a Go program builds by hand draws it.

func (*Plot) Add

func (p *Plot) Add(gs ...geom.Geom) *Plot

Add appends layers, drawn in the order given.

func (*Plot) DataTable added in v0.6.0

func (p *Plot) DataTable(w io.Writer) error

DataTable writes the chart's data to w as an HTML table — the fallback for a reader who cannot see the picture, and the honest answer to what is in it. See a11y.WriteTable.

func (*Plot) Describe added in v0.6.0

func (p *Plot) Describe() a11y.Summary

Describe reads the chart's own data and attaches a description of it, then returns what it wrote. See a11y.Describe for the shape of the summary and a11y.Chart for what it is derived from.

p.Describe()
p.Render(refract.SVG("chart.svg")) // carries <title> and <desc>

It is a method that does work rather than an option that sets a flag, because the work is a pass over every plotted column: a chart that nobody asked to describe should not pay for one on every render, and a chart that did should pay for it once rather than per frame. Call it again after the data changes.

A description written by Description is replaced by this, and calling Describe on a plot that has been given one is how a caller says the data has moved on.

func (*Plot) Description added in v0.6.0

func (p *Plot) Description() ir.Description

Description reports the description the chart currently carries: the one Description or Plot.Describe set, or the chart's title alone.

func (*Plot) Facet added in v0.3.0

func (p *Plot) Facet(s *facet.Spec) *Plot

Facet splits the plot into small multiples, one panel per value of a column. See facet.Wrap and facet.Grid.

p.Facet(facet.Wrap("region", facet.Columns(3)))

Passing nil turns faceting back off.

func (*Plot) HideLayer added in v1.7.0

func (p *Plot) HideLayer(layer int, hide bool) *Plot

HideLayer turns a layer off, or back on, by its index among the plot's layers. A hidden layer is not drawn, still trains its scales, and still appears in the legend, dimmed.

It is on the plot as well as on Live for the reason Plot.Overlay is: otherwise Plot.Render and Live.Draw would disagree about what a chart is, and exporting a chart with a series put away would be impossible from the model alone. Live.Hide is the one a legend click calls, and it starts from whatever the plot said.

An index outside the plot's layers is ignored.

func (*Plot) Layers added in v1.7.0

func (p *Plot) Layers() []geom.Geom

Layers reports the plot's layers, in drawing order. The slice is a copy; the layers in it are not.

It is what a caller reaching for Plot.SetLayers needs first: keeping the ones that were there and replacing the rest means being able to see them.

func (*Plot) Live added in v0.5.0

func (p *Plot) Live(t Target) (*Live, error)

Live opens t and returns a chart drawn into it.

The target stays open until Live.Close, which is what makes this different from Plot.Render: a Live draws frame after frame into one surface.

It wants a surface rather than a document. The SVG and PDF emitters build a document and write it whole, so drawing many frames into one collects every frame in the same file rather than replacing what was there — use Plot.Render for those, and a Live for a canvas, a window, or anything else that is repainted. A single Draw into a document target is exactly a Render, and is a reasonable way to export what an interactive chart currently shows.

func (*Plot) MarshalJSON added in v0.5.0

func (p *Plot) MarshalJSON() ([]byte, error)

MarshalJSON writes the plot as a spec document, so that a Plot can be handed straight to encoding/json.

Called directly it returns the indented form spec.Spec.Marshal produces: a chart is a thing people read and edit, and the compact form of one over a hundred rows is a single very long line. Called through json.Marshal it comes back compact, because that is what json.Marshal does to anything a Marshaler returns.

func (*Plot) On added in v0.5.0

func (p *Plot) On(kind EventKind, h func(Event)) *Plot

On registers a handler for an event kind.

p.On(refract.Hover, func(ev refract.Event) {
    if ev.Found {
        tooltip(ev.Series(), ev.Hit.X, ev.Hit.Y)
    }
})
p.On(refract.Zoom, func(ev refract.Event) { log.Println(ev.Rect) })

Handlers fire in registration order, from Live's input methods, on the goroutine that called one. Registering a handler does not by itself make a chart interactive — Plot.Live is what draws one into a surface that can report input.

func (*Plot) Overlay added in v1.7.0

func (p *Plot) Overlay(o Overlay) *Plot

Overlay installs something to paint over the finished chart — a crosshair, a tooltip, a brush rectangle. Passing nil removes it. See Overlay.

It is on the plot as well as on Live so that the two agree about what a chart is: an overlay that only existed on a live surface would make Plot.Render and Live.Draw draw different pictures of the same model, and exporting what a reader is looking at — the chart with its crosshair where they left it — would be impossible from the model alone.

Live.Overlay overrides this for one surface. A plot that names one and a Live that names another draws the Live's, because the Live is the thing with a pointer over it.

func (*Plot) Render

func (p *Plot) Render(t Target) (err error)

Render draws the plot into t.

It opens the target, lowers the chart into the backend it returns, flushes, and closes the target — so a file target has a complete file on disk when Render returns nil.

func (*Plot) SetLayers added in v1.7.0

func (p *Plot) SetLayers(gs ...geom.Geom) *Plot

SetLayers replaces the plot's layers with the ones given, drawn in the order given. Passing none leaves a plot with no layers, which Plot.Render refuses with ErrNoLayers.

It is Plot.Add's counterpart and exists for the same caller: one reacting to something the reader did. A chart that gains a highlight layer on every hover and can never lose one accumulates a layer per pointer move, so a plot that can be added to has to be a plot that can be set. Building a fresh Plot each time is the alternative and a worse one — it discards the scales, and with them the zoom the reader established.

Layers already drawn are unaffected until the chart is resolved again: Live.Rebuild is what picks this up, and it keeps the view.

The slice is copied, so the caller may reuse it.

func (*Plot) Size added in v0.6.0

func (p *Plot) Size() (w, h int)

Size reports the size the plot is drawn at, in device-independent pixels. It is what Size set, or the default, and it is what a surface opening a window for this plot wants to know.

func (*Plot) Spec added in v0.5.0

func (p *Plot) Spec() (spec.Spec, error)

Spec writes the plot down as a document that can be marshalled to JSON and read back with FromSpec.

It fails on a layer or a scale that cannot describe itself rather than writing a document that draws a different chart — see github.com/timzifer/refract/spec for what survives the trip and what cannot.

func (*Plot) Track added in v1.1.0

func (p *Plot) Track(e Edge, opts ...TrackOption) *Track

Track attaches a band to an edge of the plot area and returns it, so that layers can be added to it.

p.Track(refract.Bottom, refract.TrackSize(48)).Add(states)

Tracks at one edge stack in the order they were added, outwards from the panel. Bands on two edges at once are fine — a strip below and a key beside leave the corner between them empty — and a track with a Plot.Facet is ErrTrackWithFacet: a facet already owns the grid the track would need a row or a column of.

func (*Plot) Tracks added in v1.1.0

func (p *Plot) Tracks() []*Track

Tracks reports the tracks attached to the plot, in the order they were added. It is what a caller rebuilding a plot from another one needs.

func (*Plot) UnmarshalJSON added in v0.5.0

func (p *Plot) UnmarshalJSON(b []byte) error

UnmarshalJSON replaces the plot's contents with the document in b.

func (*Plot) X

func (p *Plot) X(s scale.Scale) *Plot

X sets the horizontal scale. The default is scale.Linear with nicing.

func (*Plot) X2 added in v1.3.0

func (p *Plot) X2(s scale.Scale) *Plot

X2 sets the chart's secondary horizontal axis: a second scale, drawn along the top, read by the layers that asked for it with geom.OnX2.

It is Plot.Y2 turned a quarter turn and everything said there holds, including that the second axis draws no grid lines. What it is *for* is different: two series measured over different extents of the same thing — a run indexed by cycle beside one indexed by elapsed time, a spectrum read in wavelength against the same spectrum in wavenumber, a backlog by date against a backlog by sprint.

The two directions are independent. A layer may name geom.OnX2 and geom.OnY2 together, and then it reads the top axis and the right one.

func (*Plot) Y

func (p *Plot) Y(s scale.Scale) *Plot

Y sets the vertical scale. The default is scale.Linear with nicing.

func (*Plot) Y2 added in v1.3.0

func (p *Plot) Y2(s scale.Scale) *Plot

Y2 sets the chart's secondary vertical axis: a second scale, drawn down the right-hand side, read by the layers that asked for it with geom.OnY2.

It is the chart of two quantities in different units — revenue as bars against the left axis, margin as a percentage line against the right — and it is one chart with two axes rather than two charts overlaid, which is why the binding is on the layer and the scale is on the plot.

The second axis draws **no grid lines**. Two ladders of horizontal rules at different values are a moiré rather than a reading, and which of the two a line belongs to is unanswerable by looking; the grid stays the primary axis's. See [ADR 0037](docs/adr/0037-secondary-axis.md).

A chart with no layer on it draws the axis anyway, because an axis somebody asked for is a statement about the chart even where nothing reaches it yet — a live chart whose second series has not arrived is the case.

type RowRef added in v1.7.0

type RowRef = interact.RowRef

RowRef is where one source row landed. See interact.RowRef.

type Source

type Source = data.Source

Source is a columnar data source. See package data.

func Float64Columns

func Float64Columns(cols map[string][]float64) Source

Float64Columns builds a Source over numeric columns, borrowing the slices. See data.Float64Columns.

type Target

type Target = ir.Target

Target is a render destination. See package ir.

func PDF added in v0.3.0

func PDF(path string, opts ...pdf.Option) Target

PDF returns a target writing a PDF document to the named file.

Like SVG, this is a zero-dependency path: the emitter is in backend/pdf and uses nothing but the standard library. The page is one PDF point per device-independent pixel, so a chart sized 800x500 is an 800x500pt page.

func PDFWriter added in v0.3.0

func PDFWriter(w io.Writer, opts ...pdf.Option) Target

PDFWriter returns a target writing a PDF document to w.

func SVG

func SVG(path string, opts ...svg.Option) Target

SVG returns a target writing an SVG document to the named file.

This is the zero-dependency path: it uses the built-in emitter in backend/svg and links no rendering engine.

func SVGWriter

func SVGWriter(w io.Writer, opts ...svg.Option) Target

SVGWriter returns a target writing an SVG document to w.

type Tooltip added in v1.7.0

type Tooltip struct {
	// At is the point the tooltip is about, in device space. The box is
	// placed beside it, not over it.
	At ir.Point
	// Lines are the text, one line each. No lines is no tooltip.
	Lines []string

	// Fill, Stroke and Color override the theme. A zero Fill takes the
	// theme's canvas background, a zero Stroke its axis colour, and a zero
	// Color its label colour — so a tooltip over a dark chart is dark.
	Fill   ir.Color
	Stroke ir.Color
	Color  ir.Color

	// Size is the type size in device units. Zero takes the theme's tick
	// size, which is the size the chart's other small text is set in.
	Size float64
	// Pad is the space between the text and the box. Zero takes six.
	Pad float32
	// Offset is how far the box sits from At. Zero takes twelve, which clears
	// a default marker and a fingertip.
	Offset float32
}

Tooltip is a box of text beside a point.

It is the one overlay that measures: the box is sized to the lines it holds, through the backend that is going to draw them, so a tooltip is the width of its text in the font it is actually rendered in rather than in an estimate of one.

It keeps itself on the canvas. A tooltip near the right edge flips to the left of its anchor and one near the bottom flips above it, because a box that ran off the drawing would hide the thing it was explaining.

The zero value draws nothing.

func (*Tooltip) DrawOverlay added in v1.7.0

func (t *Tooltip) DrawOverlay(b ir.Backend, f OverlayFrame)

DrawOverlay implements render.Overlay.

type Track added in v1.1.0

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

Track is a band at an edge of the plot area, on one of the plot's own scales.

It is what a chart needs when part of what it shows is not on the panel's other axis at all: a gantt strip of machine states under a speed trace, a rug of event times, a ribbon of shifts, a key or a marginal distribution beside the panel. The track shares the axis it runs along — the same scale object, so a zoom is one zoom rather than two that agree — and carries a scale of its own across it, of whatever kind suits it. Putting an ordinal band under a linear panel is the case this exists for.

p := refract.New()
p.X(scale.Time()).Y(scale.Linear())
p.Add(geom.Line(src, geom.X("t"), geom.Y("speed")))

p.Track(refract.Bottom, refract.TrackSize(48)).
	Add(geom.Rect(states, geom.X("start"), geom.X2("end"), geom.Y("row"),
		geom.ColorBy("state", palette)))

A track's size comes out of the panel, not out of the panel's domain: the axis the track does not share is identical with the track and without it. That is the difference between a track and the lane of negative values it replaces, where the lane was in the domain and `scale.Zero` stopped meaning what it said.

The axis it *does* share is trained by both, because it is one axis and not two that agree — a rug of event times widens the time axis to cover the events, which is the whole reason to draw them against it.

func (*Track) Add added in v1.1.0

func (t *Track) Add(gs ...geom.Geom) *Track

Add appends layers to the track, drawn in the order given.

func (*Track) Edge added in v1.1.0

func (t *Track) Edge() Edge

Edge reports which side of the panel the track is attached to.

type TrackOption added in v1.1.0

type TrackOption func(*Track)

TrackOption configures a track.

func TrackAxis added in v1.1.0

func TrackAxis(show bool) TrackOption

TrackAxis decides whether the track writes the tick labels of its own scale — the lane names, on an ordinal one. The default is true: a lane nobody can name is a coloured stripe.

They are written along the same edge the panel's are, and share the panel's gutter there, which is what keeps a track's edge and the panel's edge in the same place.

func TrackFraction added in v1.1.0

func TrackFraction(f float32) TrackOption

TrackFraction sets the band's thickness as a share of the canvas, in (0, 1) — of its height for a Bottom or Top track, of its width for a Left or Right one. It is what a Responsive chart wants: a strip given 48 pixels keeps them as the canvas shrinks around it, and eventually there is nothing left to keep them out of.

It replaces any TrackSize.

func TrackGrid added in v1.1.0

func TrackGrid(show bool) TrackOption

TrackGrid decides whether the track draws grid lines. The default is false: a grid line through a gantt bar is a rule drawn across a solid shape.

func TrackScale added in v1.1.0

func TrackScale(s scale.Scale) TrackOption

TrackScale sets the track's own scale: the one it does not share. That is the vertical scale of a Bottom or Top track and the horizontal scale of a Left or Right one — in both cases the axis the band is thick in.

The default is scale.Ordinal, because a track's rows are usually lanes with names rather than a quantity. The scale is the track's alone: nothing here touches the scale the track shares with the panel.

func TrackSize added in v1.1.0

func TrackSize(px float32) TrackOption

TrackSize sets how thick the band is, in device-independent pixels: the height of a Bottom or Top track, the width of a Left or Right one. It is the unit a lane count is naturally counted in — three lanes of sixteen pixels — and it is taken out of the panel, so the panel is what shrinks.

It replaces any TrackFraction. The default is DefaultTrackSize.

type Transition added in v1.7.0

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

Transition moves a chart from where its tweens start to where they end.

refract owns no clock

Transition.At is the whole primitive: a fraction in, a frame out. It reads no clock, starts no goroutine and schedules nothing — which is what makes a transition a pure function of a number, and therefore something a golden file can be taken of. Transition.Advance is sugar for a host that has a time.Time to hand over.

The loop belongs to whatever is already running one:

// A browser, from requestAnimationFrame:
running, err := tr.Advance(time.Now())

// A window, from window.Handler.Frame — asking for another frame only
// while it is running, so an idle window stays idle:
if running {
	w.Redraw()
}

// A test, with no clock at all:
tr.At(0.5)

The chart is not rebuilt

A data.Tween is one Source whose contents change, so the layer over it is built once and a frame of a transition costs a frame. Build the layers over data.Tween.Source before opening the Live, and hand the same tweens here.

The axes stay where they are

By default a transition moves the data and leaves the axes alone, because an axis that rescales itself every frame is one a reader cannot compare two frames of — the same reason a live chart pins its axes. A chart whose two states need different axes says so with Transition.Rescale.

A Transition is not safe for concurrent use, and neither is the Live behind it.

func (*Transition) Advance added in v1.7.0

func (tr *Transition) Advance(now time.Time) (running bool, err error)

Advance puts the transition where the clock says it should be and redraws, reporting whether there is more to come.

The first call starts it, so a transition begins when the host first asks about it rather than when it was built — which is what lets one be prepared on a click and driven from the next frame callback.

running is false on the frame that reaches the end and on every call after it, so a host asking for another frame only while it is true stops asking exactly once.

func (*Transition) At added in v1.7.0

func (tr *Transition) At(f float64) error

At puts the transition at a fraction of the way through and redraws.

The fraction is clamped to [0, 1] and goes through the easing curve. It is the primitive: everything else here is a way of choosing a number to give it.

func (*Transition) Done added in v1.7.0

func (tr *Transition) Done() bool

Done reports whether the transition has reached its end.

func (*Transition) Ease added in v1.7.0

func (tr *Transition) Ease(e Easing) *Transition

Ease sets the curve the fraction goes through and returns tr, so the call can be chained onto Live.Transition. The default is EaseInOut.

func (*Transition) Finish added in v1.7.0

func (tr *Transition) Finish() error

Finish jumps to the end.

With Transition.Rescale on it also releases the axes, so that the chart goes back to following its data rather than staying pinned to wherever the transition left it. An axis left pinned is one that has quietly stopped tracking what it describes, which is a bug that shows up an hour later.

func (*Transition) Fraction added in v1.7.0

func (tr *Transition) Fraction() float64

Fraction reports how far through the transition is, before easing.

func (*Transition) Over added in v1.7.0

func (tr *Transition) Over(d time.Duration) *Transition

Over sets how long the transition takes and returns tr. It is read only by Transition.Advance; Transition.At does not know about time. The default is DefaultDuration.

func (*Transition) Rescale added in v1.7.0

func (tr *Transition) Rescale(on bool) *Transition

Rescale makes the axes move with the data, and returns tr.

It is off by default: an axis that rescales itself every frame is one a reader cannot compare two frames of, and a chart whose two states share an axis should keep it. Turn it on when they genuinely do not — a transition from last week's range to this year's — and the domains slide between the two rather than jumping at the first frame.

It works by finding out where each axis ends up: the tweens are put at each end and the chart rendered into a recording nobody sees, twice, here rather than per frame. Between the two the axes are released, so **an axis with a domain fixed at construction loses it** — which is the point, because a fixed domain is a caller saying the axis does not move and this is a caller saying it does. Pass false to change one's mind before the first frame.

While it is on the domains are pinned, and pinning is doing a second job: a scale.Nice axis re-rounds whatever it is trained on, so an unpinned one would relabel itself mid-move — and a changed tick *count* is a structural change, which makes ir.Damage report the two frames as not comparable and turns every frame into a full repaint. The animation would be the slowest one available and would look exactly right. Transition.Finish releases them again.

type View added in v1.7.0

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

View is where a chart is looking: the domain of every panel axis of the frame currently drawn.

It is the zoom and the pan a reader has established, in a form a caller can hold on to and put back. Live.Rebuild uses it to keep the view across a change to the plot, and a caller wanting the same guarantee across something wider — swapping a data source, rebuilding a dashboard — reaches for Live.View and Live.SetView directly.

A View is a value: taking one copies the numbers out of the scales, so it stays true after the scales move on. It describes the chart it was taken from and nothing else — put one back into a chart with a different number of panels and it is ignored, because a domain from the third panel of a grid of nine means nothing in a chart with one.

func (View) Empty added in v1.7.0

func (v View) Empty() bool

Empty reports whether the view describes nothing — which is what Live.View returns from a chart that has not been drawn, because the panels are known from the render rather than from the plot.

func (View) Panels added in v1.7.0

func (v View) Panels() int

Panels reports how many panels the view describes.

Directories

Path Synopsis
Package a11y makes a chart readable by something other than an eye.
Package a11y makes a chart readable by something other than an eye.
arrow module
backend
canvas
Package canvas draws a chart into a browser canvas.
Package canvas draws a chart into a browser canvas.
pdf
Package pdf renders a chart to PDF using nothing but the standard library.
Package pdf renders a chart to PDF using nothing but the standard library.
svg
Package svg is refract's built-in, zero-dependency SVG backend.
Package svg is refract's built-in, zero-dependency SVG backend.
gg module
gg/gpu module
window module
Package coord maps scaled positions into device space.
Package coord maps scaled positions into device space.
Package data is refract's data layer: columnar, batch-oriented access to a table of values.
Package data is refract's data layer: columnar, batch-oriented access to a table of values.
examples
accessible command
Command accessible renders a chart that can be read without being seen.
Command accessible renders a chart that can be read without being seen.
bigdata command
Command bigdata renders two charts that could not be drawn mark for mark.
Command bigdata renders two charts that could not be drawn mark for mark.
categories command
Command categories renders the categorical chart from the README.
Command categories renders the categorical chart from the README.
dashboard command
Command dashboard renders the v0.3 additions: small multiples, annotations, a colourbar, a grid of subplots, and PDF output.
Command dashboard renders the v0.3 additions: small multiples, annotations, a colourbar, a grid of subplots, and PDF output.
diagnostics command
Command diagnostics writes a normal QQ plot and a chart with placed labels.
Command diagnostics writes a normal QQ plot and a chart with placed labels.
distributions command
Command distributions renders the v0.9 charts from the README.
Command distributions renders the v0.9 charts from the README.
groups command
Command groups renders the grouped charts from the README.
Command groups renders the grouped charts from the README.
linked command
Command linked wires two charts together through the host program.
Command linked wires two charts together through the host program.
machine command
Command machine renders the chart tracks exist for: a shopfloor terminal's view of one machine's last hour.
Command machine renders the chart tracks exist for: a shopfloor terminal's view of one machine's last hour.
polar command
Command polar renders the charts a coordinate system unlocks.
Command polar renders the charts a coordinate system unlocks.
relational command
Command relational renders the charts a layout in the unit square unlocks.
Command relational renders the charts a layout in the unit square unlocks.
signal command
Command signal renders the chart from CONCEPT.md §13.
Command signal renders the chart from CONCEPT.md §13.
smith command
Command smith renders the charts a third coordinate system unlocks.
Command smith renders the charts a third coordinate system unlocks.
status command
Command status renders the two charts a path coloured from a column is for: a measurement read against a limit, and a series read against the state of the thing that produced it.
Command status renders the two charts a path coloured from a column is for: a measurement read against a limit, and a series read against the state of the thing that produced it.
stream command
Command stream draws a live chart over a growing series.
Command stream draws a live chart over a growing series.
transition command
Command transition animates a chart between two states of the same table.
Command transition animates a chart between two states of the same table.
web command
Command web draws an interactive chart in a browser.
Command web draws an interactive chart in a browser.
Package facet splits one chart into small multiples.
Package facet splits one chart into small multiples.
Package geom holds the visual marks a chart is made of.
Package geom holds the visual marks a chart is made of.
Package interact turns a rendered chart into something a pointer can ask questions of.
Package interact turns a rendered chart into something a pointer can ask questions of.
internal
cmd/release command
Command release tags a release of every module in this repository.
Command release tags a release of every module in this repository.
cmd/releasecheck command
Command releasecheck verifies a module against its published dependencies, with the development workspace disabled.
Command releasecheck verifies a module against its published dependencies, with the development workspace disabled.
fontmetrics
Package fontmetrics answers "how wide is this string" using nothing but the standard library.
Package fontmetrics answers "how wide is this string" using nothing but the standard library.
irtest
Package irtest provides a recording ir.Backend for tests.
Package irtest provides a recording ir.Backend for tests.
layout
Package layout decides where the plot area, titles and guides go.
Package layout decides where the plot area, titles and guides go.
markers
Package markers builds the outline of a scatter marker.
Package markers builds the outline of a scatter marker.
release
Package release holds what the release commands share: the list of modules this repository publishes, the tag each one is released under, and the check that a module builds against the versions its own go.mod names.
Package release holds what the release commands share: the list of modules this repository publishes, the tag each one is released under, and the check that a module builds against the versions its own go.mod names.
sfnt
Package sfnt reads the parts of a TrueType or OpenType font that embedding one in a PDF needs, and cuts a subset of it down to the glyphs a document actually uses.
Package sfnt reads the parts of a TrueType or OpenType font that embedding one in a PDF needs, and cuts a subset of it down to the glyphs a document actually uses.
sfnttest
Package sfnttest builds a font file byte by byte.
Package sfnttest builds a font file byte by byte.
svgdiff
Package svgdiff compares two SVG documents as drawings rather than as bytes.
Package svgdiff compares two SVG documents as drawings rather than as bytes.
Package ir defines refract's intermediate representation: a small, backend-agnostic scene description, plus the Backend interface every renderer implements.
Package ir defines refract's intermediate representation: a small, backend-agnostic scene description, plus the Backend interface every renderer implements.
Package mathtext typesets mathematical notation for chart labels.
Package mathtext typesets mathematical notation for chart labels.
Package palette provides colours and colour sequences for charts.
Package palette provides colours and colour sequences for charts.
Package render lowers a resolved chart into IR.
Package render lowers a resolved chart into IR.
Package scale maps data values onto visual positions and generates the ticks that label them.
Package scale maps data values onto visual positions and generates the ticks that label them.
Package spec writes a chart down as JSON and reads it back.
Package spec writes a chart down as JSON and reads it back.
Package stat aggregates data before it is drawn.
Package stat aggregates data before it is drawn.
Package theme holds the visual tokens a chart is drawn with: colours, fonts, sizes and spacings.
Package theme holds the visual tokens a chart is drawn with: colours, fonts, sizes and spacings.

Jump to

Keyboard shortcuts

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