Plot

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:

plotconfig/facets

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 x or y and 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:

KeyMeaning
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.

waiting for the playground…
-- 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:none

Four 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.

waiting for the playground…
-- 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:2

Small 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.

waiting for the playground…
-- 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,4

The 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.

waiting for the playground…
-- 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 targetx 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:

KeyMeaning
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
byexplicit 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:

waiting for the playground…
x : region

Each 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.

waiting for the playground…
-- 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:-90

Filter 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):

waiting for the playground…
x : : n:2 ax:xy

Carve 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.

waiting for the playground…
:  1  line
: -1  line
-1:  1  scatter  -- terminal price marker

waiting for the playground…
:  1  line

waiting for the playground…
: : line

waiting for the playground…
: : line