Phase 12B: Continuity Explorer Page.
This commit is contained in:
parent
eafa9927c7
commit
e7aabe8b41
28
PlotLine/Controllers/ContinuityExplorerController.cs
Normal file
28
PlotLine/Controllers/ContinuityExplorerController.cs
Normal file
@ -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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -114,6 +114,10 @@ public interface ISceneService
|
|||||||
public interface IStoryStateService
|
public interface IStoryStateService
|
||||||
{
|
{
|
||||||
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
|
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
|
||||||
|
Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter);
|
||||||
|
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
|
||||||
|
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
|
||||||
|
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IPlotService
|
public interface IPlotService
|
||||||
@ -2204,6 +2208,7 @@ public sealed class TimelineService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryStateService(
|
public sealed class StoryStateService(
|
||||||
|
IProjectRepository projects,
|
||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
IChapterRepository chapters,
|
IChapterRepository chapters,
|
||||||
ISceneRepository scenes,
|
ISceneRepository scenes,
|
||||||
@ -2227,7 +2232,7 @@ public sealed class StoryStateService(
|
|||||||
return new StoryStateViewModel();
|
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 orderedScenes = OrderScenes(timeline).ToList();
|
||||||
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
|
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
|
||||||
if (currentSceneIndex < 0)
|
if (currentSceneIndex < 0)
|
||||||
@ -2235,22 +2240,252 @@ public sealed class StoryStateService(
|
|||||||
return new StoryStateViewModel();
|
return new StoryStateViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
var sceneIndexes = orderedScenes
|
var sceneIndexes = BuildSceneIndexes(orderedScenes);
|
||||||
.Select((scene, index) => new { scene.SceneID, Index = index })
|
|
||||||
.ToDictionary(x => x.SceneID, x => x.Index);
|
|
||||||
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
|
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
|
||||||
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet();
|
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 characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null);
|
||||||
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID);
|
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null);
|
||||||
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
|
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
|
||||||
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
|
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
|
||||||
|
|
||||||
return new StoryStateViewModel
|
var storyState = new StoryStateViewModel
|
||||||
{
|
{
|
||||||
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes),
|
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))
|
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<ContinuityExplorerViewModel?> 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<CharacterJourneyViewModel>();
|
||||||
|
foreach (var characterId in characterIds)
|
||||||
|
{
|
||||||
|
characterJourney.AddRange(await GetCharacterJourneyAsync(filter.ProjectID, characterId, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetJourney = new List<AssetJourneyViewModel>();
|
||||||
|
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<LocationActivityViewModel>();
|
||||||
|
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<List<CharacterJourneyViewModel>> 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<CharacterJourneyViewModel>();
|
||||||
|
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<List<AssetJourneyViewModel>> 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<AssetJourneyViewModel>();
|
||||||
|
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<List<LocationActivityViewModel>> 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<LocationActivityViewModel>();
|
||||||
|
|
||||||
|
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<RangeContext> 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<Scene> OrderScenes(TimelineData timeline)
|
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
|
||||||
@ -2267,6 +2502,36 @@ public sealed class StoryStateService(
|
|||||||
.ThenBy(scene => scene.SceneID);
|
.ThenBy(scene => scene.SceneID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Scene> ApplySceneRange(IReadOnlyList<Scene> orderedScenes, IReadOnlyList<Chapter> 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<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
|
private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
|
||||||
{
|
{
|
||||||
var rows = new List<SceneAssetLocation>();
|
var rows = new List<SceneAssetLocation>();
|
||||||
@ -2324,6 +2589,7 @@ public sealed class StoryStateService(
|
|||||||
{
|
{
|
||||||
CharacterID = character.CharacterID,
|
CharacterID = character.CharacterID,
|
||||||
CharacterName = character.CharacterName,
|
CharacterName = character.CharacterName,
|
||||||
|
LocationID = latest.LocationID,
|
||||||
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
|
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
|
||||||
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
||||||
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
|
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
|
||||||
@ -2342,19 +2608,7 @@ public sealed class StoryStateService(
|
|||||||
IReadOnlyDictionary<int, int> sceneIndexes,
|
IReadOnlyDictionary<int, int> sceneIndexes,
|
||||||
IReadOnlyDictionary<int, Scene> scenesById)
|
IReadOnlyDictionary<int, Scene> scenesById)
|
||||||
{
|
{
|
||||||
var stateAssignments = assetEvents
|
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
|
||||||
.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();
|
|
||||||
|
|
||||||
return projectAssets
|
return projectAssets
|
||||||
.OrderByDescending(x => x.Importance)
|
.OrderByDescending(x => x.Importance)
|
||||||
@ -2381,6 +2635,7 @@ public sealed class StoryStateService(
|
|||||||
{
|
{
|
||||||
StoryAssetID = asset.StoryAssetID,
|
StoryAssetID = asset.StoryAssetID,
|
||||||
AssetName = asset.AssetName,
|
AssetName = asset.AssetName,
|
||||||
|
LocationID = latest.LocationID,
|
||||||
CurrentHolderOrLocation = latest.Label,
|
CurrentHolderOrLocation = latest.Label,
|
||||||
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
||||||
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
|
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
|
||||||
@ -2391,6 +2646,92 @@ public sealed class StoryStateService(
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<AssetStateAssignment> BuildAssetAssignments(
|
||||||
|
IReadOnlyList<AssetEvent> assetEvents,
|
||||||
|
IReadOnlyList<SceneAssetLocation> assetLocations,
|
||||||
|
IReadOnlyList<AssetCustodyEvent> assetCustody,
|
||||||
|
IReadOnlySet<int> 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<LocationStoryStateViewModel> BuildLocationState(IReadOnlyList<LocationItem> 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<int, int> BuildSceneIndexes(IReadOnlyList<Scene> 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)
|
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(sceneTitle)
|
return string.IsNullOrWhiteSpace(sceneTitle)
|
||||||
@ -2401,9 +2742,16 @@ public sealed class StoryStateService(
|
|||||||
private sealed record AssetStateAssignment(
|
private sealed record AssetStateAssignment(
|
||||||
int StoryAssetID,
|
int StoryAssetID,
|
||||||
int SceneID,
|
int SceneID,
|
||||||
|
int? LocationID,
|
||||||
string Label,
|
string Label,
|
||||||
int Priority,
|
int Priority,
|
||||||
DateTime UpdatedDate);
|
DateTime UpdatedDate);
|
||||||
|
|
||||||
|
private sealed record RangeContext(
|
||||||
|
IReadOnlyList<Scene> ScopedScenes,
|
||||||
|
IReadOnlySet<int> AllowedSceneIds,
|
||||||
|
IReadOnlyDictionary<int, int> SceneIndexes,
|
||||||
|
IReadOnlyDictionary<int, string> SceneLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService
|
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService
|
||||||
|
|||||||
@ -241,12 +241,14 @@ public sealed class StoryStateViewModel
|
|||||||
{
|
{
|
||||||
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
|
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
|
||||||
public List<AssetStoryStateViewModel> Assets { get; set; } = [];
|
public List<AssetStoryStateViewModel> Assets { get; set; } = [];
|
||||||
|
public List<LocationStoryStateViewModel> Locations { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CharacterStoryStateViewModel
|
public sealed class CharacterStoryStateViewModel
|
||||||
{
|
{
|
||||||
public int CharacterID { get; set; }
|
public int CharacterID { get; set; }
|
||||||
public string CharacterName { get; set; } = string.Empty;
|
public string CharacterName { get; set; } = string.Empty;
|
||||||
|
public int? LocationID { get; set; }
|
||||||
public string CurrentLocation { get; set; } = "Unknown";
|
public string CurrentLocation { get; set; } = "Unknown";
|
||||||
public string State { get; set; } = "Unknown";
|
public string State { get; set; } = "Unknown";
|
||||||
public string LastConfirmed { get; set; } = "Never";
|
public string LastConfirmed { get; set; } = "Never";
|
||||||
@ -256,11 +258,85 @@ public sealed class AssetStoryStateViewModel
|
|||||||
{
|
{
|
||||||
public int StoryAssetID { get; set; }
|
public int StoryAssetID { get; set; }
|
||||||
public string AssetName { get; set; } = string.Empty;
|
public string AssetName { get; set; } = string.Empty;
|
||||||
|
public int? LocationID { get; set; }
|
||||||
public string CurrentHolderOrLocation { get; set; } = "Unknown";
|
public string CurrentHolderOrLocation { get; set; } = "Unknown";
|
||||||
public string State { get; set; } = "Unknown";
|
public string State { get; set; } = "Unknown";
|
||||||
public string LastConfirmed { get; set; } = "Never";
|
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<string> CharactersPresent { get; set; } = [];
|
||||||
|
public List<string> AssetsPresent { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ContinuityFilter
|
||||||
|
{
|
||||||
|
public int ProjectID { get; set; }
|
||||||
|
public List<string> EntityTypes { get; set; } = ["Characters", "Assets", "Locations"];
|
||||||
|
public List<int> CharacterIDs { get; set; } = [];
|
||||||
|
public List<int> AssetIDs { get; set; } = [];
|
||||||
|
public List<int> 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<CharacterJourneyViewModel> CharacterJourney { get; set; } = [];
|
||||||
|
public IReadOnlyList<AssetJourneyViewModel> AssetJourney { get; set; } = [];
|
||||||
|
public IReadOnlyList<LocationActivityViewModel> LocationActivity { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> AssetOptions { get; set; } = [];
|
||||||
|
public IReadOnlyList<SelectListItem> 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 sealed class SceneWorkflowEditViewModel
|
||||||
{
|
{
|
||||||
public int SceneID { get; set; }
|
public int SceneID { get; set; }
|
||||||
|
|||||||
300
PlotLine/Views/ContinuityExplorer/Index.cshtml
Normal file
300
PlotLine/Views/ContinuityExplorer/Index.cshtml
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span>Continuity Explorer</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||||
|
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Continuity</p>
|
||||||
|
<h1>Continuity Explorer</h1>
|
||||||
|
<p class="lead-text">Investigate where characters, assets, and locations stand across the story.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Filters</strong>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#continuity-filter-panel" aria-expanded="true" aria-controls="continuity-filter-panel">Toggle</button>
|
||||||
|
</div>
|
||||||
|
<div class="collapse show" id="continuity-filter-panel">
|
||||||
|
<div class="card-body">
|
||||||
|
<form asp-action="ApplyFilters" method="post">
|
||||||
|
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label">Entity types</label>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Characters" checked="@IsEntitySelected("Characters")" />
|
||||||
|
<span class="form-check-label">Characters</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Assets" checked="@IsEntitySelected("Assets")" />
|
||||||
|
<span class="form-check-label">Assets</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Locations" checked="@IsEntitySelected("Locations")" />
|
||||||
|
<span class="form-check-label">Locations</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="CharacterIDs">Characters</label>
|
||||||
|
<select class="form-select form-select-sm" id="CharacterIDs" name="CharacterIDs" asp-items="Model.CharacterOptions" multiple size="6"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="AssetIDs">Assets</label>
|
||||||
|
<select class="form-select form-select-sm" id="AssetIDs" name="AssetIDs" asp-items="Model.AssetOptions" multiple size="6"></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="LocationIDs">Locations</label>
|
||||||
|
<select class="form-select form-select-sm" id="LocationIDs" name="LocationIDs" asp-items="Model.LocationOptions" multiple size="6"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label class="form-label">Scene range</label>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="SceneRangeMode" value="EntireProject" checked="@(Model.Filter.SceneRangeMode == "EntireProject")" />
|
||||||
|
<span class="form-check-label">Entire Project</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Book" checked="@(Model.Filter.SceneRangeMode == "Book")" />
|
||||||
|
<span class="form-check-label">Book</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Chapter" checked="@(Model.Filter.SceneRangeMode == "Chapter")" />
|
||||||
|
<span class="form-check-label">Chapter</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Custom" checked="@(Model.Filter.SceneRangeMode == "Custom")" />
|
||||||
|
<span class="form-check-label">Custom Scene Range</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="BookID">Book</label>
|
||||||
|
<select class="form-select form-select-sm" id="BookID" name="BookID" asp-items="Model.BookOptions">
|
||||||
|
<option value="">Any book</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="ChapterID">Chapter</label>
|
||||||
|
<select class="form-select form-select-sm" id="ChapterID" name="ChapterID" asp-items="Model.ChapterOptions">
|
||||||
|
<option value="">Any chapter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="StartSceneID">Start scene</label>
|
||||||
|
<select class="form-select form-select-sm" id="StartSceneID" name="StartSceneID" asp-items="Model.SceneOptions">
|
||||||
|
<option value="">First scene</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="EndSceneID">End scene</label>
|
||||||
|
<select class="form-select form-select-sm" id="EndSceneID" name="EndSceneID" asp-items="Model.SceneOptions">
|
||||||
|
<option value="">Last scene</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label class="form-label">Display mode</label>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="DisplayMode" value="StoryState" checked="@(Model.Filter.DisplayMode == "StoryState")" />
|
||||||
|
<span class="form-check-label">Story State</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="DisplayMode" value="Journey" checked="@(Model.Filter.DisplayMode == "Journey")" />
|
||||||
|
<span class="form-check-label">Journey</span>
|
||||||
|
</label>
|
||||||
|
<label class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
|
||||||
|
<span class="form-check-label">Location Activity</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<label class="form-label" for="SelectedSceneID">Story State scene</label>
|
||||||
|
<select class="form-select form-select-sm" id="SelectedSceneID" name="SelectedSceneID" asp-items="Model.SceneOptions"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row mt-3">
|
||||||
|
<button class="btn btn-primary" type="submit">Apply filters</button>
|
||||||
|
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (Model.Filter.DisplayMode == "StoryState")
|
||||||
|
{
|
||||||
|
<section class="story-bible-section">
|
||||||
|
<h2>Story State</h2>
|
||||||
|
@if (!Model.StoryState.Characters.Any() && !Model.StoryState.Assets.Any() && !Model.StoryState.Locations.Any())
|
||||||
|
{
|
||||||
|
<p class="muted">No matching continuity information found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (Model.Filter.IncludeCharacters)
|
||||||
|
{
|
||||||
|
<h3>Characters</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Character</th><th>Current Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in Model.StoryState.Characters)
|
||||||
|
{
|
||||||
|
<tr><td>@row.CharacterName</td><td>@row.CurrentLocation</td><td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td><td>@row.LastConfirmed</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Filter.IncludeAssets)
|
||||||
|
{
|
||||||
|
<h3>Assets</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Asset</th><th>Holder / Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in Model.StoryState.Assets)
|
||||||
|
{
|
||||||
|
<tr><td>@row.AssetName</td><td>@row.CurrentHolderOrLocation</td><td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td><td>@row.LastConfirmed</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Filter.IncludeLocations)
|
||||||
|
{
|
||||||
|
<h3>Locations</h3>
|
||||||
|
@if (!Model.StoryState.Locations.Any())
|
||||||
|
{
|
||||||
|
<p class="muted">No matching location presence found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3">
|
||||||
|
@foreach (var location in Model.StoryState.Locations)
|
||||||
|
{
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<article class="story-bible-card h-100">
|
||||||
|
<h3>@location.LocationPath</h3>
|
||||||
|
<p><strong>Characters:</strong> @(location.CharactersPresent.Any() ? string.Join(", ", location.CharactersPresent) : "None")</p>
|
||||||
|
<p><strong>Assets:</strong> @(location.AssetsPresent.Any() ? string.Join(", ", location.AssetsPresent) : "None")</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (Model.Filter.DisplayMode == "Journey")
|
||||||
|
{
|
||||||
|
<section class="story-bible-section">
|
||||||
|
<h2>Journey</h2>
|
||||||
|
@if (!Model.CharacterJourney.Any() && !Model.AssetJourney.Any())
|
||||||
|
{
|
||||||
|
<p class="muted">No movement recorded.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var group in Model.CharacterJourney.GroupBy(x => new { x.CharacterID, x.CharacterName }))
|
||||||
|
{
|
||||||
|
<h3>@group.Key.CharacterName</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Scene</th><th>Location</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in group)
|
||||||
|
{
|
||||||
|
<tr><td>@row.SceneLabel</td><td>@row.Location</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var group in Model.AssetJourney.GroupBy(x => new { x.StoryAssetID, x.AssetName }))
|
||||||
|
{
|
||||||
|
<h3>@group.Key.AssetName</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Scene</th><th>Holder / Location</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in group)
|
||||||
|
{
|
||||||
|
<tr><td>@row.SceneLabel</td><td>@row.HolderOrLocation</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="story-bible-section">
|
||||||
|
<h2>Location Activity</h2>
|
||||||
|
@if (!Model.LocationActivity.Any())
|
||||||
|
{
|
||||||
|
<p class="muted">No matching continuity information found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Location</th><th>Scene</th><th>Characters Present</th><th>Assets Present</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in Model.LocationActivity)
|
||||||
|
{
|
||||||
|
<tr><td>@row.LocationName</td><td>@row.SceneLabel</td><td>@row.CharactersPresent</td><td>@row.AssetsPresent</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@
|
|||||||
<a class="pw-project-nav__link @(ActiveClass("Metrics"))" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@projectId">Metrics</a>
|
<a class="pw-project-nav__link @(ActiveClass("Metrics"))" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@projectId">Metrics</a>
|
||||||
<a class="pw-project-nav__link @(ActiveClass("Scenarios"))" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@projectId">Scenarios</a>
|
<a class="pw-project-nav__link @(ActiveClass("Scenarios"))" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@projectId">Scenarios</a>
|
||||||
<a class="pw-project-nav__link @(ActiveClass("Story Bible"))" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@projectId">Story Bible</a>
|
<a class="pw-project-nav__link @(ActiveClass("Story Bible"))" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@projectId">Story Bible</a>
|
||||||
|
<a class="pw-project-nav__link @(ActiveClass("Continuity Explorer"))" asp-controller="ContinuityExplorer" asp-action="Index" asp-route-projectId="@projectId">Continuity Explorer</a>
|
||||||
<a class="pw-project-nav__link @(ActiveClass("Health"))" asp-controller="Analytics" asp-action="Index" asp-route-projectId="@projectId">Health</a>
|
<a class="pw-project-nav__link @(ActiveClass("Health"))" asp-controller="Analytics" asp-action="Index" asp-route-projectId="@projectId">Health</a>
|
||||||
<a class="pw-project-nav__link @(ActiveClass("Warnings"))" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@projectId">Warnings</a>
|
<a class="pw-project-nav__link @(ActiveClass("Warnings"))" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@projectId">Warnings</a>
|
||||||
<a class="pw-project-nav__link @(ActiveClass("Archive"))" asp-controller="Archives" asp-action="Index" asp-route-projectId="@projectId">Archive</a>
|
<a class="pw-project-nav__link @(ActiveClass("Archive"))" asp-controller="Archives" asp-action="Index" asp-route-projectId="@projectId">Archive</a>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user