Phase 21J.1 - Story Intelligence Visualisation Fixes

This commit is contained in:
Nick Beckley 2026-07-12 20:59:08 +00:00
parent 4ebbfdab92
commit b513bd7df0
5 changed files with 261 additions and 219 deletions

View File

@ -279,7 +279,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
foreach (var item in completed)
{
var scene = item.Scene!;
storyMemory.Update(scene);
storyMemory.Update(scene, item);
var sourceSceneNumber = SceneNumber(item);
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
@ -786,35 +786,60 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
{
private readonly Dictionary<string, MemoryCharacter> characters = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, StoryIntelligenceExperienceRelationshipState> relationships = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PovResolution> chapterPov = new(StringComparer.OrdinalIgnoreCase);
private string? narratorResolvedName;
private string? bookNarratorName;
private string? previousPovName;
private string? previousChapterKey;
public IReadOnlyCollection<MemoryCharacter> Characters => characters.Values;
public IReadOnlyCollection<StoryIntelligenceExperienceRelationshipState> Relationships => relationships.Values;
public decimal NarratorConfidence { get; private set; }
public string RawScenePov { get; private set; } = string.Empty;
public string InheritedChapterPov { get; private set; } = string.Empty;
public string ResolvedPovIdentity { get; private set; } = string.Empty;
public string PovResolutionReason { get; private set; } = string.Empty;
public decimal PovConfidence { get; private set; }
public bool PovChangeDetected { get; private set; }
public int ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved);
public int UnresolvedCharacterCount => characters.Values.Count(character => !character.IsResolved);
private string CurrentChapterKey { get; set; } = string.Empty;
public void Update(SceneIntelligenceScene scene)
public void Update(SceneIntelligenceScene scene, ParsedSceneResult result)
{
CurrentChapterKey = ChapterMemoryKey(scene, result);
RawScenePov = Clean(scene.PointOfView?.CharacterName);
InheritedChapterPov = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? string.Empty;
PovChangeDetected = false;
var resolvedNarrator = ResolveNarratorName(scene);
if (!string.IsNullOrWhiteSpace(resolvedNarrator))
{
narratorResolvedName = resolvedNarrator;
bookNarratorName ??= resolvedNarrator;
NarratorConfidence = Math.Max(NarratorConfidence, 0.82m);
SetChapterPov(CurrentChapterKey, resolvedNarrator, 0.86m, "explicit narrator identity in current scene", result);
}
else if (IsGenericNarratorName(scene.PointOfView?.CharacterName))
{
NarratorConfidence = Math.Max(NarratorConfidence, 0.35m);
}
else if (!string.IsNullOrWhiteSpace(RawScenePov))
{
var existing = chapterPov.GetValueOrDefault(CurrentChapterKey);
PovChangeDetected = existing is not null && !string.Equals(existing.Name, RawScenePov, StringComparison.OrdinalIgnoreCase);
bookNarratorName ??= RawScenePov;
SetChapterPov(CurrentChapterKey, RawScenePov, 0.92m, PovChangeDetected ? "explicit named POV change in current scene" : "explicit named POV in current scene", result);
}
foreach (var character in scene.Characters ?? [])
{
var name = Clean(character.Name);
if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name))
{
if (!string.IsNullOrWhiteSpace(narratorResolvedName))
var inheritedPov = ResolvePointOfView(scene);
if (!string.IsNullOrWhiteSpace(inheritedPov))
{
name = narratorResolvedName;
name = inheritedPov;
}
else
{
@ -862,6 +887,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Weight = ConfidenceWeight(relationship.Confidence, 64)
};
}
previousPovName = ResolvePointOfView(scene);
previousChapterKey = CurrentChapterKey;
}
public string? ResolvePointOfView(SceneIntelligenceScene scene)
@ -869,16 +897,51 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var raw = Clean(scene.PointOfView?.CharacterName);
if (IsGenericNarratorName(raw))
{
return narratorResolvedName ?? raw;
var resolved = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
?? bookNarratorName
?? narratorResolvedName;
ResolvedPovIdentity = resolved ?? raw;
PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited resolved chapter POV";
PovConfidence = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Confidence ?? (resolved is null ? 0.35m : 0.74m);
return resolved ?? raw;
}
return string.IsNullOrWhiteSpace(raw) ? narratorResolvedName : raw;
var fallback = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
?? bookNarratorName
?? narratorResolvedName;
ResolvedPovIdentity = string.IsNullOrWhiteSpace(raw) ? fallback ?? string.Empty : raw;
PovResolutionReason = string.IsNullOrWhiteSpace(raw) ? "inherited previous POV" : "explicit named POV";
PovConfidence = string.IsNullOrWhiteSpace(raw) ? 0.72m : 0.92m;
return string.IsNullOrWhiteSpace(raw) ? fallback : raw;
}
private void SetChapterPov(string chapterKey, string name, decimal confidence, string reason, ParsedSceneResult result)
{
chapterPov[chapterKey] = new PovResolution(name, confidence, reason, result.Result.SceneResultID);
InheritedChapterPov = name;
ResolvedPovIdentity = name;
PovResolutionReason = reason;
PovConfidence = confidence;
}
private static string ChapterMemoryKey(SceneIntelligenceScene scene, ParsedSceneResult result)
=> (scene.SceneReference?.ChapterNumber ?? SceneSortChapter(result)).ToString("0.##");
private string ResolveCharacterName(string? raw)
{
var name = Clean(raw);
return IsGenericNarratorName(name) && !string.IsNullOrWhiteSpace(narratorResolvedName) ? narratorResolvedName! : name;
if (!IsGenericNarratorName(name))
{
return name;
}
return chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
?? bookNarratorName
?? narratorResolvedName
?? name;
}
private MemoryCharacter GetOrAddCharacter(string name)
@ -955,6 +1018,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
public bool IsResolved => !IsGenericNarratorName(Name);
}
private sealed record PovResolution(string Name, decimal Confidence, string Reason, int LastConfirmedSceneResultId);
private static string CurrentSceneText(RunSnapshot snapshot)
{
if (snapshot.Run.TotalDetectedScenes.HasValue)
@ -1019,6 +1084,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence,
RawScenePov = storyMemory.RawScenePov,
InheritedChapterPov = storyMemory.InheritedChapterPov,
ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = "Real",
RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
@ -1066,6 +1137,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence,
RawScenePov = storyMemory.RawScenePov,
InheritedChapterPov = storyMemory.InheritedChapterPov,
ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = "Real",
RunStatus = run.Status,
PersistedCompletedSceneCount = run.CompletedScenes ?? 0,

View File

@ -38,6 +38,12 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { get; set; }
public string RawScenePov { get; init; } = string.Empty;
public string InheritedChapterPov { get; init; } = string.Empty;
public string ResolvedPovIdentity { get; init; } = string.Empty;
public string PovResolutionReason { get; init; } = string.Empty;
public decimal PovConfidence { get; init; }
public bool PovChangeDetected { get; init; }
public string SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; }

View File

@ -44,7 +44,6 @@
<strong>PlotDirector</strong>
<span>Story Intelligence</span>
</div>
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
<div class="story-exp-context">
<span data-experience-status>@statusLabel</span>
<span data-project-name>@Model.ProjectName</span>
@ -253,6 +252,30 @@
<dt>Narrator confidence</dt>
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
</div>
<div>
<dt>Raw scene POV</dt>
<dd data-diagnostic-key="rawScenePov">@Model.Diagnostics.RawScenePov</dd>
</div>
<div>
<dt>Inherited chapter POV</dt>
<dd data-diagnostic-key="inheritedChapterPov">@Model.Diagnostics.InheritedChapterPov</dd>
</div>
<div>
<dt>Resolved POV identity</dt>
<dd data-diagnostic-key="resolvedPovIdentity">@Model.Diagnostics.ResolvedPovIdentity</dd>
</div>
<div>
<dt>POV resolution reason</dt>
<dd data-diagnostic-key="povResolutionReason">@Model.Diagnostics.PovResolutionReason</dd>
</div>
<div>
<dt>POV confidence</dt>
<dd data-diagnostic-key="povConfidence">@Model.Diagnostics.PovConfidence</dd>
</div>
<div>
<dt>POV change detected</dt>
<dd data-diagnostic-key="povChangeDetected">@Model.Diagnostics.PovChangeDetected</dd>
</div>
<div>
<dt>Illustration assignments</dt>
<dd data-diagnostic-key="illustrationAssignmentCount">@Model.Diagnostics.IllustrationAssignmentCount</dd>

View File

@ -54,7 +54,7 @@ input:focus-visible {
.story-exp {
position: relative;
display: grid;
grid-template-rows: 66px minmax(0, 1fr);
grid-template-rows: 56px 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.52fr) minmax(400px, 1.35fr) minmax(290px, 0.78fr) auto;
grid-template-columns: minmax(210px, auto) minmax(0, 1fr) auto;
align-items: center;
gap: 18px;
padding: 10px 24px;
gap: 16px;
padding: 8px 24px;
border-bottom: 1px solid var(--story-line);
background: rgba(4, 10, 20, 0.68);
backdrop-filter: blur(18px);
@ -102,88 +102,12 @@ 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;
transition: opacity 420ms ease, transform 560ms cubic-bezier(.16, .84, .28, 1), color 420ms ease;
}
.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;
grid-template-columns: minmax(188px, auto) minmax(0, 1fr) auto;
gap: 12px;
}
.story-exp-pipeline-step {
font-size: 0.55rem;
}
.story-exp-pipeline-step strong {
max-width: 45px;
}
.story-exp-context {
gap: 8px;
}
@ -293,7 +217,7 @@ input:focus-visible {
.story-exp-run-selector {
position: fixed;
left: 50%;
top: 86px;
top: 68px;
z-index: 20;
display: grid;
gap: 8px;
@ -330,7 +254,7 @@ input:focus-visible {
.story-exp-status-message {
position: fixed;
left: 50%;
top: 86px;
top: 68px;
z-index: 19;
width: min(720px, calc(100vw - 48px));
border: 1px solid rgba(215, 168, 93, 0.32);
@ -669,22 +593,31 @@ input:focus-visible {
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.68);
stroke-width: 3px;
fill: var(--story-soft);
display: -webkit-box;
width: 100%;
max-height: 100%;
overflow: hidden;
border: 1px solid rgba(151, 185, 218, 0.18);
border-radius: 8px;
padding: 4px 7px 5px;
background: rgba(4, 10, 20, 0.86);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.32), 0 0 14px rgba(4, 10, 20, 0.55);
color: var(--story-soft);
font-size: 0.66rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.01em;
overflow-wrap: anywhere;
text-align: center;
text-transform: lowercase;
filter: drop-shadow(0 0 9px rgba(0, 0, 0, 0.5));
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.story-exp-connection-label-wrap {
overflow: visible;
pointer-events: none;
}
.story-exp-zone {
@ -984,11 +917,12 @@ input:focus-visible {
.story-exp-ribbon-wrap {
display: grid;
gap: 12px;
grid-template-rows: minmax(76px, auto) 54px;
gap: 10px;
min-width: 0;
border: 1px solid rgba(151, 185, 218, 0.1);
border-radius: 24px;
padding: 15px;
padding: 12px 14px 14px;
background: rgba(6, 16, 30, 0.54);
box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(14px);
@ -1000,18 +934,19 @@ input:focus-visible {
align-items: stretch;
justify-content: center;
gap: 12px;
min-height: 94px;
min-height: 76px;
overflow: hidden;
}
.story-scene-card {
flex: 0 0 136px;
display: grid;
align-content: space-between;
gap: 7px;
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: start;
gap: 5px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 15px;
padding: 10px 11px;
padding: 9px 10px;
background: rgba(255, 255, 255, 0.032);
color: var(--story-muted);
opacity: 0.68;
@ -1024,7 +959,7 @@ input:focus-visible {
}
.story-scene-card.is-current {
flex-basis: 194px;
flex-basis: 186px;
border-color: rgba(215, 168, 93, 0.64);
background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075));
color: var(--story-text);
@ -1034,24 +969,39 @@ input:focus-visible {
}
.story-scene-card strong {
display: -webkit-box;
overflow: hidden;
color: inherit;
font-size: 0.86rem;
line-height: 1.12;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.story-scene-card span {
color: var(--story-muted);
font-size: 0.72rem;
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.06em;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.story-scene-card small {
display: -webkit-box;
overflow: hidden;
color: var(--story-muted);
font-size: 0.68rem;
line-height: 1.1;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.story-exp-timeline {
position: relative;
display: grid;
grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr));
gap: 0;
min-height: 46px;
min-height: 54px;
}
.story-exp-timeline::before {
@ -1069,14 +1019,26 @@ input:focus-visible {
position: relative;
display: grid;
justify-items: center;
gap: 8px;
gap: 6px;
color: var(--story-muted);
font-size: 0.73rem;
font-size: 0.66rem;
font-weight: 700;
text-align: center;
transition: opacity 560ms ease, transform 720ms cubic-bezier(.16, .84, .28, 1);
}
.story-timeline-marker span {
display: block;
max-width: 72px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-timeline-marker.is-label-hidden span {
opacity: 0;
}
.story-timeline-marker::before {
content: "";
z-index: 1;
@ -1273,16 +1235,9 @@ input:focus-visible {
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;
grid-row: 2;
justify-content: start;
}

View File

@ -36,26 +36,11 @@
snapshotBuildMs: 0,
lastRefreshTime: "Initial"
};
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]"),
projectName: root.querySelector("[data-project-name]"),
bookTitle: root.querySelector("[data-book-title]"),
snapshotMessage: root.querySelector("[data-snapshot-message]"),
stageRail: root.querySelector("[data-analysis-stages]"),
currentScene: root.querySelector("[data-current-scene]"),
progressPercent: root.querySelector("[data-progress-percent]"),
elapsedTime: root.querySelector("[data-elapsed-time]"),
@ -110,7 +95,6 @@
logImageDiagnostics(current);
updateProgress(current);
updateAnalysisStages(current);
updateSceneCopy(current);
const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout);
@ -168,45 +152,6 @@
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);
const activeKeys = new Set(analysisStages.map((stage) => stage.key));
analysisStages.forEach((stage, index) => {
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);
text(node.querySelector("strong"), stage.label);
});
removeInactive(dom.stageRail, "[data-stage-key]", "stageKey", activeKeys);
}
function stageIndex(scene) {
const stage = String(scene.stage || "").toLowerCase();
if (scene.progressPercent >= 100 || stage.includes("review")) return analysisStages.length - 1;
if (stage.includes("final")) return 10;
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;
if (stage.includes("reading") || stage.includes("read ")) return 0;
return Math.max(0, Math.min(analysisStages.length - 1, Math.floor(scene.progressPercent / 10)));
}
function updateSceneCopy(scene) {
text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`);
text(dom.sceneSummary, scene.summary);
@ -422,9 +367,9 @@
}
card.classList.toggle("is-current", item.id === scene.id);
card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
text(card.querySelector("span"), `${item.chapterLabel || "Chapter"} • Scene ${item.sceneNumber}`);
text(card.querySelector("strong"), compactPhrase(item.title, item.id === scene.id ? 42 : 28));
text(card.querySelector("small"), item.timelineEvent);
text(card.querySelector("span"), `${(item.chapterLabel || "Chapter").toUpperCase()} · SCENE ${item.sceneNumber}`);
text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), item.id === scene.id ? 44 : 30));
text(card.querySelector("small"), compactPhrase(item.timelineEvent, 34));
dom.ribbon.append(card);
});
removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
@ -444,12 +389,36 @@
settleEntering(marker);
}
marker.classList.toggle("is-current", item.id === scene.id);
text(marker.querySelector("span"), `${item.timelineLabel} - S${item.sceneNumber}`);
const shouldLabel = shouldShowTimelineLabel(completed, item, scene);
marker.classList.toggle("is-label-hidden", !shouldLabel);
text(marker.querySelector("span"), shortTimelineLabel(item));
dom.timeline.append(marker);
});
removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds);
}
function shouldShowTimelineLabel(items, item, scene) {
const index = items.findIndex((candidate) => candidate.id === item.id);
if (index === 0 || index === items.length - 1 || item.id === scene.id) return true;
const interval = items.length > 12 ? 5 : items.length > 8 ? 4 : 3;
return index % interval === 0 && Math.abs(item.sceneNumber - scene.sceneNumber) > 1;
}
function shortTimelineLabel(item) {
const value = String(item.timelineLabel || "").trim();
if (value && !/book\s+\d|alpha flame|chapter/i.test(value) && value.length <= 18) return value;
return `${abbreviateChapter(item.chapterLabel)} · S${item.sceneNumber}`;
}
function abbreviateChapter(value) {
const match = String(value || "").match(/(\d+(?:\.\d+)?)/);
return match ? `Ch ${match[1]}` : "Ch";
}
function cleanSceneTitle(value) {
return String(value || "").replace(/^book\s+\d+\s*[:|-]\s*/i, "").replace(/^the alpha flame[^:|-]*[:|-]\s*/i, "").trim();
}
function updateFeeds(scene) {
renderList(dom.discoveries, scene.latestDiscoveries);
renderList(dom.activityFeed, scene.activityFeed);
@ -607,27 +576,34 @@
group.querySelector(".story-exp-connection-label-group")?.remove();
if (label) {
const labelPosition = relationshipLabelPosition(start, route, end, occupiedRects);
let displayLabel = label;
let labelBox = relationshipLabelBox(displayLabel);
let labelPosition = relationshipLabelPosition(start, route, end, occupiedRects, labelBox);
if (!labelPosition) {
displayLabel = compactPhrase(label, 24);
labelBox = relationshipLabelBox(displayLabel);
labelPosition = relationshipLabelPosition(start, route, end, occupiedRects, labelBox);
}
if (!labelPosition) {
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(labelPosition.x));
labelNode.setAttribute("y", String(labelPosition.y));
labelNode.setAttribute("text-anchor", "middle");
labelNode.textContent = label;
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
labelNode.setAttribute("class", "story-exp-connection-label-wrap");
labelNode.setAttribute("x", String(labelPosition.x - labelBox.width / 2));
labelNode.setAttribute("y", String(labelPosition.y - labelBox.height / 2));
labelNode.setAttribute("width", String(labelBox.width));
labelNode.setAttribute("height", String(labelBox.height));
labelNode.setAttribute("title", label);
const labelDiv = document.createElement("div");
labelDiv.className = "story-exp-connection-label";
labelDiv.title = label;
labelDiv.textContent = displayLabel;
labelNode.append(labelDiv);
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));
occupiedRects.push(labelRect(labelPosition, labelBox));
}
if (isNew) group.classList.add("is-entering");
@ -970,29 +946,48 @@
};
}
function relationshipLabelPosition(start, route, end, occupiedRects) {
function relationshipLabelBox(label) {
const length = String(label || "").length;
const width = length > 42 ? 168 : length > 24 ? 144 : 118;
const lines = Math.min(3, Math.max(1, Math.ceil(length / 24)));
return { width, height: 18 + lines * 15 };
}
function relationshipLabelPosition(start, route, end, occupiedRects, labelBox) {
const sign = Math.sign(route.offset || 1);
const candidates = [0.42, 0.5, 0.58, 0.34, 0.66].flatMap((t) => {
const bounds = dom.visualStage?.getBoundingClientRect();
const boundary = {
left: 6,
top: 6,
right: Math.max(10, bounds?.width || 1000) - 6,
bottom: Math.max(10, bounds?.height || 560) - 6
};
const candidates = [0.5, 0.42, 0.58, 0.32, 0.68, 0.24, 0.76].flatMap((t) => {
const point = pointOnCubic(start, route.startControl, route.endControl, end, t);
return [34, 54, 76, -42, -64].map((distance) => ({
return [38, 62, 88, -42, -68, 112, -96].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);
const candidateRect = labelRect(candidate, labelBox);
const boundaryPenalty = candidateRect.left < boundary.left || candidateRect.right > boundary.right || candidateRect.top < boundary.top || candidateRect.bottom > boundary.bottom ? 2000 : 0;
const score = boundaryPenalty + occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidateRect, 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
function labelRect(point, labelBox) {
return {
left: point.x - labelBox.width / 2,
right: point.x + labelBox.width / 2,
top: point.y - labelBox.height / 2,
bottom: point.y + labelBox.height / 2
};
}
function labelRectPenalty(labelRect, rect) {
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);
@ -1003,20 +998,6 @@
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);