**Artifact** from Bead: Incident Cataloging · [canonical source](https://redfish.acequia.io/guerin/.agents/c38c1239-bfd3-44dd-8d97-1a0aa39ac8da/2026-06-08/artifacts/santafe-live-catalog-manager.md) · session 2026-06-08 · discussion: Talk: Incident Cataloging
**Subject:** https://santafe.live (local mirror: `sites/santafe.live/`) **Snapshot:** 2026-06-08 (synced down this session) **Role in the family:** the **authoring / CRUD half** of the event-catalog system. Where the MISB viewer and the `alertLive` viewer *read* a catalog and render it, santafe.live is where a human *builds* one — by dragging images onto a map to set position and onto a timeline to set date. Companions: - [santafe-alert-live-events-structure.md](https://redfish.acequia.io/guerin/.agents/c38c1239-bfd3-44dd-8d97-1a0aa39ac8da/2026-06-08/artifacts/santafe-alert-live-events-structure.md) — the published `alertLive` event; santafe.live writes the **same `imageMeta` + `imagePose` schema** that event stores. - [misb-viewer-data-formats.md](https://redfish.acequia.io/guerin/.agents/c38c1239-bfd3-44dd-8d97-1a0aa39ac8da/2026-06-08/artifacts/misb-viewer-data-formats.md) — establishes "Pose is the common currency" and the read-side catalog (`incidents.json`). santafe.live is the matching write side.
## 1. What it is A browser app (vanilla ES modules, Leaflet map + a hand-rolled `<canvas>` timeline, no framework) that manages a **catalog of images** and lets you assign each image a **place** and a **time** by direct manipulation. Every edit is persisted immediately as a **WebDAV `PUT`** straight from the browser to the nephele server (ambient token auth). There is no backend app logic: the filesystem *is* the database, and HTTP verbs are the API. Three-panel workspace: - **Thumbnail grid** (center) — every catalogued image as a card. - **Leaflet map** (right column, collapsible) — geographic placement. - **Canvas timeline** (bottom, resizable) — temporal placement, year precision. Version: live "Image Cache Manager" (per the site's own `CLAUDE.md`). Map default view is Santa Fe (`[35.687, -105.938] z12`).
## 2. The on-disk data model (what it authors) Catalog lives in three sibling collections, joined by a content-addressed id: ``` santafe.live/ ├── thumbnails/<id>.jpg 201-px-ish JPEG preview (the only binary stored locally) ├── imageMeta/<id>.json identity + time + notes └── imagePose/<id>.json placement (lat/lng now; full 6-DOF pose schema reserved) ``` **`<id>` = first 16 hex chars of `SHA-256(sourceImageUrl)`.** Content-addressed: the same source URL always maps to the same catalog entry (idempotent ingest, natural dedupe). The id is the join key across all three collections — identical pattern to the `alertLive` event, where `imageMeta/<id>` and `imagePoses/<id>` share an id. ### `imageMeta/<id>.json` ```json { "url": "https://guerin.acequia.io/SantaFeHistory/collection001/14681013_..._o.jpg", "id": "0313b5b14c00a1ec", "imageId-equivalent": "(id)", "displayName": "14681013_..._o.jpg", "contentType": "image/jpeg", "lastUpdate": 1776868380818, "calendarDate": "1920", // ← set by dragging onto the TIMELINE ("YYYY" or "YYYY-MM-DD") "timeUTC": null, "notes": "" } ``` ### `imagePose/<id>.json` ```json { "id": "0313b5b14c00a1ec", "imageId": "0313b5b14c00a1ec", "Latitude": 35.67577485622499, // ← set by dragging onto the MAP "Longitude": -105.92985402947608,// ← "Elevation": null, "heading": null, "pitch": null, "roll": null, "fov": null, "fovy": null, "far": null, // reserved — never set by this UI "lastupdate": 1776868374726 } ``` > **Schema match + drift vs the `alertLive` event** (companion artifact): the `imagePose` object is **field-for-field the same Pose schema** as `alertLive`'s `imagePoses/` record (`Latitude, Longitude, Elevation, heading, pitch, roll, fov, fovy, far`). Differences worth carrying into the catalog spec: > - Directory name drift: **`imagePose`** (singular, here) vs **`imagePoses`** (plural, in the `alertLive` event). > - Casing drift: meta uses **`lastUpdate`** (capital U); pose uses **`lastupdate`**. Same field, two spellings, in sibling files. > - santafe.live only ever fills **Latitude/Longitude** (2-DOF placement). The other seven pose fields exist but stay `null` — full 6-DOF pose is what the 3D viewers consume, not what this 2D manager produces. Current local catalog: **59 images**; **11 placed** on the map, **7 dated** on the timeline. (Most are ingested-but-unplaced — exactly the backlog this tool exists to work through.)
## 3. The authoring gestures (the heart of it) ### 3a. Ingest — "Link cache" (`+` button → popover) Paste a single image URL **or** a directory URL. `index.js`: 1. If it's a directory, crawl it for images. Two crawl modes: - **generic WebDAV** — recursive `PROPFIND Depth:1` (`getDirectoryImageUrls`), following collections, collecting `.jpg/.png/.gif/.webp`. - **"hostgo"** — reads a pre-baked tree `assets/guerin.acequia.io-propfind.json` and walks it (`getDirectoryImageUrlsHostgo`), because HostGo's doc-root isn't WebDAV-crawlable the same way. 2. For each image (`cacheImage`): generate a thumbnail (`generateThumbnail`), `PUT thumbnails/<id>.jpg`, and **`PUT` stub `imageMeta/<id>.json` + `imagePose/<id>.json`** with all placement fields `null`. So ingest creates the catalog entry; placement and dating are separate, deferred gestures. ### 3b. Set LOCATION — drag a card onto the map - `createCard` makes each grid card `draggable`; `dragstart` records `draggedIndex` and the card's color. - The map element listens for `dragover` (shows a live drop-dot under the cursor) and `drop`: ```js const { lat, lng } = map.containerPointToLatLng(point) // pixel → geo item.lat = lat; item.lng = lng createMarker(...) // or move existing await savePose(item, lat, lng) // GET pose, set Latitude/Longitude, PUT back ``` - Markers are themselves `draggable`; `dragend` re-runs `savePose`. So placement is correctable by dragging the dot. - `savePose` is read-modify-write: `GET imagePose/<id>.json` → set `Latitude/Longitude/lastupdate` → `PUT`. Other pose fields preserved untouched. ### 3c. Set TIME — drag a card onto the timeline - The timeline `<canvas>` (`timeline.js`) listens for `dragover`/`drop`. On drop: ```js const year = tlXToYear(e.clientX - rect.left, canvas.clientWidth) // pixel → year onDrop(year) // → saveCalendarDate(item, year): GET meta, set calendarDate="YYYY", PUT ``` - Existing dots are also draggable along the axis (mouse `mousedown`→`mousemove`→`mouseup`) to **re-date** an already-placed image; mouseup writes the new year the same way. - Timeline is **year-precision**, zoom/pan by scroll wheel (`tlViewStart`/`tlViewEnd`, default 1850–2030), dots packed into 8 lanes to avoid overlap. ### 3d. Other edits - **Notes**: `notesField` blur → `GET/PUT imageMeta` `notes`. - **Indicators**: each card shows a 📍 pin if placed and a 🕐 clock if dated (`buildIndicators`) — the at-a-glance "is this entry complete" signal.
## 4. Place ∩ Time = the view filter `renderGrid()` is the payoff of authoring: it sorts cards into **in-view** vs **other** by intersecting the **current map bounds** with the **current timeline window**: ``` dateOk = no date OR (year within [tlView.start, tlView.end]) locationOk = no location OR (map bounds contains [lat, lng]) in-view = dateOk AND locationOk // shown first, sorted by year ``` Pan/zoom the map or the timeline and the grid re-filters live (`map.on('moveend')`, timeline `onViewChange`). The catalog becomes a spatially- and temporally-browsable corpus — which is the whole point of assigning place + time.
## 5. Persistence model — paths as the API There is **no application server**. Every mutation is an HTTP verb against a path: | Action | Verb + path | |---|---| | ingest image | `PUT thumbnails/<id>.jpg`, `PUT imageMeta/<id>.json`, `PUT imagePose/<id>.json` | | place on map | `GET`+`PUT imagePose/<id>.json` (Latitude/Longitude) | | date on timeline | `GET`+`PUT imageMeta/<id>.json` (calendarDate) | | edit notes | `GET`+`PUT imageMeta/<id>.json` (notes) | | list catalog | `PROPFIND Depth:1 thumbnails/` then `GET` each meta+pose | | dedupe | content-addressed id ⇒ `HEAD thumbnails/<id>.jpg` decides skip | This is a concrete instance of the workspace's "paths as event bus / agent-as-file" direction: the catalog is edited by PUTting to resource URIs, and a viewer reading the same URIs sees the result. Authoring tool and viewer are decoupled through the namespace, not through a shared process.
## 6. Why this is the keystone for a view/controller-agnostic catalog The MISB artifact argued the catalog *could* be separated from the renderer. santafe.live **proves it empirically**: the exact same `imageMeta` + `imagePose` JSON is - **written** here by a 2D Leaflet+timeline manager (sets lat/lng + year), and - **read** by the 3D `alertLive` viewer (consumes full pose + scenePresets), with no shared code — only a shared on-disk schema. That schema *is* the catalog format, today, in embryo. What santafe.live adds to the format requirements gathered so far: 1. **Pose is partial and progressive.** An entry can have an id and media but no place and no time; then gain a 2-DOF location; then a date; later (in another app) a full 6-DOF pose. The catalog must treat **every spatial/temporal attribute as optional and independently editable**, with completeness indicators rather than required fields. 2. **Time has multiple granularities.** Here: a bare **year** (`calendarDate: "YYYY"`). In MISB: a **continuous UTC stream** (µs samples). In `alertLive`: effectively **untimed** (only `lastupdate`). The catalog's time field must admit *year / instant / range / stream*, not a single representation. (Note `timeUTC` sits unused in the meta — a reserved second time channel.) 3. **Content-addressed identity.** `id = SHA-256(sourceUrl)[:16]` gives idempotent ingest and cross-collection joins for free. A good default identity rule for the unified catalog. 4. **Media-by-reference, thumbnail-by-value.** Full images stay at their origin (`guerin.acequia.io/SantaFeHistory/...`); only a hashed thumbnail is localized. Same media-by-URL pattern as both other artifacts; santafe.live just adds a derived-preview cache. 5. **Edit surface = HTTP verbs on paths.** The "controller" (drag-to-map, drag-to-timeline) is fully separable from the "model" (the JSON at a URI). A different controller (a 3D placement tool, an agent doing bulk geocoding) could write the same files. This is the controller-agnostic claim, demonstrated. ### Crosswalk across all three documented surfaces | | santafe.live (manager) | `alertLive` event (viewer-read) | MISB FMV (viewer-read) | |---|---|---|---| | role | author place + time | display posed photos | display posed video | | catalog unit | image entry (`<id>`) | event → image records | incident → aircraft leaf | | identity | `SHA-256(url)[:16]` | opaque 24-char slug | manifest `id` string | | pose written/read | **Lat/Lng only** (2-DOF) | full `imagePoses` (6-DOF) | per-sample 6-DOF stream | | pose dir name | `imagePose` | `imagePoses` | (in telemetry samples) | | time | year (`calendarDate`) | none (`lastupdate` only) | UTC µs stream | | media | thumb local + src by URL | JPEG by URL | MP4 by URL | | persistence | browser `PUT` per edit | static files | static files | | controller | drag-to-map / drag-to-timeline | camera presets / orbit | timeline scrub / follow | The three already converge on: **`{ id, displayName, media-by-URL, optional Pose, optional Time }`**, differing only in which fields are populated and at what granularity. The unified catalog draft (next artifact) can take santafe.live's `imageMeta`+`imagePose` pair as the canonical starting schema and generalize the **Time** field (year/instant/range/stream) and the **Pose** field (2-DOF → 6-DOF → time-varying).
## Appendix — santafe.live file map | File | Role | |---|---| | `index.html` | three-panel layout, add-popover, lightbox | | `index.js` | catalog state, ingest/crawl, map drag-drop → pose PUT, grid filter, selection | | `timeline.js` | canvas timeline: draw, zoom/pan, dot-drag re-date, card-drop → date | | `generateThumbnail.js` | source image → small JPEG blob | | `styles.css` | layout + dots/indicators | | `assets/guerin.acequia.io-propfind.json` | pre-baked dir tree for "hostgo" ingest mode | | `imageMeta/`, `imagePose/`, `thumbnails/` | the catalog (59 entries) | | `.acequia-access.json` | access descriptor | | `guerin/` | (separate subtree, 12 files) |