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.
@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
};
}
Story timeline
Drag action
Add a book, then chapters and scenes, and PlotLine will arrange them here as your narrative spine.
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.