From 57df5646e6cbd367df8562e1aba87c5007e56ae5 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 11 Jul 2026 20:49:15 +0000 Subject: [PATCH] Phase 21B.2 - Story Intelligence Experience Composition Refinement --- .../StoryIntelligenceExperience.cshtml | 1 + ...tory-intelligence-experience-prototype.css | 360 ++++++++++++++---- ...story-intelligence-experience-prototype.js | 250 ++++++++++-- ...gence-Experience-Composition-Refinement.md | 99 +++++ 4 files changed, 607 insertions(+), 103 deletions(-) create mode 100644 docs/phases/Phase-21B.2-Story-Intelligence-Experience-Composition-Refinement.md diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 87a323e..72744ed 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -21,6 +21,7 @@ PlotDirector Story Intelligence +
    @Model.ProjectName @Model.BookTitle diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 8e9294f..b3346c1 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -54,7 +54,7 @@ input:focus-visible { .story-exp { position: relative; display: grid; - grid-template-rows: 70px minmax(0, 1fr); + grid-template-rows: 66px minmax(0, 1fr); width: 100vw; height: 100vh; isolation: isolate; @@ -87,10 +87,10 @@ input:focus-visible { .story-exp-header { display: grid; - grid-template-columns: minmax(210px, 0.65fr) minmax(460px, 1.35fr) auto; + grid-template-columns: minmax(210px, 0.52fr) minmax(400px, 1.35fr) minmax(290px, 0.78fr) auto; align-items: center; - gap: 22px; - padding: 14px 24px; + gap: 18px; + padding: 10px 24px; border-bottom: 1px solid var(--story-line); background: rgba(4, 10, 20, 0.68); backdrop-filter: blur(18px); @@ -102,6 +102,139 @@ input:focus-visible { gap: 12px; } +.story-exp-pipeline { + position: relative; + display: grid; + grid-template-columns: repeat(11, minmax(38px, 1fr)); + gap: 0; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.story-exp-pipeline::before { + content: ""; + position: absolute; + left: 4%; + right: 4%; + top: 10px; + height: 1px; + background: linear-gradient(90deg, rgba(109, 181, 255, 0.54), rgba(215, 168, 93, 0.24), rgba(255, 255, 255, 0.18)); +} + +.story-exp-pipeline-step { + position: relative; + display: grid; + justify-items: center; + gap: 6px; + color: rgba(196, 214, 235, 0.54); + font-size: 0.62rem; + font-weight: 700; + min-width: 0; +} + +.story-exp-pipeline-step span { + z-index: 1; + width: 10px; + height: 10px; + border: 1px solid rgba(196, 214, 235, 0.36); + border-radius: 50%; + background: rgba(8, 18, 32, 0.98); +} + +.story-exp-pipeline-step strong { + overflow: hidden; + max-width: 78px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.story-exp-pipeline-step.is-complete span { + border-color: rgba(109, 181, 255, 0.9); + background: var(--story-blue); + box-shadow: 0 0 14px rgba(109, 181, 255, 0.35); +} + +.story-exp-pipeline-step.is-current { + color: var(--story-text); +} + +.story-exp-pipeline-step.is-current span { + width: 14px; + height: 14px; + margin-top: -2px; + border-color: rgba(215, 168, 93, 0.9); + background: var(--story-gold); + box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.12), 0 0 22px rgba(215, 168, 93, 0.5); +} + +@media (max-width: 1500px) { + .story-exp-header { + grid-template-columns: minmax(188px, 0.44fr) minmax(360px, 1.2fr) minmax(230px, 0.66fr) auto; + gap: 12px; + } + + .story-exp-pipeline-step { + font-size: 0.55rem; + } + + .story-exp-pipeline-step strong { + max-width: 45px; + } + + .story-exp-context { + gap: 8px; + } + + .story-exp-context span { + max-width: 96px; + } +} + +@media (max-height: 820px) and (min-width: 1181px) { + .story-exp-shell { + padding-top: 10px; + padding-bottom: 64px; + } + + .story-exp-stage { + grid-template-rows: auto minmax(0, 1fr) 126px; + } + + .story-exp-stage-copy { + min-height: 54px; + } + + .story-exp-ribbon-wrap { + gap: 8px; + padding: 10px; + } + + .story-exp-ribbon { + min-height: 70px; + } + + .story-scene-card { + flex-basis: 126px; + padding: 8px 10px; + } + + .story-scene-card.is-current { + flex-basis: 172px; + } + + .story-exp-timeline { + min-height: 34px; + } + + .story-exp-controls { + bottom: 10px; + padding-top: 7px; + padding-bottom: 7px; + } +} + .story-exp-brand strong { font-size: 1.12rem; letter-spacing: 0.02em; @@ -121,8 +254,8 @@ input:focus-visible { .story-exp-context { display: flex; - justify-content: center; - gap: 16px; + justify-content: flex-end; + gap: 12px; min-width: 0; } @@ -154,7 +287,7 @@ input:focus-visible { grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr); gap: 16px; min-height: 0; - padding: 18px 22px 84px; + padding: 14px 22px 74px; } .story-exp-left, @@ -363,8 +496,8 @@ input:focus-visible { .story-exp-stage { display: grid; - grid-template-rows: auto minmax(0, 1fr) 166px; - gap: 14px; + grid-template-rows: auto minmax(0, 1fr) 158px; + gap: 10px; min-height: 0; } @@ -373,14 +506,14 @@ input:focus-visible { grid-template-columns: 1fr minmax(240px, 0.72fr); align-items: end; gap: 22px; - min-height: 86px; + min-height: 62px; } .story-exp-stage-copy h1 { grid-column: 1; margin: 0; - font-size: clamp(1.8rem, 3vw, 3.8rem); - line-height: 1.02; + font-size: clamp(1.55rem, 2.35vw, 2.75rem); + line-height: 1.05; letter-spacing: 0; } @@ -388,38 +521,37 @@ input:focus-visible { grid-column: 2; margin: 0; color: var(--story-soft); - font-size: clamp(0.9rem, 1.2vw, 1.05rem); - line-height: 1.45; + font-size: clamp(0.86rem, 1vw, 1rem); + line-height: 1.38; } .story-exp-visual { position: relative; - display: grid; - grid-template-columns: minmax(470px, 1.22fr) minmax(320px, 0.9fr) minmax(238px, 0.58fr); - grid-template-rows: minmax(0, 1fr); - gap: 10px; + display: block; min-height: 0; overflow: hidden; - border: 1px solid rgba(151, 185, 218, 0.11); - border-radius: 30px; - padding: 22px; + border: 1px solid rgba(151, 185, 218, 0.045); + border-radius: 24px; + padding: 0; background: - radial-gradient(circle at 22% 40%, rgba(109, 181, 255, 0.18), transparent 24rem), - radial-gradient(circle at 58% 49%, rgba(215, 168, 93, 0.15), transparent 20rem), - radial-gradient(circle at 86% 30%, rgba(227, 140, 168, 0.08), transparent 18rem), - linear-gradient(180deg, rgba(11, 24, 43, 0.66), rgba(4, 10, 20, 0.54)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), var(--story-shadow); + radial-gradient(circle at 20% 22%, rgba(109, 181, 255, 0.14), transparent 16rem), + radial-gradient(circle at 58% 46%, rgba(109, 181, 255, 0.13), transparent 18rem), + radial-gradient(circle at 78% 55%, rgba(215, 168, 93, 0.11), transparent 18rem), + linear-gradient(180deg, rgba(11, 24, 43, 0.34), rgba(4, 10, 20, 0.18)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025), 0 22px 72px rgba(0, 0, 0, 0.22); } .story-exp-visual::before { content: ""; position: absolute; - inset: 28px 34px; - border-radius: 28px; + inset: 0; + border-radius: inherit; background: - linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent), - linear-gradient(180deg, transparent, rgba(109, 181, 255, 0.04), transparent); - opacity: 0.52; + radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 1px, transparent 1.6px), + linear-gradient(122deg, transparent 0 34%, rgba(109, 181, 255, 0.08) 34.05% 34.18%, transparent 34.28%), + linear-gradient(28deg, transparent 0 62%, rgba(215, 168, 93, 0.08) 62.05% 62.18%, transparent 62.28%); + background-size: 112px 112px, auto, auto; + opacity: 0.34; pointer-events: none; } @@ -433,30 +565,93 @@ input:focus-visible { } .story-exp-connection { - stroke: rgba(215, 168, 93, 0.48); + stroke: rgba(215, 168, 93, 0.5); stroke-linecap: round; - stroke-width: 2; + stroke-width: 1.8; stroke-dasharray: 1; stroke-dashoffset: 1; - filter: drop-shadow(0 0 8px rgba(215, 168, 93, 0.28)); - animation: drawLine 900ms ease forwards; + fill: none; + filter: drop-shadow(0 0 7px rgba(215, 168, 93, 0.22)); + animation: drawLine 950ms cubic-bezier(.16, .84, .28, 1) forwards; +} + +.story-exp-connection-group--presence .story-exp-connection, +.story-exp-connection-group--presence-soft .story-exp-connection { + stroke: rgba(125, 193, 255, 0.58); +} + +.story-exp-connection-group--presence-soft .story-exp-connection, +.story-exp-connection-group--knowledge .story-exp-connection, +.story-exp-connection-group--asset-link .story-exp-connection { + stroke-dasharray: 0.12 0.08; +} + +.story-exp-connection-group--support .story-exp-connection { + stroke: rgba(121, 210, 166, 0.64); +} + +.story-exp-connection-group--conflict .story-exp-connection { + stroke: rgba(227, 140, 168, 0.76); + stroke-dasharray: 0.18 0.1; +} + +.story-exp-connection-group--uncertain .story-exp-connection { + stroke: rgba(182, 162, 255, 0.56); + stroke-dasharray: 0.08 0.09; +} + +.story-exp-connection-group--knowledge .story-exp-connection { + stroke: rgba(182, 162, 255, 0.5); +} + +.story-exp-connection-label { + paint-order: stroke; + stroke: rgba(4, 10, 20, 0.86); + stroke-width: 5px; + fill: var(--story-soft); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.01em; + text-transform: lowercase; + filter: drop-shadow(0 0 9px rgba(0, 0, 0, 0.5)); } .story-exp-zone { - position: relative; + position: absolute; + inset: 0; z-index: 2; min-width: 0; min-height: 0; + pointer-events: none; } .story-exp-zone-label { - margin: 0 0 8px; - opacity: 0.72; + position: absolute; + margin: 0; + color: rgba(158, 177, 199, 0.54); + font-size: 0.64rem; + opacity: 0.56; +} + +.story-exp-zone--characters .story-exp-zone-label { + left: 24px; + top: 22px; +} + +.story-exp-zone--location .story-exp-zone-label { + left: 55%; + top: 22px; +} + +.story-exp-zone--assets .story-exp-zone-label { + right: 8%; + top: 22px; } .story-exp-characters { position: relative; - min-height: 100%; + width: 100%; + height: 100%; } .story-character { @@ -466,6 +661,7 @@ input:focus-visible { width: var(--node-size, 150px); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); opacity: var(--opacity, 1); + pointer-events: auto; transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease; } @@ -478,9 +674,9 @@ input:focus-visible { .story-asset__image { display: block; overflow: hidden; - border: 1px solid rgba(169, 205, 240, 0.2); + border: 1px solid rgba(169, 205, 240, 0.36); background: rgba(255, 255, 255, 0.045); - box-shadow: 0 24px 54px rgba(0, 0, 0, 0.28); + box-shadow: 0 24px 54px rgba(0, 0, 0, 0.32), 0 0 28px rgba(109, 181, 255, 0.08); } .story-character__image { @@ -499,7 +695,7 @@ input:focus-visible { .story-character.is-pov .story-character__image { border-color: rgba(215, 168, 93, 0.82); - box-shadow: 0 0 0 7px rgba(215, 168, 93, 0.1), 0 34px 78px rgba(215, 168, 93, 0.2); + box-shadow: 0 0 0 7px rgba(215, 168, 93, 0.1), 0 34px 78px rgba(215, 168, 93, 0.22), 0 0 36px rgba(215, 168, 93, 0.22); } .story-character__text { @@ -521,12 +717,14 @@ input:focus-visible { } .story-location { + position: absolute; display: grid; - align-content: center; - gap: 14px; - height: calc(100% - 28px); - padding: 8px 0; - transition: opacity 560ms ease, transform 800ms cubic-bezier(.16, .84, .28, 1); + justify-items: center; + gap: 10px; + width: var(--node-size, 190px); + transform: translate(var(--x, 0), var(--y, 0)); + pointer-events: auto; + transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1); } .story-location.is-changing { @@ -535,11 +733,11 @@ input:focus-visible { } .story-location__image { - width: 100%; - max-height: 360px; - aspect-ratio: 4 / 5; - border-radius: 32px; - box-shadow: 0 28px 78px rgba(0, 0, 0, 0.34), 0 0 54px rgba(109, 181, 255, 0.1); + width: var(--node-size, 190px); + height: var(--node-size, 190px); + border-radius: 50%; + border-color: rgba(125, 193, 255, 0.82); + box-shadow: 0 0 0 7px rgba(109, 181, 255, 0.1), 0 30px 82px rgba(0, 0, 0, 0.38), 0 0 58px rgba(109, 181, 255, 0.22); } .story-location h2, @@ -548,35 +746,47 @@ input:focus-visible { } .story-location h2 { - font-size: clamp(1.45rem, 2vw, 2.1rem); + font-size: clamp(1.12rem, 1.55vw, 1.55rem); + text-align: center; } .story-location p { color: var(--story-muted); + font-size: 0.86rem; + text-align: center; } .story-exp-assets { - display: grid; - align-content: center; - gap: 14px; - height: calc(100% - 28px); - padding-top: 24px; + position: relative; + width: 100%; + height: 100%; } .story-asset { + position: absolute; display: grid; - grid-template-columns: 66px 1fr; - align-items: center; - gap: 11px; + justify-items: center; + gap: 7px; + width: max(var(--node-size, 86px), 112px); opacity: var(--opacity, 1); - transform: translateY(var(--y, 0)) scale(var(--scale, 1)); + transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); + pointer-events: auto; transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease; } .story-asset__image { - width: 66px; - height: 66px; - border-radius: 17px; + width: var(--node-size, 86px); + height: var(--node-size, 86px); + border-radius: 50%; + border-color: rgba(215, 168, 93, 0.68); + box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.08), 0 22px 48px rgba(0, 0, 0, 0.3), 0 0 28px rgba(215, 168, 93, 0.14); +} + +.story-asset__text { + display: grid; + justify-items: center; + gap: 2px; + text-align: center; } .story-asset strong, @@ -586,7 +796,7 @@ input:focus-visible { .story-asset span { color: var(--story-muted); - font-size: 0.82rem; + font-size: 0.76rem; } .story-exp-ribbon-wrap { @@ -781,13 +991,29 @@ input:focus-visible { min-height: 100vh; } - .story-exp-header, .story-exp-shell, .story-exp-stage-copy, .story-exp-visual { grid-template-columns: 1fr; } + .story-exp-header { + grid-template-columns: 1fr auto; + } + + .story-exp-pipeline { + grid-column: 1 / -1; + grid-row: 2; + overflow-x: auto; + padding-bottom: 4px; + } + + .story-exp-context { + grid-column: 1 / -1; + grid-row: 3; + justify-content: start; + } + .story-exp-shell { padding-bottom: 110px; } diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 810e89d..975eff7 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -14,9 +14,23 @@ intervalMs: 6200, timer: null }; + const analysisStages = [ + { key: "reading", label: "Reading" }, + { key: "chapters", label: "Chapters" }, + { key: "scenes", label: "Scenes" }, + { key: "content", label: "Content" }, + { key: "characters", label: "Characters" }, + { key: "locations", label: "Locations" }, + { key: "assets", label: "Assets" }, + { key: "relationships", label: "Relationships" }, + { key: "knowledge", label: "Knowledge" }, + { key: "continuity", label: "Continuity" }, + { key: "finalising", label: "Finalising" } + ]; const dom = { stageLabel: root.querySelector("[data-stage-label]"), + stageRail: root.querySelector("[data-analysis-stages]"), currentScene: root.querySelector("[data-current-scene]"), progressPercent: root.querySelector("[data-progress-percent]"), elapsedTime: root.querySelector("[data-elapsed-time]"), @@ -53,6 +67,7 @@ const current = loadSceneState(state.index); updateProgress(current); + updateAnalysisStages(current); updateSceneCopy(current); reconcileCharacters(current, previous); reconcileLocation(current, previous); @@ -77,6 +92,37 @@ if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`); } + function updateAnalysisStages(scene) { + if (!dom.stageRail) return; + const currentIndex = stageIndex(scene); + dom.stageRail.innerHTML = ""; + analysisStages.forEach((stage, index) => { + const node = document.createElement("li"); + node.className = "story-exp-pipeline-step"; + node.classList.toggle("is-complete", index < currentIndex || scene.progressPercent >= 100); + node.classList.toggle("is-current", index === currentIndex && scene.progressPercent < 100); + node.classList.toggle("is-future", index > currentIndex && scene.progressPercent < 100); + node.innerHTML = ""; + text(node.querySelector("strong"), stage.label); + dom.stageRail.append(node); + }); + } + + function stageIndex(scene) { + const stage = String(scene.stage || "").toLowerCase(); + if (scene.progressPercent >= 100 || stage.includes("review")) return analysisStages.length - 1; + if (stage.includes("continuity")) return 9; + if (stage.includes("knowledge")) return 8; + if (stage.includes("relationship")) return 7; + if (stage.includes("asset")) return 6; + if (stage.includes("location")) return 5; + if (stage.includes("character")) return 4; + if (stage.includes("content") || stage.includes("inferring")) return 3; + if (stage.includes("scene")) return 2; + if (stage.includes("chapter")) return 1; + return Math.max(0, Math.min(analysisStages.length - 1, Math.floor(scene.progressPercent / 10))); + } + function updateSceneCopy(scene) { text(dom.sceneTitle, scene.title); text(dom.sceneSummary, scene.summary); @@ -108,7 +154,10 @@ if (!activeIds.has(node.dataset.characterId)) { node.style.setProperty("--opacity", "0"); node.style.setProperty("--scale", "0.86"); - window.setTimeout(() => node.remove(), reducedMotion ? 0 : 620); + window.setTimeout(() => { + const currentIds = new Set(loadSceneState(state.index).characters.map((character) => character.id)); + if (!currentIds.has(node.dataset.characterId)) node.remove(); + }, reducedMotion ? 0 : 620); } }); } @@ -126,14 +175,19 @@ node.dataset.locationId = scene.location.id; node.innerHTML = `
    ${escapeHtml(scene.location.name)} prototype location
    -
    +

    `; + const position = locationPosition(); + node.style.setProperty("--node-size", `${position.size}px`); + node.style.setProperty("--x", `${position.x}px`); + node.style.setProperty("--y", `${position.y}px`); node.querySelector("img").src = scene.location.imagePath; text(node.querySelector("h2"), scene.location.name); text(node.querySelector("p"), scene.location.label); dom.location.append(node); + scheduleConnectionDraw(); }, changed && !reducedMotion ? 260 : 0); } @@ -146,8 +200,11 @@ dom.assets.append(node); } - node.style.setProperty("--y", `${(index - 1) * 2}px`); - node.style.setProperty("--scale", String(0.92 + Math.min(asset.weight, 100) / 500)); + const position = 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("--opacity", "1"); node.querySelector("img").src = asset.imagePath; node.querySelector("img").alt = `${asset.name} prototype asset`; @@ -159,7 +216,10 @@ if (!activeIds.has(node.dataset.assetId)) { node.style.setProperty("--opacity", "0"); node.style.setProperty("--scale", "0.8"); - window.setTimeout(() => node.remove(), reducedMotion ? 0 : 520); + window.setTimeout(() => { + const currentIds = new Set(loadSceneState(state.index).assets.map((asset) => asset.id)); + if (!currentIds.has(node.dataset.assetId)) node.remove(); + }, reducedMotion ? 0 : 520); } }); } @@ -272,35 +332,80 @@ if (!dom.connections || !dom.visualStage) return; const stageRect = dom.visualStage.getBoundingClientRect(); dom.connections.innerHTML = ""; + const drawn = []; - scene.relationships.forEach((relationship) => { + scene.relationships.forEach((relationship, index) => { const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`); const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`); if (!source || !target) return; - const start = centrePoint(source, stageRect); - const end = centrePoint(target, stageRect); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - const midY = Math.min(start.y, end.y) - 36; - path.setAttribute("d", `M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}`); - path.setAttribute("pathLength", "1"); - path.setAttribute("class", "story-exp-connection"); - path.style.strokeWidth = String(1.5 + Math.min(relationship.weight, 100) / 28); - dom.connections.append(path); + 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) : "")); }); const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`); const location = dom.visualStage.querySelector("[data-location-id]"); if (pov && location) { - const start = centrePoint(pov, stageRect); - const end = centrePoint(location, stageRect); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", `M ${start.x} ${start.y} C ${start.x + 120} ${start.y + 30}, ${end.x - 110} ${end.y - 30}, ${end.x} ${end.y}`); - path.setAttribute("pathLength", "1"); - path.setAttribute("class", "story-exp-connection"); - path.style.stroke = "rgba(109, 181, 255, 0.42)"; - dom.connections.append(path); + drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 72, "")); } + + scene.characters + .filter((character) => character.id !== scene.povCharacterId && character.weight >= 65) + .slice(0, 2) + .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, "")); + } + }); + + 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) : "")); + if (pov && index < 2) { + drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), "")); + } + }); + + if (scene.assets.length > 1) { + 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, "")); + } + } + } + + function drawConnection(start, end, tone, weight, label) { + 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}`; + path.setAttribute("d", d); + path.setAttribute("pathLength", "1"); + path.setAttribute("class", "story-exp-connection"); + path.style.strokeWidth = String(1.1 + Math.min(weight, 100) / 56); + group.append(path); + + if (label) { + 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("text-anchor", "middle"); + labelNode.textContent = label; + group.append(labelNode); + } + + dom.connections.append(group); + return group; } function createCharacterNode(character) { @@ -322,7 +427,7 @@ node.dataset.assetId = asset.id; node.innerHTML = `
    -
    +
    `; @@ -330,15 +435,15 @@ } function characterPosition(character, index, povId) { - const bounds = dom.characters?.getBoundingClientRect(); - const width = Math.max(520, bounds?.width || 620); - const height = Math.max(520, bounds?.height || 620); - const povSize = Math.min(238, Math.max(206, width * 0.34)); + const bounds = dom.visualStage?.getBoundingClientRect(); + const width = Math.max(820, bounds?.width || 1040); + const height = Math.max(500, bounds?.height || 560); + const povSize = Math.min(210, Math.max(166, width * 0.16)); if (character.id === povId) { return { - x: Math.round(width * 0.36 - povSize / 2), - y: Math.round(height * 0.46 - povSize / 2), + x: Math.round(width * 0.25 - povSize / 2), + y: Math.round(height * 0.25 - povSize / 2), size: Math.round(povSize), scale: 1, opacity: 1 @@ -348,14 +453,14 @@ const important = character.weight >= 70; const positions = important ? [ - { x: width * 0.08, y: height * 0.18 }, - { x: width * 0.62, y: height * 0.15 }, - { x: width * 0.58, y: height * 0.58 } + { x: width * 0.12, y: height * 0.16 }, + { x: width * 0.38, y: height * 0.38 }, + { x: width * 0.16, y: height * 0.56 } ] : [ - { x: width * 0.12, y: height * 0.62 }, - { x: width * 0.73, y: height * 0.48 }, - { x: width * 0.06, y: height * 0.42 } + { x: width * 0.08, y: height * 0.45 }, + { x: width * 0.39, y: height * 0.66 }, + { x: width * 0.18, y: height * 0.34 } ]; const slot = positions[index % positions.length]; const size = important @@ -370,6 +475,38 @@ }; } + function locationPosition() { + const bounds = dom.visualStage?.getBoundingClientRect(); + const width = Math.max(820, bounds?.width || 1040); + const height = Math.max(500, bounds?.height || 560); + const size = Math.min(220, Math.max(176, width * 0.18)); + return { + x: Math.round(width * 0.62 - size / 2), + y: Math.round(height * 0.46 - size / 2), + size + }; + } + + function assetPosition(asset, index, total) { + const bounds = dom.visualStage?.getBoundingClientRect(); + const width = Math.max(820, bounds?.width || 1040); + const height = Math.max(500, bounds?.height || 560); + const anchors = [ + { x: 0.83, y: 0.26 }, + { x: 0.84, y: 0.51 }, + { x: 0.82, y: 0.74 }, + { x: 0.72, y: 0.19 } + ]; + const anchor = anchors[index % anchors.length]; + const size = Math.min(116, Math.max(76, 64 + Math.min(asset.weight, 100) * 0.42)); + return { + x: Math.round(width * anchor.x - size / 2), + y: Math.round(height * anchor.y - size / 2), + size: Math.round(size), + scale: 1 + }; + } + function centrePoint(node, parentRect) { const rect = node.getBoundingClientRect(); return { @@ -378,6 +515,45 @@ }; } + function connectionPoint(node, parentRect, targetNode) { + const centre = centrePoint(node, parentRect); + const rect = node.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); + return { + x: centre.x + Math.cos(angle) * radius, + y: centre.y + Math.sin(angle) * radius + }; + } + + 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"; + if (stateText.includes("trust") || stateText.includes("ally") || stateText.includes("protect")) return "support"; + return relationship.weight >= 80 ? "strong" : "uncertain"; + } + + function inlineRelationshipLabel(relationship) { + const stateText = `${relationship.label} ${relationship.state}`.toLowerCase(); + if (stateText.includes("trust")) return "trust strengthened"; + if (stateText.includes("ally") || stateText.includes("alliance")) return "ally"; + if (stateText.includes("conflict") || stateText.includes("threat")) return "tension increasing"; + if (stateText.includes("suspect") || stateText.includes("question")) return "questions"; + if (stateText.includes("family") || stateText.includes("grief")) return "family"; + if (stateText.includes("motive")) return "motive exposed"; + return relationship.state || relationship.label; + } + + function assetConnectionLabel(asset) { + const value = `${asset.name} ${asset.label}`.toLowerCase(); + if (value.includes("letter")) return "evidence"; + if (value.includes("key")) return "access"; + if (value.includes("tr6") || value.includes("car")) return "blocks escape"; + if (value.includes("phone")) return "recorded"; + return "found here"; + } + function startTimer() { stopTimer(); if (!state.playing) return; @@ -441,6 +617,8 @@ window.addEventListener("resize", () => { const current = loadSceneState(state.index); reconcileCharacters(current); + reconcileLocation(current, current); + reconcileAssets(current); scheduleConnectionDraw(); }); diff --git a/docs/phases/Phase-21B.2-Story-Intelligence-Experience-Composition-Refinement.md b/docs/phases/Phase-21B.2-Story-Intelligence-Experience-Composition-Refinement.md new file mode 100644 index 0000000..ad32f5c --- /dev/null +++ b/docs/phases/Phase-21B.2-Story-Intelligence-Experience-Composition-Refinement.md @@ -0,0 +1,99 @@ +# Phase 21B.2 - Story Intelligence Experience Composition Refinement + +## Purpose + +Phase 21B.2 refines the development-only Story Intelligence Experience prototype so the Living Story View reads as one connected narrative map rather than separate character, location and asset regions. + +This phase remains visual-only. It does not connect real Story Intelligence data, add SignalR, alter the import workflow, expose production navigation, create SQL, or modify the database. + +## Composition Changes + +- The central map container border and background were greatly softened. +- Characters, the active location and significant assets now share a single visual canvas. +- The scene title was reduced so the map has more vertical room. +- Category labels remain only as faint positional guides. +- The background now carries a subtler star/network texture behind the entities. + +## Circular Location + +The active location is now rendered as a circular node with the same broad visual language as character and asset nodes: + +- Circular image mask +- Soft illuminated border +- Location name and secondary description below the node +- Stable `data-location-id` connection target + +The existing prototype SVG location artwork is still used and masked with `object-fit: cover`, so future curated images can replace it without changing the layout contract. + +## Circular Assets + +Assets in the main Living Story View now render as circular image-first nodes: + +- Circular image/avatar +- Label and state text underneath +- Size influenced by simulated narrative weight +- Stable `data-asset-id` connection targets + +The compact discovery feed indicators were left unchanged because they serve a different scanning purpose in the left panel. + +## Connection Lines + +The SVG connection layer now draws multiple current-scene connection categories: + +- Character relationship +- Character present at location +- Asset found at location +- Character knowledge or evidence connection to assets +- Asset-to-asset supporting evidence link + +Connections use curved paths and category-specific styling. Supportive, uncertain, conflict, presence, evidence and knowledge lines are distinguishable by colour and line style rather than colour alone. + +## Inline Relationship Labels + +The central map now renders one or two inline relationship labels per scene on selected character-to-character connections. These labels summarize the active relationship state, while the right-hand Relationship Changes panel continues to show the latest detected changes. + +Examples include: + +- `trust strengthened` +- `tension increasing` +- `family` +- `questions` +- `motive exposed` + +## Analysis Stage Progress + +A compact analysis-stage rail was added to the header. It advances with simulated scene state and uses live-analysis stage labels: + +- Reading +- Chapters +- Scenes +- Content +- Characters +- Locations +- Assets +- Relationships +- Knowledge +- Continuity +- Finalising + +The real Story Intelligence pipeline classes were inspected, but not changed. This rail is prototype-only and maps the existing simulated scene `stage` and `progressPercent` values onto a compact display. + +## Responsive Behaviour + +The main desktop target remains 1920 x 1080 through 1366 x 768. At narrower widths the stage rail can scroll horizontally and the existing stacked layout is retained. Full mobile optimisation remains deferred. + +## Limitations + +- The map positions are still authored prototype positions, not graph-solved layout. +- SVG connection labels are intentionally sparse to avoid turning the experience into a dense generic graph. +- Location and asset artwork is still prototype SVG artwork, not final curated imagery. +- No historical replay/event store exists yet. + +## Deferred + +- Real data binding +- Durable Story Intelligence snapshot contract +- Production route/navigation +- Final image library +- Mobile-specific composition pass +- Phase 21C real-data integration