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:
+67
-2
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user