Skip to content

visualdynamics.report

report

Render a Report to one self-contained HTML file.

The reader opens the file in the browser they already have, with nothing to install, no network, and no third-party code inside the file. Everything interactive is JavaScript written for this package (about 2,200 lines, in page.py): an orthographic 3-D scene the reader can turn, which animates mode shapes with the same phase arithmetic as the desktop animator, and zoomable plots with the app's own axis labels. The data is carried as one JSON payload, already converted to the display unit system, so the file shows what the screen showed.

Functions:

Name Description
render_html

The report as one HTML document string.

resolve_references

Replace each {{Object Name.field}} in report text with the

block_caption

The caption block a plot block's first figure carries, or None.

scalogram_channel_options

The DOF names a scalogram block can draw, for the editor's

export_html

Write one interactive figure as a single self-contained HTML file.

export_figure

Write one interactive figure as a JSON data file, and optionally

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.

objects maps names to the project's objects, and unit_system is the display unit system (the default system when None). object_groups is the project's object groups: symbolic bindings such as '@basis:Frf' are resolved against them, so a report depends on how the project is linked, not on what its objects are named.

The reading page (edit False) leaves out blocks whose references cannot be resolved, since an unbound block in a template is a place to fill, not an error to show a reader. The edit page (edit True) shows them as cards to bind again, tags every block with its index, frames the block at selected, and sends one message to the app over Qt's web channel: which block was clicked. channel_js is the text of Qt's qwebchannel.js to put inline in the edit page; without it the page loads it from qrc:. All editing is done from the app's bar and pane, and the page has no insert bars, block toolbars or editors. Only the app loads the edit page; an exported file has 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, with the same numbering the page shows.

The report features enabled on report (report.features) act here, each only as its description says; with none enabled the document is the one this function has always written, byte for byte.

fill makes the page one figure filling its frame, without a title, marking or theme toggle (export_html). theme, 'light' or 'dark', fixes the page's theme, which otherwise follows the reader's.

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.

    `objects` maps names to the project's objects, and `unit_system` is
    the display unit system (the default system when None).
    `object_groups` is the project's object groups: symbolic bindings
    such as '@basis:Frf' are resolved against them, so a report depends
    on how the project is linked, not on what its objects are named.

    The reading page (`edit` False) leaves out blocks whose references
    cannot be resolved, since an unbound block in a template is a place
    to fill, not an error to show a reader. The edit page (`edit` True)
    shows them as cards to bind again, tags every block with its index,
    frames the block at `selected`, and sends one message to the app over
    Qt's web channel: which block was clicked. `channel_js` is the text
    of Qt's qwebchannel.js to put inline in the edit page; without it the
    page loads it from qrc:. All editing is done from the app's bar and
    pane, and the page has no insert bars, block toolbars or editors.
    Only the app loads the edit page; an exported file has 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, with the same numbering the page shows.

    The report features enabled on `report` (`report.features`) act
    here, each only as its description says; with none enabled the
    document is the one this function has always written, byte for
    byte.

    `fill` makes the page one figure filling its frame, without a title,
    marking or theme toggle (`export_html`). `theme`, 'light' or 'dark',
    fixes the page's theme, which otherwise follows the reader's.
    """
    # All editing moved to the app's bar and pane on 2026-09-08.
    from .features import enabled as enabled_features

    payload = _report_payload(report, objects, unit_system, edit,
                              object_groups, selected, labels, fill, theme)
    active = enabled_features(report)
    css = ''
    if 'report_identity' in active:
        from .identity import page_css

        css = page_css(report)
    if 'flash_report' in active:
        from ..core.run_log_extract import held
        from .flash import LOG_CSS

        if held(objects or {}):
            # the run log's part of the flash page, set tight; a page
            # without a run log keeps its stylesheet as it was
            css += LOG_CSS
    return _page_html(payload, edit, channel_js, active, css)

resolve_references

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

Replace each {{Object Name.field}} in report text with the object's current value, formatted in the unit system us.

This is what makes templates work: a summary that says how the data was sampled fills itself in, in whatever project the template is used. The name may be a symbolic selector, such as {{@basis:TimeHistory.sample_rate}}, resolved against object_groups, so the text does not depend on the objects' names either. A reference that cannot be resolved (no such object, or a field the object does not have) is left in the text as written, the same way an unbound block is left as a place to fill.

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:
    """Replace each {{Object Name.field}} in report text with the
    object's current value, formatted in the unit system `us`.

    This is what makes templates work: a summary that says how the data
    was sampled fills itself in, in whatever project the template is
    used. The name may be a symbolic selector, such as
    {{@basis:TimeHistory.sample_rate}}, resolved against `object_groups`,
    so the text does not depend on the objects' names either. A
    reference that cannot be resolved (no such object, or a field the
    object does not have) is left in the text as written, the same way
    an unbound block is left as a place to fill.
    """
    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 '')

block_caption

block_caption(block: Mapping[str, Any], objects: Mapping[str, Any], object_groups: Sequence[Mapping[str, Any]] | None = None, unit_system: UnitSystem | None = None) -> Any

The caption block a plot block's first figure carries, or None.

What the report editor reads out beside a plot block: the caption Project.caption_of reads for the records the figure opens on: the channel the block names, else the first channel the block's requirement names, else the records its filter keeps that the first figure draws. None when the block draws no measured record a caption reads (report_caption.caption_kind), draws a grid, or the objects are not a project.

Parameters:

Name Type Description Default
block mapping

The plot block.

required
objects Project

The project the report reads.

required
object_groups sequence of mapping

The project's object groups, for symbolic bindings.

None
unit_system UnitSystem

The display units.

None

Returns:

Type Description
(RecordCaption, SrsCaption, SineCaption, TimeCaption or None)
Source code in src/visualdynamics/report/__init__.py
def block_caption(block: Mapping[str, Any], objects: Mapping[str, Any],
                  object_groups: Sequence[Mapping[str, Any]] | None = None,
                  unit_system: UnitSystem | None = None) -> Any:
    """The caption block a plot block's first figure carries, or None.

    What the report editor reads out beside a plot block: the caption
    `Project.caption_of` reads for the records the figure opens on: the
    channel the block names, else the first channel the block's
    requirement names, else the records its filter keeps that the
    first figure draws. None when the block draws no measured record a
    caption reads (`report_caption.caption_kind`), draws a grid, or the
    objects are not a project.

    Parameters
    ----------
    block : mapping
        The plot block.
    objects : Project
        The project the report reads.
    object_groups : sequence of mapping, optional
        The project's object groups, for symbolic bindings.
    unit_system : UnitSystem, optional
        The display units.

    Returns
    -------
    RecordCaption, SrsCaption, SineCaption, TimeCaption or None
    """
    from ..core.compliance import matched_records
    from ..core.data import ShockSpecification, Specification
    from ..core.report import base_quantity, resolve_binding
    from ..core.report_caption import caption_kind
    from ..core.sine import SineLevelSet, SineSweepSpecification

    caption_of = getattr(objects, 'caption_of', None)
    if caption_of is None or block.get('kind') != 'plot' or block.get('grid'):
        return None
    name = resolve_binding(block.get('source'), objects, object_groups)
    source = objects.get(name or '')
    kind = caption_kind(source)
    if kind is None:
        return None
    against = objects.get(resolve_binding(block.get('specification'), objects,
                                          object_groups) or '')
    named = block.get('channel')
    tone = block.get('tone') or None
    measured, target = source, None
    if kind == 'sine' and isinstance(source, SineLevelSet):
        try:
            measured = (source.tone(tone) if tone else
                        source.levels[0] if source.levels else None)
        except KeyError:
            return None
        if measured is None:
            return None
    if kind == 'spectrum' and isinstance(against, Specification):
        pairs = [mi for _label, si, mi in matched_records(against, source)
                 if not named or _channel_label(against, si) == str(named)]
        if not pairs:
            return None
        record = pairs[0]
    elif kind == 'srs' and isinstance(against, ShockSpecification):
        target = against
    elif kind == 'sine' and isinstance(against, SineSweepSpecification):
        try:
            target = against.tone_curve(measured.tone)
        except KeyError:
            return None
    else:
        against = None
    if target is not None:
        # an SRS or sine figure draws a channel at a time, every event
        # of it: the channel the block names, else the requirement's
        # first that the measurement holds (`_srs_comparison_block`)
        held = {_pair_key(measured, i) for i in range(measured.num_records)}
        keys = [_pair_key(target, j) for j in range(target.num_records)
                if _pair_key(target, j) in held
                and (not named or _channel_label(target, j) == str(named))]
        if not keys:
            return None
        record = [i for i in range(measured.num_records)
                  if _pair_key(measured, i) == keys[0]]
    elif kind != 'spectrum' or against is None:
        select = block.get('select', '')
        records = list(range(measured.num_records))
        if select == 'drive' and measured.reference_dof is not None:
            records = [i for i in records
                       if measured.response_dof[i] == measured.reference_dof[i]]
        elif select.startswith('dim:'):
            records = [i for i in records
                       if base_quantity(measured.known_dim(i)) == select[4:]]
        if not records:
            return None
        # the figure's own records: a block of many continues into
        # further figures, and the first draws the first page of them
        record = records[:MAX_REPORT_CURVES]
    extra = {'tone': measured.tone} if kind == 'sine' else {}
    return caption_of(name, record, specification=against,
                      per_octave=(int(block['octave']) if block.get('octave')
                                  else None),
                      unit_system=unit_system or DEFAULT_SYSTEM, **extra)

scalogram_channel_options

scalogram_channel_options(block, objects, object_groups=())

The DOF names a scalogram block can draw, for the editor's drop-down list. The figure shows one channel, so the person editing the report 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 allows; empty when the source does not resolve or has no DOFs.

Source code in src/visualdynamics/report/__init__.py
def scalogram_channel_options(block, objects, object_groups=()):
    """The DOF names a scalogram block can draw, for the editor's
    drop-down list. The figure shows one channel, so the person editing
    the report 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` allows; empty when the
        source does not resolve or has no DOFs.
    """
    # One channel, chosen in the editor (Brandon, 2026-08-29).
    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, channels: Sequence[str] | None = None, max_points: int | None = None, component: str = 'magnitude') -> str

Write one interactive figure 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 is the one a report draws, with zoom, pan and the readout on a plot, and turning and animation on a scene, and nothing else: no title and no marking. With fill the figure fills whatever frame holds it, such as an iframe or a box on a slide, and redraws when the frame changes size. Everything the page needs is in the file, so it opens offline.

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

channels and max_points make the file smaller: the first keeps only those records of data (and of specification), the second thins every curve of a plot to that many points, keeping every peak and dip, and says so under the figure. export_figure writes the same figure as a data file and a script, for a page that cannot carry an inline script.

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
channels sequence of str

The records of data to keep, by response DOF ('101Z+').

None
max_points int

The most points a plot's curve keeps; at least 16.

None
component ('magnitude', 'phase', 'real', 'imag')

How a plot of complex data reads, as the app's drop-down reads it.

'magnitude'

Returns:

Type Description
str

The path written.

Raises:

Type Description
ValueError

When neither data nor geometry is given, when channels names a channel data does not hold, when nothing of the figure can be drawn, when max_points is under 16, or when component is not one of the four.

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,
                channels: Sequence[str] | None = None,
                max_points: int | None = None,
                component: str = 'magnitude') -> str:
    """Write one interactive figure 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 is the one a report draws, with zoom, pan and the readout
    on a plot, and turning and animation on a scene, and nothing else:
    no title and no marking. With `fill` the figure fills whatever frame
    holds it, such as an iframe or a box on a slide, and redraws when the
    frame changes size. Everything the page needs is in the file, so it
    opens offline.

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

    `channels` and `max_points` make the file smaller: the first keeps
    only those records of `data` (and of `specification`), the second
    thins every curve of a plot to that many points, keeping every
    peak and dip, and says so under the figure. `export_figure` writes
    the same figure as a data file and a script, for a page that cannot
    carry an inline script.

    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.
    channels : sequence of str, optional
        The records of `data` to keep, by response DOF ('101Z+').
    max_points : int, optional
        The most points a plot's curve keeps; at least 16.
    component : {'magnitude', 'phase', 'real', 'imag'}, default 'magnitude'
        How a plot of complex data reads, as the app's drop-down
        reads it.

    Returns
    -------
    str
        The path written.

    Raises
    ------
    ValueError
        When neither `data` nor `geometry` is given, when `channels`
        names a channel `data` does not hold, when nothing of the
        figure can be drawn, when `max_points` is under 16, or when
        `component` is not one of the four.
    """
    # `fill` was added for an interactive slide deck made by the scripts
    # that draw a paper's printed figures (Brandon, 2026-09-27).
    import pathlib

    payload = _figure_payload(data, specification, channel, mode, geometry,
                              shapes, dofs, name, caption, theme,
                              unit_system, fill, channels, max_points,
                              component)
    path = pathlib.Path(path)
    path.write_text(_page_html(payload), encoding='utf-8')
    return str(path)

export_figure

export_figure(path: str | PathLike, data: Any = None, *, renderer: str | PathLike | None = 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 = False, channels: Sequence[str] | None = None, max_points: int | None = None, component: str = 'magnitude') -> str

Write one interactive figure as a JSON data file, and optionally the script that draws it, for a page that mounts the figure itself.

visualdynamics.export_figure('spec.json', psd, specification=spec,
                             channel='101Z+', theme='light',
                             renderer='visualdynamics-figure.js')

The figure is the one export_html writes, split in two. The data file holds this figure alone. The renderer is one script for every figure, the same viewers the HTML file carries; a page loads it from its own site and draws a figure into an element of its own:

<div id="spec"></div>
<script src="visualdynamics-figure.js"></script>
<script src="page.js"></script>   // VisualDynamicsFigure.load(
                                  //   element, 'spec.json')

So a page whose Content-Security-Policy allows no inline script and no frames can still show the figure. VisualDynamicsFigure.mount( element, data, options) draws data already fetched, and load( element, url, options) fetches it first; with {pageScroll: true} the figure leaves the wheel and a vertical swipe to the page and zooms on Ctrl or Cmd with the wheel. The figure's styles are scoped to that element. The renderer refuses data written for another format (FIGURE_FORMAT), so a page never draws a figure it would misread.

Parameters:

Name Type Description Default
path str or path - like

Where the .json goes.

required
data DataArray or ShapeSet

What a plot draws.

None
renderer str or path - like

Where to write the renderer script as well. It is the same for every figure this version writes, so a page needs it once.

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

Lay the figure out as the report page does, at its own height; True fills the element, which must then have a height.

False
channels sequence of str

The records of data to keep, by response DOF ('101Z+').

None
max_points int

The most points a plot's curve keeps; at least 16.

None
component ('magnitude', 'phase', 'real', 'imag')

How a plot of complex data reads, as the app's drop-down reads it.

'magnitude'

Returns:

Type Description
str

The data file's path.

Raises:

Type Description
ValueError

As export_html raises them.

Source code in src/visualdynamics/report/__init__.py
def export_figure(path: str | os.PathLike, data: Any = None, *,
                  renderer: str | os.PathLike | None = 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 = False,
                  channels: Sequence[str] | None = None,
                  max_points: int | None = None,
                  component: str = 'magnitude') -> str:
    """Write one interactive figure as a JSON data file, and optionally
    the script that draws it, for a page that mounts the figure itself.

        visualdynamics.export_figure('spec.json', psd, specification=spec,
                                     channel='101Z+', theme='light',
                                     renderer='visualdynamics-figure.js')

    The figure is the one `export_html` writes, split in two. The data
    file holds this figure alone. The renderer is one script for every
    figure, the same viewers the HTML file carries; a page loads it from
    its own site and draws a figure into an element of its own:

        <div id="spec"></div>
        <script src="visualdynamics-figure.js"></script>
        <script src="page.js"></script>   // VisualDynamicsFigure.load(
                                          //   element, 'spec.json')

    So a page whose Content-Security-Policy allows no inline script and
    no frames can still show the figure. `VisualDynamicsFigure.mount(
    element, data, options)` draws data already fetched, and `load(
    element, url, options)` fetches it first; with ``{pageScroll:
    true}`` the figure leaves the wheel and a vertical swipe to the
    page and zooms on Ctrl or Cmd with the wheel. The figure's styles
    are scoped to that element. The renderer refuses data written for
    another format (`FIGURE_FORMAT`), so a page never draws a figure it
    would misread.

    Parameters
    ----------
    path : str or path-like
        Where the .json goes.
    data : DataArray or ShapeSet, optional
        What a plot draws.
    renderer : str or path-like, optional
        Where to write the renderer script as well. It is the same for
        every figure this version writes, so a page needs it once.
    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 False
        Lay the figure out as the report page does, at its own height;
        True fills the element, which must then have a height.
    channels : sequence of str, optional
        The records of `data` to keep, by response DOF ('101Z+').
    max_points : int, optional
        The most points a plot's curve keeps; at least 16.
    component : {'magnitude', 'phase', 'real', 'imag'}, default 'magnitude'
        How a plot of complex data reads, as the app's drop-down
        reads it.

    Returns
    -------
    str
        The data file's path.

    Raises
    ------
    ValueError
        As `export_html` raises them.
    """
    import pathlib

    from .. import __version__
    from .page import FIGURE_FORMAT, renderer_js

    payload = _figure_payload(data, specification, channel, mode, geometry,
                              shapes, dofs, name, caption, theme,
                              unit_system, fill, channels, max_points,
                              component)
    payload['format'] = FIGURE_FORMAT
    path = pathlib.Path(path)
    path.write_text(json.dumps(payload, allow_nan=False, separators=(',', ':')),
                    encoding='utf-8')
    if renderer is not None:
        # the same bytes on every platform, so a site that keeps a copy
        # sees a change only when the renderer changed
        pathlib.Path(renderer).write_text(renderer_js(__version__),
                                          encoding='utf-8', newline='\n')
    return str(path)