diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index b3346c1..8c2f130 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -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 { diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 471cb31..5555bab 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -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(); })();