Phase 21E.1 - Story Intelligence Visualisation Cleanup
This commit is contained in:
parent
f0b444e0ee
commit
35208591fd
@ -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 {
|
||||
|
||||
@ -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();
|
||||
})();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user