This document is written to be followed by a coding agent. It describes how to migrate an
existing vis-timeline integration onto the
canvas renderer (src/canvas-timeline.js), and — importantly — how to run both in
parallel behind a flag so you can validate before cutting over.
DataSets of items and groups) but replaces the rendering and the
options/template surface. Migration effort is proportional to how much you rely on custom
HTML templates and editing callbacks, not to how much data you have.
// vis-timeline
import { Timeline } from 'vis-timeline/standalone';
const tl = new Timeline(container, itemsDataSet, groupsDataSet, options);
// vis-timeline-canvas
import { CanvasTimeline } from './src/canvas-timeline.js';
const tl = new CanvasTimeline(container, itemsDataSet, {
...options, // see option mapping below
groups: groupsDataSet // groups move INTO the options object
});
Both accept either a plain array or a vis-data DataSet. When given a
DataSet, the canvas renderer subscribes to add/update/
remove and redraws automatically — same as vis-timeline.
| Field | Supported | Notes |
|---|---|---|
id, start, end, group | Yes | Identical semantics. No end ⇒ a box item. |
content | Plain text only | Rendered as canvas text. HTML strings are not parsed. See §4. |
type | box / range / block | point and background not implemented; block is new (spans a parent's subgroups). |
className | No CSS | Canvas can't apply stylesheet classes. Map styling to data fields (below). |
style (CSS string) | Colors only | Parses background-color, border-color, color. |
backgroundColor/borderColor/color | Yes | Direct fields, preferred over CSS strings. |
title | Yes | Shown in the hover tooltip. |
accentColor, pattern, patternColor, noText | New | Canvas-only extras (accent strip, error stripes, textless markers). |
| vis-timeline | canvas equivalent |
|---|---|
height | height (number, px) |
min / max | min / max (hard pan bounds) |
start / end | start / end (initial window) |
stack | stack |
zoomMin / zoomMax | minZoom / maxZoom (ms) |
groupOrder | groupOrder (field name or comparator) |
margin.item | itemMargin + itemSpacing |
orientation | Not supported (axis is bottom; labels left) |
editable | Drag-move & edge-resize are always on; emit itemMove/itemResize — you persist |
template / groupTemplate | Not supported — see §4 |
| vis-timeline | canvas | Payload |
|---|---|---|
select | select | { items: id[] } |
doubleClick | doubleClick | { item, event } |
| drag/move | itemMove | { items: [{id,start,end}] } |
| resize | itemResize | { item, start, end, edge } |
| group click | groupClick | { group, event } |
rangechange / rangechanged | Yes | Payload is { start: Date, end: Date }; first initial render is intentionally skipped. |
template:, groupTemplate, CSS-driven className styling, and
type: 'point' / 'background'. These are the migration blockers;
also grep .on('rangechange / .on('rangechanged to verify consumers only
need { start, end }. List every hit before writing code.TimelineAdapter (see §3) exposing the subset of the vis-timeline API your app actually calls, backed by CanvasTimeline. Do not touch call-sites yet.Both renderers consume the same vis-data DataSet, so they stay in sync for free —
edits made through the DataSet propagate to whichever renderer is mounted. Mount one at a time
(toggle) or both stacked (side-by-side compare).
// timeline-factory.js — single switch point for the whole app.
import { Timeline } from 'vis-timeline/standalone';
import { CanvasTimeline } from './src/canvas-timeline.js';
const USE_CANVAS = localStorage.getItem('timeline2') === 'on'; // or your flag system
export function createTimeline(container, items, groups, options) {
if (USE_CANVAS) {
return new CanvasTimelineAdapter(container, items, groups, options);
}
return new Timeline(container, items, groups, options);
}
// Adapter: present a vis-timeline-shaped surface over CanvasTimeline.
class CanvasTimelineAdapter {
constructor(container, items, groups, options = {}) {
this._tl = new CanvasTimeline(container, items, { ...mapOptions(options), groups });
}
on(evt, cb) { this._tl.on(evt, cb); } // supported events in §1
off(evt, cb) { this._tl.off(evt, cb); }
setItems(i) { this._tl.setItems(i); }
setGroups(g) { this._tl.setGroups(g); }
setWindow(s, e) { this._tl.setWindow(s, e); }
getWindow() { return this._tl.getWindow(); }
setSelection(ids) { this._tl.setSelection(ids); }
getSelection() { return this._tl.getSelection(); }
fit() { this._tl.fit(); }
moveTo(t) { this._tl.moveTo(t); }
destroy() { this._tl.destroy(); }
}
function mapOptions(o) {
return {
height: o.height, stack: o.stack, min: o.min, max: o.max,
start: o.start, end: o.end, minZoom: o.zoomMin, maxZoom: o.zoomMax,
groupOrder: o.groupOrder, itemMargin: o.margin?.item,
// carry through any canvas-only extras you opt into:
theme: o.theme, minimap: o.minimap, uniformItemColor: o.uniformItemColor
};
}
// Mount BOTH against one DataSet to eyeball parity during Phase 2.
const items = new DataSet(rawItems);
const groups = new DataSet(rawGroups);
new Timeline(document.getElementById('old'), items, groups, oldOptions);
new CanvasTimeline(document.getElementById('new'), items, { ...mapOptions(oldOptions), groups });
// Edits flow to both: items.update({ id: 42, start: newStart });
itemMove/
itemResize. Make your persistence layer the single writer (call
items.update(...) in those handlers) and both renderers observe the same source of
truth.The canvas draws plain text plus colored rectangles; it cannot render arbitrary DOM per item. Convert template logic into precomputed item fields:
// Before (vis-timeline):
options.template = (item) => `<span class="badge ${item.sev}">${item.label}</span>`;
// After: precompute fields the canvas understands.
items.forEach(it => items.update({
id: it.id,
content: it.label,
backgroundColor: SEV_COLOR[it.sev],
pattern: it.sev === 'error', // red stripes for errors (Feature 2)
}));
For genuinely rich tooltips, keep using the title field (plain text) or render your
own absolutely-positioned DOM overlay on the doubleClick event.
tl.on('itemMove', ({ items: moved }) => moved.forEach(m => dataset.update(m)));
tl.on('itemResize', ({ item, start, end }) => dataset.update({ id: item, start, end }));
rangechange/rangechanged payloads ({ start, end }) or reworked off render + getWindow() where they need extra context.demo/stress-test.html as a template).