From 041fcdeb7dda2f6508197aa4a374edec46060e4b Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 09:06:36 -0700 Subject: [PATCH] fix(sim): keep Feel (affect) words from overlapping Think (label) chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- simulator/static/app.js | 69 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/simulator/static/app.js b/simulator/static/app.js index b8d7489..44e4143 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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 }); } }