1566 lines
100 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);
int HiddenCount<T>(IReadOnlyList<T> items, int visibleCount) => Math.Max(0, items.Count - visibleCount);
string PlotMarkerKind(string? plotEventTypeName) => (plotEventTypeName ?? "Progress").Trim().ToLowerInvariant() switch
{
"start" => "start",
"reveal" => "reveal",
"twist" => "twist",
"branch" => "branch",
"split" => "split",
"merge" => "merge",
"resolve" => "resolve",
"abandon" => "abandon",
_ => "progress"
};
string PlotMarkerIcon(string? plotEventTypeName) => PlotMarkerKind(plotEventTypeName) switch
{
"start" => ">",
"progress" => "+",
"reveal" => "o",
"twist" => "!",
"branch" => "B",
"split" => "Y",
"merge" => "M",
"resolve" => "OK",
"abandon" => "x",
_ => "+"
};
string ImportanceLabel(int importance) => importance switch
{
>= 8 => $"High ({importance})",
>= 4 => $"Medium ({importance})",
> 0 => $"Low ({importance})",
_ => "Not set"
};
string ThreadEventTooltip(ThreadEvent threadEvent, BookTimelineViewModel book, ChapterTimelineViewModel? chapter, Scene scene)
{
var lines = new List<string>
{
$"Thread: {threadEvent.ThreadTitle}",
$"Event: {threadEvent.PlotEventTypeName}",
$"Book: {book.Book.BookDisplayTitle}",
$"Chapter: {(chapter is null ? "No chapter" : $"{chapter.Chapter.ChapterNumber:g}: {chapter.Chapter.ChapterTitle}")}",
$"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}",
$"Importance: {ImportanceLabel(threadEvent.Importance)}",
$"Status: {(string.IsNullOrWhiteSpace(threadEvent.ThreadStatusName) ? "Unknown" : threadEvent.ThreadStatusName)}"
};
if (!string.IsNullOrWhiteSpace(threadEvent.EventTitle))
{
lines.Add($"Marker: {threadEvent.EventTitle}");
}
if (!string.IsNullOrWhiteSpace(threadEvent.EventDescription))
{
lines.Add($"Summary: {threadEvent.EventDescription}");
}
return string.Join(Environment.NewLine, lines);
}
string SuggestedWarningAction(ContinuityWarning warning)
{
var warningText = $"{warning.WarningTypeName} {warning.Message} {warning.Details}".ToLowerInvariant();
if (warningText.Contains("dormant") || warningText.Contains("no activity") || warningText.Contains("neglected"))
{
return "Add a Progress, Reveal, Twist, Resolve or Abandon event, or confirm the thread is intentionally quiet.";
}
if (warningText.Contains("unresolved") || warningText.Contains("missing closure") || warningText.Contains("closure"))
{
return "Review the ending scenes and add a Resolve or Abandon event if the thread is finished.";
}
if (warningText.Contains("contradict") || warningText.Contains("conflict"))
{
return "Review the related scene details and update the conflicting story state.";
}
if (warningText.Contains("dependency"))
{
return "Review the linked scene dependency and adjust the ordering or dependency status.";
}
return "Open the warning dashboard or scene inspector and review the affected story detail.";
}
string WarningTooltip(Scene scene)
{
if (!Model.WarningsByScene.TryGetValue(scene.SceneID, out var sceneWarnings) || sceneWarnings.Count == 0)
{
return $"{scene.WarningCount} warning{(scene.WarningCount == 1 ? "" : "s")} on Scene {scene.SceneNumber:g}: {scene.SceneTitle}";
}
var lines = new List<string>
{
$"{sceneWarnings.Count} warning{(sceneWarnings.Count == 1 ? "" : "s")} on Scene {scene.SceneNumber:g}: {scene.SceneTitle}"
};
foreach (var warning in sceneWarnings.OrderByDescending(x => x.SeverityName).ThenBy(x => x.WarningTypeName))
{
lines.Add(string.Empty);
lines.Add($"Warning: {warning.WarningTypeName}");
lines.Add($"Reason: {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}");
lines.Add($"Suggested Action: {SuggestedWarningAction(warning)}");
}
return string.Join(Environment.NewLine, lines);
}
string TrimMarkerLabel(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().Length <= 5 ? value.Trim() : value.Trim()[..5];
IReadOnlyList<ContinuityWarning> EntityWarnings(Scene scene, string entityType, int entityId)
=> Model.WarningsByScene.TryGetValue(scene.SceneID, out var sceneWarnings)
? sceneWarnings
.Where(warning => string.Equals(warning.EntityType, entityType, StringComparison.OrdinalIgnoreCase)
&& warning.EntityID == entityId)
.OrderByDescending(warning => warning.SeverityName)
.ThenBy(warning => warning.WarningTypeName)
.ToList()
: [];
bool HasStoryState(SceneCharacter appearance)
=> !string.IsNullOrWhiteSpace(appearance.EmotionalState)
|| !string.IsNullOrWhiteSpace(appearance.PhysicalCondition)
|| !string.IsNullOrWhiteSpace(appearance.KnowledgeNotes);
string AssetMarkerLabel(AssetEvent assetEvent)
{
var typeName = assetEvent.AssetEventTypeName?.Trim() ?? string.Empty;
return typeName.ToLowerInvariant() switch
{
"first mentioned" => "First",
"mentioned" => "Ref",
"found" => "Find",
"lost" => "Lost",
"given" => "Give",
"taken" => "Take",
"stolen" => "Stol",
"hidden" => "Hide",
"moved" or "location changed" => "Move",
"used" => "Use",
"opened" => "Open",
"destroyed" => "X",
"revealed" => "Show",
"investigated" => "?",
"resolved" => "Done",
"state changed" => "State",
_ => string.IsNullOrWhiteSpace(typeName) ? "Asset" : TrimMarkerLabel(typeName)
};
}
string AssetMarkerMeaning(AssetEvent assetEvent)
{
var typeName = assetEvent.AssetEventTypeName?.Trim() ?? string.Empty;
return typeName.ToLowerInvariant() switch
{
"first mentioned" => "Asset is first mentioned in this scene.",
"mentioned" => "Asset is mentioned in this scene.",
"found" => "Asset is found in this scene.",
"lost" => "Asset is lost in this scene.",
"given" => "Asset changes hands in this scene.",
"taken" => "Asset is taken in this scene.",
"stolen" => "Asset is stolen in this scene.",
"hidden" => "Asset is hidden in this scene.",
"moved" or "location changed" => "Asset changes location in this scene.",
"used" => "Asset is used in this scene.",
"opened" => "Asset is opened in this scene.",
"destroyed" => "Asset is destroyed in this scene.",
"revealed" => "Asset is revealed in this scene.",
"investigated" => "Asset is investigated in this scene.",
"resolved" => "Asset thread is resolved in this scene.",
"state changed" => "Asset state changes in this scene.",
_ => string.IsNullOrWhiteSpace(typeName) ? "Asset event occurs in this scene." : $"Asset event: {typeName}."
};
}
string AssetStorySentence(AssetEvent assetEvent)
{
var title = assetEvent.EventTitle?.Trim();
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(title, assetEvent.AssetEventTypeName, StringComparison.OrdinalIgnoreCase))
{
return $"{assetEvent.AssetName}: {title}.";
}
return AssetMarkerMeaning(assetEvent);
}
string AssetMarkerClass(AssetEvent assetEvent, StoryAsset asset, IReadOnlyList<ContinuityWarning> assetWarnings)
{
var classes = new List<string>();
var typeName = assetEvent.AssetEventTypeName?.Trim().ToLowerInvariant() ?? string.Empty;
if (assetWarnings.Any())
{
classes.Add("timeline-marker-has-warning");
}
if (asset.Importance >= 8)
{
classes.Add("timeline-marker-important");
}
if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
classes.Add("timeline-marker-state-change");
}
if (typeName is "found" or "revealed" or "destroyed" or "resolved" or "state changed")
{
classes.Add("timeline-marker-story-turn");
}
if (typeName is "given" or "taken" or "stolen" or "moved" or "location changed")
{
classes.Add("timeline-marker-transfer");
}
return string.Join(' ', classes);
}
string AssetMarkerTooltip(AssetEvent assetEvent, StoryAsset asset, Scene scene, IReadOnlyList<ContinuityWarning> assetWarnings)
{
var lines = new List<string>
{
AssetStorySentence(assetEvent),
$"Asset: {assetEvent.AssetName}",
$"Event type: {(string.IsNullOrWhiteSpace(assetEvent.AssetEventTypeName) ? "Not set" : assetEvent.AssetEventTypeName)}",
$"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}"
};
if (asset.Importance >= 8)
{
lines.Add("Significance: High-importance story asset.");
}
if (!string.IsNullOrWhiteSpace(assetEvent.FromStateName) || !string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
lines.Add($"State change: {(string.IsNullOrWhiteSpace(assetEvent.FromStateName) ? "Unknown" : assetEvent.FromStateName)} -> {(string.IsNullOrWhiteSpace(assetEvent.ToStateName) ? "Unknown" : assetEvent.ToStateName)}");
}
if (!string.IsNullOrWhiteSpace(assetEvent.ToStateName))
{
lines.Add($"Current state after this event: {assetEvent.ToStateName}");
}
if (!string.IsNullOrWhiteSpace(assetEvent.EventDescription))
{
lines.Add($"Summary: {assetEvent.EventDescription}");
}
foreach (var warning in assetWarnings.Take(2))
{
lines.Add($"Continuity: {warning.WarningTypeName} - {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}");
}
return string.Join(Environment.NewLine, lines);
}
string CharacterMarkerLabel(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance)
{
if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID)
{
return "Intro";
}
if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID
&& string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase))
{
return "Exit";
}
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase))
{
return "POV";
}
if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes))
{
return "Know";
}
if (HasStoryState(appearance))
{
return "State";
}
var presence = appearance.PresenceTypeName?.Trim() ?? string.Empty;
return presence.ToLowerInvariant() switch
{
"present" => "In",
"arrives" => "In+",
"leaves" => "Out",
"nearby" => "Near",
"hidden" => "Hide",
"overhearing" => "Hear",
"mentioned only" => "Ref",
"background" => "Bg",
"off-screen" => "Off",
_ => "Char"
};
}
string CharacterMarkerMeaning(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance)
{
if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID)
{
return $"{appearance.CharacterName} enters this story lane in this scene.";
}
if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID
&& string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase))
{
return $"{appearance.CharacterName} exits this story lane in this scene.";
}
if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes))
{
return $"{appearance.CharacterName}'s knowledge changes in this scene.";
}
if (!string.IsNullOrWhiteSpace(appearance.EmotionalState) || !string.IsNullOrWhiteSpace(appearance.PhysicalCondition))
{
return $"{appearance.CharacterName}'s story state changes in this scene.";
}
var presence = appearance.PresenceTypeName?.Trim() ?? string.Empty;
return presence.ToLowerInvariant() switch
{
"present" => "Character appears in this scene.",
"arrives" => "Character arrives in this scene.",
"leaves" => "Character leaves in this scene.",
"nearby" => "Character is nearby in this scene.",
"hidden" => "Character is hidden in this scene.",
"overhearing" => "Character overhears this scene.",
"mentioned only" => "Character is mentioned in this scene.",
"background" => "Character is in the background of this scene.",
"off-screen" => "Character is off-screen in this scene.",
_ => "Character appears in this scene."
};
}
string CharacterMarkerClass(SceneCharacter appearance, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance, IReadOnlyList<ContinuityWarning> characterWarnings)
{
var classes = new List<string>();
if (characterWarnings.Any())
{
classes.Add("timeline-marker-has-warning");
}
if (firstAppearance?.SceneCharacterID == appearance.SceneCharacterID)
{
classes.Add("timeline-marker-story-turn");
}
if (lastAppearance?.SceneCharacterID == appearance.SceneCharacterID
&& string.Equals(appearance.PresenceTypeName, "Leaves", StringComparison.OrdinalIgnoreCase))
{
classes.Add("timeline-marker-exit");
}
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase)
|| string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase))
{
classes.Add("timeline-marker-important");
}
if (HasStoryState(appearance))
{
classes.Add("timeline-marker-state-change");
}
return string.Join(' ', classes);
}
string CharacterMarkerTooltip(SceneCharacter appearance, Scene scene, SceneCharacter? firstAppearance, SceneCharacter? lastAppearance, IReadOnlyList<ContinuityWarning> characterWarnings)
{
var lines = new List<string>
{
CharacterMarkerMeaning(appearance, firstAppearance, lastAppearance),
$"Character: {appearance.CharacterName}",
$"Role: {(string.IsNullOrWhiteSpace(appearance.RoleInSceneTypeName) ? "Not set" : appearance.RoleInSceneTypeName)}",
$"Presence: {(string.IsNullOrWhiteSpace(appearance.PresenceTypeName) ? "Not set" : appearance.PresenceTypeName)}",
$"Scene: {scene.SceneNumber:g}: {scene.SceneTitle}"
};
if (!string.IsNullOrWhiteSpace(appearance.LocationPath) || !string.IsNullOrWhiteSpace(appearance.LocationName))
{
lines.Add($"Location: {appearance.LocationPath ?? appearance.LocationName}");
}
if (!string.IsNullOrWhiteSpace(appearance.EntryLocationName))
{
lines.Add($"Entry: {appearance.EntryLocationName}");
}
if (!string.IsNullOrWhiteSpace(appearance.ExitLocationName))
{
lines.Add($"Exit: {appearance.ExitLocationName}");
}
if (!string.IsNullOrWhiteSpace(appearance.EmotionalState))
{
lines.Add($"Emotional state: {appearance.EmotionalState}");
}
if (!string.IsNullOrWhiteSpace(appearance.PhysicalCondition))
{
lines.Add($"Physical condition: {appearance.PhysicalCondition}");
}
if (!string.IsNullOrWhiteSpace(appearance.KnowledgeNotes))
{
lines.Add($"Knowledge: {appearance.KnowledgeNotes}");
}
if (!string.IsNullOrWhiteSpace(appearance.AppearanceNotes))
{
lines.Add($"Summary: {appearance.AppearanceNotes}");
}
foreach (var warning in characterWarnings.Take(2))
{
lines.Add($"Continuity: {warning.WarningTypeName} - {(string.IsNullOrWhiteSpace(warning.Details) ? warning.Message : warning.Details)}");
}
return string.Join(Environment.NewLine, lines);
}
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>
<help-icon key="timeline.fields.bookFilter" mode="inline" />
<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 <help-icon key="timeline.fields.chapterRange" mode="inline" /></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 <help-icon key="timeline.fields.chapterRange" mode="inline" /></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 <help-icon key="timeline.fields.sceneRange" mode="inline" /></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 <help-icon key="timeline.fields.sceneRange" mode="inline" /></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 <help-icon key="timeline.fields.locationFilter" mode="inline" /></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 <help-icon key="timeline.fields.warningSeverity" mode="inline" /></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 <help-icon key="timeline.fields.warningType" mode="inline" /></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 <help-icon key="timeline.fields.revisionStatus" mode="inline" /></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 <help-icon key="timeline.fields.scenePurpose" mode="inline" /></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 <help-icon key="timeline.fields.povCharacter" mode="inline" /></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 <help-icon key="timeline.fields.warningOptions" mode="inline" /></label>
<label><input type="checkbox" name="IncludeDismissedWarnings" value="true" checked="@Model.Filter.IncludeDismissedWarnings" /> Include dismissed <help-icon key="timeline.fields.warningOptions" mode="inline" /></label>
<label><input type="checkbox" name="IncludeIntentionalWarnings" value="true" checked="@Model.Filter.IncludeIntentionalWarnings" /> Include intentional <help-icon key="timeline.fields.warningOptions" mode="inline" /></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 <help-icon key="timeline.fields.density" mode="inline" />
<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 <help-icon key="timeline.fields.zoom" mode="inline" />
<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>
<option value="scene-overview">Scene Overview</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 />
<help-icon key="timeline.fields.jumpControls" mode="inline" />
<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: Shift + scroll moves sideways, 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>
}
<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">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = character.CharacterName, ImagePath = character.AvatarImagePath ?? character.ImagePath, ThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath, Size = "sm" })" />
@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 <help-icon key="timeline.fields.dragCharacterRole" mode="inline" /></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 <help-icon key="timeline.fields.dragCharacterRole" mode="inline" /></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 <help-icon key="sceneInspector.fields.characterLocation" mode="inline" /></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 <help-icon key="timeline.fields.dragAssetEvent" mode="inline" /></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 <help-icon key="timeline.fields.dragAssetEvent" mode="inline" /></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 <help-icon key="sceneInspector.fields.assetLocation" mode="inline" /></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 <help-icon key="timeline.fields.dragAssetDependency" mode="inline" /></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 <help-icon key="timeline.fields.dragAssetDependency" mode="inline" /></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 <help-icon key="timeline.fields.dragAssetDependency" mode="inline" /></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 <help-icon key="timeline.fields.dragLocationRelationship" mode="inline" /></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-navigation-card" data-timeline-navigation-card hidden>
<div class="timeline-navigation-card-icon" aria-hidden="true">&harr;</div>
<div class="timeline-navigation-card-copy">
<h2>Your timeline continues off-screen</h2>
<p>Use Jump to move straight to another chapter.</p>
<p>To browse nearby scenes sideways, hold Shift while using your mouse wheel.</p>
<p>On a trackpad, hold Shift and use a two-finger scroll gesture.</p>
</div>
<div class="timeline-navigation-card-actions">
<button type="button" class="btn btn-primary btn-sm" data-timeline-navigation-demo aria-label="Show timeline horizontal scrolling demonstration">Show Me</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-timeline-navigation-dismiss aria-label="Got it, do not show timeline navigation guidance again">Got It</button>
</div>
</section>
<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 overview = Model.SceneOverviews.GetValueOrDefault(scene.SceneID) ?? new TimelineSceneOverviewViewModel();
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 title="@WarningTooltip(scene)">
<span aria-hidden="true">!</span>
<span>@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s")</span>
</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>
<div class="scene-overview-panel">
<div class="scene-overview-section scene-overview-characters">
<span class="scene-overview-label">Characters</span>
<div class="scene-overview-chip-row">
@if (overview.Characters.Any())
{
foreach (var character in overview.Characters.Take(7))
{
<span class="scene-overview-chip @(character.IsPov ? "is-pov" : "")">@character.CharacterName@(character.IsPov ? " [POV]" : "")</span>
}
if (HiddenCount(overview.Characters, 7) > 0)
{
<span class="scene-overview-chip is-more">+@HiddenCount(overview.Characters, 7) more</span>
}
}
else
{
<span class="scene-overview-empty">No characters linked</span>
}
</div>
</div>
<div class="scene-overview-section scene-overview-assets">
<span class="scene-overview-label">Assets</span>
<div class="scene-overview-chip-row">
@if (overview.Assets.Any())
{
foreach (var asset in overview.Assets.Take(6))
{
<span class="scene-overview-chip @(asset.Importance >= 8 ? "is-important" : "")">@asset.AssetName</span>
}
if (HiddenCount(overview.Assets, 6) > 0)
{
<span class="scene-overview-chip is-more">+@HiddenCount(overview.Assets, 6) more</span>
}
}
else
{
<span class="scene-overview-empty">No assets linked</span>
}
</div>
</div>
<div class="scene-overview-section scene-overview-locations">
<span class="scene-overview-label">Locations</span>
<div class="scene-overview-chip-row">
@if (overview.Locations.Any())
{
foreach (var location in overview.Locations.Take(4))
{
<span class="scene-overview-chip">@location</span>
}
if (HiddenCount(overview.Locations, 4) > 0)
{
<span class="scene-overview-chip is-more">+@HiddenCount(overview.Locations, 4) more</span>
}
}
else
{
<span class="scene-overview-empty">No locations linked</span>
}
</div>
</div>
@if (scene.PurposeLabels.Any() || !string.IsNullOrWhiteSpace(scene.Summary))
{
<div class="scene-overview-section scene-overview-purpose">
<span class="scene-overview-label">Purpose</span>
@if (scene.PurposeLabels.Any())
{
<div class="scene-overview-chip-row">
@foreach (var purpose in scene.PurposeLabels.Take(4))
{
<span class="scene-overview-chip">@purpose.PurposeName</span>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(scene.Summary))
{
<span class="scene-overview-brief">@scene.Summary</span>
}
</div>
}
</div>
</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>
<svg class="plot-lane-svg-overlay plot-lane-svg-health-overlay" data-plot-line-health-svg aria-hidden="true"></svg>
<div class="lane-board-heading plot-lane-board-heading">
<span class="timeline-heading-with-help">
<button type="button" data-collapse-toggle="plot-lines">Plot line lanes</button>
<help-icon key="timeline.plotThreadLanes" mode="inline" />
<help-icon key="timeline.threadHealth" mode="inline" />
</span>
<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" 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 markerKind = PlotMarkerKind(threadEvent.PlotEventTypeName);
var markerClass = markerKind == "resolve"
? "resolved"
: markerKind == "abandon"
? "contradicted"
: "";
<a class="thread-marker thread-marker-type-@markerKind @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="@ThreadEventTooltip(threadEvent, column.Book, column.Chapter, realPlotScene)">
<span class="thread-marker-icon" aria-hidden="true">@PlotMarkerIcon(threadEvent.PlotEventTypeName)</span>
<span class="thread-marker-text">@threadEvent.MarkerText</span>
</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>())
{
var assetWarnings = EntityWarnings(realAssetScene, "StoryAsset", assetEvent.StoryAssetID);
var assetMarkerTooltip = AssetMarkerTooltip(assetEvent, lane.Asset, realAssetScene, assetWarnings);
var assetMarkerClass = AssetMarkerClass(assetEvent, lane.Asset, assetWarnings);
<a class="asset-marker @assetMarkerClass"
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="@assetMarkerTooltip"
aria-label="@assetMarkerTooltip">
@AssetMarkerLabel(assetEvent)
</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" : "";
var orderedLaneAppearances = lane.SceneSlots
.SelectMany(slot => slot.Appearances)
.OrderBy(appearance => timelineColumns.FindIndex(column => column.Scene?.SceneID == appearance.SceneID))
.ThenBy(appearance => appearance.SceneCharacterID)
.ToList();
var firstAppearance = orderedLaneAppearances.FirstOrDefault();
var lastAppearance = orderedLaneAppearances.LastOrDefault();
<div class="character-lane-row @characterFocusClass">
<a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID">
<partial name="_Avatar" model="@(new AvatarViewModel { DisplayName = lane.Character.CharacterName, ImagePath = lane.Character.AvatarImagePath ?? lane.Character.ImagePath, ThumbnailPath = lane.Character.AvatarThumbnailPath ?? lane.Character.ThumbnailPath, Size = "sm" })" />
<span>
<strong>@lane.Character.CharacterName</strong>
<small>@(lane.Character.ShortName ?? "Character")</small>
</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>())
{
var characterWarnings = EntityWarnings(realCharacterScene, "Character", appearance.CharacterID);
var characterMarkerTooltip = CharacterMarkerTooltip(appearance, realCharacterScene, firstAppearance, lastAppearance, characterWarnings);
var characterMarkerClass = CharacterMarkerClass(appearance, firstAppearance, lastAppearance, characterWarnings);
<a class="character-marker @characterMarkerClass"
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="@characterMarkerTooltip"
aria-label="@characterMarkerTooltip">
@CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
</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/Shared/SceneInspectorV2/_SceneInspectorTimeline.cshtml" model="Model.SelectedScene" />
</div>
</aside>
}
</div>
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script src="~/js/scene-inspector-v2.js" asp-append-version="true"></script>
}