Skip to content

visualdynamics.report

report

Render a Report to one self-contained HTML file.

The whole point is the reader: they open the file in the browser they already have — no install, no network, no third-party code inside the deliverable. Everything interactive is a few hundred lines of our own JavaScript: an orthographic trackball scene that animates mode shapes with the same phase math as the desktop animator, and a zoomable log-magnitude plot with visualdynamics's own axis labels. Data rides along as one JSON payload, already converted to the display unit system, so the file shows exactly what the screen showed.

Functions:

Name Description
render_html

The report as one HTML document string.

resolve_references

{{Object Name.field}} in report text becomes the live value.

scalogram_channel_options

The DOF names a scalogram block may draw — for the editor's

export_html

One figure, interactive, as a single self-contained HTML file.

Classes

Functions:

render_html

render_html(report: Report, objects: Mapping[str, Any], unit_system: UnitSystem | None = None, edit: bool = False, channel_js: str | None = None, object_groups: Sequence[Mapping[str, Any]] | None = None, selected: int | None = None, labels: list[tuple[str, str]] | None = None, fill: bool = False, theme: str | None = None) -> str

The report as one HTML document string.

links is the project's object groups: symbolic bindings like '@basis:Frf' resolve against them, so a report depends on the project's structure, never on what anyone named their objects. Reading mode skips blocks whose references cannot resolve — an unbound template block is a slot to fill, not an error to show a reader. Edit mode keeps them as cards to rebind, tags every block with its index, frames the block at selected, and wires one message to the app over Qt's web channel — which block was clicked. Every act lives on the application's bar and pane (2026-09-08); the page carries no insert bars, block toolbars or editors. Only the app ever loads edit mode; the exported file carries none of it.

labels, when given, is filled with (label, caption) for every numbered figure and table in order — what the editor's Reference menu offers, from the same numbering the page shows.

fill makes the page one figure filling its frame, without title, marking or toggle (export_html); theme, 'light' or 'dark', fixes the page's theme where it would otherwise follow the reader.

Source code in src/visualdynamics/report/__init__.py
def render_html(report: Report, objects: Mapping[str, Any],
                unit_system: UnitSystem | None = None, edit: bool = False,
                channel_js: str | None = None,
                object_groups: Sequence[Mapping[str, Any]] | None = None,
                selected: int | None = None,
                labels: list[tuple[str, str]] | None = None,
                fill: bool = False, theme: str | None = None) -> str:
    """The report as one HTML document string.

    `links` is the project's object groups: symbolic bindings like
    '@basis:Frf' resolve against them, so a report depends on the
    project's *structure*, never on what anyone named their objects.
    Reading mode skips blocks whose references cannot resolve —
    an unbound template block is a slot to fill, not an error to show a
    reader. Edit mode keeps them as cards to rebind, tags every block
    with its index, frames the block at `selected`, and wires one
    message to the app over Qt's web channel — which block was
    clicked. Every act lives on the application's bar and pane
    (2026-09-08); the page carries no insert bars, block toolbars or
    editors. Only the app ever loads edit mode; the exported file
    carries none of it.

    `labels`, when given, is filled with (label, caption) for every
    numbered figure and table in order — what the editor's Reference
    menu offers, from the same numbering the page shows.

    `fill` makes the page one figure filling its frame, without title,
    marking or toggle (`export_html`); `theme`, 'light' or 'dark', fixes
    the page's theme where it would otherwise follow the reader.
    """
    us = unit_system or DEFAULT_SYSTEM
    from ..theme import DARK, LIGHT, VIRIDIS

    payload = {'title': report.title, 'edit': bool(edit),
               'marking': report.marking,
               'marking_color': report.marking_color,
               # the page is told the colors it draws with rather
               # than carrying its own copies: one color scale and
               # one pair of mark colors, from the app's own theme.
               # Both themes ride along because the reader can flip
               # between them in the page.
               'viridis': [list(stop) for stop in VIRIDIS],
               'marks_color': {
                   side['name']: {
                       'band': side['averaging_band'],
                       'window': side['averaging_window']}
                   for side in (LIGHT, DARK)},
               'blocks': [], 'fill': bool(fill), 'theme': theme,
               'curve_width': CURVE_WIDTH,
               'curve_colors': {'light': CURVE_COLORS_LIGHT,
                                'dark': CURVE_COLORS}}
    if edit:
        payload['selected'] = -1 if selected is None else int(selected)
    figures = tables = 0
    for index, block in enumerate(report.blocks):
        built = _build_block(block, objects, us, object_groups)
        if built is None:
            if not edit:
                continue
            # tell the truth on the card: a block whose bindings all
            # resolve but that still has nothing to draw is not
            # 'unbound' — rebinding it would change nothing
            from ..core.report import resolve_binding
            needed = [block.get(key) for key in
                      ('source', 'geometry', 'dofs_source', 'shapes')
                      if block.get(key)]
            resolvable = bool(needed) and all(
                resolve_binding(name, objects, object_groups) in objects
                for name in needed)
            built = {'kind': 'unbound',
                     'was': block.get('kind', 'block'),
                     'empty': resolvable}
        # one block can answer with several figures — a stage with
        # more channels than it holds legibly continues into the next
        # one. They share the block's index, so the editor edits the
        # block whichever of its figures was clicked.
        drawn = built if isinstance(built, list) else [built]
        for built in drawn:
            if built['kind'] in ('plot', 'mac', 'map', 'scene', 'image',
                                 'bars', 'stage', 'grid'):
                figures += 1
                built['label'] = f'Figure {figures}'
            elif built['kind'] == 'table':
                tables += 1
                built['label'] = f'Table {tables}'
            if edit:
                # the block's index rides with each of its figures, so a
                # click on any of them selects the block
                built['index'] = index
            payload['blocks'].append(built)
    # text renders last: only now does every figure have its number, so
    # {{figure:...}} references can resolve — and renumber themselves
    # the next time a block is added, removed, or moved
    from .markdown import to_html

    labeled = [(built['label'], built.get('caption', ''))
               for built in payload['blocks'] if built.get('label')]
    if labels is not None:
        labels[:] = labeled
    for built in payload['blocks']:
        if built['kind'] == 'text':
            built['html'] = to_html(
                _resolve_figures(built.pop('text'), labeled))
    data = json.dumps(payload, allow_nan=False)
    scripts = _JS + (_EDIT_JS if edit else '')
    shell = _PAGE
    if edit:
        # the app hands over Qt's own qwebchannel.js to inline, because
        # the editor page loads from a file and file: pages cannot
        # reach qrc:; the qrc tag remains for anything rendering edit
        # HTML without the app
        channel = (f'<script>{channel_js}</script>' if channel_js else
                   '<script src="qrc:///qtwebchannel/qwebchannel.js">'
                   '</script>')
        # edit pages trap script errors where the app can read them —
        # a blank editor with no diagnosis cost a debugging session
        trap = ('<script>window.__err = [];'
                "window.onerror = (m, s, l) => __err.push(m + ' @' + l);"
                '</script>')
        shell = shell.replace('<script id="data"',
                              trap + channel + '\n<script id="data"')
    return (shell.replace('__TITLE__', html_escape.escape(report.title))
                 .replace('__DATA__', data.replace('</', '<\\/'))
                 .replace('__CSS__', _CSS + (_EDIT_CSS if edit else ''))
                 .replace('__JS__', scripts))

resolve_references

resolve_references(text: str, objects: Mapping[str, Any], us: UnitSystem, object_groups: Sequence[Mapping[str, Any]] | None = None) -> str

{{Object Name.field}} in report text becomes the live value.

The whole point is templates: a summary that says how the data was sampled fills itself in whatever project the template lands in. The name may be a symbolic selector — {{@basis:TimeHistory. sample_rate}} — resolved against the object groups, so the text depends on no one's naming either. A reference that cannot resolve — no such object, or a field the object cannot answer — stays visible as written, the same way an unbound block stays a slot instead of an error.

Source code in src/visualdynamics/report/__init__.py
def resolve_references(text: str, objects: Mapping[str, Any], us: UnitSystem,
                       object_groups: Sequence[Mapping[str, Any]] | None = None) -> str:
    """{{Object Name.field}} in report text becomes the live value.

    The whole point is templates: a summary that says how the data was
    sampled fills itself in whatever project the template lands in.
    The name may be a symbolic selector — {{@basis:TimeHistory.
    sample_rate}} — resolved against the object groups, so the text
    depends on no one's naming either. A reference that cannot
    resolve — no such object, or a field the object cannot answer —
    stays visible as written, the same way an unbound block stays a
    slot instead of an error.
    """
    from ..core.report import resolve_binding

    def swap(match: re.Match) -> str:
        name, dot, field = match.group(1).rpartition('.')
        obj = (objects.get(resolve_binding(name.strip(), objects,
                                           object_groups) or '')
               if dot else None)
        if obj is not None:
            value = _field_value(obj, field.strip(), us)
            if value is not None:
                return value
        return match.group(0)

    return _REFERENCE.sub(swap, text or '')

scalogram_channel_options

scalogram_channel_options(block, objects, object_groups=())

The DOF names a scalogram block may draw — for the editor's drop-down (Brandon, 2026-08-29: the figure shows one channel, so the reader chooses which).

Parameters:

Name Type Description Default
block dict

The scalogram plot block.

required
objects mapping

The report's objects, name to object.

required
object_groups sequence

The project's object groups, for symbolic source bindings.

()

Returns:

Type Description
list of str

The response DOFs the block's select admits.

Source code in src/visualdynamics/report/__init__.py
def scalogram_channel_options(block, objects, object_groups=()):
    """The DOF names a scalogram block may draw — for the editor's
    drop-down (Brandon, 2026-08-29: the figure shows one channel, so
    the reader chooses which).

    Parameters
    ----------
    block : dict
        The scalogram plot block.
    objects : mapping
        The report's objects, name to object.
    object_groups : sequence, optional
        The project's object groups, for symbolic source bindings.

    Returns
    -------
    list of str
        The response DOFs the block's `select` admits.
    """
    from ..core.report import resolve_binding

    name = resolve_binding(block.get('source', ''), objects, object_groups)
    source = objects.get(name)
    if source is None or not hasattr(source, 'response_dof'):
        return []
    return [str(source.response_dof[i])
            for i in _scalogram_candidates(block, source)]

export_html

export_html(path: str | PathLike, data: Any = None, *, specification: Any = None, channel: str | None = None, mode: str = 'curves', geometry: Any = None, shapes: Any = None, dofs: tuple[str, Any] | None = None, name: str | None = None, caption: str = '', theme: str | None = None, unit_system: UnitSystem | None = None, fill: bool = True) -> str

One figure, interactive, as a single self-contained HTML file.

visualdynamics.export_html('spec.html', psd, specification=spec,
                           channel='101Z+', theme='light')
visualdynamics.export_html('modes.html', geometry=g, shapes=modes)

The figure the report draws — zoom, pan and the readout on a plot, turning and animating on a scene — with nothing else: no title and no marking, and with fill the figure takes whatever frame holds it, an iframe or a box on a slide, redrawing when the frame changes size (Brandon, 2026-09-27, for an interactive slide deck from the scripts that draw a paper's printed figures). Everything the page needs is in the file, so it opens offline.

A plot of data — against specification and its zones when one is given, one channel of it when named, in mode ('curves', 'stage', 'cmif', 'mac' or 'map'); or, given a geometry, a scene of it, its shapes animating or the dofs one object measures as a quantity, ('acceleration', frf), drawn as labeled arrows.

Parameters:

Name Type Description Default
path str or path - like

Where the .html goes.

required
data DataArray or ShapeSet

What a plot draws.

None
specification Specification

What data is drawn against.

None
channel str

The one control channel of a comparison to draw.

None
mode str

The plot's reading.

'curves'
geometry Geometry

Makes the figure a scene.

None
shapes ShapeSet

What the scene animates.

None
dofs (str, object)

A quantity and an object: its DOFs of that quantity, as arrows.

None
name str

What the legend calls data.

None
caption str

A line under the figure.

''
theme ('light', 'dark')

Fixes the figure's theme; unset, it follows the reader's.

'light'
unit_system UnitSystem

The units it is drawn in.

None
fill bool

Fill the frame; False lays it out as the report page does.

True

Returns:

Type Description
str

The path written.

Source code in src/visualdynamics/report/__init__.py
def export_html(path: str | os.PathLike, data: Any = None, *,
                specification: Any = None, channel: str | None = None,
                mode: str = 'curves', geometry: Any = None,
                shapes: Any = None, dofs: tuple[str, Any] | None = None,
                name: str | None = None, caption: str = '',
                theme: str | None = None,
                unit_system: UnitSystem | None = None,
                fill: bool = True) -> str:
    """One figure, interactive, as a single self-contained HTML file.

        visualdynamics.export_html('spec.html', psd, specification=spec,
                                   channel='101Z+', theme='light')
        visualdynamics.export_html('modes.html', geometry=g, shapes=modes)

    The figure the report draws — zoom, pan and the readout on a plot,
    turning and animating on a scene — with nothing else: no title and
    no marking, and with `fill` the figure takes whatever frame holds
    it, an iframe or a box on a slide, redrawing when the frame changes
    size (Brandon, 2026-09-27, for an interactive slide deck from the
    scripts that draw a paper's printed figures). Everything the page
    needs is in the file, so it opens offline.

    A plot of `data` — against `specification` and its zones when one
    is given, one `channel` of it when named, in `mode` ('curves',
    'stage', 'cmif', 'mac' or 'map'); or, given a `geometry`, a scene of
    it, its `shapes` animating or the `dofs` one object measures as a
    quantity, ``('acceleration', frf)``, drawn as labeled arrows.

    Parameters
    ----------
    path : str or path-like
        Where the .html goes.
    data : DataArray or ShapeSet, optional
        What a plot draws.
    specification : Specification, optional
        What `data` is drawn against.
    channel : str, optional
        The one control channel of a comparison to draw.
    mode : str, default 'curves'
        The plot's reading.
    geometry : Geometry, optional
        Makes the figure a scene.
    shapes : ShapeSet, optional
        What the scene animates.
    dofs : (str, object), optional
        A quantity and an object: its DOFs of that quantity, as arrows.
    name : str, optional
        What the legend calls `data`.
    caption : str, optional
        A line under the figure.
    theme : {'light', 'dark'}, optional
        Fixes the figure's theme; unset, it follows the reader's.
    unit_system : UnitSystem, optional
        The units it is drawn in.
    fill : bool, default True
        Fill the frame; False lays it out as the report page does.

    Returns
    -------
    str
        The path written.
    """
    import pathlib

    from ..core.report import Report
    from ..project import Project

    holder = Project('Figure')
    if geometry is not None:
        holder.add('Geometry', geometry)
        block = {'kind': 'scene', 'geometry': 'Geometry', 'shapes': '',
                 'caption': caption}
        if shapes is not None:
            holder.add('Shapes', shapes)
            holder.link('Geometry', 'Shapes')
            block['shapes'] = 'Shapes'
        if dofs is not None:
            quantity, source = dofs
            holder.add('Measured', source)
            block.update(dofs=quantity, dofs_source='Measured')
    elif data is not None:
        label = name or ('Response' if specification is not None else 'Data')
        holder.add(label, data)
        block = {'kind': 'plot', 'source': label, 'mode': mode,
                 'caption': caption}
        if specification is not None:
            holder.add('Specification', specification)
            block['specification'] = 'Specification'
        if channel is not None:
            block['channel'] = channel
    else:
        raise ValueError('export_html needs data to plot or a geometry '
                         'to show')
    report = Report('', [block], marking='')
    html = render_html(report, dict(holder.items()), unit_system,
                       object_groups=holder.object_groups, fill=fill, theme=theme)
    path = pathlib.Path(path)
    path.write_text(html, encoding='utf-8')
    return str(path)