Facets
Split a plot into multiple panes with a shared configuration but different slices of the data, carve it by hand or derive it from columns.
Facets are novems way of separating a plot into multiple drawing surfaces keyd by input data structure or content.
The facet uses the standard novem slicing notation to select a section of input data to work on along with some facet related grammar on how to paint it.
Facets live in one file directly under your plot's config:
facets_price_volume => plot id
├── config => configuration options
│ ├── facets => the fact confgi
│ └── type => line/bar etc
├── data => data to chart
...
Facets live in one file directly under your plot's config:
The file speaks two forms, told apart by the head of each line:
- Carve lines place panels by hand — a slicer claims cells, the geometry keys dock and size the panel. A volume strip under a price chart.
- Filter lines start with a bare
xoryand derive the panels from the data — one panel per distinct value of a column, or one per series. Penguins by sex and species, a grid of sector small multiples.
The governing principle holds for both: one config, many panels — every panel inherits the whole global configuration (type, axis, colors, the one legend), and the only per-panel inputs are its slice of the data and the line that made it. The first line's form claims the file; the two forms don't mix.
[row-slicer] [col-slicer] [key:value ...]
Slicers are the shared novem vocabulary you know from colors and type — indices, inclusive ranges, lists, series headers. One slicer addresses columns (series); two are rows × columns. A line with no slicer claims the next unclaimed series.
The geometry keys, all optional:
| Key | Meaning |
|---|---|
w: | panel width — px, or a percent of the surface with % (w:33%) |
h: | panel height — px, or a percent of the surface with % |
wi: wx: hi: hx: | min/max clamps on the panel's resolved size — same value forms, min beats max; h:25% hi:160px keeps a strip readable on short surfaces |
e: | docking edge — l, r, t or b (default b, below) |
x: | scale group for the x axis — panels naming the same token sync |
y: | scale group for the y axis |
l: | the panel's title — text (quote for spaces), 0 hides |
l* | the strip label letters — le: li: lm: (plus the per-side lml:–lma:) lo: ltx: lc: — style the title, per panel |
Unstated sizes take the even share of what remains, split with the
remainder panel — so a bare carve with no geometry is an even split.
-- starts a comment; invalid tokens are dropped with a diagnostic
while the line survives.
A carve panel that holds a single series titles itself with that
series' header; anything else has no default. l: overrides the
text (a quoted value is always literal, so l:"0" titles the panel
"0") and l:0 turns the default off. The l* letters are the
labels grammar's
strip vocabulary — the same table as the filter grid's
strip labels, but applied to this line's panel
rather than grid-wide, since a carve line is exactly one panel:
: -1 w:100% h:25% e:b l:"Net flow" le:tl li:in lm:4
: 2 e:r l:0 -- no title on this one
The title tucks against the panel's own draw frame with the same
machinery as filter strips: corners (le:tl), inside or outside
(li:), the gap pair (lm:), overflow fits (lo:), rotation
(lr:) and text restyling (ltx:/lc:). The remainder panel is
not addressable by any line, so it never carries a title.
Four examples, one per shape of input. Price and volume is a hand carve: two columns, one of them broken out into a strip. Anscombe's quartet is a tidy csv, where a category column keys the panels. Unemployment by industry is a matrix, where the columns themselves are the panels; it runs the same facet line as the quartet. Penguins has two category columns and spends one on each direction, so the grid is a cartesian product rather than a wrap.
One traditional use of faceting is to show a bar chart of trading volume together with a line chart of price development.
In the example below we carve out the last series of the
data set (column -1) and place it on the edge below the primary
chart area. Here we leverage the default preservation and only
have to define one line in our facet config.
We carve based on the dataset so the bar styling is inherited
from the /config/type allocation.
-- volume as a 25% strip on the edge below the price line,
-- sharing the date axis and a height-minimum of 130px
-- no label for the facet
: -1 w:100% h:25% hi:130px e:b x:x l:noneFour datasets with near-identical summary statistics and wildly
different shapes — the canonical argument for plotting your data
rather than describing it. The set column is a category, so one
filter line derives the four panels from it and n:2 wraps them into
a 2×2 grid. Nothing else in the file does any work.
-- the set column is a category, so it keys the panels; n:2 wraps to a grid
-- try ax:xy to put every panel on ONE pair of scales — the shapes then
-- compare directly, which is the whole point of the quartet
x : : n:2Small multiples straight out of a matrix. The csv is a date column
and one column per industry — fourteen series, 2000 through early
2010, no key column and no reshaping. x : : is the same line the
quartet runs, and here it reads the other half of the dispatch: the
selected columns are numeric, so each one becomes a panel. n:4
sets the grid's width and ax:x shares the date axis while leaving
every panel its own y, which is what lets a small industry keep a
readable shape next to a large one.
Each column is a real series, so it keeps its own color and its
header becomes the strip label. Colour carries nothing the label
does not already say, so a /config/colors of : : blue-300 puts
the grid back on one measure.
Then try ax:xy. Every panel lands on one pair of scales and the
comparison turns absolute: Construction and Wholesale swamp
Agriculture and Mining, which is the honest picture of the same data
and a different question.
-- the csv is a matrix, so every column is a panel; four to a row
-- ax:x shares the date axis, each panel keeps its own y
-- ax:xy shares both — the panels become comparable in absolute terms
x : : n:4 ax:x le:tl li:in ltx:n,xxs lo:e lm:4,4The two filter examples above each fan in one direction and wrap.
The palmerpenguins dataset carries two category columns, sex and
species, so it can spend one on each direction instead: an x line
claims the first, a y line claims the second, and the grid is their
cartesian product. Each panel wears the joined key, male · Adelie.
Neither line names a scale, so both are shared. Every panel measures culmen the same way and the clouds compare directly by position: Gentoo bills are long and shallow, Chinstrap long and deep, and in every row the male cloud sits up and to the right of the female one.
NA · Chinstrap is the case worth watching. Nine birds have no
recorded sex and none of them are Chinstrap, so that combination has
no observations. The cell stays, keeps its label and draws nothing:
a sparse dataset is normal data, and the grid holding its shape is
what makes the gap legible.
Swap the two lines to transpose the grid, or delete either one to fall back to a single row or column. Nine panels repeating a joined key is a lot of ink for six distinct values, so a grid this shape is usually the moment to reach for label lanes.
-- data-driven grid: sex across, species down, labels outside
x : sex ll:t
y : species ll:l lr:-90: -1 w:100% h:25% e:b
Two series broken out to the right (declaration order walks outward, so the first line lands in the middle):
: 2 w:33% e:r
: 3 w:33% e:r
An even split below, no geometry at all:
: -1
A row carve — the last three rows of every series beside the rest, two plots splitting the index side by side:
-3: : w:50% h:100% e:r
Carving a column takes that series out of the main panel; carving rows across all series takes those rows out instead, so the main panel keeps every series over the remaining index. Row splits always read in index order: panels arrange by their first row (top to bottom, or left to right for side docks), with any leftover rows slotting into their natural position between the carves.
A line that begins with a bare x or y derives panels from the
data itself and fans them along that direction — x across, y
down. Both together form a grid.
x [row-slicer] [col-slicer] [key:value ...]
y [row-slicer] [col-slicer] [key:value ...]
The selection is the same slicer pair as everywhere else. What the selected columns contribute follows their type:
- A text or date column contributes its distinct values — one panel per value — and leaves the plotted series; its job is now grouping.
- A numeric column contributes itself: one panel per series, small multiples straight from a wide csv.
- When both appear, values win and the numeric columns simply stay plotted inside every panel.
So x : : is the universal line: on a wide csv it fans every series
into its own panel; on a tidy csv with a category column, the
category facets and the numeric series stays plotted.
x : : -- every series its own panel, fanned across
x : : n:4 -- same, wrapped four per row
x : region -- one panel per region value
x : sex
y : species -- the sex × species grid
x -90: region -- last 90 rows only, split by region
x by year -- numeric year, but it means categories
x by 0 n:4 -- one panel per X VALUE, wrapped four per row
by is the explicit spelling — the same value-key keyword the
colors and
legend grammars use.
It replaces the type dispatch outright: the named column's distinct
values fan the direction whatever its type, and column 0 is a
first-class target — x by 0 (or the x column's header name) gives
one panel per category or date. Nothing is consumed from the plotted
matrix, so the series keep their colors and the legend: on a wide
states × age-brackets csv, x by 0 n:4 is one panel per state with
the brackets as colored series inside. A comma list products
(x by 0,region); an optional leading slicer is the row window.
Since by-x panels' x domains are disjoint by construction, the
default scale sharing drops to ax:y — values stay comparable,
every panel keeps its own x labels; an explicit ax: wins.
The keys, all optional:
| Key | Meaning |
|---|---|
ax: | scale sharing across all panels — xy (default), x, y or 0 |
n: | wrap: panels per row (x) or per column (y); one direction only |
o: | panel order — a/d by key, x descending by each panel's max, i ascending by its min; k:s is the compact alias for o:a. Absent = data order |
by | explicit value keys — by <column>, column 0 included (see above) |
Every panel is labelled with its key value. An x line and a y
line form the cartesian product: empty combinations keep their
cells so the grid stays aligned, and draw nothing — a sparse dataset
is normal data, not an error. The cell keeps its label, so you can
see which combination has no observations. Blank key cells group under a
(blank) panel. The grid truncates at 50 panels with a
⚠ facets truncated badge — if you hit it on a continuous column,
bin the values upstream.
Claims are last-wins across the whole file: a column claimed by
a later line moves there, and the earlier line keeps what it still
holds. Restating a direction replaces it; the + x / + y amend
extends it instead:
x : sex -- claim
+ x : region -- extend: sex × region on x
+ x n:4 -- no slicer: patch options on the standing x line
The panels re-derive from the data on every update — a feed that grows a new region value slides one new panel in beside the survivors, which keep their identity and simply make room.
The sex × species grid is worked through in
Penguins by sex and species; the
quartet, whose whole file is x : : n:2, is in
Anscombes quartet.
A filter line to play with — one panel per region value; try y : region
to fan downward instead, or x : : to fan the series:
x : regionEach panel wears its key value as a strip label, tucked just above
its frame. On a grid the strip carries the joined key, male · Adelie; ll: moves a direction's keys out to a lane
instead. The options are the
labels grammar's
letters, written on any filter line and applied grid-wide (last one
wins):
x : region le:b lm:8 -- strips below the frame, 8px off
x : : n:3 le:tl li:in lm:2 -- inside each panel's top-left corner
y : species li:in -- overlaid just inside the frame
x : sensor ltx:b,sm lc:gray-500 -- restyled
x : id lo:e -- long keys ellipsize to the panel
x : region l:0 -- no strips at all
le: picks the frame edge (t, b, or a corner — le:tl fuses
the edge and the alignment, exactly like e:tl on a data label),
li: in or out, lm: the gap (default 5px; unsigned is exact and an
explicit +/- adjusts that default; a single value insets both axes
at a corner, the pair form is lm:x,y), lo: fits long
keys against the panel width, ltx:/lc: restyle the text,
lr: rotates about the label's anchor (signed degrees, −90 to 90),
and l:0 turns the strips off. le:l and le:r dock a strip on
the panel's side (a vertical strip), and lb: declares the strip's
legibility plate — auto, 0, or a color pair, the labels plate
grammar. The plate follows the strip on every edge, including its
rotation on vertical strips. The per-side margins from the
labels grammar ride
too: lml: lmr: lmt: lmb: (px; unsigned exact, +/- relative
to zero) with the bundles lmx:
lmy: lma: — a bundle expands onto its sides and a later single
side overrides its half; they compose with lm:. lo: takes the
shared value set — e t w o h f, or the full words
ellipsis truncate wrap overflow hide flip — and af:
sets the fit policy for crowded strips: af:a forces the alternate
stagger, af:0 disables it, absent runs the automatic ladder.
Outside strips stay clear of any
axis furniture drawn beyond their edge, exactly like the default
position does, and strips always paint above the panel's ink — an
inside label never hides behind a bar.
A line leading with the grid slicer pair addresses one facet's strip — rows × columns of the physical grid, one slicer meaning columns, exactly like the margins file's scoped lines:
x : : n:3 ax:xy -- the panels
0 0 le:tl li:in lm:2 -- the first facet: inside its top-left
-1 l:0 -- last grid column: no label
In a carve file the same letters ride each carve line instead and style that line's own panel — see Panel titles.
A strip repeats its direction's key on every panel. On a grid that
gets noisy fast: nine panels reading male · Adelie, female · Adelie, and so on, when there are only three sexes and three
species. ll: moves a direction's keys into a lane instead — one
label per grid column or row, in a band outside the grid:
x : sex ll:t -- sex across the top, once per column
y : species ll:r lr:-90 -- species down the right, turned
The side is the axis's to give. An x line's keys index grid
columns, so its lane runs horizontally, t or b. A y line's
keys index rows, so its lane runs vertically, l or r. Full
words work (ll:top). ll:1 takes the direction's default side —
t for x and r for y, which keeps the lane clear of the y axis on
the left — and ll:0 hands the keys back to the strips.
A lane takes its keys out of the join. Lane the x only and each
panel's strip reads Adelie rather than male · Adelie; lane both
and no panel carries a strip, so the band above each cell is
reclaimed and the panels grow into it.
The l* letters follow the lane. Written on a line that declared
ll:, they style that lane instead of landing grid-wide — which
is what lets the side lane rotate while the top one stays level.
lw:/lwi:/lwx: become the lane's thickness and its clamps,
lm: the gap to the grid, lo: the fit for long keys, ltx:/lc:
the text, and l:0 drops the lane's text while keeping the keys off
the strips. le: and li: are per-panel geometry and have no lane
reading, so they are dropped with a diagnostic.
Left alone, a lane measures itself to its widest label, rotation
included: a turned side lane is one line thick no matter how long the
keys are, which is why lr:-90 is the usual spelling for row labels.
A lane needs an unwrapped direction. n: and ll: contradict
each other — a wrapped grid's columns are not one key each, since
x : : n:4 puts series 1, 5, 9 and 13 in the first column — so the
wrap wins and the lane drops with a diagnostic.
-- try ll:0 on either line to put that direction's keys back on the
-- panels, or ll:b / ll:l to move a lane to the opposite side
x : region ll:t
y : segment ll:r lr:-90Filter panels share both scales by default — comparability is why
you facet. ax:x keeps the shared index and frees each panel's y
(the small-multiples middle ground), ax:0 frees everything. Shared
panels lay out identical axis furniture: one domain, the union of
every panel's extent, zero-clamped as ever, with the panels' draw
areas gutter-aligned so the shared scale lands on the same pixels
everywhere.
Shared scales also draw joint axes: x tick labels appear only on the lowest panel of each grid column, y tick labels only on the leftmost panel of each row. Every panel keeps its tick marks, domain lines and frame — only the repeated label text goes. Free-scale families always label every panel.
Feel the difference — ax:xy reads magnitudes across panels, ax:0
reads each panel's own shape (the north series dwarfs the others until
you free the scales):
x : : n:2 ax:xyCarve panels share explicitly, by group token: panels naming the
same x: or y: group share one domain — the union of the members'
extents. The main panel sits in the implicit groups x and y, so
syncing with it is one token:
: -1 w:100% h:25% e:b y:y -- the strip shares the main y scale
: 2 w:33% e:r y:y2 -- these two sync with each other,
: 3 w:33% e:r y:y2 -- but not with the main panel
Without a group token a panel scales freely. Domains declared in the axis grammar still win everywhere — one config, many panels.
Linked panels also align their frames: panels sharing an x: group
take one y-axis gutter (the widest among them), so their draw areas
line up exactly even when their y scales measure differently sized
labels; y: groups do the same for tops and bottoms.
To give one panel its own y-axis instructions, claim the series onto a rail in the axis grammar — the rail travels with its columns into the panel:
y _ f:$,.1f -- price: the unclaimed primary
y2 s:volume f:.0s -- volume's own scale — inside the volume
-- panel this becomes the ONLY y axis
A panel that shares a y: group merges domains with its group; a
panel without one brings its own scale and its own claimed styling.
- Declaration order is carve order. Each line takes its strip from the rectangle that remains, so cross-edge questions ("does the bottom strip span under the right column?") are answered by ordering alone — declare the bottom strip first and it spans the full width.
- Same-edge lines share one strip, laid out walking outward from the main panel: main, then the first carve, then the second, toward the edge.
- Percentages are of the total surface, not of what remains —
two
w:33%carves leave roughly a third for the main panel. - Over-subscribed sizes never break the layout: the carves rescale so the remainder keeps its even share.
- The gutter between panels comes from the
margins file's
px:/py:directives (between columns / between rows); the margin sides describe the outer frame.
Each panel is a complete miniature chart: its own scales and axes, rendered from its slice of the data. Colors resolve in global series space — a series keeps its color when it moves into its own panel — and the plot draws one legend for the whole surface. A single-series carve titles its panel with the series name.
Type instructions from the type grammar follow their cells into the panels, so the volume strip can be bars under a price line with two more lines of config.
Panels move on the same clock as everything else: a new panel makes the survivors resize first, then fades in; a removed panel fades out first, then the rest reclaim the space. Geometry edits are a single smooth reposition.
Using the same data as one of the fact examples we can drastically change the layout by just tweaking the grammar.
: 1 line
: -1 line
-1: 1 scatter -- terminal price marker: 1 line: : line: : line