1074 lines
70 KiB
Plaintext

@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);
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
};
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
@if (selectedBook is not null)
{
<span>@selectedBook.BookDisplayTitle</span>
}
<span>Timeline</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="workspace-heading">
<div>
<p class="eyebrow">Story timeline</p>
<h1>@Model.Project.ProjectName <help-icon key="timeline.overview" /></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" aria-label="Book filter">
<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>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Settings" asp-route-projectId="@Model.Project.ProjectID">Timeline Settings</a>
<a class="btn btn-outline-primary" asp-controller="Scenarios" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Create Scenario</a>
<a class="btn btn-outline-secondary" asp-controller="Archives" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Archived Items</a>
</div>
</div>
<section class="timeline-control-panel" data-timeline-controls>
<details class="timeline-filter-drawer">
<summary>Filters and focus</summary>
<help-icon key="timeline.filters" mode="inline" />
<form method="get" class="timeline-filter-grid">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<div>
<label class="form-label">Book <help-icon key="timeline.fields.bookFilter" mode="inline" /></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 <help-icon key="timeline.plotThreadLanes" mode="inline" /></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 <help-icon key="timeline.plotThreadLanes" mode="inline" /></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 <help-icon key="timeline.assetEvents" mode="inline" /></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 <help-icon key="timeline.characterAppearances" mode="inline" /></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 <help-icon key="timeline.fields.focusType" mode="inline" /></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 <help-icon key="timeline.fields.focusId" mode="inline" /></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>
<details class="timeline-control-group">
<summary>View</summary>
<div class="timeline-visibility-panel">
<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>
@if (Model.Settings.ShowSceneCards) { <label><input type="checkbox" data-timeline-toggle="scene-cards" checked /> Scene cards</label> }
@if (Model.Settings.ShowPlotLines) { <label><input type="checkbox" data-timeline-toggle="plot-lines" checked /> Plot lines</label> }
@if (Model.Settings.ShowStoryAssets) { <label><input type="checkbox" data-timeline-toggle="assets" checked /> Story assets</label> }
@if (Model.Settings.ShowCharacterAppearances) { <label><input type="checkbox" data-timeline-toggle="characters" checked /> Characters</label> }
@if (Model.Settings.ShowWarnings) { <label><input type="checkbox" data-timeline-toggle="warnings" checked /> Warnings</label> }
@if (Model.Settings.ShowMetricShape) { <label><input type="checkbox" data-timeline-toggle="metrics" checked /> Metrics</label> }
</div>
</details>
<details class="timeline-control-group">
<summary>Jump</summary>
<div class="timeline-jump-panel">
<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>
</details>
<details class="timeline-control-group">
<summary>Saved Views</summary>
<help-icon key="timeline.savedViews" mode="inline" />
<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()" />
<label class="visually-hidden" for="TimelinePresetName">View name <help-icon key="timeline.fields.savedViewName" mode="inline" /></label>
<input class="form-control form-control-sm" id="TimelinePresetName" 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>
</details>
</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 (TempData["TimelineDragMessage"] is string dragMessage)
{
<section class="timeline-focus-banner drag-message">
<strong>@dragMessage</strong>
<a class="btn btn-outline-secondary btn-sm" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Review warnings</a>
</section>
}
<section class="timeline-scroll-hint" data-timeline-scroll-hint hidden>
<span>Tip: Hold Shift while scrolling to move left and right through the timeline.</span>
<button type="button" class="btn btn-outline-secondary btn-sm" data-timeline-scroll-hint-dismiss>Dismiss</button>
</section>
<details class="timeline-drag-palette" data-drag-palette>
<summary>Drag Sources</summary>
<div class="drag-chip-group">
@if (!Model.QuickAddCharacters.Any() && !Model.QuickAddAssets.Any() && !Model.QuickAddLocations.Any())
{
<span class="muted">No Quick Add items yet. Tick “Show in Quick Add Bar” on characters, story assets or locations to add shortcuts here.</span>
}
@foreach (var character in Model.QuickAddCharacters)
{
<span class="drag-chip character" draggable="true" data-drag-type="character" data-drag-id="@character.CharacterID" data-drag-label="@character.CharacterName">@character.CharacterName</span>
}
@foreach (var asset in Model.QuickAddAssets)
{
<span class="drag-chip asset" draggable="true" data-drag-type="asset" data-drag-id="@asset.StoryAssetID" data-drag-label="@asset.AssetName">@asset.AssetName</span>
}
@foreach (var location in Model.QuickAddLocations)
{
<span class="drag-chip location" draggable="true" data-drag-type="location" data-drag-id="@location.LocationID" data-drag-label="@location.LocationName" title="@location.LocationPath">@location.LocationName</span>
}
</div>
</details>
@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);
var isActiveChapter = chapter.Scenes.Any(scene => scene.SceneID == Model.SelectedSceneID);
<button type="button"
class="@(isActiveChapter ? "is-active" : "")"
data-active-chapter="@(isActiveChapter ? "true" : "false")"
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>
}
<section class="drag-drop-panel d-none" data-drag-drop-panel>
<div class="drag-drop-panel-header">
<div>
<p class="eyebrow" data-drag-panel-kicker>Drag action</p>
<h2 data-drag-panel-title>Confirm action</h2>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-drag-cancel>Cancel</button>
</div>
<p class="muted" data-drag-panel-summary></p>
<form asp-controller="Timeline" asp-action="DropCharacter" method="post" class="drag-drop-form d-none" data-drag-form="character">
<input type="hidden" name="SceneID" />
<input type="hidden" name="CharacterID" />
<input type="hidden" name="ReturnProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.SelectedBookID" />
<div class="row g-2">
<div class="col-6"><label class="form-label">Role in scene</label><select class="form-select form-select-sm" name="RoleInSceneTypeID" asp-items="Model.CharacterRoleOptions"><option value="">Not set</option></select></div>
<div class="col-6"><label class="form-label">Presence type</label><select class="form-select form-select-sm" name="PresenceTypeID" asp-items="Model.PresenceTypeOptions"><option value="">Not set</option></select></div>
<div class="col-12"><label class="form-label">Current location</label><select class="form-select form-select-sm" name="LocationID" asp-items="Model.LocationOptions"><option value="">Optional</option></select></div>
</div>
<div class="button-row mt-2">
<button class="btn btn-primary btn-sm" type="submit">Add Character</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-drag-cancel>Cancel</button>
</div>
</form>
<form asp-controller="Timeline" asp-action="DropAssetEvent" method="post" class="drag-drop-form d-none" data-drag-form="asset-event">
<input type="hidden" name="SceneID" />
<input type="hidden" name="StoryAssetID" />
<input type="hidden" name="ReturnProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.SelectedBookID" />
<div class="row g-2">
<div class="col-6"><label class="form-label">Event type</label><select class="form-select form-select-sm" name="AssetEventTypeID" asp-items="Model.AssetEventTypeOptions"></select></div>
<div class="col-6"><label class="form-label">State transition</label><select class="form-select form-select-sm" name="ToStateID" asp-items="Model.AssetStateOptions"><option value="">Optional</option></select></div>
<div class="col-12"><label class="form-label">Location</label><select class="form-select form-select-sm" name="LocationID" asp-items="Model.LocationOptions"><option value="">Optional</option></select></div>
<div class="col-12"><label class="form-label">Event title</label><input class="form-control form-control-sm" name="EventTitle" /></div>
<div class="col-12"><label class="form-label">Custody note</label><textarea class="form-control form-control-sm" name="CustodyNote" rows="2"></textarea></div>
</div>
<div class="button-row mt-2">
<button class="btn btn-primary btn-sm" type="submit">Add Asset Event</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-drag-cancel>Cancel</button>
</div>
</form>
<form asp-controller="Timeline" asp-action="DropAssetDependency" method="post" class="drag-drop-form d-none" data-drag-form="asset-dependency">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.SelectedBookID" />
<input type="hidden" name="SourceAssetID" />
<input type="hidden" name="TargetAssetID" />
<div class="row g-2">
<div class="col-12"><label class="form-label">Dependency type</label><select class="form-select form-select-sm" name="AssetDependencyTypeID" asp-items="Model.AssetDependencyTypeOptions"></select></div>
<div class="col-6"><label class="form-label">Source required state</label><select class="form-select form-select-sm" name="SourceRequiredStateID" asp-items="Model.AssetStateOptions"><option value="">Optional</option></select></div>
<div class="col-6"><label class="form-label">Target blocked state</label><select class="form-select form-select-sm" name="TargetBlockedStateID" asp-items="Model.AssetStateOptions"><option value="">Optional</option></select></div>
<div class="col-12"><label class="form-label">Notes</label><textarea class="form-control form-control-sm" name="Description" rows="2"></textarea></div>
</div>
<p class="plain-preview" data-asset-dependency-preview></p>
<div class="button-row mt-2">
<button class="btn btn-primary btn-sm" type="submit">Save Dependency</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-drag-cancel>Cancel</button>
</div>
</form>
<form asp-controller="Timeline" asp-action="DropLocationRelationship" method="post" class="drag-drop-form d-none" data-drag-form="location-relationship">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.SelectedBookID" />
<input type="hidden" name="FromLocationID" />
<input type="hidden" name="ToLocationID" />
<div class="row g-2">
<div class="col-12"><label class="form-label">Relationship type</label><select class="form-select form-select-sm" name="LocationRelationshipTypeID" asp-items="Model.LocationRelationshipTypeOptions"></select></div>
<div class="col-12 drag-check-grid">
<label><input type="checkbox" name="IsBidirectional" value="true" /> Bidirectional</label>
<label><input type="checkbox" name="CanHearNormalSpeech" value="true" /> Can hear speech</label>
<label><input type="checkbox" name="CanHearLoudNoise" value="true" /> Can hear loud noise</label>
<label><input type="checkbox" name="CanSeeMovement" value="true" /> Can see movement</label>
<label><input type="checkbox" name="CanSeeClearly" value="true" /> Can see clearly</label>
<label><input type="checkbox" name="CanTravelDirectly" value="true" /> Can travel directly</label>
</div>
<div class="col-12"><label class="form-label">Notes</label><textarea class="form-control form-control-sm" name="Notes" rows="2"></textarea></div>
</div>
<div class="button-row mt-2">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-drag-cancel>Cancel</button>
</div>
</form>
</section>
<div class="modal fade plotline-modal" id="sceneMoveModal" tabindex="-1" aria-labelledby="sceneMoveModalTitle" aria-hidden="true" data-scene-move-modal>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content plotline-confirm-modal scene-move-modal-content">
<form asp-controller="Timeline" asp-action="DropMoveScene" method="post" data-drag-form="scene">
<div class="modal-header">
<div>
<p class="eyebrow mb-1">Scene move</p>
<h2 class="modal-title" id="sceneMoveModalTitle" data-scene-move-title>Move Scene?</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel move"></button>
</div>
<div class="modal-body">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="BookID" value="@Model.SelectedBookID" />
<input type="hidden" name="SceneID" />
<input type="hidden" name="AnchorSceneID" />
<input type="hidden" name="TargetChapterID" />
<input type="hidden" name="Position" />
<input type="hidden" name="ReturnSelectedSceneID" value="@Model.SelectedSceneID" />
<p class="scene-move-summary" data-scene-move-summary></p>
<div class="focused-validation-summary">
<span>After moving, PlotDirector will run continuity validation.</span>
<span>Existing links, events, metrics, purposes, characters, assets, warnings and dependencies stay attached to the same SceneID.</span>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit" data-scene-move-submit>Move Scene</button>
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
@if (!Model.Books.Any())
{
<section class="empty-panel">
<h2>No timeline yet</h2>
<p>Add a book, then chapters and scenes, and PlotDirector will arrange them here as your narrative spine.</p>
</section>
}
else
{
<div class="workspace-layout timeline-filtered-root @(hasSelectedScene ? "inspector-drawer-open" : "inspector-drawer-closed")" data-timeline-root>
<main class="timeline-workspace">
<section class="timeline-shell workspace-timeline" data-timeline-section="scene-cards" style="--scene-count:@timelineColumns.Count">
@if (Model.Settings.ShowSceneCards)
{
<div class="timeline-section-header" data-collapse-target="scene-cards">
<button type="button" data-collapse-toggle="scene-cards">Scene Cards</button>
<help-icon key="timeline.sceneCards" mode="inline" />
<span>@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")</span>
</div>
<div data-collapse-body="scene-cards">
<div class="timeline-series-row">
<div class="timeline-project-title">
<span>Project / Series</span>
<strong>@Model.Project.ProjectName</strong>
</div>
<div class="timeline-books">
@foreach (var book in Model.Books)
{
<div class="timeline-book" style="--book-scene-count:@BookSceneColumns(book)">
<div class="timeline-book-title">
<span>Book @book.Book.BookNumber</span>
<strong>@book.Book.BookDisplayTitle</strong>
</div>
<div class="timeline-chapters">
@if (!book.Chapters.Any())
{
<div class="timeline-chapter" style="--chapter-scene-count:1">
<div class="timeline-chapter-title">
<span>No chapters</span>
<strong>Start planning</strong>
</div>
<div class="timeline-scenes">
<div class="timeline-empty-scene timeline-placeholder-scene is-placeholder">No scenes</div>
</div>
</div>
}
else
{
@foreach (var chapter in book.Chapters)
{
<div class="timeline-chapter"
style="--chapter-scene-count:@ChapterSceneColumns(chapter)"
data-chapter-drop-target="true"
data-chapter-id="@chapter.Chapter.ChapterID"
data-chapter-label="Chapter @chapter.Chapter.ChapterNumber: @chapter.Chapter.ChapterTitle">
<div class="timeline-chapter-title">
<span>Chapter @chapter.Chapter.ChapterNumber</span>
<strong>@chapter.Chapter.ChapterTitle</strong>
</div>
<div class="timeline-scenes"
data-chapter-drop-target="true"
data-chapter-id="@chapter.Chapter.ChapterID"
data-chapter-label="Chapter @chapter.Chapter.ChapterNumber: @chapter.Chapter.ChapterTitle">
@if (!chapter.Scenes.Any())
{
<div class="timeline-empty-scene timeline-placeholder-scene is-placeholder" data-empty-chapter-drop-target="true">Drop a scene here</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"
draggable="true"
data-scene-id="@scene.SceneID"
data-drag-type="scene"
data-drag-id="@scene.SceneID"
data-drag-label="Scene @scene.SceneNumber: @scene.SceneTitle"
data-chapter-id="@chapter.Chapter.ChapterID"
data-chapter-label="Chapter @chapter.Chapter.ChapterNumber: @chapter.Chapter.ChapterTitle"
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&#10;POV: @povLabel&#10;Location: @(scene.PrimaryLocationName ?? "No location")&#10;@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 (Model.Settings.ShowWarnings)
{
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>
</div>
}
@if (Model.Settings.ShowMetricShape)
{
<div class="metric-timeline-board" data-timeline-section="metrics" style="--scene-count:@timelineColumns.Count">
<div class="lane-board-heading metric-lane-board-heading">
<button type="button" data-collapse-toggle="metrics">Metric Shape</button>
<help-icon key="timeline.metrics" mode="inline" />
<small>@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")</small>
</div>
<div data-collapse-body="metrics">
@if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any())
{
<div class="future-lane-placeholder">
Add scenes to begin seeing emotional and pacing shape.
</div>
}
else
{
<div class="metric-shape-chart metric-shape-timeline-chart" data-metric-shape-chart data-timeline-metric-editor data-update-url="@Url.Action("UpdateSceneMetricValue", "SceneMetrics")">
<div class="metric-shape-controls">
<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="timeline-metric-edit-toolbar" data-timeline-metric-edit-toolbar>
@Html.AntiForgeryToken()
<span class="timeline-metric-edit-status" data-edit-metric-status>Show only one metric line to edit its curve.</span>
<help-icon key="timeline.fields.metricEdit" mode="inline" />
</div>
</div>
<div class="metric-timeline-row">
<div class="metric-lane-label">
<strong>Metric Shape</strong>
<span>Scene pacing</span>
</div>
<div class="metric-chart-frame metric-timeline-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>
</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" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
<div class="metric-graph-label">@graph.MetricName</div>
<div class="metric-graph-points" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
@foreach (var column in timelineColumns)
{
if (column.Scene is null)
{
<span class="metric-point metric-point-placeholder timeline-placeholder-cell is-placeholder" title="@column.PlaceholderLabel"><span>No value</span></span>
continue;
}
var point = graph.Points.FirstOrDefault(x => x.SceneID == column.Scene.SceneID);
if (point is null)
{
<span class="metric-point metric-point-placeholder timeline-placeholder-cell is-placeholder" title="No metric value"><span>No value</span></span>
continue;
}
<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>
</div>
}
@if (Model.Settings.ShowPlotLines)
{
if (Model.PlotLanes.Any() && Model.OrderedScenes.Any())
{
<div class="plot-lane-board" data-timeline-section="plot-lines" data-plot-line-svg-board style="--scene-count:@timelineColumns.Count">
<svg class="plot-lane-svg-overlay" data-plot-line-svg aria-hidden="true"></svg>
<div class="lane-board-heading plot-lane-board-heading">
<button type="button" data-collapse-toggle="plot-lines">Plot line lanes</button>
<help-icon key="timeline.plotThreadLanes" mode="inline" />
<small>Thread event markers by scene</small>
<help-icon key="timeline.threadHealth" mode="inline" />
</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" data-plot-line-row data-plot-line-id="@lane.PlotLine.PlotLineID" data-plot-colour="@lane.PlotLine.Colour" 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 column in timelineColumns)
{
if (column.Scene is not Scene realPlotScene)
{
<div class="plot-lane-slot timeline-placeholder-cell is-placeholder" title="@column.PlaceholderLabel"></div>
continue;
}
var slot = lane.SceneSlots.FirstOrDefault(x => x.Scene.SceneID == realPlotScene.SceneID);
<div class="plot-lane-slot" data-plot-line-slot data-scene-id="@realPlotScene.SceneID" title="Scene @realPlotScene.SceneNumber: @realPlotScene.SceneTitle">
@foreach (var threadEvent in slot?.Events ?? Enumerable.Empty<ThreadEvent>())
{
var markerClass = threadEvent.EventTypeName is "Resolved" or "Payoff"
? "resolved"
: threadEvent.EventTypeName is "Contradicted"
? "contradicted"
: "";
<a class="thread-marker @markerClass"
data-plot-event-node
data-plot-event-type="@threadEvent.PlotEventTypeName"
data-target-plot-line-id="@threadEvent.TargetPlotLineID"
data-target-plot-line-ids="@string.Join(",", threadEvent.TargetPlotLineIDs)"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@realPlotScene.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.Settings.ShowStoryAssets)
{
if (Model.AssetLanes.Any() && Model.OrderedScenes.Any())
{
<div class="asset-lane-board" data-timeline-section="assets" style="--scene-count:@timelineColumns.Count">
<div class="lane-board-heading asset-lane-board-heading">
<button type="button" data-collapse-toggle="assets">Story asset lanes</button>
<help-icon key="timeline.assetEvents" mode="inline" />
<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" draggable="true" data-drag-type="asset" data-drag-id="@lane.Asset.StoryAssetID" data-drag-label="@lane.Asset.AssetName" data-asset-drop-target="true" data-asset-id="@lane.Asset.StoryAssetID" data-asset-label="@lane.Asset.AssetName" 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 column in timelineColumns)
{
if (column.Scene is not Scene realAssetScene)
{
<div class="asset-lane-slot timeline-placeholder-cell is-placeholder" title="@column.PlaceholderLabel"></div>
continue;
}
var slot = lane.SceneSlots.FirstOrDefault(x => x.Scene.SceneID == realAssetScene.SceneID);
<div class="asset-lane-slot" title="Scene @realAssetScene.SceneNumber: @realAssetScene.SceneTitle">
@foreach (var assetEvent in slot?.Events ?? Enumerable.Empty<AssetEvent>())
{
<a class="asset-marker"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@realAssetScene.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.Settings.ShowCharacterAppearances)
{
if (Model.CharacterLanes.Any() && Model.OrderedScenes.Any())
{
<div class="character-lane-board" data-timeline-section="characters" style="--scene-count:@timelineColumns.Count">
<div class="lane-board-heading character-lane-board-heading">
<button type="button" data-collapse-toggle="characters">Character appearances</button>
<help-icon key="timeline.characterAppearances" mode="inline" />
<small>All project characters, grouped by role and activity</small>
</div>
<div data-collapse-body="characters">
@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;
<div class="lane-group">
<button type="button" class="lane-group-heading" data-collapse-toggle="@groupId" data-collapse-default="@defaultCollapsed">@group.Key <span>@group.Count()</span></button>
<div class="character-lane-group-body" data-collapse-body="@groupId">
@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 column in timelineColumns)
{
if (column.Scene is not Scene realCharacterScene)
{
<div class="character-lane-slot timeline-placeholder-cell is-placeholder" title="@column.PlaceholderLabel"></div>
continue;
}
var slot = lane.SceneSlots.FirstOrDefault(x => x.Scene.SceneID == realCharacterScene.SceneID);
<div class="character-lane-slot" title="Scene @realCharacterScene.SceneNumber: @realCharacterScene.SceneTitle">
@foreach (var appearance in slot?.Appearances ?? Enumerable.Empty<SceneCharacter>())
{
<a class="character-marker"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@realCharacterScene.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>
</main>
@if (Model.SelectedScene is not null)
{
<div class="timeline-inspector-resizer" data-inspector-resizer role="separator" aria-orientation="vertical" aria-label="Resize scene inspector" tabindex="0"></div>
<aside class="scene-inspector-panel" data-inspector-drawer>
<div class="inspector-header">
<div>
<p class="eyebrow">Scene inspector</p>
<h2>@Model.SelectedScene.SceneTitle</h2>
</div>
<a class="btn btn-outline-secondary btn-sm" href="@Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = Model.SelectedBookID })" data-inspector-close>Close</a>
</div>
<div class="scene-inspector-body">
<partial name="~/Views/Scenes/_SceneInspector.cshtml" model="Model.SelectedScene" />
</div>
</aside>
}
</div>
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}