Implemented Phase 1 Scene Occupancy.
What Changed Added scene-owned occupancy data:Scenes.FloorPlanID Scenes.InitialFloorPlanFloorID new SceneFloorPlanOccupancy table Added Occupancy section to the Scene Inspector with AJAX save/refresh. Added Floor Plan editor Structure / Occupancy mode. Occupancy mode shows linked scenes, previous/next scene controls, and character/asset tokens inside assigned locations. Character tokens use image thumbnail, then initials fallback. Asset tokens do the same, smaller. No Features, no new floor-plan architecture, no transition changes. Files Changed [CoreModels.cs](C:/Source/PlotLine/PlotLine/Models/CoreModels.cs) [CoreViewModels.cs](C:/Source/PlotLine/PlotLine/ViewModels/CoreViewModels.cs) [Repositories.cs](C:/Source/PlotLine/PlotLine/Data/Repositories.cs) [CoreServices.cs](C:/Source/PlotLine/PlotLine/Services/CoreServices.cs) [ScenesController.cs](C:/Source/PlotLine/PlotLine/Controllers/ScenesController.cs) [Program.cs](C:/Source/PlotLine/PlotLine/Program.cs) [Edit.cshtml](C:/Source/PlotLine/PlotLine/Views/FloorPlans/Edit.cshtml) [_SceneInspector.cshtml](C:/Source/PlotLine/PlotLine/Views/Scenes/_SceneInspector.cshtml) [_SceneOccupancy.cshtml](C:/Source/PlotLine/PlotLine/Views/Scenes/_SceneOccupancy.cshtml) [site.js](C:/Source/PlotLine/PlotLine/wwwroot/js/site.js) [site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css) minified CSS/JS updated. Database Added new migration only: [080_SceneFloorPlanOccupancy.sql](C:/Source/PlotLine/PlotLine/Sql/080_SceneFloorPlanOccupancy.sql) Applied locally. Added constraints/FKs and filtered unique indexes for one character/asset occupancy row per scene. Stored Procedures Added/Updated Scene_Save Scene_Get Scene_FloorPlanLink_Save Scene_ListByFloorPlan FloorPlanFloor_ListByPlan FloorPlanBlock_ListByPlan SceneFloorPlanOccupancy_ListByScene SceneFloorPlanOccupancy_ListByFloorPlan SceneFloorPlanOccupancy_Save Test Data Permanent Calendar House seed data added: Book: The Calendar House Chapters 1-3 14 scenes Characters: Ellie, Grant, Stephen, Housekeeper, Gardener Assets: Brass Key, Journal, Candle, Map, Letter 45 occupancy assignments Verification dotnet build PlotLine.slnx passes with 0 warnings/errors. Migration applied locally. Local app boots and returns HTTP 200. DB checks confirm 14 linked scenes and 45 occupancy rows. Manual browser click-through of the authenticated UI still needs a human pass for the AJAX controls and visual token placement.
This commit is contained in:
parent
34a8d94cb4
commit
cea82a922f
@ -492,6 +492,32 @@ public sealed class ScenesController(
|
||||
return RedirectToAction(nameof(Edit), new { id = sceneId });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> OccupancyEditor(int sceneId, int? floorPlanId)
|
||||
{
|
||||
var model = await scenes.GetOccupancyEditorAsync(sceneId, floorPlanId);
|
||||
return model is null ? NotFound() : PartialView("_SceneOccupancy", model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveOccupancy(SceneOccupancySaveViewModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
await scenes.SaveOccupancyAsync(model);
|
||||
var refreshed = await scenes.GetOccupancyEditorAsync(model.SceneID, model.FloorPlanID);
|
||||
return refreshed is null
|
||||
? NotFound()
|
||||
: PartialView("_SceneOccupancy", refreshed);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return Json(new { ok = false, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetLocalReturnUrl(string? returnUrl = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(returnUrl)
|
||||
@ -563,6 +589,8 @@ public sealed class ScenesController(
|
||||
target.SceneOutcomeNotes = source.SceneOutcomeNotes;
|
||||
target.RevisionStatusID = source.RevisionStatusID;
|
||||
target.PrimaryLocationID = source.PrimaryLocationID;
|
||||
target.FloorPlanID = source.FloorPlanID;
|
||||
target.InitialFloorPlanFloorID = source.InitialFloorPlanFloorID;
|
||||
target.SelectedPurposeTypeIds = source.SelectedPurposeTypeIds;
|
||||
target.ReturnToTimeline = source.ReturnToTimeline;
|
||||
target.ReturnProjectID = source.ReturnProjectID;
|
||||
|
||||
@ -69,8 +69,10 @@ public interface IChapterRepository
|
||||
public interface ISceneRepository
|
||||
{
|
||||
Task<IReadOnlyList<Scene>> ListByChapterAsync(int chapterId);
|
||||
Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId);
|
||||
Task<Scene?> GetAsync(int sceneId);
|
||||
Task<int> SaveAsync(Scene scene);
|
||||
Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId);
|
||||
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
|
||||
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
|
||||
Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value);
|
||||
@ -83,6 +85,13 @@ public interface ISceneRepository
|
||||
Task RenumberChapterAsync(int chapterId);
|
||||
}
|
||||
|
||||
public interface ISceneFloorPlanOccupancyRepository
|
||||
{
|
||||
Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListBySceneAsync(int sceneId);
|
||||
Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListByFloorPlanAsync(int floorPlanId);
|
||||
Task SaveAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId, IEnumerable<SceneFloorPlanOccupancy> occupancyRows);
|
||||
}
|
||||
|
||||
public interface ILookupRepository
|
||||
{
|
||||
Task<LookupData> GetAllAsync();
|
||||
@ -223,6 +232,8 @@ public interface IFloorPlanRepository
|
||||
Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks, IReadOnlyList<FloorPlanTransition> Transitions)> GetEditorAsync(int floorPlanId);
|
||||
Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId);
|
||||
Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByFloorAsync(int floorPlanFloorId);
|
||||
Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByPlanAsync(int floorPlanId);
|
||||
Task<IReadOnlyList<FloorPlanFloor>> ListFloorsByPlanAsync(int floorPlanId);
|
||||
Task<FloorPlanBlock?> GetBlockAsync(int floorPlanBlockId);
|
||||
Task<int> SavePlanAsync(FloorPlan floorPlan);
|
||||
Task DeletePlanAsync(int floorPlanId);
|
||||
@ -582,6 +593,20 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FloorPlanBlock>> ListBlocksByPlanAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<FloorPlanBlock>("dbo.FloorPlanBlock_ListByPlan", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FloorPlanFloor>> ListFloorsByPlanAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<FloorPlanFloor>("dbo.FloorPlanFloor_ListByPlan", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<FloorPlanBlock?> GetBlockAsync(int floorPlanBlockId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -2446,6 +2471,13 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<Scene>("dbo.Scene_ListByFloorPlan", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<Scene?> GetAsync(int sceneId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -2483,11 +2515,22 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
|
||||
scene.ScenePurposeNotes,
|
||||
scene.SceneOutcomeNotes,
|
||||
scene.RevisionStatusID,
|
||||
scene.PrimaryLocationID
|
||||
scene.PrimaryLocationID,
|
||||
scene.FloorPlanID,
|
||||
scene.InitialFloorPlanFloorID
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Scene_FloorPlanLink_Save",
|
||||
new { SceneID = sceneId, FloorPlanID = floorPlanId, InitialFloorPlanFloorID = initialFloorPlanFloorId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -2584,6 +2627,53 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SceneFloorPlanOccupancyRepository(ISqlConnectionFactory connectionFactory) : ISceneFloorPlanOccupancyRepository
|
||||
{
|
||||
public async Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListBySceneAsync(int sceneId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<SceneFloorPlanOccupancy>(
|
||||
"dbo.SceneFloorPlanOccupancy_ListByScene",
|
||||
new { SceneID = sceneId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListByFloorPlanAsync(int floorPlanId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<SceneFloorPlanOccupancy>(
|
||||
"dbo.SceneFloorPlanOccupancy_ListByFloorPlan",
|
||||
new { FloorPlanID = floorPlanId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId, IEnumerable<SceneFloorPlanOccupancy> occupancyRows)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Scene_FloorPlanLink_Save",
|
||||
new { SceneID = sceneId, FloorPlanID = floorPlanId, InitialFloorPlanFloorID = initialFloorPlanFloorId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
foreach (var row in occupancyRows)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.SceneFloorPlanOccupancy_Save",
|
||||
new
|
||||
{
|
||||
SceneID = sceneId,
|
||||
FloorPlanID = floorPlanId,
|
||||
row.CharacterID,
|
||||
row.StoryAssetID,
|
||||
LocationID = row.LocationID == 0 ? (int?)null : row.LocationID
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) : ILookupRepository
|
||||
{
|
||||
public async Task<LookupData> GetAllAsync()
|
||||
|
||||
@ -313,6 +313,8 @@ public sealed class Scene
|
||||
public string? Summary { get; set; }
|
||||
public int? POVCharacterID { get; set; }
|
||||
public int? PrimaryLocationID { get; set; }
|
||||
public int? FloorPlanID { get; set; }
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
public string? PrimaryLocationName { get; set; }
|
||||
public string? PrimaryLocationPath { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
@ -1248,6 +1250,29 @@ public static class FloorPlanTransitionTypes
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SceneFloorPlanOccupancy
|
||||
{
|
||||
public int SceneFloorPlanOccupancyID { get; set; }
|
||||
public int SceneID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
public int? FloorPlanFloorID { get; set; }
|
||||
public string? FloorName { get; set; }
|
||||
public int? CharacterID { get; set; }
|
||||
public string? CharacterName { get; set; }
|
||||
public string? CharacterShortName { get; set; }
|
||||
public string? CharacterImagePath { get; set; }
|
||||
public string? CharacterThumbnailPath { get; set; }
|
||||
public int? StoryAssetID { get; set; }
|
||||
public string? AssetName { get; set; }
|
||||
public string? AssetImagePath { get; set; }
|
||||
public string? AssetThumbnailPath { get; set; }
|
||||
public int LocationID { get; set; }
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string LocationPath { get; set; } = string.Empty;
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public DateTime? ModifiedDate { get; set; }
|
||||
}
|
||||
|
||||
public static class FloorPlanTransitionPositions
|
||||
{
|
||||
public const string Centre = "Centre";
|
||||
|
||||
@ -97,6 +97,7 @@ public class Program
|
||||
builder.Services.AddScoped<IBookRepository, BookRepository>();
|
||||
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
|
||||
builder.Services.AddScoped<ISceneRepository, SceneRepository>();
|
||||
builder.Services.AddScoped<ISceneFloorPlanOccupancyRepository, SceneFloorPlanOccupancyRepository>();
|
||||
builder.Services.AddScoped<ILookupRepository, LookupRepository>();
|
||||
builder.Services.AddScoped<ITimelineRepository, TimelineRepository>();
|
||||
builder.Services.AddScoped<ITimelinePresetRepository, TimelinePresetRepository>();
|
||||
|
||||
@ -109,6 +109,8 @@ public interface ISceneService
|
||||
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
|
||||
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
|
||||
Task<int> SaveAsync(SceneEditViewModel model);
|
||||
Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null);
|
||||
Task SaveOccupancyAsync(SceneOccupancySaveViewModel model);
|
||||
Task ArchiveAsync(int sceneId);
|
||||
Task MoveAsync(int sceneId, string direction);
|
||||
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
|
||||
@ -1360,6 +1362,8 @@ public sealed class SceneService(
|
||||
IAssetRepository assets,
|
||||
ICharacterRepository characters,
|
||||
ILocationRepository locations,
|
||||
IFloorPlanRepository floorPlans,
|
||||
ISceneFloorPlanOccupancyRepository sceneOccupancy,
|
||||
IWarningRepository warnings,
|
||||
ISceneDependencyRepository sceneDependencies,
|
||||
IWriterWorkspaceRepository writerWorkspace,
|
||||
@ -1427,6 +1431,8 @@ public sealed class SceneService(
|
||||
SceneOutcomeNotes = scene.SceneOutcomeNotes,
|
||||
RevisionStatusID = scene.RevisionStatusID,
|
||||
PrimaryLocationID = scene.PrimaryLocationID,
|
||||
FloorPlanID = scene.FloorPlanID,
|
||||
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
||||
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
|
||||
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
|
||||
{
|
||||
@ -1475,7 +1481,9 @@ public sealed class SceneService(
|
||||
ScenePurposeNotes = model.ScenePurposeNotes,
|
||||
SceneOutcomeNotes = model.SceneOutcomeNotes,
|
||||
RevisionStatusID = model.RevisionStatusID,
|
||||
PrimaryLocationID = model.PrimaryLocationID
|
||||
PrimaryLocationID = model.PrimaryLocationID,
|
||||
FloorPlanID = model.FloorPlanID,
|
||||
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
|
||||
});
|
||||
|
||||
await scenes.SavePurposesAsync(sceneId, model.SelectedPurposeTypeIds);
|
||||
@ -1724,10 +1732,172 @@ public sealed class SceneService(
|
||||
ReturnProjectID = model.ReturnProjectID,
|
||||
ReturnBookID = model.ReturnBookID
|
||||
};
|
||||
model.Occupancy = model.SceneID == 0
|
||||
? new SceneOccupancyEditorViewModel()
|
||||
: await BuildOccupancyEditorAsync(model, projectAssets);
|
||||
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
|
||||
return model;
|
||||
}
|
||||
|
||||
public async Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null)
|
||||
{
|
||||
var sceneModel = await GetEditAsync(sceneId);
|
||||
if (sceneModel is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (floorPlanId.HasValue)
|
||||
{
|
||||
sceneModel.FloorPlanID = floorPlanId;
|
||||
}
|
||||
|
||||
var projectAssets = sceneModel.Project is null ? [] : await assets.ListAssetsAsync(sceneModel.Project.ProjectID);
|
||||
return await BuildOccupancyEditorAsync(sceneModel, projectAssets);
|
||||
}
|
||||
|
||||
public async Task SaveOccupancyAsync(SceneOccupancySaveViewModel model)
|
||||
{
|
||||
var scene = await scenes.GetAsync(model.SceneID) ?? throw new InvalidOperationException("Scene not found.");
|
||||
var context = await GetContextAsync(scene.ChapterID) ?? throw new InvalidOperationException("Scene context not found.");
|
||||
|
||||
if (model.FloorPlanID.HasValue)
|
||||
{
|
||||
var floorPlan = await floorPlans.GetAsync(model.FloorPlanID.Value);
|
||||
if (floorPlan is null || floorPlan.ProjectID != context.Project.ProjectID)
|
||||
{
|
||||
throw new InvalidOperationException("Floor plan not found.");
|
||||
}
|
||||
|
||||
if (model.InitialFloorPlanFloorID.HasValue)
|
||||
{
|
||||
var floors = await floorPlans.ListFloorsByPlanAsync(model.FloorPlanID.Value);
|
||||
if (!floors.Any(x => x.FloorPlanFloorID == model.InitialFloorPlanFloorID.Value))
|
||||
{
|
||||
throw new InvalidOperationException("Initial floor does not belong to the selected floor plan.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
model.InitialFloorPlanFloorID = null;
|
||||
}
|
||||
|
||||
var allowedLocations = model.FloorPlanID.HasValue
|
||||
? (await floorPlans.ListBlocksByPlanAsync(model.FloorPlanID.Value)).Select(x => x.LocationID).ToHashSet()
|
||||
: [];
|
||||
|
||||
var sceneCharacterIds = (await characters.ListSceneCharactersAsync(model.SceneID)).Select(x => x.CharacterID).ToHashSet();
|
||||
var sceneAssetIds = (await assets.ListEventsBySceneAsync(model.SceneID)).Select(x => x.StoryAssetID).ToHashSet();
|
||||
var rows = new List<SceneFloorPlanOccupancy>();
|
||||
|
||||
foreach (var character in model.Characters.Where(x => sceneCharacterIds.Contains(x.CharacterID)))
|
||||
{
|
||||
if (character.LocationID.HasValue && !allowedLocations.Contains(character.LocationID.Value))
|
||||
{
|
||||
throw new InvalidOperationException("Character location must be on the selected floor plan.");
|
||||
}
|
||||
|
||||
rows.Add(new SceneFloorPlanOccupancy
|
||||
{
|
||||
CharacterID = character.CharacterID,
|
||||
LocationID = character.LocationID ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var asset in model.Assets.Where(x => sceneAssetIds.Contains(x.StoryAssetID)))
|
||||
{
|
||||
if (asset.LocationID.HasValue && !allowedLocations.Contains(asset.LocationID.Value))
|
||||
{
|
||||
throw new InvalidOperationException("Asset location must be on the selected floor plan.");
|
||||
}
|
||||
|
||||
rows.Add(new SceneFloorPlanOccupancy
|
||||
{
|
||||
StoryAssetID = asset.StoryAssetID,
|
||||
LocationID = asset.LocationID ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
await sceneOccupancy.SaveAsync(model.SceneID, model.FloorPlanID, model.InitialFloorPlanFloorID, rows);
|
||||
await activity.RecordAsync(context.Project.ProjectID, "Updated", "Scene Occupancy", model.SceneID, scene.SceneTitle);
|
||||
}
|
||||
|
||||
private async Task<SceneOccupancyEditorViewModel> BuildOccupancyEditorAsync(SceneEditViewModel model, IReadOnlyList<StoryAsset> projectAssets)
|
||||
{
|
||||
var floorPlanList = model.Project is null ? [] : await floorPlans.ListByProjectAsync(model.Project.ProjectID);
|
||||
var selectedFloorPlanId = model.FloorPlanID ?? floorPlanList.FirstOrDefault()?.FloorPlanID;
|
||||
var existingRows = model.SceneID == 0 ? [] : await sceneOccupancy.ListBySceneAsync(model.SceneID);
|
||||
var floors = new List<SceneOccupancyFloorOption>();
|
||||
var locationsOnPlans = new List<SceneOccupancyLocationOption>();
|
||||
|
||||
foreach (var plan in floorPlanList)
|
||||
{
|
||||
var planFloors = await floorPlans.ListFloorsByPlanAsync(plan.FloorPlanID);
|
||||
var blocks = await floorPlans.ListBlocksByPlanAsync(plan.FloorPlanID);
|
||||
var floorsById = planFloors.ToDictionary(x => x.FloorPlanFloorID);
|
||||
floors.AddRange(planFloors.Select(floor => new SceneOccupancyFloorOption
|
||||
{
|
||||
FloorPlanID = floor.FloorPlanID,
|
||||
FloorPlanFloorID = floor.FloorPlanFloorID,
|
||||
Name = floor.Name,
|
||||
SortOrder = floor.SortOrder
|
||||
}));
|
||||
|
||||
locationsOnPlans.AddRange(blocks
|
||||
.GroupBy(x => new { x.LocationID, x.FloorPlanFloorID })
|
||||
.Select(group =>
|
||||
{
|
||||
floorsById.TryGetValue(group.Key.FloorPlanFloorID, out var floor);
|
||||
var first = group.First();
|
||||
return new SceneOccupancyLocationOption
|
||||
{
|
||||
FloorPlanID = plan.FloorPlanID,
|
||||
FloorPlanFloorID = group.Key.FloorPlanFloorID,
|
||||
FloorName = floor?.Name ?? "Floor",
|
||||
FloorSortOrder = floor?.SortOrder ?? 0,
|
||||
LocationID = group.Key.LocationID,
|
||||
LocationName = first.LocationName,
|
||||
LocationPath = first.LocationPath
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
var existingByCharacter = existingRows.Where(x => x.CharacterID.HasValue).ToDictionary(x => x.CharacterID!.Value, x => x.LocationID);
|
||||
var existingByAsset = existingRows.Where(x => x.StoryAssetID.HasValue).ToDictionary(x => x.StoryAssetID!.Value, x => x.LocationID);
|
||||
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
|
||||
|
||||
return new SceneOccupancyEditorViewModel
|
||||
{
|
||||
SceneID = model.SceneID,
|
||||
FloorPlanID = selectedFloorPlanId,
|
||||
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID,
|
||||
FloorPlanOptions = floorPlanList.OrderBy(x => x.Name).Select(x => new SelectListItem(x.Name, x.FloorPlanID.ToString())).ToList(),
|
||||
Floors = floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList(),
|
||||
Locations = locationsOnPlans.OrderBy(x => x.FloorSortOrder).ThenBy(x => x.FloorName).ThenBy(x => x.LocationPath).ToList(),
|
||||
Characters = model.SceneCharacters
|
||||
.GroupBy(x => x.CharacterID)
|
||||
.Select(group => new SceneCharacterOccupancyRowViewModel
|
||||
{
|
||||
CharacterID = group.Key,
|
||||
CharacterName = group.First().CharacterName,
|
||||
LocationID = existingByCharacter.GetValueOrDefault(group.Key)
|
||||
})
|
||||
.OrderBy(x => x.CharacterName)
|
||||
.ToList(),
|
||||
Assets = model.AssetEvents
|
||||
.GroupBy(x => x.StoryAssetID)
|
||||
.Select(group => new SceneAssetOccupancyRowViewModel
|
||||
{
|
||||
StoryAssetID = group.Key,
|
||||
AssetName = assetNames.GetValueOrDefault(group.Key, group.First().AssetName),
|
||||
LocationID = existingByAsset.GetValueOrDefault(group.Key)
|
||||
})
|
||||
.OrderBy(x => x.AssetName)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ContinuityWarning>> BuildAgeContinuityWarningsAsync(SceneEditViewModel model)
|
||||
{
|
||||
if (model.SceneID == 0 || model.Project is null || model.Book is null)
|
||||
@ -7561,6 +7731,8 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
|
||||
|
||||
public sealed class FloorPlanService(
|
||||
IProjectRepository projects,
|
||||
ISceneRepository scenes,
|
||||
ISceneFloorPlanOccupancyRepository sceneOccupancy,
|
||||
IFloorPlanRepository floorPlans,
|
||||
ILocationRepository locations,
|
||||
IFloorPlanBackgroundImageService backgroundImages,
|
||||
@ -7595,6 +7767,11 @@ public sealed class FloorPlanService(
|
||||
|
||||
var projectLocations = await locations.ListByProjectAsync(project.ProjectID);
|
||||
var orderedFloors = data.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList();
|
||||
var occupancyScenes = await scenes.ListByFloorPlanAsync(floorPlanId);
|
||||
var occupancyRows = await sceneOccupancy.ListByFloorPlanAsync(floorPlanId);
|
||||
var firstBlockByLocation = data.Blocks
|
||||
.GroupBy(x => x.LocationID)
|
||||
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.FloorPlanFloorID).ThenBy(block => block.Y).ThenBy(block => block.X).First());
|
||||
return new FloorPlanEditorViewModel
|
||||
{
|
||||
Project = project,
|
||||
@ -7635,7 +7812,37 @@ public sealed class FloorPlanService(
|
||||
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
|
||||
Locations = projectLocations,
|
||||
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList(),
|
||||
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList()
|
||||
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList(),
|
||||
OccupancyScenes = occupancyScenes
|
||||
.OrderBy(x => x.StartDateTime ?? DateTime.MaxValue)
|
||||
.ThenBy(x => x.SceneNumber)
|
||||
.ThenBy(x => x.SceneTitle)
|
||||
.Select(x => new FloorPlanOccupancySceneViewModel
|
||||
{
|
||||
SceneID = x.SceneID,
|
||||
InitialFloorPlanFloorID = x.InitialFloorPlanFloorID,
|
||||
Label = $"Scene {x.SceneNumber}: {x.SceneTitle}"
|
||||
})
|
||||
.ToList(),
|
||||
OccupancyTokens = occupancyRows
|
||||
.Where(x => firstBlockByLocation.ContainsKey(x.LocationID))
|
||||
.Select(x =>
|
||||
{
|
||||
var block = firstBlockByLocation[x.LocationID];
|
||||
return new FloorPlanOccupancyTokenViewModel
|
||||
{
|
||||
SceneID = x.SceneID,
|
||||
FloorPlanFloorID = block.FloorPlanFloorID,
|
||||
LocationID = x.LocationID,
|
||||
LocationName = x.LocationName,
|
||||
EntityType = x.CharacterID.HasValue ? "Character" : "Asset",
|
||||
EntityID = x.CharacterID ?? x.StoryAssetID ?? 0,
|
||||
DisplayName = x.CharacterName ?? x.AssetName ?? "Occupant",
|
||||
ImagePath = x.CharacterImagePath ?? x.AssetImagePath,
|
||||
ThumbnailPath = x.CharacterThumbnailPath ?? x.AssetThumbnailPath
|
||||
};
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
278
PlotLine/Sql/080_SceneFloorPlanOccupancy.sql
Normal file
278
PlotLine/Sql/080_SceneFloorPlanOccupancy.sql
Normal file
@ -0,0 +1,278 @@
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'FloorPlanID') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD FloorPlanID int NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'InitialFloorPlanFloorID') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD InitialFloorPlanFloorID int NULL;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.FK_Scenes_FloorPlans', N'F') IS NULL
|
||||
ALTER TABLE dbo.Scenes WITH CHECK ADD CONSTRAINT FK_Scenes_FloorPlans FOREIGN KEY (FloorPlanID) REFERENCES dbo.FloorPlans(FloorPlanID);
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.FK_Scenes_InitialFloorPlanFloors', N'F') IS NULL
|
||||
ALTER TABLE dbo.Scenes WITH CHECK ADD CONSTRAINT FK_Scenes_InitialFloorPlanFloors FOREIGN KEY (InitialFloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID);
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.SceneFloorPlanOccupancy', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.SceneFloorPlanOccupancy
|
||||
(
|
||||
SceneFloorPlanOccupancyID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_SceneFloorPlanOccupancy PRIMARY KEY,
|
||||
SceneID int NOT NULL,
|
||||
FloorPlanID int NOT NULL,
|
||||
FloorPlanFloorID int NULL,
|
||||
CharacterID int NULL,
|
||||
StoryAssetID int NULL,
|
||||
LocationID int NOT NULL,
|
||||
CreatedDate datetime2 NOT NULL CONSTRAINT DF_SceneFloorPlanOccupancy_CreatedDate DEFAULT SYSUTCDATETIME(),
|
||||
ModifiedDate datetime2 NULL,
|
||||
CONSTRAINT CK_SceneFloorPlanOccupancy_OneEntity CHECK
|
||||
(
|
||||
(CharacterID IS NOT NULL AND StoryAssetID IS NULL)
|
||||
OR (CharacterID IS NULL AND StoryAssetID IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT FK_SceneFloorPlanOccupancy_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_SceneFloorPlanOccupancy_FloorPlans FOREIGN KEY (FloorPlanID) REFERENCES dbo.FloorPlans(FloorPlanID),
|
||||
CONSTRAINT FK_SceneFloorPlanOccupancy_FloorPlanFloors FOREIGN KEY (FloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID),
|
||||
CONSTRAINT FK_SceneFloorPlanOccupancy_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID),
|
||||
CONSTRAINT FK_SceneFloorPlanOccupancy_StoryAssets FOREIGN KEY (StoryAssetID) REFERENCES dbo.StoryAssets(StoryAssetID),
|
||||
CONSTRAINT FK_SceneFloorPlanOccupancy_Locations FOREIGN KEY (LocationID) REFERENCES dbo.Locations(LocationID)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SceneFloorPlanOccupancy_Character' AND object_id = OBJECT_ID(N'dbo.SceneFloorPlanOccupancy'))
|
||||
CREATE UNIQUE INDEX UX_SceneFloorPlanOccupancy_Character ON dbo.SceneFloorPlanOccupancy(SceneID, CharacterID) WHERE CharacterID IS NOT NULL;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SceneFloorPlanOccupancy_Asset' AND object_id = OBJECT_ID(N'dbo.SceneFloorPlanOccupancy'))
|
||||
CREATE UNIQUE INDEX UX_SceneFloorPlanOccupancy_Asset ON dbo.SceneFloorPlanOccupancy(SceneID, StoryAssetID) WHERE StoryAssetID IS NOT NULL;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_Save
|
||||
@SceneID int = NULL, @ChapterID int, @SceneNumber decimal(10,2), @SceneTitle nvarchar(200), @Summary nvarchar(max) = NULL,
|
||||
@TimeModeID int, @StartDateTime datetime2 = NULL, @EndDateTime datetime2 = NULL, @DurationAmount decimal(10,2) = NULL,
|
||||
@DurationUnitID int = NULL, @RelativeTimeText nvarchar(200) = NULL, @TimeConfidenceID int,
|
||||
@ScenePurposeNotes nvarchar(max) = NULL, @SceneOutcomeNotes nvarchar(max) = NULL, @RevisionStatusID int,
|
||||
@PrimaryLocationID int = NULL, @FloorPlanID int = NULL, @InitialFloorPlanFloorID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
DECLARE @NextSortOrder int;
|
||||
IF @SceneID IS NULL OR @SceneID = 0
|
||||
BEGIN
|
||||
SELECT @NextSortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.Scenes WHERE ChapterID = @ChapterID;
|
||||
INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, Summary, SortOrder, TimeModeID, StartDateTime, EndDateTime,
|
||||
DurationAmount, DurationUnitID, RelativeTimeText, TimeConfidenceID, ScenePurposeNotes, SceneOutcomeNotes,
|
||||
RevisionStatusID, PrimaryLocationID, FloorPlanID, InitialFloorPlanFloorID)
|
||||
VALUES (@ChapterID, @SceneNumber, @SceneTitle, @Summary, @NextSortOrder, @TimeModeID, @StartDateTime, @EndDateTime,
|
||||
@DurationAmount, @DurationUnitID, @RelativeTimeText, @TimeConfidenceID, @ScenePurposeNotes, @SceneOutcomeNotes,
|
||||
@RevisionStatusID, @PrimaryLocationID, @FloorPlanID, @InitialFloorPlanFloorID);
|
||||
SET @SceneID = CAST(SCOPE_IDENTITY() AS int);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
UPDATE dbo.Scenes
|
||||
SET SceneNumber = @SceneNumber, SceneTitle = @SceneTitle, Summary = @Summary, TimeModeID = @TimeModeID,
|
||||
StartDateTime = @StartDateTime, EndDateTime = @EndDateTime, DurationAmount = @DurationAmount, DurationUnitID = @DurationUnitID,
|
||||
RelativeTimeText = @RelativeTimeText, TimeConfidenceID = @TimeConfidenceID, ScenePurposeNotes = @ScenePurposeNotes,
|
||||
SceneOutcomeNotes = @SceneOutcomeNotes, RevisionStatusID = @RevisionStatusID, PrimaryLocationID = @PrimaryLocationID,
|
||||
FloorPlanID = @FloorPlanID, InitialFloorPlanFloorID = @InitialFloorPlanFloorID,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE SceneID = @SceneID;
|
||||
END
|
||||
SELECT @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_Get
|
||||
@SceneID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
EXEC dbo.SceneMetric_EnsureDefaults @SceneID;
|
||||
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID, s.PrimaryLocationID,
|
||||
s.FloorPlanID, s.InitialFloorPlanFloorID,
|
||||
location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath,
|
||||
s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID,
|
||||
du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes,
|
||||
s.SceneOutcomeNotes, s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived
|
||||
FROM dbo.Scenes s
|
||||
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
|
||||
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
|
||||
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
|
||||
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
|
||||
LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID
|
||||
WHERE s.SceneID = @SceneID AND s.IsArchived = 0;
|
||||
SELECT ScenePurposeTypeID FROM dbo.ScenePurposes WHERE SceneID = @SceneID;
|
||||
EXEC dbo.SceneMetric_ListByScene @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_FloorPlanLink_Save
|
||||
@SceneID int,
|
||||
@FloorPlanID int = NULL,
|
||||
@InitialFloorPlanFloorID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
IF @FloorPlanID IS NULL
|
||||
SET @InitialFloorPlanFloorID = NULL;
|
||||
|
||||
UPDATE dbo.Scenes
|
||||
SET FloorPlanID = @FloorPlanID,
|
||||
InitialFloorPlanFloorID = @InitialFloorPlanFloorID,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE SceneID = @SceneID;
|
||||
|
||||
IF @FloorPlanID IS NULL
|
||||
DELETE FROM dbo.SceneFloorPlanOccupancy WHERE SceneID = @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_ListByFloorPlan
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID, s.PrimaryLocationID,
|
||||
s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime,
|
||||
s.EndDateTime, s.DurationAmount, s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID,
|
||||
tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID,
|
||||
rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived
|
||||
FROM dbo.Scenes s
|
||||
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
|
||||
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
|
||||
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
|
||||
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
|
||||
WHERE s.FloorPlanID = @FloorPlanID AND s.IsArchived = 0
|
||||
ORDER BY s.StartDateTime, s.SceneNumber, s.SortOrder, s.SceneTitle;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_ListByPlan
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT FloorPlanFloorID, FloorPlanID, LocationID, Name, SortOrder, GridWidth, GridHeight,
|
||||
BackgroundImagePath, BackgroundImageOriginalFileName, BackgroundOpacity, BackgroundScale,
|
||||
BackgroundOffsetX, BackgroundOffsetY, BackgroundLocked, CreatedDate, ModifiedDate
|
||||
FROM dbo.FloorPlanFloors
|
||||
WHERE FloorPlanID = @FloorPlanID
|
||||
ORDER BY SortOrder, Name;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.FloorPlanBlock_ListByPlan
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT fpb.FloorPlanBlockID, fpb.FloorPlanFloorID, fpb.LocationID,
|
||||
COALESCE(lp.LocationName, l.LocationName) AS LocationName,
|
||||
COALESCE(lp.LocationPath, l.LocationName) AS LocationPath,
|
||||
fpb.X, fpb.Y, fpb.WidthCells, fpb.HeightCells, fpb.BlockType, fpb.LabelOverride, fpb.Notes,
|
||||
fpb.CreatedDate, fpb.ModifiedDate
|
||||
FROM dbo.FloorPlanBlocks fpb
|
||||
INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID
|
||||
INNER JOIN dbo.Locations l ON l.LocationID = fpb.LocationID
|
||||
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpb.LocationID
|
||||
WHERE fpf.FloorPlanID = @FloorPlanID
|
||||
ORDER BY fpf.SortOrder, fpf.Name, fpb.Y, fpb.X;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_ListByScene
|
||||
@SceneID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT o.SceneFloorPlanOccupancyID, o.SceneID, o.FloorPlanID, o.FloorPlanFloorID,
|
||||
o.CharacterID, c.CharacterName, c.ShortName AS CharacterShortName, c.ImagePath AS CharacterImagePath, c.ThumbnailPath AS CharacterThumbnailPath,
|
||||
o.StoryAssetID, sa.AssetName, sa.ImagePath AS AssetImagePath, sa.ThumbnailPath AS AssetThumbnailPath,
|
||||
o.LocationID, COALESCE(lp.LocationName, l.LocationName) AS LocationName, COALESCE(lp.LocationPath, l.LocationName) AS LocationPath,
|
||||
fpf.Name AS FloorName, o.CreatedDate, o.ModifiedDate
|
||||
FROM dbo.SceneFloorPlanOccupancy o
|
||||
INNER JOIN dbo.Locations l ON l.LocationID = o.LocationID
|
||||
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = o.LocationID
|
||||
LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = o.FloorPlanFloorID
|
||||
LEFT JOIN dbo.Characters c ON c.CharacterID = o.CharacterID
|
||||
LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = o.StoryAssetID
|
||||
WHERE o.SceneID = @SceneID
|
||||
ORDER BY COALESCE(c.CharacterName, sa.AssetName);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_ListByFloorPlan
|
||||
@FloorPlanID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT o.SceneFloorPlanOccupancyID, o.SceneID, o.FloorPlanID, o.FloorPlanFloorID,
|
||||
o.CharacterID, c.CharacterName, c.ShortName AS CharacterShortName, c.ImagePath AS CharacterImagePath, c.ThumbnailPath AS CharacterThumbnailPath,
|
||||
o.StoryAssetID, sa.AssetName, sa.ImagePath AS AssetImagePath, sa.ThumbnailPath AS AssetThumbnailPath,
|
||||
o.LocationID, COALESCE(lp.LocationName, l.LocationName) AS LocationName, COALESCE(lp.LocationPath, l.LocationName) AS LocationPath,
|
||||
fpf.Name AS FloorName, o.CreatedDate, o.ModifiedDate
|
||||
FROM dbo.SceneFloorPlanOccupancy o
|
||||
INNER JOIN dbo.Locations l ON l.LocationID = o.LocationID
|
||||
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = o.LocationID
|
||||
LEFT JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = o.FloorPlanFloorID
|
||||
LEFT JOIN dbo.Characters c ON c.CharacterID = o.CharacterID
|
||||
LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = o.StoryAssetID
|
||||
WHERE o.FloorPlanID = @FloorPlanID
|
||||
ORDER BY o.SceneID, COALESCE(c.CharacterName, sa.AssetName);
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanOccupancy_Save
|
||||
@SceneID int,
|
||||
@FloorPlanID int = NULL,
|
||||
@CharacterID int = NULL,
|
||||
@StoryAssetID int = NULL,
|
||||
@LocationID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF @FloorPlanID IS NULL OR @LocationID IS NULL
|
||||
BEGIN
|
||||
DELETE FROM dbo.SceneFloorPlanOccupancy
|
||||
WHERE SceneID = @SceneID
|
||||
AND ((@CharacterID IS NOT NULL AND CharacterID = @CharacterID)
|
||||
OR (@StoryAssetID IS NOT NULL AND StoryAssetID = @StoryAssetID));
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @FloorPlanFloorID int;
|
||||
SELECT TOP (1) @FloorPlanFloorID = fpb.FloorPlanFloorID
|
||||
FROM dbo.FloorPlanBlocks fpb
|
||||
INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpb.FloorPlanFloorID
|
||||
WHERE fpf.FloorPlanID = @FloorPlanID
|
||||
AND fpb.LocationID = @LocationID
|
||||
ORDER BY fpf.SortOrder, fpf.Name, fpb.Y, fpb.X;
|
||||
|
||||
IF @FloorPlanFloorID IS NULL
|
||||
THROW 51000, 'Location must be placed on the selected floor plan.', 1;
|
||||
|
||||
MERGE dbo.SceneFloorPlanOccupancy AS target
|
||||
USING
|
||||
(
|
||||
SELECT @SceneID AS SceneID, @FloorPlanID AS FloorPlanID, @FloorPlanFloorID AS FloorPlanFloorID,
|
||||
@CharacterID AS CharacterID, @StoryAssetID AS StoryAssetID, @LocationID AS LocationID
|
||||
) AS source
|
||||
ON target.SceneID = source.SceneID
|
||||
AND ((source.CharacterID IS NOT NULL AND target.CharacterID = source.CharacterID)
|
||||
OR (source.StoryAssetID IS NOT NULL AND target.StoryAssetID = source.StoryAssetID))
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET FloorPlanID = source.FloorPlanID, FloorPlanFloorID = source.FloorPlanFloorID,
|
||||
LocationID = source.LocationID, ModifiedDate = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (SceneID, FloorPlanID, FloorPlanFloorID, CharacterID, StoryAssetID, LocationID)
|
||||
VALUES (source.SceneID, source.FloorPlanID, source.FloorPlanFloorID, source.CharacterID, source.StoryAssetID, source.LocationID);
|
||||
END;
|
||||
GO
|
||||
@ -294,6 +294,12 @@ public sealed class SceneEditViewModel
|
||||
[Display(Name = "Primary location")]
|
||||
public int? PrimaryLocationID { get; set; }
|
||||
|
||||
[Display(Name = "Floor plan")]
|
||||
public int? FloorPlanID { get; set; }
|
||||
|
||||
[Display(Name = "Initial floor")]
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
|
||||
[Display(Name = "Scene purposes")]
|
||||
public List<int> SelectedPurposeTypeIds { get; set; } = [];
|
||||
|
||||
@ -358,6 +364,7 @@ public sealed class SceneEditViewModel
|
||||
public IReadOnlyList<SelectListItem> SceneAttachmentTypeOptions { get; set; } = [];
|
||||
public StorageUsage? StorageUsage { get; set; }
|
||||
public StoryStateViewModel StoryState { get; set; } = new();
|
||||
public SceneOccupancyEditorViewModel Occupancy { get; set; } = new();
|
||||
|
||||
public int? ReturnProjectID { get; set; }
|
||||
public int? ReturnBookID { get; set; }
|
||||
@ -373,6 +380,60 @@ public sealed class SceneEditViewModel
|
||||
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SceneOccupancyEditorViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
public int? FloorPlanID { get; set; }
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
public IReadOnlyList<SelectListItem> FloorPlanOptions { get; set; } = [];
|
||||
public IReadOnlyList<SceneOccupancyFloorOption> Floors { get; set; } = [];
|
||||
public IReadOnlyList<SceneOccupancyLocationOption> Locations { get; set; } = [];
|
||||
public IReadOnlyList<SceneCharacterOccupancyRowViewModel> Characters { get; set; } = [];
|
||||
public IReadOnlyList<SceneAssetOccupancyRowViewModel> Assets { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SceneOccupancyFloorOption
|
||||
{
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneOccupancyLocationOption
|
||||
{
|
||||
public int LocationID { get; set; }
|
||||
public int FloorPlanID { get; set; }
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public string FloorName { get; set; } = string.Empty;
|
||||
public int FloorSortOrder { get; set; }
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string LocationPath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SceneCharacterOccupancyRowViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
public string CharacterName { get; set; } = string.Empty;
|
||||
public int? LocationID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneAssetOccupancyRowViewModel
|
||||
{
|
||||
public int StoryAssetID { get; set; }
|
||||
public string AssetName { get; set; } = string.Empty;
|
||||
public int? LocationID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SceneOccupancySaveViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
public int? FloorPlanID { get; set; }
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
public List<SceneCharacterOccupancyRowViewModel> Characters { get; set; } = [];
|
||||
public List<SceneAssetOccupancyRowViewModel> Assets { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryStateViewModel
|
||||
{
|
||||
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
|
||||
@ -1872,6 +1933,28 @@ public sealed class FloorPlanEditorViewModel
|
||||
public IReadOnlyList<LocationItem> Locations { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> BlockTypeOptions { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> TransitionTypeOptions { get; set; } = [];
|
||||
public IReadOnlyList<FloorPlanOccupancySceneViewModel> OccupancyScenes { get; set; } = [];
|
||||
public IReadOnlyList<FloorPlanOccupancyTokenViewModel> OccupancyTokens { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class FloorPlanOccupancySceneViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class FloorPlanOccupancyTokenViewModel
|
||||
{
|
||||
public int SceneID { get; set; }
|
||||
public int FloorPlanFloorID { get; set; }
|
||||
public int LocationID { get; set; }
|
||||
public string LocationName { get; set; } = string.Empty;
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityID { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FloorPlanFloorEditViewModel
|
||||
|
||||
@ -5,6 +5,14 @@
|
||||
var activeFloor = Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).FirstOrDefault();
|
||||
var blocksByFloor = Model.Blocks.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList());
|
||||
var transitionsByFloor = Model.Transitions.GroupBy(x => x.FloorPlanFloorID).ToDictionary(x => x.Key, x => x.ToList());
|
||||
var firstBlockIdByLocation = Model.Blocks
|
||||
.Where(x => x.LocationID.HasValue)
|
||||
.GroupBy(x => x.LocationID)
|
||||
.ToDictionary(x => x.Key!.Value, x => x.OrderBy(block => block.FloorPlanFloorID).ThenBy(block => block.Y).ThenBy(block => block.X).First().FloorPlanBlockID);
|
||||
var occupancyTokensByLocation = Model.OccupancyTokens
|
||||
.GroupBy(x => $"{x.SceneID}:{x.LocationID}")
|
||||
.ToDictionary(x => x.Key, x => x.OrderBy(token => token.EntityType == "Character" ? 0 : 1).ThenBy(token => token.DisplayName).ToList());
|
||||
var activeOccupancyScene = Model.OccupancyScenes.FirstOrDefault();
|
||||
var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : 0;
|
||||
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
|
||||
string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
||||
@ -187,6 +195,21 @@
|
||||
</select>
|
||||
</label>
|
||||
<span class="floor-plan-save-status" data-save-status>Saved</span>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Floor plan mode">
|
||||
<button class="btn btn-outline-secondary active" type="button" data-floor-plan-mode="structure">Structure</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-floor-plan-mode="occupancy">Occupancy</button>
|
||||
</div>
|
||||
<label class="floor-plan-reference-control floor-plan-occupancy-control" data-occupancy-controls hidden>
|
||||
<span class="muted">Scene</span>
|
||||
<select class="form-select form-select-sm" data-occupancy-scene-select>
|
||||
@foreach (var scene in Model.OccupancyScenes)
|
||||
{
|
||||
<option value="@scene.SceneID" data-initial-floor-id="@scene.InitialFloorPlanFloorID" selected="@(scene.SceneID == activeOccupancyScene?.SceneID)">@scene.Label</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-outline-secondary btn-sm floor-plan-occupancy-control" type="button" data-occupancy-prev hidden>Previous Scene</button>
|
||||
<button class="btn btn-outline-secondary btn-sm floor-plan-occupancy-control" type="button" data-occupancy-next hidden>Next Scene</button>
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
|
||||
<button class="btn btn-primary btn-sm" type="submit" data-save-layout-button>Save layout</button>
|
||||
</div>
|
||||
@ -232,6 +255,44 @@
|
||||
<span class="floor-plan-block-label">@block.DisplayLabel</span>
|
||||
<span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>
|
||||
</button>
|
||||
if (block.LocationID.HasValue && firstBlockIdByLocation.GetValueOrDefault(block.LocationID.Value) == block.FloorPlanBlockID)
|
||||
{
|
||||
var locationTokens = Model.OccupancyScenes
|
||||
.SelectMany(scene => occupancyTokensByLocation.GetValueOrDefault($"{scene.SceneID}:{block.LocationID.Value}") ?? [])
|
||||
.ToList();
|
||||
if (locationTokens.Any())
|
||||
{
|
||||
foreach (var group in locationTokens.GroupBy(x => x.SceneID))
|
||||
{
|
||||
var tokens = group.ToList();
|
||||
var tokenTitle = string.Join(", ", tokens.Select(x => x.DisplayName));
|
||||
<div class="floor-plan-occupancy-tray"
|
||||
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
|
||||
data-occupancy-location="@block.LocationID"
|
||||
data-occupancy-scene="@group.Key"
|
||||
title="@tokenTitle">
|
||||
@foreach (var token in tokens.Take(3))
|
||||
{
|
||||
var imagePath = !string.IsNullOrWhiteSpace(token.ThumbnailPath) ? token.ThumbnailPath : token.ImagePath;
|
||||
<span class="floor-plan-occupancy-token floor-plan-occupancy-token--@token.EntityType.ToLowerInvariant()" title="@token.DisplayName">
|
||||
@if (!string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
<img src="@imagePath" alt="@token.DisplayName" loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@AvatarHelper.Initials(token.DisplayName)</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@if (tokens.Count > 3)
|
||||
{
|
||||
<span class="floor-plan-occupancy-more">+@(tokens.Count - 3)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@foreach (var transition in floorTransitions)
|
||||
{
|
||||
@ -913,6 +974,12 @@
|
||||
const saveButtons = [...editor.querySelectorAll("[data-save-layout-button]")];
|
||||
const gridCoordinatesToggle = editor.querySelector("[data-grid-coordinates-toggle]");
|
||||
const referenceFloorSelect = editor.querySelector("[data-reference-floor-select]");
|
||||
const modeButtons = [...editor.querySelectorAll("[data-floor-plan-mode]")];
|
||||
const occupancyControls = [...editor.querySelectorAll("[data-occupancy-controls], .floor-plan-occupancy-control")];
|
||||
const occupancySceneSelect = editor.querySelector("[data-occupancy-scene-select]");
|
||||
const occupancyPrev = editor.querySelector("[data-occupancy-prev]");
|
||||
const occupancyNext = editor.querySelector("[data-occupancy-next]");
|
||||
const occupancyTrays = [...editor.querySelectorAll("[data-occupancy-scene]")];
|
||||
const initialSelectedBlockId = options.selectedBlockId || "@selectedBlockId";
|
||||
const saveStatusText = {
|
||||
saved: "Saved",
|
||||
@ -983,6 +1050,54 @@
|
||||
saveStatus.textContent = message || saveStatusText[state] || state;
|
||||
}
|
||||
|
||||
function selectedOccupancySceneId() {
|
||||
return occupancySceneSelect?.value || "";
|
||||
}
|
||||
|
||||
function refreshOccupancyTrays() {
|
||||
const sceneId = selectedOccupancySceneId();
|
||||
const showOccupancy = editor.classList.contains("floor-plan-editor--occupancy");
|
||||
occupancyTrays.forEach(tray => {
|
||||
tray.hidden = !showOccupancy || tray.dataset.occupancyScene !== sceneId;
|
||||
});
|
||||
}
|
||||
|
||||
function setFloorPlanMode(mode) {
|
||||
const isOccupancy = mode === "occupancy";
|
||||
editor.classList.toggle("floor-plan-editor--occupancy", isOccupancy);
|
||||
modeButtons.forEach(button => {
|
||||
const active = button.dataset.floorPlanMode === mode;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
occupancyControls.forEach(control => {
|
||||
control.hidden = !isOccupancy;
|
||||
});
|
||||
saveButtons.forEach(button => {
|
||||
button.hidden = isOccupancy;
|
||||
});
|
||||
refreshOccupancyTrays();
|
||||
}
|
||||
|
||||
function moveOccupancyScene(direction) {
|
||||
if (!occupancySceneSelect || occupancySceneSelect.options.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = Math.max(0, Math.min(occupancySceneSelect.options.length - 1, occupancySceneSelect.selectedIndex + direction));
|
||||
occupancySceneSelect.selectedIndex = nextIndex;
|
||||
applyOccupancySceneFloor();
|
||||
refreshOccupancyTrays();
|
||||
}
|
||||
|
||||
function applyOccupancySceneFloor() {
|
||||
const option = occupancySceneSelect?.selectedOptions?.[0];
|
||||
const initialFloorId = option?.dataset.initialFloorId;
|
||||
if (initialFloorId) {
|
||||
setActiveFloor(initialFloorId);
|
||||
}
|
||||
}
|
||||
|
||||
function gridColumnLabel(index) {
|
||||
let label = "";
|
||||
let value = index + 1;
|
||||
@ -2177,6 +2292,13 @@
|
||||
|
||||
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
|
||||
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
|
||||
modeButtons.forEach(button => button.addEventListener("click", () => setFloorPlanMode(button.dataset.floorPlanMode)));
|
||||
occupancySceneSelect?.addEventListener("change", () => {
|
||||
applyOccupancySceneFloor();
|
||||
refreshOccupancyTrays();
|
||||
});
|
||||
occupancyPrev?.addEventListener("click", () => moveOccupancyScene(-1));
|
||||
occupancyNext?.addEventListener("click", () => moveOccupancyScene(1));
|
||||
editor.querySelectorAll("[data-transition-type-select],[data-transition-from-select]").forEach(input => {
|
||||
const floorId = input.dataset.transitionTypeSelect || input.dataset.transitionFromSelect;
|
||||
input.addEventListener("change", () => refreshTransitionDestination(floorId));
|
||||
@ -2386,6 +2508,7 @@
|
||||
}
|
||||
refreshGridCoordinateLabels();
|
||||
refreshReferenceOverlay();
|
||||
refreshOccupancyTrays();
|
||||
checkOverlaps();
|
||||
};
|
||||
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
<a href="#inspector-thread-events">Threads</a>
|
||||
<a href="#inspector-asset-events">Assets</a>
|
||||
<a href="#inspector-asset-locations">Locations</a>
|
||||
<a href="#inspector-occupancy">Occupancy</a>
|
||||
<a href="#inspector-asset-custody">Custody</a>
|
||||
<a href="#inspector-story-state">Story State</a>
|
||||
|
||||
@ -106,6 +107,8 @@
|
||||
<input asp-for="ReturnProjectID" type="hidden" />
|
||||
<input asp-for="ReturnBookID" type="hidden" />
|
||||
<input asp-for="ReturnToTimeline" type="hidden" />
|
||||
<input asp-for="FloorPlanID" type="hidden" />
|
||||
<input asp-for="InitialFloorPlanFloorID" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
|
||||
<div class="inspector-section" data-section-title="Timing & Structure" data-section-id="inspector-timing">
|
||||
@ -1161,6 +1164,11 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="inspector-section asset-events-section" data-section-title="Occupancy" data-section-id="inspector-occupancy">
|
||||
<h3>Occupancy <help-icon key="sceneInspector.occupancy" /></h3>
|
||||
<partial name="_SceneOccupancy" model="Model.Occupancy" />
|
||||
</section>
|
||||
|
||||
<section class="inspector-section asset-events-section" data-section-title="Asset Custody" data-section-id="inspector-asset-custody">
|
||||
<h3>Asset Custody <help-icon key="sceneInspector.custody" /></h3>
|
||||
@if (!Model.AssetCustodyEvents.Any())
|
||||
|
||||
98
PlotLine/Views/Scenes/_SceneOccupancy.cshtml
Normal file
98
PlotLine/Views/Scenes/_SceneOccupancy.cshtml
Normal file
@ -0,0 +1,98 @@
|
||||
@model SceneOccupancyEditorViewModel
|
||||
@{
|
||||
var selectedPlanId = Model.FloorPlanID;
|
||||
var locationOptions = Model.Locations
|
||||
.Where(x => !selectedPlanId.HasValue || x.FloorPlanID == selectedPlanId.Value)
|
||||
.OrderBy(x => x.FloorSortOrder)
|
||||
.ThenBy(x => x.FloorName)
|
||||
.ThenBy(x => x.LocationPath)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
<form asp-controller="Scenes" asp-action="SaveOccupancy" method="post" class="scene-occupancy-form" data-scene-occupancy-form data-editor-url="@Url.Action("OccupancyEditor", "Scenes")">
|
||||
<input type="hidden" name="SceneID" value="@Model.SceneID" />
|
||||
<div class="scene-occupancy-status" data-scene-occupancy-status>Saved</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="OccupancyFloorPlanID">Floor Plan</label>
|
||||
<select class="form-select form-select-sm" id="OccupancyFloorPlanID" name="FloorPlanID" data-occupancy-plan-select>
|
||||
<option value="">No floor plan</option>
|
||||
@foreach (var option in Model.FloorPlanOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == Model.FloorPlanID?.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="OccupancyInitialFloorID">Initial Floor</label>
|
||||
<select class="form-select form-select-sm" id="OccupancyInitialFloorID" name="InitialFloorPlanFloorID" data-occupancy-floor-select>
|
||||
<option value="">Use first floor</option>
|
||||
@foreach (var floor in Model.Floors.Where(x => !selectedPlanId.HasValue || x.FloorPlanID == selectedPlanId.Value))
|
||||
{
|
||||
<option value="@floor.FloorPlanFloorID" selected="@(floor.FloorPlanFloorID == Model.InitialFloorPlanFloorID)">@floor.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Model.FloorPlanOptions.Any())
|
||||
{
|
||||
<p class="muted mt-2 mb-0">No floor plans exist for this project yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="scene-occupancy-grid mt-3">
|
||||
<section>
|
||||
<h4>Characters</h4>
|
||||
@if (!Model.Characters.Any())
|
||||
{
|
||||
<p class="muted">No characters appear in this scene yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@for (var i = 0; i < Model.Characters.Count; i++)
|
||||
{
|
||||
<div class="scene-occupancy-row">
|
||||
<input type="hidden" name="Characters[@i].CharacterID" value="@Model.Characters.ElementAt(i).CharacterID" />
|
||||
<span>@Model.Characters.ElementAt(i).CharacterName</span>
|
||||
<select class="form-select form-select-sm" name="Characters[@i].LocationID">
|
||||
<option value="">Not placed</option>
|
||||
@foreach (var location in locationOptions)
|
||||
{
|
||||
<option value="@location.LocationID" selected="@(location.LocationID == Model.Characters.ElementAt(i).LocationID)">@location.FloorName > @location.LocationPath</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>Assets</h4>
|
||||
@if (!Model.Assets.Any())
|
||||
{
|
||||
<p class="muted">No story assets appear in this scene yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@for (var i = 0; i < Model.Assets.Count; i++)
|
||||
{
|
||||
<div class="scene-occupancy-row">
|
||||
<input type="hidden" name="Assets[@i].StoryAssetID" value="@Model.Assets.ElementAt(i).StoryAssetID" />
|
||||
<span>@Model.Assets.ElementAt(i).AssetName</span>
|
||||
<select class="form-select form-select-sm" name="Assets[@i].LocationID">
|
||||
<option value="">Not placed</option>
|
||||
@foreach (var location in locationOptions)
|
||||
{
|
||||
<option value="@location.LocationID" selected="@(location.LocationID == Model.Assets.ElementAt(i).LocationID)">@location.FloorName > @location.LocationPath</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-sm mt-3" type="submit">Save occupancy</button>
|
||||
}
|
||||
</form>
|
||||
@ -5189,6 +5189,116 @@ body.dragging-location [data-drag-type="location"] {
|
||||
color: var(--muted-text, #5d6f65);
|
||||
}
|
||||
|
||||
.scene-occupancy-form {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scene-occupancy-status {
|
||||
justify-self: start;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
color: var(--plotline-muted);
|
||||
background: var(--plotline-soft);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scene-occupancy-status[data-save-state="saving"] {
|
||||
color: #7a4d08;
|
||||
background: #fff4d6;
|
||||
}
|
||||
|
||||
.scene-occupancy-status[data-save-state="failed"] {
|
||||
color: #842029;
|
||||
background: #f8d7da;
|
||||
}
|
||||
|
||||
.scene-occupancy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scene-occupancy-grid h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scene-occupancy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 0.45fr) minmax(0, 0.55fr);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.floor-plan-editor--occupancy .floor-plan-block {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.floor-plan-editor--occupancy .floor-plan-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floor-plan-occupancy-tray {
|
||||
position: absolute;
|
||||
z-index: 14;
|
||||
grid-column: calc(var(--x) + 1) / span var(--w);
|
||||
grid-row: calc(var(--y) + 1) / span var(--h);
|
||||
align-self: end;
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.18rem;
|
||||
margin: 0 0 0.25rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.floor-plan-occupancy-tray[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floor-plan-occupancy-token {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--plotline-accent);
|
||||
box-shadow: 0 4px 12px rgba(30, 37, 43, 0.18);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.floor-plan-occupancy-token img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.floor-plan-occupancy-token--asset {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
background: #805c15;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.floor-plan-occupancy-more {
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.36rem;
|
||||
color: var(--plotline-ink);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 4px 12px rgba(30, 37, 43, 0.12);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.floor-plan-transition-accordion .accordion-item {
|
||||
background: transparent;
|
||||
border-color: var(--border-color, #d8ded9);
|
||||
@ -5374,6 +5484,24 @@ body.dragging-location [data-drag-type="location"] {
|
||||
border-color: rgba(212, 154, 98, .22);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .scene-occupancy-status,
|
||||
.dark .scene-occupancy-status {
|
||||
color: #d9c8b8;
|
||||
background: rgba(212, 154, 98, .12);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-occupancy-token,
|
||||
.dark .floor-plan-occupancy-token {
|
||||
border-color: #1f2630;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, .28);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-occupancy-more,
|
||||
.dark .floor-plan-occupancy-more {
|
||||
color: #f1e7dd;
|
||||
background: rgba(31, 38, 48, .92);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .floor-plan-transition-accordion .accordion-item,
|
||||
.dark .floor-plan-transition-accordion .accordion-item {
|
||||
border-color: rgba(212, 154, 98, .22);
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -91,6 +91,109 @@
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const roots = document.querySelectorAll(".scene-inspector-root");
|
||||
if (!roots.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setStatus = (form, text, state) => {
|
||||
const status = form.querySelector("[data-scene-occupancy-status]");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = text;
|
||||
status.dataset.saveState = state || "";
|
||||
};
|
||||
|
||||
const replaceOccupancyForm = (form, html) => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html;
|
||||
const nextForm = wrapper.querySelector("[data-scene-occupancy-form]");
|
||||
if (nextForm) {
|
||||
form.replaceWith(nextForm);
|
||||
const sceneForm = nextForm.closest(".scene-inspector-root")?.querySelector(".scene-inspector-form");
|
||||
const floorPlanInput = sceneForm?.querySelector("[name='FloorPlanID']");
|
||||
const initialFloorInput = sceneForm?.querySelector("[name='InitialFloorPlanFloorID']");
|
||||
const nextFloorPlan = nextForm.querySelector("[name='FloorPlanID']");
|
||||
const nextInitialFloor = nextForm.querySelector("[name='InitialFloorPlanFloorID']");
|
||||
if (floorPlanInput && nextFloorPlan) {
|
||||
floorPlanInput.value = nextFloorPlan.value;
|
||||
}
|
||||
if (initialFloorInput && nextInitialFloor) {
|
||||
initialFloorInput.value = nextInitialFloor.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
roots.forEach((root) => {
|
||||
root.addEventListener("change", async (event) => {
|
||||
const planSelect = event.target.closest?.("[data-occupancy-plan-select]");
|
||||
if (!planSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = planSelect.closest("[data-scene-occupancy-form]");
|
||||
const sceneId = form?.querySelector("[name='SceneID']")?.value;
|
||||
const editorUrl = form?.dataset.editorUrl;
|
||||
if (!form || !sceneId || !editorUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(form, "Loading...", "saving");
|
||||
const url = new URL(editorUrl, window.location.origin);
|
||||
url.searchParams.set("sceneId", sceneId);
|
||||
if (planSelect.value) {
|
||||
url.searchParams.set("floorPlanId", planSelect.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
|
||||
if (!response.ok) {
|
||||
throw new Error("The occupancy editor could not be refreshed.");
|
||||
}
|
||||
|
||||
replaceOccupancyForm(form, await response.text());
|
||||
} catch (error) {
|
||||
setStatus(form, error.message || "Load failed", "failed");
|
||||
}
|
||||
});
|
||||
|
||||
root.addEventListener("submit", async (event) => {
|
||||
const form = event.target.closest?.("[data-scene-occupancy-form]");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setStatus(form, "Saving...", "saving");
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
body: new FormData(form),
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Save failed";
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const payload = await response.json();
|
||||
message = payload.message || message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
replaceOccupancyForm(form, await response.text());
|
||||
} catch (error) {
|
||||
setStatus(form, error.message || "Save failed", "failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const modalElement = document.getElementById("plotlineConfirmModal");
|
||||
const modal = modalElement && window.bootstrap ? new bootstrap.Modal(modalElement) : null;
|
||||
|
||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user