// Anchor manager & friend tags (spec M13, lane F). // // 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). // // + 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, 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)}`); } }, };