he timeline as a graphics surface rather than a document structure. Below is a production-grade implementation pattern.
Step 1: Viewport & Coordinate Management
Canvas rendering requires explicit coordinate mapping. You must translate task data (start/end dates, durations) into pixel coordinates relative to the visible viewport. This calculation should be decoupled from rendering to allow efficient updates.
export interface TimelineBounds {
viewStart: number;
viewEnd: number;
pixelWidth: number;
rowHeight: number;
}
export class CoordinateMapper {
private bounds: TimelineBounds;
constructor(bounds: TimelineBounds) {
this.bounds = bounds;
}
updateViewport(newBounds: Partial<TimelineBounds>): void {
Object.assign(this.bounds, newBounds);
}
mapDateToPixel(dateMs: number): number {
const timeRange = this.bounds.viewEnd - this.bounds.viewStart;
const relativeTime = dateMs - this.bounds.viewStart;
return (relativeTime / timeRange) * this.bounds.pixelWidth;
}
mapPixelToDate(pixelX: number): number {
const timeRange = this.bounds.viewEnd - this.bounds.viewStart;
return this.bounds.viewStart + (pixelX / this.bounds.pixelWidth) * timeRange;
}
}
Step 2: Canvas Rendering Loop
Instead of relying on Angular's template rendering, you manage a requestAnimationFrame loop that clears and redraws only the visible task rectangles. This eliminates layout thrashing and reduces paint operations to a single compositor layer.
import { Component, ElementRef, ViewChild, AfterViewInit, OnDestroy, NgZone } from '@angular/core';
@Component({
selector: 'app-timeline-surface',
template: `<canvas #renderSurface class="timeline-canvas"></canvas>`,
standalone: true
})
export class TimelineSurfaceComponent implements AfterViewInit, OnDestroy {
@ViewChild('renderSurface', { static: true }) canvasEl!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private animationId: number = 0;
private isRendering = false;
private mapper: CoordinateMapper;
constructor(private ngZone: NgZone) {
this.mapper = new CoordinateMapper({
viewStart: Date.now(),
viewEnd: Date.now() + 86400000 * 30,
pixelWidth: 1200,
rowHeight: 40
});
}
ngAfterViewInit(): void {
this.setupCanvas();
this.startRenderLoop();
}
private setupCanvas(): void {
const native = this.canvasEl.nativeElement;
const dpr = window.devicePixelRatio || 1;
const rect = native.getBoundingClientRect();
native.width = rect.width * dpr;
native.height = rect.height * dpr;
native.style.width = `${rect.width}px`;
native.style.height = `${rect.height}px`;
this.ctx = native.getContext('2d')!;
this.ctx.scale(dpr, dpr);
}
private startRenderLoop(): void {
this.ngZone.runOutsideAngular(() => {
const loop = () => {
if (!this.isRendering) return;
this.drawFrame();
this.animationId = requestAnimationFrame(loop);
};
this.isRendering = true;
loop();
});
}
private drawFrame(): void {
const canvas = this.canvasEl.nativeElement;
const dpr = window.devicePixelRatio || 1;
this.ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
// Batch drawing operations to minimize state changes
this.ctx.fillStyle = '#3b82f6';
this.ctx.strokeStyle = '#1e40af';
this.ctx.lineWidth = 1;
// Render visible tasks only (implementation depends on data source)
const visibleTasks = this.getVisibleTasks();
for (const task of visibleTasks) {
const x = this.mapper.mapDateToPixel(task.startDate);
const y = task.rowIndex * this.mapper.bounds.rowHeight;
const w = this.mapper.mapDateToPixel(task.endDate) - x;
const h = this.mapper.bounds.rowHeight - 4;
this.ctx.fillRect(x, y + 2, w, h);
this.ctx.strokeRect(x, y + 2, w, h);
}
}
private getVisibleTasks(): Array<{ startDate: number; endDate: number; rowIndex: number }> {
// Placeholder: filter tasks based on mapper.viewStart/viewEnd
return [];
}
ngOnDestroy(): void {
this.isRendering = false;
cancelAnimationFrame(this.animationId);
}
}
Step 3: Interaction & Hit Detection
Canvas is a single DOM element. Clicks and drags require manual coordinate-to-data mapping. For performance, avoid iterating through all tasks on every pointer event. Implement spatial partitioning or bounding-box pre-filtering.
export class InteractionHandler {
private pointerState = { isDown: false, startX: 0, startY: 0 };
constructor(private mapper: CoordinateMapper) {}
handlePointerDown(x: number, y: number): void {
this.pointerState.isDown = true;
this.pointerState.startX = x;
this.pointerState.startY = y;
}
handlePointerMove(x: number, y: number): { taskIndex: number | null; dx: number; dy: number } {
if (!this.pointerState.isDown) return { taskIndex: null, dx: 0, dy: 0 };
const dx = x - this.pointerState.startX;
const dy = y - this.pointerState.startY;
const row = Math.floor(y / this.mapper.bounds.rowHeight);
return { taskIndex: row, dx, dy };
}
handlePointerUp(): void {
this.pointerState.isDown = false;
}
}
Architecture Decisions & Rationale
- Canvas over DOM: Eliminates layout recalculation and style resolution. Draw calls are batched and handled by the GPU compositor.
- Zone Isolation: High-frequency pointer events are routed outside Angular's change detection. Only final state commits re-enter the zone, preventing digest cycle storms.
- DPR Scaling: Canvas elements are scaled by
window.devicePixelRatio to prevent blurry rendering on Retina/HiDPI displays. CSS dimensions remain logical; internal dimensions are physical.
- Frame Budgeting: The render loop uses
requestAnimationFrame to sync with the browser's refresh rate. Heavy computations are deferred or chunked to maintain 60 FPS.
- Coordinate Decoupling: Date-to-pixel mapping is isolated in a dedicated service. This allows viewport panning/zooming without touching rendering logic.
Pitfall Guide
1. Ignoring Device Pixel Ratio (DPR)
Explanation: Canvas defaults to CSS pixel dimensions. On high-DPI screens, this results in blurry lines and text because the browser upscales a low-resolution bitmap.
Fix: Always multiply canvas width and height by window.devicePixelRatio, then scale the context with ctx.scale(dpr, dpr). Keep CSS dimensions logical.
2. Naive Hit Detection Loops
Explanation: Iterating through 10,000 tasks on every pointermove to find which bar was clicked creates O(n) CPU spikes, dropping frame rates.
Fix: Implement spatial indexing. Divide the canvas into a grid or use bounding-box pre-filtering. Only test tasks within the pointer's proximity. For linear timelines, row-based indexing reduces checks to O(1).
3. Zone.js Event Flood
Explanation: Attaching pointermove or scroll listeners inside Angular's zone triggers change detection on every pixel shift. This causes microtask queue saturation and UI freezing.
Fix: Wrap high-frequency listeners in ngZone.runOutsideAngular(). Commit state changes only on pointerup or scrollend by re-entering the zone or calling ChangeDetectorRef.markForCheck().
4. Memory Leaks from Event Listeners
Explanation: Canvas components often attach document-level listeners for drag operations. Forgetting to remove them during component destruction accumulates orphaned callbacks and references.
Fix: Use AbortController for listener management, or explicitly call removeEventListener in ngOnDestroy. Track active listeners in a Set for deterministic cleanup.
5. Canvas Text Rendering Assumptions
Explanation: ctx.fillText() does not support CSS features like text-overflow: ellipsis, word wrapping, or dynamic font loading. Long task names overflow or clip unpredictably.
Fix: Pre-calculate text metrics using ctx.measureText(). Implement custom clipping logic, or render labels in a lightweight DOM overlay positioned over the canvas. Use font-variant-numeric and explicit font stacks for consistency.
6. Synchronous Render Blocking
Explanation: Drawing 10,000 rectangles in a single frame blocks the main thread. Even with canvas, excessive draw calls can exceed the 16.6ms frame budget.
Fix: Implement chunked rendering. Use requestIdleCallback for non-critical updates, or split the draw loop across multiple frames. Prioritize visible viewport tasks and defer off-screen rendering.
7. Accessibility Neglect
Explanation: Screen readers and keyboard navigation cannot interpret canvas pixels. A pure canvas timeline is invisible to assistive technologies, violating WCAG standards.
Fix: Maintain a hidden ARIA grid or role="application" container that syncs with canvas state. Update aria-live regions on selection changes. Provide keyboard shortcuts for navigation and expose task metadata via tabindex and aria-label.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| < 1,000 tasks, static data | Standard DOM + OnPush | DOM overhead is negligible; development speed prioritized | Low (Native Angular) |
| 1,000–5,000 tasks, moderate interaction | Virtual Scroll + trackBy | Reduces active nodes; balances performance and maintainability | Medium (CDK Integration) |
| 5,000–15,000 tasks, real-time updates | Canvas Pipeline | Bypasses layout engine; sustains 60 FPS under load | High (Custom Rendering) |
| 15,000+ tasks, industrial scheduling | Specialized Framework | Pre-optimized hit detection, a11y, and WebGL acceleration | Medium-High (License/Config) |
| Cross-platform mobile scheduling | Canvas + Touch Optimization | DOM touch events are inconsistent; canvas provides deterministic input handling | High (Platform Tuning) |
Configuration Template
// timeline-config.ts
export interface TimelineConfig {
rowHeight: number;
headerHeight: number;
minZoomMs: number;
maxZoomMs: number;
snapIntervalMs: number;
enableDependencies: boolean;
enableTooltips: boolean;
}
export const DEFAULT_TIMELINE_CONFIG: TimelineConfig = {
rowHeight: 40,
headerHeight: 32,
minZoomMs: 86400000 * 7,
maxZoomMs: 86400000 * 365,
snapIntervalMs: 3600000,
enableDependencies: true,
enableTooltips: true
};
// timeline-module.ts
import { NgModule } from '@angular/core';
import { TimelineSurfaceComponent } from './timeline-surface.component';
import { TimelineControlsComponent } from './timeline-controls.component';
@NgModule({
imports: [TimelineSurfaceComponent, TimelineControlsComponent],
exports: [TimelineSurfaceComponent, TimelineControlsComponent]
})
export class TimelineModule {}
Quick Start Guide
- Initialize Canvas Surface: Create a standalone component with a
<canvas> element. Apply DPR scaling in ngAfterViewInit and set CSS dimensions to match the container.
- Bind Viewport State: Connect scroll/zoom events to a
CoordinateMapper instance. Update viewStart, viewEnd, and pixelWidth on interaction.
- Wire Render Loop: Start a
requestAnimationFrame cycle outside Angular's zone. Clear the context, iterate visible tasks, and draw rectangles using mapped coordinates.
- Attach Interaction Handler: Register
pointerdown, pointermove, and pointerup listeners. Calculate row/index hits using pixel-to-row division. Commit drag results to your data store on pointerup.
- Validate Performance: Open DevTools Performance tab. Record a 5-second drag operation. Confirm FPS stays above 50, memory remains under 200MB, and no Zone.js digest cycles trigger during pointer movement.