@roster5/vis-timeline-canvas
v1.5.0
Published
Standalone, high-performance canvas renderer for vis-timeline-style data — a drop-in component with vis-data interop
Maintainers
Readme
vis-timeline-canvas
A high-performance, canvas-based fork of vis-timeline.
It keeps the familiar data model (plain arrays or vis-data DataSets of items
and groups) but replaces the one-DOM-node-per-item renderer with a single
<canvas>, so it stays fast into the tens of thousands of items.
- Drop-in-ish data model — items/groups look like vis-timeline's.
- Canvas rendering — viewport culling, cached text widths and group heights.
- Extras not in stock vis-timeline — top-axis overview band, follow-now, time probe, uniform-color + accent strips, error stripes, block-across-subgroups, blocks that gradient/cover, per-instance theming, leading item icons, a left scrollbar, and desktop keyboard navigation.
Migrating an existing integration? See
transition.html. Go/no-go analysis:risks.html. Deferred perf work:optimizations.html.
Install
npm install @roster5/vis-timeline-canvasAlso installable from source — a git URL, a local path, or a packed tarball:
# from the git repo
npm install git+https://gitlab.com/roster5/vis-timeline-canvas.git
# or from a local checkout / packed tarball
npm install /path/to/vis-timeline-canvas
npm pack # -> roster5-vis-timeline-canvas-x.y.z.tgz, then `npm install ./that.tgz`The only runtime dependency is vis-data (DataSet/DataView interop).
vis-timeline itself is a devDependency — used by the side-by-side demo and
the event-parity verification, never imported by the shipped code — so the
package stays light while remaining interoperable with an existing
vis-timeline / vis-data integration (see transition.html).
Cutting a release? The step-by-step process (tag → Body of Evidence →
npm publish) is documented in RELEASING.md.
For convenience the package re-exports the data containers, so you can import
the timeline and its DataSet from one place:
import { CanvasTimeline, DataSet } from 'vis-timeline-canvas';To pull in only the renderer (no vis-data re-export), use the subpath:
import { CanvasTimeline } from 'vis-timeline-canvas/renderer';Quick start
import { CanvasTimeline, DataSet } from 'vis-timeline-canvas';
const items = new DataSet([
{ id: 1, content: 'Kickoff', start: '2024-01-01T09:00', end: '2024-01-01T11:00', group: 'a' },
{ id: 2, content: 'Review', start: '2024-01-01T13:00', end: '2024-01-01T15:00', group: 'b' },
]);
const groups = [{ id: 'a', content: 'Team A' }, { id: 'b', content: 'Team B' }];
const timeline = new CanvasTimeline('#timeline', items, {
groups,
theme: 'dark',
height: 500,
});
timeline.on('select', ({ items }) => console.log('selected', items));
timeline.on('itemMove', ({ items }) => items.forEach(m => dataset.update(m)));Note the two API differences from vis-timeline highlighted up front:
- Groups are passed in the options object (
{ groups }), not as a third constructor argument. - You persist edits. Drag/resize emit
itemMove/itemResize; the renderer never writes back to yourDataSeton its own.
Options reference
Every option is optional. Defaults are shown in parentheses.
Sizing & layout
| Option | Default | What it does |
| --- | --- | --- |
| height | 400 | Total component height in px — the top-axis band and bottom axis are carved out of it, so a container of exactly this height is never overflowed. Pass '100%' to fill the container's content box instead (size the container with CSS; the timeline follows it via ResizeObserver). Numeric strings ('350px') are coerced; any other value is ignored with a warning. Live-updatable via setOptions. |
| itemHeight | 32 | Height of a single item row in px. |
| itemMargin | 4 | Vertical gap between stacked rows in px. Set 0 for dense rows. |
| itemSpacing | 2 | Horizontal gap reserved between items when deciding if they fit on the same row. 0 lets adjacent items touch. |
| itemBorderRadius | null | Global corner radius override. 0 = square corners; null keeps the per-item radius. |
| groupMinHeight | 40 | Minimum height of a group band in px. |
| parentGroupHeight | null | Base height in px for parent groups (groups with nestedGroups), independent of groupMinHeight — may be smaller or larger. null = use groupMinHeight. A per-group height overrides both. Heights are floors: stacked items still grow a band so they are never clipped. |
| groupLabelWidth | 150 | Width of the left group-label panel in px. |
| resizableLabelPanel | true | When the group table is off, show a drag splitter after the label panel so its width can be resized from the UI (min 60px, always leaving timelineMinWidth of timeline). Set false to pin the panel at groupLabelWidth. With the group table on, the table's own splitter handles resizing. |
| timelineMinWidth | 200 | Minimum width in px the timeline body keeps when its container shrinks. The group table yields first (its right-hand columns are clipped), then the label panel, so the body — and the splitter — never disappear off screen. Configured column/panel widths are untouched and come back when the container grows (until the splitter is dragged in the clamped state — a drag adopts the displayed width). Splitter drags are capped by the same budget. 0 lets the body shrink to nothing, but the panels are still clamped to the container so the splitter stays on screen. Live-updatable via setOptions. |
| groupOrder | 'order' | Field name to sort groups by, or a comparator (a, b) => number over the raw group objects. |
| subgroupOrder | null | How to sort subgroups (nested groups) within their parent: a raw-group field name or a comparator (a, b) => number. null keeps the parent's nestedGroups array order. |
| showUngrouped | false | Show the synthetic "(No Group)" band at the bottom that collects items whose group is missing or unknown. By default those items are hidden entirely (no band, not rendered, not hit-testable). Only applies when groups are in use — without groups every item renders in the flat lane. |
Time window & zoom
| Option | Default | What it does |
| --- | --- | --- |
| start / end | data range | Initial visible window (use both). |
| min / max | null | Hard pan/zoom bounds. If both are set, the bounds are exactly this range. |
| minZoom | 60000 | Smallest visible duration in ms (max zoom-in). |
| maxZoom | null | Largest visible duration in ms (max zoom-out). null = the full data range. |
| zoomSpeed | 0.002 | Wheel-zoom sensitivity. |
With no
min/max, the bounds are padded ~5% around the data and expanded to a 24-hour minimum span so single-item timelines still open at a sane scale.
Stacking, editing & snapping
| Option | Default | What it does |
| --- | --- | --- |
| stack | true | Stack overlapping items into rows. false overlaps them on one row. |
| editable | false | Enable item editing (vis parity: off by default — dragging over items pans). true enables everything; the object form grants capabilities individually: { updateTime } allows drag-move in time, edge-resize and keyboard nudge; { updateGroup } allows dragging an item vertically into another group (emitted in itemMove as group). When several selected items are dragged, each moves by the same number of rows from its own group, counted in the displayed row order, so items from different groups stay in different groups; the move stops when the topmost or bottommost item reaches the first or last row. remove is accepted for vis compat but is a no-op — there is no built-in delete UI; delete from your DataSet instead. A press on an item becomes a move or resize only after the pointer travels 4px; a click (including Ctrl/Cmd+click) never moves, snaps or emits itemMove/itemResize. Ctrl/Cmd+click toggles an item in the selection without starting a drag when it removes it; a plain click on one item of a multi-selection selects just that item (pressing and dragging it moves the whole selection). |
| doubleClickToFit | false | A double-click on empty body space fits the window to the data. Off by default: the doubleClick event is the app's to handle (before 1.5.0 this always happened; set true to keep it). Live-updatable via setOptions. |
| timeResolution | 60000 | The time-resolution unit in ms for manual edits. Drag/resize/keyboard-nudge are quantized to multiples of this, and committed values snap to the nearest unit on mouseup. Default one minute. |
| snap | 'minute' | Edit quantization: 'minute' snaps to timeResolution; false = free editing; or a function (ms) => ms. |
| snapToItems | true | Magnetically snap edited edges to nearby item edges and the now-line. |
| snapDistance | 6 | Magnetic snap radius in px. |
| dragTimePreview | false | While an item is moved or edge-resized, show a tooltip above it with the start, end and duration it will have when dropped, e.g. Jan 15, 09:00 – 11:30 (2h 30m). The times are the snapped values the drop commits (timeResolution, snapToItems), formatted with hour24 and utc. A multi-item drag shows it for the item under the cursor. It hides on drop, and does not show for a plain click or a group-only drag. Live-updatable via setOptions. |
| dragTimePreviewFormat | null | Replace the preview's default text with a callback ({ item, start, end, edge }) => string: item is the item id, start/end are Dates (end is undefined for a point item), edge is 'left'/'right' while resizing and null for a move. A throwing callback falls back to the default text. Live-updatable via setOptions. |
Selection & interaction
| Option | Default | What it does |
| --- | --- | --- |
| tooltipDelay | 300 | Hover delay (ms) before the title tooltip shows. |
| verticalScrollbar | true | Show the draggable scrollbar on the left of the body (auto-hides when content fits). |
| scrollbarWidth | 12 | Scrollbar width in px. |
| keyboard | true | Enable desktop keyboard navigation + the focus ring (see below). |
| fullscreenButton | true | Show a fullscreen toggle button in the zoom-control overlay. |
| showTimelineControls | 'on' | Visibility of the zoom/fit/fullscreen control overlay. 'on' always shows it; 'off' removes it (it otherwise covers the top-right of the body); 'on-move' keeps it hidden and click-through until the pointer comes within ~64px of it, then hides it again after ~1.2s idle (resting on the buttons keeps them up). true/false are aliases for 'on'/'off'; values are case-insensitive and accept _ for - ('ON_MOVE'); anything else warns and uses 'on'. Live-updatable via setOptions. |
| groupTable | null | Optional sortable/searchable metadata table rendered to the left of group labels: { columns, sortMode, defaultSort, search, … } — see Group metadata table. |
Call timeline.setFilter((rawItem, item) => boolean) to filter items across
stacking, drawing, and hit-testing. Pass null to clear. Throwing
predicates fail open per item and emit a filter event with { active, visible }.
The predicate runs on a hot path (several times per item per change, once per
item per frame), so read precomputed fields rather than building strings in
it — FILTERING.md explains the filter internals, their
measured cost, and the patterns to mirror in app-side search.
Appearance
| Option | Default | What it does |
| --- | --- | --- |
| theme | 'light' | 'light', 'dark', or a partial object merged over the light preset (colors plus bandInset, see below). |
| hour24 | true | Show axis/probe time-of-day on a 24-hour clock (13:00) instead of 12-hour with AM/PM. |
| utc | true | Render axis and probe time labels in UTC. Ticks are placed on UTC epoch boundaries, so this keeps labels aligned with them. Set false to label in the runtime's local time zone. |
| fontFamily | system sans | CSS font-family for all canvas text. |
| fontSize | 12 | Base font size in px (axis/probe use one px smaller). |
| uniformItemColor | false | Draw every item in one grey color (uniformItemBg) with its own color as a bottom accent strip. |
| uniformItemBg | '#9e9e9e' | The uniform fill color used when uniformItemColor is on. |
| accentBorderHeight | 3 | Height of the bottom accent strip in px. |
| itemStyles | {} | Named canvas item style definitions. Items can reference them with styleType / itemStyle, or definitions can include a selector over item data/properties. |
| itemStyleResolution | 'merge' | 'merge': all matching definitions combine in cascade order, later wins per property; an explicit null clears a property set by an earlier match. 'first': legacy first-match-wins. |
| styleBuilderButton | false | Show a toolbar button that opens the item style builder dialog. You can also call openItemStyleBuilder() directly. |
| styleBuilderOpen | false | Open the item style builder dialog programmatically; live via setOptions. Reflects the dialog's real state — closing it any way flips this back to false and emits styleBuilderOpen. |
| itemDebugTooltip | false | Show raw + normalized item JSON in the hover tooltip, useful for building style selectors. |
| groupDebugTooltip | false | Show raw + computed group JSON (nesting level, collapsed/visible state, item counts, layout height) when hovering a group band or table row. Items under the cursor keep tooltip priority. |
A theme object holds the named colors used by the renderer (see LIGHT_THEME
in src/theme.js for the full key list) plus one layout knob:
| Theme key | Default | What it does |
| --- | --- | --- |
| bandInset | 0 | Vertical clearance in px between a band's top/bottom edges and the item rows inside it, applied symmetrically (rows start bandInset below the band top and the band reserves the same space below the last row). 0 packs rows flush to the band edges; set e.g. 4 to keep a bottom accent strip off the group separator. Changing it via setTheme/setOptions relayouts the bands. |
// Dark preset with 4px of breathing room around item rows.
import { CanvasTimeline, DARK_THEME } from 'vis-timeline-canvas';
new CanvasTimeline('#timeline', items, { theme: { ...DARK_THEME, bandInset: 4 } });Icons
| Option | Default | What it does |
| --- | --- | --- |
| icons | null | Registry of named icons: { name: { glyph, color, font, fontWeight } } or { name: { src } }. font/fontWeight render that glyph in its own font family (e.g. an icon font) while item text keeps the timeline fontFamily. |
| iconAtlas | null | Sprite sheet: { src \| image, icons: { name: {x,y,w,h} } }. |
| iconSize | 14 | Icon draw size in px. |
| iconGap | 3 | Gap between leading icons in px. |
Icons are drawn before the label and clipped at the item edge the same way the label is, so an item too narrow for its icons shows them cut off rather than dropping them.
Overlays
| Option | Default | What it does |
| --- | --- | --- |
| timeProbe | false | Vertical line following the mouse; the time chip shows on the top-axis band (or the bottom axis when topAxis is off). |
| probeFormat | null | Format of the probe chip label. null/'auto' scales detail with the visible span (seconds zoomed in, date-only zoomed out). Pass a preset — 'time', 'datetime', or 'date' (all respect hour24) — or a callback (time, span) => string for full control (time epoch ms, span visible window ms). Live-updatable via setOptions. |
| topAxisDual | false | Dual top axis. Splits the top-axis band into two axis rows: an outer axis whose ticks cover the full data extent and stay put while you zoom, showing the viewport as a tall thumb (drag it to pan, drag its ends to resize, drag beside it to select a new window, click beside it to jump), and below it a sub axis that breaks the zoomed window down with its own ticks (drag across it to brush-zoom, hover for the probe). Live-updatable via setOptions. |
| topAxisHeight | 48 | Height of the top-axis band in px — formerly minimapHeight, which is still accepted with a warning (64 when topAxisDual is on and no height is given; the dual band is never drawn shorter than 46, the least that fits two labelled rows). |
| topAxisClickToFit | false | A single click on the band's axis area zooms back out to the full extent instead of recentering on the clicked time. Live-updatable via setOptions. |
| topAxis | true | The overview band above the body, which is the top time axis (issue #41 removed the separate minimap switch — passing minimap now warns and is ignored). With a group table the table header shares the band, so the body starts flush below both. The band's axis area mirrors the bottom axis' viewport time scale: drag across it to zoom to the selected times as labelled (a translucent preview with start/end time chips follows the drag), hover it to show the time probe, single-click to recenter. The strip along its bottom edge maps the full data extent and shows the viewport as a thumb — drag the thumb to pan, drag its ends to grow/shrink the window, click the track to jump. A small × over the band's right end resets to the full extent while the view differs from it. Set false for the bottom axis only. Live-updatable via setOptions. |
| followInterval | 1000 | Follow-now tick interval in ms (see setFollowNow). |
Item fields
{
id, start, // required
end, // omit => a "box" item (point-in-time)
content, // plain text label (HTML is NOT parsed)
group, // group id
type, // 'range' | 'box' | 'block' | 'background' (see "Layers" below)
title, // plain-text tooltip
// colors (preferred over a CSS `style` string)
backgroundColor, borderColor, color,
// canvas extras
accentColor, // bottom strip color in uniform mode
pattern, // true | 'stripes' — diagonal stripe overlay (errors)
patternColor,
noText, // hide the label; renders a compact marker
textAlign, // label alignment: 'left' (default) | 'center' | 'right'
onlyDisplayCompleteText, // true: hide a label that doesn't fit instead of clipping it
icon, icons, // leading icons: names or inline {glyph, color?, font?, fontWeight?}
styleType, itemStyle, // named canvas style definition
properties, // string-like selector properties
// block / background only (layer and opacity are also style keys)
interactive, // block: true to allow hover, tooltip, selection and drag (default false)
layer, // block: 'back' (default) | 'front' (covers items)
onTop, // block: alias for layer:'front'
opacity, // override the block / background fill alpha
gradient, // true (auto) | ['#a','#b'] color stops; a style's backgroundGradient wins
gradientDirection, // 'vertical' (default) | 'horizontal'
}Item style builder
Canvas cannot apply arbitrary CSS classes per item. Instead, define reusable canvas-native item styles and apply them by id or selector:
const timeline = new CanvasTimeline('#timeline', items, {
itemStyles: [{
id: 'critical',
selector: { field: 'severity', operator: 'equals', value: 'critical' },
style: {
backgroundGradient: ['#dc2626', '#7f1d1d'],
color: '#fff',
accentBorderColor: '#facc15',
accentBorderWidth: 5,
bottomAccentColor: '#fee2e2',
bottomAccentHeight: 3,
borderRadius: 6,
paddingX: 12,
backgroundGradientType: 'repeating', // optional: 'linear' (default), 'repeating' or 'grid'
backgroundGradientStripeWidth: 10, // optional: stripe thickness ('repeating') or square size ('grid') in px (default 14)
hoverBackgroundColor: '#ef4444',
selectedBackgroundColor: '#f97316',
icons: ['alert']
}
}]
});
timeline.openItemStyleBuilder();backgroundGradientType sets how the two backgroundGradient colours fill
the item:
'linear'(default): a smooth gradient alonggradientDirection.'repeating': stripesbackgroundGradientStripeWidthpx thick, alternating between the two colours, alonggradientDirection.'grid': a checkerboard ofbackgroundGradientStripeWidthpx squares, with the first colour at the top-left.gradientDirectiondoes not apply. In CSS terms this isrepeating-conic-gradient(b 0 25%, a 0 50%)with a background-size of twice the square size.
Any other value falls back to 'linear'. The stripe and grid fills are
clipped to the item's rounded corners. A selected or hovered item keeps its
stripes or grid and shows the selected / hover border and shadow. On an
interactive block, a selectedBackgroundColor or hoverBackgroundColor
replaces the stripes or grid while it applies. In the style builder, tick
Gradient and pick repeating grid in the type list.
Selectors read from item.properties first, then from top-level item fields.
Use fields like properties.status for explicit property-map matching, or set
styleType: 'critical' on an item to apply a style directly.
A definition applies to every kind of item unless it sets itemType, which
names what the item covers: 'item' (range and box items), 'group'
(fills its group's row: background items) or 'subtree' (fills its group
and all subgroups: blocks). The builder's Applies to field sets it. A definition with an itemType and no
selector or class styles every item of that kind; without an itemType
such a definition applies only to items that name it in styleType.
Background and block items take the same style keys as normal items: fill
colour, linear, stripe and grid gradients, stripe pattern, accent strips,
border, corner rounding, label colour, font, textAlign,
onlyDisplayCompleteText, padding and icons. Their label runs along the
top edge of the band. A block with interactive: true (see below) can be
hovered and selected, so the hover and selected colours apply to it;
background items and other blocks are never hovered or selected. Two keys
apply to these bands only:
opacity(0 to 1): the alpha of the fill, gradient and pattern. The border and label stay opaque. Defaults: 0.16 for background items and back blocks, 0.7 for front blocks.layer(blocks):'back'(default) draws behind items,'front'covers them.onTop: trueis accepted aslayer: 'front'.
Both can also be set on the item. As for every style key, a matching
definition that sets layer or opacity wins over the item's own value.
Bands take the full style set only from definitions aimed at them
(itemType: 'group' or 'subtree') and from their own item fields. The base
style and definitions without an itemType give them only their fill colour,
as in 1.2.x, so a base or selector style written for normal items (white
text, rounded corners, hover colours) doesn't restyle every band. An
unstyled band keeps its usual look: square corners, a semibold label in a
darkened fill colour, and a 1.5px outline in the fill colour on blocks (none
on background items). A band's fill colour gets the band alpha in place of
its own, so an rgba() colour is not faded twice. In the builder, a style
limited to one kind hides the fields that can't apply to it, and doesn't
save them.
itemStyles: [
{ id: 'outage', itemType: 'group', selector: 'kind=outage', style: { backgroundColor: '#e53935' } },
{ id: 'maintenance', itemType: 'subtree', selector: 'kind=maintenance', style: { backgroundColor: '#5c6bc0' } }
]Two style keys control the label (issue #58). textAlign ('left',
'center' or 'right') places it in the room after the icons; a label wider
than that room starts at the left edge whatever the alignment.
onlyDisplayCompleteText: true hides a label that doesn't fit whole inside
the item instead of clipping it, and does the same for icons: icons that fit
whole still draw, an icon that doesn't is left out along with the label after
it. Without it, icons are clipped at the item edge like the label, so a
narrow item shows part of an icon. Both are ordinary style keys: set them on
a definition, the base style, or an item.
Labels and icons stay at least 4px inside the left and right edges of the
timeline body, whatever the paddingX. When an item runs past either edge,
its label is held 4px inside that edge while the item box still runs to the
edge.
Resolution: merge (default) vs first
itemStyleResolution controls what happens when multiple definitions match
one item:
'merge'(default) — every matching definition applies, in cascade order (array order, reordered bypriority; higher priority lands later). Style objects are sparse, so each definition only controls the keys it sets and later definitions win per property — CSS source-order semantics. Independent rules (a team color, an urgency accent) combine on one item. An explicitnullfor a key clears whatever an earlier matching definition set, letting the per-item/base/default value show through — e.g.style: { backgroundGradient: null }removes another definition's gradient.iconsis the one accumulating key: the icon lists of all matching definitions concatenate in cascade order (deduplicated) instead of replacing each other, so a team badge and an urgency flag from independent rules both show;icons: nullstill clears the list, and per-itemicon/iconsfields override style icons entirely.'first'— legacy first-match-wins: only one definition (the first match initemStylesorder) applies;nullkeys are ignored.
The style builder includes a small manager where styles can be reordered by
dragging; that order is the cascade (and, under 'first', the precedence).
Each row has a leading On checkbox that toggles the definition without
removing it — a definition with enabled: false stays in the list (and in
exported themes) but is dropped from the cascade and skipped by both
resolvers, so it styles nothing. Only an explicit enabled: false disables a
definition; the flag defaults to on and round-trips through JSON
export/import.
The Definition JSON box in the builder is editable: tweak a style's JSON by
hand — including keys the form fields don't expose — and click Apply JSON to
merge it back into the selected definition. The text runs through the same
validation the theme import uses, so a typo can't corrupt a definition; invalid
JSON is reported inline and your text is left intact. Editing the pinned Base
row applies { base: true, style: {...} } to the base layer instead. The
controller returned by openItemStyleBuilder() exposes the same operation
programmatically as controller.applyJson(text?), returning
{ ok, error?, style?, base? }.
Saving and reloading the style state
exportItemStyles() returns the complete style state as plain JSON — the
color theme, the cascade resolution mode, the base layer, and the
styles cascade — so application code can persist it (per user, per view,
…) and hand it back to importItemStyles() later. Keys missing from a
payload keep their current value, so older { base, styles } exports and
bare style arrays still load.
// save
localStorage.setItem('timeline-style', JSON.stringify(timeline.exportItemStyles()));
// … and reload
timeline.importItemStyles(JSON.parse(localStorage.getItem('timeline-style')));Listen to itemStyleChange to save as the user edits in the builder. To
control the builder itself from app code use the styleBuilderOpen option:
timeline.setOptions({ styleBuilderOpen: true }); // open
timeline.isStyleBuilderOpen(); // true
timeline.on('styleBuilderOpen', ({ open }) => ui.builderToggle.checked = open);
timeline.setOptions({ styleBuilderOpen: false }); // closeThe option always mirrors the dialog: closing it from its own Close button
(or the toolbar S button opening it) updates options.styleBuilderOpen and
emits styleBuilderOpen, so a bound checkbox never drifts. Seed your UI from
isStyleBuilderOpen() right after construction, then track the event — with
styleBuilderOpen: true in the constructor the dialog opens before any on()
can be registered, so that first { open: true } is never observed. Reopening
an already-open builder (e.g. openItemStyleBuilder('other-id')) swaps the
dialog without emitting; destroy() closes it without emitting.
Group fields
{
id, content,
order, // sort key
nestedGroups, // [childId, ...] to nest groups (any depth — subgroups
// can hold subgroups; collapse hides the subgroup rows
// and their items, but the parent's own items stay
// visible in the parent band)
showNested, // start expanded (default true)
visible, // hide the group
height, // base band height in px for this group (overrides
// groupMinHeight / parentGroupHeight; stacked items
// still grow the band so they are never clipped)
backgroundColor, color,
accentColor, // highlight tint
}Group metadata table
Use groupTable to add DOM columns that stay aligned with canvas group bands:
new CanvasTimeline('#timeline', items, {
groups,
groupTable: {
search: true,
columns: [
{ key: 'owner', label: 'Owner', width: 120, sortable: true },
{ key: 'status', label: 'Status', width: 90, sortable: true },
{ key: 'count', label: 'Items', width: 70, align: 'right', sortable: true }
]
}
});Column values are read from each raw group object. While the table is shown it
replaces the group-name label panel (add a content column to display
group names; pass showGroupLabels: true to keep the panel too).
Accent highlights (a group's accentColor, or setGroupHighlight at runtime)
are mirrored on the matching table row — the same left bar and tint the canvas
band gets — so the table and timeline read as one row. Hover and selection
highlights mirror both ways too: hovering a canvas band lights up its table row
and vice versa, and a row whose group holds a selected item gets the selected
wash. Rows are cached and reconciled (each tagged with data-group-id), so a
hover only restyles the affected rows — no per-mousemove DOM rebuild.
Cell text that overflows its column is ellipsized; hovering such a cell shows
the full value as a native tooltip. Column headers get the same treatment: a
header whose title is truncated by a narrow column shows the full title on
hover, and headers that fit show nothing (disable both with
cellTooltips: false).
Search filters visible groups by column values and group content. Each column
also gets its own clearable filter popup (disable the funnel with
columnFilters: false): a plain-text substring input plus an exists /
not-exists pair — "Has a value" keeps only groups whose raw column value is
present, "Is empty" only those where it is missing or blank (the two are
mutually exclusive). The presence test reads the raw value before it is coerced
for display, so a genuinely missing field counts as empty. A column's text and
exists constraints AND together, and all active column filters AND together.
Both are driven programmatically through setGroupTableState({ query,
filters }); how the matching works and why it is fast is written up in
FILTERING.md.
The filter popup and the ⚙ column picker stay inside the timeline container:
they shift sideways (or open above the header) to fit the container and the
browser viewport when the table is narrow or sits near a screen edge. The
container is the hard boundary, so a timeline shorter than a popup (roughly
under 150px tall) still clips its bottom edge.
With nested groups, a matching subgroup keeps its whole ancestor chain visible
so the tree stays intact; a matching parent does not reveal non-matching
children, and groups hidden via visible: false are never forced visible.
Sorting is multi-column: a header click cycles that column ascending → descending → unsorted, and clicking additional columns subsorts in click order (the header shows ▲/▼ plus the sort priority).
With nested groups, sorting is tree-aware by default (sortMode: 'tree'):
each parent stays immediately above its children, and the sort keys order the
siblings within each parent — so the hierarchy is preserved while the rows
you can compare get sorted. Ties fall back to subgroupOrder / the natural
nestedGroups order. Set sortMode: 'flat' to instead sort every group into
one strict ranked list, breaking parents apart — use it when a single global
ordering matters more than the nesting.
sortMode is a key inside groupTable — a top-level sortMode option
warns and is ignored — and it only decides how an active column sort (a
header click or defaultSort) treats nested groups: with no sort active both
modes show the natural groupOrder / tree order, and without nested groups the
two are identical. Switch it at runtime by re-passing the config,
setOptions({ groupTable: { ...config, sortMode: 'flat' } }); the active
sort is kept and re-applied in the new mode.
To keep flat mode readable once the hierarchy is broken up, while a column sort is active it changes how nested groups present (this reverts the moment the sort is cleared):
- Item-less parents are hidden. A parent that owns no items of its own is a pure grouping header; in a strict global order it would sit above whatever child happened to scatter beneath it, falsely implying ownership. Parents that do carry their own items stay as ordinary rows.
- Child rows show a breadcrumb. With indentation meaningless, the first
column shows each group's full ancestry path (
Infra ▸ Cache, nesting fully toA ▸ B ▸ C) so the parent/child relationship stays parseable at a glance. The label panel applies the same breadcrumb to its band labels.
For orderings the default numeric-then-locale comparison can't express (say a
fixed status ranking, or a natural sort of alphanumeric ids), give a column its
own comparator(a, b). It receives the two raw cell values and returns a
negative/zero/positive number describing the ascending order; a descending
sort calls the same function with its arguments swapped, so the column inverts
exactly:
const RANK = { critical: 0, high: 1, medium: 2, low: 3 };
groupTable: {
sortMode: 'tree', // default; 'flat' for a strict global sort
columns: [
{ key: 'content', label: 'Group', width: 170, sortable: true },
{ key: 'status', label: 'Status', width: 90, sortable: true,
comparator: (a, b) => (RANK[a] ?? 99) - (RANK[b] ?? 99) }
]
}When the descending order isn't a pure mirror of the ascending one, give the
column both: ascendingComparator (the explicit name for comparator; it wins
when both are set) and descendingComparator, which is used as-is for the
descending sort. descendingComparator only affects the descending sort — set
on its own, ascending still uses comparator/ascendingComparator or, if
neither is given, the default comparison. A typical case is keeping blank
values at the bottom in both directions:
const blankLast = (a, b, cmp) => {
if (!a && !b) return 0;
if (!a) return 1; // blanks sort after any value...
if (!b) return -1;
return cmp(a, b); // ...and the rest follows the direction
};
{ key: 'owner', label: 'Owner', width: 90, sortable: true,
ascendingComparator: (a, b) => blankLast(a, b, (x, y) => x.localeCompare(y)),
descendingComparator: (a, b) => blankLast(a, b, (x, y) => y.localeCompare(x)) }Column visibility is controlled by the built-in ⚙ picker, or from app code:
timeline.setGroupTableColumns(['owner', 'status'])shows exactly those columns (getGroupTableColumns()reads the current set).timeline.setOptions({ groupTable })re-passes a whole config: the table is rebuilt andcolumns[].hiddenis re-read, so app-side checkboxes that togglehiddenflags work by re-passing the config (the same call switchessortModelive, keeping the active sort). PassgroupTable: nullto remove the table; a config object withoutcolumnsalso removes it, with a warning.
Note columns[].hidden is only read at construction and by setOptions —
mutating the flag on the config object alone does nothing.
Pass defaultSort to start the table already sorted, before any header
clicks — entry order is the sort priority, and dir accepts 1/'asc'
(default) or -1/'desc':
groupTable: {
columns: [ /* ... */ ],
defaultSort: [{ key: 'owner' }, { key: 'count', dir: 'desc' }]
}Header clicks then adjust or clear the sort exactly as if the user had built
it by clicking; clearing the last sorted column restores the natural
groupOrder order.
Columns can be reordered by dragging a header cell left or right (disable
with reorderableColumns: false); an accent edge marks the drop slot. The
whole user-adjusted view state — column order, hidden columns, widths, sorts,
filters, and the search query — is a JSON-safe snapshot via
getGroupTableState(), restorable (fully or partially) with
setGroupTableState(state).
Events
Subscribe with timeline.on(name, cb) / unsubscribe with off.
| Event | Payload |
| --- | --- |
| select | { items: id[] } |
| doubleClick | { item, event } (also fired by Enter on the focused item) |
| itemMove | { items: [{ id, start, end, group? }] } — persist these (group only when a cross-group drag changed it; each item reports its own new group) |
| itemResize | { item, start, end, edge } — persist these |
| groupClick | { group, event } |
| groupCollapse | { groupId, collapsed } |
| followNow | { enabled } |
| rangechange | { start, end } (continuous during pan/zoom) |
| rangechanged | { start, end } (debounced after motion settles) |
| itemover / itemout | { item, event } |
| contextmenu | { item \| null, event } (right-click) |
| itemStyleChange | { style } (a style was saved through upsertItemStyle or the builder) |
| styleBuilderOpen | { open } (the style builder dialog opened or closed, by any route) |
| render | { visibleCount } |
The vis-timeline pointer events (click, doubleClick, contextmenu,
mouseOver, mouseDown, mouseMove, mouseUp) carry a
getEventProperties() payload and fire only for pointer activity over the
timeline canvases — the item body, the axes (only the stretch that runs over
the body, not the blank band under the table) and the group-label panel. The
group table, top-axis band, scrollbar and toolbar buttons never emit them (a table
row has its own groupClick), so a doubleClick handler can trust time/x
to lie within the body unless what === 'group-label'.
Custom time bars
addCustomTime, setCustomTime, getCustomTime, removeCustomTime,
setCustomTimeTitle and setCustomTimeMarker keep their vis-timeline
signatures, and a dragged bar emits timechange / timechanged as in vis.
Each bar also takes its own options, as the third argument of
addCustomTime or later through setCustomTimeOptions(options, id):
| Key | Default | Effect |
|---|---|---|
| interactive | true | false: the bar is never hit-tested. No drag, no move cursor, no timechange, and getEventProperties does not report it, so hover, clicks, selection and drags reach the item or background under it. |
| strokeStyle | 'solid' | 'solid', 'dashed' or 'dotted'. |
| strokeWidth | 2 | Line width in px. The grab distance grows with it (at least 4px either side). |
| strokeColor | theme minimapWindowBorder | CSS colour of the line. The setCustomTimeMarker label uses it too. |
| chips | none | Chips that show where the bar sits on the time scale: { top, bottom, label, color, textColor }. |
chips keys:
top: a chip on the top-axis band (in the sub axis withtopAxisDual). Nothing is drawn whiletopAxisis off.bottom: a chip on the bottom time axis.label: the chip text. By default the chip shows the bar's time, formatted like the time-probe chip (probeFormat). An empty string draws a small tab with no text.color/textColor: chip background (default: the bar'sstrokeColor) and text colour (default: the theme'sprobeText).
With either chip on, the bar's line is also drawn across that axis.
setCustomTimeOptions merges into the bar's current options, and chips
merges key by key, so { chips: { top: false } } leaves bottom and label
alone. null resets a key to its default and chips: null removes the chips.
Invalid values (an unknown strokeStyle, a width that is not a positive
number, a non-boolean interactive, an unknown key) log a warning and fall
back to the default. getCustomTimeOptions(id) returns the options with
every default filled in. Turning interactive off while the bar is being
dragged ends the drag where it is and emits timechanged.
// A fixed deadline that never gets in the way of the items under it.
timeline.addCustomTime(deadline, 'deadline', {
interactive: false,
strokeStyle: 'dotted',
strokeColor: '#e53935',
chips: { top: true, bottom: true, label: 'Deadline' },
});
// A draggable cursor whose bottom chip shows its time while it moves.
timeline.addCustomTime(new Date(), 'cursor', { strokeWidth: 3, chips: { bottom: true } });
timeline.setCustomTimeOptions({ strokeColor: '#43a047' }, 'cursor');The setCustomTimeMarker label (vis's marker) is separate from the chips: it
is a DOM label pinned to the top of the bar inside the body. On a
non-interactive bar a read-only marker lets pointer input through; an
editable one still takes focus and input. The current-time line
(showCurrentTime) does not use these options.
Live updates (setOptions)
setOptions(options) applies a subset of options after construction, routing
each supported key to its live setter. Anything not listed here warns and is
ignored (use the dedicated setter or rebuild instead).
| Key | Effect | Equivalent setter |
|---|---|---|
| theme | switch theme (relayouts when bandInset changes) | setTheme |
| filter | set/clear the item filter predicate (null clears) | setFilter |
| hour24 | 24-hour vs 12-hour axis/probe labels | — |
| topAxis | add/remove the top-axis band | — |
| height | resize the whole component (px number or '100%') | — |
| showUngrouped | show/hide the "(No Group)" band | — |
| min / max | re-pin (or clear with null) a hard time bound | — |
| showTimelineControls | 'on' / 'off' / 'on-move' control overlay mode | — |
| groupTable | re-pass a whole table config (or null to remove); also switches sortMode live | setGroupTableColumns |
| styleBuilderOpen | open/close the item style builder dialog (idempotent) | openItemStyleBuilder |
| itemStyleResolution | switch 'merge' / 'first' cascade resolution | — |
| timelineMinWidth | change the minimum body width and reflow | — |
| topAxisClickToFit | single click on the minimap fits (true) or recenters (false) | — |
| doubleClickToFit | a double-click on empty body space fits the data (true) or does nothing (false) | — |
| topAxisDual | switch the minimap band between the single scale + window strip and the dual outer/sub axis layout | — |
| dragTimePreview / dragTimePreviewFormat | turn the drag time preview on/off (hides it at once mid-drag) / change its text | — |
timeline.setOptions({ theme: 'dark', topAxis: true, hour24: false });Methods
setItems · setGroups · getGroups · getItemCount · fit · setWindow ·
getWindow · moveTo · zoomIn · zoomOut · toggleFullscreen ·
isFullscreen · setSelection · getSelection · getVisibleItems ·
toggleGroupCollapse · setGroupHighlight · clearGroupHighlights ·
setGroupTableColumns · getGroupTableColumns · setOptions ·
addCustomTime · setCustomTime · getCustomTime · removeCustomTime ·
setCustomTimeOptions · getCustomTimeOptions · setCustomTimeTitle ·
setCustomTimeMarker · getEventProperties ·
setFollowNow · isFollowingNow · setTheme · on · off · redraw ·
setItemStyles · getItemStyles · upsertItemStyle · exportItemStyles ·
importItemStyles · openItemStyleBuilder · isStyleBuilderOpen · destroy. See
src/canvas-timeline.d.ts for full signatures.
Keyboard (when keyboard is on)
Click the timeline to focus it, then:
- ← / → — move the selection to the previous/next item within the same group (stays on one row band; pans when nothing is selected).
- ↑ / ↓ — move to the nearest-in-time item in the adjacent group.
- Shift + ← / → — nudge the selected item(s) in time (needs
editablewithupdateTime). - + / − — zoom in/out. F — fit. Home / End — first/last item.
- Esc — clear selection. Enter — activate (emits
doubleClick).
Raw item passthrough & reference semantics
When you hand the timeline an item or group, it builds a normalized internal
record (epoch-ms times, resolved style, split class tokens, …) and keeps your
original object by reference on _raw — it is not deep-copied. One
pointer per record, so there is no per-item copying overhead.
This is load-bearing, not just a debug convenience. The renderer reads _raw
whenever it needs a field it didn't normalize:
setFilter(predicate)calls your predicate aspredicate(rawItem, item)— the first argument is your original object.- Item style selectors (
itemStylesselector.field,styleType,properties) resolve against_rawfirst, then the normalized fields. getGroups()returns your original group objects (the_rawreferences), in render order — not normalized copies.- Group tables read every cell value, sort key, and
visibleflag off the raw group. - Group drag-reorder swaps the
orderfield in place on your raw group objects (matching vis-timeline'sgroupOrderSwap), then persists the new values through your DataSet on drop.
Mutation caveat
Because _raw is a live reference, two things follow:
- Mutating your source object after load is visible to the timeline on the
next re-resolve (e.g. changing
properties/classNamethen triggering a style refresh, or a filter re-run). This is intentional, but it means the timeline is not a defensive copy — treat shared objects accordingly. - The timeline may mutate your objects. Group drag-reorder writes
orderback onto the raw group. If you need the input untouched, pass clones.
The normalized record is the timeline's own; mutate it freely without
affecting your data. The _raw field is internal — prefer the predicate/
callback arguments and getGroups() over reaching into item._raw yourself.
How this fork diverges from vis-timeline
These are the behaviors most likely to surprise you when migrating. Worked
examples are in EXAMPLES.html.
- No HTML templates.
template/groupTemplateare not supported — the canvas draws plain text + shapes. Convert template logic into data-driven fields (backgroundColor,pattern,icons, …). className/ CSS classes do nothing. Style via data fields instead.- Groups go in options, not the third constructor argument.
- You persist edits via
itemMove/itemResize. - Item types:
box,range,background, and the newblock.pointis not implemented. type: 'background'is supported as in vis: a translucent band that fills its own group's row (however tall that row is) over its time range and is not interactive — no hover/tooltip, selection, drag/resize or item events; items drawn over it receive them instead. It is not stacked and does not grow the row. Items render in three layers: background items at the back,blockitems over them, regular items on top. Style it like any item, with item fields or style definitions (see "Item style builder");opacitysets the fill alpha (default 0.16) andlayer/onTopdo not apply. Differences from vis:endis required (a background without one is not drawn rather than throwing); a background on a parent group covers only the parent's own row, not its subgroups (use ablockfor that); withgroupsconfigured, a background with nogrouplands in the "(No Group)" row like any other groupless item (drawn only whenshowUngroupedshows that row), while with nogroupsat all it spans the whole timeline; the label is plain text drawn at the top of the row, and CSSclassNamestyling does not apply.type: 'block'(orblock: true) fills its group's row and the rows of all its subgroups over its time range. It is not stacked. Since 1.3.0 a block is not interactive by default, like a background item: no hover or tooltip, selection, drag or item events, and the pointer reaches the items beneath it even on the front layer. Setinteractive: trueon the block to make it hoverable, selectable and (witheditable) draggable in time, as all blocks were in 1.2.x.interactiveis an item field, not a style key, and only applies to blocks.- No
item.subgroup. Subgroups are nested groups: add a child group under the parent'snestedGroupsand pointitem.groupat it.subgroupStack(per-subgroup stacking) has no equivalent — seetransition.html§4. - Plain-text tooltips only (the
titlefield). For rich content, render your own overlay ondoubleClick/contextmenu.
See transition.html for the full migration plan and a
parallel-run harness.
Project layout
The renderer is one class spread across focused modules. The big method groups
are defined as "mixin" classes and copied onto CanvasTimeline.prototype at
load (see src/mixin.js), so they share one this while living in separate,
readable files.
index.js package entry: re-exports CanvasTimeline + DataSet/DataView
index.d.ts package types
src/
canvas-timeline.js core: constructor, options, viewport/selection/follow/
theme public API, events, low-level draw/measure helpers
mixin.js applyMixins(target, ...sources)
timeline/
groups.js group loading, nesting, collapse, highlights
data.js item loading/normalizing + vis-data DataSet sync
setup.js DOM construction, canvas sizing, event wiring
layout.js viewport/time-bounds math, stacking, snapping
rendering.js the canvas draw path (grid, items, blocks, axis)
minimap.js the top-axis band: time scale, brush zoom, window strip / dual axis
interaction.js pointer + keyboard input, drag/resize, hit-testing
item-group-drag.js target groups for a cross-group item drag
theme.js light/dark presets + buildTheme()
colors.js lighten / darken / withAlpha
canvas-draw.js roundRect / truncateText
time-format.js axis + probe label formatting
canvas-timeline.d.ts TypeScript declarationsRun the tests with npm test (layout/unit assertions + a headless render
smoke test). npm run test:visual runs the Playwright visual-regression suite
(pixel comparison of rendered scenarios; see
test/visual/README.md).
