scale

package
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package scale maps data values onto visual positions and generates the ticks that label them.

A Scale owns two things: the domain→range mapping, and the choice of tick positions and labels for that domain. Keeping both in one place is what lets a time axis label itself in calendar units while a linear axis labels itself with round numbers, without either the geom or the layout knowing which is which.

Index

Constants

This section is empty.

Variables

View Source
var ErrUnknownKind = fmt.Errorf("refract/scale: unknown scale kind")

ErrUnknownKind reports a Desc naming a scale this package does not have.

Functions

func FromNanos

func FromNanos(v float64) time.Time

FromNanos converts a time scale's domain value back to a time.

func InstantOf added in v0.6.0

func InstantOf(s Scale, v float64) time.Time

InstantOf converts a domain value of s back into an instant, falling back to FromNanos for a scale that is not Temporal.

func Nanos

func Nanos(t time.Time) float64

Nanos converts a time to the float64 domain value a time scale uses.

func ValueOf added in v0.6.0

func ValueOf(s Scale, t time.Time) float64

ValueOf converts an instant into s's domain space, falling back to Nanos for a scale that is not Temporal — including a nil one, which is what a geom reading a column for a colour scale has.

Types

type Band added in v0.2.0

type Band interface {
	// Bandwidth returns the width of one slot in device units, after padding.
	Bandwidth() float32
}

Band is implemented by scales that give each category a slot of finite width, which is what a bar or a boxplot needs in order to size itself.

A geom that finds a Band on its axis takes the width from the scale instead of guessing one from the spacing of the data.

type Categorical added in v0.2.0

type Categorical interface {
	// Encode returns the domain value for a category label, registering the
	// label if the scale has not seen it before.
	Encode(label string) float64

	// Labels returns the categories in axis order.
	Labels() []string
}

Categorical is implemented by scales that position named categories rather than numbers, so that a geom can map a string column onto an axis.

The numeric domain of such a scale is the category index: Encode turns a label into the index that Scale.Map positions, which is what lets one Scale interface serve both continuous and categorical axes.

type Cloner added in v0.3.0

type Cloner interface {
	// Clone returns an untrained copy with the same configuration. A fixed
	// domain counts as configuration and is kept.
	Clone() Scale
}

Cloner is implemented by a scale that can hand back a fresh copy of itself: the same configuration, with nothing trained into it yet.

Faceting needs it for free scales. "Each panel gets its own Y axis" means each panel gets its own scale object, configured the way the plot's scale was but trained only on that panel's rows — and the only thing that knows how a scale was configured is the scale.

It is an optional interface. A scale that does not implement it can still be shared across panels, which is the default and by far the common case; only a free axis needs a copy, and a facet says so rather than guessing.

type ColorDesc added in v0.5.0

type ColorDesc struct {
	Kind      ColorKind
	Ramp      string
	Colors    palette.Ramp
	Min, Max  float64
	Fixed     bool
	Center    float64
	Reverse   bool
	Undefined ir.Color
}

ColorDesc is a colour scale reduced to what configures it.

Ramp is a name from palette.RampByName rather than a list of colours: a registered ramp is a word, and a chart that named one should read back as having named it. A ramp nobody registered has no name, so Colors carries it literally instead — an unregistered ramp is still a ramp, and losing it would be worse than spelling it out.

A KindQualitative scale uses the same two fields for its palette, named through palette.QualitativeByName. Min, Max, Fixed and Center have no meaning for one: its domain is the labels it has been shown, and those are the data rather than the scale — the same line Desc draws for a discovered ordinal domain.

func DescribeColor added in v0.5.0

func DescribeColor(s ColorScale) (ColorDesc, bool)

DescribeColor reports s's configuration, or ok == false if s cannot describe itself.

type ColorDescriber added in v0.5.0

type ColorDescriber interface {
	// DescribeColor returns the scale's configuration.
	DescribeColor() ColorDesc
}

ColorDescriber is implemented by a colour scale that can say what it is.

type ColorKind added in v0.5.0

type ColorKind string

ColorKind names a colour scale's type.

const (
	KindSequential ColorKind = "sequential"
	KindDiverging  ColorKind = "diverging"
	// KindQualitative is a discrete scale: one colour per category, from a
	// qualitative palette rather than from a ramp. See [Qualitative].
	KindQualitative ColorKind = "qualitative"
)

The colour scale kinds.

type ColorOption added in v0.2.0

type ColorOption func(*colorScale)

ColorOption configures a colour scale.

func ColorCenter added in v0.2.0

func ColorCenter(v float64) ColorOption

ColorCenter pins the value that lands on the middle of a diverging ramp. The default is 0.

It has no effect on a sequential scale, where there is no middle to pin.

func ColorDomain added in v0.2.0

func ColorDomain(min, max float64) ColorOption

ColorDomain pins the domain explicitly, disabling training.

func ColorReverse added in v0.2.0

func ColorReverse() ColorOption

ColorReverse runs the ramp the other way.

func ColorUndefined added in v0.2.0

func ColorUndefined(col ir.Color) ColorOption

ColorUndefined sets the colour for a value the scale cannot place — NaN, an infinity, or a category outside a fixed set. The default is fully transparent, which draws nothing.

type ColorScale added in v0.2.0

type ColorScale interface {
	// Train extends the scale's domain to include vs, ignoring NaN and
	// infinities. Calling Train repeatedly accumulates.
	Train(vs ...float64)

	// Domain reports the current data domain.
	Domain() (min, max float64)

	// Color returns the colour for a value. Values outside the domain clamp to
	// its ends; NaN and infinities return the undefined colour.
	Color(v float64) ir.Color
}

ColorScale maps data values onto colours, the way a Scale maps them onto positions.

It is a separate interface rather than a Scale because the two answer different questions and are trained from different columns: a chart commonly has two positional scales and one colour scale over a third column.

func ColorFromDesc added in v0.5.0

func ColorFromDesc(d ColorDesc) (ColorScale, error)

ColorFromDesc builds the colour scale d describes.

func Diverging added in v0.2.0

func Diverging(ramp palette.Ramp, opts ...ColorOption) ColorScale

Diverging returns a colour scale that puts the middle of a ramp on a centre value and stretches both halves to the further end of the domain, so that equal deviations in either direction get equally strong colours.

It is the scale for a quantity read against a reference: a residual, a change, an anomaly. Use ColorCenter to move the centre off zero.

A nil ramp uses palette.BlueOrange.

func Sequential added in v0.2.0

func Sequential(ramp palette.Ramp, opts ...ColorOption) ColorScale

Sequential returns a colour scale that runs a ramp across the domain from end to end. It is the scale for a quantity with a natural low and high — a count, a duration, a temperature.

A nil ramp uses palette.DefaultRamp.

type Definite added in v0.2.0

type Definite interface {
	// Defined reports whether v has a position on this scale.
	Defined(v float64) bool
}

Definite is implemented by scales whose domain excludes some finite values. A log scale cannot place zero or a negative number anywhere on an axis, and Scale.Map returns NaN for one.

Geoms consult it so that such a value is treated as missing — subject to the layer's own missing-data policy — rather than being handed to a backend as a NaN coordinate. A scale that does not implement Definite accepts every finite value.

type Desc added in v0.5.0

type Desc struct {
	// Kind is which scale this is.
	Kind Kind

	// Min and Max are the domain. They are meaningful only when Fixed is set;
	// a trained domain belongs to the data, not to the scale.
	Min, Max float64
	// Fixed reports a domain pinned at construction by [Domain], [LogDomain]
	// or [SymLogDomain], or afterwards by [Zoomer.SetDomain].
	Fixed bool

	// Nice and Zero are the linear and log framing options.
	Nice, Zero bool
	// Base is the log or symlog base, and Threshold the symlog linear region.
	Base, Threshold float64
	// MinorTicks reports the unlabelled subdivisions of a log or symlog axis.
	MinorTicks bool

	// Origin is a time scale's epoch, in Unix nanoseconds: the instant its
	// domain values are measured from. It is zero for every other kind, and
	// for a time scale left on the Unix epoch. It is not a formatting choice
	// like Location is — it decides what the numbers in Min and Max *mean* —
	// so a document that dropped it would read back a different axis. See
	// [Origin].
	Origin int64

	// Categories is an ordinal scale's fixed category set, empty when it
	// discovers its categories from the data. Padding is the fraction of each
	// slot left blank.
	Categories []string
	Padding    float64

	// Location is a time scale's zone, by IANA name.
	Location string

	// Formatted reports a scale carrying a formatter that a Desc cannot hold.
	Formatted bool
}

Desc is a scale reduced to what configures it.

It is the same bargain github.com/timzifer/refract/geom.Desc makes: a Scale is an interface over an unexported type, which is right for mapping values and useless for writing one down, so every scale here answers Describer and FromDesc builds one back.

What does not survive

A tick formatter is a Go function. Format, LogFormat, SymLogFormat and TimeFormat therefore have no place in a Desc, and a scale carrying one says so through Formatted — a chart that is written down and read back labels its ticks the standard way. Nothing else about the scale is lost.

func Describe added in v0.5.0

func Describe(s Scale) (Desc, bool)

Describe reports s's configuration, or ok == false if s cannot describe itself.

type Describer added in v0.5.0

type Describer interface {
	// Describe returns the scale's configuration.
	Describe() Desc
}

Describer is implemented by a scale that can say what it is. It is optional: a third-party scale that does not implement it still draws, and is simply not serializable.

type DiscreteColorScale added in v0.7.0

type DiscreteColorScale interface {
	ColorScale

	// Encode returns the index of a label, registering it if the scale has not
	// seen it before. Registration is in order of first sight.
	Encode(label string) float64

	// ColorOf returns the colour of a label, registering it the same way.
	ColorOf(label string) ir.Color

	// Labels returns the categories in the order they were registered, which
	// is the order a legend lists them in.
	Labels() []string
}

DiscreteColorScale paints named categories rather than numbers: one colour per distinct label, taken from a qualitative palette.

It rides the ColorScale interface the way Categorical rides Scale, and for the same reason. A layer binds a column to a colour scale through one option — github.com/timzifer/refract/geom.ColorBy — and a geom that paints per mark already reads its colours through ColorScale.Color; a category is simply a value that had to be encoded before it was a number. Which *guide* the layer contributes then follows from which kind of scale it was handed: a ramp gets a colourbar, a qualitative scale gets one legend entry per label.

The numeric domain is the category index, so `Color(2)` is the third label's colour and a scale trained on the encoded column has the domain [0, n-1].

func Discrete added in v0.7.0

func Discrete(s ColorScale) (DiscreteColorScale, bool)

Discrete reports whether a colour scale paints categories, which is what decides whether a layer using it contributes legend entries or a colourbar.

func Qualitative added in v0.7.0

func Qualitative(p palette.Qualitative, opts ...ColorOption) DiscreteColorScale

Qualitative returns a colour scale that gives each distinct label a colour from p, in order of first appearance in the data.

First appearance rather than sorted order is the same convention faceting and the ordinal axis already use, and ADR 0012 is why it is not negotiable: a parallel render must be byte-identical to a serial one, and an order that depends on map iteration is an order that depends on scheduling.

A domain larger than the palette wraps rather than fails — palette.Qualitative.At does — which is the behaviour a layer index already gets. A chart with more series than the palette has colours needs a second encoding channel, not a longer palette; see github.com/timzifer/refract/theme.Redundant.

A nil palette uses palette.Default. Of the colour options only ColorUndefined and ColorReverse mean anything here: there is no domain to pin and no middle to centre, so ColorDomain and ColorCenter are accepted and ignored, exactly as an option a geom has no use for is.

type Kind added in v0.5.0

type Kind string

Kind names a scale's type. It is the word a written-down chart carries in place of the constructor that built the scale.

const (
	KindLinear  Kind = "linear"
	KindLog     Kind = "log"
	KindSymLog  Kind = "symlog"
	KindTime    Kind = "time"
	KindOrdinal Kind = "ordinal"
)

The scale kinds.

type LinearOption

type LinearOption func(*linear)

LinearOption configures a linear scale.

func Domain

func Domain(min, max float64) LinearOption

Domain pins the data domain explicitly, disabling training.

func Format

func Format(fn func(v float64) string) LinearOption

Format overrides tick label formatting.

func Nice

func Nice() LinearOption

Nice expands the domain outwards to the tick sequence's own bounds, so the axis starts and ends on a labelled tick rather than on the extreme data value. This is what most charts want and what CONCEPT.md §13 shows.

func Zero

func Zero() LinearOption

Zero forces the domain to include zero. Bar charts need this: a bar chart whose baseline is off-screen misleads.

type LogOption added in v0.2.0

type LogOption func(*logScale)

LogOption configures a log scale.

func LogBase added in v0.2.0

func LogBase(b float64) LogOption

LogBase sets the base. The default is 10. Bases at or below 1 are ignored, because a logarithm to such a base is not a scale.

func LogDomain added in v0.2.0

func LogDomain(min, max float64) LogOption

LogDomain pins the data domain explicitly, disabling training. Both bounds must be positive; a non-positive bound is ignored, since a log scale has no position for it.

func LogFormat added in v0.2.0

func LogFormat(fn func(v float64) string) LogOption

LogFormat overrides tick label formatting.

func LogMinorTicks added in v0.2.0

func LogMinorTicks(show bool) LogOption

LogMinorTicks turns the unlabelled subdivisions inside each decade on or off. They are on by default: without them a reader has no way to judge where 3 sits between 1 and 10, which is the one thing a log axis is bad at.

func LogNice added in v0.2.0

func LogNice() LogOption

LogNice expands the domain outwards to whole powers of the base, so the axis starts and ends on a labelled decade.

type OrdinalOption added in v0.2.0

type OrdinalOption func(*ordinal)

OrdinalOption configures an ordinal scale.

func Categories added in v0.2.0

func Categories(labels ...string) OrdinalOption

Categories fixes the category set and its axis order.

Without it the scale discovers categories as it encodes them, in the order the data presents them — which is right for data that is already in the order you want to read it, and wrong for anything else. A chart whose bars reorder themselves because yesterday's export sorted differently is a chart nobody trusts.

A label outside a fixed set has no position: it encodes to NaN and the geom treats the row as missing under its own policy.

func OrdinalPadding added in v0.2.0

func OrdinalPadding(f float64) OrdinalOption

OrdinalPadding sets the fraction of each slot left blank, in [0, 1). The default is 0.2, which separates adjacent bars without making them look unrelated.

type Scale

type Scale interface {
	// Train extends the scale's data domain to include vs. Values that are
	// NaN or infinite are ignored. Calling Train repeatedly accumulates.
	Train(vs ...float64)

	// SetRange sets the device-space output interval. lo may be greater than
	// hi, which is how a Y axis is flipped so that larger values are higher on
	// screen.
	SetRange(lo, hi float32)

	// Domain reports the current data domain, after any nicing.
	Domain() (min, max float64)

	// Map converts a data value to a device position. Values outside the
	// domain map outside the range; clipping is the caller's business.
	Map(v float64) float32

	// Invert converts a device position back to a data value. It is the
	// inverse of Map over the whole real line, not just the range.
	Invert(pos float32) float64

	// Ticks returns tick positions and labels, aiming for about want ticks.
	// The result is ordered ascending by Value.
	Ticks(want int) []Tick
}

Scale maps data values onto a device-space range.

A scale is trained on data, given a device range, and then queried. The order matters: Map and Ticks are only meaningful once both the domain and the range are set.

func FromDesc added in v0.5.0

func FromDesc(d Desc) (Scale, error)

FromDesc builds the scale d describes.

func Linear

func Linear(opts ...LinearOption) Scale

Linear returns a linear scale.

func Log added in v0.2.0

func Log(opts ...LogOption) Scale

Log returns a logarithmic scale.

The domain is strictly positive. Training ignores zero and negative values the same way it ignores NaN, and Scale.Map returns NaN for them, which geoms treat as missing data under the layer's own policy — a log chart that silently clamped a negative reading to the axis minimum would be inventing a measurement. Use SymLog for data that genuinely crosses zero.

func Ordinal added in v0.2.0

func Ordinal(opts ...OrdinalOption) Scale

Ordinal returns a categorical band scale: each category gets an equal slot, and a value sits at the centre of its slot.

It satisfies the same Scale interface as every continuous scale by carrying the category *index* as its numeric domain — see Categorical. A geom mapping a string column onto an ordinal axis encodes the labels through the scale; a geom mapping a numeric or time column onto one treats each distinct formatted value as a category, so a bar chart over the values 10, 20 and 30 gets three equally spaced bars rather than a numeric axis with gaps.

It also satisfies Band, so bars and boxplots take their width from the scale rather than inferring it from the spacing of the data.

func SymLog added in v0.2.0

func SymLog(opts ...SymLogOption) Scale

SymLog returns a symmetric log scale: linear within a threshold of zero, logarithmic outside it, and defined for every finite value including negative ones.

This is the scale for data that spans orders of magnitude *and* crosses zero — a signed residual, a profit and loss, a delta that is sometimes tiny. A plain Log cannot show such data at all and a Linear one buries everything small.

The transform is sign(v)·log_base(1 + |v|/threshold), which is smooth at the origin rather than merely continuous: there is no visible kink where the linear region hands over to the logarithmic one.

func Time

func Time(opts ...TimeOption) Scale

Time returns a time scale.

Its domain is carried as float64 Unix nanoseconds, so it satisfies the same Scale interface as every other scale and geoms need no special case. Use Nanos and FromNanos to convert.

type Snapshotter added in v0.4.0

type Snapshotter interface {
	// Snapshot returns an independent copy that maps, inverts and ticks
	// identically to the receiver.
	Snapshot() Scale
}

Snapshotter is implemented by a scale that can hand back an independent copy of itself exactly as it stands — configuration, trained domain, device range and all.

It is what makes parallel panels possible. Panels that share an axis share one scale object, and drawing sets that object's device range; two panels drawing at once would be writing the same field. A snapshot per goroutine removes the sharing without changing what any panel draws, because a snapshot maps every value exactly as its original does.

It differs from Cloner, which returns an *untrained* copy for a free facet axis. The two exist for opposite reasons: Clone is for a panel that must not inherit the plot's domain, Snapshot for one that must inherit it exactly.

It is an optional interface. A scale that does not implement it is drawn one panel at a time, which is always correct and is what a single-panel chart does anyway.

type SymLogOption added in v0.2.0

type SymLogOption func(*symlogScale)

SymLogOption configures a symmetric log scale.

func SymLogBase added in v0.2.0

func SymLogBase(b float64) SymLogOption

SymLogBase sets the base. The default is 10.

func SymLogDomain added in v0.2.0

func SymLogDomain(min, max float64) SymLogOption

SymLogDomain pins the data domain explicitly, disabling training.

func SymLogFormat added in v0.2.0

func SymLogFormat(fn func(v float64) string) SymLogOption

SymLogFormat overrides tick label formatting.

func SymLogMinorTicks added in v0.2.0

func SymLogMinorTicks(show bool) SymLogOption

SymLogMinorTicks turns the unlabelled subdivisions inside each decade on or off. They are on by default.

func SymLogThreshold added in v0.2.0

func SymLogThreshold(t float64) SymLogOption

SymLogThreshold sets the half-width of the linear region around zero. The default is 1.

Choose it to match the smallest magnitude that carries meaning: below the threshold the scale is linear, above it logarithmic, and the join is smooth.

type Temporal added in v0.6.0

type Temporal interface {
	// Value converts an instant into the scale's domain space.
	Value(t time.Time) float64

	// Instant converts a domain value back into an instant. It is the inverse
	// of Value.
	Instant(v float64) time.Time
}

Temporal is implemented by scales whose domain is time.

It is the seam between an exact timestamp and the float64 a Scale maps: a scale that measures from an Origin converts across it in int64 and loses nothing, and one that does not is exactly Nanos and FromNanos. Anything turning a timestamp into a domain value — a geom reading a time column, a document writing an axis down — goes through it rather than assuming the Unix epoch, which is what ValueOf and InstantOf are for.

It is an optional interface, like every other in this package: a scale that does not implement it is not a time axis.

type Tick

type Tick struct {
	// Value is the tick's position in data space.
	Value float64
	// Pos is the tick's position in device space, already mapped.
	Pos float32
	// Label is the formatted text for the tick. An empty label means a tick
	// mark and grid line are drawn but nothing is written.
	Label string
	// Minor marks a tick that subdivides the axis without a label.
	Minor bool
}

Tick is one labelled position on an axis.

type TimeOption

type TimeOption func(*timeScale)

TimeOption configures a time scale.

func In

func In(loc *time.Location) TimeOption

In sets the location used to compute calendar ticks and format labels. The default is time.UTC, so that a chart renders identically wherever it is built — a server rendering in the client's local zone is a bug, not a feature.

func Origin added in v0.6.0

func Origin(t time.Time) TimeOption

Origin sets the instant the scale measures its domain from, so that a domain value is nanoseconds since t rather than nanoseconds since 1970.

It is what makes a deep zoom exact. A float64 holds 53 bits of mantissa, and a Unix nanosecond count in this century needs 61 — so two instants a nanosecond apart become the *same* float64, and an axis zoomed to a microsecond window has nothing left to separate them with. Measured from an origin near the data, the same two instants are 1.0 apart, and stay whole numbers of nanoseconds for the hundred days either side of it that a float64 counts exactly.

The origin is part of the scale's domain space: every value handed to Scale.Map or Scale.Train on this scale, and every value it returns from Scale.Invert or Scale.Domain, is measured from it. Use Temporal — or the package helpers ValueOf and InstantOf — to convert, rather than Nanos and FromNanos, which are the origin-free pair and stay so. A geom reading a time column does this for you.

The default origin is the Unix epoch, which is exactly Nanos.

func TimeFormat

func TimeFormat(fn func(t time.Time, unit time.Duration) string) TimeOption

TimeFormat overrides tick label formatting. The unit the tick sequence settled on is passed so a caller can vary detail with zoom level.

type Zoomer added in v0.5.0

type Zoomer interface {
	// SetDomain pins the data domain to [min, max]. A scale that cannot place
	// part of that interval — a log scale given a negative bound — clamps to
	// what it can place rather than refusing, because the caller is a pointer
	// drag rather than a programmer.
	SetDomain(min, max float64)

	// Autoscale releases a pinned domain and forgets what was trained into it,
	// so the next render establishes the domain from the data again.
	//
	// It releases a domain fixed at construction too. A scale built with a
	// fixed domain and then autoscaled is a scale that was asked, twice, to do
	// two different things; the later call wins.
	Autoscale()
}

Zoomer is implemented by a scale whose domain can be set outright, which is what pan and zoom do.

It is an optional interface, and the third of its family: Cloner hands back an untrained copy for a free facet axis, Snapshotter an exact copy for another goroutine, and Zoomer changes the domain of the scale in place. A scale that does not implement it is simply not pannable — an ordinal axis is the honest example, because half a category is not a view of anything.

Pinning

SetDomain pins the domain the way Domain does at construction: training stops moving it, and a linear scale stops nicing it. Both are what an interactive view needs — a chart whose axis snapped to round numbers after every wheel notch would not follow the pointer, and one that retrained on the next frame would undo the zoom the reader just asked for. [Autoscale] releases the pin.

Jump to

Keyboard shortcuts

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