From 1f984c8e1e09d5ce81e38e20d1db2e719f4ba376 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 12 Jul 2026 14:54:03 +0000 Subject: [PATCH] Phase 21E.2 - Prototype Visual Polish --- ...tory-intelligence-experience-prototype.css | 75 ++++---- ...story-intelligence-experience-prototype.js | 181 +++++++++++++----- 2 files changed, 174 insertions(+), 82 deletions(-) diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 8c2f130..ff61c5e 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -496,33 +496,36 @@ input:focus-visible { .story-exp-stage { display: grid; - grid-template-rows: auto minmax(0, 1fr) 158px; - gap: 10px; + grid-template-rows: auto minmax(0, 1fr) 154px; + gap: 8px; min-height: 0; } .story-exp-stage-copy { display: grid; - grid-template-columns: 1fr minmax(240px, 0.72fr); - align-items: end; - gap: 22px; - min-height: 62px; + grid-template-columns: minmax(260px, 0.64fr) 1fr; + align-items: center; + gap: 18px; + min-height: 42px; } .story-exp-stage-copy h1 { grid-column: 1; margin: 0; - font-size: clamp(1.55rem, 2.35vw, 2.75rem); - line-height: 1.05; + overflow: hidden; + font-size: clamp(1rem, 1.2vw, 1.34rem); + line-height: 1.12; letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; } .story-exp-stage-copy p:last-child { grid-column: 2; margin: 0; color: var(--story-soft); - font-size: clamp(0.86rem, 1vw, 1rem); - line-height: 1.38; + font-size: clamp(0.78rem, 0.86vw, 0.92rem); + line-height: 1.28; } .story-exp-visual { @@ -565,51 +568,57 @@ input:focus-visible { } .story-exp-connection { - stroke: rgba(215, 168, 93, 0.5); + stroke: rgba(215, 168, 93, 0.48); stroke-linecap: round; stroke-width: 1.8; stroke-dasharray: 1; stroke-dashoffset: 1; fill: none; - filter: drop-shadow(0 0 7px rgba(215, 168, 93, 0.22)); - animation: drawLine 950ms cubic-bezier(.16, .84, .28, 1) forwards; + filter: drop-shadow(0 0 5px rgba(215, 168, 93, 0.18)); + animation: drawLine 760ms 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); + stroke: rgba(125, 193, 255, 0.52); } .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; + stroke-dasharray: 0.16 0.12; } .story-exp-connection-group--support .story-exp-connection { - stroke: rgba(121, 210, 166, 0.64); + stroke: rgba(121, 210, 166, 0.58); } .story-exp-connection-group--conflict .story-exp-connection { - stroke: rgba(227, 140, 168, 0.76); - stroke-dasharray: 0.18 0.1; + stroke: rgba(227, 140, 168, 0.7); + stroke-dasharray: 0.2 0.12; } .story-exp-connection-group--uncertain .story-exp-connection { - stroke: rgba(182, 162, 255, 0.56); - stroke-dasharray: 0.08 0.09; + stroke: rgba(182, 162, 255, 0.48); + stroke-dasharray: 0.12 0.11; } .story-exp-connection-group--knowledge .story-exp-connection { - stroke: rgba(182, 162, 255, 0.5); + stroke: rgba(182, 162, 255, 0.44); +} + +.story-exp-connection-label-bg { + fill: rgba(4, 10, 20, 0.72); + stroke: rgba(151, 185, 218, 0.12); + stroke-width: 1px; } .story-exp-connection-label { paint-order: stroke; - stroke: rgba(4, 10, 20, 0.92); - stroke-width: 6px; + stroke: rgba(4, 10, 20, 0.68); + stroke-width: 3px; fill: var(--story-soft); - font-size: 0.68rem; + font-size: 0.66rem; font-weight: 800; letter-spacing: 0.01em; text-transform: lowercase; @@ -857,8 +866,8 @@ input:focus-visible { position: absolute; display: grid; justify-items: center; - gap: 7px; - width: max(var(--node-size, 86px), 112px); + gap: 6px; + width: max(var(--node-size, 78px), 104px); opacity: var(--opacity, 1); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); z-index: var(--z, 3); @@ -867,21 +876,21 @@ input:focus-visible { } .story-asset__image { - width: var(--node-size, 86px); - height: var(--node-size, 86px); + width: var(--node-size, 78px); + height: var(--node-size, 78px); 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); + box-shadow: 0 0 0 4px rgba(215, 168, 93, 0.08), 0 18px 38px rgba(0, 0, 0, 0.28), 0 0 24px rgba(215, 168, 93, 0.13); } .story-asset__text { display: grid; justify-items: center; gap: 2px; - width: min(148px, calc(var(--node-size, 86px) + 52px)); + width: min(140px, calc(var(--node-size, 78px) + 52px)); border: 1px solid rgba(215, 168, 93, 0.11); border-radius: 10px; - padding: 5px 7px; + padding: 4px 7px 5px; background: rgba(4, 10, 20, 0.6); box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22); backdrop-filter: blur(8px); @@ -899,12 +908,12 @@ input:focus-visible { } .story-asset strong { - font-size: 0.82rem; + font-size: 0.78rem; } .story-asset span { color: var(--story-muted); - font-size: 0.68rem; + font-size: 0.64rem; } .story-exp-ribbon-wrap { diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 5555bab..4b3be77 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -131,7 +131,7 @@ } function updateSceneCopy(scene) { - text(dom.sceneTitle, scene.title); + text(dom.sceneTitle, `Scene ${scene.sceneNumber} • ${scene.title}`); text(dom.sceneSummary, scene.summary); } @@ -418,7 +418,7 @@ const stageRect = dom.visualStage.getBoundingClientRect(); dom.connections.innerHTML = ""; const drawn = []; - const occupiedRects = readableRects(stageRect); + const occupiedRects = layoutRects(stageRect); scene.relationships.forEach((relationship, index) => { const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`); @@ -427,65 +427,69 @@ const start = connectionPoint(source, stageRect, target); const end = connectionPoint(target, stageRect, source); - drawn.push(drawConnection(start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, occupiedRects)); + const obstacles = connectionObstacles(stageRect, source, target); + drawn.push(drawConnection(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", 72, "", 0, occupiedRects)); + drawn.push(drawConnection(connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects)); } scene.characters .filter((character) => character.id !== scene.povCharacterId && character.weight >= 65) - .slice(0, 2) + .slice(0, 1) .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, occupiedRects)); + drawn.push(drawConnection(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, occupiedRects)); - if (pov && index < 2) { - drawn.push(drawConnection(connectionPoint(pov, stageRect, assetNode), connectionPoint(assetNode, stageRect, pov), "knowledge", Math.max(45, asset.weight - 18), "", drawn.length, occupiedRects)); - } + 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)); }); - - 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, "", drawn.length, occupiedRects)); - } - } } - function drawConnection(start, end, tone, weight, label, index, occupiedRects) { + function drawConnection(start, end, tone, weight, label, index, obstacles, 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 route = routeConnection(start, end, index, occupiedRects); - const d = `M ${start.x} ${start.y} Q ${route.control.x} ${route.control.y}, ${end.x} ${end.y}`; + 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(1.1 + Math.min(weight, 100) / 56); + path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62); group.append(path); if (label) { - const point = pointOnQuadratic(start, route.control, end, 0.52); + 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"); + labelGroup.setAttribute("class", "story-exp-connection-label-group"); const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text"); labelNode.setAttribute("class", "story-exp-connection-label"); - labelNode.setAttribute("x", String(point.x + route.labelOffset.x)); - labelNode.setAttribute("y", String(point.y + route.labelOffset.y)); + labelNode.setAttribute("x", String(labelPosition.x)); + labelNode.setAttribute("y", String(labelPosition.y)); labelNode.setAttribute("text-anchor", "middle"); labelNode.textContent = label; - group.append(labelNode); + labelGroup.append(labelNode); + group.append(labelGroup); + occupiedRects.push({ + left: labelPosition.x - 78, + right: labelPosition.x + 78, + top: labelPosition.y - 22, + bottom: labelPosition.y + 10 + }); + window.requestAnimationFrame(() => fitConnectionLabelBackground(labelGroup)); } dom.connections.append(group); @@ -651,7 +655,7 @@ { 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)); + const size = Math.min(104, Math.max(68, (64 + Math.min(asset.weight, 100) * 0.42) * 0.9)); return { x: Math.round(width * anchor.x - size / 2), y: Math.round(height * anchor.y - size / 2), @@ -676,8 +680,8 @@ 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))); + ? Math.min(104, Math.max(83, (70 + Math.min(asset.weight, 100) * 0.42) * 0.9)) + : Math.min(88, Math.max(68, (58 + Math.min(asset.weight, 100) * 0.32) * 0.9))); const desired = { x: width * anchor.x - size / 2, y: height * anchor.y - size / 2 @@ -741,7 +745,7 @@ const target = targetNode ? centrePoint(targetNode, parentRect) : centre; 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 radius = Math.min(rect.width, rect.height) * 0.5; const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x); return { x: centre.x + Math.cos(angle) * radius, @@ -749,43 +753,67 @@ }; } - function routeConnection(start, end, index, occupiedRects) { + function routeConnection(start, end, index, obstacles) { 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 tangent = { x: dx / length, y: dy / length }; + const base = Math.max(34, Math.min(122, length * 0.2)); const direction = index % 2 === 0 ? 1 : -1; - const offsets = [direction * base, -direction * base, direction * base * 1.55, -direction * base * 1.55, 0]; + const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 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); + const controls = cubicControls(start, end, tangent, normal, offset, length); + const samples = [0.18, 0.33, 0.5, 0.67, 0.82].map((t) => pointOnCubic(start, controls.startControl, controls.endControl, end, t)); + const score = samples.reduce((total, point) => total + obstacles.reduce((sum, rect) => sum + pointRectPenalty(point, rect), 0), Math.abs(offset) * 0.04); if (score < bestScore) { bestScore = score; best = offset; } }); + return cubicControls(start, end, tangent, normal, best, length); + } + + function cubicControls(start, end, tangent, normal, offset, length) { + const pull = Math.max(52, Math.min(150, length * 0.34)); 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 } + startControl: { + x: start.x + tangent.x * pull + normal.x * offset, + y: start.y + tangent.y * pull + normal.y * offset + }, + endControl: { + x: end.x - tangent.x * pull + normal.x * offset, + y: end.y - tangent.y * pull + normal.y * offset + }, + normal, + offset }; } - 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 layoutRects(parentRect) { + return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")] + .map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 10)); + } + + function connectionObstacles(parentRect, source, target) { + const sourceImage = source.querySelector(".story-character__image, .story-location__image, .story-asset__image") || source; + const targetImage = target.querySelector(".story-character__image, .story-location__image, .story-asset__image") || target; + return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")] + .filter((node) => node !== sourceImage && node !== targetImage) + .map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12)); + } + + function rectRelativeTo(node, parentRect, padding) { + const rect = node.getBoundingClientRect(); + return { + left: rect.left - parentRect.left - padding, + right: rect.right - parentRect.left + padding, + top: rect.top - parentRect.top - padding, + bottom: rect.bottom - parentRect.top + padding + }; } function pointOnQuadratic(start, control, end, t) { @@ -796,6 +824,61 @@ }; } + function pointOnCubic(start, startControl, endControl, end, t) { + const inverse = 1 - t; + return { + x: inverse ** 3 * start.x + 3 * inverse ** 2 * t * startControl.x + 3 * inverse * t ** 2 * endControl.x + t ** 3 * end.x, + y: inverse ** 3 * start.y + 3 * inverse ** 2 * t * startControl.y + 3 * inverse * t ** 2 * endControl.y + t ** 3 * end.y + }; + } + + function relationshipLabelPosition(start, route, end, occupiedRects) { + const sign = Math.sign(route.offset || 1); + const candidates = [0.42, 0.5, 0.58, 0.34, 0.66].flatMap((t) => { + const point = pointOnCubic(start, route.startControl, route.endControl, end, t); + return [34, 54, 76, -42, -64].map((distance) => ({ + x: point.x + route.normal.x * sign * distance, + y: point.y + route.normal.y * sign * distance - 5 + })); + }); + const best = candidates.reduce((current, candidate) => { + const score = occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidate, rect), 0); + return score < current.score ? { point: candidate, score } : current; + }, { point: candidates[0], score: Number.POSITIVE_INFINITY }); + return best.score >= 1000 ? null : best.point; + } + + function labelRectPenalty(point, rect) { + const labelRect = { + left: point.x - 70, + right: point.x + 70, + top: point.y - 18, + bottom: point.y + 8 + }; + if (rectsIntersect(labelRect, rect)) return 1000; + const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right); + const dy = Math.max(rect.top - labelRect.bottom, 0, labelRect.top - rect.bottom); + return Math.max(0, 30 - Math.hypot(dx, dy)); + } + + function rectsIntersect(a, b) { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + } + + function fitConnectionLabelBackground(group) { + const label = group.querySelector(".story-exp-connection-label"); + if (!label || group.querySelector("rect")) return; + const box = label.getBBox(); + const background = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + background.setAttribute("class", "story-exp-connection-label-bg"); + background.setAttribute("x", String(box.x - 8)); + background.setAttribute("y", String(box.y - 4)); + background.setAttribute("width", String(box.width + 16)); + background.setAttribute("height", String(box.height + 8)); + background.setAttribute("rx", "7"); + group.insertBefore(background, label); + } + 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);