@model TimelineViewModel @{ ViewData["Title"] = "Timeline"; ViewData["ShellClass"] = "plotline-shell-wide"; var selectedBook = Model.Books.FirstOrDefault(x => x.Book.BookID == Model.SelectedBookID)?.Book; var timelineMetricScenes = Model.Books .SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => new { sceneId = scene.SceneID, axisLabel = $"{chapter.Chapter.ChapterNumber:g}.{scene.SceneNumber:g}", chapterLabel = $"Chapter {chapter.Chapter.ChapterNumber:g}", sceneNumber = scene.SceneNumber.ToString("g"), sceneTitle = scene.SceneTitle, bookTitle = book.Book.BookTitle, timelineUrl = Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = book.Book.BookID, selectedSceneId = scene.SceneID }) }))) .ToList(); var timelineMetricShapePayload = new { scenes = timelineMetricScenes, metrics = Model.MetricGraphs.Select(graph => new { metricName = graph.MetricName, points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value }) }) }; var hasActiveFilter = Model.MatchingSceneIDs.Count != Model.OrderedScenes.Count || !string.IsNullOrWhiteSpace(Model.FocusLabel); var warningScenes = Model.OrderedScenes.Where(x => x.WarningCount > 0).ToList(); }
Workspace context @Model.Project.ProjectName@if (selectedBook is not null) { / @selectedBook.BookTitle }

Story timeline

@Model.Project.ProjectName

@if (Model.SelectedBookID.HasValue) {
} Warnings Story health
Filters and focus
Clear filters
View
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); } }
}

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 books, chapters and scenes to see them arranged here in narrative order.

} else {
@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")
@foreach (var book in Model.Books) {
Book @book.Book.BookNumber: @book.Book.BookTitle
@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 (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.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 slot in lane.SceneSlots) {
@foreach (var threadEvent in slot.Events) { 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.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 slot in lane.SceneSlots) {
@foreach (var assetEvent in slot.Events) { @assetEvent.MarkerText }
}
}
}
} else {
Add story assets and asset events to see asset lanes here.
} @if (Model.CharacterLanes.Any() && Model.OrderedScenes.Any()) {
First twelve project characters
@foreach (var group in Model.CharacterLanes.Select((lane, index) => new { lane, group = index < 6 ? "Major characters" : "Secondary characters" }).GroupBy(x => x.group)) {
@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" : ""; }
}
} else {
Add characters to scenes to see character appearance lanes here.
}

Pacing

@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 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 point in graph.Points) { @point.Value }
}
}
} @section Scripts { }