@model TimelineViewModel @{ ViewData["Title"] = "Timeline"; ViewData["ShellClass"] = "plotline-shell-wide"; ViewData["ProjectSection"] = "Timeline"; var selectedBook = Model.Books.FirstOrDefault(x => x.Book.BookID == Model.SelectedBookID)?.Book; var hasActiveFilter = Model.MatchingSceneIDs.Count != Model.OrderedScenes.Count || !string.IsNullOrWhiteSpace(Model.FocusLabel); var warningScenes = Model.OrderedScenes.Where(x => x.WarningCount > 0).ToList(); var timelineColumns = new List<(BookTimelineViewModel Book, ChapterTimelineViewModel? Chapter, Scene? Scene, bool IsPlaceholder, string PlaceholderLabel)>(); foreach (var book in Model.Books) { if (!book.Chapters.Any()) { timelineColumns.Add((book, null, null, true, "No chapters")); continue; } foreach (var chapter in book.Chapters) { if (!chapter.Scenes.Any()) { timelineColumns.Add((book, chapter, null, true, "No scenes")); continue; } foreach (var scene in chapter.Scenes) { timelineColumns.Add((book, chapter, scene, false, string.Empty)); } } } var timelineMetricColumns = timelineColumns.Select(column => new { sceneId = column.Scene?.SceneID, isPlaceholder = column.Scene is null, axisLabel = column.Scene is null ? string.Empty : $"Ch {column.Chapter?.Chapter.ChapterNumber:g}.{column.Scene.SceneNumber:g}", chapterLabel = column.Chapter is null ? column.Book.Book.BookDisplayTitle : $"Chapter {column.Chapter.Chapter.ChapterNumber:g}", sceneNumber = column.Scene?.SceneNumber.ToString("g"), sceneTitle = column.Scene?.SceneTitle ?? column.PlaceholderLabel, bookTitle = column.Book.Book.BookDisplayTitle, timelineUrl = column.Scene is null ? null : Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = column.Book.Book.BookID, selectedSceneId = column.Scene.SceneID }) }).ToList(); var timelineMetricShapePayload = new { scenes = timelineMetricColumns, metrics = Model.MetricGraphs.Select(graph => new { metricTypeId = graph.MetricTypeID, metricName = graph.MetricName, minValue = graph.MinValue, maxValue = graph.MaxValue, points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value }) }) }; int ChapterSceneColumns(ChapterTimelineViewModel chapter) => Math.Max(chapter.Scenes.Count, 1); int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1); var hasSelectedScene = Model.SelectedScene is not null; int CharacterAppearanceCount(CharacterLaneViewModel lane) => lane.SceneSlots.Sum(slot => slot.Appearances.Count); string PlotMarkerKind(string? plotEventTypeName) => (plotEventTypeName ?? "Progress").Trim().ToLowerInvariant() switch { "start" => "start", "reveal" => "reveal", "twist" => "twist", "branch" => "branch", "split" => "split", "merge" => "merge", "resolve" => "resolve", "abandon" => "abandon", _ => "progress" }; string PlotMarkerIcon(string? plotEventTypeName) => PlotMarkerKind(plotEventTypeName) switch { "start" => ">", "progress" => "+", "reveal" => "o", "twist" => "!", "branch" => "B", "split" => "Y", "merge" => "M", "resolve" => "OK", "abandon" => "x", _ => "+" }; string ImportanceLabel(int importance) => importance switch { >= 8 => $"High ({importance})", >= 4 => $"Medium ({importance})", > 0 => $"Low ({importance})", _ => "Not set" }; string ThreadEventTooltip(ThreadEvent threadEvent, BookTimelineViewModel book, ChapterTimelineViewModel? chapter, Scene scene) { var lines = new List { $"Thread: {threadEvent.ThreadTitle}", $"Event: {threadEvent.PlotEventTypeName}", $"Book: {book.Book.BookDisplayTitle}", $"Chapter: {(chapter is null ? "No chapter" : $"{chapter.Chapter.ChapterNumber:g}: {chapter.Chapter.ChapterTitle}")}", $"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}", $"Importance: {ImportanceLabel(threadEvent.Importance)}", $"Status: {(string.IsNullOrWhiteSpace(threadEvent.ThreadStatusName) ? "Unknown" : threadEvent.ThreadStatusName)}" }; if (!string.IsNullOrWhiteSpace(threadEvent.EventTitle)) { lines.Add($"Marker: {threadEvent.EventTitle}"); } if (!string.IsNullOrWhiteSpace(threadEvent.EventDescription)) { lines.Add($"Summary: {threadEvent.EventDescription}"); } return string.Join(Environment.NewLine, lines); } string SuggestedWarningAction(ContinuityWarning warning) { var warningText = $"{warning.WarningTypeName} {warning.Message} {warning.Details}".ToLowerInvariant(); if (warningText.Contains("dormant") || warningText.Contains("no activity") || warningText.Contains("neglected")) { return "Add a Progress, Reveal, Twist, Resolve or Abandon event, or confirm the thread is intentionally quiet."; } if (warningText.Contains("unresolved") || warningText.Contains("missing closure") || warningText.Contains("closure")) { return "Review the ending scenes and add a Resolve or Abandon event if the thread is finished."; } if (warningText.Contains("contradict") || warningText.Contains("conflict")) { return "Review the related scene details and update the conflicting story state."; } if (warningText.Contains("dependency")) { return "Review the linked scene dependency and adjust the ordering or dependency status."; } return "Open the warning dashboard or scene inspector and review the affected story detail."; } string WarningTooltip(Scene scene) { if (!Model.WarningsByScene.TryGetValue(scene.SceneID, out var sceneWarnings) || sceneWarnings.Count == 0) { return $"{scene.WarningCount} warning{(scene.WarningCount == 1 ? "" : "s")} on Scene {scene.SceneNumber:g}: {scene.SceneTitle}"; } var lines = new List { $"{sceneWarnings.Count} warning{(sceneWarnings.Count == 1 ? "" : "s")} on Scene {scene.SceneNumber:g}: {scene.SceneTitle}" }; foreach (var warning in sceneWarnings.OrderByDescending(x => x.SeverityName).ThenBy(x => x.WarningTypeName)) { lines.Add(string.Empty); lines.Add($"Warning: {warning.WarningTypeName}"); lines.Add($"Reason: {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}"); lines.Add($"Suggested Action: {SuggestedWarningAction(warning)}"); } return string.Join(Environment.NewLine, lines); } string TrimMarkerLabel(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().Length <= 5 ? value.Trim() : value.Trim()[..5]; IReadOnlyList EntityWarnings(Scene scene, string entityType, int entityId) => Model.WarningsByScene.TryGetValue(scene.SceneID, out var sceneWarnings) ? sceneWarnings .Where(warning => string.Equals(warning.EntityType, entityType, StringComparison.OrdinalIgnoreCase) && warning.EntityID == entityId) .OrderByDescending(warning => warning.SeverityName) .ThenBy(warning => warning.WarningTypeName) .ToList() : []; bool HasStoryState(SceneCharacter appearance) => !string.IsNullOrWhiteSpace(appearance.EmotionalState) || !string.IsNullOrWhiteSpace(appearance.PhysicalCondition) || !string.IsNullOrWhiteSpace(appearance.KnowledgeNotes); string AssetMarkerLabel(AssetEvent assetEvent) { var typeName = assetEvent.AssetEventTypeName?.Trim() ?? string.Empty; return typeName.ToLowerInvariant() switch { "first mentioned" => "First", "mentioned" => "Ref", "found" => "Find", "lost" => "Lost", "given" => "Give", "taken" => "Take", "stolen" => "Stol", "hidden" => "Hide", "moved" or "location changed" => "Move", "used" => "Use", "opened" => "Open", "destroyed" => "X", "revealed" => "Show", "investigated" => "?", "resolved" => "Done", "state changed" => "State", _ => string.IsNullOrWhiteSpace(typeName) ? "Asset" : TrimMarkerLabel(typeName) }; } string AssetMarkerMeaning(AssetEvent assetEvent) { var typeName = assetEvent.AssetEventTypeName?.Trim() ?? string.Empty; return typeName.ToLowerInvariant() switch { "first mentioned" => "Asset is first mentioned in this scene.", "mentioned" => "Asset is mentioned in this scene.", "found" => "Asset is found in this scene.", "lost" => "Asset is lost in this scene.", "given" => "Asset changes hands in this scene.", "taken" => "Asset is taken in this scene.", "stolen" => "Asset is stolen in this scene.", "hidden" => "Asset is hidden in this scene.", "moved" or "location changed" => "Asset changes location in this scene.", "used" => "Asset is used in this scene.", "opened" => "Asset is opened in this scene.", "destroyed" => "Asset is destroyed in this scene.", "revealed" => "Asset is revealed in this scene.", "investigated" => "Asset is investigated in this scene.", "resolved" => "Asset thread is resolved in this scene.", "state changed" => "Asset state changes in this scene.", _ => string.IsNullOrWhiteSpace(typeName) ? "Asset event occurs in this scene." : $"Asset event: {typeName}." }; } string AssetStorySentence(AssetEvent assetEvent) { var title = assetEvent.EventTitle?.Trim(); if (!string.IsNullOrWhiteSpace(title) && !string.Equals(title, assetEvent.AssetEventTypeName, StringComparison.OrdinalIgnoreCase)) { return $"{assetEvent.AssetName}: {title}."; } return AssetMarkerMeaning(assetEvent); } string AssetMarkerClass(AssetEvent assetEvent, StoryAsset asset, IReadOnlyList assetWarnings) { var classes = new List(); var typeName = assetEvent.AssetEventTypeName?.Trim().ToLowerInvariant() ?? string.Empty; if (assetWarnings.Any()) { classes.Add("timeline-marker-has-warning"); } if (asset.Importance >= 8) { classes.Add("timeline-marker-important"); } if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName)) { classes.Add("timeline-marker-state-change"); } if (typeName is "found" or "revealed" or "destroyed" or "resolved" or "state changed") { classes.Add("timeline-marker-story-turn"); } if (typeName is "given" or "taken" or "stolen" or "moved" or "location changed") { classes.Add("timeline-marker-transfer"); } return string.Join(' ', classes); } string AssetMarkerTooltip(AssetEvent assetEvent, StoryAsset asset, Scene scene, IReadOnlyList assetWarnings) { var lines = new List { AssetStorySentence(assetEvent), $"Asset: {assetEvent.AssetName}", $"Event type: {(string.IsNullOrWhiteSpace(assetEvent.AssetEventTypeName) ? "Not set" : assetEvent.AssetEventTypeName)}", $"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}" }; if (asset.Importance >= 8) { lines.Add("Significance: High-importance story asset."); } if (!string.IsNullOrWhiteSpace(assetEvent.FromStateName) || !string.IsNullOrWhiteSpace(assetEvent.ToStateName)) { lines.Add($"State change: {(string.IsNullOrWhiteSpace(assetEvent.FromStateName) ? "Unknown" : assetEvent.FromStateName)} -> {(string.IsNullOrWhiteSpace(assetEvent.ToStateName) ? "Unknown" : assetEvent.ToStateName)}"); } if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName)) { lines.Add($"Current state after this event: {assetEvent.ToStateName}"); } if (!string.IsNullOrWhiteSpace(assetEvent.EventDescription)) { lines.Add($"Summary: {assetEvent.EventDescription}"); } foreach (var warning in assetWarnings.Take(2)) { lines.Add($"Continuity: {warning.WarningTypeName} - {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}"); } return string.Join(Environment.NewLine, lines); } string CharacterMarkerLabel(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance) { if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID) { return "Intro"; } if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID && string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase)) { return "Exit"; } if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase)) { return "POV"; } if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes)) { return "Know"; } if (HasStoryState(appearance)) { return "State"; } var presence = appearance.PresenceTypeName?.Trim() ?? string.Empty; return presence.ToLowerInvariant() switch { "present" => "In", "arrives" => "In+", "leaves" => "Out", "nearby" => "Near", "hidden" => "Hide", "overhearing" => "Hear", "mentioned only" => "Ref", "background" => "Bg", "off-screen" => "Off", _ => "Char" }; } string CharacterMarkerMeaning(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance) { if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID) { return $"{appearance.CharacterName} enters this story lane in this scene."; } if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID && string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase)) { return $"{appearance.CharacterName} exits this story lane in this scene."; } if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes)) { return $"{appearance.CharacterName}'s knowledge changes in this scene."; } if (!string.IsNullOrWhiteSpace(appearance.EmotionalState) || !string.IsNullOrWhiteSpace(appearance.PhysicalCondition)) { return $"{appearance.CharacterName}'s story state changes in this scene."; } var presence = appearance.PresenceTypeName?.Trim() ?? string.Empty; return presence.ToLowerInvariant() switch { "present" => "Character appears in this scene.", "arrives" => "Character arrives in this scene.", "leaves" => "Character leaves in this scene.", "nearby" => "Character is nearby in this scene.", "hidden" => "Character is hidden in this scene.", "overhearing" => "Character overhears this scene.", "mentioned only" => "Character is mentioned in this scene.", "background" => "Character is in the background of this scene.", "off-screen" => "Character is off-screen in this scene.", _ => "Character appears in this scene." }; } string CharacterMarkerClass(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance, IReadOnlyList characterWarnings) { var classes = new List(); if (characterWarnings.Any()) { classes.Add("timeline-marker-has-warning"); } if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID) { classes.Add("timeline-marker-story-turn"); } if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID && string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase)) { classes.Add("timeline-marker-exit"); } if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase) || string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase)) { classes.Add("timeline-marker-important"); } if (HasStoryState(appearance)) { classes.Add("timeline-marker-state-change"); } return string.Join(' ', classes); } string CharacterMarkerTooltip(SceneCharacter appearance, Scene scene, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance, IReadOnlyList characterWarnings) { var lines = new List { CharacterMarkerMeaning(appearance, firstAppearance, lastAppearance), $"Character: {appearance.CharacterName}", $"Role: {(string.IsNullOrWhiteSpace(appearance.RoleInSceneTypeName) ? "Not set" : appearance.RoleInSceneTypeName)}", $"Presence: {(string.IsNullOrWhiteSpace(appearance.PresenceTypeName) ? "Not set" : appearance.PresenceTypeName)}", $"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}" }; if (!string.IsNullOrWhiteSpace(appearance.LocationPath) || !string.IsNullOrWhiteSpace(appearance.LocationName)) { lines.Add($"Location: {appearance.LocationPath ?? appearance.LocationName}"); } if (!string.IsNullOrWhiteSpace(appearance.EntryLocationName)) { lines.Add($"Entry: {appearance.EntryLocationName}"); } if (!string.IsNullOrWhiteSpace(appearance.ExitLocationName)) { lines.Add($"Exit: {appearance.ExitLocationName}"); } if (!string.IsNullOrWhiteSpace(appearance.EmotionalState)) { lines.Add($"Emotional state: {appearance.EmotionalState}"); } if (!string.IsNullOrWhiteSpace(appearance.PhysicalCondition)) { lines.Add($"Physical condition: {appearance.PhysicalCondition}"); } if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes)) { lines.Add($"Knowledge: {appearance.KnowledgeNotes}"); } if (!string.IsNullOrWhiteSpace(appearance.AppearanceNotes)) { lines.Add($"Summary: {appearance.AppearanceNotes}"); } foreach (var warning in characterWarnings.Take(2)) { lines.Add($"Continuity: {warning.WarningTypeName} - {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}"); } return string.Join(Environment.NewLine, lines); } bool HasLeadSceneRole(CharacterLaneViewModel lane) => lane.SceneSlots .SelectMany(slot => slot.Appearances) .Any(appearance => string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase) || string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase)); string CharacterGroupName(CharacterLaneViewModel lane) { var description = lane.Character.DefaultDescription ?? string.Empty; // Timeline grouping prefers the editable CharacterImportance field; imported // classification metadata remains a fallback for older imported projects. if (lane.Character.CharacterImportance >= 8) { return "Major characters"; } if (lane.Character.CharacterImportance >= 4) { return "Secondary / supporting characters"; } if (lane.Character.CharacterImportance.HasValue) { return "Minor / mentioned characters"; } if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase)) { return "Major characters"; } if (description.Contains("Imported character classification: Supporting", StringComparison.OrdinalIgnoreCase)) { return "Secondary / supporting characters"; } if (description.Contains("Imported character classification: Minor", StringComparison.OrdinalIgnoreCase)) { return "Minor / mentioned characters"; } if (HasLeadSceneRole(lane) || CharacterAppearanceCount(lane) >= 6) { return "Major characters"; } return CharacterAppearanceCount(lane) <= 1 ? "Minor / mentioned characters" : "Secondary / supporting characters"; } int CharacterGroupOrder(string groupName) => groupName switch { "Major characters" => 0, "Secondary / supporting characters" => 1, _ => 2 }; }

Story timeline

@Model.Project.ProjectName

@if (Model.SelectedBookID.HasValue) {
} Warnings Story health Timeline Settings Create Scenario Archived Items
Filters and focus
Clear filters
View
@if (Model.Settings.ShowSceneCards) { } @if (Model.Settings.ShowPlotLines) { } @if (Model.Settings.ShowStoryAssets) { } @if (Model.Settings.ShowCharacterAppearances) { } @if (Model.Settings.ShowWarnings) { } @if (Model.Settings.ShowMetricShape) { }
Jump
Shortcuts: Shift + scroll moves sideways, arrows scroll, +/- zoom, Esc clears focus
Saved Views
@if (Model.ViewPresets.Any()) {
@foreach (var preset in Model.ViewPresets) { @preset.PresetName
}
}
@if (!string.IsNullOrWhiteSpace(Model.FocusLabel)) {
Focus: @Model.FocusLabel @Model.FocusSceneIDs.Count matching scene@(Model.FocusSceneIDs.Count == 1 ? "" : "s") Clear focus
} @if (TempData["TimelineDragMessage"] is string dragMessage) {
@dragMessage Review warnings
}
Drag Sources
@if (!Model.QuickAddCharacters.Any() && !Model.QuickAddAssets.Any() && !Model.QuickAddLocations.Any()) { No Quick Add items yet. Tick “Show in Quick Add Bar” on characters, story assets or locations to add shortcuts here. } @foreach (var character in Model.QuickAddCharacters) { @character.CharacterName } @foreach (var asset in Model.QuickAddAssets) { @asset.AssetName } @foreach (var location in Model.QuickAddLocations) { @location.LocationName }
@if (Model.Books.Any()) {
@foreach (var book in Model.Books) { @foreach (var chapter in book.Chapters) { var chapterWarnings = chapter.Scenes.Sum(x => x.WarningCount); var isActiveChapter = chapter.Scenes.Any(scene => scene.SceneID == Model.SelectedSceneID); } }
}

Drag action

Confirm action

@if (!Model.Books.Any()) {

No timeline yet

Add a book, then chapters and scenes, and PlotDirector will arrange them here as your narrative spine.

} else {
@if (Model.Settings.ShowSceneCards) {
@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")
Project / Series @Model.Project.ProjectName
@foreach (var book in Model.Books) {
Book @book.Book.BookNumber @book.Book.BookDisplayTitle
@if (!book.Chapters.Any()) {
No chapters Start planning
No scenes
} else { @foreach (var chapter in book.Chapters) {
Chapter @chapter.Chapter.ChapterNumber @chapter.Chapter.ChapterTitle
@if (!chapter.Scenes.Any()) {
Drop a scene here
} else { @foreach (var scene in chapter.Scenes) { var selectedClass = scene.SceneID == Model.SelectedSceneID ? "selected" : ""; var matchClass = Model.MatchingSceneIDs.Contains(scene.SceneID) ? "filter-match" : "filter-dim"; var focusClass = Model.FocusSceneIDs.Contains(scene.SceneID) ? "focus-match" : (Model.FocusSceneIDs.Any() ? "focus-dim" : ""); var povLabel = Model.ScenePovLabels.GetValueOrDefault(scene.SceneID, scene.POVCharacterID.HasValue ? "POV set" : "No POV"); var metricSummary = string.Join(" / ", Model.MetricGraphs.Take(2).Select(graph => $"{graph.MetricName}: {graph.Points.FirstOrDefault(x => x.SceneID == scene.SceneID)?.Value ?? 0}")); var plotSearch = string.Join(' ', Model.PlotLanes .SelectMany(x => x.SceneSlots.Where(slot => slot.Scene.SceneID == scene.SceneID).SelectMany(slot => slot.Events.Select(evt => $"{x.PlotLine.PlotLineName} {evt.ThreadTitle} {evt.EventTitle}")))); var assetSearch = string.Join(' ', Model.AssetLanes .SelectMany(x => x.SceneSlots.Where(slot => slot.Scene.SceneID == scene.SceneID).SelectMany(slot => slot.Events.Select(evt => $"{x.Asset.AssetName} {evt.EventTitle}")))); var characterSearch = string.Join(' ', Model.CharacterLanes .SelectMany(x => x.SceneSlots.Where(slot => slot.Scene.SceneID == scene.SceneID).SelectMany(slot => slot.Appearances.Select(appearance => $"{x.Character.CharacterName} {appearance.RoleInSceneTypeName} {appearance.PresenceTypeName}")))); var warningSearch = scene.WarningCount > 0 ? "warning warnings continuity" : ""; var searchText = $"{scene.SceneNumber} {scene.SceneTitle} {scene.Summary} {povLabel} {scene.PrimaryLocationName} {string.Join(' ', scene.PurposeLabels.Select(x => x.PurposeName))} {plotSearch} {assetSearch} {characterSearch} {warningSearch}"; Scene @scene.SceneNumber @scene.SceneTitle @povLabel
@scene.RevisionStatusName @if (Model.Settings.ShowWarnings) { if (scene.WarningCount > 0) { @scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s") } else { Warnings 0 } } @if (scene.DependencyCount > 0) { @scene.DependencyCount dep@(scene.DependencyCount == 1 ? "" : "s") }
@scene.TimeLabel @if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationName)) { @scene.PrimaryLocationName } @if (!string.IsNullOrWhiteSpace(metricSummary)) { @metricSummary } @if (scene.PurposeLabels.Any()) {
@foreach (var purpose in scene.PurposeLabels.Take(3)) { @purpose.PurposeName }
} @scene.Summary
} }
} }
}
} @if (Model.Settings.ShowMetricShape) {
@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")
@if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any()) {
Add scenes to begin seeing emotional and pacing shape.
} else {

Compare selected metrics across the same scene timeline. Click a point to focus that scene.

@Html.AntiForgeryToken() Show only one metric line to edit its curve.
Metric Shape Scene pacing

Metric chart could not load. The raw metric rows are still available below.

Show raw metric rows
@foreach (var graph in Model.MetricGraphs) {
@graph.MetricName
@foreach (var column in timelineColumns) { if (column.Scene is null) { No value continue; } var point = graph.Points.FirstOrDefault(x => x.SceneID == column.Scene.SceneID); if (point is null) { No value continue; } @point.Value }
}
}
} @if (Model.Settings.ShowPlotLines) { if (Model.PlotLanes.Any() && Model.OrderedScenes.Any()) {
Thread event markers by scene
@foreach (var group in Model.PlotLanes.GroupBy(x => x.PlotLine.PlotLineTypeName).OrderBy(x => x.Key)) {
@foreach (var lane in Model.PlotLanes) { if (lane.PlotLine.PlotLineTypeName != group.Key) { continue; } var laneFocusClass = Model.Filter.FocusType == "plotline" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.PlotLine.PlotLineID ? "lane-dim" : "";
@lane.PlotLine.PlotLineName
@foreach (var column in timelineColumns) { if (column.Scene is not Scene realPlotScene) {
continue; } var slot = lane.SceneSlots.FirstOrDefault(x => x.Scene.SceneID == realPlotScene.SceneID);
@foreach (var threadEvent in slot?.Events ?? Enumerable.Empty()) { var markerKind = PlotMarkerKind(threadEvent.PlotEventTypeName); var markerClass = markerKind == "resolve" ? "resolved" : markerKind == "abandon" ? "contradicted" : ""; @threadEvent.MarkerText }
}
}
}
} else {
Add visible plot lines and thread events to see narrative lanes here.
} } @if (Model.Settings.ShowStoryAssets) { if (Model.AssetLanes.Any() && Model.OrderedScenes.Any()) {
Asset event markers by scene
@foreach (var group in Model.AssetLanes.GroupBy(x => x.Asset.Importance >= 8 ? "High importance" : "Other assets")) {
@foreach (var lane in Model.AssetLanes) { if ((lane.Asset.Importance >= 8 ? "High importance" : "Other assets") != group.Key) { continue; } var assetFocusClass = Model.Filter.FocusType == "asset" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.Asset.StoryAssetID ? "lane-dim" : "";
@lane.Asset.AssetName @lane.Asset.KindName / @lane.Asset.Importance
@foreach (var column in timelineColumns) { if (column.Scene is not Scene realAssetScene) {
continue; } var slot = lane.SceneSlots.FirstOrDefault(x => x.Scene.SceneID == realAssetScene.SceneID);
@foreach (var assetEvent in slot?.Events ?? Enumerable.Empty()) { var assetWarnings = EntityWarnings(realAssetScene, "StoryAsset", assetEvent.StoryAssetID); var assetMarkerTooltip = AssetMarkerTooltip(assetEvent, lane.Asset, realAssetScene, assetWarnings); var assetMarkerClass = AssetMarkerClass(assetEvent, lane.Asset, assetWarnings); @AssetMarkerLabel(assetEvent) }
}
}
}
} else {
Add story assets and asset events to see asset lanes here.
} } @if (Model.Settings.ShowCharacterAppearances) { if (Model.CharacterLanes.Any() && Model.OrderedScenes.Any()) {
All project characters, grouped by role and activity
@foreach (var group in Model.CharacterLanes .Select((lane, index) => new { lane, index, group = CharacterGroupName(lane) }) .GroupBy(x => x.group) .OrderBy(group => CharacterGroupOrder(group.Key))) { var groupId = $"character-group-{CharacterGroupOrder(group.Key)}"; var defaultCollapsed = CharacterGroupOrder(group.Key) > 0 ? "collapsed" : null;
@foreach (var laneItem in group) { var lane = laneItem.lane; var characterFocusClass = Model.Filter.FocusType == "character" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.Character.CharacterID ? "lane-dim" : ""; var orderedLaneAppearances = lane.SceneSlots .SelectMany(slot => slot.Appearances) .OrderBy(appearance => timelineColumns.FindIndex(column => column.Scene?.SceneID == appearance.SceneID)) .ThenBy(appearance => appearance.SceneCharacterID) .ToList(); var firstAppearance = orderedLaneAppearances.FirstOrDefault(); var lastAppearance = orderedLaneAppearances.LastOrDefault();
@lane.Character.CharacterName @(lane.Character.ShortName ?? "Character")
@foreach (var column in timelineColumns) { if (column.Scene is not Scene realCharacterScene) {
continue; } var slot = lane.SceneSlots.FirstOrDefault(x => x.Scene.SceneID == realCharacterScene.SceneID);
@foreach (var appearance in slot?.Appearances ?? Enumerable.Empty()) { var characterWarnings = EntityWarnings(realCharacterScene, "Character", appearance.CharacterID); var characterMarkerTooltip = CharacterMarkerTooltip(appearance, realCharacterScene, firstAppearance, lastAppearance, characterWarnings); var characterMarkerClass = CharacterMarkerClass(appearance, firstAppearance, lastAppearance, characterWarnings); @CharacterMarkerLabel(appearance, firstAppearance, lastAppearance) }
}
}
}
} else {
Add characters to scenes to see character appearance lanes here.
} }
@if (Model.SelectedScene is not null) { }
} @section Scripts { }