@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.
- package/README.md +249 -0
- 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
|
+
}
|