npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-canvas

Also 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:

  1. Groups are passed in the options object ({ groups }), not as a third constructor argument.
  2. You persist edits. Drag/resize emit itemMove / itemResize; the renderer never writes back to your DataSet on 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 along gradientDirection.
  • 'repeating': stripes backgroundGradientStripeWidth px thick, alternating between the two colours, along gradientDirection.
  • 'grid': a checkerboard of backgroundGradientStripeWidth px squares, with the first colour at the top-left. gradientDirection does not apply. In CSS terms this is repeating-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: true is accepted as layer: '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 by priority; 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 explicit null for 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. icons is 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: null still clears the list, and per-item icon/icons fields override style icons entirely.
  • 'first' — legacy first-match-wins: only one definition (the first match in itemStyles order) applies; null keys 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 });  // close

The 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 to A ▸ 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 and columns[].hidden is re-read, so app-side checkboxes that toggle hidden flags work by re-passing the config (the same call switches sortMode live, keeping the active sort). Pass groupTable: null to remove the table; a config object without columns also 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 with topAxisDual). Nothing is drawn while topAxis is 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's strokeColor) and text colour (default: the theme's probeText).

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 editable with updateTime).
  • + / − — 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 as predicate(rawItem, item) — the first argument is your original object.
  • Item style selectors (itemStyles selector.field, styleType, properties) resolve against _raw first, then the normalized fields.
  • getGroups() returns your original group objects (the _raw references), in render order — not normalized copies.
  • Group tables read every cell value, sort key, and visible flag off the raw group.
  • Group drag-reorder swaps the order field in place on your raw group objects (matching vis-timeline's groupOrderSwap), then persists the new values through your DataSet on drop.

Mutation caveat

Because _raw is a live reference, two things follow:

  1. Mutating your source object after load is visible to the timeline on the next re-resolve (e.g. changing properties/className then 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.
  2. The timeline may mutate your objects. Group drag-reorder writes order back 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/groupTemplate are 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 new block. point is 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, block items over them, regular items on top. Style it like any item, with item fields or style definitions (see "Item style builder"); opacity sets the fill alpha (default 0.16) and layer/onTop do not apply. Differences from vis: end is 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 a block for that); with groups configured, a background with no group lands in the "(No Group)" row like any other groupless item (drawn only when showUngrouped shows that row), while with no groups at all it spans the whole timeline; the label is plain text drawn at the top of the row, and CSS className styling does not apply.
  • type: 'block' (or block: 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. Set interactive: true on the block to make it hoverable, selectable and (with editable) draggable in time, as all blocks were in 1.2.x. interactive is 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's nestedGroups and point item.group at it. subgroupStack (per-subgroup stacking) has no equivalent — see transition.html §4.
  • Plain-text tooltips only (the title field). For rich content, render your own overlay on doubleClick/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 declarations

Run 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).