710 lines
45 KiB
Plaintext
710 lines
45 KiB
Plaintext
@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();
|
|
}
|
|
|
|
<section class="context-header">
|
|
<div class="context-title">
|
|
<span class="eyebrow">Workspace context</span>
|
|
<strong>@Model.Project.ProjectName@if (selectedBook is not null) { <span> / @selectedBook.BookTitle</span> }</strong>
|
|
</div>
|
|
<nav class="context-nav" aria-label="Workspace navigation">
|
|
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Project</a>
|
|
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Timeline</a>
|
|
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Story health</a>
|
|
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Warnings</a>
|
|
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
|
|
<a asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
|
|
<a asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
|
|
<a asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Threads</a>
|
|
</nav>
|
|
</section>
|
|
|
|
<div class="workspace-heading">
|
|
<div>
|
|
<p class="eyebrow">Story timeline</p>
|
|
<h1>@Model.Project.ProjectName</h1>
|
|
</div>
|
|
<form method="get" class="timeline-filter">
|
|
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
|
|
<select name="BookID" asp-items="Model.BookOptions" class="form-select">
|
|
<option value="">All books</option>
|
|
</select>
|
|
<button class="btn btn-outline-primary" type="submit">View</button>
|
|
</form>
|
|
<div class="button-row">
|
|
<form asp-controller="Warnings" asp-action="ValidateProject" method="post">
|
|
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
|
<input type="hidden" name="bookId" value="@Model.SelectedBookID" />
|
|
<button class="btn btn-outline-primary" type="submit">Validate project</button>
|
|
</form>
|
|
@if (Model.SelectedBookID.HasValue)
|
|
{
|
|
<form asp-controller="Warnings" asp-action="ValidateBook" method="post">
|
|
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
|
<input type="hidden" name="bookId" value="@Model.SelectedBookID" />
|
|
<button class="btn btn-outline-primary" type="submit">Validate book</button>
|
|
</form>
|
|
}
|
|
<a class="btn btn-outline-secondary" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Warnings</a>
|
|
<a class="btn btn-outline-secondary" asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Story health</a>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="timeline-control-panel" data-timeline-controls>
|
|
<details class="timeline-filter-drawer" @(hasActiveFilter ? "open" : "")>
|
|
<summary>Filters and focus</summary>
|
|
<form method="get" class="timeline-filter-grid">
|
|
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
|
|
<div>
|
|
<label class="form-label">Book</label>
|
|
<select name="BookID" asp-items="Model.BookOptions" class="form-select form-select-sm">
|
|
<option value="">All books</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">From chapter</label>
|
|
<select name="ChapterFromID" asp-items="Model.ChapterFromOptions" class="form-select form-select-sm">
|
|
<option value="">Start</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">To chapter</label>
|
|
<select name="ChapterToID" asp-items="Model.ChapterToOptions" class="form-select form-select-sm">
|
|
<option value="">End</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">From scene</label>
|
|
<select name="SceneFromID" asp-items="Model.SceneFromOptions" class="form-select form-select-sm">
|
|
<option value="">Start</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">To scene</label>
|
|
<select name="SceneToID" asp-items="Model.SceneToOptions" class="form-select form-select-sm">
|
|
<option value="">End</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Plot line</label>
|
|
<select name="PlotLineID" asp-items="Model.PlotLineOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Plot thread</label>
|
|
<select name="PlotThreadID" asp-items="Model.PlotThreadOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Story asset</label>
|
|
<select name="StoryAssetID" asp-items="Model.AssetOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Character</label>
|
|
<select name="CharacterID" asp-items="Model.CharacterOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Location</label>
|
|
<select name="LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Warning severity</label>
|
|
<select name="WarningSeverityID" asp-items="Model.WarningSeverityOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Warning type</label>
|
|
<select name="WarningTypeID" asp-items="Model.WarningTypeOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Revision status</label>
|
|
<select name="RevisionStatusID" asp-items="Model.RevisionStatusOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Scene purpose</label>
|
|
<select name="ScenePurposeTypeID" asp-items="Model.ScenePurposeOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">POV character</label>
|
|
<select name="POVCharacterID" asp-items="Model.PovCharacterOptions" class="form-select form-select-sm"><option value="">Any</option></select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Focus type</label>
|
|
<select name="FocusType" class="form-select form-select-sm">
|
|
<option value="">No focus</option>
|
|
<option value="character" selected="@(Model.Filter.FocusType == "character")">Character</option>
|
|
<option value="asset" selected="@(Model.Filter.FocusType == "asset")">Story asset</option>
|
|
<option value="plotline" selected="@(Model.Filter.FocusType == "plotline")">Plot line</option>
|
|
<option value="plotthread" selected="@(Model.Filter.FocusType == "plotthread")">Plot thread</option>
|
|
<option value="location" selected="@(Model.Filter.FocusType == "location")">Location</option>
|
|
<option value="scene" selected="@(Model.Filter.FocusType == "scene")">Scene</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label">Focus ID</label>
|
|
<input name="FocusID" value="@Model.Filter.FocusID" class="form-control form-control-sm" placeholder="Entity ID" />
|
|
</div>
|
|
<div class="timeline-filter-checks">
|
|
<label><input type="checkbox" name="ShowWarningsOnly" value="true" checked="@Model.Filter.ShowWarningsOnly" /> Warnings only</label>
|
|
<label><input type="checkbox" name="IncludeDismissedWarnings" value="true" checked="@Model.Filter.IncludeDismissedWarnings" /> Include dismissed</label>
|
|
<label><input type="checkbox" name="IncludeIntentionalWarnings" value="true" checked="@Model.Filter.IncludeIntentionalWarnings" /> Include intentional</label>
|
|
</div>
|
|
<div class="timeline-filter-actions">
|
|
<button class="btn btn-outline-primary btn-sm" type="submit">Apply filters</button>
|
|
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Clear filters</a>
|
|
</div>
|
|
</form>
|
|
</details>
|
|
|
|
<div class="timeline-visibility-panel">
|
|
<span class="eyebrow">Visibility</span>
|
|
<label class="timeline-control-select">Density
|
|
<select class="form-select form-select-sm" data-timeline-density>
|
|
<option value="compact">Compact</option>
|
|
<option value="comfortable">Comfortable</option>
|
|
<option value="expanded">Expanded</option>
|
|
</select>
|
|
</label>
|
|
<label class="timeline-control-select">Zoom
|
|
<select class="form-select form-select-sm" data-timeline-zoom>
|
|
<option value="book">Book</option>
|
|
<option value="chapter">Chapter</option>
|
|
<option value="scene-detail">Scene Detail</option>
|
|
</select>
|
|
</label>
|
|
<label><input type="checkbox" data-timeline-toggle="scene-cards" checked /> Scene cards</label>
|
|
<label><input type="checkbox" data-timeline-toggle="plot-lines" checked /> Plot lines</label>
|
|
<label><input type="checkbox" data-timeline-toggle="assets" checked /> Story assets</label>
|
|
<label><input type="checkbox" data-timeline-toggle="characters" checked /> Characters</label>
|
|
<label><input type="checkbox" data-timeline-toggle="locations" checked /> Locations</label>
|
|
<label><input type="checkbox" data-timeline-toggle="warnings" checked /> Warnings</label>
|
|
<label><input type="checkbox" data-timeline-toggle="metrics" checked /> Metrics</label>
|
|
</div>
|
|
|
|
<div class="timeline-jump-panel">
|
|
<span class="eyebrow">Jump / search</span>
|
|
<input class="form-control form-control-sm" type="search" placeholder="Search timeline..." data-timeline-search />
|
|
<select class="form-select form-select-sm" data-timeline-jump>
|
|
<option value="">Jump to scene</option>
|
|
@foreach (var book in Model.Books)
|
|
{
|
|
@foreach (var chapter in book.Chapters)
|
|
{
|
|
@foreach (var scene in chapter.Scenes)
|
|
{
|
|
<option value="@scene.SceneID">Ch @chapter.Chapter.ChapterNumber / Scene @scene.SceneNumber: @scene.SceneTitle</option>
|
|
}
|
|
}
|
|
}
|
|
</select>
|
|
<select class="form-select form-select-sm" data-timeline-warning-jump>
|
|
<option value="">Jump to warning</option>
|
|
@foreach (var scene in warningScenes)
|
|
{
|
|
<option value="@scene.SceneID">@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s") - Scene @scene.SceneNumber: @scene.SceneTitle</option>
|
|
}
|
|
</select>
|
|
<select class="form-select form-select-sm" data-timeline-entity-jump>
|
|
<option value="">Jump to lane item</option>
|
|
@foreach (var lane in Model.PlotLanes)
|
|
{
|
|
var firstScene = lane.SceneSlots.FirstOrDefault(x => x.Events.Any())?.Scene;
|
|
if (firstScene is not null)
|
|
{
|
|
<option value="@firstScene.SceneID">Plot line: @lane.PlotLine.PlotLineName</option>
|
|
}
|
|
}
|
|
@foreach (var lane in Model.AssetLanes)
|
|
{
|
|
var firstScene = lane.SceneSlots.FirstOrDefault(x => x.Events.Any())?.Scene;
|
|
if (firstScene is not null)
|
|
{
|
|
<option value="@firstScene.SceneID">Asset: @lane.Asset.AssetName</option>
|
|
}
|
|
}
|
|
@foreach (var lane in Model.CharacterLanes)
|
|
{
|
|
var firstScene = lane.SceneSlots.FirstOrDefault(x => x.Appearances.Any())?.Scene;
|
|
if (firstScene is not null)
|
|
{
|
|
<option value="@firstScene.SceneID">Character: @lane.Character.CharacterName</option>
|
|
}
|
|
}
|
|
@foreach (var locationScene in Model.OrderedScenes.Where(x => !string.IsNullOrWhiteSpace(x.PrimaryLocationName)).GroupBy(x => x.PrimaryLocationName).Select(x => x.First()))
|
|
{
|
|
<option value="@locationScene.SceneID">Location: @locationScene.PrimaryLocationName</option>
|
|
}
|
|
</select>
|
|
<span class="timeline-shortcut-hint">Shortcuts: arrows scroll, +/- zoom, Esc clears focus</span>
|
|
</div>
|
|
|
|
<div class="timeline-preset-panel">
|
|
<form asp-action="SavePreset" method="post" data-timeline-preset-form>
|
|
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
|
|
<input type="hidden" name="BookID" value="@Model.SelectedBookID" />
|
|
<input type="hidden" name="VisibilityJson" value="{}" data-visibility-json />
|
|
<input type="hidden" name="Filter.ProjectID" value="@Model.Project.ProjectID" />
|
|
<input type="hidden" name="Filter.BookID" value="@Model.Filter.BookID" />
|
|
<input type="hidden" name="Filter.SelectedSceneID" value="@Model.Filter.SelectedSceneID" />
|
|
<input type="hidden" name="Filter.ChapterFromID" value="@Model.Filter.ChapterFromID" />
|
|
<input type="hidden" name="Filter.ChapterToID" value="@Model.Filter.ChapterToID" />
|
|
<input type="hidden" name="Filter.SceneFromID" value="@Model.Filter.SceneFromID" />
|
|
<input type="hidden" name="Filter.SceneToID" value="@Model.Filter.SceneToID" />
|
|
<input type="hidden" name="Filter.PlotLineID" value="@Model.Filter.PlotLineID" />
|
|
<input type="hidden" name="Filter.PlotThreadID" value="@Model.Filter.PlotThreadID" />
|
|
<input type="hidden" name="Filter.StoryAssetID" value="@Model.Filter.StoryAssetID" />
|
|
<input type="hidden" name="Filter.CharacterID" value="@Model.Filter.CharacterID" />
|
|
<input type="hidden" name="Filter.LocationID" value="@Model.Filter.LocationID" />
|
|
<input type="hidden" name="Filter.WarningSeverityID" value="@Model.Filter.WarningSeverityID" />
|
|
<input type="hidden" name="Filter.WarningTypeID" value="@Model.Filter.WarningTypeID" />
|
|
<input type="hidden" name="Filter.RevisionStatusID" value="@Model.Filter.RevisionStatusID" />
|
|
<input type="hidden" name="Filter.ScenePurposeTypeID" value="@Model.Filter.ScenePurposeTypeID" />
|
|
<input type="hidden" name="Filter.POVCharacterID" value="@Model.Filter.POVCharacterID" />
|
|
<input type="hidden" name="Filter.FocusType" value="@Model.Filter.FocusType" />
|
|
<input type="hidden" name="Filter.FocusID" value="@Model.Filter.FocusID" />
|
|
<input type="hidden" name="Filter.ShowWarningsOnly" value="@Model.Filter.ShowWarningsOnly.ToString().ToLowerInvariant()" />
|
|
<input type="hidden" name="Filter.IncludeDismissedWarnings" value="@Model.Filter.IncludeDismissedWarnings.ToString().ToLowerInvariant()" />
|
|
<input type="hidden" name="Filter.IncludeIntentionalWarnings" value="@Model.Filter.IncludeIntentionalWarnings.ToString().ToLowerInvariant()" />
|
|
<input class="form-control form-control-sm" name="PresetName" placeholder="View name" />
|
|
<button class="btn btn-outline-primary btn-sm" type="submit">Save view</button>
|
|
</form>
|
|
@if (Model.ViewPresets.Any())
|
|
{
|
|
<div class="preset-chip-row">
|
|
@foreach (var preset in Model.ViewPresets)
|
|
{
|
|
<span class="preset-chip">
|
|
<a asp-action="LoadPreset" asp-route-id="@preset.TimelineViewPresetID">@preset.PresetName</a>
|
|
<form asp-action="DeletePreset" method="post">
|
|
<input type="hidden" name="id" value="@preset.TimelineViewPresetID" />
|
|
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
|
<input type="hidden" name="bookId" value="@Model.SelectedBookID" />
|
|
<button type="submit" title="Delete view">x</button>
|
|
</form>
|
|
</span>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(Model.FocusLabel))
|
|
{
|
|
<section class="timeline-focus-banner">
|
|
<strong>Focus: @Model.FocusLabel</strong>
|
|
<span>@Model.FocusSceneIDs.Count matching scene@(Model.FocusSceneIDs.Count == 1 ? "" : "s")</span>
|
|
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Clear focus</a>
|
|
</section>
|
|
}
|
|
|
|
@if (Model.Books.Any())
|
|
{
|
|
<section class="timeline-minimap" data-timeline-minimap>
|
|
@foreach (var book in Model.Books)
|
|
{
|
|
@foreach (var chapter in book.Chapters)
|
|
{
|
|
var chapterWarnings = chapter.Scenes.Sum(x => x.WarningCount);
|
|
<button type="button"
|
|
data-jump-scene="@chapter.Scenes.FirstOrDefault()?.SceneID"
|
|
style="--chapter-scenes:@Math.Max(chapter.Scenes.Count, 1); --chapter-warnings:@Math.Min(chapterWarnings, 8)"
|
|
title="Chapter @chapter.Chapter.ChapterNumber: @chapter.Chapter.ChapterTitle (@chapter.Scenes.Count scenes, @chapterWarnings warnings)">
|
|
<span>Ch @chapter.Chapter.ChapterNumber</span>
|
|
</button>
|
|
}
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@if (!Model.Books.Any())
|
|
{
|
|
<section class="empty-panel">
|
|
<h2>No timeline yet</h2>
|
|
<p>Add books, chapters and scenes to see them arranged here in narrative order.</p>
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<div class="workspace-layout timeline-filtered-root" data-timeline-root>
|
|
<main class="timeline-workspace">
|
|
<section class="timeline-shell workspace-timeline" data-timeline-section="scene-cards">
|
|
<div class="timeline-section-header" data-collapse-target="scene-cards">
|
|
<button type="button" data-collapse-toggle="scene-cards">Scene Cards</button>
|
|
<span>@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")</span>
|
|
</div>
|
|
<div data-collapse-body="scene-cards">
|
|
@foreach (var book in Model.Books)
|
|
{
|
|
<div class="timeline-book">
|
|
<div class="timeline-book-row">
|
|
<div class="timeline-book-title">Book @book.Book.BookNumber: @book.Book.BookTitle</div>
|
|
<div class="timeline-chapters">
|
|
@foreach (var chapter in book.Chapters)
|
|
{
|
|
<div class="timeline-chapter" style="--chapter-scene-count:@Math.Max(chapter.Scenes.Count, 1)">
|
|
<div class="timeline-chapter-title">
|
|
<span>Chapter @chapter.Chapter.ChapterNumber</span>
|
|
<strong>@chapter.Chapter.ChapterTitle</strong>
|
|
</div>
|
|
<div class="timeline-scenes">
|
|
@if (!chapter.Scenes.Any())
|
|
{
|
|
<div class="timeline-empty-scene">No scenes</div>
|
|
}
|
|
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}";
|
|
<a class="timeline-scene-card @selectedClass @matchClass @focusClass"
|
|
data-scene-id="@scene.SceneID"
|
|
data-timeline-search-text="@searchText"
|
|
asp-controller="Timeline"
|
|
asp-action="Index"
|
|
asp-route-ProjectID="@Model.Project.ProjectID"
|
|
asp-route-BookID="@Model.SelectedBookID"
|
|
asp-route-SelectedSceneID="@scene.SceneID"
|
|
asp-route-FocusType="scene"
|
|
asp-route-FocusID="@scene.SceneID"
|
|
title="Scene @scene.SceneNumber: @scene.SceneTitle POV: @povLabel Location: @(scene.PrimaryLocationName ?? "No location") @scene.Summary">
|
|
<span class="scene-number">Scene @scene.SceneNumber</span>
|
|
<strong>@scene.SceneTitle</strong>
|
|
<span class="scene-pov-label">@povLabel</span>
|
|
<div class="scene-badge-row">
|
|
<span class="status-pill">@scene.RevisionStatusName</span>
|
|
@if (scene.WarningCount > 0)
|
|
{
|
|
<span class="warning-placeholder active" data-warning-marker>@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s")</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="warning-placeholder">Warnings 0</span>
|
|
}
|
|
@if (scene.DependencyCount > 0)
|
|
{
|
|
<span class="dependency-placeholder">@scene.DependencyCount dep@(scene.DependencyCount == 1 ? "" : "s")</span>
|
|
}
|
|
</div>
|
|
<span class="time-label">@scene.TimeLabel</span>
|
|
@if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationName))
|
|
{
|
|
<span class="location-pill" data-location-marker title="@scene.PrimaryLocationPath">@scene.PrimaryLocationName</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(metricSummary))
|
|
{
|
|
<span class="metric-mini-summary">@metricSummary</span>
|
|
}
|
|
@if (scene.PurposeLabels.Any())
|
|
{
|
|
<div class="purpose-chip-row">
|
|
@foreach (var purpose in scene.PurposeLabels.Take(3))
|
|
{
|
|
<span class="purpose-chip">@purpose.PurposeName</span>
|
|
}
|
|
</div>
|
|
}
|
|
<span class="scene-summary">@scene.Summary</span>
|
|
</a>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
@if (Model.PlotLanes.Any() && Model.OrderedScenes.Any())
|
|
{
|
|
<div class="plot-lane-board" data-timeline-section="plot-lines" style="--scene-count:@Model.OrderedScenes.Count">
|
|
<div class="plot-lane-board-heading">
|
|
<button type="button" data-collapse-toggle="plot-lines">Plot line lanes</button>
|
|
<small>Thread event markers by scene</small>
|
|
</div>
|
|
<div data-collapse-body="plot-lines">
|
|
@foreach (var group in Model.PlotLanes.GroupBy(x => x.PlotLine.PlotLineTypeName).OrderBy(x => x.Key))
|
|
{
|
|
<div class="lane-group">
|
|
<button type="button" class="lane-group-heading" data-collapse-toggle="plot-group-@group.Key.GetHashCode()">@group.Key</button>
|
|
<div data-collapse-body="plot-group-@group.Key.GetHashCode()">
|
|
@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" : "";
|
|
<div class="plot-lane-row @laneFocusClass" style="--plot-colour:@lane.PlotLine.Colour">
|
|
<a class="plot-lane-label" asp-controller="PlotLines" asp-action="Edit" asp-route-id="@lane.PlotLine.PlotLineID">
|
|
<span class="colour-swatch" style="background:@lane.PlotLine.Colour"></span>
|
|
<strong>@lane.PlotLine.PlotLineName</strong>
|
|
</a>
|
|
<div class="plot-lane-slots">
|
|
@foreach (var slot in lane.SceneSlots)
|
|
{
|
|
<div class="plot-lane-slot" title="Scene @slot.Scene.SceneNumber: @slot.Scene.SceneTitle">
|
|
@foreach (var threadEvent in slot.Events)
|
|
{
|
|
var markerClass = threadEvent.EventTypeName is "Resolved" or "Payoff"
|
|
? "resolved"
|
|
: threadEvent.EventTypeName is "Contradicted"
|
|
? "contradicted"
|
|
: "";
|
|
<a class="thread-marker @markerClass"
|
|
asp-controller="Timeline"
|
|
asp-action="Index"
|
|
asp-route-ProjectID="@Model.Project.ProjectID"
|
|
asp-route-BookID="@Model.SelectedBookID"
|
|
asp-route-SelectedSceneID="@slot.Scene.SceneID"
|
|
asp-route-FocusType="plotthread"
|
|
asp-route-FocusID="@threadEvent.PlotThreadID"
|
|
title="@threadEvent.ThreadTitle: @threadEvent.EventTitle">
|
|
@threadEvent.MarkerText
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="future-lane-placeholder">
|
|
Add visible plot lines and thread events to see narrative lanes here.
|
|
</div>
|
|
}
|
|
@if (Model.AssetLanes.Any() && Model.OrderedScenes.Any())
|
|
{
|
|
<div class="asset-lane-board" data-timeline-section="assets" style="--scene-count:@Model.OrderedScenes.Count">
|
|
<div class="plot-lane-board-heading">
|
|
<button type="button" data-collapse-toggle="assets">Story asset lanes</button>
|
|
<small>Asset event markers by scene</small>
|
|
</div>
|
|
<div data-collapse-body="assets">
|
|
@foreach (var group in Model.AssetLanes.GroupBy(x => x.Asset.Importance >= 8 ? "High importance" : "Other assets"))
|
|
{
|
|
<div class="lane-group">
|
|
<button type="button" class="lane-group-heading" data-collapse-toggle="asset-group-@group.Key.GetHashCode()">@group.Key</button>
|
|
<div data-collapse-body="asset-group-@group.Key.GetHashCode()">
|
|
@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" : "";
|
|
<div class="asset-lane-row @assetFocusClass">
|
|
<a class="asset-lane-label" asp-controller="StoryAssets" asp-action="Details" asp-route-id="@lane.Asset.StoryAssetID">
|
|
<strong>@lane.Asset.AssetName</strong>
|
|
<span>@lane.Asset.KindName / @lane.Asset.Importance</span>
|
|
</a>
|
|
<div class="asset-lane-slots">
|
|
@foreach (var slot in lane.SceneSlots)
|
|
{
|
|
<div class="asset-lane-slot" title="Scene @slot.Scene.SceneNumber: @slot.Scene.SceneTitle">
|
|
@foreach (var assetEvent in slot.Events)
|
|
{
|
|
<a class="asset-marker"
|
|
asp-controller="Timeline"
|
|
asp-action="Index"
|
|
asp-route-ProjectID="@Model.Project.ProjectID"
|
|
asp-route-BookID="@Model.SelectedBookID"
|
|
asp-route-SelectedSceneID="@slot.Scene.SceneID"
|
|
asp-route-FocusType="asset"
|
|
asp-route-FocusID="@assetEvent.StoryAssetID"
|
|
title="@assetEvent.AssetName: @assetEvent.EventTitle">
|
|
@assetEvent.MarkerText
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="future-lane-placeholder">
|
|
Add story assets and asset events to see asset lanes here.
|
|
</div>
|
|
}
|
|
@if (Model.CharacterLanes.Any() && Model.OrderedScenes.Any())
|
|
{
|
|
<div class="character-lane-board" data-timeline-section="characters" style="--scene-count:@Model.OrderedScenes.Count">
|
|
<div class="plot-lane-board-heading">
|
|
<button type="button" data-collapse-toggle="characters">Character appearances</button>
|
|
<small>First twelve project characters</small>
|
|
</div>
|
|
<div data-collapse-body="characters">
|
|
@foreach (var group in Model.CharacterLanes.Select((lane, index) => new { lane, group = index < 6 ? "Major characters" : "Secondary characters" }).GroupBy(x => x.group))
|
|
{
|
|
<div class="lane-group">
|
|
<button type="button" class="lane-group-heading" data-collapse-toggle="character-group-@group.Key.GetHashCode()">@group.Key</button>
|
|
<div data-collapse-body="character-group-@group.Key.GetHashCode()">
|
|
@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" : "";
|
|
<div class="character-lane-row @characterFocusClass">
|
|
<a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID">
|
|
<strong>@lane.Character.CharacterName</strong>
|
|
<span>@(lane.Character.ShortName ?? "Character")</span>
|
|
</a>
|
|
<div class="character-lane-slots">
|
|
@foreach (var slot in lane.SceneSlots)
|
|
{
|
|
<div class="character-lane-slot" title="Scene @slot.Scene.SceneNumber: @slot.Scene.SceneTitle">
|
|
@foreach (var appearance in slot.Appearances)
|
|
{
|
|
<a class="character-marker"
|
|
asp-controller="Timeline"
|
|
asp-action="Index"
|
|
asp-route-ProjectID="@Model.Project.ProjectID"
|
|
asp-route-BookID="@Model.SelectedBookID"
|
|
asp-route-SelectedSceneID="@slot.Scene.SceneID"
|
|
asp-route-FocusType="character"
|
|
asp-route-FocusID="@appearance.CharacterID"
|
|
title="@appearance.CharacterName: @appearance.RoleInSceneTypeName / @appearance.PresenceTypeName">
|
|
@(!string.IsNullOrWhiteSpace(appearance.PresenceTypeName) ? appearance.PresenceTypeName.Substring(0, 1) : "C")
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="future-lane-placeholder">
|
|
Add characters to scenes to see character appearance lanes here.
|
|
</div>
|
|
}
|
|
</section>
|
|
|
|
<section class="pacing-panel" data-timeline-section="metrics">
|
|
<div class="pacing-heading">
|
|
<div>
|
|
<p class="eyebrow">Pacing</p>
|
|
<h2><button type="button" data-collapse-toggle="metrics">Metric shape</button></h2>
|
|
</div>
|
|
<span class="muted">@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")</span>
|
|
</div>
|
|
<div data-collapse-body="metrics">
|
|
@if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any())
|
|
{
|
|
<p class="muted">Add scenes to begin seeing emotional and pacing shape.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="metric-shape-chart" data-metric-shape-chart>
|
|
<p class="metric-shape-intro">Compare selected metrics across the same scene timeline. Click a point to focus that scene.</p>
|
|
<div class="metric-toggle-row" data-metric-toggles></div>
|
|
<div class="metric-chart-frame">
|
|
<canvas aria-label="Comparative scene metric line graph"></canvas>
|
|
<p class="muted d-none" data-chart-fallback>Metric chart could not load. The raw metric rows are still available below.</p>
|
|
</div>
|
|
<script type="application/json">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(timelineMetricShapePayload))</script>
|
|
</div>
|
|
<details class="metric-raw-details">
|
|
<summary>Show raw metric rows</summary>
|
|
<div class="metric-graph-stack mt-2">
|
|
@foreach (var graph in Model.MetricGraphs)
|
|
{
|
|
<div class="metric-graph-row">
|
|
<div class="metric-graph-label">@graph.MetricName</div>
|
|
<div class="metric-graph-points" style="--scene-count:@Math.Max(graph.Points.Count, 1)">
|
|
@foreach (var point in graph.Points)
|
|
{
|
|
<a asp-controller="Timeline"
|
|
asp-action="Index"
|
|
asp-route-projectId="@Model.Project.ProjectID"
|
|
asp-route-bookId="@Model.SelectedBookID"
|
|
asp-route-selectedSceneId="@point.SceneID"
|
|
class="metric-point"
|
|
style="--metric-height:@point.Percent%"
|
|
title="Scene @point.SceneNumber: @point.SceneTitle - @point.Value">
|
|
<span>@point.Value</span>
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</details>
|
|
}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<aside class="scene-inspector-panel">
|
|
@if (Model.SelectedScene is null)
|
|
{
|
|
<div class="inspector-empty">
|
|
<p class="eyebrow">Scene inspector</p>
|
|
<h2>Select a scene</h2>
|
|
<p>Click a scene card to edit its summary, timing, purposes, outcomes and metric sliders without leaving the timeline.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="inspector-header">
|
|
<p class="eyebrow">Scene inspector</p>
|
|
<h2>@Model.SelectedScene.SceneTitle</h2>
|
|
</div>
|
|
<partial name="~/Views/Scenes/_SceneInspector.cshtml" model="Model.SelectedScene" />
|
|
}
|
|
</aside>
|
|
</div>
|
|
}
|
|
|
|
@section Scripts {
|
|
<partial name="_ValidationScriptsPartial" />
|
|
}
|