Build: map frustums, UV→heading projection ray, scrubbable inspector, panorama (2026-06-21) (Time Line Web Component)

**Note** from Bead: Time Line Web Component · [canonical source](https://redfish.acequia.io/guerin/.agents/ade9cea6-f50e-4922-8266-4bdd14ed9c73/2026-06-21/notes/04-frustums-projection-inspector-panorama-build.md) · session 2026-06-21 · discussion: Talk: Time Line Web Component

Stephen (while away ~1h): on the camera-grid app — change the map markers to **time-based directional frustums** (ref: AlertCalifornia OPS screenshots); add a **red line** that is the **heading of the mouse's UV** in the inspected image, mapped through the camera projection (hFOV, **assuming level mount**; full φ–θ panosphere calibration later); when you **inspect an image**, bring it up **over the two bottom panels** while the **timeline stays scrubbable**; **pull panoramas** into their **own scrubbable timeline lane** + show them when an image is selected; and bring in **uxui-senior-designer** to design the **image annotation → UV vector (point/polyline/polygon/raster) → URI for a STAC** flow. "Make sure to use .git." Built in **this bead's** `repo/camera-grid/` (the working copy with the SW frame-cache + process-latest scrub from note 02), committed `fb5d95f`. **Not deployed** — deploy to firewatch `repo/` + `apps/camera-grid/` stays gated on Stephen.

## Where this sits relative to the asks - The freshest camera-grid code is here (not firewatch `65783732/repo/`, which is `ad6a2d1`, no SW). Most-recent chat that edited the app on the **service-worker / caching** axis: [`ade9cea6…/2026-06-19/chats/2026-06-19-timeline-web-component.md`](../chats/2026-06-19-timeline-web-component.md) (the SW work itself ran 2026-06-20; analysis in [note 02](02-camera-grid-timeline-update-plan.md)).

## What shipped ### 1. Time-based directional frustums (the `syncMapAz()` stub, cashed in) Each camera now draws an **az ± fov/2 wedge** (`L.polygon`, geographic vertices via a great-circle `destPoint`, so it scales correctly with zoom) out to `WEDGE_M = 6 km`, plus a camera dot. `syncMapAz()` redraws every wedge from `nearestFrame(cam, playhead)` — so the cones **sweep as you scrub**. A frame-less camera shows a faint dashed wedge at `view_azimuth`. Hover (grid tile **or** map glyph) highlights a camera (`cam-hover`, brighter wedge). ### 2. Red projection ray — mouse UV → true heading `uvToAzEl(frame, u, v, aspect)`: rectilinear, **level-mount** model — the frame spans `hFOV = camViewWidth` horizontally centred on `camAzimuth`, `vFOV = hFOV/aspect` vertically centred on `camElevation`; `u∈[0,1]` left→right, `v∈[0,1]` top→bottom. Mousing the inspected image fills the **X (°T) / Y (°)** readout and draws a **red `L.polyline`** from the camera point along that bearing (`RAY_M = 22 km`) on the live map. **No roll/cant/distortion** — the documented next step is the full **φ–θ panosphere** solve (mount cant via up-vector + `k1,k2`), per [astronomical-pano `9943ac1c` note 01](https://redfish.acequia.io/guerin/.agents/9943ac1c-caa1-479d-b17e-89ef7ca326b6/2026-06-20/notes/01-treehouse-lunar-eclipse-calibration.md) (treehouse Axis Q6075-E 5-param celestial calibrator). This `uvToAzEl` is the level-mount special case of that model; the az ruler over the image == its horizontal axis made visible. ### 3. Scrubbable inspector Clicking a tile/glyph opens an **inspect stage** absolutely positioned over the **camera-wall panel** (not the timeline) while the **map stays live** (frustums + red ray). The **timeline is untouched and stays scrubbable**; `updateWall()` calls `refreshInspect()` each pump, so scrubbing swaps the inspected frame, the X/Y ruler range, the pano, and the ray in place. Az ruler across the top, X/Y readout follows the mouse, `Esc`/✕ closes. (Replaces the old full-screen lightbox, which covered the timeline.) ### 4. Panorama — its own scrubbable strip A `#pano-bar` opens under the workspace (workspace shrinks to ~60%). **Pano URL = the frame filename with `_p` before the extension** (confirmed against Stephen's example `…/1901/2026/06/21/College_Peak_1_1782070838_7497_p.jpg`). `panoCandidates()` derives the **simtable mirror** path first, then the **alertwest CDN** (`img.cdn.prod.alertwest.com/data/img/<camId>/<Y>/<M>/<D>/…_p.jpg`); `showPano()` HEAD-probes (memoised) and shows the first that 200s, else a graceful "no panorama for this frame (archive); live cams only". A ±90° az ruler marks the frame bearing on the pano (exact pano extent needs pano metadata — refine later). **Finding:** the Sandy archive frames are mirrored but the panos are **not** (404 on both mirror and CDN for 2026-05-18) — panoramas exist only for **live** cams (College Peak today). So to *see* the pano lane populate, the dataset needs a live-cam manifest entry (College Peak, camId 1901) — the obvious quick follow-up. > **"Separately in the timeline":** panos are 1:1 with frames (same timestamps), so today they > ride the existing per-camera timeline lanes and surface in the pano strip on scrub. A dedicated > pano density lane in `drawTimeline()` is cheap to add once a cam actually has panos to mark. ### 5. Annotation → UV vector → STAC (scaffold; design by uxui-senior-designer) The **uxui-senior-designer** role was brought in sketch-led (per feedback_ux-sketch-to-foreground-questions); four sketches + 6 open questions for Stephen in [note 03](03-image-annotation-uv-vector-stac-sketches.md). Built its **smallest slice**: an SVG overlay on the inspected frame with **point / polyline / polygon** tools (raster **paint** stubbed → next slice), each vertex stored as `{u,v}` **plus** the projected `(az,el)` **plus** the frame pose. **`{ } STAC`** exports a **GeoJSON FeatureCollection** (geometry in `(az,el)` image-space degrees; `uv` kept in properties) as a **`data:application/geo+json` URI** and copies a ready **STAC Item asset** snippet (`roles:["annotation"]`) to the clipboard. World reprojection (image→ground footprint) is deferred to the panosphere calibration — matches the designer's "author in image space = source of truth" bet.

## Verification (CDP, dedicated Chrome :9333 per the firewatch convention) Headless `--headless=new`, driven over CDP. 10 cameras load (≈500–670 frames each); extent 2026-05-18 07:23Z → 2026-05-19 20:51Z. After seeking mid-extent: **20 frustum paths + 10 dots** on the map; opening the inspector → **stage `flex`, pano-bar `block`**, X/Y readout live (`X 117.57°T / Y 0.28°`), 12 az-ticks, **1 red projection ray** confirmed on the Leaflet SVG; 2 annotation points draw; pano correctly reports archive-has-none. **No page errors.** The only network aborts are benign cancelled image swaps during scrub (`ERR_ABORTED`), none app-origin. Screenshots: `…/Temp/cg-wall.png`, `cg-inspect.png` (transient, not committed).

## Open / gated on Stephen - **Deploy decision** (this bead → firewatch `repo/` + `apps/camera-grid/` via `deploy.sh`), still bundled with the note-02 SW + process-latest changes. - **Live-cam manifest** (College Peak etc.) so the panorama lane populates; needs the AlertWest live telemetry/positions (a small acquisition step). - The 6 annotation design questions in note 03 (image-vs-world, instant-vs-interval, tool affordance, asset granularity + raster URI form, sidecar location, authorship). - **φ–θ panosphere calibration** to replace the level-mount `uvToAzEl` (cant + distortion); this is the firewatch ↔ astronomical-pano (`9943ac1c`) / image-pose-uxui (`74c30681`) seam.

## References (bead cross-links) - Bead: Astronomical Pano · [canonical](https://redfish.acequia.io/guerin/.agents/9943ac1c-caa1-479d-b17e-89ef7ca326b6/)