Skip to Content
This documentation is provided with the HEAT environment and is relevant for this HEAT instance only.
Dashboard ComponentsNextComposableChart (Next)

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.

This is the supported production path. It requires:

  • dashboard-v2 runner and $heat-dataservice channel payloads
  • ComposableChartDS in ui/dashboard (not the legacy wrapper)
  • v2 layout row configuration.composableChartItem per heat-layout schema
  • Page- or row-level PlayBackControlDS driving the realm TimelineClock so 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

AreaStatus
Area (multi-series), legend, events lanes, ranges lanes, spanning linesReady for layout-driven use
Hover scrubber, magnetic snap, tooltipReady
Vertical time grid + bottom tick labelsReady (composableChartItem.xAxis.ticks)
Static slice (StatsCard payload)Ready
Transcript slice (timed utterances)Ready. Optional highlightTerms underlines matching tokens
Scatter sliceImplemented; 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 sliceReady. Dual-pane lat/lon trail + altitude strip, legend-filtered, clock-scrubbed.
video_playback sliceReady. Video-atlas seat tiles following the realm clock; playback bounded to the clock span or videoPlayback.bounds. Storybook: Organisms/ComposableChart/VideoPlaybackBounded
markdown sliceReady. Inline Markdown text block. Content from markdownContent in layout (no channel needed). Storybook: Organisms/ComposableChart/MarkdownSliceMock
Index anchorPartial , x-domain derived from longest slice; less tested than time anchor
none anchorStatic-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.channels mapping.
  • Multiple widgets of the same type in one layout are supported via distinct configuration.name and element channel URIs.
  • Empty label: "" on a slice suppresses the lane title (used for combined area + external legend).

Dashboard generation

Value
Runner nodedashboard-v2
Frontendui/dashboard (ComposableChartDS)
Data envelope$heat-dataservice (contract)
RenderingSVG + D3 scales/zoom

Layout component identifier

ComposableChart , configuration.component in v2 layout rows.

Layout configuration

Typical fields on the row configuration object:

FieldTypeRequiredDescription
componentstringYes"ComposableChart"
namestringYesInternal widget name
titleContentstringYesPanel title
channelsstring[]YesLegacy/base channel list (may be empty when slices bind via URI)
composableChartItemobjectYesSlice definitions and anchor
composableChartItem.anchor"time" | "index" | "none"NoPrimary x-axis semantics (default: "time")
composableChartItem.xAxisobjectNoChart-wide vertical grid and tick labels: { ticks?: number[], timeLabelMode?: "auto" | "elapsed" | "clock" | "dateTime" | "dateTimeLong" }
composableChartItem.elementsarrayYesOrdered slice configs
composableChartItem.isTargetEngagementbooleanNoTarget Engagement mode: renders only legend and engagement elements as stacked per-target rows, disables pan/zoom, and shows the Targets multi-select dropdown
composableChartItem.showMultiDropdownbooleanNoKept alongside isTargetEngagement for parity with the UI types; the dropdown itself is driven by isTargetEngagement

Each element in composableChartItem.elements:

FieldTypeRequiredDescription
typestringYesSlice type (see Slice reference)
ordernumberYesVertical stack order (lower = higher)
labelstringNoLane title; empty string hides the title
channelsstring[]NoChannel URIs: realm:channelId:name or channelId:name
heightnumberNoSlice height in pixels (default 72)
styleobjectNoColors, opacity, marker size, visibilityKey for legend wiring
yAxisobjectNoArea slice only (see below)
seriesarrayNoArea slice: multiple series in one band
eventSeriesarrayNoEvents lane and engagement slices: multiple toggleable event types
rangeSeriesarrayNoEngagement slice: overlaid range bars (id, label, color, tooltipLabel, barHeight)
legendItemsarrayNoLegend slice: toggle keys
timesnumber[]NoSpanning lines: vertical line positions in x-domain units
videoPlaybackobjectNoVideo playback slice: { bounds?: { fromMs, toMs }, enablePlayPause?: boolean }. bounds are absolute capture-clock ms (see video_playback)

Playback for this widget

  • Set enableGlobalPlaybackControls or row enablePlaybackControls on the session layout to show PlayBackControlDS.
  • The chart draws a yellow vertical cursor at xDomain[0] + clock.elapsedMs when anchor is "time".
  • Add a playback element for the in-chart seek bar (no play button). Page-level PlayBackControlDS remains 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).

ChannelsNone
SyncNo
ConfiglegendItems: { id, label, color, swatch?: "line" | "vertical-line" | "vertical-dashed-line" | "square" | "dot" | "diamond" | "cross" | "primary" | "secondary" | "bolt", isLabel?: boolean }[]
BehaviourClick toggles visibility for that id across the chart
LimitationsToggle state resets when slice config changes

area

Primary time-series band. Supports multiple series in one slice via series[].

ChannelsOne URI per series[].channel, or legacy single channels[0]
Channel shapeseries{ timeMs, value }[]
SyncYes
Configseries[]: id, label, channel, color, fill, render (line, line_gradient, step_area), unit, tooltipLabel; yAxis: domain, bandLabels, gridLines
BehaviourLinear 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
LimitationsSingle-channel legacy path only supports one series; prefer series[] for multi-series

scatter

Points plotted by time (x) and value (y).

Channelschannels[0]
Channel shapeseries
SyncYes
Configstyle.color, style.markerSize
LimitationsOne 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).

ChannelsOptional per eventSeries[].channel; static times[] for mocks/tests
Channel shapeevents{ timeMs, occurred }[], or boolean samples on a series channel
SyncYes
ConfigeventSeries[]: id, label, color, tooltipLabel, shape (same vocabulary as legend swatches, including bolt), optional channel / times
BehaviourEach series toggled independently via legend; magnetic snap on hover; dots grow when highlighted
LimitationsLane 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:

Channelschannels[0]
Channel shaperanges{ startTimeMs, endTimeMs, durationMs }[]
SyncYes
Configstyle.color, style.visibilityKey (legend id)
BehaviourHighlight when hover/scrub time falls inside range; tooltip shows duration

Multi-series — multiple named, individually coloured series sharing the same lane row:

ChannelsNone at top level — each series supplies its own channel
Channel shaperanges per series
SyncYes
ConfigrangeSeries[]: id, label, color, tooltipLabel, barHeight, channel. Each series is toggled independently via legend
BehaviourAll 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).

Channelschannels[0], exactly one channel per element
Channel shapevalue with data.value.kind: "engagement" (see Example channels)
SyncYes (pan/zoom disabled in isTargetEngagement mode)
ConfigrangeSeries[]: id, label, color, tooltipLabel, barHeight; eventSeries[]: id, label, color, tooltipLabel, shape (dot/diamond/cross/primary/secondary/bolt)
BehaviourRow 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
LimitationsRange 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).

ChannelsNone (use times[] in layout or resolve from data later)
SyncOverlay only
Configtimes[] in x-domain units; style.color, style.visibilityKey
BehaviourToggled via legend; drawn above synced area, below hover capture
LimitationsNo 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.

ChannelsNone (content is layout-embedded)
SyncNo
ConfigmarkdownContent (Markdown string on the element); height (default 120)
BehaviourRenders Markdown via MarkDownContent atom in a foreignObject; 12 px inset top and bottom
StorybookOrganisms/ComposableChart/MarkdownSliceMock

static

Non-chart content block (typically StatsCard weather summary).

Channelschannels[0]
Channel shapevalue{ value: { statsCard: [...] } } or similar JSON
SyncNo
Configstyle.displayInSingleRow
BehaviourRenders HTML via foreignObject; errors isolated to slice
LimitationsOnly 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.

Channelschannels[0]
Channel shapevalue[{ start, end, text }, ...] or { segments: [...] } (start/end elapsed ms)
SyncNo (not in scrubber / playback-cursor bands)
Configlabel (header, default Transcript), style.color (active row accent), highlightTerms (optional string list to underline), height (default 160)
BehaviourHighlights the row whose [start, end] contains playback.elapsedMs; click seeks to segment start; auto-scrolls active row into view
LimitationsNo 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.

StatusReady
ChannelsNone
SyncSeeks playback.onSeek
BehaviourSlider with a persistent playhead timestamp (layout: scrubber)
StorybookOrganisms/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.

Channelschannels[0]
Channel shapevalue{ tracks: [{ id, label, color, points: [{ x or timeMs, lat, lon, alt }] }] }
SyncNo (not in the x-axis overlay band). Scrubs from playback.elapsedMs.
ConfigflightTracks.leftPercent (default 72), flightTracks.topDownView (default true), height (default 288)
BehaviourLegend ids hide a track from both panes. Trails truncate at the playhead.
StorybookOrganisms/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.

StatusReady
Channelschannels[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 shapevalue → video-atlas manifest (see the VideoPlayback data contract)
SyncNo (non-synced HTML panel, like flight_tracks). Follows the realm clock for seek position and play state.
ConfigvideoPlayback.bounds ({ fromMs, toMs }, absolute capture-clock ms, same domain as the manifest epochMs, fromMs < toMs); videoPlayback.enablePlayPause (accepted, currently ignored); height (default 240)
BehaviourOne 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.
PairingAdd 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.
StorybookOrganisms/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 typeChannel shapeData used
areaseries{ timeMs, value }[]
scatterseries{ timeMs, value }[]
events_laneevents{ timeMs, occurred }[]
ranges_laneranges{ startTimeMs, endTimeMs, durationMs }[]
engagementvalue{ value: { kind: "engagement", series: [], engagementData: { title, events, ranges } } }
staticvalue{ value: <json> }
transcriptvalue[{ start, end, text }, ...] or { segments: [...] }
flight_tracksvalue{ tracks: [{ id, label, color, points: [{ timeMs, lat, lon, alt }] }] }
video_playbackvalueVideo-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

InteractionDescription
Zoom / panMouse wheel / drag on synced area; all synced slices rescale together
Hover scrubberGrey 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 snapPointer snaps to nearby event dots (stronger when over dot, weaker on vertical track)
Legend togglesShow/hide series, lanes, spanning lines independently
Playback cursorYellow line at clock position (requires external playback controls, not the playback slice)
Time gridDashed 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

AnchorX-axisSync
time (default)timeMs from channel time rangesFull zoom/scrub/playback cursor
index0..N from longest sliceZoom/scrub across index
noneN/AStatic 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.