@taskctrl/canvas-timeline
v0.3.0
Published
High-performance canvas-based timeline component for React
Readme
@taskctrl/canvas-timeline
High-performance canvas-based timeline component for React. Renders 1000+ groups and 5000+ items at 60fps using a hybrid architecture: three stacked canvas layers for grid, items, and overlays, with DOM for headers, sidebar, and interactive elements.
Installation
yarn add @taskctrl/canvas-timelinePeer dependencies: react, react-dom, dayjs
Basic Usage
import {
CanvasTimeline,
TodayMarker,
DateHeader,
TimelineHeaders,
SidebarHeader,
} from '@taskctrl/canvas-timeline'
import type { Group, Item, CanvasItemRenderer } from '@taskctrl/canvas-timeline'
const groups: Group[] = [
{ id: 1, title: 'Group A' },
{ id: 2, title: 'Group B' },
]
const items: Item[] = [
{ id: 1, group: 1, start_time: Date.now(), end_time: Date.now() + 86400000 },
{ id: 2, group: 2, start_time: Date.now(), end_time: Date.now() + 172800000 },
]
const itemRenderer: CanvasItemRenderer = (ctx, item, bounds, state, h) => {
ctx.fillStyle = state.selected ? '#3B82F6' : '#93C5FD'
h.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, 3)
ctx.fillStyle = '#1F2937'
h.fillText(item.title ?? '', bounds.x + 6, bounds.y + bounds.height / 2, bounds.width - 12)
}
function MyTimeline() {
return (
<CanvasTimeline
groups={groups}
items={items}
defaultTimeStart={Date.now() - 7 * 86400000}
defaultTimeEnd={Date.now() + 21 * 86400000}
sidebarWidth={200}
lineHeight={40}
itemHeightRatio={0.8}
stackItems={true}
canMove={true}
canResize={false}
canChangeGroup={false}
dragSnap={86400000}
minZoom={86400000}
maxZoom={365 * 86400000}
itemRenderer={itemRenderer}
sidebarGroupRenderer={(group) => <div>{group.title}</div>}
onItemClick={(id) => console.log('clicked', id)}
onTimeChange={(start, end) => console.log('time changed', start, end)}
>
<TodayMarker color="#FD7171" width={2} label="Today" />
<TimelineHeaders>
<SidebarHeader width={200}>
{({ getRootProps }) => <div {...getRootProps()}>Groups</div>}
</SidebarHeader>
<DateHeader unit="year" height={28} />
<DateHeader unit="month" height={28} />
<DateHeader unit="week" height={28} />
<DateHeader unit="day" height={28} />
</TimelineHeaders>
</CanvasTimeline>
)
}Architecture
Three stacked canvas layers with independent redraw cycles:
| Layer | Content | Redraws on | |-------|---------|------------| | Grid (z:0) | Row backgrounds, grid lines, day/weekend shading | View change, theme change | | Items (z:1) | Items via custom renderer, dependency arrows | View change, data change, hover | | Overlay (z:2) | Cursor line, markers, drag ghost | Cursor move, drag |
DOM elements (headers, sidebar) sit outside the canvas stack. The sidebar is virtualized for large group counts.
API Reference
<CanvasTimeline>
| Prop | Type | Description |
|------|------|-------------|
| groups | Group[] | Array of group objects |
| items | Item[] | Array of item objects |
| defaultTimeStart | number | Initial visible start time (ms timestamp) |
| defaultTimeEnd | number | Initial visible end time (ms timestamp) |
| visibleTimeStart | number? | Controlled visible start time |
| visibleTimeEnd | number? | Controlled visible end time |
| sidebarWidth | number | Sidebar width in pixels |
| lineHeight | number | Row height in pixels |
| itemHeightRatio | number | Item height as ratio of lineHeight (0-1) |
| stackItems | boolean | Stack overlapping items vertically |
| buffer | number? | Buffer multiplier (default: 3) |
| canMove | boolean | Enable item dragging |
| canResize | boolean | Enable item resizing |
| canChangeGroup | boolean | Enable moving items between groups |
| dragSnap | number | Snap interval for dragging (ms) |
| minZoom | number | Minimum visible duration (ms) |
| maxZoom | number | Maximum visible duration (ms) |
| theme | Partial<TimelineTheme>? | Theme overrides |
| dayStyle | (date: Date) => DayStyle \| null | Per-day column styling (holidays, etc.) |
| rowStyle | (group: Group) => RowStyle \| null | Per-row background styling |
| showCursorLine | boolean? | Show vertical cursor line on hover |
| itemRenderer | CanvasItemRenderer | Canvas render function for items |
| groupRenderer | CanvasItemRenderer? | Canvas render function for group-level items |
| sidebarGroupRenderer | (group: Group) => ReactNode | Sidebar row renderer |
| dependencies | Dependency[]? | Dependency arrows between items |
| selected | number[]? | Array of selected item IDs |
| onItemClick | (id, e) => void | Item click handler |
| onItemDoubleClick | (id, e) => void | Item double-click handler |
| onItemContextMenu | (id, e) => void | Item right-click handler |
| onItemMove | (id, newStartTime) => void | Item drag-move handler |
| onItemHover | (id \| null, e) => void | Item hover handler |
| onCanvasDoubleClick | (groupId, time) => void | Empty canvas double-click |
| onCanvasContextMenu | (groupId, time, e) => void | Empty canvas right-click |
| onTimeChange | (start, end) => void | Called on scroll/pan |
| onZoom | (start, end) => void | Called on zoom |
Types
interface Group {
id: number | string
title: string
type?: string
[key: string]: unknown
}
interface Item {
id: number
group: number | string
start_time: number // ms timestamp
end_time: number // ms timestamp
type?: string
[key: string]: unknown
}
interface ItemBounds {
x: number; y: number; width: number; height: number
}
interface ItemState {
selected: boolean
hovered: boolean
dragging: boolean
filtered: boolean
}Item Renderer
The itemRenderer function receives the canvas context and draw helpers:
const renderer: CanvasItemRenderer = (ctx, item, bounds, state, h) => {
// h.roundRect - filled rounded rectangle
// h.fillText - text with auto-truncation
// h.gradient - 50/50 linear gradient
// h.leftBar - colored left edge bar
// h.icon - vector icon ('check', 'danger-red', 'danger-yellow')
// h.badge - pill-shaped badge with text
}Header Components
<DateHeader> - Auto-hiding date interval header.
| Prop | Type | Description |
|------|------|-------------|
| unit | 'year' \| 'month' \| 'week' \| 'day' \| 'hour' | Time unit |
| height | number? | Header height (default: 28) |
| className | string? | CSS class for cells |
| labelFormat | string \| ((start, end, unit) => string)? | Custom label format |
| minCellWidth | number? | Min cell width before auto-hide (set 0 to disable) |
DateHeaders automatically hide when zoomed out too far for their unit to be meaningful.
<TodayMarker> - Vertical line at current time.
| Prop | Type | Default |
|------|------|---------|
| color | string? | '#FD7171' |
| width | number? | 6 |
| label | string? | - |
<CustomMarker> - Vertical line at a specific date.
| Prop | Type | Default |
|------|------|---------|
| date | number | required |
| color | string? | '#3B82F6' |
| width | number? | 4 |
| label | string? | - |
Theming
import { DEFAULT_THEME } from '@taskctrl/canvas-timeline'
<CanvasTimeline
theme={{
grid: { line: '#E0E0E0', rowAlt: '#FAFAFA', weekend: 'rgba(0,0,0,0.02)' },
marker: { today: '#FF0000', cursor: '#0066CC' },
}}
/>Interactions
| Input | Action |
|-------|--------|
| Trackpad pinch / Ctrl+wheel | Zoom (cursor-anchored) |
| Trackpad two-finger horizontal | Pan timeline |
| Shift+wheel | Horizontal scroll |
| Wheel (vertical) | Vertical scroll |
| Click item | onItemClick |
| Double-click item | onItemDoubleClick |
| Right-click item | onItemContextMenu |
| Drag item (4px threshold) | Move item, onItemMove on drop |
| Double-click canvas | onCanvasDoubleClick |
Performance
- Spatial indexing via interval tree for O(log n + k) item queries
- Sweep-line stacking algorithm O(n log n)
- Vertical culling: only visible rows are drawn
- Adaptive grid: day/week/month lines based on zoom level
- Day background batching: consecutive same-style days merged into single fill
- Stable event handlers: wheel listener never detaches during interaction
useLayoutEffectdrawing: no flicker between state update and paint
License
MIT
