Implemented the Timeline C3 refinement.

This commit is contained in:
Nick Beckley 2026-06-20 16:36:18 +01:00
parent 36c214fab8
commit e5ed45897a
7 changed files with 354 additions and 7 deletions

View File

@ -462,8 +462,8 @@ public sealed class ProjectTimelineSettings
public bool ShowSceneCards { get; set; } = true;
public bool ShowMetricShape { get; set; } = true;
public bool ShowPlotLines { get; set; } = true;
public bool ShowStoryAssets { get; set; } = true;
public bool ShowCharacterAppearances { get; set; } = true;
public bool ShowStoryAssets { get; set; }
public bool ShowCharacterAppearances { get; set; }
public bool ShowWarnings { get; set; } = true;
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }

View File

@ -1896,8 +1896,8 @@ public sealed class TimelineService(
.ToList();
var needsPlotTimelineData = settings.ShowPlotLines || filter.PlotLineID.HasValue || filter.PlotThreadID.HasValue || filter.FocusType is "plotline" or "plotthread";
var needsMetricTimelineData = settings.ShowMetricShape && selectedMetricIds.Any();
var needsAssetTimelineData = settings.ShowStoryAssets || filter.StoryAssetID.HasValue || filter.FocusType == "asset";
var needsCharacterTimelineData = settings.ShowCharacterAppearances || filter.CharacterID.HasValue || filter.FocusType == "character";
var needsAssetTimelineData = settings.ShowSceneCards || settings.ShowStoryAssets || filter.StoryAssetID.HasValue || filter.FocusType == "asset";
var needsCharacterTimelineData = settings.ShowSceneCards || settings.ShowCharacterAppearances || filter.CharacterID.HasValue || filter.FocusType == "character";
var needsWarnings = settings.ShowWarnings || filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly;
var timeline = await TimeTimelineLoadAsync(
@ -2042,6 +2042,7 @@ public sealed class TimelineService(
PlotLanes = settings.ShowPlotLines ? BuildPlotLanes(timeline, orderedScenes) : [],
AssetLanes = settings.ShowStoryAssets ? BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes) : [],
CharacterLanes = settings.ShowCharacterAppearances ? BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) : [],
SceneOverviews = settings.ShowSceneCards ? BuildSceneOverviews(orderedScenes, characterTimeline.Appearances, assetTimeline.Assets, assetTimeline.Events) : new Dictionary<int, TimelineSceneOverviewViewModel>(),
WarningsByScene = warningsByScene,
MatchingSceneIDs = matchingSceneIds,
FocusSceneIDs = focusSceneIds,
@ -2519,6 +2520,96 @@ public sealed class TimelineService(
}).ToList();
}
private static IReadOnlyDictionary<int, TimelineSceneOverviewViewModel> BuildSceneOverviews(
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<SceneCharacter> appearances,
IReadOnlyList<StoryAsset> assets,
IReadOnlyList<AssetEvent> assetEvents)
{
var assetImportanceById = assets.ToDictionary(x => x.StoryAssetID, x => x.Importance);
var charactersByScene = appearances
.GroupBy(x => x.SceneID)
.ToDictionary(
sceneGroup => sceneGroup.Key,
sceneGroup => sceneGroup
.GroupBy(x => x.CharacterID)
.Select(characterGroup =>
{
var first = characterGroup
.OrderByDescending(x => string.Equals(x.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase))
.ThenBy(x => x.SceneCharacterID)
.First();
return new TimelineSceneCharacterOverviewViewModel
{
CharacterID = first.CharacterID,
CharacterName = first.CharacterName,
IsPov = string.Equals(first.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase)
};
})
.OrderByDescending(x => x.IsPov)
.ThenBy(x => x.CharacterName)
.ToList());
var assetsByScene = assetEvents
.GroupBy(x => x.SceneID)
.ToDictionary(
sceneGroup => sceneGroup.Key,
sceneGroup => sceneGroup
.GroupBy(x => x.StoryAssetID)
.Select(assetGroup =>
{
var first = assetGroup.OrderByDescending(x => x.UpdatedDate).First();
return new TimelineSceneAssetOverviewViewModel
{
StoryAssetID = first.StoryAssetID,
AssetName = first.AssetName,
Importance = assetImportanceById.GetValueOrDefault(first.StoryAssetID),
EventTypeName = first.AssetEventTypeName
};
})
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
.ToList());
var characterLocationsByScene = appearances
.Where(x => !string.IsNullOrWhiteSpace(x.LocationPath) || !string.IsNullOrWhiteSpace(x.LocationName))
.GroupBy(x => x.SceneID)
.ToDictionary(
sceneGroup => sceneGroup.Key,
sceneGroup => sceneGroup
.Select(x => x.LocationPath ?? x.LocationName ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList());
return orderedScenes.ToDictionary(scene => scene.SceneID, scene =>
{
var locations = new List<string>();
if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationPath))
{
locations.Add(scene.PrimaryLocationPath);
}
else if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationName))
{
locations.Add(scene.PrimaryLocationName);
}
if (characterLocationsByScene.TryGetValue(scene.SceneID, out var characterLocations))
{
locations.AddRange(characterLocations);
}
return new TimelineSceneOverviewViewModel
{
Characters = charactersByScene.GetValueOrDefault(scene.SceneID, []),
Assets = assetsByScene.GetValueOrDefault(scene.SceneID, []),
Locations = locations
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(6)
.ToList()
};
});
}
private static int CharacterLanePriority(Character character, IEnumerable<SceneCharacter> appearances)
{
var description = character.DefaultDescription ?? string.Empty;

View File

@ -0,0 +1,61 @@
DECLARE @constraintName sysname;
SELECT @constraintName = dc.name
FROM sys.default_constraints dc
INNER JOIN sys.columns c ON c.default_object_id = dc.object_id
INNER JOIN sys.tables t ON t.object_id = c.object_id
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE s.name = N'dbo'
AND t.name = N'ProjectTimelineSettings'
AND c.name = N'ShowStoryAssets';
IF @constraintName IS NOT NULL
BEGIN
EXEC(N'ALTER TABLE dbo.ProjectTimelineSettings DROP CONSTRAINT ' + QUOTENAME(@constraintName));
END;
IF COL_LENGTH(N'dbo.ProjectTimelineSettings', N'ShowStoryAssets') IS NOT NULL
BEGIN
ALTER TABLE dbo.ProjectTimelineSettings
ADD CONSTRAINT DF_ProjectTimelineSettings_ShowStoryAssets DEFAULT 0 FOR ShowStoryAssets;
END;
GO
DECLARE @constraintName sysname;
SELECT @constraintName = dc.name
FROM sys.default_constraints dc
INNER JOIN sys.columns c ON c.default_object_id = dc.object_id
INNER JOIN sys.tables t ON t.object_id = c.object_id
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE s.name = N'dbo'
AND t.name = N'ProjectTimelineSettings'
AND c.name = N'ShowCharacterAppearances';
IF @constraintName IS NOT NULL
BEGIN
EXEC(N'ALTER TABLE dbo.ProjectTimelineSettings DROP CONSTRAINT ' + QUOTENAME(@constraintName));
END;
IF COL_LENGTH(N'dbo.ProjectTimelineSettings', N'ShowCharacterAppearances') IS NOT NULL
BEGIN
ALTER TABLE dbo.ProjectTimelineSettings
ADD CONSTRAINT DF_ProjectTimelineSettings_ShowCharacterAppearances DEFAULT 0 FOR ShowCharacterAppearances;
END;
GO
CREATE OR ALTER PROCEDURE dbo.ProjectTimelineSettings_EnsureDefaults
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID)
BEGIN
INSERT dbo.ProjectTimelineSettings
(ProjectID, ShowSceneCards, ShowMetricShape, ShowPlotLines, ShowStoryAssets, ShowCharacterAppearances, ShowWarnings)
VALUES
(@ProjectID, 1, 1, 1, 0, 0, 1);
END
END;
GO

View File

@ -963,6 +963,7 @@ public sealed class TimelineViewModel
public IReadOnlyList<PlotLineLaneViewModel> PlotLanes { get; set; } = [];
public IReadOnlyList<AssetLaneViewModel> AssetLanes { get; set; } = [];
public IReadOnlyList<CharacterLaneViewModel> CharacterLanes { get; set; } = [];
public IReadOnlyDictionary<int, TimelineSceneOverviewViewModel> SceneOverviews { get; set; } = new Dictionary<int, TimelineSceneOverviewViewModel>();
public IReadOnlyDictionary<int, IReadOnlyList<ContinuityWarning>> WarningsByScene { get; set; } = new Dictionary<int, IReadOnlyList<ContinuityWarning>>();
public IReadOnlySet<int> MatchingSceneIDs { get; set; } = new HashSet<int>();
public IReadOnlySet<int> FocusSceneIDs { get; set; } = new HashSet<int>();
@ -978,8 +979,8 @@ public sealed class TimelineSettingsViewModel
public bool ShowSceneCards { get; set; } = true;
public bool ShowMetricShape { get; set; } = true;
public bool ShowPlotLines { get; set; } = true;
public bool ShowStoryAssets { get; set; } = true;
public bool ShowCharacterAppearances { get; set; } = true;
public bool ShowStoryAssets { get; set; }
public bool ShowCharacterAppearances { get; set; }
public bool ShowWarnings { get; set; } = true;
public List<int> SelectedMetricTypeIDs { get; set; } = [];
public IReadOnlyList<TimelineMetricSettingOptionViewModel> MetricOptions { get; set; } = [];
@ -994,6 +995,28 @@ public sealed class TimelineMetricSettingOptionViewModel
public bool IsSelected { get; set; }
}
public sealed class TimelineSceneOverviewViewModel
{
public IReadOnlyList<TimelineSceneCharacterOverviewViewModel> Characters { get; set; } = [];
public IReadOnlyList<TimelineSceneAssetOverviewViewModel> Assets { get; set; } = [];
public IReadOnlyList<string> Locations { get; set; } = [];
}
public sealed class TimelineSceneCharacterOverviewViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public bool IsPov { get; set; }
}
public sealed class TimelineSceneAssetOverviewViewModel
{
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int Importance { get; set; }
public string? EventTypeName { get; set; }
}
public sealed class ProjectSceneMetricsViewModel
{
public Project Project { get; set; } = new();

View File

@ -56,6 +56,7 @@
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",
@ -646,6 +647,7 @@
<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> }
@ -1041,6 +1043,7 @@ else
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
@ -1111,6 +1114,87 @@ else
</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>
}
}

View File

@ -3078,6 +3078,73 @@ body.timeline-inspector-resizing {
background: rgba(47, 111, 99, 0.08);
}
.scene-overview-panel {
display: none;
gap: 8px;
margin-top: 4px;
}
.scene-overview-section {
display: grid;
gap: 4px;
}
.scene-overview-label {
color: var(--plotline-muted);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.scene-overview-chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
}
.scene-overview-chip,
.scene-overview-empty,
.scene-overview-brief {
display: inline-flex;
width: fit-content;
max-width: 100%;
border-radius: 999px;
padding: 3px 8px;
background: rgba(47, 111, 99, 0.08);
color: var(--plotline-ink);
font-size: 0.72rem;
font-weight: 750;
line-height: 1.25;
overflow-wrap: anywhere;
}
.scene-overview-chip.is-pov {
border: 1px solid rgba(197, 139, 43, 0.38);
background: #fff3d3;
}
.scene-overview-chip.is-important {
border: 1px solid rgba(103, 80, 164, 0.3);
background: #f0edf8;
}
.scene-overview-chip.is-more,
.scene-overview-empty {
color: var(--plotline-muted);
background: rgba(102, 113, 123, 0.08);
}
.scene-overview-brief {
display: block;
width: auto;
border-radius: 8px;
background: rgba(255, 255, 255, 0.62);
color: var(--plotline-muted);
font-weight: 600;
}
.timeline-scene-card.search-hit,
.timeline-scene-card.keyboard-focus {
outline: 3px solid rgba(197, 139, 43, 0.34);
@ -3141,6 +3208,27 @@ body.timeline-inspector-resizing {
-webkit-line-clamp: 7;
}
.timeline-filtered-root.zoom-scene-overview {
--timeline-scene-width: 380px;
}
.timeline-filtered-root.zoom-scene-overview .timeline-scenes {
min-height: 320px;
}
.timeline-filtered-root.zoom-scene-overview .timeline-scene-card {
gap: 8px;
padding: 14px;
}
.timeline-filtered-root.zoom-scene-overview .scene-summary {
display: none;
}
.timeline-filtered-root.zoom-scene-overview .scene-overview-panel {
display: grid;
}
.timeline-filtered-root.density-expanded .timeline-scenes {
min-height: 230px;
}

View File

@ -1217,7 +1217,7 @@
"[data-timeline-minimap]"
].join(",");
const densityValues = ["compact", "comfortable", "expanded"];
const zoomValues = ["book", "chapter", "scene-detail"];
const zoomValues = ["book", "chapter", "scene-detail", "scene-overview"];
const inspectorDrawer = root.querySelector("[data-inspector-drawer]");
const inspectorResizer = root.querySelector("[data-inspector-resizer]");
const inspectorClose = root.querySelector("[data-inspector-close]");