ComposableChart (Next)
A layout-driven chart widget that stacks multiple UI slices in one container. Time-synced slices share a primary x-domain and interaction state (zoom, pan, hover scrubber). Non-synced slices (legend, static, transcript, playback controls, flight tracks, video playback) sit in the stack but do not participate in x-axis sync.
Use this widget when a design needs mixed chart types (area + event lanes + comms ranges), synchronized scrubbing, per-series legend toggles, and per-slice fault isolation in one panel.
Full support (recommended)
This is the supported production path. It requires:
dashboard-v2runner and$heat-dataservicechannel payloadsComposableChartDSinui/dashboard(not the legacy wrapper)- v2 layout row
configuration.composableChartItemper heat-layout schema - Page- or row-level
PlayBackControlDSdriving the realmTimelineClockso the yellow cursor and other playback widgets stay in sync
If you are on Legacy dashboards (ui/legacy, v1 combined-custom-charts), use the bridge documented in ComposableChart (Legacy) instead. Legacy binding has no $heat-dataservice, no cross-widget clock sync, and requires an explicit legacy manifest.
Current state and limitations
| Area | Status |
|---|---|
| Area (multi-series), legend, events lanes, ranges lanes, spanning lines | Ready for layout-driven use |
| Hover scrubber, magnetic snap, tooltip | Ready |
| Vertical time grid + bottom tick labels | Ready (composableChartItem.xAxis.ticks) |
| Static slice (StatsCard payload) | Ready |
| Transcript slice (timed utterances) | Ready. Optional highlightTerms underlines matching tokens |
| Scatter slice | Implemented; limited Storybook coverage |
Engagement slice (per-target rows, isTargetEngagement mode) | Ready for layout-driven use |
| Yellow playback cursor (realm clock) | Ready when anchor is "time" and page/row playback controls drive the clock |
playback slice (in-chart scrubber) | Ready. Seek bar with playhead timestamp. No play button. Storybook: Organisms/ComposableChart/PlaybackWithAircraftLanes |
flight_tracks slice | Ready. Dual-pane lat/lon trail + altitude strip, legend-filtered, clock-scrubbed. |
video_playback slice | Ready. Video-atlas seat tiles following the realm clock; playback bounded to the clock span or videoPlayback.bounds. Storybook: Organisms/ComposableChart/VideoPlaybackBounded |
markdown slice | Ready. Inline Markdown text block. Content from markdownContent in layout (no channel needed). Storybook: Organisms/ComposableChart/MarkdownSliceMock |
| Index anchor | Partial , x-domain derived from longest slice; less tested than time anchor |
none anchor | Static-only stacks |
General limitations
- Rendering is SVG + D3 (not ApexCharts). Very large series may need channel downsampling upstream.
- Slice channel binding uses URI strings on each element, not the top-level
configuration.channelsmapping. - Multiple widgets of the same type in one layout are supported via distinct
configuration.nameand element channel URIs. - Empty
label: ""on a slice suppresses the lane title (used for combined area + external legend).
Dashboard generation
| Value | |
|---|---|
| Runner node | dashboard-v2 |
| Frontend | ui/dashboard (ComposableChartDS) |
| Data envelope | $heat-dataservice (contract) |
| Rendering | SVG + D3 scales/zoom |
Layout component identifier
ComposableChart , configuration.component in v2 layout rows.
Layout configuration
Typical fields on the row configuration object:
| Field | Type | Required | Description |
|---|---|---|---|
component | string | Yes | "ComposableChart" |
name | string | Yes | Internal widget name |
titleContent | string | Yes | Panel title |
channels | string[] | Yes | Legacy/base channel list (may be empty when slices bind via URI) |
composableChartItem | object | Yes | Slice definitions and anchor |
composableChartItem.anchor | "time" | "index" | "none" | No | Primary x-axis semantics (default: "time") |
composableChartItem.xAxis | object | No | Chart-wide vertical grid and tick labels: { ticks?: number[], timeLabelMode?: "auto" | "elapsed" | "clock" | "dateTime" | "dateTimeLong" } |
composableChartItem.elements | array | Yes | Ordered slice configs |
composableChartItem.isTargetEngagement | boolean | No | Target Engagement mode: renders only legend and engagement elements as stacked per-target rows, disables pan/zoom, and shows the Targets multi-select dropdown |
composableChartItem.showMultiDropdown | boolean | No | Kept alongside isTargetEngagement for parity with the UI types; the dropdown itself is driven by isTargetEngagement |
Each element in composableChartItem.elements:
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Slice type (see Slice reference) |
order | number | Yes | Vertical stack order (lower = higher) |
label | string | No | Lane title; empty string hides the title |
channels | string[] | No | Channel URIs: realm:channelId:name or channelId:name |
height | number | No | Slice height in pixels (default 72) |
style | object | No | Colors, opacity, marker size, visibilityKey for legend wiring |
yAxis | object | No | Area slice only (see below) |
series | array | No | Area slice: multiple series in one band |
eventSeries | array | No | Events lane and engagement slices: multiple toggleable event types |
rangeSeries | array | No | Engagement slice: overlaid range bars (id, label, color, tooltipLabel, barHeight) |
legendItems | array | No | Legend slice: toggle keys |
times | number[] | No | Spanning lines: vertical line positions in x-domain units |
videoPlayback | object | No | Video playback slice: { bounds?: { fromMs, toMs }, enablePlayPause?: boolean }. bounds are absolute capture-clock ms (see video_playback) |
Playback for this widget
- Set
enableGlobalPlaybackControlsor rowenablePlaybackControlson the session layout to showPlayBackControlDS. - The chart draws a yellow vertical cursor at
xDomain[0] + clock.elapsedMswhenanchoris"time". - Add a
playbackelement for the in-chart seek bar (no play button). Page-levelPlayBackControlDSremains optional.
Layout excerpt (terrain-style timeline)
{
"component": "ComposableChart",
"colspan": 12,
"configuration": {
"name": "TerrainTimeline",
"titleContent": "Terrain Timeline",
"channels": [],
"defaultRealm": "circuit-03",
"enablePlaybackControls": true,
"composableChartItem": {
"anchor": "time",
"xAxis": { "ticks": [0, 300000, 600000, 900000] },
"elements": [
{
"type": "legend",
"order": 0,
"height": 40,
"legendItems": [
{ "id": "speed", "label": "Speed", "color": "#81842C", "swatch": "line", "isLabel": false }
]
},
{
"type": "area",
"order": 1,
"label": "",
"height": 160,
"yAxis": {
"domain": [0, 100],
"bandLabels": [
{ "label": "LOW", "value": 16.66 },
{ "label": "MEDIUM", "value": 50 },
{ "label": "HIGH", "value": 83.33 }
],
"gridLines": [33.33, 66.66]
},
"series": [
{
"id": "speed",
"label": "Speed",
"channel": "circuit-03:timeline-speed:Air Speed",
"color": "#81842C",
"render": "line_gradient",
"unit": " km/h"
}
]
},
{
"type": "ranges_lane",
"order": 2,
"label": "Comms",
"height": 32,
"channels": ["circuit-03:timeline-comms:Comms Events"],
"style": { "color": "#0B5370", "visibilityKey": "comms" }
},
{
"type": "events_lane",
"order": 3,
"label": "Commander",
"height": 32,
"eventSeries": [
{ "id": "cmdr_rep", "label": "Reports", "color": "#666666" }
]
}
]
}
}
}Slice reference
Slices render top-to-bottom by order. Synced slices share zoom, pan, hover scrubber, and (when enabled) the yellow playback cursor. Non-synced slices are legend, static, transcript, playback placeholder, flight tracks, video playback, and spanning lines overlay (zero height band).
legend
Toggle row for series visibility. Each legendItems[] entry needs a unique id matching series ids (area series[].id, eventSeries[].id, style.visibilityKey, or spanning line key).
| Channels | None |
| Sync | No |
| Config | legendItems: { id, label, color, swatch?: "line" | "vertical-line" | "vertical-dashed-line" | "square" | "dot" | "diamond" | "cross" | "primary" | "secondary" | "bolt", isLabel?: boolean }[] |
| Behaviour | Click toggles visibility for that id across the chart |
| Limitations | Toggle state resets when slice config changes |
area
Primary time-series band. Supports multiple series in one slice via series[].
| Channels | One URI per series[].channel, or legacy single channels[0] |
| Channel shape | series → { timeMs, value }[] |
| Sync | Yes |
| Config | series[]: id, label, channel, color, fill, render (line, line_gradient, step_area), unit, tooltipLabel; yAxis: domain, bandLabels, gridLines |
| Behaviour | Linear or step interpolation; gradient fill for speed-style lines; hover nubs on scrubber; y-axis band labels (e.g. LOW/MEDIUM/HIGH) are config-driven, not hardcoded |
| Limitations | Single-channel legacy path only supports one series; prefer series[] for multi-series |
scatter
Points plotted by time (x) and value (y).
| Channels | channels[0] |
| Channel shape | series |
| Sync | Yes |
| Config | style.color, style.markerSize |
| Limitations | One series per slice; y-scale auto from data extent |
events_lane
Horizontal lane of event dots at fixed y. Supports multiple event types per lane via eventSeries[] (e.g. Commander Reports + Commands).
| Channels | Optional per eventSeries[].channel; static times[] for mocks/tests |
| Channel shape | events → { timeMs, occurred }[], or boolean samples on a series channel |
| Sync | Yes |
| Config | eventSeries[]: id, label, color, tooltipLabel, shape (same vocabulary as legend swatches, including bolt), optional channel / times |
| Behaviour | Each series toggled independently via legend; magnetic snap on hover; dots grow when highlighted |
| Limitations | Lane collapses visually when all its series are hidden; single-channel legacy mode uses one color |
ranges_lane
Horizontal bars for intervals (e.g. comms active periods). Supports single-series and multi-series modes.
Single-series — one ranges channel, one colour:
| Channels | channels[0] |
| Channel shape | ranges → { startTimeMs, endTimeMs, durationMs }[] |
| Sync | Yes |
| Config | style.color, style.visibilityKey (legend id) |
| Behaviour | Highlight when hover/scrub time falls inside range; tooltip shows duration |
Multi-series — multiple named, individually coloured series sharing the same lane row:
| Channels | None at top level — each series supplies its own channel |
| Channel shape | ranges per series |
| Sync | Yes |
| Config | rangeSeries[]: id, label, color, tooltipLabel, barHeight, channel. Each series is toggled independently via legend |
| Behaviour | All series render at the same vertical position on the lane; series hidden via legend collapse the lane when all are hidden |
engagement
One per-target row combining overlaid range bars and an event marker stream, used with composableChartItem.isTargetEngagement: true to build a stacked multi-target Target Engagement panel (one engagement element per target).
| Channels | channels[0], exactly one channel per element |
| Channel shape | value with data.value.kind: "engagement" (see Example channels) |
| Sync | Yes (pan/zoom disabled in isTargetEngagement mode) |
| Config | rangeSeries[]: id, label, color, tooltipLabel, barHeight; eventSeries[]: id, label, color, tooltipLabel, shape (dot/diamond/cross/primary/secondary/bolt) |
| Behaviour | Row title comes from the channel’s engagementData.title, which also populates the Targets multi-select dropdown; elements whose channel has no data are dropped, so layouts can over-provision elements (fixed channel ids engagement-target-1..N) and adapt to the session’s real target count |
| Limitations | Range bars render solid fills only (no dashed or pattern fills); rangeSeries/eventSeries entries match engagementData arrays by id, unmatched ids are ignored silently |
spanning_lines
Vertical lines spanning the synced stack (e.g. hits received). Not a visible slice band (height: 0).
| Channels | None (use times[] in layout or resolve from data later) |
| Sync | Overlay only |
| Config | times[] in x-domain units; style.color, style.visibilityKey |
| Behaviour | Toggled via legend; drawn above synced area, below hover capture |
| Limitations | No channel-driven resolution in DS yet for all deployments; prefer explicit times in layout for static demos |
markdown
Inline Markdown text block rendered inside the chart stack. Content is embedded directly in the layout element via markdownContent — no channel or runner payload is required.
| Channels | None (content is layout-embedded) |
| Sync | No |
| Config | markdownContent (Markdown string on the element); height (default 120) |
| Behaviour | Renders Markdown via MarkDownContent atom in a foreignObject; 12 px inset top and bottom |
| Storybook | Organisms/ComposableChart/MarkdownSliceMock |
static
Non-chart content block (typically StatsCard weather summary).
| Channels | channels[0] |
| Channel shape | value → { value: { statsCard: [...] } } or similar JSON |
| Sync | No |
| Config | style.displayInSingleRow |
| Behaviour | Renders HTML via foreignObject; errors isolated to slice |
| Limitations | Only StatsCard-shaped payloads are styled; other JSON shows fallback text |
transcript
Out-of-band scrollable transcript list synced to the shared realm clock. Use under a ranges_lane (for example Comms occupancy) when you need utterance text.
| Channels | channels[0] |
| Channel shape | value → [{ start, end, text }, ...] or { segments: [...] } (start/end elapsed ms) |
| Sync | No (not in scrubber / playback-cursor bands) |
| Config | label (header, default Transcript), style.color (active row accent), highlightTerms (optional string list to underline), height (default 160) |
| Behaviour | Highlights the row whose [start, end] contains playback.elapsedMs; click seeks to segment start; auto-scrolls active row into view |
| Limitations | No collapse control in v1; requires page-level playback for highlight |
playback
In-chart seek bar wired to the realm TimelineClock. No play/pause button and no duration readout. Page-level PlayBackControlDS remains available when a design needs those controls.
| Status | Ready |
| Channels | None |
| Sync | Seeks playback.onSeek |
| Behaviour | Slider with a persistent playhead timestamp (layout: scrubber) |
| Storybook | Organisms/ComposableChart/Slices/Playback, Organisms/ComposableChart/PlaybackWithAircraftLanes |
flight_tracks
Dual-pane spatial slice: left is a lat/lon trail canvas, right is an altitude strip. Both panes share the same named tracks, the chart legend, and the realm clock.
| Channels | channels[0] |
| Channel shape | value → { tracks: [{ id, label, color, points: [{ x or timeMs, lat, lon, alt }] }] } |
| Sync | No (not in the x-axis overlay band). Scrubs from playback.elapsedMs. |
| Config | flightTracks.leftPercent (default 72), flightTracks.topDownView (default true), height (default 288) |
| Behaviour | Legend ids hide a track from both panes. Trails truncate at the playhead. |
| Storybook | Organisms/ComposableChart/Slices/FlightTracks, Organisms/ComposableChart/PlaybackWithAircraftLanes |
video_playback
Video-atlas seat tiles inside the chart stack. Renders the same N-up seat view as the standalone VideoPlayback column, but follows the chart’s realm clock and plays only the window the chart is looking at.
| Status | Ready |
| Channels | channels[0]: one value channel holding a video-atlas manifest, written as <realm:>video_atlas:video_atlas (channel id and name identical). List the same channel on the chart column channels. |
| Channel shape | value → video-atlas manifest (see the VideoPlayback data contract) |
| Sync | No (non-synced HTML panel, like flight_tracks). Follows the realm clock for seek position and play state. |
| Config | videoPlayback.bounds ({ fromMs, toMs }, absolute capture-clock ms, same domain as the manifest epochMs, fromMs < toMs); videoPlayback.enablePlayPause (accepted, currently ignored); height (default 240) |
| Behaviour | One tile per seat in the atlas geometry. Playback is bounded: by default the playable window is the realm clock’s [originMs, originMs + durationMs]. A vignette realm’s clock spans only the vignette, so inside a vignette modal the slice plays, seeks, fetches and buffers only the atlas windows inside the vignette. videoPlayback.bounds overrides that window. With no origin on the clock and no bounds the whole atlas plays (the standalone-dimension case). Windows outside the bounds are never fetched. |
| Pairing | Add a playback slice for the scrubber and one events_lane per seat for gaze lanes. Play/pause belongs to the playback slice or page-level controls, not to this slice. |
| Storybook | Organisms/ComposableChart/VideoPlaybackBounded |
Layout excerpt (eye tracking, one vignette realm; the default window is the vignette clock span, so no bounds are set):
{
"component": "ComposableChart",
"colspan": 12,
"configuration": {
"name": "EyeTracking",
"titleContent": "Eye Tracking",
"channels": ["video_atlas", "gaze-seat-1", "gaze-seat-2", "gaze-seat-3", "gaze-seat-4"],
"defaultRealm": "vignette-01",
"composableChartItem": {
"anchor": "time",
"elements": [
{
"type": "legend",
"order": 0,
"height": 40,
"legendItems": [
{ "id": "fixation", "label": "Fixation", "color": "#7EB8DA", "swatch": "dot" }
]
},
{
"type": "video_playback",
"order": 1,
"height": 240,
"channels": ["vignette-01:video_atlas:video_atlas"]
},
{ "type": "playback", "order": 2, "height": 40 },
{
"type": "events_lane",
"order": 3,
"height": 36,
"label": "Seat 1",
"eventSeries": [
{ "id": "fixation", "label": "Fixation", "channel": "vignette-01:gaze-seat-1:Fixation", "color": "#7EB8DA", "shape": "dot" }
]
},
{
"type": "events_lane",
"order": 4,
"height": 36,
"label": "Seat 2",
"eventSeries": [
{ "id": "fixation", "label": "Fixation", "channel": "vignette-01:gaze-seat-2:Fixation", "color": "#7EB8DA", "shape": "dot" }
]
},
{
"type": "events_lane",
"order": 5,
"height": 36,
"label": "Seat 3",
"eventSeries": [
{ "id": "fixation", "label": "Fixation", "channel": "vignette-01:gaze-seat-3:Fixation", "color": "#7EB8DA", "shape": "dot" }
]
},
{
"type": "events_lane",
"order": 6,
"height": 36,
"label": "Seat 4",
"eventSeries": [
{ "id": "fixation", "label": "Fixation", "channel": "vignette-01:gaze-seat-4:Fixation", "color": "#7EB8DA", "shape": "dot" }
]
}
]
}
}
}Data contract
Publish canonical $heat-dataservice with realms, groups, and channels per the dashboard-v2 upstream contract.
Channel shapes summary
| Slice type | Channel shape | Data used |
|---|---|---|
area | series | { timeMs, value }[] |
scatter | series | { timeMs, value }[] |
events_lane | events | { timeMs, occurred }[] |
ranges_lane | ranges | { startTimeMs, endTimeMs, durationMs }[] |
engagement | value | { value: { kind: "engagement", series: [], engagementData: { title, events, ranges } } } |
static | value | { value: <json> } |
transcript | value | [{ start, end, text }, ...] or { segments: [...] } |
flight_tracks | value | { tracks: [{ id, label, color, points: [{ timeMs, lat, lon, alt }] }] } |
video_playback | value | Video-atlas manifest, passed through unchanged (see VideoPlayback) |
Example channels
Series (area / scatter):
{
"id": "timeline-speed",
"name": "Air Speed",
"groupId": "timeline",
"shape": "series",
"data": [
{ "timeMs": 0, "value": 50 },
{ "timeMs": 60000, "value": 70 }
]
}Events lane:
{
"id": "timeline-hits-taken",
"name": "Hits Taken",
"groupId": "timeline",
"shape": "events",
"data": [
{ "timeMs": 198000, "occurred": true }
]
}Ranges lane:
{
"id": "timeline-comms",
"name": "Comms Events",
"groupId": "timeline",
"shape": "ranges",
"data": [
{ "startTimeMs": 72000, "endTimeMs": 120000, "durationMs": 48000 }
]
}Engagement (one channel per target row):
{
"id": "engagement-target-1",
"name": "engagement-target-1",
"groupId": "targets",
"shape": "value",
"data": {
"value": {
"kind": "engagement",
"series": [],
"engagementData": {
"title": "Target 1",
"events": [
{ "id": "hit", "times": [1500, 2500] },
{ "id": "neutralised", "times": [12000] }
],
"ranges": [
{ "id": "exposed", "data": [{ "startX": 1000, "endX": 12000 }] }
]
}
}
}
}events[].id and ranges[].id must match the element’s eventSeries[].id and rangeSeries[].id. See the full example in tools/arbex/reference/next/ComposableChart-target-engagement-example.json.
Interactions
| Interaction | Description |
|---|---|
| Zoom / pan | Mouse wheel / drag on synced area; all synced slices rescale together |
| Hover scrubber | Grey vertical line + tooltip (time, interpolated area values, active ranges, snapped events). Time label follows xAxis.timeLabelMode (default auto: elapsed under 4h, clock through 3d, date+time through 14d, then month+year). |
| Magnetic snap | Pointer snaps to nearby event dots (stronger when over dot, weaker on vertical track) |
| Legend toggles | Show/hide series, lanes, spanning lines independently |
| Playback cursor | Yellow line at clock position (requires external playback controls, not the playback slice) |
| Time grid | Dashed vertical lines at xAxis.ticks with labels using the same timeLabelMode rules as the hover header |
Fault isolation
Each slice is wrapped in an error boundary. Invalid config or missing data shows an inline empty state for that slice only.
Anchor modes
| Anchor | X-axis | Sync |
|---|---|---|
time (default) | timeMs from channel time ranges | Full zoom/scrub/playback cursor |
index | 0..N from longest slice | Zoom/scrub across index |
none | N/A | Static slices only |
Storybook
- TerrainTimelineMock , reference-aligned legend, combined area, comms, commander/gunner/driver lanes, time grid
- TerrainTimelineWithSummary , above plus StatsCard static slice
- CommsWithTranscript , Pilot ranges lane plus transcript viewer
- Per-slice ,
Organisms/ComposableChart/Slices/*(area, scatter, events, ranges, legend, static) - DataService , live mock client when loaded
The playback slice is intentionally omitted from Storybook mocks until it is product-ready.
Related
- Next components index
- Dataservice migration status
- ComposableChart (Legacy) , v1
combined-custom-chartsbridge forui/legacy - TimelineChart , legacy Apex-based timeline (partial migration)
- VideoPlayback , standalone column and the manifest contract behind the
video_playbackslice - dashboard-v2 upstream contract
- Reference JSON:
tools/arbex/reference/next/ComposableChart.json - Arbex RAG:
tools/arbex/rag/reference/components/ComposableChart.md,composable-chart-channel-keys.md - Source:
ui/dashboard/src/components/organisms/composable-chart/