Phase 21J.1 - Story Intelligence Visualisation Fixes
This commit is contained in:
parent
4ebbfdab92
commit
b513bd7df0
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user