Phase 21E.1 - Story Intelligence Visualisation Cleanup

This commit is contained in:
Nick Beckley 2026-07-12 14:46:10 +00:00
parent f0b444e0ee
commit 35208591fd
2 changed files with 409 additions and 43 deletions

View File

@ -606,10 +606,10 @@ input:focus-visible {
.story-exp-connection-label {
paint-order: stroke;
stroke: rgba(4, 10, 20, 0.86);
stroke-width: 5px;
stroke: rgba(4, 10, 20, 0.92);
stroke-width: 6px;
fill: var(--story-soft);
font-size: 0.72rem;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.01em;
text-transform: lowercase;
@ -656,17 +656,22 @@ input:focus-visible {
.story-character {
position: absolute;
display: grid;
gap: 8px;
display: block;
width: var(--node-size, 150px);
height: var(--node-size, 150px);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
opacity: var(--opacity, 1);
z-index: var(--z, 3);
pointer-events: auto;
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
}
.story-character.is-pov {
z-index: 4;
z-index: 7;
}
.story-character.is-context .story-character__image {
filter: saturate(0.82);
}
.story-character__image,
@ -699,21 +704,84 @@ input:focus-visible {
}
.story-character__text {
position: absolute;
top: calc(var(--node-size, 150px) + 9px);
left: 50%;
display: grid;
justify-items: center;
gap: 2px;
width: min(176px, calc(var(--node-size, 150px) + 44px));
max-width: calc(100vw - 32px);
margin-left: 0;
border: 1px solid rgba(151, 185, 218, 0.1);
border-radius: 10px;
padding: 5px 7px 6px;
background: rgba(4, 10, 20, 0.58);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(8px);
text-align: center;
transform: translateX(-50%);
}
.story-character.is-pov .story-character__text {
top: auto;
left: calc(50% + 34px);
bottom: calc(100% + 9px);
}
.story-character[data-layout-role="partner"] .story-character__text {
top: 50%;
left: calc(100% + 12px);
justify-items: start;
text-align: left;
transform: translateY(-50%);
}
.story-character[data-layout-role="support"] .story-character__text {
left: calc(100% + 10px);
justify-items: start;
text-align: left;
transform: translateY(0);
}
.story-character[data-layout-role="context"] .story-character__text {
width: min(132px, calc(var(--node-size, 150px) + 34px));
left: 50%;
top: calc(100% + 7px);
}
.story-character[data-label-side="left"] .story-character__text {
top: 50%;
left: auto;
right: calc(100% + 10px);
justify-items: end;
text-align: right;
transform: translateY(-50%);
}
.story-character__text strong {
font-size: 1rem;
overflow: hidden;
max-width: 100%;
font-size: clamp(0.82rem, 0.9vw, 0.98rem);
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-character__text span {
max-width: 150px;
overflow: hidden;
max-width: 100%;
color: var(--story-muted);
font-size: 0.76rem;
font-size: 0.68rem;
font-weight: 700;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-character.is-context .story-character__text {
padding: 4px 6px;
opacity: 0.9;
}
.story-location {
@ -723,6 +791,7 @@ input:focus-visible {
gap: 10px;
width: var(--node-size, 190px);
transform: translate(var(--x, 0), var(--y, 0));
z-index: 4;
pointer-events: auto;
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
}
@ -745,15 +814,37 @@ input:focus-visible {
margin: 0;
}
.story-location__text {
display: grid;
justify-items: center;
gap: 3px;
max-width: min(244px, calc(100vw - 32px));
border: 1px solid rgba(125, 193, 255, 0.12);
border-radius: 12px;
padding: 7px 10px;
background: rgba(4, 10, 20, 0.62);
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(9px);
}
.story-location h2 {
overflow: hidden;
max-width: 100%;
font-size: clamp(1.12rem, 1.55vw, 1.55rem);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-location p {
overflow: hidden;
max-width: 100%;
color: var(--story-muted);
font-size: 0.86rem;
font-size: 0.78rem;
line-height: 1.15;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-exp-assets {
@ -770,6 +861,7 @@ input:focus-visible {
width: max(var(--node-size, 86px), 112px);
opacity: var(--opacity, 1);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
z-index: var(--z, 3);
pointer-events: auto;
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
}
@ -786,17 +878,33 @@ input:focus-visible {
display: grid;
justify-items: center;
gap: 2px;
width: min(148px, calc(var(--node-size, 86px) + 52px));
border: 1px solid rgba(215, 168, 93, 0.11);
border-radius: 10px;
padding: 5px 7px;
background: rgba(4, 10, 20, 0.6);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(8px);
text-align: center;
}
.story-asset strong,
.story-asset span {
display: block;
overflow: hidden;
max-width: 100%;
line-height: 1.08;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-asset strong {
font-size: 0.82rem;
}
.story-asset span {
color: var(--story-muted);
font-size: 0.76rem;
font-size: 0.68rem;
}
.story-exp-ribbon-wrap {

View File

@ -14,7 +14,8 @@
index: 0,
playing: true,
intervalMs: 6200,
timer: null
timer: null,
drawRequest: null
};
const analysisStages = [
{ key: "reading", label: "Reading" },
@ -74,9 +75,10 @@
updateProgress(current);
updateAnalysisStages(current);
updateSceneCopy(current);
reconcileCharacters(current, previous);
const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout);
reconcileLocation(current, previous);
reconcileAssets(current, previous);
reconcileAssets(current, previous, layout);
reconcileRelationships(current);
updateKnowledgeThreads(current);
updateSceneRibbon(current);
@ -146,7 +148,9 @@
if (!fallback || image.src.endsWith(fallback)) return;
failedImageUrls.add(url);
image.src = fallback;
scheduleConnectionDraw();
};
image.onload = () => scheduleConnectionDraw();
if (image.getAttribute("src") !== nextUrl) {
image.src = nextUrl;
}
@ -194,7 +198,7 @@
console.table(rows);
}
function reconcileCharacters(scene) {
function reconcileCharacters(scene, previous, layout) {
const activeIds = new Set(scene.characters.map((character) => character.id));
scene.characters.forEach((character, index) => {
let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
@ -203,16 +207,23 @@
dom.characters.append(node);
}
const position = characterPosition(character, index, scene.povCharacterId);
const position = layout.characters.get(character.id) || characterPosition(character, index, scene.povCharacterId, scene.characters);
node.classList.toggle("is-pov", character.id === scene.povCharacterId);
node.classList.toggle("is-secondary", character.id !== scene.povCharacterId && character.weight >= 65);
node.classList.toggle("is-context", character.id !== scene.povCharacterId && character.weight < 65);
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--opacity", String(position.opacity));
node.style.setProperty("--z", String(position.z));
node.dataset.layoutRole = position.layoutRole;
node.dataset.labelSide = position.labelSide || "";
setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
text(node.querySelector("[data-character-name]"), character.name);
text(node.querySelector("[data-character-role]"), `${character.role} - ${character.relevance}`);
const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
text(node.querySelector("[data-character-role]"), label);
node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`;
});
dom.characters.querySelectorAll("[data-character-id]").forEach((node) => {
@ -250,13 +261,14 @@
node.style.setProperty("--y", `${position.y}px`);
setEntityImage(node.querySelector("img"), scene.location.imagePath, scene.location.imageResolution, `${scene.location.name} prototype location`);
text(node.querySelector("h2"), scene.location.name);
text(node.querySelector("p"), scene.location.label);
text(node.querySelector("p"), conciseLocationLabel(scene.location));
node.title = `${scene.location.name}: ${scene.location.label}`;
dom.location.append(node);
scheduleConnectionDraw();
}, changed && !reducedMotion ? 260 : 0);
}
function reconcileAssets(scene) {
function reconcileAssets(scene, previous, layout) {
const activeIds = new Set(scene.assets.map((asset) => asset.id));
scene.assets.forEach((asset, index) => {
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
@ -265,15 +277,17 @@
dom.assets.append(node);
}
const position = assetPosition(asset, index, scene.assets.length);
const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length);
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--z", String(position.z));
node.style.setProperty("--opacity", "1");
setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
text(node.querySelector("[data-asset-name]"), asset.name);
text(node.querySelector("[data-asset-label]"), asset.label);
text(node.querySelector("[data-asset-label]"), conciseAssetLabel(asset));
node.title = `${asset.name}: ${asset.label}`;
});
dom.assets.querySelectorAll("[data-asset-id]").forEach((node) => {
@ -389,7 +403,14 @@
}
function scheduleConnectionDraw() {
window.requestAnimationFrame(() => drawConnections(loadSceneState(state.index)));
if (state.drawRequest) {
window.cancelAnimationFrame(state.drawRequest);
}
state.drawRequest = window.requestAnimationFrame(() => {
state.drawRequest = null;
drawConnections(loadSceneState(state.index));
});
}
function drawConnections(scene) {
@ -397,6 +418,7 @@
const stageRect = dom.visualStage.getBoundingClientRect();
dom.connections.innerHTML = "";
const drawn = [];
const occupiedRects = readableRects(stageRect);
scene.relationships.forEach((relationship, index) => {
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
@ -405,13 +427,13 @@
const start = connectionPoint(source, stageRect, target);
const end = connectionPoint(target, stageRect, source);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index === 0 ? inlineRelationshipLabel(relationship) : ""));
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, occupiedRects));
});
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
const location = dom.visualStage.querySelector("[data-location-id]");
if (pov && location) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, ""));
drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, "", 0, occupiedRects));
}
scene.characters
@ -420,16 +442,16 @@
.forEach((character) => {
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (node && location) {
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, ""));
drawn.push(drawConnection(connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, occupiedRects));
}
});
scene.assets.forEach((asset, index) => {
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!assetNode || !location) return;
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : ""));
drawn.push(drawConnection(connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, occupiedRects));
if (pov && index < 2) {
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), ""));
drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), "", drawn.length, occupiedRects));
}
});
@ -437,21 +459,18 @@
const first = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[0].id)}"]`);
const second = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(scene.assets[1].id)}"]`);
if (first && second) {
drawn.push(drawConnection(connectionPoint(first, stageRect, second), connectionPoint(second, stageRect, first), "asset-link", 48, ""));
drawn.push(drawConnection(connectionPoint(first, stageRect, second), connectionPoint(second, stageRect, first), "asset-link", 48, "", drawn.length, occupiedRects));
}
}
}
function drawConnection(start, end, tone, weight, label) {
function drawConnection(start, end, tone, weight, label, index, occupiedRects) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const dx = end.x - start.x;
const dy = end.y - start.y;
const curve = Math.max(34, Math.min(126, Math.abs(dx) * 0.24 + Math.abs(dy) * 0.1));
const bend = start.x < end.x ? curve : -curve;
const d = `M ${start.x} ${start.y} C ${start.x + bend} ${start.y - curve * 0.28}, ${end.x - bend} ${end.y + curve * 0.28}, ${end.x} ${end.y}`;
const route = routeConnection(start, end, index, occupiedRects);
const d = `M ${start.x} ${start.y} Q ${route.control.x} ${route.control.y}, ${end.x} ${end.y}`;
path.setAttribute("d", d);
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
@ -459,10 +478,11 @@
group.append(path);
if (label) {
const point = pointOnQuadratic(start, route.control, end, 0.52);
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text");
labelNode.setAttribute("class", "story-exp-connection-label");
labelNode.setAttribute("x", String((start.x + end.x) / 2));
labelNode.setAttribute("y", String((start.y + end.y) / 2 - 8));
labelNode.setAttribute("x", String(point.x + route.labelOffset.x));
labelNode.setAttribute("y", String(point.y + route.labelOffset.y));
labelNode.setAttribute("text-anchor", "middle");
labelNode.textContent = label;
group.append(labelNode);
@ -498,10 +518,79 @@
return node;
}
function characterPosition(character, index, povId) {
function sceneLayout(scene) {
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(760, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
return {
characters: characterLayout(scene, width, height),
assets: assetLayout(scene, width, height)
};
}
function characterLayout(scene, width, height) {
const positions = new Map();
const characters = [...scene.characters];
const pov = characters.find((character) => character.id === scene.povCharacterId) || characters[0];
const others = characters
.filter((character) => character.id !== pov?.id)
.sort((a, b) => b.weight - a.weight);
const povSize = Math.round(Math.min(198, Math.max(154, width * 0.155)));
if (pov) {
positions.set(pov.id, {
x: clamp(Math.round(width * 0.24 - povSize / 2), 28, width * 0.44 - povSize),
y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize),
size: povSize,
scale: 1,
opacity: 1,
z: 7,
layoutRole: "pov",
labelSide: "pov"
});
}
const slots = [
{ x: 0.41, y: 0.48, role: "partner" },
{ x: 0.12, y: 0.55, role: "support" },
{ x: 0.11, y: 0.19, role: "support" },
{ x: 0.33, y: 0.70, role: "context" },
{ x: 0.05, y: 0.36, role: "context" }
];
const placed = [...positions.values()];
others.forEach((character, index) => {
const contextual = isContextCharacter(character);
const size = Math.round(contextual
? Math.min(108, Math.max(82, 70 + character.weight * 0.42))
: Math.min(154, Math.max(116, 96 + character.weight * 0.58)));
const slot = slots[index] || slots[slots.length - 1];
const desired = {
x: width * slot.x - size / 2,
y: height * slot.y - size / 2
};
const resolved = avoidNodeCollisions(desired, size, placed, width, height);
const position = {
x: resolved.x,
y: resolved.y,
size,
scale: 1,
opacity: contextual ? 0.76 : 0.94,
z: contextual ? 3 : 5,
layoutRole: contextual ? "context" : slot.role,
labelSide: contextual && pov && resolved.x < positions.get(pov.id).x ? "left" : slot.role === "partner" || slot.role === "support" ? "right" : "below"
};
placed.push(position);
positions.set(character.id, position);
});
return positions;
}
function characterPosition(character, index, povId, characters) {
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
const layout = characterLayout({ characters: characters || [character], povCharacterId: povId }, width, height);
if (layout.has(character.id)) return layout.get(character.id);
const povSize = Math.min(210, Math.max(166, width * 0.16));
if (character.id === povId) {
@ -571,6 +660,72 @@
};
}
function assetLayout(scene, width, height) {
const positions = new Map();
const total = scene.assets.length;
const anchorsByTotal = {
1: [{ x: 0.83, y: 0.48 }],
2: [{ x: 0.83, y: 0.34 }, { x: 0.83, y: 0.62 }],
3: [{ x: 0.82, y: 0.25 }, { x: 0.85, y: 0.50 }, { x: 0.81, y: 0.75 }],
4: [{ x: 0.78, y: 0.16 }, { x: 0.90, y: 0.37 }, { x: 0.86, y: 0.62 }, { x: 0.73, y: 0.82 }],
5: [{ x: 0.76, y: 0.13 }, { x: 0.91, y: 0.30 }, { x: 0.80, y: 0.48 }, { x: 0.92, y: 0.66 }, { x: 0.72, y: 0.84 }]
};
const anchors = anchorsByTotal[Math.min(5, Math.max(1, total))] || anchorsByTotal[5];
const placed = [];
scene.assets.forEach((asset, index) => {
const anchor = anchors[index] || anchors[anchors.length - 1];
const important = index === 0 || asset.weight >= 90;
const size = Math.round(important
? Math.min(116, Math.max(92, 70 + Math.min(asset.weight, 100) * 0.42))
: Math.min(98, Math.max(76, 58 + Math.min(asset.weight, 100) * 0.32)));
const desired = {
x: width * anchor.x - size / 2,
y: height * anchor.y - size / 2
};
const resolved = avoidNodeCollisions(desired, size, placed, width, height);
const position = { x: resolved.x, y: resolved.y, size, scale: 1, z: important ? 4 : 3 };
placed.push(position);
positions.set(asset.id, position);
});
return positions;
}
function avoidNodeCollisions(desired, size, placed, width, height) {
const padding = Math.max(16, size * 0.12);
const candidates = [
desired,
{ x: desired.x, y: desired.y + size * 0.34 },
{ x: desired.x + size * 0.42, y: desired.y },
{ x: desired.x - size * 0.42, y: desired.y },
{ x: desired.x, y: desired.y - size * 0.34 },
{ x: desired.x + size * 0.34, y: desired.y + size * 0.28 },
{ x: desired.x - size * 0.34, y: desired.y + size * 0.28 }
];
let best = clampPosition(candidates[0], size, width, height);
let bestScore = Number.POSITIVE_INFINITY;
candidates.forEach((candidate) => {
const clamped = clampPosition(candidate, size, width, height);
const score = placed.reduce((total, other) => {
const distance = Math.hypot((clamped.x + size / 2) - (other.x + other.size / 2), (clamped.y + size / 2) - (other.y + other.size / 2));
const minDistance = (size + other.size) * 0.45 + padding;
return total + Math.max(0, minDistance - distance) * 12;
}, Math.hypot(clamped.x - desired.x, clamped.y - desired.y));
if (score < bestScore) {
bestScore = score;
best = clamped;
}
});
return best;
}
function clampPosition(position, size, width, height) {
return {
x: Math.round(clamp(position.x, 18, width - size - 18)),
y: Math.round(clamp(position.y, 46, height - size - 58))
};
}
function centrePoint(node, parentRect) {
const rect = node.getBoundingClientRect();
return {
@ -580,17 +735,108 @@
}
function connectionPoint(node, parentRect, targetNode) {
const centre = centrePoint(node, parentRect);
const rect = node.getBoundingClientRect();
const image = node.querySelector(".story-character__image, .story-location__image, .story-asset__image") || node;
const centre = centrePoint(image, parentRect);
const rect = image.getBoundingClientRect();
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
const radius = Math.min(rect.width, rect.height) * 0.44;
const angle = Math.atan2(target.y - centre.y, target.x - centre.x);
const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image, .story-asset__image");
const targetCentre = targetImage ? centrePoint(targetImage, parentRect) : target;
const radius = Math.min(rect.width, rect.height) * 0.5 + 3;
const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x);
return {
x: centre.x + Math.cos(angle) * radius,
y: centre.y + Math.sin(angle) * radius
};
}
function routeConnection(start, end, index, occupiedRects) {
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.max(1, Math.hypot(dx, dy));
const normal = { x: -dy / length, y: dx / length };
const base = Math.max(28, Math.min(118, length * 0.18));
const direction = index % 2 === 0 ? 1 : -1;
const offsets = [direction * base, -direction * base, direction * base * 1.55, -direction * base * 1.55, 0];
let best = offsets[0];
let bestScore = Number.POSITIVE_INFINITY;
offsets.forEach((offset) => {
const control = { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset };
const samples = [0.28, 0.5, 0.72].map((t) => pointOnQuadratic(start, control, end, t));
const score = samples.reduce((total, point) => total + occupiedRects.reduce((sum, rect) => sum + pointRectPenalty(point, rect), 0), Math.abs(offset) * 0.05);
if (score < bestScore) {
bestScore = score;
best = offset;
}
});
return {
control: { x: mid.x + normal.x * best, y: mid.y + normal.y * best },
labelOffset: { x: normal.x * Math.sign(best || direction) * 14, y: normal.y * Math.sign(best || direction) * 14 - 6 }
};
}
function readableRects(parentRect) {
return [...dom.visualStage.querySelectorAll(".story-character__text, .story-location__text, .story-asset__text")]
.map((node) => {
const rect = node.getBoundingClientRect();
return {
left: rect.left - parentRect.left - 10,
right: rect.right - parentRect.left + 10,
top: rect.top - parentRect.top - 6,
bottom: rect.bottom - parentRect.top + 6
};
});
}
function pointOnQuadratic(start, control, end, t) {
const inverse = 1 - t;
return {
x: inverse * inverse * start.x + 2 * inverse * t * control.x + t * t * end.x,
y: inverse * inverse * start.y + 2 * inverse * t * control.y + t * t * end.y
};
}
function pointRectPenalty(point, rect) {
if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000;
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
const distance = Math.hypot(dx, dy);
return Math.max(0, 48 - distance);
}
function conciseCharacterLabel(character, isPov) {
if (isPov) return "POV";
const value = `${character.role} ${character.relevance}`.toLowerCase();
if (value.includes("suspect") || value.includes("threat") || value.includes("opposing")) return "Suspect";
if (value.includes("witness") || value.includes("neighbour")) return "Witness";
if (value.includes("ally") || value.includes("protected")) return "Ally";
if (value.includes("absent") || value.includes("remembered") || value.includes("voice") || value.includes("unseen")) return "Absent";
if (value.includes("letter") || value.includes("evidence") || value.includes("target")) return "Evidence";
return compactPhrase(character.role || character.relevance, 18);
}
function conciseLocationLabel(location) {
return compactPhrase(location.label, 30);
}
function conciseAssetLabel(asset) {
return compactPhrase(asset.label, 24);
}
function compactPhrase(value, limit) {
const textValue = String(value || "").replace(/\s+/g, " ").trim();
return textValue.length > limit ? `${textValue.slice(0, limit - 1).trim()}...` : textValue;
}
function isContextCharacter(character) {
const value = `${character.role} ${character.relevance}`.toLowerCase();
return character.weight < 65 || value.includes("absent") || value.includes("remembered") || value.includes("unseen") || value.includes("voice");
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function relationshipTone(relationship) {
const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict";
@ -680,12 +926,24 @@
window.addEventListener("resize", () => {
const current = loadSceneState(state.index);
reconcileCharacters(current);
const layout = sceneLayout(current);
reconcileCharacters(current, current, layout);
reconcileLocation(current, current);
reconcileAssets(current);
reconcileAssets(current, current, layout);
scheduleConnectionDraw();
});
if (window.ResizeObserver && dom.visualStage) {
const observer = new ResizeObserver(() => {
const current = loadSceneState(state.index);
const layout = sceneLayout(current);
reconcileCharacters(current, current, layout);
reconcileAssets(current, current, layout);
scheduleConnectionDraw();
});
observer.observe(dom.visualStage);
}
transitionToNextState(0);
startTimer();
})();