diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 7a12e33..b83bab7 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -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 characters = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary relationships = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary chapterPov = new(StringComparer.OrdinalIgnoreCase); private string? narratorResolvedName; + private string? bookNarratorName; + private string? previousPovName; + private string? previousChapterKey; public IReadOnlyCollection Characters => characters.Values; public IReadOnlyCollection 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, diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index be15f02..2e15baa 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -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; } diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 278b321..ae026ca 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -44,7 +44,6 @@ PlotDirector Story Intelligence -
    @statusLabel @Model.ProjectName @@ -253,6 +252,30 @@
    Narrator confidence
    @Model.Diagnostics.NarratorConfidence
    +
    +
    Raw scene POV
    +
    @Model.Diagnostics.RawScenePov
    +
    +
    +
    Inherited chapter POV
    +
    @Model.Diagnostics.InheritedChapterPov
    +
    +
    +
    Resolved POV identity
    +
    @Model.Diagnostics.ResolvedPovIdentity
    +
    +
    +
    POV resolution reason
    +
    @Model.Diagnostics.PovResolutionReason
    +
    +
    +
    POV confidence
    +
    @Model.Diagnostics.PovConfidence
    +
    +
    +
    POV change detected
    +
    @Model.Diagnostics.PovChangeDetected
    +
    Illustration assignments
    @Model.Diagnostics.IllustrationAssignmentCount
    diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 3588686..57a4da5 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -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; } diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index ae66b25..bb9b0f6 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -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 = ""; - 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);