From 13b7424c8374b8f0fa737d46b34f7d2e60f8c542 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:54:11 +1000 Subject: [PATCH] M13: anchor manager & friend tags (lane F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsible #anchor-panel (toggle ⚓ #btn-anchors): every anchor gets a color dot (recolor via inline color input), label (rename via prompt), one-click jump-to (scene3d.focusOn), and delete — PATCH/DELETE /api/anchors/{id}, then state.anchors update + emit("anchors-changed") so scene3d + overlays refresh. + Tag drives the FROZEN M8 annotator without an event (annotate.js is null-event safe by construction; the backend keeps event-less annotations as independent anchors): prompt a name -> startAnnotateMode -> user drags a bbox on any video -> the resolved anchor is PATCHed to the name + a palette color. Esc / + Tag again cancels. All mutating controls carry .write-ui (capsule mode keeps the read-only list + jump). CSS injected from the module — index.html is frozen. Live-verified: create->rename->recolor->jump->delete roundtrip, labels in 3D + both video overlays, reload survival, capsule hiding (plan/status/lane-F.md). Co-Authored-By: Claude Fable 5 --- frontend/src/anchorPanel.js | 339 ++++++++++++++++++++++++++++++++++-- 1 file changed, 326 insertions(+), 13 deletions(-) diff --git a/frontend/src/anchorPanel.js b/frontend/src/anchorPanel.js index 84db2ed..1b8cb7b 100644 --- a/frontend/src/anchorPanel.js +++ b/frontend/src/anchorPanel.js @@ -1,19 +1,332 @@ -// Anchor manager & friend tags (spec M13, lane F). STUB — lane F fills THIS FILE ONLY. +// Anchor manager & friend tags (spec M13, lane F). // -// Pre-wired by foundation2: the hidden ⚓ toggle (#btn-anchors) and the empty #anchor-panel -// container in index.html, this module's import + init() call in main.js, the hooks -// `scene3d.focusOn(x, y, z)` (jump-to) and `PATCH ${state.apiBase}/api/anchors/{id}` -// {label?, color?} (rename/recolor), plus DELETE /api/anchors/{id} from phase 4. +// A collapsible panel (pre-wired container #anchor-panel, toggle #btn-anchors) listing every +// anchor in state.anchors: color dot (recolor), label (rename), jump-to (scene3d.focusOn), +// delete. Mutations go through PATCH/DELETE ${state.apiBase}/api/anchors/{id}; after any +// mutation we update state.anchors and emit("anchors-changed") so scene3d + the video +// overlays refresh (main.js wires that event to scene3d.refreshAnchors; overlays read +// state.anchors every frame). // -// When implementing: unhide the button, set `ready = true`; build the collapsible list -// (color dot, label, jump-to, rename, recolor, delete) from state.anchors; after any -// mutation update state.anchors and emit("anchors-changed") so scene3d + overlays refresh. -// The + Tag flow reuses the M8 bbox-annotation flow WITHOUT an event (annotate.js is -// frozen — drive it, don't edit it) and names the resulting anchor. -// Capsule mode: give every MUTATING control the `write-ui` class — body.capsule hides that -// class globally (contract #5); the read-only list + jump-to stay available. +// + Tag (friend tags): drives the FROZEN M8 annotation flow WITHOUT an event. +// annotate.js was written null-event safe on purpose: startAnnotateMode() only needs the +// registered cells, and _submit POSTs /api/annotations with event_id: null, which the backend +// resolves to an INDEPENDENT anchor ("Annotations with no event stay independent"). +// Flow: prompt for the friend's name -> annotator.close() (clears any currentEvent) -> +// annotator.startAnnotateMode() -> the user drags a bbox over the friend on any video -> +// the resolved anchor arrives via emit("anchors-changed", anchor) -> we PATCH the pending +// name + a palette color onto it and stop annotate mode. Esc (or + Tag again) cancels. +// +// Capsule mode (contract #5): every MUTATING control carries class="write-ui" so a baked +// bundle hides it; the read-only list + jump-to stay available. + +import { state, emit, on } from "./state.js"; +import { scene3d } from "./scene3d.js"; +import { annotator } from "./annotate.js"; + +const DEFAULT_COLOR = "#59d499"; +// Friendly tag palette (mirrors the scene's per-video accent hues). +const TAG_COLORS = ["#ff5d73", "#4dd0ff", "#c9a2ff", "#ffcf5d", "#7dffb0", "#ff9d5d"]; + +const CSS = ` + #anchor-panel { + position: absolute; top: 3.1rem; left: 0.6rem; width: 244px; z-index: 7; + background: rgba(15,20,32,0.94); border: 1px solid var(--border); border-radius: 9px; + font-size: 0.78rem; box-shadow: 0 8px 26px rgba(0,0,0,0.5); backdrop-filter: blur(5px); + max-height: min(60vh, 420px); display: flex; flex-direction: column; + } + .anch-head { + display: flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.55rem; + border-bottom: 1px solid var(--border); flex: 0 0 auto; + } + .anch-head strong { flex: 1 1 auto; font-size: 0.8rem; } + .anch-n { color: var(--dim); font-weight: 400; } + .anch-x { + background: none; border: none; color: var(--dim); cursor: pointer; + font-size: 0.85rem; padding: 0 0.2rem; + } + .anch-x:hover { color: var(--text); } + .anch-hint { padding: 0.3rem 0.55rem 0; line-height: 1.35; min-height: 0; flex: 0 0 auto; } + .anch-hint:empty { display: none; } + .anch-list { + padding: 0.4rem 0.45rem 0.5rem; overflow-y: auto; display: flex; + flex-direction: column; gap: 0.12rem; flex: 1 1 auto; + } + .anch-empty { color: var(--dim); padding: 0.1rem 0.15rem 0.2rem; line-height: 1.4; } + .anch-row { + display: flex; align-items: center; gap: 0.4rem; padding: 0.22rem 0.3rem; + border-radius: 6px; + } + .anch-row:hover { background: rgba(255,255,255,0.05); } + .anch-dot { + width: 12px; height: 12px; border-radius: 4px; flex: 0 0 auto; position: relative; + box-shadow: 0 0 0 1px rgba(0,0,0,0.4); + } + .anch-color { + position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; + cursor: pointer; padding: 0; border: none; + } + .anch-label { + flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; + } + .anch-label:hover { color: var(--accent); } + .anch-btn { + background: none; border: none; color: var(--dim); cursor: pointer; + font-size: 0.78rem; padding: 0 0.15rem; flex: 0 0 auto; line-height: 1; + } + .anch-btn:hover { color: var(--text); } + .anch-del:hover { color: var(--bad); } +`; + +function esc(s) { + return String(s ?? "").replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) + ); +} + +async function patchAnchor(id, body) { + const resp = await fetch(`${state.apiBase}/api/anchors/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); +} + +/** Replace (or append) an anchor in state.anchors and notify every consumer. */ +function upsertIntoState(anchor) { + const i = state.anchors.findIndex((a) => a.id === anchor.id); + if (i >= 0) state.anchors[i] = anchor; + else state.anchors.push(anchor); + emit("anchors-changed", anchor); +} export const anchorPanel = { ready: false, - init() {}, // ponytail: stub — lane F replaces the body + open: false, + _panel: null, + _btn: null, + _pendingTag: null, // { name, color, knownIds:Set } while a friend tag is in flight + + init() { + this._btn = document.getElementById("btn-anchors"); + this._panel = document.getElementById("anchor-panel"); + if (!this._btn || !this._panel) return; // pre-wired DOM missing — stay hidden, degrade + + const style = document.createElement("style"); + style.textContent = CSS; + document.head.appendChild(style); + + this._panel.innerHTML = ` +
+ ⚓ Anchors + + +
+
+
`; + + this._panel.querySelector(".anch-x").addEventListener("click", () => this.toggle(false)); + this._panel.querySelector(".anch-tag").addEventListener("click", () => { + if (this._pendingTag) this._cancelTag(); + else this._startTag(); + }); + this._btn.addEventListener("click", () => this.toggle()); + + // Any anchor mutation anywhere (this panel, annotate.js, a pending friend tag resolving). + on("anchors-changed", (payload) => this._onAnchorsChanged(payload)); + + // Esc cancels a pending tag (main.js also maps Esc to free-roam; both are fine together). + window.addEventListener("keydown", (e) => { + if (e.code === "Escape" && this._pendingTag) this._cancelTag(); + }); + + this._render(); + this._btn.style.display = ""; // unhide: the module is live + this.ready = true; + }, + + toggle(force) { + if (!this._panel) return; + this.open = force != null ? !!force : !this.open; + this._panel.style.display = this.open ? "" : "none"; + this._btn?.classList.toggle("active", this.open); + if (this.open) this._render(); + }, + + // --- friend tag flow ------------------------------------------------------------------ + + _startTag() { + let name; + try { + name = window.prompt("Friend's name:", ""); + } catch { + name = null; // prompt() unavailable (headless) — nothing to do + } + name = (name || "").trim(); + if (!name) return; + const color = TAG_COLORS[state.anchors.length % TAG_COLORS.length]; + this._pendingTag = { name, color, knownIds: new Set(state.anchors.map((a) => a.id)) }; + try { + annotator.close(); // clear any currentEvent so the annotation POSTs event_id: null + annotator.startAnnotateMode(); + } catch (err) { + console.warn("[anchorPanel] could not start annotate mode", err); + this._pendingTag = null; + this._hint(`tag mode failed: ${esc(err.message)}`); + return; + } + this._tagButton(true); + this._hint(`drag a box over ${esc(name)} on any video · Esc cancels`); + this.toggle(true); + }, + + _cancelTag() { + this._pendingTag = null; + try { + annotator.stopAnnotateMode(); + } catch { + /* already stopped */ + } + this._tagButton(false); + this._hint(""); + }, + + _tagButton(active) { + const b = this._panel?.querySelector(".anch-tag"); + if (!b) return; + b.textContent = active ? "✕ Cancel" : "+ Tag"; + b.classList.toggle("active", active); + }, + + _onAnchorsChanged(payload) { + const tag = this._pendingTag; + if (tag && payload && payload.id != null && !tag.knownIds.has(payload.id)) { + // The bbox the user just drew resolved into a brand-new anchor: name + color it. + this._pendingTag = null; + this._finishTag(tag, payload); + } + this._render(); + }, + + async _finishTag(tag, anchor) { + try { + annotator.stopAnnotateMode(); + } catch { + /* fine */ + } + this._tagButton(false); + try { + const updated = await patchAnchor(anchor.id, { label: tag.name, color: tag.color }); + upsertIntoState(updated); + this._hint(`tagged ${esc(tag.name)} — visible in 3D and on every video`); + } catch (err) { + this._hint(`naming the tag failed: ${esc(err.message)}`); + } + }, + + // --- list rendering + row actions ------------------------------------------------------ + + _hint(html) { + const h = this._panel?.querySelector(".anch-hint"); + if (h) h.innerHTML = html; + }, + + _render() { + if (!this._panel) return; + const n = this._panel.querySelector(".anch-n"); + if (n) n.textContent = `(${state.anchors.length})`; + const list = this._panel.querySelector(".anch-list"); + if (!list) return; + if (state.anchors.length === 0) { + list.innerHTML = + `
no anchors yet — + Tag a friend,` + + ` or annotate an event on the timeline
`; + return; + } + list.innerHTML = state.anchors + .map((a) => { + const color = a.color || DEFAULT_COLOR; + return ( + `
` + + `` + + `` + + `${esc(a.label) || "(unnamed)"}` + + `` + + `` + + `
` + ); + }) + .join(""); + for (const row of list.querySelectorAll(".anch-row")) { + const id = Number(row.dataset.id); + row.querySelector(".anch-jump").addEventListener("click", () => this._jump(id)); + row.querySelector(".anch-label").addEventListener("click", () => this._jump(id)); + row.querySelector(".anch-rename").addEventListener("click", () => this._rename(id)); + row.querySelector(".anch-del").addEventListener("click", () => this._delete(id)); + const colorInput = row.querySelector(".anch-color"); + // Live-preview the dot while the picker is open; PATCH once on commit. + colorInput.addEventListener("input", () => { + row.querySelector(".anch-dot").style.background = colorInput.value; + }); + colorInput.addEventListener("change", () => this._recolor(id, colorInput.value)); + } + }, + + _find(id) { + return state.anchors.find((a) => a.id === id); + }, + + _jump(id) { + const a = this._find(id); + if (!a) return; + try { + scene3d.focusOn(a.x, a.y, a.z); + } catch (err) { + console.warn("[anchorPanel] focusOn failed", err); + } + }, + + async _rename(id) { + const a = this._find(id); + if (!a) return; + let name; + try { + name = window.prompt("Rename anchor:", a.label || ""); + } catch { + name = null; + } + if (name == null) return; // cancelled + name = name.trim(); + if (!name || name === a.label) return; + try { + upsertIntoState(await patchAnchor(id, { label: name })); + } catch (err) { + this._hint(`rename failed: ${esc(err.message)}`); + } + }, + + async _recolor(id, color) { + if (!this._find(id)) return; + try { + upsertIntoState(await patchAnchor(id, { color })); + } catch (err) { + this._hint(`recolor failed: ${esc(err.message)}`); + this._render(); // roll the previewed dot back to the stored color + } + }, + + async _delete(id) { + try { + const resp = await fetch(`${state.apiBase}/api/anchors/${id}`, { method: "DELETE" }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const i = state.anchors.findIndex((a) => a.id === id); + if (i >= 0) state.anchors.splice(i, 1); + emit("anchors-changed", null); + } catch (err) { + this._hint(`delete failed: ${esc(err.message)}`); + } + }, };