πΊοΈβZooming canvas
How do I zoom and pan across one big canvas instead of cutting between slides?
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.
```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.
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.
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.
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 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:
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.
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
<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:
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.
Relation to TransformSlide
TransformSlide 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.