Implemented the cleanup/refinement pass.

What changed:
Removed the standalone floor-plan details panel under the page title.
Added current-floor Description and editable Root Location into the Floors toolbox tab.
Added live computed Occupancy Warnings in Occupancy mode.
Preserved the existing AJAX workspace flow, including active floor/toolbox tab/mode/selected occupancy scene through refreshes.
Added floor description support via a new migration:
[081_FloorPlanFloorDescriptionAndOccupancyWarnings.sql](C:/Source/PlotLine/PlotLine/Sql/081_FloorPlanFloorDescriptionAndOccupancyWarnings.sql)
Database changes:
Added nullable FloorPlanFloors.Description.
Updated active stored procedures:FloorPlan_GetEditor
FloorPlanFloor_Get
FloorPlanFloor_ListByPlan
FloorPlanFloor_Save

Warnings are not persisted. No warning tables were added.
Files changed include:
[Edit.cshtml](C:/Source/PlotLine/PlotLine/Views/FloorPlans/Edit.cshtml)
[CoreServices.cs](C:/Source/PlotLine/PlotLine/Services/CoreServices.cs)
[Repositories.cs](C:/Source/PlotLine/PlotLine/Data/Repositories.cs)
[CoreModels.cs](C:/Source/PlotLine/PlotLine/Models/CoreModels.cs)
[CoreViewModels.cs](C:/Source/PlotLine/PlotLine/ViewModels/CoreViewModels.cs)
[site.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css)
[site.min.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.min.css)
Verification:
Applied the new migration locally.
Confirmed FloorPlanFloors.Description exists.
Confirmed scene/occupancy source data exists for The Calendar House.
dotnet build PlotLine.slnx passes with 0 warnings and 0 errors.
Manual QA still needed in the browser: edit floor description/root location, switch Structure/Occupancy mode, step scenes, and confirm warnings/tokens update visually without jumps.
This commit is contained in:
Nick Beckley 2026-06-22 11:56:01 +01:00
parent a943ac3da4
commit 7ad993aae3
8 changed files with 518 additions and 32 deletions

View File

@ -633,7 +633,7 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.FloorPlanFloor_Save",
new { floor.FloorPlanFloorID, floor.FloorPlanID, floor.LocationID, floor.Name, floor.SortOrder, floor.GridWidth, floor.GridHeight },
new { floor.FloorPlanFloorID, floor.FloorPlanID, floor.LocationID, floor.Name, floor.Description, floor.SortOrder, floor.GridWidth, floor.GridHeight },
commandType: CommandType.StoredProcedure);
}

View File

@ -1167,6 +1167,7 @@ public sealed class FloorPlanFloor
public string? LocationName { get; set; }
public string? LocationPath { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public int SortOrder { get; set; }
public int GridWidth { get; set; } = 24;
public int GridHeight { get; set; } = 16;

View File

@ -7733,6 +7733,8 @@ public sealed class FloorPlanService(
IProjectRepository projects,
ISceneRepository scenes,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
ICharacterRepository characters,
IAssetRepository assets,
IFloorPlanRepository floorPlans,
ILocationRepository locations,
IFloorPlanBackgroundImageService backgroundImages,
@ -7860,10 +7862,132 @@ public sealed class FloorPlanService(
})
.Where(x => x is not null)
.Cast<FloorPlanOccupancyTokenViewModel>()
.ToList()
.ToList(),
OccupancyWarnings = await BuildFloorPlanOccupancyWarningsAsync(occupancyScenes, occupancyRows, data.Blocks, data.Transitions)
};
}
private async Task<IReadOnlyList<FloorPlanOccupancyWarningViewModel>> BuildFloorPlanOccupancyWarningsAsync(
IReadOnlyList<Scene> occupancyScenes,
IReadOnlyList<SceneFloorPlanOccupancy> occupancyRows,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions)
{
var warnings = new List<FloorPlanOccupancyWarningViewModel>();
var blocksByLocation = blocks.GroupBy(x => x.LocationID).ToDictionary(x => x.Key, x => x.ToList());
var floors = blocks.Select(x => x.FloorPlanFloorID).Distinct().ToList();
var visibleEntranceLocationIds = transitions
.Where(x => IsVisibleEntranceTransition(x.TransitionType))
.SelectMany(x => new[] { x.FromLocationID, x.ToLocationID })
.ToHashSet();
foreach (var scene in occupancyScenes)
{
var sceneRows = occupancyRows.Where(x => x.SceneID == scene.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)
.Select(x => x.First())
.Where(x => !occupiedCharacterIds.Contains(x.CharacterID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
Severity = "warning",
Message = $"{character.CharacterName} 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)
.Select(x => x.First())
.Where(x => !occupiedAssetIds.Contains(x.StoryAssetID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
Severity = "warning",
Message = $"{asset.AssetName} appears in this scene but has no floor-plan location."
});
}
foreach (var row in sceneRows.Where(x => !blocksByLocation.ContainsKey(x.LocationID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
Severity = "warning",
Message = $"{OccupantName(row)} is assigned to {row.LocationName}, but that location is not placed on this floor plan."
});
}
var rowsOnPlacedLocations = sceneRows.Where(x => blocksByLocation.ContainsKey(x.LocationID)).ToList();
var occupiedFloorIds = rowsOnPlacedLocations
.SelectMany(x => blocksByLocation[x.LocationID].Select(block => block.FloorPlanFloorID))
.Distinct()
.ToHashSet();
foreach (var floorId in floors.Where(floorId => rowsOnPlacedLocations.Any() && !occupiedFloorIds.Contains(floorId)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
FloorPlanFloorID = floorId,
Severity = "info",
Message = "Some occupants are on other floors."
});
}
foreach (var row in rowsOnPlacedLocations.Where(x => !visibleEntranceLocationIds.Contains(x.LocationID)))
{
foreach (var floorId in blocksByLocation[row.LocationID].Select(x => x.FloorPlanFloorID).Distinct())
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = scene.SceneID,
FloorPlanFloorID = floorId,
Severity = "info",
Message = $"{row.LocationName} has no visible entrance."
});
}
}
}
return warnings
.GroupBy(x => new { x.SceneID, x.FloorPlanFloorID, x.Severity, x.Message })
.Select(x => x.First())
.OrderByDescending(x => string.Equals(x.Severity, "warning", StringComparison.OrdinalIgnoreCase))
.ThenBy(x => x.Message)
.ToList();
}
private static string OccupantName(SceneFloorPlanOccupancy row)
=> row.CharacterName ?? row.AssetName ?? "Occupant";
private static bool IsVisibleEntranceTransition(string? transitionType)
{
var normalized = (transitionType ?? string.Empty)
.Trim()
.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
.ToLowerInvariant();
return normalized is "door"
or "doubledoor"
or "frenchdoor"
or "frenchdoors"
or "slidingdoor"
or "slidingdoors"
or "exteriordoor"
or "archway"
or "openpassage"
or "stairsup"
or "stairsdown";
}
public async Task<int> SavePlanAsync(FloorPlanEditViewModel model)
{
var isNew = model.FloorPlanID == 0;
@ -7915,6 +8039,7 @@ public sealed class FloorPlanService(
FloorPlanID = model.FloorPlanID,
LocationID = locationId,
Name = model.Name.Trim(),
Description = model.Description,
SortOrder = model.SortOrder,
GridWidth = model.GridWidth,
GridHeight = model.GridHeight
@ -8497,6 +8622,7 @@ public sealed class FloorPlanService(
LocationName = floor.LocationName,
LocationPath = floor.LocationPath,
Name = floor.Name,
Description = floor.Description,
SortOrder = floor.SortOrder,
GridWidth = floor.GridWidth,
GridHeight = floor.GridHeight,

View File

@ -0,0 +1,238 @@
IF COL_LENGTH(N'dbo.FloorPlanFloors', N'Description') IS NULL
ALTER TABLE dbo.FloorPlanFloors ADD Description nvarchar(max) NULL;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int;
SELECT @ProjectID = ProjectID
FROM dbo.FloorPlans
WHERE FloorPlanID = @FloorPlanID;
EXEC dbo.FloorPlan_Get @FloorPlanID;
CREATE TABLE #ProjectLocationPaths
(
LocationID int NOT NULL PRIMARY KEY,
LocationName nvarchar(200) NOT NULL,
LocationPath nvarchar(max) NOT NULL
);
;WITH ProjectLocationTree AS
(
SELECT
LocationID,
LocationName,
CAST(LocationName AS nvarchar(max)) AS LocationPath
FROM dbo.Locations
WHERE ProjectID = @ProjectID
AND ParentLocationID IS NULL
AND IsArchived = 0
UNION ALL
SELECT
child.LocationID,
child.LocationName,
CAST(parent.LocationPath + N' > ' + child.LocationName AS nvarchar(max)) AS LocationPath
FROM dbo.Locations child
INNER JOIN ProjectLocationTree parent ON parent.LocationID = child.ParentLocationID
WHERE child.ProjectID = @ProjectID
AND child.IsArchived = 0
)
INSERT #ProjectLocationPaths (LocationID, LocationName, LocationPath)
SELECT LocationID, LocationName, LocationPath
FROM ProjectLocationTree;
SELECT
fpf.FloorPlanFloorID,
fpf.FloorPlanID,
fpf.LocationID,
lp.LocationName,
lp.LocationPath,
fpf.Name,
fpf.Description,
fpf.SortOrder,
fpf.GridWidth,
fpf.GridHeight,
fpf.BackgroundImagePath,
fpf.BackgroundImageOriginalFileName,
COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity,
COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale,
COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX,
COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY,
fpf.BackgroundLocked,
fpf.CreatedDate,
fpf.ModifiedDate
FROM dbo.FloorPlanFloors fpf
LEFT JOIN #ProjectLocationPaths lp ON lp.LocationID = fpf.LocationID
WHERE fpf.FloorPlanID = @FloorPlanID
ORDER BY fpf.SortOrder, fpf.Name;
SELECT
fpb.FloorPlanBlockID,
fpb.FloorPlanFloorID,
fpb.LocationID,
lp.LocationName,
COALESCE(NULLIF(lp.LocationPath, N''), lp.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 #ProjectLocationPaths lp ON lp.LocationID = fpb.LocationID
WHERE fpf.FloorPlanID = @FloorPlanID
ORDER BY fpf.SortOrder, fpb.Y, fpb.X, lp.LocationName;
SELECT
fpt.FloorPlanTransitionID,
fpt.FloorPlanFloorID,
fpt.FromLocationID,
fromPath.LocationName AS FromLocationName,
COALESCE(NULLIF(fromPath.LocationPath, N''), fromPath.LocationName) AS FromLocationPath,
fpt.ToLocationID,
toPath.LocationName AS ToLocationName,
COALESCE(NULLIF(toPath.LocationPath, N''), toPath.LocationName) AS ToLocationPath,
fpt.TransitionType,
fpt.Notes,
fpt.IsLocked,
fpt.DisplayLabel,
fpt.PositionWithinLocation,
fpt.CreatedDate,
fpt.ModifiedDate
FROM dbo.FloorPlanTransitions fpt
INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpt.FloorPlanFloorID
INNER JOIN #ProjectLocationPaths fromPath ON fromPath.LocationID = fpt.FromLocationID
INNER JOIN #ProjectLocationPaths toPath ON toPath.LocationID = fpt.ToLocationID
WHERE fpf.FloorPlanID = @FloorPlanID
ORDER BY fpf.SortOrder, fromPath.LocationName, toPath.LocationName, fpt.TransitionType;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Get
@FloorPlanFloorID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
fpf.FloorPlanFloorID,
fpf.FloorPlanID,
fpf.LocationID,
lp.LocationName,
lp.LocationPath,
fpf.Name,
fpf.Description,
fpf.SortOrder,
fpf.GridWidth,
fpf.GridHeight,
fpf.BackgroundImagePath,
fpf.BackgroundImageOriginalFileName,
COALESCE(fpf.BackgroundOpacity, CONVERT(decimal(5,2), 0.35)) AS BackgroundOpacity,
COALESCE(fpf.BackgroundScale, CONVERT(decimal(8,3), 1.000)) AS BackgroundScale,
COALESCE(fpf.BackgroundOffsetX, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetX,
COALESCE(fpf.BackgroundOffsetY, CONVERT(decimal(10,2), 0.00)) AS BackgroundOffsetY,
fpf.BackgroundLocked,
fpf.CreatedDate,
fpf.ModifiedDate
FROM dbo.FloorPlanFloors fpf
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID
WHERE fpf.FloorPlanFloorID = @FloorPlanFloorID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_ListByPlan
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
fpf.FloorPlanFloorID,
fpf.FloorPlanID,
fpf.LocationID,
lp.LocationName,
lp.LocationPath,
fpf.Name,
fpf.Description,
fpf.SortOrder,
fpf.GridWidth,
fpf.GridHeight,
fpf.BackgroundImagePath,
fpf.BackgroundImageOriginalFileName,
fpf.BackgroundOpacity,
fpf.BackgroundScale,
fpf.BackgroundOffsetX,
fpf.BackgroundOffsetY,
fpf.BackgroundLocked,
fpf.CreatedDate,
fpf.ModifiedDate
FROM dbo.FloorPlanFloors fpf
LEFT JOIN dbo.LocationPaths lp ON lp.LocationID = fpf.LocationID
WHERE fpf.FloorPlanID = @FloorPlanID
ORDER BY fpf.SortOrder, fpf.Name;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanFloor_Save
@FloorPlanFloorID int,
@FloorPlanID int,
@LocationID int = NULL,
@Name nvarchar(100),
@Description nvarchar(max) = NULL,
@SortOrder int,
@GridWidth int,
@GridHeight int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectID int;
SELECT @ProjectID = ProjectID FROM dbo.FloorPlans WHERE FloorPlanID = @FloorPlanID;
IF @ProjectID IS NULL
THROW 51003, 'Floor plan not found.', 1;
SET @Name = NULLIF(LTRIM(RTRIM(@Name)), N'');
IF @Name IS NULL
THROW 51001, 'Floor name is required.', 1;
IF @GridWidth NOT BETWEEN 8 AND 80 OR @GridHeight NOT BETWEEN 8 AND 80
THROW 51002, 'Grid width and height must be between 8 and 80.', 1;
IF @LocationID IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID = @LocationID AND ProjectID = @ProjectID AND IsArchived = 0)
THROW 51008, 'Floor location must belong to this project.', 1;
IF @FloorPlanFloorID = 0
BEGIN
INSERT dbo.FloorPlanFloors (FloorPlanID, LocationID, Name, Description, SortOrder, GridWidth, GridHeight, BackgroundOpacity, BackgroundScale, BackgroundOffsetX, BackgroundOffsetY, BackgroundLocked)
VALUES (@FloorPlanID, @LocationID, @Name, @Description, @SortOrder, @GridWidth, @GridHeight, 0.35, 1.000, 0.00, 0.00, 1);
SELECT CONVERT(int, SCOPE_IDENTITY());
RETURN;
END;
UPDATE dbo.FloorPlanFloors
SET LocationID = @LocationID,
Name = @Name,
Description = @Description,
SortOrder = @SortOrder,
GridWidth = @GridWidth,
GridHeight = @GridHeight,
ModifiedDate = SYSUTCDATETIME()
WHERE FloorPlanFloorID = @FloorPlanFloorID
AND FloorPlanID = @FloorPlanID;
SELECT @FloorPlanFloorID;
END;
GO

View File

@ -1935,6 +1935,7 @@ public sealed class FloorPlanEditorViewModel
public IReadOnlyList<SelectListItem> TransitionTypeOptions { get; set; } = [];
public IReadOnlyList<FloorPlanOccupancySceneViewModel> OccupancyScenes { get; set; } = [];
public IReadOnlyList<FloorPlanOccupancyTokenViewModel> OccupancyTokens { get; set; } = [];
public IReadOnlyList<FloorPlanOccupancyWarningViewModel> OccupancyWarnings { get; set; } = [];
}
public sealed class FloorPlanOccupancySceneViewModel
@ -1957,6 +1958,14 @@ public sealed class FloorPlanOccupancyTokenViewModel
public string? ThumbnailPath { get; set; }
}
public sealed class FloorPlanOccupancyWarningViewModel
{
public int SceneID { get; set; }
public int? FloorPlanFloorID { get; set; }
public string Severity { get; set; } = "warning";
public string Message { get; set; } = string.Empty;
}
public sealed class FloorPlanFloorEditViewModel
{
public int FloorPlanFloorID { get; set; }
@ -1968,6 +1977,8 @@ public sealed class FloorPlanFloorEditViewModel
[Required, StringLength(100)]
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public int SortOrder { get; set; }
[Range(8, 80)]

View File

@ -103,32 +103,6 @@
<div class="alert alert-danger">@error</div>
}
<section class="edit-panel floor-plan-meta floor-plan-meta-wide">
<form asp-action="SavePlan" method="post" class="row g-3">
<input asp-for="FloorPlan.FloorPlanID" type="hidden" name="FloorPlanID" />
<input asp-for="FloorPlan.ProjectID" type="hidden" name="ProjectID" />
<div class="col-md-4">
<label asp-for="FloorPlan.Name" class="form-label"></label>
<input asp-for="FloorPlan.Name" class="form-control" name="Name" />
<span asp-validation-for="FloorPlan.Name" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="FloorPlan.Description" class="form-label"></label>
<input asp-for="FloorPlan.Description" class="form-control" name="Description" />
</div>
<div class="col-md-2">
<label class="form-label">Root Location</label>
<select asp-for="FloorPlan.LocationID" asp-items="Model.LocationOptions" class="form-select" name="LocationID"></select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-outline-primary w-100" type="submit">Save details</button>
</div>
<div class="col-12">
<p class="muted mb-0">Linked Location: @(string.IsNullOrWhiteSpace(Model.FloorPlan.LocationPath) ? "A matching Location will be created or linked on save." : Model.FloorPlan.LocationPath)</p>
</div>
</form>
</section>
<div data-floor-plan-workspace data-floor-plan-id="@Model.FloorPlan.FloorPlanID">
@if (!Model.Floors.Any())
{
@ -226,6 +200,21 @@
</label>
<button class="btn btn-outline-secondary btn-sm" type="button" data-occupancy-prev>Previous Scene</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-occupancy-next>Next Scene</button>
<div class="floor-plan-occupancy-warning-panel" data-occupancy-warning-panel>
<strong>Occupancy Warnings</strong>
<p class="floor-plan-occupancy-warning-empty" data-occupancy-warning-empty>All scene occupants are placed.</p>
<ul>
@foreach (var warning in Model.OccupancyWarnings)
{
<li class="floor-plan-occupancy-warning floor-plan-occupancy-warning--@warning.Severity"
data-occupancy-warning
data-occupancy-scene="@warning.SceneID"
data-occupancy-floor="@warning.FloorPlanFloorID">
@warning.Message
</li>
}
</ul>
</div>
</div>
</section>
@ -722,11 +711,18 @@
<div class="floor-plan-floor-editor @(isActive ? string.Empty : "d-none")" data-floor-editor="@floor.FloorPlanFloorID">
<input asp-for="Floors[i].FloorPlanFloorID" type="hidden" />
<input asp-for="Floors[i].FloorPlanID" type="hidden" />
<input asp-for="Floors[i].LocationID" type="hidden" />
<div class="mb-2">
<label class="form-label">Floor name</label>
<input asp-for="Floors[i].Name" class="form-control form-control-sm" />
</div>
<div class="mb-2">
<label class="form-label">Description</label>
<textarea asp-for="Floors[i].Description" class="form-control form-control-sm" rows="2"></textarea>
</div>
<div class="mb-2">
<label class="form-label">Root Location</label>
<select asp-for="Floors[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm" data-floor-location-select="@floor.FloorPlanFloorID"></select>
</div>
<div class="mb-2">
<label class="form-label">Level</label>
<input asp-for="Floors[i].SortOrder" class="form-control form-control-sm" min="-10" max="50" step="1" data-floor-level-input="@floor.FloorPlanFloorID" />
@ -1014,6 +1010,8 @@
const occupancyNext = editor.querySelector("[data-occupancy-next]");
const occupancyTrays = [...editor.querySelectorAll("[data-occupancy-tray]")];
const occupancyMessages = [...editor.querySelectorAll("[data-occupancy-message]")];
const occupancyWarnings = [...editor.querySelectorAll("[data-occupancy-warning]")];
const occupancyWarningEmpty = editor.querySelector("[data-occupancy-warning-empty]");
const initialSelectedBlockId = options.selectedBlockId || "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
@ -1098,6 +1096,18 @@
occupancyMessages.forEach(message => {
message.hidden = !showOccupancy || message.dataset.occupancyScene !== sceneId || message.dataset.occupancyFloor !== activeFloorId;
});
let visibleWarnings = 0;
occupancyWarnings.forEach(warning => {
const warningFloorId = warning.dataset.occupancyFloor || "";
const visible = showOccupancy
&& warning.dataset.occupancyScene === sceneId
&& (!warningFloorId || warningFloorId === activeFloorId);
warning.hidden = !visible;
if (visible) visibleWarnings++;
});
if (occupancyWarningEmpty) {
occupancyWarningEmpty.hidden = !showOccupancy || visibleWarnings > 0;
}
}
function setFloorPlanMode(mode) {
@ -1262,6 +1272,8 @@
activeFloorId: activePanel?.dataset.floorPanel || tabs.find(tab => tab.classList.contains("active"))?.dataset.floorTab || "",
activeToolboxTab: toolboxTabs.find(tab => tab.classList.contains("active"))?.dataset.toolboxTab || "add",
selectedBlockId: selectedBlock?.dataset.block || "",
floorPlanMode: modeButtons.find(button => button.classList.contains("active"))?.dataset.floorPlanMode || "structure",
occupancySceneId: selectedOccupancySceneId(),
pageX: window.scrollX,
pageY: window.scrollY,
gridLeft: gridWrap?.scrollLeft || 0,
@ -1300,6 +1312,12 @@
if (state.activeToolboxTab) {
setToolboxTab(state.activeToolboxTab);
}
if (state.occupancySceneId && occupancySceneSelect) {
occupancySceneSelect.value = String(state.occupancySceneId);
}
if (state.floorPlanMode) {
setFloorPlanMode(state.floorPlanMode);
}
if (state.selectedBlockId) {
const block = blocks.find(item => item.dataset.block === String(state.selectedBlockId));
if (block) {
@ -1347,7 +1365,9 @@
window.initializeFloorPlanEditor({
activeFloorId: state.activeFloorId,
activeToolboxTab: state.activeToolboxTab,
selectedBlockId: state.selectedBlockId
selectedBlockId: state.selectedBlockId,
floorPlanMode: state.floorPlanMode,
occupancySceneId: state.occupancySceneId
});
const nextEditor = document.querySelector("[data-floor-plan-editor]");
const nextStatus = nextEditor?.querySelector("[data-save-status]");
@ -2403,7 +2423,9 @@
window.initializeFloorPlanEditor({
activeFloorId: state.activeFloorId,
activeToolboxTab: state.activeToolboxTab,
selectedBlockId: state.selectedBlockId
selectedBlockId: state.selectedBlockId,
floorPlanMode: state.floorPlanMode,
occupancySceneId: state.occupancySceneId
});
window.requestAnimationFrame(() => {
const refreshedEditor = document.querySelector("[data-floor-plan-editor]");
@ -2517,6 +2539,13 @@
input.addEventListener("change", () => updateFloorLevel(true));
});
editor.querySelectorAll("[data-floor-location-select]").forEach(input => {
input.addEventListener("change", () => {
refreshAfterSave = true;
markDirty();
});
});
editor.querySelectorAll("input, select, textarea").forEach(input => {
if (input.matches("[form='floor-plan-add-block-form'], [form='floor-plan-add-floor-form']")) return;
if (input.matches("[form^='floor-plan-background-'], [form^='floor-plan-transition-add-']")) return;

View File

@ -4575,6 +4575,62 @@ body.dragging-location [data-drag-type="location"] {
width: 100%;
}
.floor-plan-occupancy-warning-panel {
background: rgba(47, 111, 99, .05);
border: 1px solid rgba(47, 111, 99, .16);
border-radius: 8px;
color: var(--plotline-ink, #1e252b);
flex: 1 1 100%;
font-size: .82rem;
padding: .55rem .7rem;
}
.floor-plan-occupancy-warning-panel strong {
display: block;
font-size: .78rem;
margin-bottom: .25rem;
text-transform: uppercase;
}
.floor-plan-occupancy-warning-panel p,
.floor-plan-occupancy-warning-panel ul {
margin: 0;
}
.floor-plan-occupancy-warning-panel ul {
display: grid;
gap: .25rem;
list-style: none;
padding: 0;
}
.floor-plan-occupancy-warning,
.floor-plan-occupancy-warning-empty {
border-radius: 6px;
font-weight: 700;
padding: .32rem .45rem;
}
.floor-plan-occupancy-warning-empty {
background: rgba(47, 111, 99, .08);
color: var(--plotline-accent-dark, #22534a);
}
.floor-plan-occupancy-warning--warning {
background: rgba(251, 236, 211, .78);
color: #7b4b12;
}
.floor-plan-occupancy-warning--info {
background: rgba(233, 243, 248, .86);
color: #245b78;
}
.floor-plan-occupancy-warning[hidden],
.floor-plan-occupancy-warning-empty[hidden] {
display: none;
}
.floor-plan-editor-toolbar .muted {
display: block;
font-size: .76rem;
@ -5582,6 +5638,31 @@ body.dragging-location [data-drag-type="location"] {
color: #d9c8b8;
}
[data-bs-theme="dark"] .floor-plan-occupancy-warning-panel,
.dark .floor-plan-occupancy-warning-panel {
background: rgba(244, 234, 220, .04);
border-color: rgba(212, 154, 98, .2);
color: #f1e7dd;
}
[data-bs-theme="dark"] .floor-plan-occupancy-warning-empty,
.dark .floor-plan-occupancy-warning-empty {
background: rgba(47, 111, 99, .18);
color: #b8e2d7;
}
[data-bs-theme="dark"] .floor-plan-occupancy-warning--warning,
.dark .floor-plan-occupancy-warning--warning {
background: rgba(84, 57, 25, .92);
color: #ffd9a5;
}
[data-bs-theme="dark"] .floor-plan-occupancy-warning--info,
.dark .floor-plan-occupancy-warning--info {
background: rgba(24, 45, 58, .92);
color: #b9dcf3;
}
[data-bs-theme="dark"] .floor-plan-transition-accordion .accordion-item,
.dark .floor-plan-transition-accordion .accordion-item {
border-color: rgba(212, 154, 98, .22);

File diff suppressed because one or more lines are too long