Phase 21G - Live Story Intelligence Visualisation Engine

This commit is contained in:
Nick Beckley 2026-07-12 15:46:27 +00:00
parent 88315070a2
commit 9989dfec1e
4 changed files with 540 additions and 60 deletions

View File

@ -157,6 +157,40 @@
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
</label>
</aside>
<details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary>
<dl>
<div>
<dt>Current Snapshot Version</dt>
<dd data-diagnostics-current>0</dd>
</div>
<div>
<dt>Previous Snapshot Version</dt>
<dd data-diagnostics-previous>0</dd>
</div>
<div>
<dt>Change Token</dt>
<dd data-diagnostics-token>@Model.ChangeToken</dd>
</div>
<div>
<dt>Animation Queue Length</dt>
<dd data-diagnostics-queue>0</dd>
</div>
<div>
<dt>Last Refresh Time</dt>
<dd data-diagnostics-refresh>Initial</dd>
</div>
<div>
<dt>Snapshot Build Time</dt>
<dd data-diagnostics-build>0ms</dd>
</div>
<div>
<dt>Operations detected</dt>
<dd data-diagnostics-operations>InitialRender</dd>
</div>
</dl>
</details>
</main>
<script src="~/js/story-intelligence-experience-prototype.js" asp-append-version="true"></script>

View File

@ -132,6 +132,7 @@ input:focus-visible {
font-size: 0.62rem;
font-weight: 700;
min-width: 0;
transition: opacity 420ms ease, transform 560ms cubic-bezier(.16, .84, .28, 1), color 420ms ease;
}
.story-exp-pipeline-step span {
@ -851,7 +852,7 @@ input:focus-visible {
.story-location.is-changing {
opacity: 0.35;
transform: scale(0.98);
transform: translate(var(--x, 0), var(--y, 0)) scale(0.98);
}
.story-location__image {
@ -997,6 +998,10 @@ input:focus-visible {
transition: transform 840ms cubic-bezier(.16, .84, .28, 1), opacity 760ms ease, background 760ms ease, border-color 760ms ease, box-shadow 760ms ease;
}
.story-scene-card.is-complete {
opacity: 0.82;
}
.story-scene-card.is-current {
flex-basis: 194px;
border-color: rgba(215, 168, 93, 0.64);
@ -1048,6 +1053,7 @@ input:focus-visible {
font-size: 0.73rem;
font-weight: 700;
text-align: center;
transition: opacity 560ms ease, transform 720ms cubic-bezier(.16, .84, .28, 1);
}
.story-timeline-marker::before {
@ -1084,6 +1090,11 @@ input:focus-visible {
transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1);
}
.story-exp-list li,
.story-exp-feed li {
transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1);
}
.story-exp-insight strong {
color: var(--story-text);
font-size: 0.92rem;
@ -1136,6 +1147,85 @@ input:focus-visible {
width: 104px;
}
.story-exp-diagnostics {
position: fixed;
left: 24px;
bottom: 18px;
z-index: 11;
width: min(420px, calc(100vw - 48px));
border: 1px solid rgba(151, 185, 218, 0.16);
border-radius: 16px;
background: rgba(4, 10, 20, 0.84);
box-shadow: var(--story-shadow);
color: var(--story-soft);
backdrop-filter: blur(16px);
}
.story-exp-diagnostics summary {
cursor: pointer;
padding: 10px 13px;
color: var(--story-text);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.story-exp-diagnostics dl {
display: grid;
gap: 1px;
max-height: 280px;
overflow: auto;
margin: 0;
padding: 0 10px 10px;
}
.story-exp-diagnostics div {
display: grid;
grid-template-columns: 1fr 1.25fr;
gap: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
padding: 8px 0;
}
.story-exp-diagnostics dt {
color: var(--story-muted);
font-size: 0.68rem;
font-weight: 800;
}
.story-exp-diagnostics dd {
margin: 0;
overflow-wrap: anywhere;
color: var(--story-soft);
font-size: 0.72rem;
}
.is-entering {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
.story-character.is-entering,
.story-asset.is-entering {
opacity: 0;
transform: translate(var(--x, 0), var(--y, 0)) scale(0.82);
}
.story-location.is-entering {
opacity: 0;
transform: translate(var(--x, 0), var(--y, 0)) scale(0.94);
}
.story-exp-connection-group.is-entering .story-exp-connection {
stroke-dashoffset: 1;
}
.is-removing {
opacity: 0 !important;
transform: translateY(12px) scale(0.96) !important;
}
@keyframes drawLine {
to {
stroke-dashoffset: 0;

View File

@ -21,7 +21,15 @@
pollTimer: null,
polling: false,
changeToken: model.changeToken || root.dataset.changeToken || "",
terminal: model.isTerminal || root.dataset.terminal === "true"
terminal: model.isTerminal || root.dataset.terminal === "true",
currentSnapshot: model,
previousSnapshot: null,
currentVersion: 1,
previousVersion: 0,
operationQueue: [],
lastOperations: ["InitialRender"],
snapshotBuildMs: 0,
lastRefreshTime: "Initial"
};
const analysisStages = [
{ key: "reading", label: "Reading" },
@ -63,17 +71,26 @@
knowledge: root.querySelector("[data-knowledge]"),
observations: root.querySelector("[data-observations]"),
play: root.querySelector("[data-control='play']"),
speed: root.querySelector("[data-speed-control]")
speed: root.querySelector("[data-speed-control]"),
diagnostics: root.querySelector("[data-diagnostics]"),
diagnosticsCurrent: root.querySelector("[data-diagnostics-current]"),
diagnosticsPrevious: root.querySelector("[data-diagnostics-previous]"),
diagnosticsToken: root.querySelector("[data-diagnostics-token]"),
diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"),
diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
diagnosticsOperations: root.querySelector("[data-diagnostics-operations]")
};
function loadSceneState(index) {
return scenes[Math.max(0, Math.min(scenes.length - 1, index))];
}
function transitionToNextState(nextIndex) {
function transitionToNextState(nextIndex, operations) {
const previous = loadSceneState(state.index);
state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
const current = loadSceneState(state.index);
queueOperations(operations || compareScenes(previous, current));
preloadSceneImages(current);
preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1)));
logImageDiagnostics(current);
@ -91,14 +108,27 @@
updateTimeline(current);
updateFeeds(current);
scheduleConnectionDraw();
updateDiagnostics();
}
function replaceModel(nextModel) {
function replaceModel(nextModel, snapshotBuildMs) {
if (!nextModel) return;
const operations = compareSnapshots(state.currentSnapshot, nextModel);
if (!operations.length && !nextModel.isTerminal) return;
state.previousSnapshot = state.currentSnapshot;
state.currentSnapshot = nextModel;
state.previousVersion = state.currentVersion;
state.currentVersion += 1;
state.operationQueue = operations;
state.lastOperations = operations.map((operation) => operation.type);
state.snapshotBuildMs = snapshotBuildMs || 0;
state.lastRefreshTime = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
model = nextModel || model;
scenes = model.scenes || [];
state.changeToken = model.changeToken || state.changeToken;
state.terminal = !!model.isTerminal;
state.index = Math.min(state.index, Math.max(0, scenes.length - 1));
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1));
if (model.projectName) {
const project = root.querySelector(".story-exp-context span:first-child");
text(project, model.projectName);
@ -107,15 +137,15 @@
const book = root.querySelector(".story-exp-context span:nth-child(2)");
text(book, model.bookTitle);
}
transitionToNextState(state.index);
transitionToNextState(state.index, operations);
if (state.terminal) stopPolling();
}
function updateProgress(scene) {
text(dom.stageLabel, scene.stage);
text(dom.currentScene, scene.sceneNumber);
text(dom.progressPercent, `${scene.progressPercent}%`);
text(dom.elapsedTime, `${Math.max(1, Math.round(scene.progressPercent / 9))}m`);
animateNumber(dom.progressPercent, scene.progressPercent, "%");
animateNumber(dom.elapsedTime, Math.max(1, Math.round(scene.progressPercent / 9)), "m");
text(dom.estimatedRemaining, scene.estimatedRemaining);
text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`);
text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`);
@ -126,17 +156,23 @@
function updateAnalysisStages(scene) {
if (!dom.stageRail) return;
const currentIndex = stageIndex(scene);
dom.stageRail.innerHTML = "";
const activeKeys = new Set(analysisStages.map((stage) => stage.key));
analysisStages.forEach((stage, index) => {
const node = document.createElement("li");
node.className = "story-exp-pipeline-step";
let node = dom.stageRail.querySelector(`[data-stage-key="${stage.key}"]`);
if (!node) {
node = document.createElement("li");
node.className = "story-exp-pipeline-step is-entering";
node.dataset.stageKey = stage.key;
node.innerHTML = "<span></span><strong></strong>";
dom.stageRail.append(node);
settleEntering(node);
}
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 = "<span></span><strong></strong>";
text(node.querySelector("strong"), stage.label);
dom.stageRail.append(node);
});
removeInactive(dom.stageRail, "[data-stage-key]", "stageKey", activeKeys);
}
function stageIndex(scene) {
@ -228,7 +264,9 @@
let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (!node) {
node = createCharacterNode(character);
node.classList.add("is-entering");
dom.characters.append(node);
settleEntering(node);
}
const position = layout.characters.get(character.id) || characterPosition(character, index, scene.povCharacterId, scene.characters);
@ -264,32 +302,35 @@
function reconcileLocation(scene, previous) {
const changed = previous?.location?.id !== scene.location.id;
if (changed) {
dom.location.firstElementChild?.classList.add("is-changing");
}
window.setTimeout(() => {
dom.location.innerHTML = "";
const node = document.createElement("article");
let node = dom.location.querySelector("[data-location-id]");
if (!node) {
node = document.createElement("article");
node.className = "story-location";
node.dataset.locationId = scene.location.id;
node.innerHTML = `
<div class="story-location__image"><img alt="${escapeHtml(scene.location.name)} prototype location" /></div>
<div class="story-location__text">
<h2></h2>
<p></p>
</div>`;
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`);
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"), conciseLocationLabel(scene.location));
node.title = `${scene.location.name}: ${scene.location.label}`;
dom.location.append(node);
scheduleConnectionDraw();
}, changed && !reducedMotion ? 260 : 0);
node.classList.add("is-entering");
settleEntering(node);
}
if (changed) {
node.classList.add("is-changing");
window.setTimeout(() => node.classList.remove("is-changing"), reducedMotion ? 0 : 420);
}
node.dataset.locationId = scene.location.id;
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`);
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"), conciseLocationLabel(scene.location));
node.title = `${scene.location.name}: ${scene.location.label}`;
}
function reconcileAssets(scene, previous, layout) {
@ -298,7 +339,9 @@
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!node) {
node = createAssetNode(asset);
node.classList.add("is-entering");
dom.assets.append(node);
settleEntering(node);
}
const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length);
@ -344,30 +387,44 @@
const currentIndex = state.index;
const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3));
const visible = scenes.slice(start, start + 7);
dom.ribbon.innerHTML = "";
const activeIds = new Set(visible.map((item) => item.id));
visible.forEach((item) => {
const card = document.createElement("article");
card.className = "story-scene-card";
let card = dom.ribbon.querySelector(`[data-scene-id="${cssEscape(item.id)}"]`);
if (!card) {
card = document.createElement("article");
card.className = "story-scene-card is-entering";
card.dataset.sceneId = item.id;
card.innerHTML = `<span></span><strong></strong><small></small>`;
settleEntering(card);
}
card.classList.toggle("is-current", item.id === scene.id);
card.innerHTML = `<span>Scene ${item.sceneNumber}</span><strong></strong><small></small>`;
text(card.querySelector("strong"), item.title);
card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
text(card.querySelector("span"), `Scene ${item.sceneNumber}`);
text(card.querySelector("strong"), compactPhrase(item.title, item.id === scene.id ? 42 : 28));
text(card.querySelector("small"), item.timelineEvent);
dom.ribbon.append(card);
});
removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
}
function updateTimeline(scene) {
const completed = scenes.slice(0, state.index + 1);
dom.timeline.style.setProperty("--timeline-count", String(completed.length));
dom.timeline.innerHTML = "";
const activeIds = new Set(completed.map((item) => item.id));
completed.forEach((item) => {
const marker = document.createElement("div");
marker.className = "story-timeline-marker";
let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`);
if (!marker) {
marker = document.createElement("div");
marker.className = "story-timeline-marker is-entering";
marker.dataset.timelineId = item.id;
marker.innerHTML = `<span></span>`;
settleEntering(marker);
}
marker.classList.toggle("is-current", item.id === scene.id);
marker.innerHTML = `<span></span>`;
text(marker.querySelector("span"), `${item.timelineLabel} - S${item.sceneNumber}`);
dom.timeline.append(marker);
});
removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds);
}
function updateFeeds(scene) {
@ -381,10 +438,11 @@
let node = container.querySelector(`[data-insight-id="${cssEscape(item.id)}"]`);
if (!node) {
node = document.createElement("article");
node.className = "story-exp-insight";
node.className = "story-exp-insight is-entering";
node.dataset.insightId = item.id;
node.innerHTML = `<span></span><strong></strong><p></p>`;
container.append(node);
settleEntering(node);
}
node.dataset.tone = item.tone || "";
@ -403,9 +461,16 @@
}
function renderList(container, items) {
container.innerHTML = "";
items.forEach((item) => {
const node = document.createElement(container.tagName === "OL" ? "li" : "li");
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
items.forEach((item, index) => {
const id = feedId(item, index);
let node = container.querySelector(`[data-feed-id="${cssEscape(id)}"]`);
if (!node) {
node = document.createElement("li");
node.dataset.feedId = id;
node.className = "is-entering";
settleEntering(node);
}
if (container === dom.discoveries) {
node.dataset.discoveryType = discoveryType(item);
node.innerHTML = "<span aria-hidden=\"true\"></span><p></p>";
@ -415,6 +480,11 @@
}
container.append(node);
});
removeInactive(container, "[data-feed-id]", "feedId", activeIds);
}
function feedId(item, index) {
return `${index}-${stableHash(item)}`;
}
function discoveryType(item) {
@ -440,8 +510,8 @@
function drawConnections(scene) {
if (!dom.connections || !dom.visualStage) return;
const stageRect = dom.visualStage.getBoundingClientRect();
dom.connections.innerHTML = "";
const drawn = [];
const activeIds = new Set();
const occupiedRects = layoutRects(stageRect);
scene.relationships.forEach((relationship, index) => {
@ -452,13 +522,17 @@
const start = connectionPoint(source, stageRect, target);
const end = connectionPoint(target, stageRect, source);
const obstacles = connectionObstacles(stageRect, source, target);
drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects));
const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`;
activeIds.add(id);
drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, 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", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
const id = `presence-${scene.povCharacterId}-${location.dataset.locationId}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
}
scene.characters
@ -467,34 +541,51 @@
.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.length, connectionObstacles(stageRect, node, location), occupiedRects));
const id = `presence-soft-${character.id}-${location.dataset.locationId}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), 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.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
const id = `asset-${location.dataset.locationId}-${asset.id}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
});
removeInactiveSvgConnections(activeIds);
}
function drawConnection(start, end, tone, weight, label, index, obstacles, occupiedRects) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
function drawConnection(id, start, end, tone, weight, label, index, obstacles, occupiedRects) {
let group = dom.connections.querySelector(`[data-connection-id="${cssEscape(id)}"]`);
const isNew = !group;
if (!group) {
group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.dataset.connectionId = id;
group.classList.add("is-entering");
dom.connections.append(group);
settleEntering(group);
}
group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
let path = group.querySelector("path");
if (!path) {
path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
group.append(path);
}
const route = routeConnection(start, end, index, obstacles);
const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`;
path.setAttribute("d", d);
path.setAttribute("pathLength", "1");
path.setAttribute("class", "story-exp-connection");
path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
group.append(path);
group.querySelector(".story-exp-connection-label-group")?.remove();
if (label) {
const labelPosition = relationshipLabelPosition(start, route, end, occupiedRects);
if (!labelPosition) {
dom.connections.append(group);
return group;
}
const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
@ -516,7 +607,7 @@
window.requestAnimationFrame(() => fitConnectionLabelBackground(labelGroup));
}
dom.connections.append(group);
if (isNew) group.classList.add("is-entering");
return group;
}
@ -1003,15 +1094,17 @@
async function pollSnapshot() {
if (state.polling || document.hidden || state.terminal) return;
state.polling = true;
const requestStarted = performance.now();
try {
const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
if (!response.ok) return;
const snapshot = await response.json();
if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
replaceModel(snapshot);
replaceModel(snapshot, Math.round(performance.now() - requestStarted));
} else if (snapshot.isTerminal) {
state.terminal = true;
stopPolling();
updateDiagnostics();
}
} catch {
// Development snapshot polling is best-effort.
@ -1020,6 +1113,177 @@
}
}
function compareSnapshots(previousSnapshot, nextSnapshot) {
if (!previousSnapshot) return [{ type: "InitialRender" }];
const operations = [];
const previousScene = activeScene(previousSnapshot);
const nextScene = activeScene(nextSnapshot);
if ((previousSnapshot.changeToken || "") !== (nextSnapshot.changeToken || "")) {
operations.push({ type: "SnapshotChanged" });
}
if ((previousSnapshot.runStatus || "") !== (nextSnapshot.runStatus || "")) {
operations.push({ type: "StageChanged" });
}
if (!!previousSnapshot.isTerminal !== !!nextSnapshot.isTerminal && nextSnapshot.isTerminal) {
operations.push({ type: "RunCompleted" });
}
if (!previousScene || !nextScene) return operations;
operations.push(...compareScenes(previousScene, nextScene));
return compactOperations(operations);
}
function compareScenes(previousScene, nextScene) {
if (!previousScene || !nextScene) return [{ type: "SceneChanged" }];
const operations = [];
if (previousScene.progressPercent !== nextScene.progressPercent) operations.push({ type: "ProgressChanged" });
if (previousScene.stage !== nextScene.stage) operations.push({ type: "StageChanged" });
if (previousScene.id !== nextScene.id || previousScene.sceneNumber !== nextScene.sceneNumber) operations.push({ type: "SceneChanged" });
if ((previousScene.scenesAnalysed || 0) !== (nextScene.scenesAnalysed || 0)) operations.push({ type: "TimelineAdvanced" });
if (previousScene.location?.id !== nextScene.location?.id) operations.push({ type: "LocationChanged" });
compareEntityLists(previousScene.characters, nextScene.characters, "Character", operations, (before, after) => {
if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "CharacterImportanceChanged", id: after.id });
if ((before.imagePath || "") !== (after.imagePath || "")) operations.push({ type: "CharacterImageChanged", id: after.id });
});
compareEntityLists(previousScene.assets, nextScene.assets, "Asset", operations, (before, after) => {
if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "AssetImportanceChanged", id: after.id });
});
compareEntityLists(previousScene.relationships, nextScene.relationships, "Relationship", operations, (before, after) => {
if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "RelationshipStrengthChanged", id: after.id });
});
compareTextItems(previousScene.knowledgeThreads, nextScene.knowledgeThreads, "KnowledgeThread", operations);
compareTextItems(previousScene.observations, nextScene.observations, "Observation", operations);
compareStrings(previousScene.latestDiscoveries, nextScene.latestDiscoveries, "DiscoveryAdded", operations);
return compactOperations(operations);
}
function compareEntityLists(previousItems, nextItems, prefix, operations, comparePersisted) {
const previousById = keyedById(previousItems);
const nextById = keyedById(nextItems);
nextById.forEach((item, id) => {
if (!previousById.has(id)) {
operations.push({ type: `${prefix}Added`, id });
} else {
comparePersisted(previousById.get(id), item);
}
});
previousById.forEach((item, id) => {
if (!nextById.has(id)) operations.push({ type: `${prefix}Removed`, id });
});
}
function compareTextItems(previousItems, nextItems, prefix, operations) {
const previousById = keyedById(previousItems);
const nextById = keyedById(nextItems);
nextById.forEach((item, id) => {
if (!previousById.has(id)) operations.push({ type: `${prefix}Added`, id });
});
previousById.forEach((item, id) => {
if (!nextById.has(id)) operations.push({ type: prefix === "KnowledgeThread" ? "KnowledgeThreadResolved" : `${prefix}Removed`, id });
});
}
function compareStrings(previousItems, nextItems, type, operations) {
const previousSet = new Set(previousItems || []);
(nextItems || []).forEach((item) => {
if (!previousSet.has(item)) operations.push({ type, id: stableHash(item) });
});
}
function activeScene(snapshot) {
const snapshotScenes = snapshot?.scenes || [];
return snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null;
}
function keyedById(items) {
return new Map((items || []).filter((item) => item?.id).map((item) => [item.id, item]));
}
function compactOperations(operations) {
const seen = new Set();
return operations.filter((operation) => {
const key = `${operation.type}:${operation.id || ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function queueOperations(operations) {
const nextOperations = operations?.length ? operations : [{ type: "NoVisualChange" }];
state.operationQueue = nextOperations;
state.lastOperations = nextOperations.map((operation) => operation.type);
}
function updateDiagnostics() {
if (!dom.diagnostics) return;
text(dom.diagnosticsCurrent, state.currentVersion);
text(dom.diagnosticsPrevious, state.previousVersion);
text(dom.diagnosticsToken, state.changeToken || "none");
text(dom.diagnosticsQueue, state.operationQueue.length);
text(dom.diagnosticsRefresh, state.lastRefreshTime);
text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`);
text(dom.diagnosticsOperations, state.lastOperations.join(", "));
}
function animateNumber(node, nextValue, suffix) {
if (!node) return;
const previousValue = Number.parseInt(node.textContent, 10);
if (reducedMotion || Number.isNaN(previousValue)) {
text(node, `${nextValue}${suffix}`);
return;
}
const start = performance.now();
const duration = 720;
const delta = nextValue - previousValue;
if (delta === 0) {
text(node, `${nextValue}${suffix}`);
return;
}
const tick = (time) => {
const progress = clamp((time - start) / duration, 0, 1);
const eased = 1 - (1 - progress) ** 3;
text(node, `${Math.round(previousValue + delta * eased)}${suffix}`);
if (progress < 1) window.requestAnimationFrame(tick);
};
window.requestAnimationFrame(tick);
}
function removeInactive(container, selector, datasetKey, activeIds) {
container.querySelectorAll(selector).forEach((node) => {
if (activeIds.has(node.dataset[datasetKey])) return;
node.classList.add("is-removing");
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 420);
});
}
function removeInactiveSvgConnections(activeIds) {
dom.connections.querySelectorAll("[data-connection-id]").forEach((node) => {
if (activeIds.has(node.dataset.connectionId)) return;
node.classList.add("is-removing");
window.setTimeout(() => node.remove(), reducedMotion ? 0 : 420);
});
}
function settleEntering(node) {
if (reducedMotion) {
node.classList.remove("is-entering");
return;
}
window.requestAnimationFrame(() => node.classList.remove("is-entering"));
}
function stableHash(value) {
const textValue = String(value || "");
let hash = 0;
for (let index = 0; index < textValue.length; index += 1) {
hash = ((hash << 5) - hash + textValue.charCodeAt(index)) | 0;
}
return Math.abs(hash).toString(36);
}
function text(node, value) {
if (node) node.textContent = value ?? "";
}
@ -1095,7 +1359,7 @@
observer.observe(dom.visualStage);
}
transitionToNextState(0);
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
startTimer();
startPolling();
})();

View File

@ -0,0 +1,92 @@
# Phase 21G - Live Story Intelligence Visualisation Engine
## Purpose
Phase 21G changes the Story Intelligence Experience from a periodically refreshed view into a live visualisation that reconciles incoming snapshots in the browser. It does not change the Story Intelligence pipeline, Illustration Library, prototype layout, prompts, data schema, or approval workflow.
## Animation Architecture
The browser now follows a small live rendering pipeline:
1. Snapshot loader polls the existing visualisation snapshot endpoint.
2. Snapshot comparator compares the current snapshot with the next snapshot.
3. Visual operation queue records the detected changes.
4. Renderer updates keyed DOM elements and SVG connections in place.
5. CSS transitions animate changed properties, inserted elements, and removed elements.
Polling still uses the Phase 21F endpoint and still ignores unchanged change tokens. When a new token arrives, the page preserves the previous snapshot, stores the next snapshot, derives visual operations, and then updates only the affected visual surface.
## Snapshot Reconciliation
The comparator detects operations including:
- `ProgressChanged`
- `StageChanged`
- `SceneChanged`
- `CharacterAdded`
- `CharacterRemoved`
- `CharacterImportanceChanged`
- `CharacterImageChanged`
- `LocationChanged`
- `AssetAdded`
- `AssetRemoved`
- `AssetImportanceChanged`
- `RelationshipAdded`
- `RelationshipRemoved`
- `RelationshipStrengthChanged`
- `KnowledgeThreadAdded`
- `KnowledgeThreadResolved`
- `ObservationAdded`
- `ObservationRemoved`
- `TimelineAdvanced`
- `DiscoveryAdded`
- `RunCompleted`
The current implementation compares the active scene represented by the latest scene in each snapshot, plus run status, terminal state, and progress metadata.
## DOM Reuse Strategy
Stable IDs are used as reconciliation keys for:
- Characters
- Assets
- Relationship insight cards
- Knowledge cards
- Observation cards
- Scene ribbon cards
- Timeline markers
- Latest discoveries
- Live activity items
- SVG connection groups
Matching nodes are retained and have their text, image, class, position, and path attributes updated. New nodes enter with `is-entering`; removed nodes receive `is-removing` and are deleted after their exit animation.
## Performance Notes
The renderer avoids emptying large panels on every snapshot. It reuses images, entity cards, list rows, scene cards, timeline markers, and SVG paths where stable IDs match. Connection drawing remains scheduled through `requestAnimationFrame`, and polling remains paused while the tab is hidden.
Progress, elapsed time, and the progress ring animate rather than jumping directly to the next value. Reduced-motion preferences are respected by shortening transition timing and disabling animated number interpolation.
## Development Diagnostics
The development Story Intelligence Experience page now includes a collapsible diagnostics panel showing:
- Current snapshot version
- Previous snapshot version
- Change token
- Animation queue length
- Last refresh time
- Snapshot fetch/build time from the browser perspective
- Operations detected
The page remains guarded by the existing Development controller environment check.
## Known Limitations
The operation model is currently client-side only and is derived from snapshot comparison. It does not yet receive event-level updates from the analysis worker. Relationship SVG paths are reused by connection ID, but their labels are still re-laid out on each draw to preserve collision avoidance.
The active-scene strategy follows the latest scene in the snapshot. If future phases expose a richer run cursor, the comparator should use that cursor instead.
## Future SignalR Migration Notes
Phase 21H can migrate snapshot delivery from polling to SignalR without changing the reconciliation model. SignalR messages should carry either a full next snapshot or a server-side change token that instructs the browser to fetch the snapshot endpoint. The existing comparator can remain the boundary between incoming data and visual animation.