# Zooming canvas

> One plane, many framings — a camera that moves instead of cutting

An `atlas` puts content on a plane and moves a camera between named framings. One
canvas, many views — a system diagram you tour, a route, a UI you walk through.

````markdown
```atlas
scene: ./architecture.svg
---
- id: whole
  at: 0, 0, 4000
- id: ingest
  at: 900, 400, 700
- id: storage
  at: 2600, 1200, 500
  hold: 2
```
````

Each waypoint is a navigation step, like a fragment: `→` flies to the next one, and
only advances past the last one to the next slide.

## The fence

The head is canvas-level options, the body is the waypoints, `---` separates them. A
fence with no `---` is all waypoints, which is the common case.

| Head option | What it is |
| --- | --- |
| `scene` | A backdrop image or SVG. Waypoint coordinates are its pixels. Required |
| `arc` | How far the camera pulls back on the way. Default √2 |
| `hold` | Seconds to rest at each waypoint. Only video and autoplay use it |
| `width`, `height` | The world grid. Default 1920×1080 |

| Waypoint option | What it is |
| --- | --- |
| `id` | Its name. Required — it is the routing fragment and the export filename |
| `at` | A framing, or a reference. Required |
| `duration` | Seconds for the flight *in*. Omit it and the camera works it out |
| `hold` | Seconds to rest here |
| `arc` | This waypoint's own `arc` |
| `label` | Human-readable, for a checklist or a progress rail |
| `chapter` | A chapter in a `.narrocast`. React only, see below |

`scene` is a **URL the deck serves**, exactly as `src` is for `<Image>` and ` ```replay `:
put the file in the deck's public directory and name it from there. A fence does not
resolve a path relative to the deck file, and it draws no content of its own — so an
`atlas` without a `scene` is a blank slide, and `narro check` refuses one.

## `at` is a rectangle, not a zoom level

```
at: 900, 400, 700
```

means *centre on (900, 400) and show 700 units of world across the frame*. The height
follows from whatever the deck is being rendered at.

That is deliberate, and it is the one thing to understand about this fence. A **scale**
is a ratio against a viewport, so `scale: 2` frames different content in a 16:9 deck
than in a 9:16 video, and re-flowing the scene silently changes what every waypoint
points at. A **width** is a fact about the world: it survives the render target, the
aspect ratio, and the deck being exported at a different size.

The other form is a **reference**, which the scene resolves:

```
at: "#ingest-box"
```

On a plane that is a node id. Over a recording it is a CSS selector, and that is the
form to prefer there — see [Zooming into a recorded app](#zooming-into-a-recorded-app).

## Durations are seconds, and you rarely want to set one

Omit `duration` and the camera computes how long the move wants to take from how far
the picture actually travels. A long flight across the plane asks for more time than a
small nudge, automatically, and the answer is right far more often than a number you
pick per waypoint.

When you do set one, it is **seconds**, like every duration in `@getnarro/core`.
`duration: 1000` is not a one-second flight — it is a sixteen-minute one, and
`narro check` says so by name.

## Why the camera does not make people ill

The complaint about every zooming presentation tool is the same: the swoop is
nauseating. That is not an inevitable property of moving a camera. It is a
consequence of how almost every tool interpolates.

Tween the centre and the zoom as two independent curves — which is what Prezi,
impress.js, Sozi and Narro's own older `TransformSlide` all do — and the *apparent*
speed is not what the numbers say. Apparent speed is world speed divided by the width
of the frame you are seeing it through, so a flight that travels a long way while
zooming a long way in starts at about one screen per second and finishes at fifty. The
picture accelerates by more than an order of magnitude while the numbers say it is
slowing down, the eye cannot track it, and that mismatch is the part that makes people
uncomfortable. No easing curve fixes it; easing only moves the peak around.

An `atlas` uses van Wijk and Nuij's interpolation instead, which takes the path that
keeps *perceived* speed constant for the whole flight. The lurch is gone by
construction rather than by tuning, and the characteristic "pull back, travel, drop in"
arc — the part people actually like — comes out of the geometry rather than being
authored. `arc` is how pronounced it is.

**Reduced motion is still honoured.** A viewer with `prefers-reduced-motion: reduce`
gets a cut between waypoints instead of a flight. Every waypoint stays reachable and
nothing is hidden; only the swoop goes.

## What `narro check` will tell you

Camera paths have failure modes that are invisible until somebody watches the deck, and
every one of them is decidable before that:

| It says | Because |
| --- | --- |
| `is a 200.0x zoom in one move` | Above about 8× in one flight, audiences lose their place |
| `Did you mean 1?` | A duration above 30 is almost always milliseconds in a seconds field |
| `reads as a lurch` | The duration is far below what that move needs |
| `will read as stalled` | The duration is far above it |
| `has no renderable framing` | The third number is missing or not positive |
| `matches nothing in the scene` | A reference that resolves to nothing |
| `is used more than once` | Two waypoints share an id |

Waypoints that name a reference are **not** geometry-checked at build time: their
rectangles do not exist until something renders the scene, and reporting a failure that
cannot be established would be worse than reporting nothing.

## Zooming into a recorded app

`narro record` captures a web app as **real DOM**, and a camera over one is the case this
package exists for. Zooming into a control magnifies live text and vector borders, so it
stays sharp — and stays selectable — at any zoom. A screenshot or a screen recording is a
grid of pixels and falls apart at 3×.

**This is a React API today, not a fence.** A scene has to be handed the document it
measures selectors against, and a markdown fence has no way to do that; writing
`provider: replay` in one is an error rather than a blank slide.

```tsx
import { Atlas } from "@getnarro/atlas/react";
import { measureIn, replayProvider } from "@getnarro/atlas/replay";

const provider = replayProvider({ cast, measure: measureIn(replayDocument) });

<Atlas
  provider={provider}
  path={{
    waypoints: [
      { id: "whole-app", at: { cx: 640, cy: 360, w: 1280 } },
      { id: "the-publish-button", at: "#publish", chapter: "publish" },
    ],
  }}
/>;
```

Name a **selector**, not coordinates. A cast's layout is fixed — it was computed once, at
the recording viewport, and the player scales rather than reflows — so a rectangle around
a DOM node is stable, while the coordinates it resolved to last week stop being right the
moment somebody re-records and the button moves. `findInCast(cast, "#publish")` answers
the same question with no browser, which is how a build catches a selector that matches
nothing.

## On a map

Web Mercator is a plane, so a map needs no second camera: project the world onto the unit
square and longitude, latitude and zoom become an ordinary framing. The same interpolation,
the same checker, the same timeline.

```tsx
import { Atlas } from "@getnarro/atlas/react";
import { mapProvider } from "@getnarro/atlas/map";

const provider = mapProvider({
  map,                                  // a MapLibre or Mapbox map
  places: {
    reykjavik: { lng: -21.94, lat: 64.15, zoom: 11 },
    iceland: { west: -24.6, south: 63.3, east: -13.5, north: 66.6 },
  },
});

<Atlas provider={provider} path={{ waypoints: [
  { id: "iceland", at: "iceland" },
  { id: "reykjavik", at: "reykjavik" },
] }} />;
```

`maplibre-gl` is an optional peer and nothing in this package imports it — the map arrives
as an object with `jumpTo`, `areTilesLoaded` and `isStyleLoaded`, so a deck with no map
pays nothing. Pair it with [PMTiles](https://docs.protomaps.com/pmtiles/maplibre) for a
basemap in a single file with no API key.

**It tells the map where to be rather than asking it to fly.** `flyTo` decides where the
camera is from the wall clock, which is exactly what a renderer advancing a frame counter
cannot use. A `jumpTo` per frame, from a camera that is a pure function of time, works in a
deck, a video and a page alike — and the frame is held until the tiles arrive, so a render
never encodes a video of empty squares.

For routes, `routeCamera` follows a traveller along a line and `routeTo` gives you the part
travelled so far, ending exactly where the traveller is:

```tsx
import { pointAlong, routeCamera, routeTo } from "@getnarro/atlas/map";
```

## Over a gigapixel image

A camera that flies close to a raster needs pixels the raster does not have. A tile pyramid
inverts that: the image is cut at every power-of-two scale in advance, and only the tiles
for the current level and region are fetched. A 40000×30000 scan costs the same handful of
tiles at every zoom.

```tsx
import { imageProvider } from "@getnarro/atlas/image";

const scene = imageProvider({
  pyramid: {
    width: 40000,
    height: 30000,
    tileSize: 254,
    overlap: 1,
    url: (level, col, row) => `/scan_files/${level}/${col}_${row}.jpg`,
  },
  regions: { signature: { x: 35000, y: 27000, width: 2000, height: 800 } },
});
```

The layout is Deep Zoom's, so anything `vips dzsave` or OpenSeadragon's tools already emit
works as-is. `scene.tiles(view, viewport)` is what to draw; tell it `noteLoaded(key)` and
`noteFailed(key)` as tiles settle, and its readiness gate covers exactly the tiles the
current framing needs — never the whole pyramid, which would never finish.

## On a web page

```html
<script type="module" src="/narro-atlas.js"></script>

<narro-atlas mode="scroll" world="1920x1080">
  <script type="application/json">
    { "waypoints": [
      { "id": "whole", "at": { "cx": 960, "cy": 540, "w": 1920 } },
      { "id": "pricing", "at": "#pricing" }
    ] }
  </script>

  <img src="/diagram.svg" alt="How it fits together">
</narro-atlas>
```

The camera follows the page's scroll, which is the third clock after a presenter's keypress
and a renderer's frame counter — and it costs almost nothing, because the camera was
already a pure function of a moment.

The page's own markup **is** the scene and the fallback: the element slots it rather than
copying it, so with JavaScript off a reader sees the whole diagram, laid out and indexed. A
waypoint can name any element in it — `at: "#pricing"` resolves the way a fragment link
would. `mode="steps"` swaps scrolling for clicks and arrow keys.

The embed is 8.5 KB, 3.6 KB gzipped, and brings no framework.

## In a video

The same path, on a frame counter:

```tsx
import { AtlasScene } from "@getnarro/atlas/video";

<AtlasScene path={path} nodes={nodes} hold={1.5} />;
```

The camera is a pure function of the frame — no `requestAnimationFrame` anywhere — so a
frame rendered on its own, out of order, or in another process comes out identical.
`atlasDurationInFrames(path, fps)` gives a composition its length from the path itself,
so a scene's duration follows its camera rather than a constant somebody has to keep in
step.

A scene that loads — a recording that has to reach a chapter — holds the frame until it
is ready, so a render never encodes the wrong screen.

## When it earns its keep

When the spatial relationship *is* the content: a system diagram you tour, a route, a
timeline that reads as one long line, a UI you walk through, a zoom from summary into
detail and back out.

**It is the wrong call for a normal talk.** Constant camera movement is tiring to watch
and disorienting on a large screen, and audiences lose the thread when they cannot tell
where they are. If the connection between two slides is logical rather than spatial, a
cut says it better than a swoop. Normal slides for most of the deck, one canvas for the
part that genuinely benefits.

## Cost

`@getnarro/atlas` reaches a deck only when it has an `atlas` fence — the generated deck
module imports the component on the strength of the compiler having seen one. A deck
without a canvas carries nothing.

## What it will not do

An export flattens a canvas to one still per waypoint; a fence reaches the plane and no
other scene; the camera follows the path and offers no free navigation; there is no
rotation; and Narro neither cuts a tile pyramid nor hosts a basemap for you.

Each of those is written out, with what to reach for instead, on
[Limitations](/docs/limitations#a-zooming-canvas-flattens-when-it-is-exported).

## Relation to `TransformSlide`

[`TransformSlide`](/docs/transform-mode) is the older, smaller version of this: a camera
over sub-slides, positioned by `x`/`y`/`scale`, interpolated as independent tweens. It
still works and is not going away.

Prefer an `atlas` for anything new. It is the one with the interpolation that does not
lurch, culling so a big scene costs what a small one does, level-of-detail, reduced-motion
handling, the checker, and a video host.