From e7aabe8b419f85358f189f65499772197c088721 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 14 Jun 2026 19:24:59 +0100 Subject: [PATCH] Phase 12B: Continuity Explorer Page. --- .../ContinuityExplorerController.cs | 28 ++ PlotLine/Services/CoreServices.cs | 388 +++++++++++++++++- PlotLine/ViewModels/CoreViewModels.cs | 76 ++++ .../Views/ContinuityExplorer/Index.cshtml | 300 ++++++++++++++ .../Views/Shared/_ProjectSectionNav.cshtml | 1 + 5 files changed, 773 insertions(+), 20 deletions(-) create mode 100644 PlotLine/Controllers/ContinuityExplorerController.cs create mode 100644 PlotLine/Views/ContinuityExplorer/Index.cshtml diff --git a/PlotLine/Controllers/ContinuityExplorerController.cs b/PlotLine/Controllers/ContinuityExplorerController.cs new file mode 100644 index 0000000..141a0a2 --- /dev/null +++ b/PlotLine/Controllers/ContinuityExplorerController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Services; +using PlotLine.ViewModels; + +namespace PlotLine.Controllers; + +[Authorize] +public sealed class ContinuityExplorerController(IStoryStateService storyState) : Controller +{ + public async Task Index([FromQuery] ContinuityFilter filter) + { + if (filter.ProjectID == 0) + { + return BadRequest("ProjectID is required."); + } + + var model = await storyState.GetContinuityExplorerAsync(filter); + return model is null ? NotFound() : View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult ApplyFilters(ContinuityFilter filter) + { + return RedirectToAction(nameof(Index), filter); + } +} diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 68ceacb..1235ee0 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -114,6 +114,10 @@ public interface ISceneService public interface IStoryStateService { Task GetStoryStateAsync(int sceneId); + Task GetContinuityExplorerAsync(ContinuityFilter filter); + Task> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter); + Task> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter); + Task> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter); } public interface IPlotService @@ -2204,6 +2208,7 @@ public sealed class TimelineService( } public sealed class StoryStateService( + IProjectRepository projects, IBookRepository books, IChapterRepository chapters, ISceneRepository scenes, @@ -2227,7 +2232,7 @@ public sealed class StoryStateService( return new StoryStateViewModel(); } - var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, book.BookID, false, [], false); + var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, null, false, [], false); var orderedScenes = OrderScenes(timeline).ToList(); var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId); if (currentSceneIndex < 0) @@ -2235,22 +2240,252 @@ public sealed class StoryStateService( return new StoryStateViewModel(); } - var sceneIndexes = orderedScenes - .Select((scene, index) => new { scene.SceneID, Index = index }) - .ToDictionary(x => x.SceneID, x => x.Index); + var sceneIndexes = BuildSceneIndexes(orderedScenes); var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList(); var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet(); + var allLocations = await locations.ListByProjectAsync(book.ProjectID); - var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, book.BookID); - var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID); + var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null); + var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null); var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent); var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets); - return new StoryStateViewModel + var storyState = new StoryStateViewModel { Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes), Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID)) }; + storyState.Locations = BuildLocationState(allLocations, storyState); + return storyState; + } + + public async Task GetContinuityExplorerAsync(ContinuityFilter filter) + { + NormaliseFilter(filter); + var project = await projects.GetAsync(filter.ProjectID); + if (project is null) + { + return null; + } + + var timeline = await timelineRepository.GetByProjectAsync(filter.ProjectID, null, false, [], false); + var orderedScenes = OrderScenes(timeline).ToList(); + var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList(); + var allCharacters = await characters.ListCharactersAsync(filter.ProjectID); + var allAssets = await assets.ListAssetsAsync(filter.ProjectID); + var allLocations = await locations.ListByProjectAsync(filter.ProjectID); + filter.SelectedSceneID ??= scopedScenes.FirstOrDefault()?.SceneID ?? orderedScenes.FirstOrDefault()?.SceneID; + + var model = new ContinuityExplorerViewModel + { + Project = project, + Filter = filter, + BookOptions = timeline.Books.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == filter.BookID)).ToList(), + ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterID)).ToList(), + SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber:g}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SelectedSceneID)).ToList(), + CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), filter.CharacterIDs.Contains(x.CharacterID))).ToList(), + AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), filter.AssetIDs.Contains(x.StoryAssetID))).ToList(), + LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), filter.LocationIDs.Contains(x.LocationID))).ToList() + }; + + if (string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase) && filter.SelectedSceneID.HasValue) + { + model.StoryState = await GetStoryStateAsync(filter.SelectedSceneID.Value); + FilterStoryState(model.StoryState, filter); + } + else if (string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase)) + { + var characterIds = filter.IncludeCharacters + ? (filter.CharacterIDs.Any() ? filter.CharacterIDs : allCharacters.Select(x => x.CharacterID)).ToList() + : []; + var assetIds = filter.IncludeAssets + ? (filter.AssetIDs.Any() ? filter.AssetIDs : allAssets.Select(x => x.StoryAssetID)).ToList() + : []; + + var characterJourney = new List(); + foreach (var characterId in characterIds) + { + characterJourney.AddRange(await GetCharacterJourneyAsync(filter.ProjectID, characterId, filter)); + } + + var assetJourney = new List(); + foreach (var assetId in assetIds) + { + assetJourney.AddRange(await GetAssetJourneyAsync(filter.ProjectID, assetId, filter)); + } + + model.CharacterJourney = characterJourney; + model.AssetJourney = assetJourney; + } + else if (string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase)) + { + var locationIds = filter.IncludeLocations + ? (filter.LocationIDs.Any() ? filter.LocationIDs : allLocations.Select(x => x.LocationID)).ToList() + : []; + + var activity = new List(); + foreach (var locationId in locationIds) + { + activity.AddRange(await GetLocationActivityAsync(filter.ProjectID, locationId, filter)); + } + + model.LocationActivity = activity + .OrderBy(x => scopedScenes.FindIndex(scene => scene.SceneID == x.SceneID)) + .ThenBy(x => x.LocationName) + .ToList(); + } + + return model; + } + + public async Task> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter) + { + var context = await GetRangeContextAsync(projectId, filter); + var characterTimeline = await characters.GetTimelineAsync(projectId, null); + var character = characterTimeline.Characters.FirstOrDefault(x => x.CharacterID == characterId); + if (character is null) + { + return []; + } + + var rows = characterTimeline.Appearances + .Where(x => x.CharacterID == characterId && x.LocationID.HasValue && context.AllowedSceneIds.Contains(x.SceneID)) + .GroupBy(x => x.SceneID) + .Select(x => x.OrderByDescending(row => row.UpdatedDate).First()) + .OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ToList(); + var result = new List(); + int? previousLocationId = null; + + foreach (var row in rows) + { + if (row.LocationID == previousLocationId) + { + continue; + } + + previousLocationId = row.LocationID; + result.Add(new CharacterJourneyViewModel + { + CharacterID = character.CharacterID, + CharacterName = character.CharacterName, + SceneID = row.SceneID, + SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"), + Location = row.LocationPath ?? row.LocationName ?? "Unknown" + }); + } + + return result; + } + + public async Task> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter) + { + var context = await GetRangeContextAsync(projectId, filter); + var assetTimeline = await assets.GetTimelineAsync(projectId, null); + var asset = assetTimeline.Assets.FirstOrDefault(x => x.StoryAssetID == assetId); + if (asset is null) + { + return []; + } + + var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes); + var assetCustody = await assets.ListCustodyByAssetAsync(assetId); + var assignments = BuildAssetAssignments(assetTimeline.Events, assetLocations, assetCustody, context.AllowedSceneIds); + var rows = assignments + .Where(x => x.StoryAssetID == assetId) + .GroupBy(x => x.SceneID) + .Select(x => x.OrderByDescending(row => row.Priority).ThenByDescending(row => row.UpdatedDate).First()) + .OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1)) + .ToList(); + var result = new List(); + string? previousLabel = null; + + foreach (var row in rows) + { + if (string.Equals(previousLabel, row.Label, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + previousLabel = row.Label; + result.Add(new AssetJourneyViewModel + { + StoryAssetID = asset.StoryAssetID, + AssetName = asset.AssetName, + SceneID = row.SceneID, + SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"), + HolderOrLocation = row.Label + }); + } + + return result; + } + + public async Task> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter) + { + var context = await GetRangeContextAsync(projectId, filter); + var allLocations = await locations.ListByProjectAsync(projectId); + var location = allLocations.FirstOrDefault(x => x.LocationID == locationId); + if (location is null) + { + return []; + } + + var characterTimeline = await characters.GetTimelineAsync(projectId, null); + var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes); + var characterIds = filter.CharacterIDs.ToHashSet(); + var assetIds = filter.AssetIDs.ToHashSet(); + var result = new List(); + + foreach (var scene in context.ScopedScenes) + { + var sceneCharacters = filter.IncludeCharacters + ? characterTimeline.Appearances + .Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!characterIds.Any() || characterIds.Contains(x.CharacterID))) + .Select(x => x.CharacterName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToList() + : []; + var sceneAssets = filter.IncludeAssets + ? assetLocations + .Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!assetIds.Any() || assetIds.Contains(x.StoryAssetID))) + .Select(x => x.AssetName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToList() + : []; + + if (!sceneCharacters.Any() && !sceneAssets.Any()) + { + continue; + } + + result.Add(new LocationActivityViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationPath, + SceneID = scene.SceneID, + SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle), + CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters) : "None", + AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets) : "None" + }); + } + + return result; + } + + private async Task GetRangeContextAsync(int projectId, ContinuityFilter filter) + { + NormaliseFilter(filter); + var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false); + var orderedScenes = OrderScenes(timeline).ToList(); + var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList(); + return new RangeContext( + scopedScenes, + scopedScenes.Select(x => x.SceneID).ToHashSet(), + BuildSceneIndexes(orderedScenes), + orderedScenes.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle))); } private static IEnumerable OrderScenes(TimelineData timeline) @@ -2267,6 +2502,36 @@ public sealed class StoryStateService( .ThenBy(scene => scene.SceneID); } + private static IEnumerable ApplySceneRange(IReadOnlyList orderedScenes, IReadOnlyList chapters, ContinuityFilter filter) + { + if (string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase) && filter.BookID.HasValue) + { + var chapterIds = chapters.Where(x => x.BookID == filter.BookID.Value).Select(x => x.ChapterID).ToHashSet(); + return orderedScenes.Where(x => chapterIds.Contains(x.ChapterID)); + } + + if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase) && filter.ChapterID.HasValue) + { + return orderedScenes.Where(x => x.ChapterID == filter.ChapterID.Value); + } + + if (string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase) && (filter.StartSceneID.HasValue || filter.EndSceneID.HasValue)) + { + var startIndex = filter.StartSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.StartSceneID.Value) : 0; + var endIndex = filter.EndSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.EndSceneID.Value) : orderedScenes.Count - 1; + startIndex = startIndex < 0 ? 0 : startIndex; + endIndex = endIndex < 0 ? orderedScenes.Count - 1 : endIndex; + if (startIndex > endIndex) + { + (startIndex, endIndex) = (endIndex, startIndex); + } + + return orderedScenes.Skip(startIndex).Take(endIndex - startIndex + 1); + } + + return orderedScenes; + } + private async Task> GetAssetLocationsThroughCurrentAsync(IReadOnlyList scenesThroughCurrent) { var rows = new List(); @@ -2324,6 +2589,7 @@ public sealed class StoryStateService( { CharacterID = character.CharacterID, CharacterName = character.CharacterName, + LocationID = latest.LocationID, CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown", State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle) @@ -2342,19 +2608,7 @@ public sealed class StoryStateService( IReadOnlyDictionary sceneIndexes, IReadOnlyDictionary scenesById) { - var stateAssignments = assetEvents - .Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName)) - .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.ToStateName!, 1, x.UpdatedDate)); - var locationAssignments = assetLocations - .Where(x => allowedSceneIds.Contains(x.SceneID)) - .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationPath, 2, x.UpdatedDate)); - var custodyAssignments = assetCustody - .Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary)) - .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.CustodianSummary!, 3, x.UpdatedDate)); - var assignments = stateAssignments - .Concat(locationAssignments) - .Concat(custodyAssignments) - .ToList(); + var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds); return projectAssets .OrderByDescending(x => x.Importance) @@ -2381,6 +2635,7 @@ public sealed class StoryStateService( { StoryAssetID = asset.StoryAssetID, AssetName = asset.AssetName, + LocationID = latest.LocationID, CurrentHolderOrLocation = latest.Label, State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene) @@ -2391,6 +2646,92 @@ public sealed class StoryStateService( .ToList(); } + private static List BuildAssetAssignments( + IReadOnlyList assetEvents, + IReadOnlyList assetLocations, + IReadOnlyList assetCustody, + IReadOnlySet allowedSceneIds) + { + var stateAssignments = assetEvents + .Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName)) + .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.ToStateName!, 1, x.UpdatedDate)); + var locationAssignments = assetLocations + .Where(x => allowedSceneIds.Contains(x.SceneID)) + .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationID, x.LocationPath, 2, x.UpdatedDate)); + var custodyAssignments = assetCustody + .Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary)) + .Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.CustodianSummary!, 3, x.UpdatedDate)); + + return stateAssignments.Concat(locationAssignments).Concat(custodyAssignments).ToList(); + } + + private static List BuildLocationState(IReadOnlyList allLocations, StoryStateViewModel storyState) + { + return allLocations + .OrderBy(x => x.LocationPath) + .Select(location => new LocationStoryStateViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationName, + LocationPath = location.LocationPath, + CharactersPresent = storyState.Characters + .Where(x => x.LocationID == location.LocationID) + .Select(x => x.CharacterName) + .OrderBy(x => x) + .ToList(), + AssetsPresent = storyState.Assets + .Where(x => x.LocationID == location.LocationID) + .Select(x => x.AssetName) + .OrderBy(x => x) + .ToList() + }) + .Where(x => x.CharactersPresent.Any() || x.AssetsPresent.Any()) + .ToList(); + } + + private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter) + { + storyState.Characters = filter.IncludeCharacters + ? storyState.Characters.Where(x => !filter.CharacterIDs.Any() || filter.CharacterIDs.Contains(x.CharacterID)).ToList() + : []; + storyState.Assets = filter.IncludeAssets + ? storyState.Assets.Where(x => !filter.AssetIDs.Any() || filter.AssetIDs.Contains(x.StoryAssetID)).ToList() + : []; + storyState.Locations = filter.IncludeLocations + ? storyState.Locations.Where(x => !filter.LocationIDs.Any() || filter.LocationIDs.Contains(x.LocationID)).ToList() + : []; + } + + private static Dictionary BuildSceneIndexes(IReadOnlyList orderedScenes) + { + return orderedScenes + .Select((scene, index) => new { scene.SceneID, Index = index }) + .ToDictionary(x => x.SceneID, x => x.Index); + } + + private static void NormaliseFilter(ContinuityFilter filter) + { + if (filter.ProjectID <= 0) + { + return; + } + + if (!filter.EntityTypes.Any()) + { + filter.EntityTypes = ["Characters", "Assets", "Locations"]; + } + + if (string.IsNullOrWhiteSpace(filter.SceneRangeMode)) + { + filter.SceneRangeMode = "EntireProject"; + } + + if (string.IsNullOrWhiteSpace(filter.DisplayMode)) + { + filter.DisplayMode = "StoryState"; + } + } + private static string SceneLabel(decimal sceneNumber, string sceneTitle) { return string.IsNullOrWhiteSpace(sceneTitle) @@ -2401,9 +2742,16 @@ public sealed class StoryStateService( private sealed record AssetStateAssignment( int StoryAssetID, int SceneID, + int? LocationID, string Label, int Priority, DateTime UpdatedDate); + + private sealed record RangeContext( + IReadOnlyList ScopedScenes, + IReadOnlySet AllowedSceneIds, + IReadOnlyDictionary SceneIndexes, + IReadOnlyDictionary SceneLabels); } public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index c119690..21d651a 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -241,12 +241,14 @@ public sealed class StoryStateViewModel { public List Characters { get; set; } = []; public List Assets { get; set; } = []; + public List Locations { get; set; } = []; } public sealed class CharacterStoryStateViewModel { public int CharacterID { get; set; } public string CharacterName { get; set; } = string.Empty; + public int? LocationID { get; set; } public string CurrentLocation { get; set; } = "Unknown"; public string State { get; set; } = "Unknown"; public string LastConfirmed { get; set; } = "Never"; @@ -256,11 +258,85 @@ public sealed class AssetStoryStateViewModel { public int StoryAssetID { get; set; } public string AssetName { get; set; } = string.Empty; + public int? LocationID { get; set; } public string CurrentHolderOrLocation { get; set; } = "Unknown"; public string State { get; set; } = "Unknown"; public string LastConfirmed { get; set; } = "Never"; } +public sealed class LocationStoryStateViewModel +{ + public int LocationID { get; set; } + public string LocationName { get; set; } = string.Empty; + public string LocationPath { get; set; } = string.Empty; + public List CharactersPresent { get; set; } = []; + public List AssetsPresent { get; set; } = []; +} + +public sealed class ContinuityFilter +{ + public int ProjectID { get; set; } + public List EntityTypes { get; set; } = ["Characters", "Assets", "Locations"]; + public List CharacterIDs { get; set; } = []; + public List AssetIDs { get; set; } = []; + public List LocationIDs { get; set; } = []; + public string SceneRangeMode { get; set; } = "EntireProject"; + public int? BookID { get; set; } + public int? ChapterID { get; set; } + public int? StartSceneID { get; set; } + public int? EndSceneID { get; set; } + public string DisplayMode { get; set; } = "StoryState"; + public int? SelectedSceneID { get; set; } + + public bool IncludeCharacters => EntityTypes.Contains("Characters", StringComparer.OrdinalIgnoreCase); + public bool IncludeAssets => EntityTypes.Contains("Assets", StringComparer.OrdinalIgnoreCase); + public bool IncludeLocations => EntityTypes.Contains("Locations", StringComparer.OrdinalIgnoreCase); +} + +public sealed class ContinuityExplorerViewModel +{ + public Project Project { get; set; } = new(); + public ContinuityFilter Filter { get; set; } = new(); + public StoryStateViewModel StoryState { get; set; } = new(); + public IReadOnlyList CharacterJourney { get; set; } = []; + public IReadOnlyList AssetJourney { get; set; } = []; + public IReadOnlyList LocationActivity { get; set; } = []; + public IReadOnlyList BookOptions { get; set; } = []; + public IReadOnlyList ChapterOptions { get; set; } = []; + public IReadOnlyList SceneOptions { get; set; } = []; + public IReadOnlyList CharacterOptions { get; set; } = []; + public IReadOnlyList AssetOptions { get; set; } = []; + public IReadOnlyList LocationOptions { get; set; } = []; +} + +public sealed class CharacterJourneyViewModel +{ + public int CharacterID { get; set; } + public string CharacterName { get; set; } = string.Empty; + public int SceneID { get; set; } + public string SceneLabel { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; +} + +public sealed class AssetJourneyViewModel +{ + public int StoryAssetID { get; set; } + public string AssetName { get; set; } = string.Empty; + public int SceneID { get; set; } + public string SceneLabel { get; set; } = string.Empty; + public string HolderOrLocation { get; set; } = string.Empty; +} + +public sealed class LocationActivityViewModel +{ + public int LocationID { get; set; } + public string LocationName { get; set; } = string.Empty; + public int SceneID { get; set; } + public string SceneLabel { get; set; } = string.Empty; + public string CharactersPresent { get; set; } = string.Empty; + public string AssetsPresent { get; set; } = string.Empty; +} + public sealed class SceneWorkflowEditViewModel { public int SceneID { get; set; } diff --git a/PlotLine/Views/ContinuityExplorer/Index.cshtml b/PlotLine/Views/ContinuityExplorer/Index.cshtml new file mode 100644 index 0000000..db48408 --- /dev/null +++ b/PlotLine/Views/ContinuityExplorer/Index.cshtml @@ -0,0 +1,300 @@ +@model ContinuityExplorerViewModel +@{ + ViewData["Title"] = $"Continuity Explorer - {Model.Project.ProjectName}"; + ViewData["ProjectSection"] = "Continuity Explorer"; +} + +@functions { + private static string StateBadgeClass(string state) => state switch + { + "Explicit" => "bg-success", + "Inferred" => "bg-secondary", + _ => "bg-warning text-dark" + }; + + private bool IsEntitySelected(string entityType) => + Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase); +} + + + + + +
+
+

Continuity

+

Continuity Explorer

+

Investigate where characters, assets, and locations stand across the story.

+
+
+ +
+
+ Filters + +
+
+
+
+ + +
+
+ +
+ + + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + + + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+ +
+ + Reset +
+
+
+
+
+ +@if (Model.Filter.DisplayMode == "StoryState") +{ +
+

Story State

+ @if (!Model.StoryState.Characters.Any() && !Model.StoryState.Assets.Any() && !Model.StoryState.Locations.Any()) + { +

No matching continuity information found.

+ } + else + { + @if (Model.Filter.IncludeCharacters) + { +

Characters

+
+ + + + @foreach (var row in Model.StoryState.Characters) + { + + } + +
CharacterCurrent LocationStateLast Confirmed
@row.CharacterName@row.CurrentLocation@row.State@row.LastConfirmed
+
+ } + + @if (Model.Filter.IncludeAssets) + { +

Assets

+
+ + + + @foreach (var row in Model.StoryState.Assets) + { + + } + +
AssetHolder / LocationStateLast Confirmed
@row.AssetName@row.CurrentHolderOrLocation@row.State@row.LastConfirmed
+
+ } + + @if (Model.Filter.IncludeLocations) + { +

Locations

+ @if (!Model.StoryState.Locations.Any()) + { +

No matching location presence found.

+ } + else + { +
+ @foreach (var location in Model.StoryState.Locations) + { +
+
+

@location.LocationPath

+

Characters: @(location.CharactersPresent.Any() ? string.Join(", ", location.CharactersPresent) : "None")

+

Assets: @(location.AssetsPresent.Any() ? string.Join(", ", location.AssetsPresent) : "None")

+
+
+ } +
+ } + } + } +
+} +else if (Model.Filter.DisplayMode == "Journey") +{ +
+

Journey

+ @if (!Model.CharacterJourney.Any() && !Model.AssetJourney.Any()) + { +

No movement recorded.

+ } + else + { + @foreach (var group in Model.CharacterJourney.GroupBy(x => new { x.CharacterID, x.CharacterName })) + { +

@group.Key.CharacterName

+
+ + + + @foreach (var row in group) + { + + } + +
SceneLocation
@row.SceneLabel@row.Location
+
+ } + + @foreach (var group in Model.AssetJourney.GroupBy(x => new { x.StoryAssetID, x.AssetName })) + { +

@group.Key.AssetName

+
+ + + + @foreach (var row in group) + { + + } + +
SceneHolder / Location
@row.SceneLabel@row.HolderOrLocation
+
+ } + } +
+} +else +{ +
+

Location Activity

+ @if (!Model.LocationActivity.Any()) + { +

No matching continuity information found.

+ } + else + { +
+ + + + @foreach (var row in Model.LocationActivity) + { + + } + +
LocationSceneCharacters PresentAssets Present
@row.LocationName@row.SceneLabel@row.CharactersPresent@row.AssetsPresent
+
+ } +
+} diff --git a/PlotLine/Views/Shared/_ProjectSectionNav.cshtml b/PlotLine/Views/Shared/_ProjectSectionNav.cshtml index 45d59a0..72b5a05 100644 --- a/PlotLine/Views/Shared/_ProjectSectionNav.cshtml +++ b/PlotLine/Views/Shared/_ProjectSectionNav.cshtml @@ -20,6 +20,7 @@ Metrics Scenarios Story Bible + Continuity Explorer Health Warnings Archive