Axis Labels — Smart Layout¶
Dataface picks readable axis-label layout automatically. The default
overlap: "smart" runs a fit-based tilt picker on discrete x-axes
(measure labels, pick the smallest tilt that fits) and forces
parity-drop on continuous x-axes (numeric, temporal). Authors
override anything they want via the standard style cascade.
The decision tree¶
flowchart TD
A[Axis with labels to render] --> B{Is this a bar chart with<br/>style.orientation: auto?}
B -- yes --> C{Is x discrete?<br/>nominal field, no timeUnit}
C -- yes --> D[Flip to horizontal bars<br/>category labels go on y-axis<br/>render horizontal naturally]
C -- no --> E[Keep vertical bars]
B -- no --> E
E --> F{Resolve overlap: smart<br/>by axis type}
F -- discrete x<br/>nominal --> G[Fit-based picker:<br/>measure labels, pick smallest tilt<br/>from tilt_increments that fits;<br/>fall back to -90° + parity if none fit]
F -- continuous x<br/>temporal / numeric / log /<br/>cyclical timeUnit --> H[Emit labelOverlap: parity<br/>VL drops alternating labels]
F -- y axis --> I[Omit labelOverlap;<br/>VL applies per-scale adaptive default]
The same tree in words:
- Bar chart with
style.orientation: auto? If yes, ask whether x is discrete (nominal field, notimeUnit). Discrete x → flip to horizontal: category labels move to the y-axis and render horizontal without tilting. Continuous x (temporal, quantitative, date-like ordinal, time-unit bucketed) → stay vertical. The rule is type-driven, not viewport-driven — orientation reflects what the data is, not how wide the chart happens to be. - Otherwise, resolve
overlap: smartby axis type. Discrete x runs the picker: measure each label with FontMeasurer, walktilt_increments(theme default[0, -30, -45, -60, -90]), pick the smallest tilt whose rotated footprint fits. If none fit, fall through to-90°+parity. Continuous x emitslabelOverlap: "parity"so VL drops alternating labels deterministically. Y-axis preserves the omit-the-field default — VL adapts per scale type.
Strategy reference¶
Overlap values¶
label.overlap is a four-value string enum. The theme defaults to
"smart".
| Value | What it does on the x-axis |
|---|---|
"smart" (theme default) |
Discrete x: run fit-based picker (tilt to fit; fall back to -90° + parity). Continuous x: emit labelOverlap: "parity". Y-axis: omit the field; VL adapts per scale. |
"parity" |
Drop every other label until no overlaps remain, regardless of scale type. Use when you want to force thinning on a nominal axis (where smart would tilt instead). |
"greedy" |
Linear scan, drop any label that overlaps the last visible one. Use on log scales or irregular cadence (fiscal years with gaps), where the leftmost label anchors. |
"allow" |
No overlap reduction — labels can collide. Use when every tick must be visible and you'll handle readability with label.angle or chart width instead. |
How "smart" resolves at compile time. Smart is a dispatcher,
not a direct VL pass-through. The resolver inspects each axis and
rewrites the compiled style before the emitter runs:
- Discrete x-axis (
nominalfield, notimeUnit) — invoke_pick_tilt_angle. FontMeasurer measures the longest formatted label; the picker walkstilt_incrementsin order and returns the first angle whose footprint (max_width * cos(angle) + line_height * sin(angle)) fits the chart's usable width. If the picker finds a fit, the resolver writeslabel.angle = <picked>+label.overlap = "allow"(no drops needed; the chosen tilt was the point). If none of the increments fit, the resolver writeslabel.angle = <last_increment>+label.overlap = "parity"(-90° at default; let parity-drop thin the labels). - Continuous x-axis (numeric, temporal — including
timeUnit-bucketed dates, cyclicalweekofyear/monthofyear/dayofweek— and log) — rewritelabel.overlap = "parity"so VL emitslabelOverlap: "parity"explicitly. This trades VL's adaptive greedy default for log scales (rare in dashboards) against reliable parity on the common case. - Y-axis — unchanged. Smart preserves the omit-the-field semantic; VL applies its per-scale default.
- Author override authority. If
label.angleis explicitly set, the picker is skipped — the resolver only writeslabel.overlap = "allow"and leaves the angle alone. Same invariant as Lane C's bar-flip: authored values never get re-stamped.
(Authoring the bool true or false directly is rejected by
Dataface's schema with an error pointing at "smart" /
"parity" / "allow".)
Fit-based tilt picker¶
The picker is the engine behind discrete-x smart. It runs only when:
label.overlapresolves to"smart"(the theme default),- the x-axis is discrete (nominal field, no
timeUnit), label.angleis at its default (not explicitly authored).
Inputs: the data, the chart's resolved width, the theme's
tilt_increments, and the resolved label font (size + family).
Algorithm:
- Measure the longest formatted label with FontMeasurer (no
px_per_charmagic). - For each angle in
tilt_increments(ordered shallowest → steepest): - Compute the rotated footprint as
max_width * cos(angle) + line_height * sin(angle). This is the horizontal slice each label needs at that tilt. - If
n_labels * footprint <= chart_width * 0.8, return(angle, fits=True). - If no angle fits, return
(last_angle, fits=False).
The dispatcher then translates the picker's outcome:
| Picker outcome | label.angle |
label.overlap |
|---|---|---|
(angle, fits=True) and angle ≠ 0 |
<picked> |
"allow" |
(0, fits=True) |
unchanged (VL's x-axis default is 0°; redundant to stamp) | "allow" |
(angle, fits=False) |
<last_increment> (e.g. -90°) |
"parity" |
The 80% chart-width budget leaves room for axis padding, tick extents, and the y-axis label gutter without modeling them precisely. If pain emerges around the threshold, the multiplier becomes a theme knob.
tilt_increments — theme knob¶
tilt_increments is the picker's candidate menu. Themes set it
once under style.charts.axis_x.label; authors don't touch it.
# Default (stark.yaml — structural root for the built-in theme cascade) style: charts: axis_x: label: tilt_increments: [0, -30, -45, -60, -90]
Order matters: the picker tries angles in list order and stops at the first fit. Put the shallowest (most readable) angle first. The list must have at least one element.
A theme that wants only horizontal-or-vertical (no editorial
gradient) can shorten the menu to [0, -90]. A theme that wants
a finer ladder can extend to [0, -15, -30, -45, -60, -75, -90].
Continuous axes (temporal, quantitative, log)¶
VL's adaptive labelOverlap default doesn't fire reliably on
timeUnit-bucketed temporal axes — daily-grain axes can render
with labels jammed together. The resolver fixes this by emitting
labelOverlap: "parity" explicitly on every continuous x-axis:
| Scale | VL default labelAngle |
Smart emits labelOverlap |
|---|---|---|
| Linear quantitative | 0° |
"parity" |
| Log quantitative | 0° |
"parity" (trade-off: loses VL's adaptive greedy default; log axes are rare in dashboards) |
Temporal (raw or timeUnit-bucketed) |
0° |
"parity" |
Cyclical weekofyear / monthofyear / dayofweek |
0° |
"parity" |
Continuous labels (numbers, dates) are short and readable
horizontally, and dropping alternates is safe because gaps are
interpolable (May between Apr and Jun).
You can override per-axis if you want a different strategy:
charts: monthly_signups: style: axis_x: label: overlap: greedy # force greedy-drop instead of smart's parity
Continuous bar charts (histograms, time-series bars) follow this same path — they never flip to horizontal orientation, even when crowded. Time runs left-to-right by convention; flipping a monthly-revenue bar chart 90° would be confusing. Crowded continuous bars rely on parity-drop to thin labels.
Discrete axes (nominal, ordinal)¶
Smart's picker runs on discrete x-axes — it measures labels and
chooses the smallest tilt from tilt_increments that lets every
label fit. For short labels (North / South / East / West) the
picker returns 0° (horizontal); for medium-length labels it
returns -30° or -45°; for long labels at typical widths it
falls back to -90° + parity.
If you want a specific angle instead of the picker's choice, set it explicitly:
charts: flat_labels: type: line x: state_code style: axis_x: label: angle: 0 # explicit horizontal; picker skipped
Themes can set the default angle once for all discrete x-axes — but if you do, smart's picker stops running (an authored value is authority):
style: charts: axis_x: label: angle: 0 # this theme renders nominal x labels horizontal everywhere
Horizontal flip (discrete bar charts)¶
When a bar chart has a discrete x-axis and style.orientation: auto
(the default), the chart flips to horizontal — unconditionally.
Category labels move to the y-axis where they render horizontal at
angle 0 (the eye scans top-to-bottom), and the bars extend right
from a shared baseline.
The rule is purely type-driven:
| x-axis type | Auto orientation |
|---|---|
nominal (category strings, no timeUnit) |
horizontal |
temporal / quantitative / date-like ordinal |
vertical |
timeUnit-bucketed (including cyclical monthofyear etc.) |
vertical |
Bar charts where the author explicitly sets style.orientation:
vertical or : horizontal skip the auto-pick entirely — authored
values are authority.
This decision only applies to bar charts with a discrete x. Bar charts with a continuous x (histograms, time-series bars) always stay vertical because time flows left-to-right by convention and magnitude bars rise from a baseline — see Continuous axes above.
Orientation reflects what the data is, not how wide the chart happens to be. Earlier behavior measured label widths and flipped only when labels wouldn't fit; that let the viewport override the data's semantic shape. The current rule is type-driven: data semantics drive layout.
Time axes¶
Bucketed time axes (monthly, quarterly, yearly) are detected
automatically and rendered with VL's timeUnit — see
Time Axes for the full grain-detection rules.
Once the time axis is bucketed, it's a temporal scale and falls
through the continuous branch of smart's resolver: VL emits
labelOverlap: "parity" and drops alternating labels. Cyclical
units (weekofyear, monthofyear, dayofweek) route through the
same continuous path — a 12-bucket monthofyear axis with smart
shows 6 labels instead of all 12. Authors who want every label
visible can override with overlap: allow or set an explicit
label.angle.
Manual overrides¶
Every smart decision is overridable.
Force a specific orientation (bar charts)¶
charts: always_vertical: type: bar x: state_name style: orientation: vertical # skip auto-pick; force column chart
Force a specific tilt¶
charts: forced_horizontal_labels: type: bar x: state_name style: axis_x: label: angle: 0 # explicit horizontal; picker skipped
Pick a different overlap strategy¶
charts: drop_alternates: type: line x: many_categories style: axis_x: label: overlap: parity # explicit drop-alternates (smart would tilt instead)
Control truncation¶
charts: long_category_names: style: axis_x: label: max_width: 240 # widen the truncation cap (default: 180px, matching Vega-Lite)
Customize the tilt menu (theme-level)¶
style: charts: axis_x: label: tilt_increments: [0, -45, -90] # binary-ish gradient: flat, mid-tilt, vertical
How does Vega-Lite handle this natively?¶
Smart's discrete-axis picker and continuous-axis explicit-parity
are Dataface compile-time behavior — VL receives concrete
labelAngle and labelOverlap values, not sentinels. For
reference, here's what VL would do on its own:
| Field type | VL default labelAngle |
VL default labelOverlap |
|---|---|---|
nominal / ordinal (no timeUnit) |
-90° |
false (none — labels overlap freely) |
temporal / quantitative (non-log) |
0° |
true (parity — but unreliable on timeUnit-bucketed temporals) |
| Log-scale quantitative | 0° |
"greedy" |
The "unreliable on timeUnit-bucketed temporals" row is what
motivated smart's continuous-x explicit-parity rewrite.
See also¶
- Time Axes — grain detection and
timeUnitbehavior - Bar Charts — orientation and grouping
- Styling — full theme/style cascade and per-element styling
- Vega-Lite Axis docs — the underlying behavior smart leans on