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:
- author-facing layout syntax
- normalized internal layout structure
- 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:
rowscolsgridtabs- 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, anddefault_tab - preserves authored width hints like
user_width
The compiled layout contract¶
After normalization, render code should mostly think in terms of:
Layout.typeLayout.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:
- Width comes mainly from layout structure.
- 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 widthcols: items split width horizontally, respectinguser_widthwhen presentgrid: width comes from column math andcol_spantabs: 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%or200px - 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
columnsand 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, andheight
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_widthandcontent_heighton 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:
- merge variable defaults and runtime values
- call
calculate_data_aware_layout() - call
render_face_svg() - 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_widthandcontent_heightto 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, andheight - places every child at
(item.x, 0)
grid¶
render_grid_layout():
- uses precomputed pixel
x,y,width, andheight - 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:
- execute the chart query
- resolve Jinja in the title
- normalize field references and series promotion
- resolve chart intent and auto properties
- render the chart with explicit width/height
- 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
Layouttree. - Sizing owns pixel geometry; render should not silently invent new layout rules.
- Nested faces get a concrete parent box and recurse inside it.
colsrows share a single sibling height.tabssize 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.mdfor render-layer chart semantics once a layout slot hands off to chart rendering