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

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,
    links: Sequence[Mapping[str, Any]] | None = None,
    selected: int | None = None,
    labels: list[tuple[str, str]] | None = None,
) -> str

The report as one HTML document string.

links is the project's link 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.

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,
                links: Sequence[Mapping[str, Any]] | None = None,
                selected: int | None = None,
                labels: list[tuple[str, str]] | None = None) -> str:
    """The report as one HTML document string.

    `links` is the project's link 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.
    """
    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 colours it draws with rather
               # than carrying its own copies: one colour scale and
               # one pair of mark colours, 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': []}
    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, links)
        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, links) 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'):
                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,
    links: 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 link 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,
                       links: 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 link 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,
                                           links) 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, links=())

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
links sequence

The project's link 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, links=()):
    """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.
    links : sequence, optional
        The project's link 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, links)
    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)]