Grid layout transitions — v7 sketch (the map viewport is an implicit selector) (Firewatch Camera Grid)

**Note** from Bead: Firewatch Camera Grid · [canonical source](https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-24/notes/01-grid-layout-v7-sketch.md) · session 2026-06-24 · discussion: Talk: Firewatch Camera Grid

Sketched by Debbie (senior UX/UI). v7 builds **wholesale on the [v6 engine](../../2026-06-23/notes/07-grid-layout-v6-sketch.md)** — same one-scene / two-positions / `lerp(mapPos, gridPos, t)` mechanism, same `MAP ◀▶ GRID` scrubber, same straight-lerp + dim-ghost basemap + angle-preserving placement + crossings readout + NAIVE↔ANGLE A/B + per-card un-pin/re-flow, same zero element create/destroy during the morph. v7 **adds one thing on top, removes nothing**: the **map viewport (pan/zoom extent) becomes an implicit camera selector.** **Rendered sketch:** [https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-24/artifacts/sketches/grid-layout-transitions-v7.html](https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-24/artifacts/sketches/grid-layout-transitions-v7.html) *(build stamp `2026-06-24` + `v7` in the top bar — freshness marker, house convention.)*

## Stephen's directive (verbatim — load-bearing) > **"if no cameras are selected, use all the cameras in the view. the user will be > use the pan and zoom of map to select in that case."** Meaning: **the map viewport is an implicit selector.** With **no explicit pick**, the effective selection = **every camera whose position falls inside the current map view**. The user **pans and zooms the map** to choose which cameras flow into the grid. An explicit click-selection still works and **overrides** the viewport set; clearing the explicit selection **falls back** to "all in view." This is the directive — v7 **shows it working**, it does not pose it.

## What v7 adds (two things, nothing removed) ### 1. Real pan/zoom on the map v6's map was a static SVG `viewBox`. v7 maintains a **view transform** in **world** space — `view = {cx, cy, scale}` = the world point shown at viewBox center + a zoom. The world is now **bigger than the viewBox** (~1100×760 world, 11 firewatch cameras spread wide) so at default zoom **several cameras sit off-screen**. Controls: - **drag-to-pan** (grab cursor on the map), - **wheel-to-zoom** (anchored to the cursor — the world point under the pointer stays put), - **+ / − / ⟲ reset** buttons (bottom-right), plus a live `zoom 0.92×` readout. `project(worldX, worldY)` now routes **world → viewBox through the view transform**, then through the existing 3D-oblique projection. Because **everything downstream calls `project()`** — the camera dots, `mapRect`, the lerp's map endpoint, the crossings segments, the path overlay, the tethers — **the whole scene tracks pan/zoom for free.** ### 2. Viewport-implicit selection (`effectiveSelection`) `effectiveCams()` is the single source of truth that drives the grid: - if the explicit `selected` set is **non-empty** → use it (v6 behavior; **explicit overrides**); - else → **all cameras whose dot is inside the current viewport** (the visible map rect under the current pan/zoom). The **grid, the angle-preserving placement, the crossings readout, the lerp** — all read `effectiveCams()`, never the raw `selected` set. `selCams()` (the v6 placement input) now returns `effectiveCams()`, so the headline machinery flows through unchanged. As you pan/zoom in the implicit case, the in-view set updates and the grid/placement recomputes **live**.

## How continuity holds under the transform The v6 single-scene invariant is intact: **one persistent DOM node per camera**, the morph writes only `left/top/width/height/opacity`, **zero create/destroy**. Each camera's lerp **mapPos endpoint = `mapDotPx(c)` = `project(c.x, c.y)` under the current pan/zoom** — so the card always travels **from where the camera visually sits on the panned/zoomed map**, not from a stale coordinate. Cameras that **leave the viewport** (implicit case) drop out of the grid; their node is **hidden (`display:none`), never destroyed** — no thrash, the scene stays whole. Note: **bearing-from-scene is a world property** (`azFromScene` uses world coords), so it's **stable under pan/zoom** — which is exactly why angle-placement stays meaningful as you move the view.

## Implicit vs explicit, made visible - A **header pill**: `auto: 8 in view` (cyan, implicit) vs `selected: 3` (amber, explicit) vs `no cameras in view — pan/zoom` (empty). Reads at a glance which mode is driving. - A camera **in view but not explicitly chosen** reads as an **auto card** — dashed cyan border + a small `auto · in view` tag — so "included because it's in view" is legible against a solid explicit pick.

## Kept from v6 (not regressed) Straight-lerp (RATIFIED); basemap **dim-ghost underneath** floored at `GHOST_FLOOR` (RATIFIED); **angle-preserving placement** (grid-center bearing ≈ scene-center bearing) + the live **`crossings: N`** readout + faint per-camera **map→grid path segments** + **NAIVE↔ANGLE A/B** demo; **tidy-grid** packing (v6 first-cut, carried); **OrientedMap glyphs** + selected-camera **tie-point red-ray**; **3D oblique** at t≈0; preserved **TIME cursor**; **always-on identity color**; **per-card un-pin + re-flow**; **tether** quiet-toggle **default OFF**; panel + fullscreen.

## The remaining fork (the only thing posed) — recompute timing + inclusion test Viewport-as-selector is the directive (shown, not posed). The open choice is **when** the in-view set recomputes and **how** a camera counts as in-view. The fork is **answerable in-sketch** (pick → it changes live behavior → send to the `request/` dock): - **A — live · center-in-view (FIRST-CUT).** Re-derive the in-view set continuously as you pan/zoom (lightly debounced); a camera is in-view when its **dot center** is inside the visible map rect. Most direct, most responsive — you literally pan to pick. - **B — on-settle · include-with-margin.** Recompute only when panning **stops** (debounced ~250ms); count a camera in-view if its dot is inside the viewport **plus a margin** so edge cameras don't flicker. Calmer, fewer reflows. - **C — snapshot-on-enter-grid.** **Freeze** the in-view set the moment you cross MAP→GRID; panning mid-morph doesn't change membership. Most stable mid-morph, least live. **First-cut (a fork, not a verdict): A — live · center-in-view, lightly debounced.** It makes the directive legible (the count updates as you pan; center-in-view is the simplest mental model). B is calmer for shaky panning; C is safest mid-morph but hides the live feel. The answer card PUTs the pick to the `request/` dock as a `sketch-answer` (`sketch:'grid-layout-v7'`), carrying `resolved` forward (the v6 decisions — `lerp-path:'straight'`, `basemap:'dim-ghost-underneath'`, `placement:'angle-preserving'`, `grid-packing:'tidy-grid'`) **plus** `empty-selection:'use-cameras-in-view'`.

## The transition the ▶ tour plays Start **zoomed out** → with no explicit pick the grid auto-uses **all 11 cameras in view** (header `auto: 11 in view`) → **pan + zoom in** so some cameras fall off-screen and the **in-view count drops live** → **scrub MAP▶GRID** (the in-view cameras **fan to angle-matched slots** over the dim-ghost map, crossings → 0) → brush a few (identity color persists) → scrub home → **click to pin an explicit selection** (header flips to `selected: 3`, **overrides** the viewport) → scrub to grid → scrub home → **clear the pick** → falls back to **auto: all in view**. *One scene, two positions, straight lerp — now the viewport picks the cast.*

## Status First-cut. Directive **implemented and shown** (real pan/zoom; viewport drives the grid when nothing is picked; explicit pick overrides; clearing falls back to in-view; continuity endpoints track the transform). `node --check` passes on the extracted script; the morph path creates/destroys **no** elements (verified: `render()` writes only node styles). Remaining fork = recompute timing + inclusion test (live·center-in-view first-cut). Awaiting Stephen's fork choice via the `request/` dock.