Skip to content

Render Stage

The render stage transforms compiled boards with data into visual output formats.

CompiledBoard + Executor → render() → SVG | HTML | PNG | PDF | Terminal

Supported Formats

The renderer supports the following output formats:

  • SVG: Scalable vector graphics (default)
  • HTML: Interactive HTML pages with embedded charts
  • PNG: Raster image format
  • PDF: PDF documents
  • Terminal: Terminal output with ASCII/Unicode charts (prints to stdout)

Entry Points

render

The main entry point for rendering dashboards:

render

render(board: CompiledBoard, executor: Executor, format: str = 'svg', variables: Optional[VariableValues] = None, **options: Any) -> Union[str, bytes]

Render a compiled dashboard.

Stage: RENDER (Main Entry Point)

This is the main rendering function. It walks the layout structure, renders each chart (triggering lazy query execution), and produces output in the requested format.

PARAMETER DESCRIPTION
board

Compiled dashboard to render

TYPE: CompiledBoard

executor

Executor for query execution

TYPE: Executor

format

Output format (svg, html, png, pdf, terminal)

TYPE: str DEFAULT: 'svg'

variables

Variable values for queries

TYPE: Optional[VariableValues] DEFAULT: None

**options

Format-specific options - background: Background color - scale: Scale factor (for png)

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
Union[str, bytes]

Rendered output: - str for svg, html, terminal - bytes for png, pdf

RAISES DESCRIPTION
RenderError

If rendering fails

FormatError

If format is unknown

Source code in dataface/render/renderer.py
def render(
    board: CompiledBoard,
    executor: Executor,
    format: str = "svg",
    variables: Optional[VariableValues] = None,
    **options: Any,
) -> Union[str, bytes]:
    """Render a compiled dashboard.

    Stage: RENDER (Main Entry Point)

    This is the main rendering function. It walks the layout structure,
    renders each chart (triggering lazy query execution), and produces
    output in the requested format.

    Args:
        board: Compiled dashboard to render
        executor: Executor for query execution
        format: Output format (svg, html, png, pdf, terminal)
        variables: Variable values for queries
        **options: Format-specific options
            - background: Background color
            - scale: Scale factor (for png)

    Returns:
        Rendered output:
            - str for svg, html, terminal
            - bytes for png, pdf

    Raises:
        RenderError: If rendering fails
        FormatError: If format is unknown
    """
    # Build variable registry from board tree (for template resolution)
    from dataface.render.variable_registry import (
        build_variable_registry_from_board,
        build_variable_types_from_registry,
        build_variable_defaults_from_registry,
    )

    variable_registry = build_variable_registry_from_board(board)
    variable_types = build_variable_types_from_registry(variable_registry)
    variable_defaults = build_variable_defaults_from_registry(variable_registry)

    # Merge variables with defaults
    # Start with all variables from global registry (set to None if no default)
    # This ensures all variables are in context for template resolution
    all_variables: Dict[str, Any] = {}
    for var_name in variable_types.keys():
        all_variables[var_name] = None
    # Override with defaults
    all_variables.update(variable_defaults)
    # Override with provided values
    # Parse JSON strings in variables (from URL parameters)
    parsed_variables = _parse_variable_json_strings(variables or {})

    # Validate variables against their expected types
    validated_variables, validation_errors = _validate_variables(
        parsed_variables, variable_types
    )
    if validation_errors:
        # Log validation errors but don't fail rendering
        import warnings

        for error in validation_errors:
            warnings.warn(f"Variable validation: {error}", UserWarning)

    merged_variables = {**all_variables, **validated_variables}

    # Get background for format
    background = get_background_for_format(
        format,
        board.style,
        options.get("background"),
    )

    # Render layout to SVG
    try:
        grid_enabled = options.get("grid", False)
        svg_content = _render_board_svg(
            board, executor, merged_variables, background, grid_enabled
        )
    except Exception as e:
        raise RenderError(f"Failed to render dashboard: {e}")

    # Convert to requested format
    if format == "svg":
        return svg_content

    elif format == "html":
        # Remove background from options to avoid duplicate argument
        html_options = {k: v for k, v in options.items() if k != "background"}
        return _to_html(
            board, svg_content, background, executor, merged_variables, **html_options
        )

    elif format == "png":
        return _to_png(svg_content, options.get("scale", 1.0))

    elif format == "pdf":
        return _to_pdf(svg_content)

    elif format == "terminal":
        return _to_terminal(board, executor, merged_variables, **options)

    else:
        raise FormatError(f"Unknown format: {format}", format)

render_chart

Render a single chart:

render_chart

render_chart(chart: CompiledChart, data: List[Dict[str, Any]], **options: Any) -> str

Render a single chart to SVG.

PARAMETER DESCRIPTION
chart

CompiledChart to render

TYPE: CompiledChart

data

Query results for the chart

TYPE: List[Dict[str, Any]]

**options

Rendering options

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
str

SVG string for the chart

Source code in dataface/render/renderer.py
def render_chart(
    chart: CompiledChart,
    data: List[Dict[str, Any]],
    **options: Any,
) -> str:
    """Render a single chart to SVG.

    Args:
        chart: CompiledChart to render
        data: Query results for the chart
        **options: Rendering options

    Returns:
        SVG string for the chart
    """
    from dataface.render.vega_lite import render_chart as vega_render

    return vega_render(chart, data, format="svg")

Layout Functions

The layout module provides functions for rendering different layout types.

Rows Layout

render_rows_layout_svg

render_rows_layout_svg(items: List[LayoutItem], executor: Executor, variables: VariableValues, available_width: float, available_height: float, gap: float, background: Optional[str] = None) -> str

Render items in vertical stack as SVG.

In a rows layout, items stack vertically. Heights are determined by: 1. Pre-calculated dimensions from sizing module (preferred) 2. Content-aware fallback if not pre-calculated

PARAMETER DESCRIPTION
items

Layout items to render

TYPE: List[LayoutItem]

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available container width

TYPE: float

available_height

Available container height

TYPE: float

gap

Gap between items

TYPE: float

background

Optional background color

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
str

SVG string for rows layout

Source code in dataface/render/layouts.py
def render_rows_layout_svg(
    items: List[LayoutItem],
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float,
    gap: float,
    background: Optional[str] = None,
) -> str:
    """Render items in vertical stack as SVG.

    In a rows layout, items stack vertically. Heights are determined by:
    1. Pre-calculated dimensions from sizing module (preferred)
    2. Content-aware fallback if not pre-calculated

    Args:
        items: Layout items to render
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available container width
        available_height: Available container height
        gap: Gap between items
        background: Optional background color

    Returns:
        SVG string for rows layout
    """
    from dataface.render.renderer import _render_layout_item
    from dataface.compile.sizing import _get_item_content_height
    import re

    if not items:
        return f'<svg width="{available_width}" height="{available_height}" viewBox="0 0 {available_width} {available_height}"></svg>'

    rendered_items: List[str] = []
    current_y = 0.0

    for item in items:
        # Use pre-calculated height if available, otherwise use content-aware fallback
        if item.height > 0:
            item_height = item.height
        else:
            item_height = _get_item_content_height(item, gap, available_width)

        item_width = item.width if item.width > 0 else available_width

        item_svg = _render_layout_item(
            item, executor, variables, item_width, item_height
        )

        if item_svg:
            # Extract actual rendered height from SVG
            actual_height = item_height
            height_match = re.search(r'height="([0-9.]+)"', item_svg)
            if height_match:
                actual_height = float(height_match.group(1))

            rendered_items.append(
                f'<g transform="translate(0, {current_y})">{item_svg}</g>'
            )
            current_y += actual_height + gap

    total_height = max(current_y - gap, 100.0)  # Remove last gap

    bg_rect = ""
    if background:
        bg_rect = f'<rect x="0" y="0" width="{available_width}" height="{total_height}" fill="{html.escape(background)}" rx="4"/>'

    return f"""<svg width="{available_width}" height="{total_height}" viewBox="0 0 {available_width} {total_height}">
{bg_rect}
{"".join(rendered_items)}
</svg>"""

render_rows_layout_html

render_rows_layout_html(items: List[LayoutItem], executor: Executor, variables: VariableValues, available_width: float, available_height: float, gap: float, background: Optional[str] = None) -> str

Render items in vertical stack as HTML.

In a rows layout, items stack vertically. Heights are determined by: 1. Pre-calculated dimensions from sizing module (preferred) 2. Content-aware fallback if not pre-calculated

PARAMETER DESCRIPTION
items

Layout items to render

TYPE: List[LayoutItem]

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available container width

TYPE: float

available_height

Available container height

TYPE: float

gap

Gap between items

TYPE: float

background

Optional background color

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
str

HTML string for rows layout

Source code in dataface/render/layouts.py
def render_rows_layout_html(
    items: List[LayoutItem],
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float,
    gap: float,
    background: Optional[str] = None,
) -> str:
    """Render items in vertical stack as HTML.

    In a rows layout, items stack vertically. Heights are determined by:
    1. Pre-calculated dimensions from sizing module (preferred)
    2. Content-aware fallback if not pre-calculated

    Args:
        items: Layout items to render
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available container width
        available_height: Available container height
        gap: Gap between items
        background: Optional background color

    Returns:
        HTML string for rows layout
    """
    from dataface.render.html import render_layout_item_html
    from dataface.compile.sizing import _get_item_content_height

    container_style = f"display: flex; flex-direction: column; width: {available_width}px; height: {available_height}px;"
    if background:
        container_style += f" background-color: {background};"

    if not items:
        return f'<div style="{container_style}"></div>'

    rendered_items: List[str] = []
    from dataface.render.html import _build_style_string

    for item in items:
        # Use pre-calculated height if available, otherwise use content-aware fallback
        if item.height > 0:
            item_height = item.height
        else:
            item_height = _get_item_content_height(item, gap)

        item_width = item.width if item.width > 0 else available_width
        item_html = render_layout_item_html(
            item, executor, variables, item_width, item_height
        )

        if item_html:
            # Base style for dimensions and layout
            style = f"width: {item_width}px; height: {item_height}px; margin-bottom: {gap}px; flex-shrink: 0;"

            # Apply styles from nested board if present
            if item.type == "board" and item.board:
                item_style = _build_style_string(item.board.style)
                if item_style:
                    style += " " + item_style

            rendered_items.append(f'<div style="{style}">{item_html}</div>')

    return f'<div style="{container_style}">{"".join(rendered_items)}</div>'

Columns Layout

render_cols_layout_svg

render_cols_layout_svg(items: List[LayoutItem], executor: Executor, variables: VariableValues, available_width: float, available_height: float, gap: float, background: Optional[str] = None) -> str

Render items in horizontal distribution as SVG.

In a cols layout, all items get the SAME height (max of content heights). This ensures proper alignment for nested layouts. For example, a nested rows layout on the right will have its full allocated height to work with.

PARAMETER DESCRIPTION
items

Layout items to render

TYPE: List[LayoutItem]

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available container width

TYPE: float

available_height

Available container height (upper bound)

TYPE: float

gap

Gap between items

TYPE: float

background

Optional background color

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
str

SVG string for cols layout

Source code in dataface/render/layouts.py
def render_cols_layout_svg(
    items: List[LayoutItem],
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float,
    gap: float,
    background: Optional[str] = None,
) -> str:
    """Render items in horizontal distribution as SVG.

    In a cols layout, all items get the SAME height (max of content heights).
    This ensures proper alignment for nested layouts. For example, a nested
    rows layout on the right will have its full allocated height to work with.

    Args:
        items: Layout items to render
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available container width
        available_height: Available container height (upper bound)
        gap: Gap between items
        background: Optional background color

    Returns:
        SVG string for cols layout
    """
    from dataface.render.renderer import _render_layout_item
    from dataface.compile.sizing import _get_item_content_height, MIN_CONTENT_HEIGHT
    import re

    if not items:
        return f'<svg width="{available_width}" height="{available_height}" viewBox="0 0 {available_width} {available_height}"></svg>'

    # Calculate default item widths (equal distribution)
    num_items = len(items)
    total_gap = gap * (num_items - 1)
    default_item_width = (available_width - total_gap) / num_items

    # Find estimated row height (max of content heights)
    max_content_height = MIN_CONTENT_HEIGHT
    for item in items:
        if item.height > 0:
            max_content_height = max(max_content_height, item.height)
        else:
            item_w = item.width if item.width > 0 else default_item_width
            max_content_height = max(
                max_content_height, _get_item_content_height(item, gap, item_w)
            )

    estimated_row_height = min(max_content_height, available_height)

    # Render all items and track actual heights
    rendered_svgs: List[tuple[str, float, float]] = []  # (svg, x_pos, actual_height)
    current_x = 0.0
    max_actual_height = MIN_CONTENT_HEIGHT

    for item in items:
        item_w = item.width if item.width > 0 else default_item_width
        item_h = item.height if item.height > 0 else estimated_row_height

        item_svg = _render_layout_item(item, executor, variables, item_w, item_h)

        if item_svg:
            # Extract actual rendered height
            actual_height = item_h
            height_match = re.search(r'height="([0-9.]+)"', item_svg)
            if height_match:
                actual_height = float(height_match.group(1))
            max_actual_height = max(max_actual_height, actual_height)

            rendered_svgs.append((item_svg, current_x, actual_height))
            current_x += item_w + gap

    # Use the max actual height as the row height
    row_height = max_actual_height

    # Build final SVG with all items
    rendered_items: List[str] = []
    for item_svg, x_pos, _ in rendered_svgs:
        rendered_items.append(f'<g transform="translate({x_pos}, 0)">{item_svg}</g>')

    bg_rect = ""
    if background:
        bg_rect = f'<rect x="0" y="0" width="{available_width}" height="{row_height}" fill="{html.escape(background)}" rx="4"/>'

    return f"""<svg width="{available_width}" height="{row_height}" viewBox="0 0 {available_width} {row_height}">
{bg_rect}
{"".join(rendered_items)}
</svg>"""

Grid Layout

render_grid_layout_svg

render_grid_layout_svg(items: List[LayoutItem], executor: Executor, variables: VariableValues, available_width: float, available_height: float, columns: int, gap: float, background: Optional[str] = None) -> str

Render items in positioned grid as SVG.

Grid items have explicit x, y positions and width, height spans. Each item's dimensions are calculated based on the grid columns/rows.

PARAMETER DESCRIPTION
items

Layout items with grid positions (x, y, width, height)

TYPE: List[LayoutItem]

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available container width

TYPE: float

available_height

Available container height

TYPE: float

columns

Number of grid columns

TYPE: int

gap

Gap between grid cells

TYPE: float

background

Optional background color

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
str

SVG string for grid layout

Source code in dataface/render/layouts.py
def render_grid_layout_svg(
    items: List[LayoutItem],
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float,
    columns: int,
    gap: float,
    background: Optional[str] = None,
) -> str:
    """Render items in positioned grid as SVG.

    Grid items have explicit x, y positions and width, height spans.
    Each item's dimensions are calculated based on the grid columns/rows.

    Args:
        items: Layout items with grid positions (x, y, width, height)
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available container width
        available_height: Available container height
        columns: Number of grid columns
        gap: Gap between grid cells
        background: Optional background color

    Returns:
        SVG string for grid layout
    """
    from dataface.render.renderer import _render_layout_item

    if not items:
        return f'<svg width="{available_width}" height="{available_height}" viewBox="0 0 {available_width} {available_height}"></svg>'

    # Calculate column width
    total_gap_x = gap * (columns - 1)
    col_width = (available_width - total_gap_x) / columns

    # Calculate row height based on the number of rows in the grid
    max_row_end = 1
    for item in items:
        grid_row = item.row or 0
        grid_row_span = item.row_span or 1
        max_row_end = max(max_row_end, grid_row + grid_row_span)

    total_gap_y = gap * (max_row_end - 1)
    row_height = (available_height - total_gap_y) / max_row_end

    rendered_items: List[str] = []

    for item in items:
        # Get grid position and span
        grid_col = item.col or 0
        grid_row = item.row or 0
        grid_col_span = item.col_span or 1
        grid_row_span = item.row_span or 1

        # Use pre-calculated pixel position if available, otherwise calculate
        pixel_x = item.x if item.x > 0 else (grid_col * col_width) + (grid_col * gap)
        pixel_y = item.y if item.y > 0 else (grid_row * row_height) + (grid_row * gap)

        # Use pre-calculated dimensions if available, otherwise calculate
        item_w = (
            item.width
            if item.width > 0
            else (col_width * grid_col_span) + (gap * (grid_col_span - 1))
        )
        item_h = (
            item.height
            if item.height > 0
            else (row_height * grid_row_span) + (gap * (grid_row_span - 1))
        )

        item_svg = _render_layout_item(item, executor, variables, item_w, item_h)

        if item_svg:
            rendered_items.append(
                f'<g transform="translate({pixel_x}, {pixel_y})">{item_svg}</g>'
            )

    bg_rect = ""
    if background:
        bg_rect = f'<rect x="0" y="0" width="{available_width}" height="{available_height}" fill="{html.escape(background)}" rx="4"/>'

    return f"""<svg width="{available_width}" height="{available_height}" viewBox="0 0 {available_width} {available_height}">
{bg_rect}
{"".join(rendered_items)}
</svg>"""

Tabs Layout

render_tabs_layout_svg

render_tabs_layout_svg(items: List[LayoutItem], executor: Executor, variables: VariableValues, available_width: float, available_height: float, tab_titles: Optional[List[str]] = None, active_tab: int = 0, tab_position: str = 'top', background: Optional[str] = None) -> str

Render tabbed container as SVG (active tab only).

In a tabs layout, each tab gets the full container size minus the tab bar. Only the active tab is rendered in SVG output.

PARAMETER DESCRIPTION
items

Layout items (one per tab)

TYPE: List[LayoutItem]

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available container width

TYPE: float

available_height

Available container height

TYPE: float

tab_titles

Optional titles for tabs

TYPE: Optional[List[str]] DEFAULT: None

active_tab

Index of active tab (0-based)

TYPE: int DEFAULT: 0

tab_position

Position of tabs ("top" or "left")

TYPE: str DEFAULT: 'top'

background

Optional background color

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
str

SVG string for tabs layout (showing only active tab)

Source code in dataface/render/layouts.py
def render_tabs_layout_svg(
    items: List[LayoutItem],
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float,
    tab_titles: Optional[List[str]] = None,
    active_tab: int = 0,
    tab_position: str = "top",
    background: Optional[str] = None,
) -> str:
    """Render tabbed container as SVG (active tab only).

    In a tabs layout, each tab gets the full container size minus the tab bar.
    Only the active tab is rendered in SVG output.

    Args:
        items: Layout items (one per tab)
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available container width
        available_height: Available container height
        tab_titles: Optional titles for tabs
        active_tab: Index of active tab (0-based)
        tab_position: Position of tabs ("top" or "left")
        background: Optional background color

    Returns:
        SVG string for tabs layout (showing only active tab)
    """
    from dataface.render.renderer import _render_layout_item

    if not items:
        return f'<svg width="{available_width}" height="{available_height}" viewBox="0 0 {available_width} {available_height}"></svg>'

    # Render only active tab
    tab_bar_height = 40.0
    content_height = (
        available_height - tab_bar_height if tab_position == "top" else available_height
    )
    content_y = tab_bar_height if tab_position == "top" else 0.0

    active_item = items[min(active_tab, len(items) - 1)]
    item_svg = _render_layout_item(
        active_item, executor, variables, available_width, content_height
    )

    # Render tab bar
    tab_bar_svg = ""
    if tab_titles and len(tab_titles) == len(items):
        tab_width = available_width / len(items)
        for idx, title in enumerate(tab_titles):
            x_pos = idx * tab_width
            is_active = idx == active_tab
            fill_color = "#e0e0e0" if is_active else "#f5f5f5"
            text_color = "#1a1a1a" if is_active else "#666666"
            escaped_title = html.escape(title)

            tab_bar_svg += f"""
            <rect x="{x_pos}" y="0" width="{tab_width}" height="{tab_bar_height}" fill="{fill_color}" stroke="#d0d0d0" stroke-width="1"/>
            <text x="{x_pos + tab_width / 2}" y="{tab_bar_height / 2 + 5}" text-anchor="middle" font-size="14" fill="{text_color}" font-weight="{'600' if is_active else '400'}">{escaped_title}</text>
            """
    else:
        # Generate default tab titles
        tab_width = available_width / len(items)
        for idx in range(len(items)):
            x_pos = idx * tab_width
            is_active = idx == active_tab
            fill_color = "#e0e0e0" if is_active else "#f5f5f5"
            text_color = "#1a1a1a" if is_active else "#666666"
            tab_title = f"Tab {idx + 1}"

            tab_bar_svg += f"""
            <rect x="{x_pos}" y="0" width="{tab_width}" height="{tab_bar_height}" fill="{fill_color}" stroke="#d0d0d0" stroke-width="1"/>
            <text x="{x_pos + tab_width / 2}" y="{tab_bar_height / 2 + 5}" text-anchor="middle" font-size="14" fill="{text_color}" font-weight="{'600' if is_active else '400'}">{tab_title}</text>
            """

    content_svg = ""
    if item_svg:
        content_svg = f'<g transform="translate(0, {content_y})">{item_svg}</g>'

    bg_rect = ""
    if background:
        bg_rect = f'<rect x="0" y="0" width="{available_width}" height="{available_height}" fill="{html.escape(background)}" rx="4"/>'

    return f"""<svg width="{available_width}" height="{available_height}" viewBox="0 0 {available_width} {available_height}">
{bg_rect}
{tab_bar_svg}
{content_svg}
</svg>"""

Vega-Lite Integration

Charts are rendered using Vega-Lite specifications.

generate_vega_lite_spec

generate_vega_lite_spec

generate_vega_lite_spec(chart: Union[CompiledChart, Any], data: List[Dict[str, Any]], width: Optional[float] = None, height: Optional[float] = None) -> Dict[str, Any]

Generate a Vega-Lite specification from a chart definition and data.

PARAMETER DESCRIPTION
chart

CompiledChart definition (or legacy Chart type)

TYPE: Union[CompiledChart, Any]

data

List of dicts containing chart data

TYPE: List[Dict[str, Any]]

width

Optional explicit width in pixels

TYPE: Optional[float] DEFAULT: None

height

Optional explicit height in pixels

TYPE: Optional[float] DEFAULT: None

RETURNS DESCRIPTION
Dict[str, Any]

Vega-Lite specification dictionary

Source code in dataface/render/vega_lite.py
def generate_vega_lite_spec(
    chart: Union[CompiledChart, Any],
    data: List[Dict[str, Any]],
    width: Optional[float] = None,
    height: Optional[float] = None,
) -> Dict[str, Any]:
    """Generate a Vega-Lite specification from a chart definition and data.

    Args:
        chart: CompiledChart definition (or legacy Chart type)
        data: List of dicts containing chart data
        width: Optional explicit width in pixels
        height: Optional explicit height in pixels

    Returns:
        Vega-Lite specification dictionary
    """
    if not isinstance(data, list):
        data = list(data) if data else []

    data = _normalize_data_types(data)

    spec: Dict[str, Any] = {
        "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
        "background": None,
        "data": {"values": data},
    }

    # Apply explicit dimensions if provided
    # Use "fit" autosize mode to fill container while respecting dimensions
    if width is not None and width > 0:
        # Use full width - Vega-Lite will handle padding for axis labels
        spec["width"] = width

    if height is not None and height > 0:
        # Use full height - Vega-Lite will handle padding for title and axis labels
        spec["height"] = height

    # Use "fit" autosize with "contains": "padding" to fit chart within
    # specified dimensions while including space for axis labels and legends
    spec["autosize"] = {"type": "fit", "contains": "padding"}

    chart_type_map: Dict[str, str] = {
        # Direct mappings
        "bar": "bar",
        "line": "line",
        "area": "area",
        "point": "point",
        "circle": "circle",
        "square": "square",
        "text": "text",
        "tick": "tick",
        "rule": "rule",
        "trail": "trail",
        "rect": "rect",
        "arc": "arc",
        # Composite marks
        "boxplot": "boxplot",
        "errorbar": "errorbar",
        "errorband": "errorband",
        # Special marks
        "geoshape": "geoshape",
        "image": "image",
        # Aliases
        "scatter": "point",
        "heatmap": "rect",
        "pie": "arc",
        "donut": "arc",
        "histogram": "bar",
        # Dataface-specific (handled separately)
        "table": "table",
        "kpi": "text",
    }

    chart_type = getattr(chart, "type", "bar")
    vega_type = chart_type_map.get(chart_type, "bar")

    # Handle special chart types that need custom generation
    if chart_type == "kpi":
        return _generate_kpi_spec(chart, data, width=width, height=height)
    elif chart_type == "table":
        return _generate_table_spec(chart, data)
    elif chart_type == "histogram":
        return _generate_histogram_spec(chart, data, spec, width, height)
    elif chart_type == "boxplot":
        return _generate_boxplot_spec(chart, data, spec)
    elif chart_type in ("errorbar", "errorband"):
        return _generate_error_spec(chart, data, spec, chart_type)
    elif chart_type in ("arc", "pie", "donut"):
        return _generate_arc_spec(chart, data, spec, chart_type, width, height)
    elif chart_type in ("rect", "square", "heatmap"):
        return _generate_rect_spec(chart, data, spec, chart_type, width, height)

    encoding: Dict[str, Any] = {}
    columns = set(data[0].keys()) if data else set()

    x_field = getattr(chart, "x", None)
    if x_field:
        encoding["x"] = {
            "field": x_field,
            "type": (
                _infer_vega_type_from_data(data, x_field)
                if x_field in columns
                else "nominal"
            ),
            "title": slug_to_title(x_field),
            "axis": _get_smart_x_axis_config(data, x_field, width),
        }
        if chart_type in ["bar", "line", "area"]:
            encoding["x"]["sort"] = None

    y_field = getattr(chart, "y", None)
    if y_field:
        if isinstance(y_field, list):
            return _generate_layered_spec(chart, data, encoding, width, height)
        else:
            encoding["y"] = {
                "field": y_field,
                "type": "quantitative",
                "title": slug_to_title(y_field),
            }

    color_field = getattr(chart, "color", None)
    if color_field:
        encoding["color"] = {
            "field": color_field,
            "type": (
                _infer_vega_type_from_data(data, color_field)
                if color_field in columns
                else "nominal"
            ),
        }

    size_field = getattr(chart, "size", None)
    if size_field:
        encoding["size"] = {"field": size_field, "type": "quantitative"}

    shape_field = getattr(chart, "shape", None)
    if shape_field:
        encoding["shape"] = {"field": shape_field, "type": "nominal"}

    # For line and area charts, use interactive crosshair tooltip
    if chart_type in ["line", "area"] and x_field and y_field:
        return _generate_line_chart_with_crosshair(
            chart,
            data,
            spec,
            encoding,
            x_field,
            y_field,
            color_field,
            vega_type,
            width,
            height,
        )

    # Enable tooltips for interactive charts
    spec["mark"] = {"type": vega_type, "tooltip": True}
    spec["encoding"] = encoding

    # Add tooltip encoding with all relevant fields
    tooltip_fields = _build_tooltip_encoding(encoding, data)
    if tooltip_fields:
        spec["encoding"]["tooltip"] = tooltip_fields

    style = getattr(chart, "style", None)
    if style:
        _apply_style(spec, style)

    title = getattr(chart, "title", None)
    if title:
        spec["title"] = {"text": title, "anchor": "start", "align": "left"}

    return spec

render_chart

render_chart

render_chart(chart: Union[CompiledChart, Any], data: List[Dict[str, Any]], format: str = 'json', width: Optional[float] = None, height: Optional[float] = None) -> str

Render a chart to the specified format.

PARAMETER DESCRIPTION
chart

CompiledChart definition

TYPE: Union[CompiledChart, Any]

data

List of dicts containing chart data

TYPE: List[Dict[str, Any]]

format

Output format ('json', 'svg', 'png', 'pdf')

TYPE: str DEFAULT: 'json'

width

Optional explicit width in pixels

TYPE: Optional[float] DEFAULT: None

height

Optional explicit height in pixels

TYPE: Optional[float] DEFAULT: None

RETURNS DESCRIPTION
str

Rendered chart as string

RAISES DESCRIPTION
ValueError

If format is not supported

ImportError

If vl-convert-python is not installed for non-JSON formats

Source code in dataface/render/vega_lite.py
def render_chart(
    chart: Union[CompiledChart, Any],
    data: List[Dict[str, Any]],
    format: str = "json",
    width: Optional[float] = None,
    height: Optional[float] = None,
) -> str:
    """Render a chart to the specified format.

    Args:
        chart: CompiledChart definition
        data: List of dicts containing chart data
        format: Output format ('json', 'svg', 'png', 'pdf')
        width: Optional explicit width in pixels
        height: Optional explicit height in pixels

    Returns:
        Rendered chart as string

    Raises:
        ValueError: If format is not supported
        ImportError: If vl-convert-python is not installed for non-JSON formats
    """
    # Handle table charts specially - they render directly to SVG
    chart_type = getattr(chart, "type", "bar")
    if chart_type == "table":
        if format == "svg":
            return render_table_svg(chart, data, width=width, height=height)
        elif format == "json":
            # Return a simple JSON representation for tables
            import json

            if not isinstance(data, list):
                data = list(data) if data else []
            data = _normalize_data_types(data)

            return json.dumps(
                {
                    "type": "table",
                    "title": getattr(chart, "title", None),
                    "columns": list(data[0].keys()) if data else [],
                    "data": data,
                    "width": width,
                    "height": height,
                },
                indent=2,
            )
        elif format in ["png", "pdf"]:
            # For PNG/PDF, render SVG first then convert
            svg_content = render_table_svg(chart, data, width=width, height=height)
            try:
                import cairosvg  # type: ignore[import-untyped]

                if format == "png":
                    import base64

                    png_bytes = cairosvg.svg2png(
                        bytestring=svg_content.encode(), scale=2
                    )
                    return base64.b64encode(png_bytes).decode("utf-8")
                else:  # pdf
                    import base64

                    pdf_bytes = cairosvg.svg2pdf(bytestring=svg_content.encode())
                    return base64.b64encode(pdf_bytes).decode("utf-8")
            except ImportError:
                raise ImportError(
                    f"cairosvg is required for {format} table rendering. "
                    "Install with: pip install cairosvg"
                )
        else:
            raise ValueError(
                f"Unsupported format: {format}. Supported: json, svg, png, pdf"
            )

    # Standard Vega-Lite chart rendering
    spec = generate_vega_lite_spec(chart, data, width=width, height=height)

    if format == "json":
        import json

        return json.dumps(spec, indent=2)
    elif format in ["svg", "png", "pdf"]:
        try:
            import vl_convert as vlc
        except ImportError:
            raise ImportError(
                f"vl-convert-python is required for {format} rendering. "
                "Install with: pip install vl-convert-python"
            )

        if format == "svg":
            return vlc.vegalite_to_svg(spec)
        elif format == "png":
            import base64

            png_bytes = vlc.vegalite_to_png(spec, scale=2)
            return base64.b64encode(png_bytes).decode("utf-8")
        else:  # pdf
            import base64

            pdf_bytes = vlc.vegalite_to_pdf(spec)
            return base64.b64encode(pdf_bytes).decode("utf-8")

    raise ValueError(f"Unsupported format: {format}. Supported: json, svg, png, pdf")

HTML Output

For rendering interactive HTML dashboards:

render_board_html

render_board_html

render_board_html(board: CompiledBoard, background: Optional[str] = None, executor: Optional[Executor] = None, variables: Optional[VariableValues] = None, width: Optional[float] = None, **options: Any) -> str

Render a compiled board as interactive HTML.

PARAMETER DESCRIPTION
board

CompiledBoard to render

TYPE: CompiledBoard

background

Optional background color

TYPE: Optional[str] DEFAULT: None

executor

Executor for query execution (required)

TYPE: Optional[Executor] DEFAULT: None

variables

Variable values for queries

TYPE: Optional[VariableValues] DEFAULT: None

width

Optional width override

TYPE: Optional[float] DEFAULT: None

**options

Additional rendering options

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
str

Complete HTML document string

Source code in dataface/render/html.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
def render_board_html(
    board: CompiledBoard,
    background: Optional[str] = None,
    executor: Optional[Executor] = None,
    variables: Optional[VariableValues] = None,
    width: Optional[float] = None,
    **options: Any,
) -> str:
    """Render a compiled board as interactive HTML.

    Args:
        board: CompiledBoard to render
        background: Optional background color
        executor: Executor for query execution (required)
        variables: Variable values for queries
        width: Optional width override
        **options: Additional rendering options

    Returns:
        Complete HTML document string
    """
    from dataface.compile.config import get_config
    from dataface.render.layouts import (
        render_rows_layout_html,
        render_cols_layout_html,
        render_grid_layout_html,
        render_tabs_layout_html,
    )

    if executor is None:
        raise ValueError("Executor is required for HTML rendering")

    # Use pre-computed variable registry from board if available (built during normalization)
    # Otherwise fall back to building it here (for backward compatibility)
    from dataface.render.variable_registry import (
        build_variable_registry_from_board,
        build_variable_types_from_registry,
        build_variable_defaults_from_registry,
    )

    if board.variable_registry is not None:
        variable_registry = board.variable_registry
    else:
        variable_registry = build_variable_registry_from_board(board)
    variable_types = build_variable_types_from_registry(variable_registry)
    variable_defaults = build_variable_defaults_from_registry(variable_registry)

    if variables is None:
        # Start with all variables from global registry (set to None if no default)
        # This ensures all variables are in context for template resolution
        variables = {}
        for var_name in variable_types.keys():
            variables[var_name] = None
        # Override with defaults
        variables.update(variable_defaults)
    else:
        # Validate variables from URL parameters
        from dataface.render.validation import validate_variables

        validated_vars, validation_errors = validate_variables(
            variables, variable_types
        )
        if validation_errors:
            # Log validation errors (could be shown to user in future)
            import warnings

            for error in validation_errors:
                warnings.warn(f"Variable validation: {error}", UserWarning)

        # Merge validated variables with defaults
        all_vars = {}
        for var_name in variable_types.keys():
            all_vars[var_name] = None
        all_vars.update(variable_defaults)
        all_vars.update(validated_vars)
        variables = all_vars

    config = get_config()
    board_width = width or board.layout.width or config.board.width
    board_height = board.layout.height or 600.0
    gap = config.board.row_gap

    # Import sizing constants for consistency
    from dataface.compile.sizing import DEFAULT_TITLE_HEIGHT, estimate_title_height

    # Render title
    title_html = ""
    if board.title:
        from dataface.compile.jinja import resolve_jinja_template

        resolved_title = board.title
        if "{{" in board.title:
            resolved_title = resolve_jinja_template(board.title, variables)
        escaped_title = html_module.escape(resolved_title)
        title_html = f'<h1 style="font-size: {_get_title_size(board.depth)}px; margin: 0 0 20px 0; font-weight: 600; color: #1a1a1a;">{escaped_title}</h1>'

    # Render content (markdown) if present
    content_html = ""
    if board.content:
        from dataface.compile.jinja import resolve_jinja_template
        import markdown  # type: ignore[import-untyped]

        resolved_content = board.content
        if "{{" in board.content or "{%" in board.content:
            resolved_content = resolve_jinja_template(board.content, variables)
        # Convert markdown to HTML
        content_html = f'<div class="dashboard-content-markdown" style="margin-bottom: 20px; line-height: 1.6;">{markdown.markdown(resolved_content, extensions=["tables", "fenced_code"])}</div>'

    # Render variable controls
    variables_html = ""
    if board.variables:
        variables_html = render_variables_html(board.variables, variables)

    # Calculate layout area (subtract padding and title space)
    layout_width = board_width - 40  # 20px padding on each side
    # Use consistent title height calculation from sizing module
    if board.title:
        title_height = estimate_title_height(board.title, layout_width, board.depth)
        title_height = max(title_height, DEFAULT_TITLE_HEIGHT) + 20.0  # Add margin
    else:
        title_height = 20.0  # Just padding
    variables_height = 60.0 if board.variables else 0.0  # Variables section height
    layout_height = board_height - title_height - variables_height

    # Render layout based on type
    layout_html = ""

    if board.layout.type == "rows":
        layout_html = render_rows_layout_html(
            board.layout.items,
            executor,
            variables,
            layout_width,
            layout_height,
            gap,
            board.style.get("background"),
        )
    elif board.layout.type == "cols":
        layout_html = render_cols_layout_html(
            board.layout.items,
            executor,
            variables,
            layout_width,
            layout_height,
            gap,
            board.style.get("background"),
        )
    elif board.layout.type == "grid":
        columns = board.layout.columns or config.layout.grid_columns
        layout_html = render_grid_layout_html(
            board.layout.items,
            executor,
            variables,
            layout_width,
            layout_height,
            columns,
            gap,
            board.style.get("background"),
        )
    elif board.layout.type == "tabs":
        layout_html = render_tabs_layout_html(
            board.layout.items,
            executor,
            variables,
            layout_width,
            layout_height,
            board.layout.tab_titles,
            board.layout.default_tab or 0,
            board.layout.tab_position or "top",
            board.style.get("background"),
        )
    else:
        # Fallback to rows
        layout_html = render_rows_layout_html(
            board.layout.items,
            executor,
            variables,
            layout_width,
            layout_height,
            gap,
            board.style.get("background"),
        )

    # Build HTML document
    bg_style = f"background-color: {background};" if background else ""
    # Resolve Jinja in page title
    from dataface.compile.jinja import resolve_jinja_template

    page_title = board.title or "Dashboard"
    if "{{" in page_title or "{%" in page_title:
        page_title = resolve_jinja_template(page_title, variables)
    escaped_title = html_module.escape(page_title)

    return f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{escaped_title}</title>
    <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
    <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
    <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
    <style>
        * {{
            box-sizing: border-box;
        }}
        body {{
            margin: 0;
            padding: 20px;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            {bg_style}
        }}
        .dashboard-wrapper {{
            transform-origin: top left;
            min-width: {board_width+40}px;
        }}
        .dashboard-container {{
            width: {board_width}px;
        }}
        .dashboard-title {{
            margin-bottom: 20px;
        }}
        .dashboard-content {{
            width: 100%;
        }}
        .dashboard-variables {{
            margin-bottom: 20px;
            padding: 15px;
            background: #f8f9fa;
            border-radius: 6px;
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            align-items: center;
        }}
        .variable-control {{
            display: flex;
            align-items: center;
            gap: 8px;
        }}
        .variable-control label {{
            font-weight: 500;
            color: #495057;
            font-size: 14px;
        }}
        .variable-control select,
        .variable-control input[type="text"],
        .variable-control input[type="number"],
        .variable-control input[type="date"] {{
            padding: 6px 10px;
            border: 1px solid #ced4da;
            border-radius: 4px;
            font-size: 14px;
            font-family: inherit;
        }}
        .variable-control input[type="range"] {{
            width: 150px;
        }}
        .variable-control input[type="checkbox"] {{
            width: 18px;
            height: 18px;
            cursor: pointer;
        }}
        .dashboard-content-markdown {{
            color: #333;
        }}
        .dashboard-content-markdown h1 {{
            font-size: 28px;
            margin: 0 0 16px 0;
            font-weight: 600;
        }}
        .dashboard-content-markdown h2 {{
            font-size: 22px;
            margin: 20px 0 12px 0;
            font-weight: 600;
        }}
        .dashboard-content-markdown h3 {{
            font-size: 18px;
            margin: 16px 0 8px 0;
            font-weight: 600;
        }}
        .dashboard-content-markdown p {{
            margin: 0 0 12px 0;
        }}
        .dashboard-content-markdown a {{
            color: #2563eb;
            text-decoration: none;
        }}
        .dashboard-content-markdown a:hover {{
            text-decoration: underline;
        }}
        .dashboard-content-markdown code {{
            background: #f3f4f6;
            padding: 2px 6px;
            border-radius: 4px;
            font-family: 'Monaco', 'Menlo', monospace;
            font-size: 0.9em;
        }}
        .dashboard-content-markdown pre {{
            background: #f3f4f6;
            padding: 12px 16px;
            border-radius: 6px;
            overflow-x: auto;
        }}
        .dashboard-content-markdown ul, .dashboard-content-markdown ol {{
            margin: 0 0 12px 0;
            padding-left: 24px;
        }}
        .dashboard-content-markdown li {{
            margin: 4px 0;
        }}
        .dashboard-content-markdown table {{
            border-collapse: collapse;
            width: 100%;
            margin: 12px 0;
        }}
        .dashboard-content-markdown th, .dashboard-content-markdown td {{
            border: 1px solid #e5e7eb;
            padding: 8px 12px;
            text-align: left;
        }}
        .dashboard-content-markdown th {{
            background: #f9fafb;
            font-weight: 600;
        }}
        .dataface-chart {{
            position: relative;
        }}
        .dataface-chart.loading {{
            opacity: 0.5;
            pointer-events: none;
        }}
        .dataface-chart.loading::after {{
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 28px;
            height: 28px;
            margin: -14px 0 0 -14px;
            border: 3px solid #e0e0e0;
            border-top-color: #3498db;
            border-radius: 50%;
            animation: dataface-spin 0.8s linear infinite;
            z-index: 100;
        }}
        @keyframes dataface-spin {{
            to {{ transform: rotate(360deg); }}
        }}
        .dataface-chart-chat-btn {{
            position: absolute;
            top: 8px;
            right: 8px;
            background: rgba(255, 107, 53, 0.9);
            color: white;
            border: none;
            border-radius: 4px;
            width: 32px;
            height: 32px;
            cursor: pointer;
            font-size: 16px;
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 10;
            opacity: 0;
            transition: opacity 0.2s, background 0.2s;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
        }}
        .dataface-chart:hover .dataface-chart-chat-btn {{
            opacity: 1;
        }}
        .dataface-chart-chat-btn:hover {{
            background: rgba(255, 107, 53, 1);
            transform: scale(1.1);
        }}
        /* Hide vega-embed action menu by default, show on hover */
        .dataface-chart .vega-embed details {{
            opacity: 0;
            transition: opacity 0.2s;
        }}
        .dataface-chart:hover .vega-embed details {{
            opacity: 1;
        }}
        /* Highlight charts when hovering over their dependent variable controls */
        .dataface-chart.var-highlight {{
            outline: 3px solid #667eea;
            outline-offset: 2px;
            border-radius: 4px;
            transition: outline 0.15s ease-in-out;
        }}
        .variable-control:hover {{
            cursor: pointer;
        }}
    </style>
</head>
<body>
    <div class="dashboard-wrapper" id="dashboard-wrapper">
        <div class="dashboard-container">
            {f'<div class="dashboard-title">{title_html}</div>' if title_html else ''}
            {content_html if content_html else ''}
            {variables_html if variables_html else ''}
            <div class="dashboard-content">
                {layout_html}
            </div>
        </div>
    </div>
    <script>
        // Variable types for validation (built from board tree)
        const variableTypes = {json.dumps(variable_types)};

        // Auto-scale dashboard to fit viewport width
        function scaleDashboard() {{
            const wrapper = document.getElementById('dashboard-wrapper');
            const container = wrapper.querySelector('.dashboard-container');
            const contentWidth = {board_width};
            const viewportWidth = window.innerWidth - 40; // Account for body padding

            if (contentWidth > viewportWidth && viewportWidth > 0) {{
                const scale = viewportWidth / contentWidth;
                wrapper.style.transform = 'scale(' + scale + ')';
                // Adjust body height to account for scaled content
                document.body.style.minHeight = (container.offsetHeight * scale) + 'px';
            }} else {{
                wrapper.style.transform = 'scale(1)';
                document.body.style.minHeight = 'auto';
            }}
        }}

        // Scale on load and resize
        window.addEventListener('load', scaleDashboard);
        window.addEventListener('resize', scaleDashboard);
        // Also scale after a short delay for Vega charts to render
        setTimeout(scaleDashboard, 500);

        // Variable change handling
        function getAllVariableValues() {{
            // Collect all current variable values from the form controls
            const allVars = new Object();

            // Get all variable controls
            document.querySelectorAll('[data-variable]').forEach(function(control) {{
                const name = control.getAttribute('data-variable');
                const part = control.getAttribute('data-part');

                if (part) {{
                    // Date range - collect both parts
                    if (!allVars[name]) {{
                        const startInput = document.querySelector('input[data-variable="' + name + '"][data-part="start"]');
                        const endInput = document.querySelector('input[data-variable="' + name + '"][data-part="end"]');
                        if (startInput && endInput) {{
                            allVars[name] = [startInput.value, endInput.value];
                        }}
                    }}
                }} else {{
                    // Regular control
                    if (control.type === 'checkbox') {{
                        allVars[name] = control.checked;
                    }} else if (control.type === 'range') {{
                        allVars[name] = parseFloat(control.value);
                    }} else if (control.type === 'number') {{
                        allVars[name] = control.value ? parseFloat(control.value) : null;
                    }} else {{
                        allVars[name] = control.value;
                    }}
                }}
            }});

            return allVars;
        }}

        function updateVariable(name, value) {{
            // Mark all charts that depend on this variable as loading
            document.querySelectorAll('[data-var-' + name + ']').forEach(function(el) {{
                el.classList.add('loading');
            }});

            // Update URL parameters (preserves existing params)
            const url = new URL(window.location);
            url.searchParams.set(name, value);

            // If in iframe, notify parent (playground) with ALL current variables
            if (window.parent !== window) {{
                const allVars = getAllVariableValues();
                window.parent.postMessage({{
                    type: 'dataface-variable-change',
                    variables: allVars
                }}, '*');
            }} else {{
                // Standalone mode: reload page with new URL
                window.location.href = url.toString();
            }}
        }}

        // Validate a variable value against its expected type
        function validateVariableValue(varName, value, varType) {{
            if (value === null || value === undefined) {{
                return {{ valid: true }};
            }}

            // String types
            const stringTypes = ['select', 'multiselect', 'input', 'text', 'textarea', 'radio'];
            if (stringTypes.includes(varType)) {{
                if (typeof value !== 'string') {{
                    return {{ valid: false, error: `Variable '${{varName}}' (type: ${{varType}}) expects a string` }};
                }}
                return {{ valid: true }};
            }}

            // Number types
            const numberTypes = ['number', 'slider', 'range'];
            if (numberTypes.includes(varType)) {{
                const numValue = typeof value === 'string' ? parseFloat(value) : value;
                if (isNaN(numValue)) {{
                    return {{ valid: false, error: `Variable '${{varName}}' (type: ${{varType}}) expects a number` }};
                }}
                return {{ valid: true }};
            }}

            // Boolean type
            if (varType === 'checkbox') {{
                if (typeof value === 'boolean') {{
                    return {{ valid: true }};
                }}
                if (typeof value === 'string' && ['true', 'false', '1', '0'].includes(value.toLowerCase())) {{
                    return {{ valid: true }};
                }}
                return {{ valid: false, error: `Variable '${{varName}}' (type: checkbox) expects a boolean` }};
            }}

            // Date types
            const dateTypes = ['date', 'datepicker'];
            if (dateTypes.includes(varType)) {{
                if (typeof value !== 'string') {{
                    return {{ valid: false, error: `Variable '${{varName}}' (type: ${{varType}}) expects a date string` }};
                }}
                // Basic date format validation (YYYY-MM-DD)
                if (value.length === 10 && value.split('-').length === 3) {{
                    return {{ valid: true }};
                }}
                return {{ valid: false, error: `Variable '${{varName}}' (type: ${{varType}}) expects date format YYYY-MM-DD` }};
            }}

            // Date range type
            if (varType === 'daterange') {{
                if (Array.isArray(value) && value.length === 2) {{
                    return {{ valid: true }};
                }}
                if (typeof value === 'string' && value.startsWith('[')) {{
                    // JSON string, will be parsed
                    return {{ valid: true }};
                }}
                return {{ valid: false, error: `Variable '${{varName}}' (type: daterange) expects an array of 2 dates` }};
            }}

            return {{ valid: true }};
        }}

        // Initialize variables from URL parameters on page load
        function initializeVariablesFromURL() {{
            const urlParams = new URLSearchParams(window.location.search);
            const validationErrors = [];

            // Iterate through all variable controls and set values from URL
            document.querySelectorAll('[data-variable]').forEach(function(control) {{
                const name = control.getAttribute('data-variable');
                const part = control.getAttribute('data-part');
                const urlValue = urlParams.get(name);

                if (urlValue !== null) {{
                    // Get variable type for validation
                    const varType = variableTypes[name];

                    if (part) {{
                        // Date range - parse JSON array
                        try {{
                            const rangeValue = JSON.parse(urlValue);
                            if (Array.isArray(rangeValue) && rangeValue.length >= 2) {{
                                // Validate date range
                                if (varType) {{
                                    const validation = validateVariableValue(name, rangeValue, varType);
                                    if (!validation.valid) {{
                                        validationErrors.push(validation.error);
                                        return;
                                    }}
                                }}
                                if (part === 'start' && rangeValue[0]) {{
                                    control.value = rangeValue[0];
                                }} else if (part === 'end' && rangeValue[1]) {{
                                    control.value = rangeValue[1];
                                }}
                            }}
                        }} catch (e) {{
                            // Not valid JSON, ignore
                            if (varType === 'daterange') {{
                                validationErrors.push(`Variable '${{name}}' (type: daterange) has invalid JSON: ${{urlValue}}`);
                            }}
                        }}
                    }} else {{
                        // Regular control - validate before setting
                        let valueToSet = urlValue;
                        if (varType) {{
                            // Convert value for validation
                            let validationValue = urlValue;
                            if (control.type === 'checkbox') {{
                                validationValue = urlValue === 'true' || urlValue === '1';
                            }} else if (control.type === 'range' || control.type === 'number') {{
                                validationValue = parseFloat(urlValue);
                            }}

                            const validation = validateVariableValue(name, validationValue, varType);
                            if (!validation.valid) {{
                                validationErrors.push(validation.error);
                                return;
                            }}
                        }}

                        // Set the value
                        if (control.type === 'checkbox') {{
                            control.checked = urlValue === 'true' || urlValue === '1';
                        }} else if (control.type === 'range' || control.type === 'number') {{
                            const numValue = parseFloat(urlValue);
                            if (!isNaN(numValue)) {{
                                control.value = urlValue;
                                // Update slider display if present
                                if (control.type === 'range') {{
                                    const valueSpan = control.nextElementSibling;
                                    if (valueSpan && valueSpan.classList.contains('slider-value')) {{
                                        valueSpan.textContent = urlValue;
                                    }}
                                }}
                            }}
                        }} else {{
                            control.value = urlValue;
                        }}
                    }}
                }}
            }});

            // Log validation errors to console
            if (validationErrors.length > 0) {{
                console.warn('Variable validation errors:', validationErrors);
            }}
        }}

        // Attach event listeners to all variable controls
        document.addEventListener('DOMContentLoaded', function() {{
            // Initialize variables from URL first
            initializeVariablesFromURL();

            // Handle select dropdowns
            document.querySelectorAll('select[data-variable]').forEach(function(select) {{
                select.addEventListener('change', function() {{
                    const name = this.getAttribute('data-variable');
                    const value = this.value;
                    updateVariable(name, value);
                }});
            }});

            // Handle text inputs (with debounce)
            let textInputTimeout = null;
            document.querySelectorAll('input[type="text"][data-variable], textarea[data-variable]').forEach(function(input) {{
                input.addEventListener('input', function() {{
                    clearTimeout(textInputTimeout);
                    const name = this.getAttribute('data-variable');
                    const value = this.value;
                    textInputTimeout = setTimeout(function() {{
                        updateVariable(name, value);
                    }}, 500);
                }});
            }});

            // Handle number inputs
            document.querySelectorAll('input[type="number"][data-variable]').forEach(function(input) {{
                input.addEventListener('change', function() {{
                    const name = this.getAttribute('data-variable');
                    const value = this.value;
                    updateVariable(name, value);
                }});
            }});

            // Handle range sliders
            document.querySelectorAll('input[type="range"][data-variable]').forEach(function(input) {{
                const valueSpan = input.nextElementSibling;
                function updateSliderDisplay() {{
                    if (valueSpan && valueSpan.classList.contains('slider-value')) {{
                        valueSpan.textContent = input.value;
                    }}
                }}
                input.addEventListener('input', updateSliderDisplay);
                input.addEventListener('change', function() {{
                    updateSliderDisplay();
                    const name = input.getAttribute('data-variable');
                    const value = input.value;
                    updateVariable(name, value);
                }});
            }});

            // Handle checkboxes
            document.querySelectorAll('input[type="checkbox"][data-variable]').forEach(function(input) {{
                input.addEventListener('change', function() {{
                    const name = this.getAttribute('data-variable');
                    const value = this.checked;
                    updateVariable(name, value);
                }});
            }});

            // Handle date inputs
            document.querySelectorAll('input[type="date"][data-variable]').forEach(function(input) {{
                input.addEventListener('change', function() {{
                    const name = this.getAttribute('data-variable');
                    const part = this.getAttribute('data-part');
                    const value = this.value;

                    if (part) {{
                        // Date range - need to get both values
                        const varName = name;
                        const startInput = document.querySelector('input[data-variable="' + varName + '"][data-part="start"]');
                        const endInput = document.querySelector('input[data-variable="' + varName + '"][data-part="end"]');
                        if (startInput && endInput) {{
                            const rangeValue = [startInput.value, endInput.value];
                            updateVariable(varName, JSON.stringify(rangeValue));
                        }}
                    }} else {{
                        updateVariable(name, value);
                    }}
                }});
            }});

            // Highlight dependent charts when hovering over variable controls
            document.querySelectorAll('.variable-control').forEach(function(control) {{
                const input = control.querySelector('[data-variable]');
                if (!input) return;

                const varName = input.getAttribute('data-variable');
                if (!varName) return;

                control.addEventListener('mouseenter', function() {{
                    // Add highlight to all charts that depend on this variable
                    document.querySelectorAll('[data-var-' + varName + ']').forEach(function(chart) {{
                        chart.classList.add('var-highlight');
                    }});
                }});

                control.addEventListener('mouseleave', function() {{
                    // Remove highlight from all charts
                    document.querySelectorAll('[data-var-' + varName + ']').forEach(function(chart) {{
                        chart.classList.remove('var-highlight');
                    }});
                }});
            }});
        }});
    </script>
</body>
</html>"""

render_layout_item_html

render_layout_item_html

render_layout_item_html(item: LayoutItem, executor: Executor, variables: VariableValues, available_width: float, available_height: float = 300.0) -> str

Render a single layout item as HTML (chart or nested board).

PARAMETER DESCRIPTION
item

Layout item to render

TYPE: LayoutItem

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available width for rendering

TYPE: float

available_height

Available height for rendering

TYPE: float DEFAULT: 300.0

RETURNS DESCRIPTION
str

HTML string for the item

Source code in dataface/render/html.py
def render_layout_item_html(
    item: LayoutItem,
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float = 300.0,
) -> str:
    """Render a single layout item as HTML (chart or nested board).

    Args:
        item: Layout item to render
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available width for rendering
        available_height: Available height for rendering

    Returns:
        HTML string for the item
    """
    import html as html_module

    # Use calculated dimensions if available, otherwise use passed dimensions
    width = item.width if item.width > 0 else available_width
    height = item.height if item.height > 0 else available_height

    if item.type == "chart" and item.chart:
        return render_chart_item_html(item.chart, executor, variables, width, height)
    elif item.type == "board" and item.board:
        # Render nested board using layout functions
        from dataface.render.layouts import (
            render_rows_layout_html,
            render_cols_layout_html,
            render_grid_layout_html,
            render_tabs_layout_html,
        )
        from dataface.compile.config import get_config
        from dataface.compile.sizing import DEFAULT_TITLE_HEIGHT

        config = get_config()
        board = item.board
        board_width = width
        # Use passed height if board doesn't have calculated dimensions
        board_height = board.layout.height if board.layout.height > 0 else height
        gap = config.board.row_gap

        # Merge nested board's variables into the context
        # Variables are global, but nested boards can have local variable controls.
        # Inherit parent variable values, then add any local variables from this board.
        nested_variables = dict(variables) if variables else {}
        if board.variables:
            for var_name in board.variables.keys():
                if var_name not in nested_variables:
                    nested_variables[var_name] = None
        # Override with nested board's defaults
        nested_variables.update(board.variable_defaults)

        # Account for title height (use consistent constant from sizing)
        title_height = DEFAULT_TITLE_HEIGHT if board.title else 0.0
        layout_height = board_height - title_height

        if board.layout.type == "rows":
            layout_html = render_rows_layout_html(
                board.layout.items,
                executor,
                nested_variables,
                board_width,
                layout_height,
                gap,
                board.style.get("background"),
            )
        elif board.layout.type == "cols":
            layout_html = render_cols_layout_html(
                board.layout.items,
                executor,
                nested_variables,
                board_width,
                layout_height,
                gap,
                board.style.get("background"),
            )
        elif board.layout.type == "grid":
            columns = board.layout.columns or config.layout.grid_columns
            layout_html = render_grid_layout_html(
                board.layout.items,
                executor,
                nested_variables,
                board_width,
                layout_height,
                columns,
                gap,
                board.style.get("background"),
            )
        elif board.layout.type == "tabs":
            layout_html = render_tabs_layout_html(
                board.layout.items,
                executor,
                nested_variables,
                board_width,
                layout_height,
                board.layout.tab_titles,
                board.layout.default_tab or 0,
                board.layout.tab_position or "top",
                board.style.get("background"),
            )
        else:
            layout_html = render_rows_layout_html(
                board.layout.items,
                executor,
                nested_variables,
                board_width,
                layout_height,
                gap,
                board.style.get("background"),
            )

        # Wrap in board container
        title_html = ""
        if board.title:
            from dataface.compile.jinja import resolve_jinja_template

            resolved_title = board.title
            if "{{" in board.title:
                resolved_title = resolve_jinja_template(board.title, nested_variables)
            escaped_title = html_module.escape(resolved_title)
            title_html = f'<h2 style="font-size: {_get_title_size(board.depth)}px; margin: 0 0 10px 0;">{escaped_title}</h2>'

        # Render content (markdown) if present
        content_html = ""
        if board.content:
            from dataface.compile.jinja import resolve_jinja_template
            import markdown  # type: ignore[import-untyped]

            resolved_content = board.content
            if "{{" in board.content or "{%" in board.content:
                resolved_content = resolve_jinja_template(
                    board.content, nested_variables
                )
            content_html = f'<div class="dashboard-content-markdown" style="margin-bottom: 15px; line-height: 1.6;">{markdown.markdown(resolved_content, extensions=["tables", "fenced_code"])}</div>'

        # Render nested board's variable controls if it has any
        nested_variables_html = ""
        if board.variables:
            nested_variables_html = render_variables_html(
                board.variables, nested_variables
            )

        # Build style string from board style
        # Boards have NO padding unless explicitly set in style
        style_str = _build_style_string(board.style)

        # Only add wrapper style if there's something to apply
        wrapper_style = f' style="{style_str}"' if style_str else ""

        return f"<div{wrapper_style}>{title_html}{content_html}{nested_variables_html}{layout_html}</div>"
    return ""

render_chart_item_html

render_chart_item_html

render_chart_item_html(chart: CompiledChart, executor: Executor, variables: VariableValues, available_width: float, available_height: float = 300.0) -> str

Render a chart item as HTML with Vega Embed.

PARAMETER DESCRIPTION
chart

CompiledChart to render

TYPE: CompiledChart

executor

Executor for query execution

TYPE: Executor

variables

Variable values for queries

TYPE: VariableValues

available_width

Available width for rendering

TYPE: float

available_height

Available height for rendering

TYPE: float DEFAULT: 300.0

RETURNS DESCRIPTION
str

HTML string with Vega Embed chart

Source code in dataface/render/html.py
def render_chart_item_html(
    chart: CompiledChart,
    executor: Executor,
    variables: VariableValues,
    available_width: float,
    available_height: float = 300.0,
) -> str:
    """Render a chart item as HTML with Vega Embed.

    Args:
        chart: CompiledChart to render
        executor: Executor for query execution
        variables: Variable values for queries
        available_width: Available width for rendering
        available_height: Available height for rendering

    Returns:
        HTML string with Vega Embed chart
    """
    import html as html_module
    import json

    try:
        # Execute query to get data
        data = executor.execute_chart(chart, variables)

        # Resolve Jinja templates in chart title if present
        from dataface.compile.jinja import resolve_jinja_template

        resolved_chart = chart
        if chart.title and "{{" in chart.title:
            resolved_title = resolve_jinja_template(chart.title, variables)
            # Create a copy with resolved title
            resolved_chart = chart.model_copy(update={"title": resolved_title})

        # Generate Vega-Lite spec with explicit dimensions
        from dataface.render.vega_lite import generate_vega_lite_spec

        spec = generate_vega_lite_spec(
            resolved_chart, data, width=available_width, height=available_height
        )
        spec_json = json.dumps(spec)

        # Generate unique ID for this chart
        chart_id = f"chart-{chart.id.replace(' ', '-').lower()}"

        # Build wrapper div style with chart style background if present
        wrapper_style = f"width: {available_width}px; height: {available_height}px; box-sizing: border-box; overflow: hidden; position: relative;"

        # Apply chart style background if present
        chart_style = getattr(chart, "style", None)
        if chart_style and isinstance(chart_style, dict):
            bg_color = chart_style.get("background")
            if bg_color:
                escaped_bg = html_module.escape(str(bg_color))
                wrapper_style += f" background-color: {escaped_bg};"

        # Escape chart title and ID for use in HTML/JavaScript
        escaped_chart_title = html_module.escape(chart.title or chart.id)
        escaped_chart_id = html_module.escape(chart.id)
        # JavaScript-safe escaping (escape single quotes and backslashes)
        js_chart_id = escaped_chart_id.replace("\\", "\\\\").replace("'", "\\'")
        js_chart_title = escaped_chart_title.replace("\\", "\\\\").replace("'", "\\'")

        # Extract variable dependencies and build data attributes
        var_dependencies = _extract_variable_dependencies(chart)
        var_attrs = " ".join(
            f"data-var-{html_module.escape(v)}" for v in sorted(var_dependencies)
        )
        var_attrs_str = f" {var_attrs}" if var_attrs else ""

        return f"""<div id="{chart_id}" class="dataface-chart" data-chart-id="{escaped_chart_id}" data-chart-title="{escaped_chart_title}"{var_attrs_str} style="{wrapper_style}">
    <button class="dataface-chart-chat-btn" onclick="if(window.parent && window.parent.chatAboutChart) {{ window.parent.chatAboutChart('{js_chart_id}', '{js_chart_title}'); }} else {{ alert('Chat about this chart: {js_chart_title}'); }}" title="Chat about this chart">
        💬
    </button>
    <div id="{chart_id}-content" style="width: 100%; height: 100%;"></div>
</div>
<script>
    vegaEmbed('#{chart_id}-content', {spec_json}, {{actions: {{editor: false}}}}).catch(console.error);
</script>"""

    except Exception as e:
        # Render error message with data-var attributes so highlighting still works
        import html as html_module

        var_dependencies = _extract_variable_dependencies(chart)
        var_attrs = " ".join(
            f"data-var-{html_module.escape(v)}" for v in sorted(var_dependencies)
        )
        var_attrs_str = f" {var_attrs}" if var_attrs else ""

        error_msg = html_module.escape(str(e)[:200])
        return f'<div class="dataface-chart"{var_attrs_str} style="padding: 20px; background: #fff3f3; border: 1px solid #ffcdd2; border-radius: 4px; color: #c62828;">Error rendering {html_module.escape(chart.id)}: {error_msg}</div>'

Configuration

RenderConfig

RenderConfig dataclass

RenderConfig(background: Optional[str] = None, backgrounds: Dict[str, Optional[str]] = dict(), scale: float = 1.0, quality: int = 90)

Rendering configuration.

ATTRIBUTE DESCRIPTION
background

Default background color

TYPE: Optional[str]

backgrounds

Format-specific backgrounds

TYPE: Dict[str, Optional[str]]

scale

Scale factor for PNG output

TYPE: float

quality

Quality setting for image output

TYPE: int

get_background_for_format

get_background_for_format

get_background_for_format(format: str, style: Optional[Dict] = None, override: Optional[str] = None) -> Optional[str]

Get the appropriate background color for a format.

Priority: 1. Override parameter 2. Style format-specific background 3. Style general background 4. Default for format

PARAMETER DESCRIPTION
format

Output format (svg, html, png, pdf, terminal)

TYPE: str

style

Board style dictionary

TYPE: Optional[Dict] DEFAULT: None

override

Explicit override value

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
Optional[str]

Background color or None for transparent

Source code in dataface/render/config.py
def get_background_for_format(
    format: str,
    style: Optional[Dict] = None,
    override: Optional[str] = None,
) -> Optional[str]:
    """Get the appropriate background color for a format.

    Priority:
    1. Override parameter
    2. Style format-specific background
    3. Style general background
    4. Default for format

    Args:
        format: Output format (svg, html, png, pdf, terminal)
        style: Board style dictionary
        override: Explicit override value

    Returns:
        Background color or None for transparent
    """
    if override is not None:
        return None if override == "transparent" else override

    if style:
        # Check format-specific
        backgrounds = style.get("backgrounds", {})
        if format in backgrounds:
            bg = backgrounds[format]
            return None if bg == "transparent" else bg

        # Check general background
        if "background" in style:
            bg = style["background"]
            return None if bg == "transparent" else bg

    # Default for format
    return _default_config.backgrounds.get(format)

Errors

errors

Rendering error types.

Stage: RENDER Purpose: Define error types for rendering failures.

These errors are raised during: - Chart rendering (ChartError) - Layout rendering (LayoutError) - Format conversion (FormatError)

All errors inherit from RenderError for easy catching.

Note: Many render errors are displayed IN the output rather than thrown, so users see helpful error messages in the rendered dashboard.

RenderError

RenderError(message: str, element: Optional[str] = None)

Bases: Exception

Base error for all rendering failures.

This is the parent class for all rendering-related errors. Catch this to handle any rendering error.

ATTRIBUTE DESCRIPTION
message

Human-readable error description

element

Element that failed to render (if applicable)

Source code in dataface/render/errors.py
def __init__(self, message: str, element: Optional[str] = None):
    self.message = message
    self.element = element
    super().__init__(self._format_message())

FormatError

FormatError(message: str, format: Optional[str] = None)

Bases: RenderError

Error during format conversion.

Raised when: - Unknown format requested - SVG to PNG/PDF conversion fails - HTML template error

Example

try: ... render(board, executor, format="unknown") ... except FormatError as e: ... print(f"Format error: {e}")

Source code in dataface/render/errors.py
def __init__(self, message: str, format: Optional[str] = None):
    self.format = format
    super().__init__(f"Format conversion failed: {message}", format)

ChartError

ChartError(message: str, chart_id: Optional[str] = None)

Bases: RenderError

Error during chart rendering.

Raised when: - Chart type not supported - Data doesn't match chart requirements - Vega-Lite spec generation fails

Example

try: ... render_chart(chart, data) ... except ChartError as e: ... print(f"Chart failed: {e}")

Source code in dataface/render/errors.py
def __init__(self, message: str, chart_id: Optional[str] = None):
    self.chart_id = chart_id
    super().__init__(f"Chart rendering failed: {message}", chart_id)