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

View File

@ -1274,6 +1274,14 @@ public sealed class SceneFloorPlanOccupancy
public DateTime? ModifiedDate { get; set; } 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 static class FloorPlanTransitionPositions
{ {
public const string Centre = "Centre"; public const string Centre = "Centre";

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long