Implemented the urgent stability/performance pass.

What I fixed:
Removed the warning-calculation N+1 queries. Occupancy warnings now use one floor-plan participant query instead of per-scene character/asset lookups.
Added SceneFloorPlanParticipant_ListByFloorPlan to the existing migration script with SET ANSI_NULLS ON and SET QUOTED_IDENTIFIER ON.
Reapplied the migration locally.
Confirmed FloorPlanBlock_Save, FloorPlan_GetEditor, and the new participant proc have quoted identifiers enabled.
Reduced drag/resize freeze risk by throttling expensive geometry recalculation to animation frames instead of every pointer event.
Added a final geometry refresh on pointer-up/cancel before autosave.
Improved autosave failure visibility by showing the real error in the editor, not just leaving status stuck.
Made Occupancy mode view/staging only:Blocks no longer start drag/resize in Occupancy mode.
Resize handles are hidden.
Structural toolbox is hidden.
Plan canvas expands to use the freed width.
Structure mode restores normal editing.

Verification:
dotnet build PlotLine.slnx passes with 0 warnings and 0 errors.
Non-destructive SQL smoke test of FloorPlanBlock_Save against the Lounge block passed inside a transaction and rolled back.
Confirmed the Lounge block stayed unchanged after the rollback.
Reapplied 081_FloorPlanFloorDescriptionAndOccupancyWarnings.sql successfully.
Files changed:
[Repositories.cs](C:/Source/PlotLine/PlotLine/Data/Repositories.cs)
[CoreModels.cs](C:/Source/PlotLine/PlotLine/Models/CoreModels.cs)
[CoreServices.cs](C:/Source/PlotLine/PlotLine/Services/CoreServices.cs)
[081 SQL migration](C:/Source/PlotLine/PlotLine/Sql/081_FloorPlanFloorDescriptionAndOccupancyWarnings.sql)
[Edit.cshtml](C:/Source/PlotLine/PlotLine/Views/FloorPlans/Edit.cshtml)
[site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css)
[site.min.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.min.css)
Manual browser QA still needed for the exact drag/add/reload flow, but the code path and SQL save path are now stabilized.
This commit is contained in:
Nick Beckley 2026-06-22 12:06:53 +01:00
parent 7ad993aae3
commit 13c97db5a0
7 changed files with 117 additions and 24 deletions

View File

@ -89,6 +89,7 @@ public interface ISceneFloorPlanOccupancyRepository
{
Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListBySceneAsync(int sceneId);
Task<IReadOnlyList<SceneFloorPlanOccupancy>> ListByFloorPlanAsync(int floorPlanId);
Task<IReadOnlyList<FloorPlanOccupancyParticipant>> ListParticipantsByFloorPlanAsync(int floorPlanId);
Task SaveAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId, IEnumerable<SceneFloorPlanOccupancy> occupancyRows);
}
@ -2649,6 +2650,16 @@ public sealed class SceneFloorPlanOccupancyRepository(ISqlConnectionFactory conn
return rows.ToList();
}
public async Task<IReadOnlyList<FloorPlanOccupancyParticipant>> ListParticipantsByFloorPlanAsync(int floorPlanId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<FloorPlanOccupancyParticipant>(
"dbo.SceneFloorPlanParticipant_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();

View File

@ -1274,6 +1274,14 @@ public sealed class SceneFloorPlanOccupancy
public DateTime? ModifiedDate { get; set; }
}
public sealed class FloorPlanOccupancyParticipant
{
public int SceneID { get; set; }
public string EntityType { get; set; } = string.Empty;
public int EntityID { get; set; }
public string DisplayName { get; set; } = string.Empty;
}
public static class FloorPlanTransitionPositions
{
public const string Centre = "Centre";

View File

@ -7733,8 +7733,6 @@ public sealed class FloorPlanService(
IProjectRepository projects,
ISceneRepository scenes,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
ICharacterRepository characters,
IAssetRepository assets,
IFloorPlanRepository floorPlans,
ILocationRepository locations,
IFloorPlanBackgroundImageService backgroundImages,
@ -7771,6 +7769,7 @@ public sealed class FloorPlanService(
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 occupancyParticipants = await sceneOccupancy.ListParticipantsByFloorPlanAsync(floorPlanId);
var firstBlockByLocation = data.Blocks
.GroupBy(x => x.LocationID)
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
@ -7863,12 +7862,12 @@ public sealed class FloorPlanService(
.Where(x => x is not null)
.Cast<FloorPlanOccupancyTokenViewModel>()
.ToList(),
OccupancyWarnings = await BuildFloorPlanOccupancyWarningsAsync(occupancyScenes, occupancyRows, data.Blocks, data.Transitions)
OccupancyWarnings = BuildFloorPlanOccupancyWarnings(occupancyParticipants, occupancyRows, data.Blocks, data.Transitions)
};
}
private async Task<IReadOnlyList<FloorPlanOccupancyWarningViewModel>> BuildFloorPlanOccupancyWarningsAsync(
IReadOnlyList<Scene> occupancyScenes,
private static IReadOnlyList<FloorPlanOccupancyWarningViewModel> BuildFloorPlanOccupancyWarnings(
IReadOnlyList<FloorPlanOccupancyParticipant> participants,
IReadOnlyList<SceneFloorPlanOccupancy> occupancyRows,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions)
@ -7881,37 +7880,38 @@ public sealed class FloorPlanService(
.SelectMany(x => new[] { x.FromLocationID, x.ToLocationID })
.ToHashSet();
foreach (var scene in occupancyScenes)
foreach (var sceneGroup in participants.GroupBy(x => x.SceneID))
{
var sceneRows = occupancyRows.Where(x => x.SceneID == scene.SceneID).ToList();
var sceneId = sceneGroup.Key;
var sceneRows = occupancyRows.Where(x => x.SceneID == sceneId).ToList();
var occupiedCharacterIds = sceneRows.Where(x => x.CharacterID.HasValue).Select(x => x.CharacterID!.Value).ToHashSet();
var occupiedAssetIds = sceneRows.Where(x => x.StoryAssetID.HasValue).Select(x => x.StoryAssetID!.Value).ToHashSet();
var sceneCharacters = await characters.ListSceneCharactersAsync(scene.SceneID);
foreach (var character in sceneCharacters
.GroupBy(x => x.CharacterID)
foreach (var character in sceneGroup
.Where(x => string.Equals(x.EntityType, "Character", StringComparison.OrdinalIgnoreCase))
.GroupBy(x => x.EntityID)
.Select(x => x.First())
.Where(x => !occupiedCharacterIds.Contains(x.CharacterID)))
.Where(x => !occupiedCharacterIds.Contains(x.EntityID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
SceneID = sceneId,
Severity = "warning",
Message = $"{character.CharacterName} appears in this scene but has no floor-plan location."
Message = $"{character.DisplayName} appears in this scene but has no floor-plan location."
});
}
var sceneAssets = await assets.ListEventsBySceneAsync(scene.SceneID);
foreach (var asset in sceneAssets
.GroupBy(x => x.StoryAssetID)
foreach (var asset in sceneGroup
.Where(x => string.Equals(x.EntityType, "Asset", StringComparison.OrdinalIgnoreCase))
.GroupBy(x => x.EntityID)
.Select(x => x.First())
.Where(x => !occupiedAssetIds.Contains(x.StoryAssetID)))
.Where(x => !occupiedAssetIds.Contains(x.EntityID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
SceneID = sceneId,
Severity = "warning",
Message = $"{asset.AssetName} appears in this scene but has no floor-plan location."
Message = $"{asset.DisplayName} appears in this scene but has no floor-plan location."
});
}
@ -7919,7 +7919,7 @@ public sealed class FloorPlanService(
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
SceneID = sceneId,
Severity = "warning",
Message = $"{OccupantName(row)} is assigned to {row.LocationName}, but that location is not placed on this floor plan."
});
@ -7934,7 +7934,7 @@ public sealed class FloorPlanService(
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
SceneID = sceneId,
FloorPlanFloorID = floorId,
Severity = "info",
Message = "Some occupants are on other floors."
@ -7947,7 +7947,7 @@ public sealed class FloorPlanService(
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
SceneID = sceneId,
FloorPlanFloorID = floorId,
Severity = "info",
Message = $"{row.LocationName} has no visible entrance."

View File

@ -1,3 +1,8 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'Description') IS NULL
ALTER TABLE dbo.FloorPlanFloors ADD Description nvarchar(max) NULL;
GO
@ -119,6 +124,38 @@ BEGIN
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneFloorPlanParticipant_ListByFloorPlan
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
SELECT DISTINCT
s.SceneID,
N'Character' AS EntityType,
sc.CharacterID AS EntityID,
c.CharacterName AS DisplayName
FROM dbo.Scenes s
INNER JOIN dbo.SceneCharacters sc ON sc.SceneID = s.SceneID
INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID
WHERE s.FloorPlanID = @FloorPlanID
AND s.IsArchived = 0
UNION
SELECT DISTINCT
s.SceneID,
N'Asset' AS EntityType,
ae.StoryAssetID AS EntityID,
sa.AssetName AS DisplayName
FROM dbo.Scenes s
INNER JOIN dbo.AssetEvents ae ON ae.SceneID = s.SceneID
INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ae.StoryAssetID
WHERE s.FloorPlanID = @FloorPlanID
AND s.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Get
@FloorPlanFloorID int
AS

View File

@ -1059,6 +1059,7 @@
let saveInFlight = false;
let saveAgain = false;
let refreshAfterSave = false;
let geometryRefreshQueued = false;
const backgroundTimers = new Map();
const gridCoordinatesStorageKey = "floorPlan.showGridCoordinates";
const referenceFloorStorageKey = "floorPlan.referenceFloor";
@ -1124,6 +1125,11 @@
saveButtons.forEach(button => {
button.hidden = isOccupancy;
});
if (isOccupancy) {
pointerState = null;
blocks.forEach(item => item.classList.remove("selected"));
if (noSelection) noSelection.classList.remove("d-none");
}
refreshOccupancyTrays();
}
@ -1295,6 +1301,10 @@
alert.textContent = message || "Save failed";
}
function clearWorkspaceError() {
editor.querySelector("[data-workspace-error]")?.remove();
}
function cleanupModalArtifacts() {
document.querySelectorAll(".modal-backdrop").forEach(backdrop => backdrop.remove());
document.body.classList.remove("modal-open");
@ -1407,6 +1417,7 @@
throw new Error(result.message || "Unable to save layout.");
}
clearWorkspaceError();
setSaveStatus("saved");
if (refreshAfterSave) {
refreshAfterSave = false;
@ -1414,6 +1425,7 @@
}
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
showWorkspaceError(error.message || "Save failed");
} finally {
saveInFlight = false;
saveButtons.forEach(button => button.disabled = false);
@ -1562,6 +1574,7 @@
function attachBlockEvents(block) {
block.addEventListener("pointerdown", event => {
if (editor.classList.contains("floor-plan-editor--occupancy")) return;
selectBlock(block);
const grid = gridFor(block);
if (!grid) return;
@ -1819,7 +1832,16 @@
if (fields.y) fields.y.value = next.y;
if (fields.w) fields.w.value = next.w;
if (fields.h) fields.h.value = next.h;
checkOverlaps();
scheduleGeometryRefresh();
}
function scheduleGeometryRefresh() {
if (geometryRefreshQueued) return;
geometryRefreshQueued = true;
window.requestAnimationFrame(() => {
geometryRefreshQueued = false;
checkOverlaps();
});
}
function rangesOverlap(aStart, aLength, bStart, bLength) {
@ -2445,6 +2467,10 @@
editor.addEventListener("pointermove", event => {
if (!pointerState) return;
if (editor.classList.contains("floor-plan-editor--occupancy")) {
pointerState = null;
return;
}
const currentCell = cellFromPointer(pointerState.grid, event);
const dx = currentCell.x - pointerState.start.x;
const dy = currentCell.y - pointerState.start.y;
@ -2466,6 +2492,7 @@
editor.addEventListener("pointerup", () => {
if (pointerState) {
const floorId = pointerState.block.dataset.floorId;
checkOverlaps();
markDirty();
refreshTransitionDestination(floorId);
}
@ -2474,6 +2501,7 @@
editor.addEventListener("pointercancel", () => {
if (pointerState) {
const floorId = pointerState.block.dataset.floorId;
checkOverlaps();
markDirty();
refreshTransitionDestination(floorId);
}

View File

@ -4510,6 +4510,10 @@ body.dragging-location [data-drag-type="location"] {
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
}
.floor-plan-editor--occupancy .floor-plan-layout {
grid-template-columns: minmax(0, 1fr);
}
.floor-plan-editor-toolbar {
background: rgba(255, 255, 255, .92);
border: 1px solid var(--border-color, #d8ded9);
@ -5341,12 +5345,17 @@ body.dragging-location [data-drag-type="location"] {
.floor-plan-editor--occupancy .floor-plan-block {
cursor: default;
touch-action: auto;
}
.floor-plan-editor--occupancy .floor-plan-resize-handle {
display: none;
}
.floor-plan-editor--occupancy .floor-plan-side-panel {
display: none;
}
.floor-plan-occupancy-tray {
position: absolute;
z-index: 14;

File diff suppressed because one or more lines are too long