diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index e409337..eb8e2cc 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -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; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 9539c3d..0beab72 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -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(), WarningsByScene = warningsByScene, MatchingSceneIDs = matchingSceneIds, FocusSceneIDs = focusSceneIds, @@ -2519,6 +2520,96 @@ public sealed class TimelineService( }).ToList(); } + private static IReadOnlyDictionary BuildSceneOverviews( + IReadOnlyList orderedScenes, + IReadOnlyList appearances, + IReadOnlyList assets, + IReadOnlyList 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(); + 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 appearances) { var description = character.DefaultDescription ?? string.Empty; diff --git a/PlotLine/Sql/070_TimelineDefaultSceneFocusedView.sql b/PlotLine/Sql/070_TimelineDefaultSceneFocusedView.sql new file mode 100644 index 0000000..24e9228 --- /dev/null +++ b/PlotLine/Sql/070_TimelineDefaultSceneFocusedView.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index e111391..3ae8756 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -963,6 +963,7 @@ public sealed class TimelineViewModel public IReadOnlyList PlotLanes { get; set; } = []; public IReadOnlyList AssetLanes { get; set; } = []; public IReadOnlyList CharacterLanes { get; set; } = []; + public IReadOnlyDictionary SceneOverviews { get; set; } = new Dictionary(); public IReadOnlyDictionary> WarningsByScene { get; set; } = new Dictionary>(); public IReadOnlySet MatchingSceneIDs { get; set; } = new HashSet(); public IReadOnlySet FocusSceneIDs { get; set; } = new HashSet(); @@ -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 SelectedMetricTypeIDs { get; set; } = []; public IReadOnlyList MetricOptions { get; set; } = []; @@ -994,6 +995,28 @@ public sealed class TimelineMetricSettingOptionViewModel public bool IsSelected { get; set; } } +public sealed class TimelineSceneOverviewViewModel +{ + public IReadOnlyList Characters { get; set; } = []; + public IReadOnlyList Assets { get; set; } = []; + public IReadOnlyList 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(); diff --git a/PlotLine/Views/Timeline/Index.cshtml b/PlotLine/Views/Timeline/Index.cshtml index 3344f19..eb58ac6 100644 --- a/PlotLine/Views/Timeline/Index.cshtml +++ b/PlotLine/Views/Timeline/Index.cshtml @@ -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(IReadOnlyList items, int visibleCount) => Math.Max(0, items.Count - visibleCount); string PlotMarkerKind(string? plotEventTypeName) => (plotEventTypeName ?? "Progress").Trim().ToLowerInvariant() switch { "start" => "start", @@ -646,6 +647,7 @@ + @if (Model.Settings.ShowSceneCards) { } @@ -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 } @scene.Summary +
+
+ Characters +
+ @if (overview.Characters.Any()) + { + foreach (var character in overview.Characters.Take(7)) + { + @character.CharacterName@(character.IsPov ? " [POV]" : "") + } + if (HiddenCount(overview.Characters, 7) > 0) + { + +@HiddenCount(overview.Characters, 7) more + } + } + else + { + No characters linked + } +
+
+
+ Assets +
+ @if (overview.Assets.Any()) + { + foreach (var asset in overview.Assets.Take(6)) + { + @asset.AssetName + } + if (HiddenCount(overview.Assets, 6) > 0) + { + +@HiddenCount(overview.Assets, 6) more + } + } + else + { + No assets linked + } +
+
+
+ Locations +
+ @if (overview.Locations.Any()) + { + foreach (var location in overview.Locations.Take(4)) + { + @location + } + if (HiddenCount(overview.Locations, 4) > 0) + { + +@HiddenCount(overview.Locations, 4) more + } + } + else + { + No locations linked + } +
+
+ @if (scene.PurposeLabels.Any() || !string.IsNullOrWhiteSpace(scene.Summary)) + { +
+ Purpose + @if (scene.PurposeLabels.Any()) + { +
+ @foreach (var purpose in scene.PurposeLabels.Take(4)) + { + @purpose.PurposeName + } +
+ } + @if (!string.IsNullOrWhiteSpace(scene.Summary)) + { + @scene.Summary + } +
+ } +
} } diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index 1823152..10a72a3 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -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; } diff --git a/PlotLine/wwwroot/js/site.js b/PlotLine/wwwroot/js/site.js index 8e12e1b..b32a0bf 100644 --- a/PlotLine/wwwroot/js/site.js +++ b/PlotLine/wwwroot/js/site.js @@ -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]");