A large plan
The chart draws only what you can see: the rows in view, and the bars, links, grid lines and days that belong to them (plus a few beyond the edges, so that scrolling does not show gaps). A plan of ten thousand tasks costs the DOM about what a plan of a hundred does. This example generates a plan of the size you choose, with a link from each task to the one before it in its chain, and says how long the chart took from being asked for to having its first bars on the screen. The two buttons jump to the ends of the plan.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
/** The sizes the select offers. */export const COUNTS = [100, 1_000, 5_000, 10_000] as const;/** The size the chart opens with: enough to see what the chart does, light enough for a page. */export const DEFAULT_COUNT = 1_000;
const TEAMS = ['Backend', 'Frontend', 'Docs', 'Marketing', 'QA', 'Design'] as const;const STAGES = ['Design', 'Build', 'Review', 'QA', 'Release'] as const;/** How far each stage of a chain has got: the first is done, the second is under way. */const PROGRESS = [100, 50, 0, 0, 0] as const;
/** The date `n` working days after Monday 2026-01-05, as `'YYYY-MM-DD'`. */function workingDay(n: number): string { const calendarDays = Math.floor(n / 5) * 7 + (n % 5); return new Date(Date.UTC(2026, 0, 5 + calendarDays)).toISOString().slice(0, 10);}
/** * Makes a plan of `count` tasks, with no randomness: the same count gives the same tasks every * time. The tasks come in chains of five (Design, Build, Review, QA, Release), each one starting * after the one before it and linked to it, and the chains start a few days apart, so the chart * is a long staircase. Every task starts and ends on a working day, so no task is in conflict * with the weekends. * * @param count - How many tasks. * @returns The tasks (ids `t1` … `t<count>`) and about four links for every five of them. */export function generate(count: number): { tasks: Task[]; links: Link[] } { const tasks: Task[] = []; const links: Link[] = []; let day = 0; for (let i = 0; i < count; i++) { const chain = Math.floor(i / STAGES.length); const stage = i % STAGES.length; if (stage === 0) day = (chain * 3) % 90; const length = 2 + ((chain * 7 + stage * 3) % 5); const team = TEAMS[chain % TEAMS.length] ?? 'Team'; tasks.push({ id: `t${String(i + 1)}`, name: `${team} ${String(chain + 1)} · ${STAGES[stage] ?? ''}`, start: workingDay(day), end: workingDay(day + length - 1), progress: PROGRESS[stage] ?? 0, }); if (stage > 0) links.push({ source: `t${String(i)}`, target: `t${String(i + 1)}` }); day += length + ((chain + stage) % 2); } return { tasks, links };}
// What `createGantt` and the tests get: the plan at its default size.export const { tasks, links } = generate(DEFAULT_COUNT);import type { GanttOptions } from '@ganttlane/dom';
export const options = { viewMode: 'week', // Open on the plan, at its first task. scroll: { initial: 'first-task' }, // Compact rows: more of the plan on the screen. The chart draws only the rows (and the bars, // links and days) that are in view, plus a few beyond, whatever the size of the plan. layout: { rowHeight: 28, barHeight: 18 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { COUNTS, DEFAULT_COUNT, generate, links, tasks } from './data';import { describe, onFirstPaint } from './helpers';import { options } from './options';
/** A chart on the screen, with what it takes to take it away again. */interface Mounted { readonly gantt: Gantt; readonly size: number; /** Stops watching for the first paint. */ readonly stop: () => void;}
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The chart fills its container and scrolls inside it, so the container needs a height. const chart = document.createElement('div'); chart.style.height = '400px'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
const create = (size: number): Mounted => { const plan = size === DEFAULT_COUNT ? { tasks, links } : generate(size); status.textContent = describe(plan.tasks.length, plan.links.length); const started = performance.now(); const gantt = createGantt(chart, { tasks: plan.tasks, links: plan.links, ...options }); // The chart is made at once and draws its bars in the next animation frame: the time to the // first bars is what a visitor waits for. const stop = onFirstPaint(chart, started, (milliseconds) => { status.textContent = describe(plan.tasks.length, plan.links.length, milliseconds); }); return { gantt, size, stop }; }; let current = create(DEFAULT_COUNT);
const controller = new AbortController(); const { signal } = controller;
const select = document.createElement('select'); for (const size of COUNTS) { const option = document.createElement('option'); option.value = String(size); option.textContent = size.toLocaleString('en'); select.append(option); } select.value = String(DEFAULT_COUNT); select.addEventListener( 'change', () => { const size = COUNTS.find((candidate) => String(candidate) === select.value); if (size === undefined) return; // A new plan is a new chart: the old one is taken away first. current.stop(); current.gantt.destroy(); current = create(size); }, { signal }, ); const label = document.createElement('label'); label.append('Tasks ', select); toolbar.append(label);
const addButton = (name: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = name; button.addEventListener('click', onClick, { signal }); toolbar.append(button); }; // Only the rows in view are drawn, so a jump to the last row of the plan costs no more than the // first screen did. addButton('First task', () => { current.gantt.scrollToTask('t1', { behavior: 'instant' }); }); addButton('Last task', () => { current.gantt.scrollToTask(`t${String(current.size)}`, { behavior: 'instant' }); });
return () => { controller.abort(); current.stop(); current.gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useEffect, useMemo, useRef, useState } from 'react';
import { COUNTS, DEFAULT_COUNT, generate, links, tasks } from './data';import { describe, onFirstPaint } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const host = useRef<HTMLDivElement>(null); // The size of the plan, and when it was asked for. const [run, setRun] = useState(() => ({ size: DEFAULT_COUNT, asked: performance.now() })); const [milliseconds, setMilliseconds] = useState<number | undefined>(); const { size, asked } = run; const plan = useMemo(() => (size === DEFAULT_COUNT ? { tasks, links } : generate(size)), [size]); // In React `viewMode` is a controlled prop, and `defaultViewMode` is its uncontrolled twin: here // nothing changes the view mode, so the chart owns it and starts from the option. const { viewMode, ...rest } = options;
// The time to the first bars: from the moment the plan was asked for, to the first frame. useEffect(() => { const element = host.current; if (!element) return undefined; return onFirstPaint(element, asked, setMilliseconds); }, [asked]);
return ( <> <div role="toolbar"> <label> Tasks{' '} <select value={size} onChange={(event) => { setMilliseconds(undefined); setRun({ size: Number(event.target.value), asked: performance.now() }); }} > {COUNTS.map((count) => ( <option key={count} value={count}> {count.toLocaleString('en')} </option> ))} </select> </label> {/* Only the rows in view are drawn, so a jump to the last row of the plan costs no more than the first screen did. */} <button type="button" onClick={() => chart.current?.scrollToTask('t1', { behavior: 'instant' })} > First task </button> <button type="button" onClick={() => chart.current?.scrollToTask(`t${String(size)}`, { behavior: 'instant' })} > Last task </button> </div> <div ref={host}> <Gantt // A new plan is a new chart: the `key` makes React take the old one away. key={size} ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 400 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={plan.tasks} defaultLinks={plan.links} defaultViewMode={viewMode} {...rest} /> </div> <p>{describe(plan.tasks.length, plan.links.length, milliseconds)}</p> </> );}const number = new Intl.NumberFormat('en');
/** * Calls `report` with the time from `since` until the first bar of the chart in `host` is on the * screen: when it has been drawn, and the browser has had a frame to paint it. The chart draws its * bars in an animation frame of its own, so this watches `host` for the first one. * * @param host - The element the chart is drawn in. * @param since - A `performance.now()` reading from just before the chart was asked for. * @param report - Called once, with the milliseconds. * @returns A function that stops watching (call it when the chart goes first). */export function onFirstPaint( host: HTMLElement, since: number, report: (milliseconds: number) => void,): () => void { let frame = 0; const observer = new MutationObserver(() => { check(); }); const check = (): void => { if (!host.querySelector('.gantt__bar')) return; observer.disconnect(); frame = requestAnimationFrame(() => { report(performance.now() - since); }); }; observer.observe(host, { childList: true, subtree: true }); check(); return () => { observer.disconnect(); cancelAnimationFrame(frame); };}
/** * Says what was drawn, and how long it took. * * @param tasks - How many tasks the plan has. * @param links - How many links. * @param milliseconds - The time to the first bars, or `undefined` while it is not known yet. * @returns A sentence. */export function describe(tasks: number, links: number, milliseconds?: number): string { const plan = `${number.format(tasks)} tasks and ${number.format(links)} links`; return milliseconds === undefined ? `${plan}: drawing …` : `${plan}: the first bars were on screen ${String(Math.round(milliseconds))} ms after the chart was asked for (on this machine, in this browser)`;}Files
data.ts:generate(count), a deterministic generator of tasks and links, and the plan at its default size (1,000 tasks) astasksandlinks.options.ts: the view mode, the first scroll and compact rows.helpers.ts:onFirstPaint, which times the first bars, anddescribe.vanilla.ts: a new chart for each size, andscrollToTask.react.tsx: the same, with akeythat makes a new chart for each size.
The plan
generate(count) has no randomness: the same count gives the same tasks every time. The tasks
come in chains of five (Design, Build, Review, QA, Release), named after a team and the number of
the chain (Backend 1 · Design). Each task starts after the one before it and is linked to it
(t1 → t2 → …, four links for every five tasks), and each chain starts three working days after
the one before it (over thirty chains, and then from the start again), so the plan is a row of
staircases. Every task starts and ends on a working day, so no bar is in conflict with the
weekends. Ids are t1 … t<count>.
Options
| Option | Value | What it does |
|---|---|---|
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode, as nothing here changes it) |
scroll.initial |
'first-task' |
Opens on the plan, at its first task |
layout.rowHeight |
28 |
Compact rows: more of the plan on the screen |
layout.barHeight |
18 |
The height of a bar inside its row |
What to try
Pick 10,000 and read the line under the chart: it is the time from the moment the chart was
asked for (generating the plan is not counted) to the first animation frame that has bars in it,
on your machine. Pick a few sizes to see how it grows with the plan: the chart creates its model
of every task once, but draws a screenful. Press Last task: the chart is at the end of a plan
of ten thousand rows at once (scrollToTask, which here is instant: a smooth scroll through
three hundred thousand pixels would take a while). Drag a bar and the tasks that follow it move
with it, as they do on a plan of four.
Notes
- What is drawn. With the plan of 10,000 tasks, the chart holds about twenty rows, a dozen bars
and as many links in the DOM at a time, whatever the size of the plan. That is the virtual
window: the rows in view, and a few more on each side (
overscan). - Where the time goes. Creating a chart reads every task once: it validates the shapes, parses the dates, builds the tree and the graph of links, and works out the rows. That is the part that grows with the plan. Drawing does not.
- Changing the data. To change some tasks, give
setTasksthe new list: it is diffed by id, and only what changed is recomputed. A new chart, as this example makes for each size, is for a plan that is a different one altogether (in React, akeydoes it). - Generating data. The generator is a plain function of the count, so that the data is the same in the tests, in the code tabs and on the page. It makes its dates by counting working days from a Monday; the chart does its own date arithmetic through its date adapter.
- What the number is.
onFirstPaintwatches the chart’s element for the first.gantt__barand then waits for one more animation frame. It measures this page, in this browser, with whatever else is running: use it to compare sizes, not as a benchmark. For the numbers of the library, run the repository’spnpm bench, or open the playground’s/bench/page (a generated plan of 1k or 10k tasks, scrolled and dragged automatically, with timings). - When a plan is too large to load at all, load it by date range: the infinite timeline’s
onRangeChangesays which range the chart now shows (Infinite timeline), andsetTaskstakes what you fetched.