Grid layout transitions — v6 sketch (angle-matched grid, crossings minimized) (Firewatch Camera Grid)

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

Sketched by Debbie (senior UX/UI). v6 builds **wholesale on the [v5 lerp engine](./06-grid-layout-v5-sketch.md)** — same one-scene/two-positions/ `lerp(mapPos, gridPos, t)` mechanism, same `MAP ◀▶ GRID` scrubber, same zero element create/destroy during the morph. Stephen **ratified two v5 questions** and **named one new design problem**; v6 implements all three. Still in the sketch-ux house style (rendered HTML/SVG, "✎ SKETCH" mark, a replay-able transition, answer card → `request/` dock). **Rendered sketch:** https://redfish.acequia.io/guerin/.agents/65783732-7907-4a36-983a-3b015e75e80b/2026-06-23/artifacts/sketches/grid-layout-transitions-v6.html *(build stamp `2026-06-23` + `v6` in the top bar — freshness marker, house convention.)*

## Stephen's answer + correction (verbatim — load-bearing) > Chosen lerp path: **`straight-lerp`** — RATIFIED. > > **"keep the base map underneath. the relative grid position should be as close > as possible to the relative camera position. eg the angle from center of grid > should be close to the angle the camera has to the center of the map. Right now > you have unnecessary camera path crossings. or maybe if you think of it as each > camera having an edge for start and destination you should minimize crossing."** Two ratifications + one new problem: 1. **Straight-line lerp — RATIFIED.** Kept the literal straight interpolation of the two rects, all cameras in lockstep on the *same* global `ease(t)` — no arc, no azimuth stagger (v5 fork B is gone). `render()` does `lerp(mr, gr, lt)` with one `lt` for every camera. 2. **Basemap stays — RATIFIED as a DIM GHOST underneath.** The v5 fork C is gone; the basemap no longer fully fades at `t=1`. Its opacity is **floored at `GHOST_FLOOR = 0.20`** so the range rings, scene marker and 180° axis stay **faintly legible beneath the cards**. This is not decoration — it is *why* angle-preservation matters: with the map showing through, you can see each card sitting roughly over its real-world bearing. 3. **THE NEW PROBLEM — angle-preserving / crossing-minimizing placement.** v5 filled the grid **row-major in azimuth order**, which scatters bearings across rows so the straight-lerp paths **cross unnecessarily**. v6 fixes it.

## The headline — angle-preserving placement Stephen gave the criterion exactly: **`bearing(slotCenter, gridCenter) ≈ bearing(cameraMapPos, sceneCenter)`**. A camera in the NW of the map lands in the NW of the grid, so the straight paths **fan out without tangling**. Equivalently (his second framing): treat each camera as a **start-edge** (its map dot) → **destination-edge** (its grid slot) and **minimize total path crossings**. v6 implements this as `computePlacement()` → `slotOfId` (id → slot). Two angle-preserving modes (the remaining fork) plus a NAIVE mode kept only for the A/B demonstration: - **tidy-grid (FORK A, first-cut).** Pack a rectangle of cells, then **assign cameras to cells by matching sorted bearings**: sort cells by `θ_cell` (bearing from grid center), sort cameras by `θ_cam` (bearing from scene center), and assign `camera[i] → cell[(i+k)%n]` under the **circular rotation `k` that minimizes summed wrap-distance** `Σ|θ_cam − θ_cell|`. Angle-monotonic assignment ⇒ crossings minimized, **while keeping a packed, usable monitor-wall grid**. - **radial-clock (FORK B).** Seat each card on a **ring at its (approx) exact map bearing** around the grid center (`ringRect()` uses the literal `azFromScene`). Angle is literal; packing is looser; the center can be empty. ### Verified, not just asserted A standalone Node harness re-ran the same geometry across **all selection subsets of size 3–5**: the angle-matched assignment **never regresses** vs naive, the full 5-camera case drops **2 → 0 crossings**, and the best single improvement is **4 fewer crossings**. The fix demonstrably works; it is not a hand-wave. ### Made legible in the sketch - A live **`crossings: N`** readout sits in the morph bar (green at 0, amber otherwise), counting straight-segment intersections among the map→grid paths at the current assignment. - **Faint, per-camera-colored map→grid path segments** render during the morph (peaking at mid-`t`) so Stephen can *see* the paths fan rather than tangle. - A **NAIVE ↔ ANGLE-matched A/B toggle** flips the assignment in place and updates the crossing count — flip it and the count drops. The ▶ tour deliberately plays NAIVE first (paths cross, count > 0), then flips to ANGLE-matched (paths fan, count → 0).

## How it subsumes the 180° rule The v5 180° line-of-action ordering is the **1-D slice** of this. The line of action is a single axis; ordering cards along it is ordering by a *signed scalar* projection of bearing. v6's 2D angular correspondence is the **full-2D generalization**: it orders by the *whole* bearing, not its projection onto one axis. So the 180° rule is not dropped — it is **contained** as the degenerate case, and the axis note in the sketch now says so. Per-card **un-pin + re-flow** still apply, just **over angle-matched cells**: a pinned drag remembers its slot (`manualSlot`), and the rest re-flow back into angle order around it.

## Kept from v5 (not regressed) - The **v5 lerp engine intact**: one scene of N persistent camera nodes, a single `render()` doing `pos = lerp(mapRect, gridRect, t)`, the `MAP ◀▶ GRID` scrubber, the segmented `GRID | 3D | Map` toggle that only **animates `t`** (no pane swap), **zero** element creation/destruction during the morph. (`node --check` passes on the extracted script; the morph path writes only `style.left/top/width/height/opacity` on pre-built nodes.) - **OrientedMap glyph vocabulary** + the selected camera's **tie-point red-ray read-through**; **3D oblique** basemap at `t≈0`; preserved **selection** + amber **TIME cursor** across all `t`; **always-on per-camera identity color**; the **tether** demoted to a quiet optional toggle, **default OFF**; fullscreen ⇄ panel. - House style + the `request/`-dock `sendAnswer()` machinery (retargeted `sketch:'grid-layout-v6'`, carrying `resolved` forward incl. `lerp-path:'straight'` and `basemap:'dim-ghost-underneath'`, plus the new `placement:'angle-preserving'`).

## The remaining fork (the only thing still open) Placement is angle-preserving either way — only the **packing** is open: - **A — tidy-grid** (angle-matched cells, packed rectangle). - **B — radial-clock** (exact bearing on a ring, looser packing, possible empty center). **First-cut (a fork, not a verdict): A (tidy-grid)** — it keeps the grid **usable** (packed, monitor-wall-like) while killing crossings via angle-monotonic assignment; **B** is the purest reading of "the angle from center of grid = the angle to center of map" but spends screen on an empty center and packs looser. Flip the A/B demo to see crossings drop under *both*. The answer card PUTs the pick to the `request/` dock as a `sketch-answer`.

## The transition the ▶ tour plays select on the **3D** map → (NAIVE first) **scrub `t`:0→1** and watch the paths **cross** → flip to **ANGLE-matched** → **scrub `t`:0→1** again and the cameras **fan to angle-matched slots with no crossings** over the **dim-ghost basemap** → **brush** to show identity persists → **drag a card** → the rest **re-flow** on angle-matched cells → fullscreen → panel (co-present at mid-`t`) → **scrub `t`:1→0** (every camera flies home to its own map dot). *One scene, two positions, straight lerp — now angle-matched.*

## Status First-cut (straight-lerp + basemap-dim-ghost **ratified** and shown; angle-preserving placement implemented, crossings-readout + A/B demo wired and **verified** to reduce crossings; tidy-grid vs radial-clock fork open). `node --check` passes on the extracted script; the morph path creates/destroys **no** elements. Awaiting Stephen's packing-fork choice via the `request/` dock.