M13: anchor manager & friend tags (lane F)
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 <noreply@anthropic.com>
This commit is contained in:
parent
547450f5ca
commit
13b7424c83
@ -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<number> } 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 = `
|
||||
<div class="anch-head">
|
||||
<strong>⚓ Anchors <span class="anch-n"></span></strong>
|
||||
<button class="btn anch-tag write-ui" title="Tag a friend: drag a box over them on any video">+ Tag</button>
|
||||
<button class="anch-x" title="Collapse">✕</button>
|
||||
</div>
|
||||
<div class="anch-hint dim"></div>
|
||||
<div class="anch-list"></div>`;
|
||||
|
||||
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(`<span class="bad">tag mode failed: ${esc(err.message)}</span>`);
|
||||
return;
|
||||
}
|
||||
this._tagButton(true);
|
||||
this._hint(`drag a box over <b>${esc(name)}</b> 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 <b>${esc(tag.name)}</b> — visible in 3D and on every video`);
|
||||
} catch (err) {
|
||||
this._hint(`<span class="bad">naming the tag failed: ${esc(err.message)}</span>`);
|
||||
}
|
||||
},
|
||||
|
||||
// --- 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 =
|
||||
`<div class="anch-empty">no anchors yet — <b>+ Tag</b> a friend,` +
|
||||
` or annotate an event on the timeline</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = state.anchors
|
||||
.map((a) => {
|
||||
const color = a.color || DEFAULT_COLOR;
|
||||
return (
|
||||
`<div class="anch-row" data-id="${a.id}">` +
|
||||
`<span class="anch-dot" style="background:${esc(color)}">` +
|
||||
`<input type="color" class="anch-color write-ui" value="${esc(color)}" title="Recolor"></span>` +
|
||||
`<span class="anch-label" title="Jump to ${esc(a.label || "anchor")}">${esc(a.label) || "(unnamed)"}</span>` +
|
||||
`<button class="anch-btn anch-jump" title="Jump to in 3D">◎</button>` +
|
||||
`<button class="anch-btn anch-rename write-ui" title="Rename">✎</button>` +
|
||||
`<button class="anch-btn anch-del write-ui" title="Delete">✕</button></div>`
|
||||
);
|
||||
})
|
||||
.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(`<span class="bad">rename failed: ${esc(err.message)}</span>`);
|
||||
}
|
||||
},
|
||||
|
||||
async _recolor(id, color) {
|
||||
if (!this._find(id)) return;
|
||||
try {
|
||||
upsertIntoState(await patchAnchor(id, { color }));
|
||||
} catch (err) {
|
||||
this._hint(`<span class="bad">recolor failed: ${esc(err.message)}</span>`);
|
||||
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(`<span class="bad">delete failed: ${esc(err.message)}</span>`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user