Documentation
¶
Overview ¶
Package three draws a chart whose x, y and z are all data.
It is the second of the three features ADR 0055 split "3D" into: a surface over a grid, a trajectory through a volume, a field of bars over two categoricals — charts where the third dimension carries a reading the flat chart of the same table cannot. The camera that turns them is ADR 0057, and what the dimension is for is ADR 0058.
Shape of the API ¶
A Scene is what is plotted. A View is a camera on it. A Plot is the figure they are drawn into:
sc := three.NewScene(three.XTitle("x"), three.YTitle("y"), three.ZTitle("gain"))
sc.Z(scale.Linear(scale.Nice()))
sc.Add(three.Surface(src, geom.X("x"), geom.Y("y"), geom.Z("gain")))
p := three.New(three.Size(720, 520), three.Title("Response"))
p.Scene(sc)
err := p.Render(svg.File("surface.svg"))
The split is the point. A scene holds no camera, so several views share one scene and the scales behind it are trained once however many angles are drawn from them — one data repository, several ways of looking at it:
p.Add(
three.View{Camera: three.Home(), Label: "three-quarter"},
three.View{Camera: three.LookAt(three.Azimuth(0)), Label: "front"},
three.View{Camera: three.LookAt(three.Elevation(math.Pi / 2)), Label: "top"},
)
Why this is its own package ¶
github.com/timzifer/figure/render walks a panel's layers and each layer's Build streams straight into the backend, so a layer is a paint unit. A projected scene has no paint unit smaller than the view: a point can be in front of one part of a surface and behind another, so correct occlusion is a single depth order over the primitives of every layer at once. Producing that inside render would need either a depth on every drawing call or two drawing orders, and both are refused. So the draw loop, the ordering, the cube's furniture and the camera live here, beside render rather than through it.
A Layer is therefore not a github.com/timzifer/figure/geom.Geom, and that is a guard as much as a consequence: a geom handed a depth would ignore it and draw a flat line inside a projected box — correct by its own lights, wrong by the chart's and silent either way. Here it cannot happen, because geom.Line does not compile into a Scene. Layers do read geom's option set, though, so a channel is spelled the way it is spelled everywhere else.
The IR gains nothing ¶
There is no ir.Point3, no 4x4 in the IR and no depth on a drawing call. This package owns its own Vec3, its own matrix and its own Camera, projects everything above the seam, and calls Polyline, FillPath and Text with the plain two-dimensional coordinates those have always taken. So every backend draws a surface on the day this package compiles: SVG, PDF, canvas, raster, the native window, the GPU tier. See docs/adr/0056-three-dimensional-charts.md.
What it does not do ¶
No perspective: under one, the same value is taller at the front of the scene than at the back, and a chart is a measuring instrument first. No lighting model beyond one directional shade per face. No arbitrary meshes — a painter's order is exact only over a set that can be totally ordered, and two triangles that interpenetrate have no correct order at all. No windows, no handlers, no event loop: a camera is a value the caller holds, and turning it is the host's loop.
Index ¶
- Variables
- type Camera
- type CameraOption
- type Describer
- type Frame
- type Layer
- type Live
- func (l *Live) Camera(cam Camera) *Live
- func (l *Live) CameraOf(i int) Camera
- func (l *Live) CameraValue() Camera
- func (l *Live) Close() error
- func (l *Live) Draw() error
- func (l *Live) Home() *Live
- func (l *Live) Index() *interact.Index
- func (l *Live) Rescale(dpr float64) error
- func (l *Live) Resize(w, h int) error
- func (l *Live) SetCamera(i int, cam Camera) *Live
- func (l *Live) Size() (w, h int)
- func (l *Live) TrackRows(on bool) *Live
- func (l *Live) ViewAt(x, y float64) int
- func (l *Live) ViewCount() int
- type Option
- type Plot
- func (p *Plot) Add(vs ...View) *Plot
- func (p *Plot) DataTable(w io.Writer) error
- func (p *Plot) Describe() a11y.Summary
- func (p *Plot) Description() ir.Description
- func (p *Plot) Live(t ir.Target) (*Live, error)
- func (p *Plot) Observer(o render.Observer) *Plot
- func (p *Plot) Render(t ir.Target) (err error)
- func (p *Plot) Scene(s *Scene) *Plot
- func (p *Plot) Size() (w, h int)
- func (p *Plot) TrackRows(r geom.Rows) *Plot
- func (p *Plot) Views() []View
- type Scene
- type SceneOption
- type Sink
- type Style
- type Vec3
- type View
Constants ¶
This section is empty.
Variables ¶
var ErrCategorical = errors.New("figure/three: categorical column on a continuous scale")
ErrCategorical reports a text column mapped onto a continuous axis.
var ErrNoColumn = errors.New("figure/three: no column selected")
ErrNoColumn reports a layer that was not told which column to read.
var ErrNoScene = errors.New("three: the plot has no scene")
ErrNoScene is returned by a plot asked to draw before it was given something to draw.
Functions ¶
This section is empty.
Types ¶
type Camera ¶
type Camera struct {
// contains filtered or unexported fields
}
Camera is where a scene is looked at from.
It is an immutable value: Orbit, Dolly and Slerp each take one and return another, so the same inputs always give the same frame and a camera is testable without a surface. That is what lets the host own the drag — this package installs no handler, opens no window and runs no loop — and it is also what lets several [View]s hold several cameras onto one Scene.
It is orthographic, and stays so. Under perspective the same value is taller at the front of the scene than at the back, and a chart is a measuring instrument first. See docs/adr/0057-orbiting-a-chart.md.
The zero Camera is the front view at the default zoom. The angle a chart is designed at is Home, and it is the one a static export and a reader who cannot drag both get, so it has to be readable on its own.
A Camera is comparable, which is what lets a live chart ask cheaply whether the reader moved it.
func Dolly ¶
Dolly multiplies a camera's zoom and returns the result.
Under an orthographic camera the eye's distance from the scene changes nothing at all — not weakly, exactly nothing — so what a reader means by dollying here is a scale factor. That is why there is no Distance: a knob that does nothing is worse than no knob.
func Home ¶
func Home() Camera
Home is the three-quarter view a scene is drawn at when nobody chose.
ADR 0057 makes this a requirement rather than a default: a scene whose initial angle hides its own data behind itself is broken for everyone who cannot drag it, and for every static export — which is most of them. Live.Home is one call back to it.
func LookAt ¶
func LookAt(opts ...CameraOption) Camera
LookAt builds a camera from its angles.
cam := three.LookAt(three.Azimuth(-0.6), three.Elevation(0.35))
func Orbit ¶
Orbit turns a camera by two angle deltas and returns the result.
It is a pure function: same inputs, same camera, no state. The deltas are radians rather than pixels, because pixels per radian is a statement about how an interaction feels and that belongs to the host's input layer — as do inertia, momentum and springs, none of which are here. A host converts once, with its own constant:
const perPixel = 0.008 // radians; the host's choice, not figure's live.Camera(three.Orbit(live.CameraValue(), dx*perPixel, -dy*perPixel))
Elevation is clamped just inside the poles; azimuth wraps.
func Slerp ¶
Slerp interpolates between two cameras and is how a host eases an orbit.
A camera has no rows, no keys and no domain, so interpolating between two of them is interpolating a view — which is exactly the tweening ADR 0044 refused for data, and is trivially correct for a view, because a view has no identity to lose. So easing a camera is a loop over this function and the easings in the root package, and it does not go anywhere near a transition: animating the camera and animating the data are different features that happen to both produce frames.
Azimuth takes the short way round. t at or below zero returns a exactly and t at or above one returns b exactly, which is what a golden test of an eased orbit's last frame depends on.
func (Camera) Forward ¶
Forward is the unit direction the camera looks along, in scene space.
It is what a layer that orders its own primitives reads: a surface's back-to-front traversal is the sign of these three components and nothing else. Depth increases along it, so a point further from the camera has the larger key.
type CameraOption ¶
type CameraOption func(*Camera)
CameraOption configures a Camera.
func Azimuth ¶
func Azimuth(rad float64) CameraOption
Azimuth turns the camera about the up axis, in radians.
func Elevation ¶
func Elevation(rad float64) CameraOption
Elevation raises the camera above the floor plane, in radians. It is clamped just inside the poles: a camera looking straight down its own up-vector has no up-vector, and the scene would spin on its axis at the moment the reader least expects it.
func Zoom ¶
func Zoom(f float64) CameraOption
Zoom sets how much of the view's cell the scene fills. One is the default fit and larger fills more.
type Describer ¶
Describer is implemented by a Layer that can say what it plots.
It is optional for the reason github.com/timzifer/figure/geom.Describer is optional: a layer nobody describes does not have to know what a screen reader is. A layer that implements none is named by its position rather than skipped, because a description that quietly omits a series is worse than one that admits to it.
type Frame ¶
type Frame struct {
// X, Y and Z are the trained scales, ranged into the unit cube.
X, Y, Z scale.Scale
// Theme supplies the defaults the layer did not override, and the light a
// shaded face is lit by.
Theme theme.Theme
// Index is the layer's position in the scene, which is what picks its
// default colour out of the palette.
Index int
// View is which view is being emitted for, and Forward the unit direction
// its camera looks along. A layer that walks its own geometry in depth
// order reads Forward: a surface's traversal is the signs of its three
// components and nothing else.
View int
Forward Vec3
// Rows, when non-nil, collects which source row is behind each mark. It is
// nil for an ordinary render, and a layer reports rows by naming them on
// the sink rather than by calling anything here — see [Sink.Row].
Rows geom.Rows
}
Frame is everything a layer needs in order to turn its data into primitives. It is github.com/timzifer/figure/geom.Frame's counterpart, and the difference between them is the whole of Layer's doc comment: this one carries no backend, because a layer here does not draw.
type Layer ¶
type Layer interface {
// Train feeds the layer's data into the three scales so they can
// establish their domains. It runs before layout, because layout needs
// tick labels and tick labels need a domain.
//
// It runs once per frame however many views are drawn, because a domain is
// a fact about the data and not about where the data is looked at from.
// The struct is geom's because that is the seam ADR 0056 widened for
// exactly this: it gained a Z, and every two-dimensional geom kept
// compiling.
Train(t geom.Training) error
// Emit appends the layer's primitives, in scene space, to s.
//
// It runs once per view, because which order a layer emits in is a fact
// about the camera: a surface walks its lattice from the corner the view
// direction picks.
Emit(s *Sink, f Frame) error
// Legend returns the entry this layer contributes, or ok == false if it
// should not appear in one.
Legend(f Frame) (geom.LegendEntry, bool)
}
Layer is one set of marks in a scene.
It is deliberately not a github.com/timzifer/figure/geom.Geom. A geom's Build streams ink straight into the backend, which makes a layer a paint unit — and a projected scene has no paint unit smaller than the view, since a point can be in front of one part of a surface and behind another. So a layer here emits primitives and this package projects, orders and paints what every layer emitted, once, over all of them at a time.
The separation is a guard as well as a consequence. A geom handed a depth would ignore it and draw a flat line inside a projected box — correct by its own lights, wrong by the chart's, and silent either way. Here that cannot happen: geom.Line is not a Layer and does not compile into a Scene.
Stability ¶
Layer is implemented outside this module, so it never gains a method, and each method takes one parameter beyond its destination. What a layer can additionally do arrives as an optional interface beside it. ADR 0060.
func Bar3 ¶
Bar3 draws one box per cell of two categorical axes.
Read the heatmap first ¶
Bars occlude each other, the back row is unreadable, and the height of a bar behind another cannot be compared to it. The flat chart of the same table — github.com/timzifer/figure/geom.Rect with a colour scale, which is what a heatmap is — wins almost every time. This ships because refusing it invites a worse reimplementation by every caller, and its doc comment says so because a reader who chose wrong is a reader who was not told. See docs/adr/0058-what-3d-is-for.md.
three.Bar3(src, geom.X("service"), geom.Y("region"), geom.Z("latency"))
Both floor axes want a github.com/timzifer/figure/scale.Ordinal: this is a chart of two categoricals, and a continuous axis under it draws boxes at arbitrary widths.
The three faces a box shows are one primitive each and they carry the same source row, so a pointer anywhere on a bar reports that row. They are ordered by the cell the bar stands on rather than by the middle of the box, so a tall bar never draws itself in front of the short one standing between it and the reader.
func Line3 ¶
Line3 draws an ordered path through x, y and z.
It is the trajectory: an orbit, a tool path, an IMU track, an attractor — a curve that crosses itself in every flat projection of it and not in the data. See docs/adr/0058-what-3d-is-for.md.
three.Line3(src, geom.X("t"), geom.Y("offset"), geom.Z("power"))
github.com/timzifer/figure/geom.GroupBy draws one path per series, and that is all a cascade is: a family of traces offset along one floor axis, which is the display a spectrum analyser has had since the seventies. It is a recipe rather than a mark of its own, which is why there is no Cascade here — see examples/cascade.
A path is emitted one segment at a time rather than whole. A whole path is one primitive with one depth, and a curve that spans the scene has no single depth, so a trajectory crossing a surface would be drawn wholly in front of it or wholly behind. Per segment it interleaves at every scale a reader can see. What it still cannot do is pass *through* a surface within one segment: splitting a primitive along an intersection is a BSP tree, which is a renderer, and this package draws the shapes whose order is decidable.
func Surface ¶
Surface draws z = f(x, y) over a regular grid.
It is the chart the third dimension is for. A heatmap of the same grid gives the values and hides which way the ground falls; a surface gives the shape of the response between its samples — a ridge, a saddle, the edge of a plateau. See docs/adr/0058-what-3d-is-for.md.
three.Surface(src, geom.X("x"), geom.Y("y"), geom.Z("gain"))
The grid is required rather than guessed. The rows must be the full product of the distinct x and y values with each cell present exactly once, and anything else is an error out of Train rather than a picture with holes in it: a surface drawn over a scattered sample is a surface over a triangulation nobody asked for.
Shading is one directional light from the theme and nothing else — the face's normal against github.com/timzifer/figure/theme.Theme.LightDir, mixed in linear light. github.com/timzifer/figure/geom.ColorBy paints from the height through a ramp instead, which is the terrain reading, and the two compose: the ramp gives the height and the shade gives the slope. There is no colourbar beside it and none is missing — the depth axis is the key, and it is drawn with ticks and a title.
Decimation is off, and github.com/timzifer/figure/geom.Decimate is accepted and ignored. A reduction defined over pixel columns measures nothing in a projected scene, where one column of screen mixes values from everywhere along the view direction; a grid the caller chose the size of is the caller's to choose smaller.
type Live ¶
type Live struct {
// contains filtered or unexported fields
}
Live is a scene drawn into a surface that can be redrawn and turned.
It mirrors figure.Live's shape and keeps its split: a method that changes the view and does not draw returns nothing, and a method that draws returns an error. So turning a scene is two calls —
live.Camera(three.Orbit(live.CameraValue(), dx*perPixel, -dy*perPixel)) live.Draw()
— and that is what leaves the host owning its own loop. This package installs no handler, opens no window and does not know where the two floats came from.
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; use Plot.Render for those. There is no interactive SVG and there will not be one: emitting a document with a script that re-projects the scene in the viewer means shipping a second renderer, in another language, inside a file.
A Live is not safe for concurrent use.
func (*Live) Camera ¶
Camera points every view at cam.
This is ADR 0057's spelling, and on a figure with several views it turns all of them together — a synchronised orbit of a front / top / side figure is one statement about the chart. Live.SetCamera turns one.
func (*Live) CameraOf ¶
CameraOf reports one view's camera, or the zero camera for an index that is not a view.
func (*Live) CameraValue ¶
CameraValue is the first view's camera, which on a figure with one view is its camera.
func (*Live) Draw ¶
Draw records a frame and paints what changed.
What a redraw costs ¶
A frame whose camera moved is not comparable with the last one: every drawing call in the turned view differs, because every point moved. Diffing two whole recordings to arrive at an answer known before it started is work nobody asked for, so a turn damages the views that turned and takes the diffing path when the cameras are at rest — where it earns its keep exactly as it does in a flat chart, because a changing dataset under a fixed camera is the case damage was designed for. See ADR 0016 and ADR 0057.
Expressing it that way rather than as a "a drag is in flight" flag is deliberate. A flag is state the library cannot verify, it is wrong for a programmatic eased orbit that no pointer is involved in, and a host that forgets to turn it off pays for a full repaint forever with nothing failing. A camera moved or it did not, and this package knows which.
func (*Live) Home ¶
Home returns every view to the camera its author chose.
A reader who has turned a scene into a mess is one call from the picture the chart was designed at, which is the other half of ADR 0057's requirement that the default camera be readable on its own.
func (*Live) Rescale ¶
Rescale tells the figure its surface's device pixel ratio has changed, and redraws it.
func (*Live) Resize ¶
Resize tells the figure its surface has changed size, and redraws it. The cameras keep whatever they were turned to.
func (*Live) TrackRows ¶
TrackRows turns row identity on or off and returns l, so the call can be chained onto Plot.Live.
It is off by default because it is not free: with it on, every layer records where each of its rows landed and the hit index keeps a position and a row number per mark. Without it interact.Hit.Row is -1 — and in a projected scene that is the whole answer a pointer has, because there are no screen axes to invert a device point through.
type Option ¶
type Option func(*Plot)
Option configures a Plot.
func Columns ¶
Columns sets how many views sit side by side before the next row. It is one by default, which is what a figure with a single camera wants.
func Description ¶
Description sets what the chart says about itself in words, for a backend that can carry it.
It is camera-independent, and that is a requirement rather than a convenience: a reader using the description gets the same chart as a reader dragging the scene, and every static export says what the turned one says. See docs/adr/0057-orbiting-a-chart.md.
type Plot ¶
type Plot struct {
// contains filtered or unexported fields
}
Plot is a figure: one or more [View]s of a Scene, at a size, in a theme.
It is not a figure.Plot and the root package re-exports nothing of this one. figure's whole job is resolving a plot into a render.Chart, and a scene is not one — no coordinate system, no two-dimensional panels, no geoms — so a re-export would put names in the root package whose methods did not work on the type that lives there. That is the second path through one seam that ADR 0056 spends a version to avoid, moved to the root package.
What is shared is the vocabulary: a scale is a scale, a theme is a theme, a channel is spelled with geom's options, and a target is an ir.Target — so a scene renders into every backend figure has without the root package being in the import graph at all.
func (*Plot) DataTable ¶
DataTable writes the scene's rows as HTML tables — the third of the three channels a chart says what it is in, and the one a projected scene leans on hardest: a reader who cannot turn the picture still gets every number in it.
func (*Plot) Describe ¶
Describe reads the plot's scene and says what it shows.
**It does not mention the camera, and that is a requirement rather than an omission.** A reader using the description gets the same chart as a reader dragging the scene; the description of a figure with four views is the description of what is plotted, once. A chart that must be turned to be read is a chart some readers cannot read, so the words and the data table have to be complete without it. See docs/adr/0057-orbiting-a-chart.md and docs/adr/0024-accessibility.md.
func (*Plot) Description ¶
func (p *Plot) Description() ir.Description
Description is what a backend that can carry a description is told, before anything is drawn.
func (*Plot) Live ¶
Live opens t and returns the scene drawn into it. The target stays open until Live.Close.
func (*Plot) Observer ¶
Observer is told which view and which layer is drawing, so that a caller wrapping the backend can attribute a mark to the layer that made it. It is how hit-testing is built without widening the IR, and it is the same interface render announces through — so interact.Index needs no change to index a projected scene.
func (*Plot) Render ¶
Render draws the plot once into t.
It wants a document or a surface indifferently: a single frame is the same call either way. A scene redrawn frame after frame into one surface is Plot.Live.
type Scene ¶
type Scene struct {
// contains filtered or unexported fields
}
Scene is what is plotted: the layers, and the three scales they train.
It is the data half of a three-dimensional chart, and it holds no camera. That split is the whole arrangement: a View is a camera on a scene, several views share one scene, and the scales behind it are trained once however many angles are drawn from them. One data repository, several ways of looking at it.
It holds no theme and no chart title either. A theme is how the *figure* looks and two views in one figure must share it; a chart title names the figure. Both belong to Plot. The three axis titles belong here, because an axis names the data and every view of one scene labels the same three.
A *Scene is shareable and is not safe for concurrent modification, which is the contract figure.Plot has.
func (*Scene) SetLayers ¶
SetLayers replaces the layers, which is what a scene that gains a highlight layer on every pointer move needs so that it does not gain one per move.
type Sink ¶
type Sink struct {
// contains filtered or unexported fields
}
Sink collects the primitives of one view of a scene, in scene space.
Vertices go into one flat arena and a primitive is a range in it, which is what makes a frame cost a handful of allocations over a surface of any size: the arena, the primitive list and the sort's key slice all come from a pool and are refilled rather than rebuilt. ADR 0057 pre-commits this package to that discipline and the allocation gate holds it to it.
A Sink is handed to a layer's Layer.Emit and is not retained afterwards.
func (*Sink) Face ¶
Face appends a filled face: three or more corners, in order around it.
The face is drawn as one closed subpath, which is what makes it one mark to a hit index — a quad of a surface is a thing the reader can point at.
func (*Sink) Forward ¶
Forward is the unit direction the camera looks along, which is the same vector Frame.Forward carries. It is here as well so that a helper handed only a sink can pick its traversal.
func (*Sink) Line ¶
Line appends a stroked run through vs.
A layer that draws a path emits one Line per segment rather than one per path. A whole path is one primitive with one depth, and a curve that spans the scene has no single depth — so a trajectory that crossed a surface would be drawn wholly in front of it or wholly behind.
func (*Sink) Row ¶
Row attributes the primitives that follow to a source row, or to -1 for one that no row is behind — an interpolated point, a wall, a summary. It holds until it is changed and is reset to -1 for each layer.
It costs nothing when nobody is listening: a scene rendered without row tracking never reads what a layer reported.
func (*Sink) Text ¶
Text appends a run anchored at a scene point.
Only the anchor is projected. A label lying in a projected plane needs a shear and ir.TextRun has none — deliberately, since a backend shapes its own runs — so the text stays upright, which is also easier to read than the sheared alternative every serious 3D tool ends up offering to turn off.
type Style ¶
type Style struct {
// Fill is the interior colour, and is transparent for a line.
Fill ir.Color
// Stroke is the outline colour, and is transparent for a face drawn
// without one.
Stroke ir.Color
// Width is the outline's width.
Width float32
}
Style is the paint of one primitive.
It carries no dash. A dash is a length on screen, and a projected run's length on screen is not its length in the data, so a dashed line in a scene says less than it appears to; when one is asked for, this gains a field. Being comparable is what lets adjacent primitives batch into one drawing call with ==.
type Vec3 ¶
type Vec3 struct{ X, Y, Z float32 }
Vec3 is a point or a direction in scene space.
Scene space is the unit cube: x and y span the floor, z is up, and every coordinate lies in [0, 1] because that is the interval the three scales are ranged into. A scale maps a value into an interval without caring what the interval means — the property ADR 0018 was built on — and here the interval is an edge of a box rather than an edge of a panel.
The components are float32 because github.com/timzifer/figure/ir.Point is, so the projection converts nothing on the hot path.
func (Vec3) Cross ¶
Cross returns the vector perpendicular to both v and w, which is how a face gets the normal its shade is computed from.
func (Vec3) Dot ¶
Dot returns the scalar product, in float64.
The width is deliberate and it is not symmetry with the rest of the package: this is the arithmetic behind a depth key, a depth key feeds a comparison, and a comparison is a decision. AGENTS.md records what one float32 ulp did to a decision once already.
type View ¶
type View struct {
// Camera is where this view looks from. The zero value is the front view;
// [Home] is the angle a chart is designed at, and the one a static export
// and a reader who cannot drag both get.
Camera Camera
// Label is written in a band above the view, or "" for none. It is what
// names a cell of a front / top / side figure.
Label string
// Scene overrides the plot's scene for this view, and is nil for the views
// that share it — which is the ordinary case and the one this whole
// arrangement is for.
Scene *Scene
}
View is one camera on a scene.
It is a struct with exported fields because it grows: a per-view layer mask, a clip to a sub-box of the cube, a per-view theme override are all fields when they arrive rather than a second View type. ADR 0060 is the rule.
Where a view lands in the figure is Plot's to say rather than the view's, because a row and a column on a struct have no honest zero value — nought, nought is a real cell. Views are placed in the order they were added, across the rows of a grid whose width is Columns.