npm package: @lancercomet/zoom-pan
Description: Yet another web 2D rendering lib.
Latest version: 0.4.0

# ZoomPan


ZoomPan is a **2D rendering viewport / render-pipeline library**.

It is **not** a scene-graph engine. If you are looking for a full display tree, rich shape primitives, filters, batching, and a large ecosystem, you probably want **PixiJS** / **Fabric.js** / **Konva**.

It provides a consistent world/screen coordinate model, pan/zoom interactions, a pluggable render pipeline (RenderPipeline/Pass), a layer system, and optional rendering backends (Canvas2D/WebGL).

Use it as a simple “image viewer with pan & zoom”, or as the rendering core of a “drawing app / annotation tool / editor”.

## ZoomPan vs Pixi / Fabric / Konva

| Topic | ZoomPan | Pixi / Fabric / Konva |
| --- | --- | --- |
| Primary goal | Viewport + render orchestration for editor-style apps | General-purpose 2D rendering engine / scene graph |
| Best for | Zoom/pan canvas viewers, drawing apps, editors, annotation tools | Complex object trees, shape primitives, filters, rich rendering features |
| Extension model | Explicit render pipeline passes + plugins | Scene graph + framework lifecycle + ecosystem plugins |
| World/screen split | Built-in phases: `world` and `screen` | Often requires custom conventions / layers / containers |
| What it does **not** try to be | A full engine with display tree, batching, filters | A small viewport kernel |

## Features

- **Selectable renderer backend**: `renderer: 'canvas2d' | 'webgl' | 'auto' | IRenderer`
- **Pluggable render pipeline**: 4 phases `beforeWorld / world / afterWorld / screen` + stable ordering via `order`
- **Viewport transform**: pan/zoom + world ↔ screen coordinate conversion
- **Interaction plugin**: mouse/touch/pen input + inertia + `cancel()` for focus-loss cleanup
- **Layer system**: split world/screen rendering (you can insert passes between them)
- **Bounds (document bounds)**: background/shadow/clip/border, pan clamping, `zoomToFit({ maxScale })`
- **History (Undo/Redo)**: provided via plugin/examples (see `examples/`)

## Install

```bash
npm install @lancercomet/zoom-pan
```

## Quick Start

### Image Viewer

```ts
import { ViewManager, ContentLayerManager, createInteractionPlugin } from '@lancercomet/zoom-pan'

const content = new ContentLayerManager()

const view = new ViewManager(canvas, {
  renderer: 'auto' // 'canvas2d' | 'webgl' | 'auto'
})

view.registerLayerManager(content)
view.use(createInteractionPlugin())

await content.createImageLayer({
  src: 'image.png',
  x: 0,
  y: 0
})
```

### Drawing App (simplified)

```ts
import {
  ViewManager,
  ContentLayerManager,
  CanvasLayer,
  HistoryManager,
  createInteractionPlugin,
  createBoundsPlugin,
  createSnapshotCommand
} from '@lancercomet/zoom-pan'

const content = new ContentLayerManager()
const history = new HistoryManager({ maxHistorySize: 50 })

const view = new ViewManager(canvas, { renderer: 'auto' })
view.registerLayerManager(content)

// Interaction plugin controls pan/zoom.
// - Touch gestures (pan/pinch) always work
// - Mouse/pen pan is controlled by setPanEnabled()
const interaction = view.use(createInteractionPlugin())

// Bounds defines document rect/margins and clamps pan.
const bounds = view.use(createBoundsPlugin({
  rect: { x: 0, y: 0, width: 1200, height: 800 },
  margins: { left: 50, right: 50, top: 50, bottom: 50 }
}))

// Prevent upscaling small documents
bounds.zoomToFit({ maxScale: 1 })

const drawLayer = new CanvasLayer({ width: 1200, height: 800 })
content.addLayer(drawLayer)

// Drawing mode: disable mouse/pen pan (touch still works)
interaction.setPanEnabled(false)

// On focus loss, cancel any ongoing drag/pinch/inertia
window.addEventListener('blur', () => interaction.cancel())

let snapshotBefore: ImageData | null = null

canvas.onpointerdown = (e) => {
  if (e.pointerType === 'touch') return
  const { wx, wy } = view.toWorld(e.offsetX, e.offsetY)
  snapshotBefore = drawLayer.captureSnapshot()
  drawLayer.beginStroke(wx, wy)
}

canvas.onpointermove = (e) => {
  if (e.pointerType === 'touch') return
  if (e.buttons !== 1) return
  const { wx, wy } = view.toWorld(e.offsetX, e.offsetY)
  drawLayer.stroke(wx, wy, '#000', 10, e.pressure, 'brush')
  view.requestRender()
}

canvas.onpointerup = () => {
  drawLayer.endStroke()
  const snapshotAfter = drawLayer.captureSnapshot()
  const cmd = createSnapshotCommand(drawLayer, snapshotBefore, snapshotAfter)
  if (cmd) history.addCommand(cmd)
}
```

## Core Concepts

### ViewManager

The main controller: render loop, coordinate transforms, renderer backend, render pipeline, and plugins.

```ts
const view = new ViewManager(canvas, {
  minZoom: 0.2,
  maxZoom: 10,
  background: '#fff',
  renderer: 'auto'
})

const { wx, wy } = view.toWorld(screenX, screenY)
const { x, y } = view.toScreen(worldX, worldY)

view.zoomToAtScreen(anchorX, anchorY, 2.0)
view.zoomByFactorAtScreen(anchorX, anchorY, 1.5)
```

### Renderer Selection

```ts
import { ViewManager } from '@lancercomet/zoom-pan'

new ViewManager(canvas, { renderer: 'canvas2d' })
new ViewManager(canvas, { renderer: 'webgl' })
new ViewManager(canvas, { renderer: 'auto' }) // tries WebGL first, falls back to Canvas2D
```

### RenderPipeline / Pass

You can insert your own passes (e.g. draw a debug grid before world rendering):

```ts
view.addRenderPass({
  name: 'debug.grid',
  phase: 'beforeWorld',
  order: -50,
  render: ({ renderer }) => {
    const ctx = renderer.getContentContext()
    ctx.save()
    ctx.strokeStyle = 'rgba(0,0,0,0.08)'
    ctx.lineWidth = 1
    // draw grid / guides here
    ctx.restore()
  }
})
```

### InteractionPlugin

```ts
import { createInteractionPlugin } from '@lancercomet/zoom-pan'

const interaction = view.use(createInteractionPlugin())

interaction.setPanEnabled(true)  // mouse/pen can pan
interaction.setPanEnabled(false) // mouse/pen cannot pan (drawing mode)

interaction.setZoomEnabled(true) // wheel zoom + pinch zoom

interaction.cancel() // cancel drag/pinch/inertia (blur/visibilitychange)
```

### BoundsPlugin

BoundsPlugin draws background/shadow, clips the world, and draws border via pipeline passes.
It also supports per-instance pass ordering (useful when you need to insert your own passes around the bounds clip).

```ts
import { createBoundsPlugin } from '@lancercomet/zoom-pan'

const bounds = view.use(createBoundsPlugin({
  rect: { x: 0, y: 0, width: 1200, height: 800 },
  margins: { left: 50, right: 50, top: 50, bottom: 50 },
  drawBorder: true,
  background: '#f0f0f0',
  shadow: { blur: 20, color: 'rgba(0,0,0,0.3)', offsetX: 0, offsetY: 5 },
  passOrder: {
    beforeWorld: -100,
    afterWorld: 100
  }
}))

bounds.zoomToFit({ maxScale: 1 })
bounds.setPanClampMode('minVisible') // 'margin' | 'minVisible'
```

### Layers

```ts
import { ViewManager, ContentLayerManager, TopScreenLayerManager, CanvasLayer } from '@lancercomet/zoom-pan'

const view = new ViewManager(canvas, { renderer: 'auto' })

// World-space content
const content = new ContentLayerManager()
view.registerLayerManager(content)

// Screen-space overlay (UI / cursor / HUD)
const overlay = new TopScreenLayerManager()
view.registerLayerManager(overlay)

content.addLayer(new CanvasLayer({ width: 1200, height: 800 }))
```

Default rendering is split by pipeline phase:

- `world`: calls each LayerManager's `renderWorldLayersIn(view)`
- `screen`: calls each LayerManager's `renderScreenLayersIn(view)`

This means you can insert passes between world and screen (e.g. selection overlay after world, UI in screen).

## Plugin Lifecycle

`ViewManager.use(plugin)` is transactional: if `install()` throws, it performs a best-effort rollback to avoid leaving a half-installed plugin behind.

```ts
view.hasPlugin('bounds')
view.listPlugins()
view.clearPlugins()

view.unuse('bounds')
```

## Examples

See `examples/`:

- `examples/viewer/`: image viewer
- `examples/painter/`: drawing app (brush/eraser/layers/undo-redo)
- `examples/bounds/`: bounds (background/shadow/zoomToFit)
- `examples/history/`: history (undo/redo)