@clinth/vcanvas
v0.0.3
Published
A virtualized canvas web component for efficiently rendering large scrollable content using a tiled approach with automatic viewport culling and tile recycling.
Readme
x-vcanvas
A virtualized canvas web component for efficiently rendering large scrollable content using a tiled approach with automatic viewport culling and tile recycling.
Overview
The virtual canvas divides content into tiles that are only rendered when visible in the viewport. As the user scrolls, tiles are repositioned and recycled rather than destroyed and recreated, providing smooth performance even with very large content areas.
Virtualization can be applied to either axis independently, or both simultaneously:
- Horizontal only — set
logicalWidth(original behavior) - Vertical only — set
logicalHeight - 2D (both axes) — set both
logicalWidthandlogicalHeight
When an axis is left at 0 (default), the component uses the actual viewport dimension for that axis, meaning no virtualization or scrolling occurs on that axis.
Installation
npm install @clinth/vcanvasUsage
<x-vcanvas logicalWidth="10000"></x-vcanvas>import '@clinth/vcanvas';
const vcanvas = document.querySelector('x-vcanvas');
vcanvas.logicalWidth = 10000;
vcanvas.drawCallback = (ctx, region, signal) => {
ctx.fillStyle = '#3498db';
ctx.fillRect(region.x, region.y, region.width, region.height);
};2D virtualization
vcanvas.logicalWidth = 10000;
vcanvas.logicalHeight = 5000;
vcanvas.drawCallback = (ctx, region, signal) => {
// region.x, region.y, region.width, region.height are all in logical coordinates
ctx.fillStyle = '#3498db';
ctx.fillRect(region.x, region.y, region.width, region.height);
};Vertical-only virtualization
// logicalWidth defaults to 0, so the component uses the actual viewport width
vcanvas.logicalHeight = 5000;
vcanvas.drawCallback = (ctx, region, signal) => {
ctx.fillRect(region.x, region.y, region.width, region.height);
};API Reference
Properties
logicalWidth: number
The total logical width of the virtual canvas content. When set to 0 (default), the component uses the actual viewport width — no horizontal virtualization or scrollbar.
vcanvas.logicalWidth = 10000;logicalHeight: number
The total logical height of the virtual canvas content. When set to 0 (default), the component uses the actual viewport height — no vertical virtualization or scrollbar.
vcanvas.logicalHeight = 5000;logicalScrollLeft: number
The current horizontal logical scroll position (getter/setter). Values are automatically clamped to [0, logicalWidth - viewportWidth].
vcanvas.logicalScrollLeft = 5000;
console.log(vcanvas.logicalScrollLeft);logicalScrollTop: number
The current vertical logical scroll position (getter/setter). Values are automatically clamped to [0, logicalHeight - viewportHeight].
vcanvas.logicalScrollTop = 2000;
console.log(vcanvas.logicalScrollTop);containerScrollLeft: number (readonly)
The native scroll container's horizontal scroll position in pixels.
containerScrollTop: number (readonly)
The native scroll container's vertical scroll position in pixels.
viewportWidth: number (readonly)
The current viewport width in pixels. Automatically updated when the component is resized.
viewportHeight: number (readonly)
The current viewport height in pixels. Automatically updated when the component is resized.
drawCallback: DrawCallback | null
Callback invoked when a tile needs to be drawn. The region is always in logical coordinates, covering the full extent of the tile on both axes.
vcanvas.drawCallback = (ctx, region, signal) => {
ctx.fillStyle = 'blue';
ctx.fillRect(region.x, region.y, region.width, region.height);
};hinting: boolean
Enables horizontal scroll-edge fade animations. Default: false.
debugTrace: boolean
Enables detailed console logging of tile operations. Default: false.
debugDraw: boolean
Enables visual debug overlay on tiles. Default: false.
Methods
setRenderingOptions(options: Partial<VCanvasRenderingOptions>): void
Updates rendering options. Any options not provided retain their current values.
vcanvas.setRenderingOptions({
tileWidthMultiplier: 0.25,
tileHeightMultiplier: 0.25,
maxPoolSize: 12,
});getRenderingOptions(): VCanvasRenderingOptions
Returns the current rendering options configuration.
invalidateAll(): void
Forces a complete redraw of all visible tiles.
theme.addEventListener('change', () => {
vcanvas.invalidateAll();
});invalidateRegion(x: number, y: number, width: number, height: number): void
Forces redraw of tiles intersecting the specified 2D region. More efficient than invalidateAll() for localized updates.
vcanvas.invalidateRegion(1000, 500, 200, 200);isRegionVisible(rect: Rect): boolean
Checks if any part of a rectangular region is currently visible in the viewport. Checks both axes.
const region = { x: 1000, y: 500, width: 200, height: 200 };
if (vcanvas.isRegionVisible(region)) {
// Region is at least partially visible
}convertFromViewportToCanvas(x: number, y: number): Point
Converts viewport-relative coordinates to logical coordinates. Accounts for scroll position and single-tile centering on both axes.
vcanvas.addEventListener('click', (e) => {
const logical = vcanvas.convertFromViewportToCanvas(e.offsetX, e.offsetY);
console.log('Clicked at logical:', logical.x, logical.y);
});convertFromCanvasToViewport(x: number, y: number): Point
Converts logical coordinates to viewport-relative coordinates.
const viewportPos = vcanvas.convertFromCanvasToViewport(markerX, markerY);
tooltip.style.left = `${viewportPos.x}px`;
tooltip.style.top = `${viewportPos.y}px`;positionChildRange(query: string, startX: number, endX: number, startY?: number, endY?: number): void
Positions light DOM children matching the selector at the specified logical coordinate range. The children scroll with the canvas content. startY/endY are optional and default to 0 / effectiveLogicalHeight.
// Horizontal positioning only (full height)
vcanvas.positionChildRange('#marker', 200, 400);
// Full 2D positioning
vcanvas.positionChildRange('#overlay', 1000, 1200, 500, 700);positionChildClear(query: string): void
Removes children from the positioning system and clears their inline styles.
manualChildPositioningUpdate(): void
Forces an immediate re-layout of all positioned children.
Events
scroll-virtual
Dispatched whenever the scroll position changes on either axis.
vcanvas.addEventListener('scroll-virtual', (event) => {
const { logicalScrollLeft, logicalScrollTop } = event.detail;
console.log(`Scroll: ${logicalScrollLeft}, ${logicalScrollTop}`);
});Event detail properties:
| Property | Description |
|---|---|
| logicalScrollLeft | Current horizontal scroll position |
| logicalScrollTop | Current vertical scroll position |
| containerScrollLeft | Native container horizontal scroll |
| containerScrollTop | Native container vertical scroll |
| viewportStartLogical | Logical x of the viewport's left edge |
| viewportEndLogical | Logical x of the viewport's right edge |
| viewportStartLogicalY | Logical y of the viewport's top edge |
| viewportEndLogicalY | Logical y of the viewport's bottom edge |
| userGesture | Whether the scroll was user-initiated |
scrollend-virtual
Dispatched when scrolling has ended (debounced).
Event detail properties:
| Property | Description |
|---|---|
| logicalScrollLeft | Final horizontal scroll position |
| logicalScrollTop | Final vertical scroll position |
| containerScrollLeft | Native container horizontal scroll |
| containerScrollTop | Native container vertical scroll |
| userGesture | Whether the scroll was user-initiated |
Types
VCanvasRenderingOptions
interface VCanvasRenderingOptions {
/** Tile width as multiplier of viewport width (default: 0.5) */
tileWidthMultiplier: number;
/** Tile height as multiplier of viewport height (default: 0.5) */
tileHeightMultiplier: number;
/** Extra tiles to keep in pool beyond visible (default: 2) */
tilesBuffer: number;
/** Overlap between adjacent tiles as a fraction (default: 0.01) */
overlapPercent: number;
/** Maximum tiles in pool (default: 3; multiplied by row count in 2D mode) */
maxPoolSize: number;
/** Debounce delay for scroll operations in ms (default: 100) */
scrollDebounceMs: number;
}Rect
type Rect = {
x: number; // X-coordinate in logical units
y: number; // Y-coordinate in logical units
width: number; // Width in logical units
height: number; // Height in logical units
};DrawCallback
type DrawCallback = (
ctx: CanvasRenderingContext2D,
region: Rect, // Logical extent of this tile (may include overlap)
abortSignal: AbortSignal
) => void;Draw Callback
The canvas context is pre-translated so that all drawing coordinates are in logical space. Both region.x/region.y and region.width/region.height reflect the 2D extent of the tile being drawn.
vcanvas.drawCallback = (ctx, region, signal) => {
// Draw using logical coordinates — works identically for 1D or 2D tiles
ctx.fillStyle = 'blue';
ctx.fillRect(region.x, region.y, region.width, region.height);
if (signal.aborted) return; // tile scrolled out of view
// Draw content clipped to this tile's region
for (const item of myItems) {
if (item.x + item.width < region.x || item.x > region.x + region.width) continue;
if (item.y + item.height < region.y || item.y > region.y + region.height) continue;
// ... draw item
}
};Coordinate System
- Logical coordinates —
(0, 0)is the top-left corner of the full virtual content area. All drawing andinvalidateRegion/isRegionVisiblecalls use this system. - Viewport coordinates —
(0, 0)is the top-left of the currently visible area. Used for pointer events (e.offsetX,e.offsetY).
Use convertFromViewportToCanvas / convertFromCanvasToViewport to move between the two.
Tile Pool and Memory
In 2D mode the pool size is effectively maxPoolSize × number_of_rows, because each column of tiles is replicated across all visible rows. Tune maxPoolSize, tileWidthMultiplier, and tileHeightMultiplier together to control memory usage.
// 2D example: smaller tiles, larger pool for smooth diagonal scrolling
vcanvas.setRenderingOptions({
tileWidthMultiplier: 0.25,
tileHeightMultiplier: 0.25,
maxPoolSize: 6,
});Slotted Children
<x-vcanvas logicalWidth="10000" logicalHeight="5000">
<div id="overlay">Content</div>
</x-vcanvas>// Position at logical rect x:200–400, y:100–300
vcanvas.positionChildRange('#overlay', 200, 400, 100, 300);Children maintain their logical position as the canvas is scrolled in either direction.
CSS Custom Properties
| Property | Default | Description |
|----------|---------|-------------|
| --scrollbar-width | auto | Scrollbar thickness (auto, thin, none) |
| --scrollbar-color | auto | Scrollbar colors (<thumb-color> <track-color>) |
x-vcanvas {
--scrollbar-width: thin;
--scrollbar-color: #6b7280 #e5e7eb;
}Utility Functions
import {
viewportToCanvas,
canvasToViewport,
logicalToScreen,
screenToLogical,
logicalToScreenRect,
screenToLogicalRect,
clamp,
} from '@clinth/vcanvas';Performance Tips
Tune
tileWidthMultiplier/tileHeightMultiplier— smaller values create more, smaller tiles which improves scroll smoothness but increases tile count and draw-callback frequency.Tune
maxPoolSize— increase for smoother fast-scrolling at the cost of memory. In 2D mode the effective pool ismaxPoolSize × rows.Use
invalidateRegion()— for partial updates, only redraw the affected area instead of callinginvalidateAll().Check visibility — use
isRegionVisible()to skip work for off-screen content within a draw callback.Abort expensive operations — use the
AbortSignalto cancel long draws when the tile scrolls out of view.
License
MIT
