fix(sim): keep Feel (affect) words from overlapping Think (label) chips

Denser left labels (now >=3/clip) plus the always-4 right affect words made the
two HUD layers collide. renderAffect now nudges each Feel word vertically off the
Think chip plates (and off already-placed words + the ANALYSIS status tag),
staying on-screen, keeping the least-overlapping spot if nothing is fully clear.

Feelings are scene-level (soft position) so we move THEM, never a chip off its
subject. Obstacles are computed from the manifest, not the DOM, and a tracked
label's path is sampled across the loop — so the clearance holds for the whole
loop even though the per-frame track re-render keeps moving the chips (no per-frame
re-layout, no jitter). New diagnostic seam window.__hefNoDeconflict (sibling of
__hefState) A/B-toggles it.

Verified across all 6 scales at max knobs: authored overlaps drop to 0 with none
introduced (cosmos 3->0), and a tracked abyss clip stays at 0 overlaps sampled
over loop time as its chip drifts. No affect-position test exists to break;
16/16 node unit + frontend pytest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 09:06:36 -07:00
parent 4e4a5c256d
commit 041fcdeb7d
+67 -2
View File
@@ -583,6 +583,49 @@ function renderOverlay(level, intensity) {
// knob no longer gates them). `intensity` is the layer opacity. Words are placed at authored
// scene points (no boxes — feelings are scene-level) and read softer than the
// clinical reticles — feeling, not measurement.
// --- Keep Feel (affect) words clear of Think (label) chips -------------------
// Feelings are scene-level (soft position), so we nudge THEM off the analytical
// chip plates rather than move a chip off its subject. Obstacles are computed
// from the manifest (not the DOM) so the clearance holds for the WHOLE loop: a
// tracked chip's drift is sampled, so the per-frame track re-render never slides
// a chip under an already-placed word. Mirrors chip()'s plate geometry.
const FS_CHIP = 2.4, PAD_CHIP = 0.6;
function chipPlateRect(bx, by, textLen) {
const w = textLen * FS_CHIP * 0.6 + PAD_CHIP * 2;
const h = FS_CHIP + PAD_CHIP * 1.4;
const cy = Math.max(by - h - 0.6, 0.4); // plate sits just above the box top
return { x: bx, y: cy, w, h };
}
function rectsOverlap(a, b, pad) {
pad = pad || 0;
return a.x < b.x + b.w + pad && a.x + a.w + pad > b.x &&
a.y < b.y + b.h + pad && a.y + a.h + pad > b.y;
}
// Plate rects for every left label visible at `level`; a tracked label's path is
// sampled across the loop so its whole drift envelope is treated as occupied.
function leftLabelPlates(clip, level) {
const out = [];
if (!clip || !clip.annotations || level <= 0) return out;
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
for (const a of clip.annotations) {
if (level < firstLevel(a)) continue;
const measure = a.key.startsWith("measure.");
const raw = strings[a.key];
const tier = measure ? 1 : clamp(level - firstLevel(a) + 1, 1, tierCount(raw));
const label = String(pickTier(raw !== undefined ? raw : a.key, tier) || "");
const len = label.length + (measure ? 0 : 5); // detections append " 0.xx"
const ts = (a.track && a.track.length) ? [0, 0.2, 0.4, 0.6, 0.8, 1] : [0];
for (const tt of ts) {
const b = boxAt(a, tt);
out.push(chipPlateRect(b[0] * 100, b[1] * 100, len));
}
}
// The global "◉ ANALYSIS · L… · … OBJ" status tag (top-right) shows whenever any
// label does — reserve its corner so feelings don't collide with it either.
if (out.length) out.push({ x: 64, y: 2, w: 34, h: 6 });
return out;
}
function renderAffect(strength, intensity, right) {
lastAffect = { strength, intensity, right };
const clip = activeClip();
@@ -591,14 +634,36 @@ function renderAffect(strength, intensity, right) {
if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
affectLayer.style.opacity = String(intensity);
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
// Chip plates to avoid (left labels visible at the current Left level), plus the
// words placed so far so feelings don't stack on each other either.
const obstacles = leftLabelPlates(clip, lastOverlay ? lastOverlay.level : 0);
const placed = [];
for (const f of clip.affect) {
if (f.min_level > strength) continue;
const [x, y] = f.at.map((n) => n * 100);
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
const [x, y0] = f.at.map((n) => n * 100);
const t = svg("text", { x, y: y0, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
// RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
// knob rises (appearance is gated by the Right knob via `strength` above).
const raw = strings[f.key];
t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
// Nudge vertically off any chip plate / already-placed word, staying on-screen;
// keep the least-overlapping spot if nothing is fully clear.
const bb0 = t.getBBox();
// Diagnostic seam (sibling of window.__hefState): disable nudging to A/B the
// overlap-avoidance in tests.
const deconflict = !(typeof window !== "undefined" && window.__hefNoDeconflict);
if (deconflict && bb0.width) {
let bestY = y0, bestHits = Infinity;
for (const dy of [0, 4, -4, 8, -8, 12, -12, 16, -16, 20, -20]) {
const yy = clamp(y0 + dy, 4, 96);
const r = { x: bb0.x, y: bb0.y + (yy - y0), w: bb0.width, h: bb0.height };
const hits = obstacles.concat(placed).filter((o) => rectsOverlap(r, o, 0.4)).length;
if (hits < bestHits) { bestHits = hits; bestY = yy; if (hits === 0) break; }
}
if (bestY !== y0) t.setAttribute("y", bestY);
}
const bb = t.getBBox();
placed.push({ x: bb.x, y: bb.y, w: bb.width, h: bb.height });
}
}