@taskctrl/canvas-timeline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +249 -0
  2. package/package.json +68 -0
package/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # @taskctrl/canvas-timeline
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ yarn add @taskctrl/canvas-timeline
9
+ ```
10
+
11
+ Peer dependencies: `react`, `react-dom`, `dayjs`
12
+
13
+ ## Basic Usage
14
+
15
+ ```tsx
16
+ import {
17
+ CanvasTimeline,
18
+ TodayMarker,
19
+ DateHeader,
20
+ TimelineHeaders,
21
+ SidebarHeader,
22
+ } from '@taskctrl/canvas-timeline'
23
+ import type { Group, Item, CanvasItemRenderer } from '@taskctrl/canvas-timeline'
24
+
25
+ const groups: Group[] = [
26
+ { id: 1, title: 'Group A' },
27
+ { id: 2, title: 'Group B' },
28
+ ]
29
+
30
+ const items: Item[] = [
31
+ { id: 1, group: 1, start_time: Date.now(), end_time: Date.now() + 86400000 },
32
+ { id: 2, group: 2, start_time: Date.now(), end_time: Date.now() + 172800000 },
33
+ ]
34
+
35
+ const itemRenderer: CanvasItemRenderer = (ctx, item, bounds, state, h) => {
36
+ ctx.fillStyle = state.selected ? '#3B82F6' : '#93C5FD'
37
+ h.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, 3)
38
+ ctx.fillStyle = '#1F2937'
39
+ h.fillText(item.title ?? '', bounds.x + 6, bounds.y + bounds.height / 2, bounds.width - 12)
40
+ }
41
+
42
+ function MyTimeline() {
43
+ return (
44
+ <CanvasTimeline
45
+ groups={groups}
46
+ items={items}
47
+ defaultTimeStart={Date.now() - 7 * 86400000}
48
+ defaultTimeEnd={Date.now() + 21 * 86400000}
49
+ sidebarWidth={200}
50
+ lineHeight={40}
51
+ itemHeightRatio={0.8}
52
+ stackItems={true}
53
+ canMove={true}
54
+ canResize={false}
55
+ canChangeGroup={false}
56
+ dragSnap={86400000}
57
+ minZoom={86400000}
58
+ maxZoom={365 * 86400000}
59
+ itemRenderer={itemRenderer}
60
+ sidebarGroupRenderer={(group) => <div>{group.title}</div>}
61
+ onItemClick={(id) => console.log('clicked', id)}
62
+ onTimeChange={(start, end) => console.log('time changed', start, end)}
63
+ >
64
+ <TodayMarker color="#FD7171" width={2} label="Today" />
65
+ <TimelineHeaders>
66
+ <SidebarHeader width={200}>
67
+ {({ getRootProps }) => <div {...getRootProps()}>Groups</div>}
68
+ </SidebarHeader>
69
+ <DateHeader unit="year" height={28} />
70
+ <DateHeader unit="month" height={28} />
71
+ <DateHeader unit="week" height={28} />
72
+ <DateHeader unit="day" height={28} />
73
+ </TimelineHeaders>
74
+ </CanvasTimeline>
75
+ )
76
+ }
77
+ ```
78
+
79
+ ## Architecture
80
+
81
+ Three stacked canvas layers with independent redraw cycles:
82
+
83
+ | Layer | Content | Redraws on |
84
+ |-------|---------|------------|
85
+ | Grid (z:0) | Row backgrounds, grid lines, day/weekend shading | View change, theme change |
86
+ | Items (z:1) | Items via custom renderer, dependency arrows | View change, data change, hover |
87
+ | Overlay (z:2) | Cursor line, markers, drag ghost | Cursor move, drag |
88
+
89
+ DOM elements (headers, sidebar) sit outside the canvas stack. The sidebar is virtualized for large group counts.
90
+
91
+ ## API Reference
92
+
93
+ ### `<CanvasTimeline>`
94
+
95
+ | Prop | Type | Description |
96
+ |------|------|-------------|
97
+ | `groups` | `Group[]` | Array of group objects |
98
+ | `items` | `Item[]` | Array of item objects |
99
+ | `defaultTimeStart` | `number` | Initial visible start time (ms timestamp) |
100
+ | `defaultTimeEnd` | `number` | Initial visible end time (ms timestamp) |
101
+ | `visibleTimeStart` | `number?` | Controlled visible start time |
102
+ | `visibleTimeEnd` | `number?` | Controlled visible end time |
103
+ | `sidebarWidth` | `number` | Sidebar width in pixels |
104
+ | `lineHeight` | `number` | Row height in pixels |
105
+ | `itemHeightRatio` | `number` | Item height as ratio of lineHeight (0-1) |
106
+ | `stackItems` | `boolean` | Stack overlapping items vertically |
107
+ | `buffer` | `number?` | Buffer multiplier (default: 3) |
108
+ | `canMove` | `boolean` | Enable item dragging |
109
+ | `canResize` | `boolean` | Enable item resizing |
110
+ | `canChangeGroup` | `boolean` | Enable moving items between groups |
111
+ | `dragSnap` | `number` | Snap interval for dragging (ms) |
112
+ | `minZoom` | `number` | Minimum visible duration (ms) |
113
+ | `maxZoom` | `number` | Maximum visible duration (ms) |
114
+ | `theme` | `Partial<TimelineTheme>?` | Theme overrides |
115
+ | `dayStyle` | `(date: Date) => DayStyle \| null` | Per-day column styling (holidays, etc.) |
116
+ | `rowStyle` | `(group: Group) => RowStyle \| null` | Per-row background styling |
117
+ | `showCursorLine` | `boolean?` | Show vertical cursor line on hover |
118
+ | `itemRenderer` | `CanvasItemRenderer` | Canvas render function for items |
119
+ | `groupRenderer` | `CanvasItemRenderer?` | Canvas render function for group-level items |
120
+ | `sidebarGroupRenderer` | `(group: Group) => ReactNode` | Sidebar row renderer |
121
+ | `dependencies` | `Dependency[]?` | Dependency arrows between items |
122
+ | `selected` | `number[]?` | Array of selected item IDs |
123
+ | `onItemClick` | `(id, e) => void` | Item click handler |
124
+ | `onItemDoubleClick` | `(id, e) => void` | Item double-click handler |
125
+ | `onItemContextMenu` | `(id, e) => void` | Item right-click handler |
126
+ | `onItemMove` | `(id, newStartTime) => void` | Item drag-move handler |
127
+ | `onItemHover` | `(id \| null, e) => void` | Item hover handler |
128
+ | `onCanvasDoubleClick` | `(groupId, time) => void` | Empty canvas double-click |
129
+ | `onCanvasContextMenu` | `(groupId, time, e) => void` | Empty canvas right-click |
130
+ | `onTimeChange` | `(start, end) => void` | Called on scroll/pan |
131
+ | `onZoom` | `(start, end) => void` | Called on zoom |
132
+
133
+ ### Types
134
+
135
+ ```typescript
136
+ interface Group {
137
+ id: number | string
138
+ title: string
139
+ type?: string
140
+ [key: string]: unknown
141
+ }
142
+
143
+ interface Item {
144
+ id: number
145
+ group: number | string
146
+ start_time: number // ms timestamp
147
+ end_time: number // ms timestamp
148
+ type?: string
149
+ [key: string]: unknown
150
+ }
151
+
152
+ interface ItemBounds {
153
+ x: number; y: number; width: number; height: number
154
+ }
155
+
156
+ interface ItemState {
157
+ selected: boolean
158
+ hovered: boolean
159
+ dragging: boolean
160
+ filtered: boolean
161
+ }
162
+ ```
163
+
164
+ ### Item Renderer
165
+
166
+ The `itemRenderer` function receives the canvas context and draw helpers:
167
+
168
+ ```typescript
169
+ const renderer: CanvasItemRenderer = (ctx, item, bounds, state, h) => {
170
+ // h.roundRect - filled rounded rectangle
171
+ // h.fillText - text with auto-truncation
172
+ // h.gradient - 50/50 linear gradient
173
+ // h.leftBar - colored left edge bar
174
+ // h.icon - vector icon ('check', 'danger-red', 'danger-yellow')
175
+ // h.badge - pill-shaped badge with text
176
+ }
177
+ ```
178
+
179
+ ### Header Components
180
+
181
+ **`<DateHeader>`** - Auto-hiding date interval header.
182
+
183
+ | Prop | Type | Description |
184
+ |------|------|-------------|
185
+ | `unit` | `'year' \| 'month' \| 'week' \| 'day' \| 'hour'` | Time unit |
186
+ | `height` | `number?` | Header height (default: 28) |
187
+ | `className` | `string?` | CSS class for cells |
188
+ | `labelFormat` | `string \| ((start, end, unit) => string)?` | Custom label format |
189
+ | `minCellWidth` | `number?` | Min cell width before auto-hide (set 0 to disable) |
190
+
191
+ DateHeaders automatically hide when zoomed out too far for their unit to be meaningful.
192
+
193
+ **`<TodayMarker>`** - Vertical line at current time.
194
+
195
+ | Prop | Type | Default |
196
+ |------|------|---------|
197
+ | `color` | `string?` | `'#FD7171'` |
198
+ | `width` | `number?` | `6` |
199
+ | `label` | `string?` | - |
200
+
201
+ **`<CustomMarker>`** - Vertical line at a specific date.
202
+
203
+ | Prop | Type | Default |
204
+ |------|------|---------|
205
+ | `date` | `number` | required |
206
+ | `color` | `string?` | `'#3B82F6'` |
207
+ | `width` | `number?` | `4` |
208
+ | `label` | `string?` | - |
209
+
210
+ ### Theming
211
+
212
+ ```typescript
213
+ import { DEFAULT_THEME } from '@taskctrl/canvas-timeline'
214
+
215
+ <CanvasTimeline
216
+ theme={{
217
+ grid: { line: '#E0E0E0', rowAlt: '#FAFAFA', weekend: 'rgba(0,0,0,0.02)' },
218
+ marker: { today: '#FF0000', cursor: '#0066CC' },
219
+ }}
220
+ />
221
+ ```
222
+
223
+ ## Interactions
224
+
225
+ | Input | Action |
226
+ |-------|--------|
227
+ | Trackpad pinch / Ctrl+wheel | Zoom (cursor-anchored) |
228
+ | Trackpad two-finger horizontal | Pan timeline |
229
+ | Shift+wheel | Horizontal scroll |
230
+ | Wheel (vertical) | Vertical scroll |
231
+ | Click item | `onItemClick` |
232
+ | Double-click item | `onItemDoubleClick` |
233
+ | Right-click item | `onItemContextMenu` |
234
+ | Drag item (4px threshold) | Move item, `onItemMove` on drop |
235
+ | Double-click canvas | `onCanvasDoubleClick` |
236
+
237
+ ## Performance
238
+
239
+ - Spatial indexing via interval tree for O(log n + k) item queries
240
+ - Sweep-line stacking algorithm O(n log n)
241
+ - Vertical culling: only visible rows are drawn
242
+ - Adaptive grid: day/week/month lines based on zoom level
243
+ - Day background batching: consecutive same-style days merged into single fill
244
+ - Stable event handlers: wheel listener never detaches during interaction
245
+ - `useLayoutEffect` drawing: no flicker between state update and paint
246
+
247
+ ## License
248
+
249
+ MIT
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@taskctrl/canvas-timeline",
3
+ "version": "0.1.0",
4
+ "description": "High-performance canvas-based timeline component for React",
5
+ "scripts": {
6
+ "build": "rimraf ./dist && tsc && vite build",
7
+ "test": "vitest run",
8
+ "test:watch": "vitest",
9
+ "test:coverage": "vitest run --coverage",
10
+ "lint": "eslint --ext .ts --ext .tsx ./src"
11
+ },
12
+ "main": "./dist/canvas-timeline.cjs.js",
13
+ "module": "./dist/canvas-timeline.es.js",
14
+ "sideEffects": false,
15
+ "exports": {
16
+ ".": {
17
+ "types": [
18
+ "./dist/index.d.ts"
19
+ ],
20
+ "import": "./dist/canvas-timeline.es.js",
21
+ "require": "./dist/canvas-timeline.cjs.js"
22
+ }
23
+ },
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "!dist/__tests__"
28
+ ],
29
+ "author": "TaskCtrl AS",
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/TaskCtrl-As/canvas-timeline.git"
37
+ },
38
+ "peerDependencies": {
39
+ "dayjs": "^1.11.10",
40
+ "react": "^18 || ^19",
41
+ "react-dom": "^18 || ^19"
42
+ },
43
+ "devDependencies": {
44
+ "@rollup/plugin-typescript": "^12.1.0",
45
+ "@testing-library/dom": "^10.4.0",
46
+ "@testing-library/jest-dom": "^6.5.0",
47
+ "@testing-library/react": "^16.0.1",
48
+ "@types/react": "^18.2.41",
49
+ "@types/react-dom": "^18.2.17",
50
+ "@typescript-eslint/eslint-plugin": "^8.8.1",
51
+ "@typescript-eslint/parser": "^8.8.1",
52
+ "@vitejs/plugin-react-swc": "^3.7.1",
53
+ "@vitest/coverage-v8": "^3.2.4",
54
+ "dayjs": "^1.11.20",
55
+ "eslint": "^8.57.1",
56
+ "jsdom": "^25.0.1",
57
+ "prettier": "^3.1.0",
58
+ "react": "^19.2.5",
59
+ "react-dom": "^19.2.5",
60
+ "rimraf": "^6.0.1",
61
+ "rollup-plugin-typescript-paths": "^1.5.0",
62
+ "tslib": "^2.8.1",
63
+ "typescript": "^5.2.2",
64
+ "vite": "^5.4.11",
65
+ "vite-plugin-dts": "^4.3.0",
66
+ "vitest": "^3.0.0"
67
+ }
68
+ }