- reviewed
- 2026-06-24
- section
- visualization
- created
- 2026-07-26
- updated
- 2026-10-30
Charts
Charts
track render turns a declarative View Spec (JSON) into a chart, reading data from the
Canonical Data Model — plain JSONL files, one record per line, one kind per file. track never
fetches data itself; external sources are converted into this model by separate track-fetch-* tools.
See also Diagrams and Embeds. For a populated dashboard you can explore immediately, start with the completed metrics example. For unavailable readings, start with Missing metric observations below.
Data: the Canonical Data Model
A data file is JSONL with one homogeneous kind per file. Every record carries a schema version.
| Kind | Required fields | Meaning |
|---|---|---|
event | time, title | A point-in-time happening (news, post, milestone). |
price | entity, time, open, high, low, close | One OHLCV bar. |
metric | name, time, value | A named numeric sample, or an explicit missing observation with a reason. |
entity | id, name | A thing series refer to (ticker, index, sector). |
annotation | time, text | A label for narrative overlays. |
The full field list (with optional fields and types) is printed by track render --help, derived from
the typed structs so it never drifts. Example series.jsonl (a metric kind):
{"version":1,"name":"demo","time":"1","value":3}
{"version":1,"name":"demo","time":"2","value":7}
{"version":1,"name":"demo","time":"3","value":4}kind is a real schema: rendering validates each record against it and fails with an error if a
required field is missing or a numeric field is non-numeric, rather than drawing a partial chart. Extra
fields beyond the schema are allowed, so a spec can still chart custom columns.
Canonical data files live in the vault's data/ directory (created by track init) — that is where
track-fetch-* tools write their output. A spec references a file by path, so data may live anywhere a
spec can reach.
Missing metric observations
A missing reading is different from an observed zero. Keep a dated record with "value": null
and a nonblank missing_reason when a measurement is unavailable. Do not replace it with 0,
copy the previous reading, or omit its date if the gap should be visible.
For example, save these synthetic readings as readings.jsonl:
{"version":1,"name":"news_sources","time":"2026-10-01","value":18}
{"version":1,"name":"news_sources","time":"2026-10-02","value":12}
{"version":1,"name":"news_sources","time":"2026-10-03","value":null,"missing_reason":"Collection unavailable"}
{"version":1,"name":"news_sources","time":"2026-10-04","value":3}
{"version":1,"name":"news_sources","time":"2026-10-05","value":0}Save this View Spec alongside it as readings.json, then render it:
{
"version": 2,
"mark": "line",
"title": "News sources (synthetic)",
"data": { "kind": "metric", "source": "readings.jsonl" },
"encoding": {
"x": { "field": "time" },
"y": [{ "field": "value", "title": "Source count" }],
"detail": [{ "field": "missing_reason", "title": "Missing reason" }]
}
}track render --spec readings.json --out readings.html
track render --spec readings.json --out readings.svg --renderer svgThe same example is rendered here. October 3 breaks the line; October 5 is a genuine zero:
Keep the explanation beside the chart when readers must always see it. A missing mark may have
no hover target, even with encoding.detail:
| Date | Reading | Explanation |
|---|---|---|
| October 3 | Unknown | Collection unavailable; this is not evidence that attention fell. |
| October 5 | 0 | The measurement completed and observed zero sources. |
A source URL or vault note can travel with a reading using the existing encoding.href or
encoding.note channel. Use encoding.detail for a reason, source label, or other extra columns;
for a larger dashboard, the article table can keep
all reasons and evidence visible. Tracking collection health and calculating the readings remain
the producer's responsibility: track does not fetch data or infer why it is missing.
What charts and dashboards do with a gap
- Line and area: break at the missing observation, without connecting across it or drawing zero. A date absent from the file cannot create a gap automatically; supply an explicit missing record.
- Moving average: a
windowcontaining a missing observation is also missing. The average resumes once the complete trailing window has numeric readings. - Ranking:
sort: "value"andsort: "-value"both place categories with no finite values after observed categories, then applylimit. A real zero still ranks numerically: in a descending ranking it comes before a negative value, while a missing value comes after both. Partially observed multi-series categories keep their existing sum of finite values; check completeness in the producer. - Gauge: an explicit null in the newest filtered input clears the needle and numeric reading.
A later number, including zero, restores them. A gauge uses
mark: "gauge"with"encoding": {"y": [{"field": "value"}]}and no x channel. Input order determines latest, so write observations in chronological order; track does not calculate freshness. - Dashboard import: a valid series containing only missing observations can still be selected
by
track metrics dashboard. It is an existing series with no readings yet, not an unknown series.
Fixing invalid metric records
Rendering stops with an error for malformed data instead of silently treating it as missing:
- Keep
name,time, and thevaluekey on every record, including missing observations - Use JSON
null, not the string"null", and provide a nonblank stringmissing_reason - Omit
missing_reasonwhen the value is numeric; a nonblank reason with a number is contradictory - Do not use
NaN, infinity, an empty string, or arbitrary text as a number
These rules apply to metric.value; price OHLC fields still require numbers. Existing numeric
metric records, including numeric strings, continue to work without migration. Older track versions
may reject the new null form, so update readers before sharing datasets that use it.
View Spec: one chart
A View Spec names a data source, a mark (what to draw), and an encoding that maps record fields onto visual channels. It knows nothing about the renderer, so the same spec can be drawn by different backends.
{
"version": 2,
"mark": "line",
"title": "Line",
"data": {
"kind": "metric",
"records": [
{ "name": "demo", "time": "1", "value": 3 },
{ "name": "demo", "time": "2", "value": 7 },
{ "name": "demo", "time": "3", "value": 4 }
]
},
"encoding": {
"x": { "field": "time" },
"y": [ { "field": "value" } ]
}
}track render --spec chart.json --out chart.htmlRendered output:
Key fields:
mark—line,bar,point,area,rect,candlestick,treemap, orgauge.encoding.*.type—quantitative(default) ornominal(a category). Nominal on the right axis picks the form: a nominal-ybaris horizontal, a nominal-xpointis a scatter (vs a bubble), andrectneeds nominal x and y.encoding.y[].axis— set"y2"to put a series on a secondary right-hand axis (two series on different scales).encoding.y[].domain— optional[min,max]for a quantitative primary value axis. Shared-axis declarations must agree; bar/area ranges include zero; out-of-range data is a render error.encoding.color— a nominal field that splits records into one colored series per value (onrectit is instead the quantitative heatmap cell value).sort/limit— on the category-axis channel: order categories by label or value (ascending|descending|value|-value) and keep only the first N (top-N).stack— on a bar's measure channel (y[0], orxfor a horizontal bar): stack the series.filter—{field, equals}shorthand, or{all: [{field, op, value}]}withopofeq|ne|lt|le|gt|gefor multi-field, range, and period filtering.y[].mark—line|bar|areaon a y channel draws that series in its own form: a combo chart (volume bars + an index line ony2). Dense category charts also zoom automatically: wheel/pinch always, plus a range slider past 30 categories.y[].window— replace the series with its rolling mean over the trailing N records (a moving average); the first N-1 points stay empty.overlays— reference geometry over the chart:{source, kind}(or{records, kind}with the events inline) draws events/annotations as vertical markers,{y, axis?, label?}a horizontal threshold line,{from, to, label?}a shaded period band, and{x, y, label}a callout bubble pointing at one data point. A marker overlay withdisplay: "box"also draws each event as an always-visible annotation box hugging the plot (web reader only).
Embedding a chart in a note
Every image on this page is a View Spec asset rendered by track, not a hand-made picture. A
.viewspec.json asset carries the spec and its data inline (data.records), so it is a complete,
self-contained chart. Embed it like any image and track resolves it at build time; the published page
draws it as an interactive ECharts chart:
Just drop the .viewspec.json file into the assets directory and reference it — no import step is
required (track asset import only copies a file there for you). There is no separate data file to
keep in sync and no CDN — the engine resolves the spec at build time and the site's bundled ECharts
draws it. The charts below are each one embedded .viewspec.json.
Writing the spec inline
Prefer to keep the spec in the note itself? Fence a block with viewspec — the same way a mermaid
fence embeds a diagram — and it renders as an interactive ECharts chart (hover
tooltips, legend toggling) in both the web workspace and the published site.
Inline data.records keeps the block self-contained; data.source reads a JSONL file
from the vault's data/ directory. If the spec is invalid, the error and the source are shown at the
block's position, so a typo never hides your text:
```viewspec
{
"version": 2,
"mark": "bar",
"title": "Notes per week",
"data": {
"kind": "metric",
"records": [
{ "name": "notes", "time": "W1", "value": 4 },
{ "name": "notes", "time": "W2", "value": 7 },
{ "name": "notes", "time": "W3", "value": 5 },
{ "name": "notes", "time": "W4", "value": 9 }
]
},
"encoding": {
"x": { "field": "time", "type": "nominal", "title": "Week" },
"y": [ { "field": "value", "title": "Notes" } ]
}
}
```It renders as (live):
area — a line with the region down to zero filled; every line channel (color split, y2, sort) works unchanged.
bar — values per category; the baseline is pinned to zero, so negatives drop below it.
Horizontal bar (mark: bar, nominal y) — for rankings; categories run down the left, the value axis along the bottom.
Scatter (mark: point, nominal x) — points over a category x-axis, the connecting line suppressed.
Color split (encoding.color, nominal) — one series per category value, each in its own color; works on line, bar, and point.
Sort and top-N (sort, limit on the category-axis channel) — order categories by label
(ascending/descending) or by their value total (value/-value), and keep only the first N.
Here a ranking: the nominal y sorted -value with limit: 5.
Stacked bars (stack: true on the bar's measure channel) — series pile up per category instead
of sitting side by side; combines with a color split. On a horizontal bar, set it on encoding.x.
Combo (y[].mark) — series in mixed forms: volume bars with an index line on the secondary axis.
Heatmap (mark: rect) — a 2D grid of x columns × y[0] rows, each cell colored by encoding.color (with a value legend).
Timeline (mark: point, nominal y) — one dot per record at its (column, lane); an optional size scales the dot, one color per lane.
Candlestick (mark: candlestick, kind: price) — one OHLC candle per record: a high–low wick
behind an open–close body, green when the close is at or above the open, red otherwise. The
open/high/low/close fields are implied by the price kind, so the encoding needs only x.
Moving averages and volume (encoding.y on a candlestick) — explicit y channels draw extra
series over the candles: window: N turns a field into its rolling mean (close + window: 25 =
MA25, starting as a gap until N records exist), and {"field": "volume", "mark": "bar", "axis": "y2"}
adds volume bars in a bottom band, each colored by its candle's direction (green rising, red falling).
"y": [
{ "field": "close", "window": 5, "mark": "line", "title": "MA5" },
{ "field": "close", "window": 25, "mark": "line", "title": "MA25" },
{ "field": "volume", "mark": "bar", "axis": "y2", "title": "Volume" }
]Treemap (mark: treemap) — the industry-map view: one rectangle per record, its area from
encoding.size and its color from encoding.color, grouped one level by an optional nominal y[0];
the nominal x names each leaf. With "scale": "diverging" on the color channel the ramp centers on
zero — negatives red, positives green, like the candlestick colors — so a market-cap map of daily
change reads at a glance. It is axis-less: sort/limit and overlays are rejected.
Overlays — a vertical {records, kind} event marker, a dashed {y, label} threshold line, a
shaded {from, to, label} period band, and a {x, y, label} callout bubble pointing at a data point,
over any category-axis chart. All four travel with the spec (inline records or literal values — no
second data file), so they work in embedded assets too.
Annotation boxes (display: "box" on a marker overlay) — each event also becomes a small
always-visible box hugging the plot, boxes alternating above and below it: the date (the at value,
trimmed to its day), the wrapped label, and a source link taken from the record's url. The chart
doubles as a scannable index of its evidence. The rail is drawn by the web reader (workspace and
published site); the standalone HTML page, the composed article, and the SVG renderer keep the
classic marker look.
"overlays": [
{
"records": [
{ "time": "2026-01-12", "title": "v1.0 released", "url": "https://example.com/release" }
],
"kind": "event",
"label": "title",
"display": "box"
}
]A bubble (mark: point with a quantitative x, {x, y, r} points sized by size) is drawn over
linear axes by both the default echarts renderer and the svg renderer.
Renderers
| Renderer | Output | Notes |
|---|---|---|
echarts (default) | Self-contained HTML | Interactive (tooltips, legend toggling); loads Apache ECharts from a CDN at view time. All marks. |
svg | Static SVG | No scripts, no CDN — embeds anywhere. All marks, including heatmap, timeline, and candlestick. |
track render --spec chart.json --out chart.svg --renderer svgArticles: prose + charts in one page
A spec with a blocks array composes Markdown prose, multiple charts, and filterable tables into a
single HTML article — the "data + layout + rendering" unit for a data story.
The full notation (every field, all marks, examples) is always available with:
track render --helpReusable metrics dashboards
Explore a completed dashboard
The three panels below are a working Track dashboard, using 35 days of fictional cache-hit readings for Checkout and Search. It needs no Grafana server or collector. The wide chart compares levels; the two smaller charts show day-to-day changes on separate scales.
Cache observations · synthetic
Hover a point to read its value. Click Search in a chart legend to hide that line, then click again to restore it. Drag the bottom range slider to inspect part of the history. A missing Checkout reading breaks its line; expand the missing-observation details to read the reason.
Latest synthetic samples (2026-10-29):
| Service | Cache hit rate | Change from previous day | Relative change |
|---|---|---|---|
| Checkout | 84% | +2 percentage points (82% → 84%) | +2.44% |
| Search | 68% | −1 percentage point (69% → 68%) | −1.45% |
This published help page contains a snapshot: legend selection, tooltips and chart zoom work, but live Period and Refresh controls are not present. Its dates and values stay fixed until the site is rebuilt. Exporting a dashboard publishes the selected observations and metadata.
Try Period and Refresh in the live workspace
From the repository root, build the complete CLI and run the example:
nix build .#track-cli
python3 examples/metrics-dashboard/demo.py --track ./result/bin/trackOpen http://127.0.0.1:18765. The script uses an isolated temporary vault, shifts only the sample
dates so the latest observation is today (UTC), and opens the saved dashboard with all three charts
populated. It uses no personal notes, external data or Grafana connection. Stop with Ctrl+C to remove
the temporary vault. Use --port 18766 if the default port is occupied.
- Choose Period → Last 7 days: each chart narrows to seven days. Choose All observations to bring back the complete history and visible collection gap.
- Click a legend entry to select a service; hover the final point to read 84% or 68% in the level chart.
- Press Refresh to reread the same file. The values stay the same until the data changes.
- Reopen the note: its saved title, panel layout and data bindings are reused.
The repository's examples/metrics-dashboard/README.md also explains how to keep the example in
an existing vault. The note, Grafana-subset JSON and synthetic JSONL are all included.
Save your own definition
Save a metrics-dashboard fence in an ordinary note. Update its referenced JSONL with a collector,
CLI, skill or AI without regenerating the screen. Existing note editing saves the title, panel order,
layout and datasource bindings. The example above uses this same path; it is not an image or mock UI.
This is a Track chart dashboard rendered locally with ECharts. It does not run Grafana or contact a
Grafana server. It accepts the Grafana classic JSON subset used by track metrics dashboard; that
CLI importer performs a one-time conversion into View Specs, while the fence keeps selectors and
resolves them on each display and refresh.
In a live workspace, Period selects all observations or a trailing day, week, month or year from
the request time. Refresh rereads the bindings. Data-file changes and event-stream reconnections
refresh automatically; an event during the first load replaces its pending response. Period and
legend selections are viewing controls, not saved definition changes. Old fixed-date datasets can
correctly show no observations in a recent period. Panels resize and stack on narrow screens.
gridPos.x and gridPos.w use a 24-column grid; y orders panels. Fixed row heights, absolute
vertical positioning, drag editing and gridPos.h are outside this subset.
Series identity and color
Track's web reader and published site use an ink/vermilion-led palette that adapts to light and dark themes. Line and area series also use different line patterns and point shapes: the first is solid with circles, the second dashed with diamonds. The chart legend uses the point shapes, and a text key below a multi-line chart spells out each series' pattern and marker. Hover still shows the series name and value; selecting a legend entry can isolate overlapping readings.
Dashboard style slots come from all matching series in the bound file, before Period is applied. A series keeps its color, line pattern and marker when another series falls outside the period and returns. Reordering source records or refreshing observations for the same series does not change those slots. Raw ECharts options use stable id (or name) order when their array is reordered; unnamed series use their array position. Editing the complete identity set can reassign defaults, so use explicit styles when that assignment must survive source identity changes.
The six default pattern/shape combinations repeat after six series. Split crowded charts or select fewer series when lines overlap heavily. Dense data may show fewer point symbols until zoomed in. Missing readings still break the line. Static exports must be rebuilt to receive these defaults.
The default categorical colors are tested for at least 3:1 contrast against the reading surfaces in both themes. This does not guarantee contrast between every pair of intersecting lines or every custom color, and is not a claim of complete WCAG conformance. Color is an additional cue rather than the only key; see W3C Use of Color and Non-text Contrast.
For deliberate overrides:
- A raw
echartsfence can setcolor,series[].lineStyle.type/width,symbol,symbolSizeandshowSymbol. Explicit palette and line/marker choices are retained. Check custom choices in both themes; a fixed dark color may disappear on a dark background. - View Specs support
encoding.color.colorsfor named category colors andencoding.y[].dash(solid,dashed,dotted) andwidthfor line/area measure channels. A category color split receives the automatic pattern/shape defaults; it does not expose per-category dash settings. - Developers can override the
--chart-1through--chart-6stylesheet tokens. There is no palette picker in the workspace. Metrics dashboards use these theme defaults; arbitrary GrafanafieldConfig.overridesstyling is not part of the supported subset.
These reader defaults do not restyle an external Grafana server, the standalone HTML/SVG renderer, or semantic candlestick/market colors. Heatmaps and other non-line forms need their own accessible encoding; the line-style key is not a general accessibility certification for every chart type.
Supported data and options
Supported targets are a metric name with optional equality label matchers, such as
demo_level{entity="Checkout"}. A live panel selects metric names from one file, optionally across entities and folded labels; mixed record units fail. The CLI importer requires one folded metric name. All target datasource bindings must use the same file, even if a target currently matches no observations; missing data is never filled from another source. Put different metrics or units in separate panels. legendFormat supports {{entity}}
and folded labels. Datasources use {"type":"track","uid":"<bare filename>"}; traversal and symlinks
outside data/ fail. SQL, PromQL functions, dashboard variables and other panel types are not
implemented. stat is displayed as a line chart. Limits are 48 panels and a 1 MiB request body.
unit labels the axis without converting numbers; an absent or none panel unit uses the declared record unit; min, max and numeric threshold steps are
supported. Other Grafana options/plugins are not interpreted; this is not a full Grafana importer.
Observation frequency, units and provenance
A panel reports latest observation time, data-file modification time, observations and explicit
missing values. File modification is not source publication or collection time. Optional record
fields unit, frequency, publication_frequency, scope, source, url, asof, available_at,
fetched_at and revision remain visible in the series metadata or point tooltip. All optional fields, including nested provenance, source versions and publication/retrieval/ingestion evidence, are kept in expandable series metadata. A point's HTTP(S) url (or href) opens its source. The producer supplies these facts; Track does not infer them.
Date-only observations use UTC midnight as a reference for ordering and the rolling-period cutoff; this is not an intraday measurement. When every selected observation is date-only, axis labels stay date-only. Mixed timestamps are ordered as UTC instants. Original supplied timestamps, precision and timezone metadata remain available in series metadata and point details.
Use separate metric names for the original level, absolute difference and relative percentage
change. A percentage level moving from 20% to 22% changes by 2 percentage points and 10%
relative to its earlier level. Label these units separately; do not combine them on one scale.
Derived values belong in the data producer or domain adapter. Keep observation scopes explicit in
scope/entity; a whole-system reading must not be presented as a measured service reading.
A weekly publication stays weekly even if the collector runs hourly. Track does not
resample, invent intraday observations, fill gaps or evaluate stale-data alerts.
Display an existing Grafana dashboard
This is a separate feature for people who already have a Grafana server. To explore a populated example without a server, use the Track dashboard above or its temporary-vault demo. Track does not provide a Grafana server or a remotely hosted demo.
Replace the placeholder URL below with your existing dashboard's HTTPS URL, then save a grafana
fence. The placeholder is configuration syntax, not a running dashboard:
```grafana
{"title":"Operations","url":"https://grafana.example.test/d/operations/service?from=now-7d&to=now"}
```The saved note first shows the server origin, Load Grafana from …, and an external link. Loading creates a frame for that server. Disconnect Grafana removes it; Reconnect recreates it. Track does not substitute its own charts into this frame. What appears inside depends on your Grafana dashboard, viewer permissions and server framing policy. A real authenticated deployment needs its own end-to-end check; interactive sign-in in the frame is not promised.
Connection and permission details
Loading admits only the shown origin for that mounted frame. Moving to another note or vault clears admission, including cached notes with identical fences; returning requires loading again. The external link remains available when offline, blocked by framing policy or using browser sign-in.
Only external HTTPS /d/<uid>/<slug> and /d-solo/<uid>/<slug> URLs are accepted, including a
Grafana installation subpath. URL credentials, fragments, loopback hosts, Track's own origin and
unknown query parameters are rejected. Accepted parameters are from, to, orgId, panelId,
theme, kiosk, fullscreen, refresh, and var-*. Keep secrets out of URLs and note content.
The frame has scripts and its own origin/storage, but no top navigation, popups, forms, downloads
or native bridge. Your browser contacts the server directly; Track manages no proxy or credentials.
Grafana's framing headers/CSP and browser/WebKit cookie restrictions still apply. Track cannot reliably inspect cross-origin frame success, so it keeps the external fallback visible. It does not create anonymous access, public sharing or authentication settings. The native shell blocks unrelated loopback frames and opens explicit external links in the default browser.
See Grafana's sharing documentation and configuration reference for server-controlled permissions and framing behavior.