Skip to content

Layout Engine

This document maps the layout logic in dataface/core/ for contributors who need to change board composition, sizing, or rendering behavior.

If you are new to the repo, read Architecture Overview and Core Engine first. This page zooms into one part of that engine: how a board layout becomes concrete geometry and then rendered output.

Why layout feels spread out

Layout logic lives in a few different places because Dataface separates three concerns:

  1. author-facing layout syntax
  2. normalized internal layout structure
  3. render-time geometry and drawing

That means "layout" is not one file. The main path crosses compile, sizing, and render modules.

Concern Main files Responsibility
Author-facing schema dataface/core/compile/types.py Defines rows, cols, grid, tabs, and nested face syntax
Layout normalization dataface/core/compile/normalize_layout.py Converts authored layout forms into one unified Layout tree
Compiled layout models dataface/core/compile/compiled_types.py Defines Layout and LayoutItem, plus calculated geometry fields
Layout sizing dataface/core/compile/sizing.py Computes widths, heights, and positions, including content-aware sizing
Root/nested face render dataface/core/render/renderer.py, dataface/core/render/faces.py Starts render, reserves title/content/control space, dispatches by layout type
Layout renderers dataface/core/render/layouts.py Places already-sized items for rows, cols, grid, tabs, and details
Slot rendering dataface/core/render/chart/rendering.py Renders one LayoutItem as a chart, nested face, or collapsible section

Mental model

The most important thing to know is that Dataface does not treat browser layout as the source of truth. Layout is mostly resolved inside core before the final SVG or HTML wrapper is produced.

flowchart LR
    A["Author YAML<br/>rows / cols / grid / tabs"] --> B["normalize_layout.py<br/>build unified Layout tree"]
    B --> C["compiled_types.py<br/>Layout + LayoutItem"]
    C --> D["sizing.py<br/>calculate_data_aware_layout()"]
    D --> E["faces.py<br/>reserve title/content/variables area"]
    E --> F["layouts.py<br/>place items by layout type"]
    F --> G["chart/rendering.py<br/>render each layout slot"]
    G --> H["SVG output<br/>or HTML wrapper / PNG / PDF"]

The browser can still scale or host the output, but it is usually not deciding:

  • row heights
  • column widths
  • grid placement
  • nested face geometry
  • which tab panel gets SVG content

Those choices are mostly made upstream.

Stage 1: author syntax becomes one tree

The entry point for layout normalization is dataface/core/compile/normalizer.py, which calls _build_unified_layout() in dataface/core/compile/normalize_layout.py.

The authored surface allows several layout syntaxes:

  • rows
  • cols
  • grid
  • tabs
  • nested faces inside any of those
  • inline charts and chart references
  • details/collapsible sections
  • foreach expansion

Downstream code does not want to handle each authored form separately, so the compiler converts them into a single Layout object with LayoutItem children.

flowchart TD
    subgraph Authored["Authored face"]
        R["rows"]
        C["cols"]
        G["grid"]
        T["tabs"]
    end

    Authored --> U["_build_unified_layout()"]
    U --> L["Layout(type=...)"]
    L --> I["LayoutItem(type='chart'|'face')"]
    U --> V["_generate_layout_variables()<br/>auto tab/details variables"]
    U --> X["_collect_charts_from_layout()<br/>inline charts become global compiled charts"]

What normalization resolves

Normalization does more than shape conversion. It also:

  • resolves chart references and inline charts
  • resolves nested faces recursively
  • converts raw grid/tab dicts into typed layout objects
  • generates hidden variables for tabs and collapsible details sections
  • stores tab metadata such as tab_titles, tab_slugs, tab_variable, and default_tab
  • preserves authored width hints like user_width

The compiled layout contract

After normalization, render code should mostly think in terms of:

  • Layout.type
  • Layout.items
  • optional grid metadata like columns
  • optional tab metadata like tab_slugs
  • per-item calculated geometry fields that sizing will later populate

That contract lives in dataface/core/compile/compiled_types.py.

Stage 2: sizing turns structure into geometry

The sizing pass is the heart of layout behavior. The renderer calls calculate_data_aware_layout() in dataface/core/compile/sizing.py before rendering the face.

Two ideas drive the module:

  1. Width comes mainly from layout structure.
  2. Height comes from both layout structure and content type.

That second point is why sizing lives outside a simple static normalizer. Tables, markdown, titles, and nested faces need real measurements or data-aware estimates.

flowchart TD
    A["calculate_data_aware_layout(face, executor, variables)"] --> B["_calculate_layout_height()"]
    B --> C["_get_item_content_height()"]
    C --> D["chart height rules"]
    C --> E["markdown measurement"]
    C --> F["nested face recursive height"]
    C --> G["details expanded/collapsed state"]
    D --> H["get_chart_content_height()"]
    E --> I["get_markdown_text_height()<br/>get_title_height()"]
    H --> J["tables may execute query<br/>for row-count-aware height"]
    B --> K["_calculate_layout_items()"]
    K --> L["_calculate_rows_dimensions()"]
    K --> M["_calculate_cols_dimensions()"]
    K --> N["_calculate_grid_dimensions()"]
    K --> O["_calculate_tabs_dimensions()"]
    K --> P["recurse into nested faces"]

Height rules

Sizing is intentionally content-aware:

  • KPI charts default short
  • most charts use a wider/taller standard aspect-ratio-driven height
  • tables can execute queries to size from actual row count
  • markdown and titles are measured with mdsvg
  • nested faces sum the heights of their own title, content, layout, padding, and margin
  • collapsed details sections reserve only the summary-bar height

This is why layout can feel "product-aware" instead of generic.

Width rules

Widths are more layout-specific:

  • rows: items get full available width
  • cols: items split width horizontally, respecting user_width when present
  • grid: width comes from column math and col_span
  • tabs: each tab gets full container width

Per-layout sizing behavior

rows

_calculate_rows_dimensions():

  • measures each item's natural content height
  • sums those heights plus gaps
  • if everything fits, keeps the natural heights
  • if not, scales heights proportionally to the available container

This makes rows the most vertical-content-driven layout.

cols

_calculate_cols_dimensions():

  • parses authored widths like 30% or 200px
  • distributes the remaining width across auto-width siblings
  • measures each item's required height at its actual width
  • assigns every sibling the same row height: the maximum required height, capped by available height

This shared-height rule is why a column row stays visually aligned even when its children want different heights.

grid

_calculate_grid_dimensions():

  • computes column width from columns and gap
  • places explicit (row, col) items first
  • auto-flows unpositioned items into remaining cells
  • estimates row heights from the content requirements of the items occupying them
  • scales row heights if needed to fit the available container
  • converts row/column spans into pixel x, y, width, and height

The grid system is therefore a compile/render hybrid: authored placement hints become explicit pixel geometry before draw time.

tabs

_calculate_tabs_dimensions():

  • reserves space for the tab bar
  • finds the maximum content height across all tab panels
  • gives every tab item the same container geometry

Only one tab is visible in SVG output, but sizing still considers all tabs so switching tabs does not radically change the board frame.

Nested faces

Nested faces are sized recursively inside _calculate_layout_items().

For each nested face, sizing:

  • assigns the parent item width/height to the nested face
  • subtracts effective padding and margin
  • subtracts title/content height that sits above the nested layout
  • stores content_width and content_height on the nested face layout
  • recursively sizes the nested layout with that inner box

This recursive "box-within-a-box" model is one of the core layout invariants in Dataface.

flowchart TD
    P["Parent LayoutItem box"] --> A["nested_face.layout.width/height = parent item"]
    A --> B["subtract padding + margin"]
    B --> C["subtract nested title/content height"]
    C --> D["set layout.content_width/content_height"]
    D --> E["recurse into child layout"]

Stage 3: render reserves the root frame

The top-level render entry point is render() in dataface/core/render/renderer.py.

For layout, the important order is:

  1. merge variable defaults and runtime values
  2. call calculate_data_aware_layout()
  3. call render_face_svg()
  4. optionally wrap or convert the SVG for HTML/PNG/PDF

Inside render_face_svg() in dataface/core/render/faces.py, the renderer establishes the outer drawing frame for the face:

  • starts with face width/height
  • subtracts page padding
  • subtracts title height if present
  • subtracts markdown content height if present
  • subtracts variable-control height if present
  • passes the remaining content_width and content_height to the layout renderer
flowchart TD
    A["render()"] --> B["calculate_data_aware_layout()"]
    B --> C["render_face_svg()"]
    C --> D["page padding"]
    D --> E["title area"]
    E --> F["markdown content area"]
    F --> G["variable controls area"]
    G --> H["_render_layout()"]

This is why the layout functions in render/layouts.py can stay fairly simple: they are handed a box that already accounts for the non-layout parts of the face.

Stage 4: layout renderers mostly trust sizing

dataface/core/render/layouts.py dispatches by layout.type:

  • render_rows_layout()
  • render_cols_layout()
  • render_grid_layout()
  • render_tabs_layout()

These functions do not usually re-decide geometry. They mostly trust the sizing fields already written onto each LayoutItem.

rows

render_rows_layout():

  • walks items in order
  • renders each item using its precomputed width/height
  • translates each rendered item by an accumulating current_y

cols

render_cols_layout():

  • uses each item's precomputed x, width, and height
  • places every child at (item.x, 0)

grid

render_grid_layout():

  • uses precomputed pixel x, y, width, and height
  • treats grid placement as already solved

tabs

render_tabs_layout():

  • resolves the active tab from the generated tab variable
  • reserves tab-bar height
  • renders only the active tab panel into the SVG body
  • draws the tab bar as SVG links that update URL params and trigger server re-rendering

This is an important design choice: tabs are not browser-managed hidden DOM panels. They are part of the server-side/core layout model.

Details/collapsible sections

Collapsible sections are also layout items, not a separate page system.

render_layout_item() checks details metadata and then:

  • always renders the summary bar
  • checks the controlling variable
  • renders nested face content only when expanded

Sizing and rendering share the same expanded/collapsed logic, which keeps the reserved height consistent with what is actually drawn.

Stage 5: each layout slot renders its own content

Once a layout renderer picks a slot, it calls render_layout_item() in dataface/core/render/chart/rendering.py.

That function decides whether the slot contains:

  • a chart
  • a nested face
  • a details section wrapping a nested face

For charts, the path is:

  1. execute the chart query
  2. resolve Jinja in the title
  3. normalize field references and series promotion
  4. resolve chart intent and auto properties
  5. render the chart with explicit width/height
  6. wrap the chart SVG with metadata attributes

This is where layout and chart rendering meet: layout owns the box, chart logic owns what goes inside the box.

flowchart LR
    A["Layout slot<br/>width + height"] --> B["render_layout_item()"]
    B --> C["chart?"]
    B --> D["nested face?"]
    B --> E["details section?"]
    C --> F["execute query"]
    F --> G["resolve chart semantics"]
    G --> H["render SVG into allocated box"]
    D --> I["render_nested_face()"]
    E --> J["summary bar + conditional nested face"]

Practical invariants

When changing layout code, these are the current system invariants worth protecting:

  • Normalize once, then render from a unified Layout tree.
  • Sizing owns pixel geometry; render should not silently invent new layout rules.
  • Nested faces get a concrete parent box and recurse inside it.
  • cols rows share a single sibling height.
  • tabs size against all tab panels but render one active panel.
  • Details sections must keep sizing and render expansion logic in sync.
  • HTML output is mostly a wrapper around SVG, not a separate browser-native layout engine.

Where to edit for common layout changes

Change you want Start here
Add or change authored layout syntax dataface/core/compile/types.py, dataface/core/compile/normalize_layout.py
Change tab or details variable behavior dataface/core/compile/normalize_layout.py, layout-variable generation in normalizer
Change default heights, aspect ratios, markdown measurement, or table height behavior dataface/core/compile/sizing.py
Change row/column/grid placement rules dataface/core/compile/sizing.py and then dataface/core/render/layouts.py
Change root face padding/title/content reservation dataface/core/render/faces.py
Change how a slot renders a chart or nested face dataface/core/render/chart/rendering.py
Change browser/output wrapping without changing layout geometry dataface/core/render/converters/

Read this with

  • Core Engine for the broader compile/execute/render pipeline
  • Board Layout Implementation for the product-level explanation of why the current system feels fixed-layout
  • dataface/core/render/chart/DESIGN.md for render-layer chart semantics once a layout slot hands off to chart rendering