@ovalbee/reportifact
v0.0.5
Published
Vue 3 component + Express middleware for reports from Parquet files via DuckDB
Readme
Reportifact
Vue report renderer for report assets. A report is a directory with an index.md
plus optional JSON, Parquet, Markdown, TensorBoard event, image, or other files
referenced from that index.
Widget Index
| Widget | Use For |
| --- | --- |
| grid | Group widgets with semantic layouts like auto, 2, 4-2. |
| card | Bordered Markdown panel with optional title, subtitle, and badge. |
| notice | Lightweight placeholder, TODO, or informational callout. |
| metricCards | Compact metric tiles from a Markdown table. |
| metricRows | Metric rows with values, descriptions, and help tooltips. |
| metricCallout | One highlighted metric plus explanatory Markdown. |
| imageGallery | JSON-backed grid of images with optional overlay layers. |
| modelComparisonGallery | Model-comparison image cards that open per-item previews. |
| chart | JSON-backed ECharts visualizations. |
| modelClassComparison | Compact comparison of per-class metrics across ML models. |
| table | Parquet table with filtering, sorting, pagination, and row clicks. |
| timeline | JSON-backed per-row time tracks with point markers (e.g. videos with cut points). |
| markdown | Render a separate Markdown file. |
| tensorboard | TensorBoard scalar event charts. |
| metricsTable | JSON-backed metrics/table visualization. |
| scope | Sticky control to re-render the whole report for a data scope (asset-tag value). |
| evaluationReports | OvalBee-only custom widget for evaluation reports linked to a model asset. |
| modelCheckpoints | OvalBee-only custom widget for model checkpoint downloads. |
Built-in widgets are provided by Reportifact. OvalBee-only widgets must be
registered by the OvalBee host through customWidgets.
OvalBee Custom Widgets
| Widget | Use For | Host Component |
| --- | --- | --- |
| evaluationReports | Evaluation report links for a model asset. | web/src/components/reportifact/custom-widgets/EvaluationReportsWidget.vue |
| modelCheckpoints | Download links for checkpoint resources on a model asset. | web/src/components/reportifact/custom-widgets/ModelCheckpointsWidget.vue |
index.md Shape
Top-level # H1 headings become report sections and table-of-contents tabs.
Inside a section, use Markdown prose and ::widget directives.
# Overview
Plain Markdown text.
::chart
src: metrics.json
type: radar
title: Metrics
::Do not put raw HTML, inline CSS, or <style> blocks in report Markdown. Add a
Reportifact widget instead. Markdown is supported inside widgets that accept a
body, such as card, notice, metricRows, and metricCards.
Directive Syntax
Two forms are supported:
::widget
key: value
nested:
key: value
::::widget{title="Short title" layout="4-2"}
Markdown body here.
::Values are parsed as strings, numbers, or booleans.
Layout
Use ::grid to group widgets. Prefer semantic layouts over CSS strings.
::grid{layout="4-2"}
::card{title="A"}...::
::card{title="B"}...::
::card{title="C"}...::
::card{title="D"}...::
::Layouts:
| Layout | Behavior |
| --- | --- |
| auto | Default. Uses child count to choose a reasonable layout. |
| 1, 2, 3, 4 | Fixed column intent; collapses on narrow containers. |
| 4-2 | Four columns when wide, two columns when narrow. Good for four cards. |
| 6-3-2 | Six columns wide, three medium, two narrow. |
Grid items stretch to the same row height.
A grid cell may itself be a ::grid (one level of nesting). Use this to put a
single widget in one column and a vertical stack of widgets in the next:
::grid{layout="2"}
::chart
src: left.json
type: bar-gradient
::
::grid{layout="1"}
::chart
src: right_a.json
type: bar-gradient
::
::chart
src: right_b.json
type: bar-gradient
::
::
::Widgets
card
Generic bordered panel. Body is Markdown, including compact Markdown tables.
::card{title="Model" surface="subtle"}
**Framework:** DEIM
**Checkpoint:** best.pth
::Attrs:
| Attr | Values |
| --- | --- |
| title | Header title. |
| subtitle | Small header subtitle. |
| badge | Pill text on the right. |
| badgeValue | Number used to color the badge. |
| surface | default, subtle, dashed. |
notice
Lightweight callout or placeholder.
::notice{title="Prediction gallery placeholder" variant="dashed"}
TODO: add annotated previews.
::Attrs: title, variant (default, dashed).
metricCards
Compact metric tiles from a Markdown table.
::metricCards
| Label | Value | Description |
| --- | ---: | --- |
| mAP | 0.2314 | COCO-style quality. |
| F1 | 0.3245 | Balance of precision and recall. |
::Columns: Label, Value, Description.
metricRows
Metric rows with optional help tooltips.
::metricRows{title="Detection Metrics" subtitle="Summary values."}
| Metric | Value | Help | Description |
| --- | ---: | --- | --- |
| Precision | 0.5385 | Correct detections / detections. | Correct predictions. |
::Attrs: title, subtitle. Columns: Metric, Value, Help,
Description.
metricCallout
Single highlighted metric plus Markdown body.
::metricCallout{label="Mean IoU" value="0.7782" description="Average overlap quality."}
Use this together with AP75 to inspect localization.
::Attrs: label, value, description.
imageGallery
Renders a JSON-backed grid of images. Each tile has a required image layer and an optional overlay layer.
::imageGallery
src: image_gallery.json
overlayOpacity: 0.7
::{
"assets": ["image-asset-id", "image-asset-id"],
"annotations": ["gt-annotation-id", "prediction-annotation-id"]
}For static report files, use images and overlays instead:
{
"images": ["gallery/image.jpg"],
"overlays": ["gallery/overlay.png"]
}Attrs:
| Attr | Default |
| --- | --- |
| src | Required JSON file. |
| overlayOpacity | 0.7 |
| fit | contain; use cover for cropped tiles. |
| aspectRatio | 4 / 3 |
| drawTags | true; set to false to render annotation overlays without tag labels (useful for object-detection galleries). |
The gallery renders four columns by default and two columns on narrow
containers. Inline preview shows the first row; when there are more asset-backed
items, Reportifact emits an asset-preview row-click payload for the host to
open a larger viewer.
Static URLs and report-local files work directly. assets and annotations
are host-specific refs; pass a mediaResolver prop to resolve them. Reportifact
does not know about OvalBee assets.
modelComparisonGallery
Renders comparison cards backed by host-resolved image assets. Each card can include
a ground-truth overlay, a short list of details, and an asset-preview payload
that opens when the card is activated.
::modelComparisonGallery
src: image_comparison.json
columns: 3
previewLimit: 3
fit: cover
drawTags: false
::{
"items": [
{
"asset": "image-asset-id",
"annotation": "gt-annotation-id",
"label": "street_0142.jpg",
"details": [
{ "label": "F1 spread", "value": "0.3100" },
{ "label": "Mean F1", "value": "0.6400" }
],
"compare": {
"action": "asset-preview",
"layout": "viewer",
"mode": "side",
"sync": true,
"assets": [
{
"asset": "image-asset-id",
"layers": [{ "annotation": "gt-annotation-id", "reference": true }]
},
{
"asset": "image-asset-id",
"layers": [{ "annotation": "prediction-annotation-id" }]
}
]
}
}
]
}Attrs:
| Attr | Default |
| --- | --- |
| src | Required JSON file. |
| overlayOpacity | 0.7 |
| fit | contain; use cover for cropped cards. |
| aspectRatio | 4 / 3 |
| drawTags | true |
| columns | 4; clamped to 1–6. |
| previewLimit | 4; clamped to 1–24. |
Only visible cards resolve thumbnails. Activating a card emits its compare
payload. Show all emits a collection with click: "comparison", preserving
each item's nested comparison payload. Missing annotations render the base
image without an overlay.
chart
Renders a JSON chart file with ECharts.
::chart
src: key_metrics.json
type: radar
title: Key Metrics
height: 420
::Attrs:
| Attr | Values |
| --- | --- |
| src | JSON file in the report directory. |
| type | pie, line, radar, bar-normalized, bar-gradient, bar-grouped, bar-stacked, heatmap, custom-gauge, gauge-grade, range, scatter. |
| title | Chart title override. |
| height | CSS length or number in px. |
| radius | For pie; a single value ("50%") renders a solid pie, a comma-separated pair ("46%,70%") renders a donut ring. |
| smooth | For line-like charts; false disables smoothing. |
| horizontal | For bar-gradient; true lays the bars out horizontally (categories on the y-axis). |
Charts are full-width by default.
Chart data can attach host actions with explicit click IDs. Put clickId on
the rendered data point and map it to an inline payload or report-local JSON
file in click.payloads.
{
"xAxis": ["horse"],
"series": [
{
"name": "FP",
"data": [{ "value": 42, "clickId": "outcome.fp.horse" }]
}
],
"click": {
"version": 2,
"payloads": {
"outcome.fp.horse": { "src": "outcome_fp_horse_click.json" }
}
}
}Pie charts may use the same click contract by wrapping points in a top-level
data field:
{
"data": [{ "name": "Annotated", "value": 12, "clickId": "coverage.annotated" }],
"click": {
"version": 2,
"payloads": {
"coverage.annotated": { "payload": { "action": "asset-preview", "layout": "collection", "assets": [] } }
}
}
}For external payloads, Reportifact emits { "action": "load-report-payload", "src": "..." }.
The host resolves that report-local JSON file, then handles the loaded payload
the same way it handles table row-click payloads.
modelClassComparison
Compares per-class metrics across two or more ML models on a shared horizontal scale. The first metric is active initially, and a compact switcher selects another metric.
::modelClassComparison
src: per_class_matrix.json
::{
"min": 0,
"max": 1,
"models": [
{ "id": "report-a", "name": "Detector A", "color": "#0072B2" },
{ "id": "report-b", "name": "Detector B", "color": "#E69F00" }
],
"metrics": [
{ "key": "ap", "label": "AP" },
{ "key": "f1", "label": "F1" }
],
"classes": [
{
"name": "car",
"values": {
"report-a": { "ap": 0.82, "f1": 0.78 },
"report-b": { "ap": 0.76, "f1": 0.74 }
}
}
]
}Model IDs and metric keys must be unique. Colors must be six-digit hex values. Models use circular markers distinguished by color. Overlapping markers collapse into an accessible cluster that exposes each exact value on hover, focus, or tap. With up to eight classes the full matrix is shown. Larger matrices add search and ten-row pagination. Rows are sorted alphabetically, and malformed rows without a comparable metric are ignored.
table
Server-backed Parquet table with filtering, sorting, pagination, and optional row clicks.
::table
src: per_image_metrics.parquet
pageSize: 20
fixFirstColumn: true
::Attrs:
| Attr | Default |
| --- | --- |
| src | Required Parquet file. |
| pageSize | 10 |
| fixFirstColumn | true |
| hideHeader | false |
| columnTooltips | Optional map of column name to tooltip text. |
Row clicks: include a $row_click column in the Parquet row. The value should
be a JSON string or object. Reportifact emits it through row-click. Columns
whose names start with $ are treated as system columns and are hidden from the
rendered table.
Common OvalBee payload — the single host contract is action: "asset-preview"
(see .agents/reports-and-preview.md for the full contract). It expresses a
direct single-asset preview, a synchronized side-by-side comparison, and a
browsable collection grid in one shape via assets[] and per-asset layers[]:
{
"action": "asset-preview",
"layout": "viewer",
"mode": "side",
"sync": true,
"assets": [
{ "asset": "asset-id-1", "label": "Ground Truth", "layers": [{ "annotation": "gt-ann-id", "reference": true }] },
{ "asset": "asset-id-2", "label": "Prediction", "layers": [{ "mask": "mask-id-1" }] }
]
}A layer's overlay comes from annotation (server-rendered from an OvalBee
annotation asset), mask (a ready overlay asset), or maskUrl (a ready overlay
url); a layer may be missing entirely for a pane with no overlay (e.g. a
negative image with no ground-truth annotation).
imageGallery emits an asset-preview collection payload when the user clicks
its show-all button:
{
"action": "asset-preview",
"layout": "collection",
"title": "Images",
"click": "preview",
"grid": 4,
"assets": [
{ "asset": "asset-id-1", "layers": [{ "annotation": "annotation-id-1" }] },
{ "asset": "asset-id-2", "layers": [{ "annotation": "annotation-id-2" }] }
]
}For large media grids or chart clicks, each tile can carry its own crop and a
nested compare drill-down (a full asset-preview payload opened when that
tile is clicked and click: "comparison"):
{
"action": "asset-preview",
"layout": "collection",
"title": "False positives: horse",
"click": "comparison",
"assets": [
{
"asset": "image-asset-id",
"crop": [10, 20, 120, 80],
"layers": [{ "annotation": "prediction-annotation-id" }],
"compare": {
"action": "asset-preview",
"layout": "viewer",
"mode": "side",
"sync": true,
"grid": 2,
"assets": [
{ "asset": "image-asset-id", "crop": [10, 20, 120, 80], "layers": [{ "annotation": "gt-annotation-id", "reference": true }] },
{ "asset": "image-asset-id", "crop": [10, 20, 120, 80], "layers": [{ "annotation": "prediction-annotation-id" }] }
]
}
}
]
}layout: "collection" opens a browsable thumbnail grid first; layout: "viewer"
opens the viewer directly. For a table row that represents one image, use a
direct single-asset viewer payload:
{
"action": "asset-preview",
"layout": "viewer",
"assets": [
{ "asset": "image-asset-id", "layers": [{ "annotation": "annotation-id", "label": "main / supervisely" }] }
]
}Python report nodes build these payloads via the shared builder in the OvalBee
SDK (ovalbee.report.asset_preview: single_asset_preview, compare_payload,
media_grid_payload, asset_spec, layer) rather than hand-writing dicts.
timeline
Per-row horizontal time tracks with point markers, rendered with ECharts custom series. Built for "one bar per video with cut points" visuals, but any row/track/marker data fits.
::timeline
src: all_videos_timeline.json
title: All-videos timeline
timeFormat: clock
::{
"rows": [
{
"id": "asset-id",
"name": "2026_01_15_Umaker_L9",
"group": "good",
"start": 0,
"end": 248,
"cuts": [62, 131, 198]
}
]
}Row fields: name and end are required; start defaults to 0; cuts are
marker positions in seconds; group colors the track from a built-in palette
(one legend entry per group, rows without a group share one series); color
overrides the track color for that row; click is an optional payload object
emitted through row-click when the row's track or a marker is clicked (same
host contract as table $row_click, e.g. an asset-preview payload).
Attrs:
| Attr | Values |
| --- | --- |
| src | JSON file in the report directory. |
| title | Chart title. |
| timeFormat | Initial time-axis mode: seconds (default) or clock (mm:ss, switching to hh:mm:ss past one hour). A toolbar toggle lets the viewer change it. |
| zoom | true enables x-axis zooming (inside + slider). Off by default. |
Hovering a track shows the row name, group, duration, and its cut list; hovering a marker shows that specific cut time. Widget height grows with the number of rows.
markdown
Loads and renders another Markdown file from the report directory.
::markdown
src: details.md
live: true
::Attrs: src, live (true enables refresh).
tensorboard
Renders TensorBoard scalar event data.
::tensorboard
src: events.out.tfevents
title: Training
height: 420
smooth: true
maxPoints: 1000
::Attrs: src, title, height, smooth, maxPoints.
metricsTable
JSON-backed metrics/table visualization. Use this when the report already emits the metrics JSON shape expected by the widget.
::metricsTable
src: metrics.json
title: Metrics
show_pct: true
::scope
Renders a sticky "Report scoped to" breadcrumb control above the section
navigation and re-renders the whole report for the selected data scope. A
scope is a subset of the evaluated data identified by an asset-tag value (e.g.
split.bad_weather); the base scope is all (the whole dataset).
Place the directive in the preamble (before the first # H1). It is hoisted
above the table of contents and is not rendered as a section.
::scope
src: scopes.json
::
# Overview
...src points to a manifest describing the tags, their values, and per-scope asset
counts:
{
"all": { "n": 50 },
"tags": [
{
"key": "split",
"label": "split",
"values": [
{ "value": "bad_weather", "scopeId": "split.bad_weather", "n": 24 },
{ "value": "good", "scopeId": "split.good", "n": 26 }
]
}
]
}When a scope is active, the report updates in place (no remount):
- File-backed widgets (
chart,table,imageGallery,modelComparisonGallery,tensorboard,metricsTable,modelClassComparison) load a per-scope source: the suffix.<scopeId>is inserted before the extension, sooutcome_counts.jsonbecomesoutcome_counts.split.bad_weather.json. The base scope uses the unsuffixed file. If a per-scope file is absent from the report, the base file is used. - Text (Markdown prose,
cardbodies,metricCards/metricRowstables, badges, titles) resolves$name/${name}placeholders fromvars.<scopeId>.jsonmerged over the basevars.json. Authors keep placeholders inindex.md(instead of substituting at build time) for any value that varies per scope; literal text stays static. Tokens with no matching key in the vars dictionary are left untouched, so non-scoped reports (already substituted, novars.json) render unchanged.
vars.json is a flat dictionary, e.g.:
{ "map": 55.1, "ap50": 95.9, "f1": 92.4, "n_images": 50, "n_gt": 1781 }A report without a ::scope directive (and without vars.json) behaves
exactly as before.
Embedding
<script setup lang="ts">
import { Reportifact } from '@ovalbee/reportifact';
</script>
<template>
<Reportifact
:url-map="urlMap"
base-path="/api/reports"
:media-resolver="resolveMedia"
:custom-widgets="customWidgets"
@row-click="onRowClick"
/>
</template>Important props:
| Prop | Purpose |
| --- | --- |
| url | Direct URL to index.md. |
| urlMap | Map file names to signed/public URLs. Uses index.md for the schema. |
| linkUrlMap | Optional map for Markdown links. |
| basePath | API route base for Parquet and TensorBoard proxy routes. |
| scrollContainer | Scroll container used for active section tracking. |
| refreshInterval | Refresh interval for live report widgets. |
| mediaResolver | Optional resolver for gallery assets and annotations. |
| customWidgets | Optional Vue 3 components keyed by directive name for host-owned widgets. |
Portable Widgets
Sites and other Vue hosts can render selected widgets without loading the full
Markdown report runtime. Import every widget from its direct subpath; there is
intentionally no runtime widgets barrel because it would pull ECharts into
pages that only use metric components.
<script setup lang="ts">
import ReportifactChart from '@ovalbee/reportifact/widgets/chart';
import '@ovalbee/reportifact/style.css';
const chart = {
xAxis: ['YOLO', 'DEIM'],
data: [0.71, 0.76],
};
</script>
<template>
<ReportifactChart
type="bar-gradient"
:data="chart"
caption="DEIM leads on the held-out validation set."
/>
</template>Public subpaths:
| Import | Component | Portable input |
| --- | --- | --- |
| @ovalbee/reportifact/widgets/host | ReportifactWidgetHost | Theme and loading-style boundary. |
| @ovalbee/reportifact/widgets/chart | ReportifactChart | src JSON URL or inline data. |
| @ovalbee/reportifact/widgets/metric-cards | ReportifactMetricCards | Inline data or Markdown-table content. |
| @ovalbee/reportifact/widgets/metric-callout | ReportifactMetricCallout | Inline data or direct props. |
| @ovalbee/reportifact/widgets/metrics-table | ReportifactMetricsTable | src JSON URL or inline data. |
| @ovalbee/reportifact/widgets/timeline | ReportifactTimeline | src JSON URL or inline data. |
| @ovalbee/reportifact/widgets/image-gallery | ReportifactImageGallery | src JSON URL or inline data; optional media resolver. |
| @ovalbee/reportifact/widgets/contracts | Type-only contracts | Widget data and resolver types. |
For URL-backed widgets, src is the portable spelling and legacy url remains
supported for the full report renderer. Inline data takes precedence when
both are present. Every portable widget already renders a semantic <figure>
and accepts optional caption, sourceHref, and sourceLabel props. The
shared WidgetWrapper owns that semantic frame together with loading and error
presentation; hosts do not add another wrapper. Static fallback images are not
part of the portable v1 authoring contract.
The full report directives accept the same caption, sourceHref, and
sourceLabel options. Existing reports remain valid when those options are
absent.
MetricCallout's html field is trusted pre-rendered HTML. Do not pass
untrusted user input without sanitizing it first.
Parquet tables, TensorBoard, scopes, and the full widget dispatcher remain full-report features. Publish a static summary or link to a public Live Example for those experiences instead of importing their internals into a content page.
Custom Widgets
Hosts can register Vue 3 components for directives that Reportifact does not own. The component is compiled into the host app bundle; the Markdown only names the widget and provides parsed options.
<script setup lang="ts">
import { Reportifact, type ReportifactCustomWidgets } from '@ovalbee/reportifact';
import ModelCheckpointsWidget from './custom-widgets/ModelCheckpointsWidget.vue';
const customWidgets: ReportifactCustomWidgets = {
modelCheckpoints: ModelCheckpointsWidget,
};
</script>
<template>
<Reportifact :url-map="urlMap" :custom-widgets="customWidgets" />
</template>The registered component receives:
| Prop | Purpose |
| --- | --- |
| widget | Parsed widget item, including widgetType, content, and options. |
| url | Resolved src URL, when the directive has one. |
| options | Parsed directive options. |
| html | Markdown-rendered widget body. |
| context | Helpers: resolveUrl, resolveLinkUrl, mediaResolver, emitRowClick. |
Custom widgets may expose refresh(); Reportifact calls it during manual or
interval refresh. Unknown unregistered directives render an unsupported-widget
placeholder instead of failing the report.
Example host-owned directive:
::modelCheckpoints
assetId: 3f2504e0-4f89-11d3-9a0c-0305e82c3301
::Development
npm run buildFrom this repo, prefer the containerized check:
docker compose exec web sh -lc 'cd /workspace/packages/reportifact && npm run build'