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) foreach (var item in completed)
{ {
var scene = item.Scene!; var scene = item.Scene!;
storyMemory.Update(scene); storyMemory.Update(scene, item);
var sourceSceneNumber = SceneNumber(item); var sourceSceneNumber = SceneNumber(item);
var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber; var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID; 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, MemoryCharacter> characters = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, StoryIntelligenceExperienceRelationshipState> relationships = 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? narratorResolvedName;
private string? bookNarratorName;
private string? previousPovName;
private string? previousChapterKey;
public IReadOnlyCollection<MemoryCharacter> Characters => characters.Values; public IReadOnlyCollection<MemoryCharacter> Characters => characters.Values;
public IReadOnlyCollection<StoryIntelligenceExperienceRelationshipState> Relationships => relationships.Values; public IReadOnlyCollection<StoryIntelligenceExperienceRelationshipState> Relationships => relationships.Values;
public decimal NarratorConfidence { get; private set; } 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 ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved);
public int UnresolvedCharacterCount => 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); var resolvedNarrator = ResolveNarratorName(scene);
if (!string.IsNullOrWhiteSpace(resolvedNarrator)) if (!string.IsNullOrWhiteSpace(resolvedNarrator))
{ {
narratorResolvedName = resolvedNarrator; narratorResolvedName = resolvedNarrator;
bookNarratorName ??= resolvedNarrator;
NarratorConfidence = Math.Max(NarratorConfidence, 0.82m); NarratorConfidence = Math.Max(NarratorConfidence, 0.82m);
SetChapterPov(CurrentChapterKey, resolvedNarrator, 0.86m, "explicit narrator identity in current scene", result);
} }
else if (IsGenericNarratorName(scene.PointOfView?.CharacterName)) else if (IsGenericNarratorName(scene.PointOfView?.CharacterName))
{ {
NarratorConfidence = Math.Max(NarratorConfidence, 0.35m); 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 ?? []) foreach (var character in scene.Characters ?? [])
{ {
var name = Clean(character.Name); var name = Clean(character.Name);
if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name)) if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name))
{ {
if (!string.IsNullOrWhiteSpace(narratorResolvedName)) var inheritedPov = ResolvePointOfView(scene);
if (!string.IsNullOrWhiteSpace(inheritedPov))
{ {
name = narratorResolvedName; name = inheritedPov;
} }
else else
{ {
@ -862,6 +887,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Weight = ConfidenceWeight(relationship.Confidence, 64) Weight = ConfidenceWeight(relationship.Confidence, 64)
}; };
} }
previousPovName = ResolvePointOfView(scene);
previousChapterKey = CurrentChapterKey;
} }
public string? ResolvePointOfView(SceneIntelligenceScene scene) public string? ResolvePointOfView(SceneIntelligenceScene scene)
@ -869,16 +897,51 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var raw = Clean(scene.PointOfView?.CharacterName); var raw = Clean(scene.PointOfView?.CharacterName);
if (IsGenericNarratorName(raw)) 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) private string ResolveCharacterName(string? raw)
{ {
var name = Clean(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) private MemoryCharacter GetOrAddCharacter(string name)
@ -955,6 +1018,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
public bool IsResolved => !IsGenericNarratorName(Name); public bool IsResolved => !IsGenericNarratorName(Name);
} }
private sealed record PovResolution(string Name, decimal Confidence, string Reason, int LastConfirmedSceneResultId);
private static string CurrentSceneText(RunSnapshot snapshot) private static string CurrentSceneText(RunSnapshot snapshot)
{ {
if (snapshot.Run.TotalDetectedScenes.HasValue) if (snapshot.Run.TotalDetectedScenes.HasValue)
@ -1019,6 +1084,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence, NarratorConfidence = storyMemory.NarratorConfidence,
RawScenePov = storyMemory.RawScenePov,
InheritedChapterPov = storyMemory.InheritedChapterPov,
ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = "Real", SnapshotSource = "Real",
RunStatus = SessionStatus(session, snapshots), RunStatus = SessionStatus(session, snapshots),
PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count), PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
@ -1066,6 +1137,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ResolvedCharacterCount = storyMemory.ResolvedCharacterCount, ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount, UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
NarratorConfidence = storyMemory.NarratorConfidence, NarratorConfidence = storyMemory.NarratorConfidence,
RawScenePov = storyMemory.RawScenePov,
InheritedChapterPov = storyMemory.InheritedChapterPov,
ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
PovResolutionReason = storyMemory.PovResolutionReason,
PovConfidence = storyMemory.PovConfidence,
PovChangeDetected = storyMemory.PovChangeDetected,
SnapshotSource = "Real", SnapshotSource = "Real",
RunStatus = run.Status, RunStatus = run.Status,
PersistedCompletedSceneCount = run.CompletedScenes ?? 0, PersistedCompletedSceneCount = run.CompletedScenes ?? 0,

View File

@ -38,6 +38,12 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public decimal NarratorConfidence { get; init; } public decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; } public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { 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 SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty; public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; } public int PersistedCompletedSceneCount { get; init; }

View File

@ -44,7 +44,6 @@
<strong>PlotDirector</strong> <strong>PlotDirector</strong>
<span>Story Intelligence</span> <span>Story Intelligence</span>
</div> </div>
<ol class="story-exp-pipeline" data-analysis-stages aria-label="Analysis stages"></ol>
<div class="story-exp-context"> <div class="story-exp-context">
<span data-experience-status>@statusLabel</span> <span data-experience-status>@statusLabel</span>
<span data-project-name>@Model.ProjectName</span> <span data-project-name>@Model.ProjectName</span>
@ -253,6 +252,30 @@
<dt>Narrator confidence</dt> <dt>Narrator confidence</dt>
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd> <dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
</div> </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> <div>
<dt>Illustration assignments</dt> <dt>Illustration assignments</dt>
<dd data-diagnostic-key="illustrationAssignmentCount">@Model.Diagnostics.IllustrationAssignmentCount</dd> <dd data-diagnostic-key="illustrationAssignmentCount">@Model.Diagnostics.IllustrationAssignmentCount</dd>

View File

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

View File

@ -36,26 +36,11 @@
snapshotBuildMs: 0, snapshotBuildMs: 0,
lastRefreshTime: "Initial" 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 = { const dom = {
stageLabel: root.querySelector("[data-stage-label]"), stageLabel: root.querySelector("[data-stage-label]"),
projectName: root.querySelector("[data-project-name]"), projectName: root.querySelector("[data-project-name]"),
bookTitle: root.querySelector("[data-book-title]"), bookTitle: root.querySelector("[data-book-title]"),
snapshotMessage: root.querySelector("[data-snapshot-message]"), snapshotMessage: root.querySelector("[data-snapshot-message]"),
stageRail: root.querySelector("[data-analysis-stages]"),
currentScene: root.querySelector("[data-current-scene]"), currentScene: root.querySelector("[data-current-scene]"),
progressPercent: root.querySelector("[data-progress-percent]"), progressPercent: root.querySelector("[data-progress-percent]"),
elapsedTime: root.querySelector("[data-elapsed-time]"), elapsedTime: root.querySelector("[data-elapsed-time]"),
@ -110,7 +95,6 @@
logImageDiagnostics(current); logImageDiagnostics(current);
updateProgress(current); updateProgress(current);
updateAnalysisStages(current);
updateSceneCopy(current); updateSceneCopy(current);
const layout = sceneLayout(current); const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout); reconcileCharacters(current, previous, layout);
@ -168,45 +152,6 @@
if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`); 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) { function updateSceneCopy(scene) {
text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`); text(dom.sceneTitle, `Scene ${scene.sceneNumber}${scene.title}`);
text(dom.sceneSummary, scene.summary); text(dom.sceneSummary, scene.summary);
@ -422,9 +367,9 @@
} }
card.classList.toggle("is-current", item.id === scene.id); card.classList.toggle("is-current", item.id === scene.id);
card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber); card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
text(card.querySelector("span"), `${item.chapterLabel || "Chapter"} • Scene ${item.sceneNumber}`); text(card.querySelector("span"), `${(item.chapterLabel || "Chapter").toUpperCase()} · SCENE ${item.sceneNumber}`);
text(card.querySelector("strong"), compactPhrase(item.title, item.id === scene.id ? 42 : 28)); text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), item.id === scene.id ? 44 : 30));
text(card.querySelector("small"), item.timelineEvent); text(card.querySelector("small"), compactPhrase(item.timelineEvent, 34));
dom.ribbon.append(card); dom.ribbon.append(card);
}); });
removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds); removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
@ -444,12 +389,36 @@
settleEntering(marker); settleEntering(marker);
} }
marker.classList.toggle("is-current", item.id === scene.id); 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); dom.timeline.append(marker);
}); });
removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds); 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) { function updateFeeds(scene) {
renderList(dom.discoveries, scene.latestDiscoveries); renderList(dom.discoveries, scene.latestDiscoveries);
renderList(dom.activityFeed, scene.activityFeed); renderList(dom.activityFeed, scene.activityFeed);
@ -607,27 +576,34 @@
group.querySelector(".story-exp-connection-label-group")?.remove(); group.querySelector(".story-exp-connection-label-group")?.remove();
if (label) { 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) { if (!labelPosition) {
return group; return group;
} }
const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
labelGroup.setAttribute("class", "story-exp-connection-label-group"); labelGroup.setAttribute("class", "story-exp-connection-label-group");
const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "text"); const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
labelNode.setAttribute("class", "story-exp-connection-label"); labelNode.setAttribute("class", "story-exp-connection-label-wrap");
labelNode.setAttribute("x", String(labelPosition.x)); labelNode.setAttribute("x", String(labelPosition.x - labelBox.width / 2));
labelNode.setAttribute("y", String(labelPosition.y)); labelNode.setAttribute("y", String(labelPosition.y - labelBox.height / 2));
labelNode.setAttribute("text-anchor", "middle"); labelNode.setAttribute("width", String(labelBox.width));
labelNode.textContent = label; 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); labelGroup.append(labelNode);
group.append(labelGroup); group.append(labelGroup);
occupiedRects.push({ occupiedRects.push(labelRect(labelPosition, labelBox));
left: labelPosition.x - 78,
right: labelPosition.x + 78,
top: labelPosition.y - 22,
bottom: labelPosition.y + 10
});
window.requestAnimationFrame(() => fitConnectionLabelBackground(labelGroup));
} }
if (isNew) group.classList.add("is-entering"); 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 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); 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, x: point.x + route.normal.x * sign * distance,
y: point.y + route.normal.y * sign * distance - 5 y: point.y + route.normal.y * sign * distance - 5
})); }));
}); });
const best = candidates.reduce((current, candidate) => { 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; return score < current.score ? { point: candidate, score } : current;
}, { point: candidates[0], score: Number.POSITIVE_INFINITY }); }, { point: candidates[0], score: Number.POSITIVE_INFINITY });
return best.score >= 1000 ? null : best.point; return best.score >= 1000 ? null : best.point;
} }
function labelRectPenalty(point, rect) { function labelRect(point, labelBox) {
const labelRect = { return {
left: point.x - 70, left: point.x - labelBox.width / 2,
right: point.x + 70, right: point.x + labelBox.width / 2,
top: point.y - 18, top: point.y - labelBox.height / 2,
bottom: point.y + 8 bottom: point.y + labelBox.height / 2
}; };
}
function labelRectPenalty(labelRect, rect) {
if (rectsIntersect(labelRect, rect)) return 1000; if (rectsIntersect(labelRect, rect)) return 1000;
const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right); 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); 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; 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) { function pointRectPenalty(point, rect) {
if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000; 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 dx = Math.max(rect.left - point.x, 0, point.x - rect.right);