Scales, colour and guides
A scale maps data values to the interval [0, 1], and a role maps that
interval to what it draws: a position across the panel, a colour, an area.
This page shows how channels find their scales, how to configure them, how
colours are chosen, and how axes and legends follow from the scales.
open Hugin let steps = Nx.linspace Nx.float64 1. 1000. 200 let loss = Nx.add_s (Nx.div (Nx.ones_like steps) (Nx.sqrt steps)) 0.02
Finding a scale
Every channel reads a scale by name. Without a name, it reads the scale of
its role: a channel on ~y reads "y", one on ~fill or ~stroke reads
"color". A scale specification given to a channel configures that scale,
so this line is drawn on a logarithmic y:
let () = line ~x:(num steps) ~y:(num ~scale:(Scale.log ()) ~title:"loss" loss) () |> save "log.png"
The marks of a panel read one x and one y scale, and the scale's domain is
fitted to the values of every channel that reads it. A layer of a line and
a dot mark therefore shares its axes.
Scale constructors
Hugin.Scale makes specifications of each kind:
| Constructor | Normalises |
|---|---|
Scale.linear () |
quantities, in proportion |
Scale.log () |
positive quantities, by their logarithm |
Scale.symlog () |
quantities of either sign, logarithmic away from zero |
Scale.pow ~exponent () |
quantities, by a power |
Scale.time () |
instants, in proportion |
Scale.band () |
categories, one band each |
Each constructor's optional arguments set properties, and an unset property keeps its default:
~domainfixes the domain. The fitted domain is the extent of the data otherwise.~nice:falsekeeps the fitted domain at the data's extent instead of rounding it outward to round numbers.~zero:truewidens the domain to include zero.~reverse:trueruns the scale from 1 to 0.~clamp:trueclamps values outside the domain to its ends.~padding(band scales) is the fraction of each band left empty.~ticksfixes the values of the scale's axis ticks, legend entries and colour bar. On a band scale it limits the labels of an axis and the entries of a legend to the categories named; facet headers name every panel whatever the ticks.~notationwrites tick labels inNumber.Plain,Exponent,SiorPercentnotation.~stepped:true(quantitative scales) gives roles a range that is constant between consecutive ticks, so a colour scale paints bands.~dashesand~symbols(band scales) set the patterns and symbols its categories are drawn with.
let () = let acc = Nx.create Nx.float64 [| 5 |] [| 0.42; 0.61; 0.7; 0.74; 0.76 |] in line ~y:(num ~scale:(Scale.linear ~domain:(0., 1.) ()) ~title:"accuracy" acc) () |> save "domain.png"
Ticks are chosen to be round numbers whose labels do not overlap. ~ticks
replaces that choice, and ~notation the way labels are written:
let () = let lr = Nx.create Nx.float64 [| 5 |] [| 1e-4; 3e-4; 1e-3; 3e-3; 1e-2 |] in let acc = Nx.create Nx.float64 [| 5 |] [| 0.62; 0.71; 0.78; 0.74; 0.55 |] in line ~x:(num ~scale:(Scale.log ~ticks:[| 1e-4; 1e-3; 1e-2 |] ~notation:Number.Exponent ()) lr) ~y:(num ~scale:(Scale.linear ~notation:Number.Percent ()) acc) () |> save "ticks.png"
The labels of the x axis read 10⁻⁴, 10⁻³ and 10⁻², and those of the y axis are percentages. A tick label names a value; a label that is not the value, such as the name of a category, belongs to a band scale.
Instants come from time, which reads integer counts of a resolution since
the Unix epoch, as a datetime column stores them. Their scale is temporal:
its ticks fall on calendar boundaries, from nanoseconds to centuries, and
each label names its tick in the calendar, with a second line where the
coarser part changes, such as the month under the first day of each month.
~tz_offset_s puts the calendar at an offset from UTC:
let () = let start = 1_767_225_600L (* 2026-01-01 UTC *) in let days = Nx.add_s (Nx.mul_s (Nx.arange Nx.int64 0 90 1) 86_400L) start in let level = Nx.sin (Nx.linspace Nx.float64 0. 10. 90) in line ~x:(time Time.S days) ~y:(num ~title:"level" level) () |> save "time.png"
Marks imply properties too: a bar implies ~zero:true on its length's scale
and padding on its band scale. A property set on a channel overrides what a
mark implies, and two channels setting one property to different values
raise Invalid_argument when the figure is resolved.
Sharing a scale by name
A name of your own denotes one scale wherever it appears in the figure. Two panels that colour by the same quantity read the same scale, and the figure has one colour bar:
let () = let rates = Nx.create Nx.float64 [| 5; 1 |] [| 0.1; 0.2; 0.3; 0.4; 0.5 |] in let curves = Nx.exp (Nx.neg (Nx.mul rates (Nx.reshape [| 1; 200 |] (Nx.div_s steps 100.)))) in let final = Nx.slice [ Nx.A; Nx.I 199 ] curves in let lr = Scale.linear ~name:"lr" () in grid [ [ line ~x:(num steps) ~y:(num curves) ~stroke:(num ~scale:lr ~title:"rate" rates) (); dot ~x:(num ~scale:lr (Nx.flatten rates)) ~y:(num final) ~fill:(num ~scale:lr (Nx.flatten rates)) (); ]; ] |> save "shared-scale.png"
The second panel reads "lr" on its x axis as well, so its axis and the
colour bar show the same domain.
Colours
Color.t is an sRGB colour with an opacity:
let teal = Color.v 0.1 0.5 0.55 let faded = Color.with_alpha 0.4 teal let blend = Color.mix 0.5 teal Color.white let brand = Result.get_ok (Color.of_hex "#e4572e")
Color.mix interpolates in Oklab, a space where equal steps look equally
different, and Color.contrast c is black or white, whichever reads better
on c.
A colour scale paints with a scheme. A continuous scale reads the scheme's
continuous gradient, and a band scale gives each category the next colour of
the scheme's palette. Hugin.Scheme holds sequential schemes (viridis,
magma, blues, ...), diverging ones (rdbu, brbg, ...) and qualitative
palettes (tableau10, okabe_ito, set2, ...):
let () = let z = Nx.Rng.normal ~key:(Nx.Rng.key 1) Nx.float64 [| 10; 10 |] in rect ~x:(dim 1) ~y:(dim 0) ~fill:(num ~scale:(Scale.linear ~domain:(-3., 3.) ~scheme:Scheme.rdbu ()) z) () |> save "diverging.png"
Without a scheme, a continuous scale takes the theme's scheme, viridis by default, and a band scale the theme's palette, tableau10 by default.
map_range applies a function to a channel's values after its scale and
role have mapped them. Text drawn over coloured cells reads on each cell
with the contrasting colour:
let () = let counts = Nx.create Nx.int32 [| 2; 2 |] [| 48l; 2l; 5l; 45l |] in let share = num (Nx.div_s (Nx.cast Nx.float64 counts) 50.) in layer [ rect ~x:(dim 1) ~y:(dim 0) ~fill:share (); text ~x:(dim 1) ~y:(dim 0) ~text:(num counts) ~fill:(map_range Color.contrast share) (); ] |> save "contrast.png"
Axes and legends
Each position scale gets an axis on its panel, and each other scale a
legend: a colour bar for a continuous colour scale, and one entry per
category or tick otherwise. A guide is titled by the ~titles of the
channels that read its scale.
axis and legend replace a generated guide. Layered with the figure, they
draw nothing themselves and configure the guide of the named scale:
let () = let codes = Nx.create Nx.int32 [| 6 |] [| 0l; 0l; 1l; 1l; 2l; 2l |] in let values = Nx.create Nx.float64 [| 6 |] [| 1.; 2.; 2.5; 1.5; 3.; 2.8 |] in layer [ dot ~x:(index 0) ~y:(num values) ~fill:(cat ~labels:[| "a"; "b"; "c" |] codes) (); axis ~grid:true "y"; axis ~show:false "x"; legend ~side:`Bottom ~title:"group" "color"; ] |> save "guides.png"
~grid:true draws lines across the panel at the axis's ticks, ~show:false
hides a guide, ~side moves it, and ~title titles it in place of its
channels' titles. axis' takes the title as a Text.t, for styles,
colours, sub- and superscripts.
A legend can also lie inside its panels, in a corner with
~side:(`Inside `Top_right), in the middle of an edge with
`Inside `Top, or in the centre with `Inside `Center, and ~ncol
sets its entries in columns. It is drawn under the marks and no place is
chosen for being empty, so pick one the data leaves empty:
let () = let t = Nx.linspace Nx.float64 0. 1. 50 in let curves = Nx.exp (Nx.neg (Nx.mul (Nx.create Nx.float64 [| 2; 1 |] [| 2.; 5. |]) t)) in layer [ line ~x:(num t) ~y:(num curves) ~stroke:(dim ~labels:[| "slow"; "fast" |] 0) (); legend ~side:(`Inside `Top_right) "color"; ] |> save "inside-legend.png"
this page is tested with mdxedit this page ↗