@octostar/timeline-component
v0.1.11
Published
Reusable Octostar timeline — one JSON of events drives interchangeable horizontal, vertical, and force-directed graph views.
Downloads
2,785
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. Lanes default to most-active-first
// (an event counts for its own lane and for the lane it connects to); give
// any track an explicit `order` to keep the provided ordering instead.
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 — a single row that never wraps (it scrolls
sideways when the pane is too narrow for it): left to right it holds
New event (
onEventAdd), Clear (onClear, confirmed first), search, zoom out/in, untangle, event labels (a button cycling off → auto → all), reset view, 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. After the Load and Save menus, the last button is View options (the cog): it toggles a side panel, so every change shows on the timeline as you make it. - 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.
- View options holds the lane grouping pair — Lanes · From / To (
groupBy/groupByToand their callbacks) — alongside track height and hidden tracks. Events with no value for the "From" field share one lane, named Events. - View options 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.
- Clicking a lane label focuses the track: its events open in the sidebar and every other lane fades; click again (or close the panel) to unfocus. In the graph, clicking a node opens the same track panel and clicking an edge opens the communications between its two endpoints — rows select the event, switching the sidebar to its Preview.
- The selected event's sidebar is a Preview card (structured
after the linkchart component's entity Preview: type-colored icon, label and
type, two-column metadata table).
onOpenRecord— opt into an Open record button on that card; it hands you the id of the event in yourdata, your own input event (the exact object fromdata.events), and its index indata.events, and what opening means (a record viewer, a tab) is yours; only entity-backed events get it.onCreateRecord— events with no linked record get a Save as record button instead (the map's dropped-point affordance); write the created ids back into the event's metadata to flip the button to "Open record".onEventDelete— opt into deleting the selected event from the sidebar; removing it is yours (e.g.events.filter((_, i) => i !== eventIndex)). Note: when your input events carry noid(or duplicate ids), the id these callbacks hand you was synthesized during normalization and matches nothing in yourdata— the event and index arguments always identify the source unambiguously. A record-backed event is edited in the record viewer, not the timeline; its delete button reads "Remove from timeline" (the record is kept). The Delete key removes the selected event too. - Events that live only in the timeline:
onEventAdd— opt into a New event button in the toolbar and double-click on an empty spot of the horizontal view (prefilled with that time and lane). The form hands you a new input event with a freshlocal-…id; append it to yourdata. Its lane is written where the current lane grouping reads it (the grouping'smetadatafield, ortrackIdwithout grouping).onEventAddAsRecord— adds Create as record instead to that form, handing you the same event to create a real record from.onEventUpdate— events with no linked record get an Edit button on their Preview card (title, start/end, lane, description, properties); you receive your own input event with the edits applied and every other field kept.onEventsDelete— the lane menu's Remove this lane's events, in one batch (falls back toonEventDeleteper event).EventEditorModalandeventFromDraftare exported for hosts that offer New event before any<Timeline>is mounted (an empty timeline). - Saved timeline files ("Download timeline",
onSaveToWorkspace) hold your owndataas given — never the lane-grouped display copy, which rewrites ids and drops links and participants — and download astimeline-<date>.timeline(TIMELINE_FILE_EXTENSION, JSON inside). Import accepts.timelineand.json. Clear asks for confirmation. - 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(…). |
| expandOverlaps | boolean | — | Controlled de-overlap mode ("Expand overlapping events" in View options). When on, events that draw at the same position at the current zoom are spread out: coincident points fan vertically (their lane grows to fit) and connection endpoints ride separate from/to rows. The treatment relaxes automatically as zooming in separates the events. Horizontal view with density 'off' only. Omit to let the component manage it internally. |
| defaultExpandOverlaps | boolean | false | Initial de-overlap mode when uncontrolled. |
| onExpandOverlapsChange | (expandOverlaps: boolean) => void | — | Fired when the de-overlap mode changes from View options. |
| 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 the workspace Save entry to the Save menu ("Save to workspace…", or "Save to " once saved); 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. |
| onSaveAsToWorkspace | (json: string) => void \| Promise<void> | — | Adds "Save a copy…" to the Save menu once savedFileName is set; same JSON as onSaveToWorkspace. |
| savedFileName | string \| null | — | The workspace file the timeline is saved in. Unset: one "Save to workspace…" entry. Set: "Save to " plus "Save a copy…". |
| 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. |
| onEventAdd | (event: TimelineInputEvent) => void | — | Adds New event (toolbar, and double-click on empty canvas). Append the event it hands you to data; it is selected once it renders. |
| onEventAddAsRecord | (event: TimelineInputEvent) => void | — | Adds Create as record instead to the New event form. |
| onEventUpdate | (eventId: string, event: TimelineInputEvent, eventIndex: number) => void | — | Adds Edit to the Preview card of events with no linked record. Replace the event at eventIndex with the one handed to you. |
| onEventDelete | (eventId: string, event: TimelineInputEvent, eventIndex: number) => void | — | Adds a delete button to the Preview card and enables the Delete key. |
| onEventsDelete | (eventIds: string[], events: TimelineInputEvent[], eventIndexes: number[]) => void | — | Adds Remove this lane's events to the lane context menu (confirmed first). Falls back to onEventDelete, highest index first. |
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).
