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; optional per-seat gaze marker via videoPlayback.gazeChannel. 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, gazeChannel?: string }. bounds are absolute capture-clock ms; gazeChannel is a value channel URI holding a gaze-points payload for the per-seat gaze marker (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 | Optional. One events channel, resolved against the selected realm. Required on any layout with a realm filter, see below |
| Sync | Overlay only |
| Config | channels[] (preferred) or static times[] in x-domain units; style.color, style.visibilityKey |
| Behaviour | Toggled via legend; drawn above synced area, below hover capture |
| Limitations | A bound channel that resolves to no events drops the slice rather than falling back to times[] |
Static times cannot follow a realm filter
times[] is resolved when the layout is composed, on the runner, before any
realm exists. On a dashboard with a realm filter the selection is made in the
browser and passed to widgets at render time, so a static array shows the same
lines under every realm. A hit-marker lane built this way will show every
aircraft’s hits no matter which aircraft is selected.
Bind a channel instead and the markers follow the dropdown, the same way area series already do:
{
"type": "spanning_lines",
"order": 2,
"height": 0,
"label": "Hits Taken",
"channels": ["timeline-hits"],
"style": { "color": "#C13D40", "visibilityKey": "hits_taken" }
}The channel must carry shape: "events"; entries with occurred: true inside the
current x-domain become lines. Other shapes fall back to the same truthy-value rule
events_lane uses. Static times[] remains supported for layouts with no realm
filter, and takes effect only when no channel is bound.
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), flightTracks.trailMs (default 180000), flightTracks.terrain (optional offline basemap, see below), height (default 288) |
| Behaviour | Legend ids hide a track from both panes. Trails truncate at the playhead. |
| Storybook | Organisms/ComposableChart/Slices/FlightTracks, Organisms/ComposableChart/Slices/FlightTracksWithTerrain, Organisms/ComposableChart/PlaybackWithAircraftLanes |
Offline terrain (flightTracks.terrain)
Aircraft in real-world space can be drawn over a ground image, a low-resolution heightmap, and mission-planning overlays. The dashboard bundles none of it: the slice points at a manifest JSON that an operator publishes through Cluster Manager env-assets (a .heatpatch with target env-assets, or the hxefs API), so terrain varies per project and customer.
{
"type": "flight_tracks",
"flightTracks": {
"terrain": { "manifestUrl": "/env-assets/terrain/ol-gulf/v1/terrain.json" }
}
}The manifest (relative URLs resolve against it) declares:
bounds:{ minLat, maxLat, minLon, maxLon }in degrees, EPSG:4326 (plate carree). Keep rasters in plain lat/lon, not Web Mercator, so the pixel grid maps linearly onto the viewer.ground:{ url, opacity }, a north-up image draped over the bounds. Opacity defaults to 0.5 so bright imagery sits behind the trails; raise it per manifest or per slice withterrain.opacity.height:{ url, minM, maxM, verticalOffsetM }, a greyscale heightmap.verticalOffsetMreconciles an orthometric DEM with the ellipsoidal track altitudes.groundLevelM: flat ground altitude when no heightmap is used (default 0).overlays:{ type: "image", url, bounds, opacity }or{ type: "polygons", url | geojson, color, lineWidth, labels }. The GeoJSON may hold Polygon / MultiPolygon rings (closed outlines), LineString / MultiLineString paths (open), and Point / MultiPoint markers. Simplestyle properties on each feature (stroke,stroke-width,stroke-opacity,marker-color,Name) override the overlay defaults, so a geojson.io style mission-planning export keeps its authored colours and names.labels: falsehides feature names. Fills are parsed but not drawn yet. A third overlay type,{ type: "scenario-auto", url: "index.json" }, points at a catalogue{ scenarios: [{ slug, title, overlayUrl }] }of per-scenario GeoJSON. By default (mode: "common") the widget draws only the features present in every scenario, such as corridors and boundaries, and never a scenario-specific one, so nothing that could be wrong is shown and no per-session configuration is needed.mode: "auto"opts into detection: each scenario is scored by how much of the flown track lies inside its polygons or within 8 km of its lines (shared features carry no weight) and its specific features draw only when it leads the runner-up byminLead(default 1.25) and clearsminScore(default 0.05).mode: "off"draws nothing from the catalogue. An operator switches modes by editingterrain.jsonon the cluster; the slice’sterrain.scenarioSelectioncan override it from the template.
Slice-side options: manifest (inline instead of manifestUrl), showHeight, showOverlays, opacity, segments (drape subdivisions, default 96), and verticalExaggeration (terrain relief only, default 1). To lift the aircraft and the terrain together so climb, descent and relief read in the perspective view, set flightTracks.verticalScale (default 1, true scale; 6 works well over flat country). The two multiply. Terrain is placed through the same centre and scale transform as the tracks, so it stays aligned with them, and the whole terrain group renders beneath the flight paths so trails and markers are never hidden by the ground, an image overlay, or mission vectors.
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). Optionally a second value channel, named by videoPlayback.gazeChannel (<realm:>id:name, or a bare id when id and name are identical), holding a gaze-points payload. List both channels on the chart column channels. |
| Channel shape | value → video-atlas manifest (see the VideoPlayback data contract); gazeChannel value → gaze-points payload (see Gaze overlay) |
| 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.gazeChannel (value channel URI of the gaze-points payload; omit for video without the overlay; the Arbex builder throws unless it is a non-empty string); videoPlayback.enablePlayPause (accepted, currently ignored); height (default 240) |
| Behaviour | One tile per seat in the atlas geometry, including placeholder regions (painted black with a “no video” caption, never blitted). 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. With gazeChannel set, each tile draws a marker at the projected gaze position for the playhead, joined to the region by originUuid. The widget samples at xDomain[0] + clock elapsed, holds a sample for about two bucketMs, then shows no marker. An off-frame look (u or v outside 0..1) pins a dashed marker to the tile edge. A seat with no track shows no marker; placeholder seats still get one. |
| Pairing | Add a playback slice for the scrubber and one events_lane per seat for gaze lanes. Publish the gaze-points channel in every realm the slice is mounted in, clipped to that realm’s span. 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; gaze_points is the gaze-points value channel, id and name identical):
{
"component": "ComposableChart",
"colspan": 12,
"configuration": {
"name": "EyeTracking",
"titleContent": "Eye Tracking",
"channels": ["video_atlas", "gaze_points", "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", "vignette-01:gaze_points:gaze_points"],
"videoPlayback": { "gazeChannel": "vignette-01:gaze_points:gaze_points" }
},
{ "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); optional gazeChannel value holding a gaze-points payload ({ formatVersion: 1, byOrigin: { [originUuid]: { t, u, v } } }, see Gaze overlay) |
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/