@model TimelineViewModel @{ ViewData["Title"] = "Timeline"; ViewData["ShellClass"] = "plotline-shell-wide"; 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.BookTitle : $"Chapter {column.Chapter.Chapter.ChapterNumber:g}", sceneNumber = column.Scene?.SceneNumber.ToString("g"), sceneTitle = column.Scene?.SceneTitle ?? column.PlaceholderLabel, bookTitle = column.Book.Book.BookTitle, 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 { metricName = graph.MetricName, 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); 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 }; }
Workspace context @Model.Project.ProjectName@if (selectedBook is not null) { / @selectedBook.BookTitle }

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: 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
@foreach (var character in Model.CharacterOptions.Take(10)) { @character.Text } @foreach (var asset in Model.AssetOptions.Take(10)) { @asset.Text } @foreach (var location in Model.LocationOptions.Take(10)) { @location.Text }
@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

After moving, PlotLine will run continuity validation. Existing links, events, metrics, purposes, characters, assets, warnings and dependencies stay attached to the same SceneID.
Review Warnings

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

No timeline yet

Add a book, then chapters and scenes, and PlotLine 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.BookTitle
@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()) {
No scenes
} 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.

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 markerClass = threadEvent.EventTypeName is "Resolved" or "Payoff" ? "resolved" : threadEvent.EventTypeName is "Contradicted" ? "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()) { @assetEvent.MarkerText }
}
}
}
} 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" : "";
@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); }
}
}
} else {
Add characters to scenes to see character appearance lanes here.
} }
@if (Model.SelectedScene is not null) { }
} @section Scripts { }