Skip to content

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:

  1. Bar chart with style.orientation: auto? If yes, ask whether x is discrete (nominal field, no timeUnit). 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.
  2. Otherwise, resolve overlap: smart by axis type. Discrete x runs the picker: measure each label with FontMeasurer, walk tilt_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 emits labelOverlap: "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 (nominal field, no timeUnit) — invoke _pick_tilt_angle. FontMeasurer measures the longest formatted label; the picker walks tilt_increments in 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 writes label.angle = <picked> + label.overlap = "allow" (no drops needed; the chosen tilt was the point). If none of the increments fit, the resolver writes label.angle = <last_increment> + label.overlap = "parity" (-90° at default; let parity-drop thin the labels).
  • Continuous x-axis (numeric, temporal — including timeUnit-bucketed dates, cyclical weekofyear / monthofyear / dayofweek — and log) — rewrite label.overlap = "parity" so VL emits labelOverlap: "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.angle is explicitly set, the picker is skipped — the resolver only writes label.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.overlap resolves to "smart" (the theme default),
  • the x-axis is discrete (nominal field, no timeUnit),
  • label.angle is 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:

  1. Measure the longest formatted label with FontMeasurer (no px_per_char magic).
  2. For each angle in tilt_increments (ordered shallowest → steepest):
  3. Compute the rotated footprint as max_width * cos(angle) + line_height * sin(angle). This is the horizontal slice each label needs at that tilt.
  4. If n_labels * footprint <= chart_width * 0.8, return (angle, fits=True).
  5. 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 "parity"
Log quantitative "parity" (trade-off: loses VL's adaptive greedy default; log axes are rare in dashboards)
Temporal (raw or timeUnit-bucketed) "parity"
Cyclical weekofyear / monthofyear / dayofweek "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 (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) true (parity — but unreliable on timeUnit-bucketed temporals)
Log-scale quantitative "greedy"

The "unreliable on timeUnit-bucketed temporals" row is what motivated smart's continuous-x explicit-parity rewrite.

See also