Planning screens are where Angular apps go to die: a row per vehicle, person or machine, a bar per booking, and suddenly the page holds tens of thousands of elements. I build this kind of screen at work, so I wrote ngx-lanes, an open-source resource timeline that keeps only what you can see in the DOM.
With 1,000 resources and 10,000 bars, the DOM holds about 22 rows and 50 to 130 bars at any moment. Here is how.
1. Index bars per resource, sorted by start
The first step happens once per data change, inside a computed(): group the bars by resource and sort them by start time. While grouping, keep the longest duration in each resource.
2. Find visible bars with a binary search
A bar is visible if it overlaps the window [from, to). Sorted by start, the bars that can overlap begin at from - maxDuration at the earliest, so a binary search jumps there and a short loop stops at the first bar that starts after to:
const first = lowerBound(items, from - maxDuration); for (let i = first; i < items.length && items[i].start < to; i++) { if (items[i].end > from) result.push(items[i]); } No bar outside the window is ever touched, so a resource with 10,000 bars costs about the same as one with 10.
3. Virtualize rows and time
Rows are absolutely positioned at index * rowHeight inside a canvas as tall as all rows together, so the scrollbar is real. From scrollTop and the viewport height I compute the visible rows plus four rows of margin. The same idea works horizontally: only bars within 300 px of the viewport are rendered, and only the time ticks you can see.
Everything is a chain of signals: scrollTop, scrollLeft and the viewport size feed computed() values for the visible window, the ticks and the rows. The template loops over the result with @for and track, so Angular reuses the elements that stay on screen.
4. Keep the same instant centred when zooming
Switching from month to day zoom makes the canvas 36 times wider. If you set scrollLeft straight away, the browser clamps it to the old width and you land somewhere else. The fix is to wait for the render that resizes the canvas:
effect(() => { const scale = this.scale(); const anchor = untracked(() => this.anchorTime); if (anchor === undefined) return; afterNextRender( { write: () => this.setScrollLeft(scale.toX(anchor) - this.viewport().width / 2, false) }, { injector: this.injector }, ); }); One more trap: a scroll you set from code fires its scroll event later, asynchronously. If that event updates the anchor, a clamped value overwrites the instant you wanted to keep, so programmatic scrolls are flagged and their event is ignored.
Try it
- Demo with 1,000 resources and 10,000 bars: https://dmartinezzm.github.io/ngx-lanes/
-
npm install ngx-lanes(Angular 19+, zoneless or zone.js, MIT) - Code: https://github.com/dmartinezzm/ngx-lanes
I'm working on a Pro version with drag and drop, conflict detection and live updates from SignalR. If your team builds planning screens, the waitlist is on the demo site, and I'd like to hear what you plan with it.

