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, playback placeholder) 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 |
| Scatter slice | Implemented; limited Storybook coverage |
| Yellow playback cursor (realm clock) | Ready when anchor is "time" and page/row playback controls drive the clock |
playback slice (in-chart controls) | Not ready for use , reserved in schema; renders a placeholder. Use page-level PlayBackControlDS instead (v2) or the legacy wrapper’s local playback bar |
| 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 |
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: multiple toggleable event types |
legendItems | array | No | Legend slice: toggle keys |
times | number[] | No | Spanning lines: vertical line positions in x-domain units |
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". - Do not add a
playbackelement to layouts until the slice is marked ready in docs.
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" }
]
},
{
"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, playback placeholder, 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" | "square" | "dot" }[] |
| 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, 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).
| 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 |
| Limitations | One logical range series per slice |
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 |
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 |
playback (not ready)
Reserved slice type for future in-chart play/pause + scrubber wired to the realm TimelineClock.
| Status | Not ready for use , do not add to production layouts |
| Channels | None |
| Sync | No |
| Current behaviour | Renders placeholder text: “Not ready for use” |
| Alternative | Use session enableGlobalPlaybackControls or row enablePlaybackControls with PlayBackControlDS; yellow cursor still tracks the clock |
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 }[] |
static | value | { value: <json> } |
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 }
]
}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
- 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)
- 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/