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:
parent
7ad993aae3
commit
13c97db5a0
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
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
Loading…
x
Reference in New Issue
Block a user