@octostar/timeline-component
v0.1.8
Published
Reusable Octostar timeline — one JSON of events drives interchangeable horizontal, vertical, and force-directed graph views.
Maintainers
Readme
@octostar/timeline-component
A reusable React timeline for communication data. Give it one JSON of events (who talked to whom, and when) and it renders three interchangeable views of the same data — no backend, no OpenSearch, no LLM:
- Horizontal — a swimlane timeline, one lane per participant, with points, intervals, and connection lines between lanes.
- Vertical — the same data as a top-to-bottom timeline.
- Graph — a force-directed node-link view where each participant is a node and each exchange between two participants is an aggregated, weighted edge.
The three views are driven by a single in-memory model, so switching between them is instant and lossless.
Extracted from the Octostar CommLab app. Packaged as a standalone library and consumed by
octostar-frontend, mirroring@octostar/map-component.
Versioning & Releases
Versioning is driven by GitVersion 6 (GitVersion.yml,
GitHubFlow/v1). Every push to main (outside *.md/docs/**) computes the next
semantic version, pushes the matching v<version> git tag, publishes the package
to npm, and creates a GitHub release with generated notes. The default increment is
patch; override from a commit/PR message with +semver: major|minor|patch|none.
The publish pipeline is dormant by default so early pushes don't reserve version tags. To arm it: (1) configure
@octostar/timeline-componenton npm with an OIDC trusted publisher pointing at this repo's.github/workflows/publish.yml(noNPM_TOKENsecret needed), then (2) set the repo variableNPM_PUBLISH_ENABLED=true(Settings → Secrets and variables → Actions → Variables).CI(typecheck + test + build) runs on every push/PR regardless.
Prerequisites
- Node.js 20+ (see
.nvmrc) - pnpm 9+
nvm use
pnpm installDevelop (standalone demo)
pnpm dev # http://localhost:3200 — the demo in client/ with a sample datasetThe demo (client/index.html + client/src/{App,main}.tsx) mounts <Timeline>
with a 24-message / 5-participant sample and exercises the view button and the
imperative handle.
Build (library)
pnpm build:libOutputs to dist/:
dist/index.mjs— ES moduledist/index.cjs— CommonJS moduledist/types/index.d.ts— type declarations
The library ships no CSS — it uses antd defaults and inline styles, so the consuming app's antd theme applies directly.
Usage
import { Timeline, type TimelineData } from '@octostar/timeline-component';
const data: TimelineData = {
// `tracks` (participants / lanes / graph nodes) is optional — it is
// synthesized from the events if omitted.
tracks: [
{ id: '[email protected]', name: 'Alice' },
{ id: '[email protected]', name: 'Bob' },
],
events: [
{ id: 'm1', from: '[email protected]', to: '[email protected]', time: '2024-03-04T09:12:00Z', title: 'Kickoff' },
],
};
<Timeline data={data} height={640} defaultView="horizontal" />;Timestamps are ISO 8601 strings. Always include an explicit UTC offset (
Zor+02:00): an offset-less datetime like2024-03-04T09:12:00is parsed as the viewer's local time, so the same JSON renders differently per user (normalization reports each such value as aninfoissue). Date-only strings (2024-03-04) parse as UTC midnight, which is at least viewer-independent.
Peer dependencies: react >=18, react-dom >=18, antd ^6, @ant-design/icons ^6.
Public API
<Timeline data={...} />— the orchestrator with a single toolbar and an imperative handle (setView, zoom, select, export). The parent owns the data: there are no mutating methods on the handle — to add/update/remove events or tracks, pass a newdataprop (single source of truth). The viewport frames the data once, when it first arrives; later data changes never move it (so streaming events in keeps the user's zoom) — callfitToData()on the handle to re-frame on demand.- One toolbar, no chrome around it: left to right it holds Group by
(
groupBy/onGroupByChange), Clear (onClear), search, zoom out/in, reset view, settings, then the view button — it opens a picker that describes Horizontal, Vertical and Graph with a small visual example each, so every view is reachable from any other. The graph covers that toolbar, so it gets its own floating copy of the view button in the top-right corner — plus a time filter (seeded from the window the previous view was showing) that drops out-of-range events and the participants left with none. - Search finds an event by title, text, participant, category or metadata, then selects it, centers the viewport on it and rings it with a temporary halo.
- Lanes default to most-active-first order — hosted events plus connections
pointing at the lane; ties keep first-seen order. Any explicit
orderon an input track keeps the provided ordering. - Coincident points fan out vertically (earliest at the lane center, alternating above/below) and the lane grows to fit, so simultaneous events stay individually visible; the fan relaxes automatically as zooming in separates them.
- Settings offers "Color connections by direction" (default off): the horizontal view's connection lines take a hue instead of arrowheads — orange flows toward a lower lane, indigo toward an upper lane, grey both ways.
onEventUpdate/onEventDelete— opt into editing (time, title, description, metadata properties) and deletion of the selected event from the details sidebar. Both hand you the id of the event in yourdataplus (for updates) the changes; the component owns no data, so you apply them and pass the newdataback in.- Integration props for embedding hosts:
selectedEventId(controlled selection),onEventClick,onTimeRangeChange(fired on every zoom/pan), andmergeLanesBy— merge lanes whose tracks share the same value of a customTrackfield (e.g.mergeLanesBy="company"). Lanes are always identified by track id: one lane per entity is the base state (null), and merging never derives identity from display values, so homonymous entities with distinct ids never mix. onAddToReport— opt into an Add to report button (left of the Load control). It captures the timeline itself at 3× — the time axis, the lanes and the lane labels, without the header or the toolbar — and hands you aTimelineSnapshot(dataUrlplus its display and pixel sizes). The component owns no reports: append the image yourself (in Octostar, through the shared "Add to report" flow). Reject with a message containingcancelto treat a dismissed report picker as a no-op. Available in the horizontal and vertical views.getGraphEdges()— client-side derivation of aggregated, weighted edges.normalizeTimelineData()— accepts both the full (trackId/connectToTrackId) and convenience (from/to/time) event forms. Returns{ tracks, events, issues }; nothing is dropped silently — invalid timestamps, duplicate ids, self-connections etc. are reported inissues(and logged to the console once in dev builds).buildTimelineData(events, groupBy?)— derive the lanes from the events themselves: split a flat event list into one lane per value of an eventmetadatafield ({ axis: 'event', field: 'entity_type' }, the default) or of a linked entity's attribute ({ axis: 'link', field: 'company' }, seeEventLink). Returns a ready-to-renderTimelineDataplus its ownissues(GroupedTimelineData) — same no-silent-drops contract asnormalizeTimelineData. Used internally when thegroupByprops are set, and exported for hosts that group outside the component. A connection event's counterpart (to/connectToTrackId) is a participant id, not a lane id, so it is resolved to a lane by identity: whichever lane that participant's own events landed on.A → Bgrouped by person connects lane A to lane B;to: '[email protected]'grouped bymetadata.personstill connects to theSara Grecolane. A counterpart that resolves to no lane (or to the source's own lane, or to several) leaves the event a plain point and says so inissues— no lane is ever invented to hold an edge.groupingOptions(events)— the grouping dimensions discoverable from the events (theirmetadatakeys, plus linked-entity fields when any event haslinks), as{ value, label }options for a selector.encodeGroupBy(groupBy)/decodeGroupBy(encoded)— serialize aGroupByto/from its stable"axis::field"string form (e.g. for URL or saved-state persistence).decodeGroupByfalls back toDEFAULT_GROUP_BYon malformed input.- Connections with duration: a
connectionevent may carry anendTime("A called B for 12 minutes"). The horizontal view renders it as a translucent bridge spanning both lanes across the interval, on top of the usual connector + direction arrow atstartTime. - Types:
TimelineData,TimelineInputEvent,TimelineView,TimelineHandle,NormalizationIssue, … timeline-data.schema.json— a JSON Schema of the canonical input format, shipped in the package (@octostar/timeline-component/timeline-data.schema.json). Designed for LLM constrained decoding: point a local model's grammar at it and every generated JSON is a validTimelineData.
<Timeline> props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| data | TimelineData | — (required) | The events (and optionally participants) to visualize. The parent owns the data; pass a new data to change it. |
| view | TimelineView | — | Controlled active view ('horizontal' \| 'vertical' \| 'graph'). Omit to let the component manage it internally. |
| defaultView | TimelineView | 'horizontal' | Initial view when uncontrolled. |
| density | TimelineDensity | — | Controlled density of the horizontal view ('off' \| 'heatmap' \| 'auto'), rendered as a segmented control in the toolbar. A separate axis from view: orientation and density are independent, so switching to the graph and back keeps a heatmap. Only the horizontal view renders density — the control is disabled in the vertical view. |
| defaultDensity | TimelineDensity | 'off' | Initial density when uncontrolled. |
| onDensityChange | (density: TimelineDensity) => void | — | Fired when the density changes, from the built-in control or from getTimeline().setViewMode(…). |
| height | number \| string | 600 | Height of the visualization area: a number is pixels, a CSS length ('100%', '100vh') is applied as given. Inside a parent with a definite height, pass '100%' and let the component follow it — do not measure the parent and hand the number back, or the component keeps whatever the parent measured before layout settled. |
| onViewChange | (view: TimelineView) => void | — | Fired when the active view changes. |
| selectedEventId | string \| null | — | Controlled selection: the id of the event to select (null for none). Omit for internal selection; pair with onEventSelect to stay in sync. |
| onEventSelect | (event: TimelineEvent \| null) => void | — | Fired when an event is selected (or deselected, with null). |
| onEventClick | (event: TimelineEvent, nativeEvent: MouseEvent) => void | — | Fired when the user clicks an event on the canvas. |
| onTimeRangeChange | (range: [Date, Date]) => void | — | Fired whenever the visible time window changes (zoom, pan, programmatic). |
| mergeLanesBy | string \| null | — | Merge lanes whose tracks share the same value of this custom Track field (e.g. 'company'). Controlled-only — nothing in the UI changes it. Lanes are always identified by track id — null is the one-lane-per-entity base state, and merging never derives identity from display values, so homonymous entities with distinct ids never mix. |
| groupBy | GroupBy | — | Controlled lane grouping. Providing any of groupBy / defaultGroupBy / onGroupByChange makes the component derive the lanes from each event's own metadata (or a linked entity's attributes, see EventLink) instead of using data.tracks. |
| defaultGroupBy | GroupBy | { axis: 'event', field: 'entity_type' } | Initial grouping when groupBy is not controlled. |
| onGroupByChange | (groupBy: GroupBy) => void | — | Fired when the grouping changes from the built-in control. |
| onClear | () => void | — | When provided, a "Clear" button appears in the toolbar. The component owns no data, so clearing is the parent's job (typically emptying data). |
| onSaveToWorkspace | (json: string) => void \| Promise<void> | — | Adds "Save timeline to workspace" to the Save menu; receives the exact JSON that "Download timeline" writes. Return a promise for async saves; reject with a message containing "cancel" to treat a dismissed dialog as a no-op. |
| onImport | (data: TimelineData) => void | — | Adds the Load control; receives the parsed { tracks, events } from a file, the workspace, or a drop. Feed it back through data. Required for any restore path to take effect. |
| onLoadFromWorkspace | (entities?: unknown[]) => Promise<TimelineData \| null> \| TimelineData \| null | — | Adds "Load from workspace" to the Load menu and enables dragging workspace records onto the timeline. Called with no argument from the menu (show your own picker) or with the dragged records. Return null (or reject with "cancel") to cancel; the result is routed to onImport. |
| onAddToReport | (snapshot: TimelineSnapshot) => void \| Promise<void> | — | Adds an Add to report button; receives a high-resolution PNG of the timeline itself (time axis, lanes and lane labels — no header or toolbar) plus its display and pixel sizes. The component owns no reports, so appending the image is the parent's job. Reject with a message containing "cancel" to treat a dismissed picker as a no-op. |
| enableDropImport | boolean | true | Whether dropping a timeline JSON (file, raw text, or workspace record) onto the timeline imports it. Set false when the host owns drag-and-drop over the timeline area; the Load menu keeps working. |
| renderEventDetails | (event: TimelineEvent) => React.ReactNode | — | Custom renderer for the selected event's detail body. |
Test
pnpm test # vitest (jsdom) — getGraphEdges, normalizeTimelineData, buildTimelineData, …Consuming it in octostar-frontend
Once the publish pipeline is armed (see Versioning & Releases),
the frontend consumes the published @octostar/timeline-component npm package.
Until then — or for local iteration — use a packed tarball:
pnpm build:lib && pnpm pack # → octostar-timeline-component-<version>.tgzapps/octostar/package.json then references it via
file:../octostar-timeline-component/octostar-timeline-component-<version>.tgz,
and apps/octostar/src/components/timeline/TimelineHost.tsx registers it as the
launchable Timeline app (mirrors map-nt/MapNT.tsx).
