Implemented the Floor Plans multi-block room and transition phase.

This commit is contained in:
Nick Beckley 2026-06-20 21:41:40 +01:00
parent e455018d73
commit 18858f3fba
9 changed files with 859 additions and 10 deletions

View File

@ -178,6 +178,43 @@ public sealed class FloorPlansController(IFloorPlanService floorPlans) : Control
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveTransition(int floorPlanId, FloorPlanTransitionEditViewModel model)
{
try
{
if (ModelState.IsValid)
{
await floorPlans.SaveTransitionAsync(floorPlanId, model);
TempData["FloorPlanMessage"] = model.FloorPlanTransitionID == 0 ? "Transition created." : "Transition updated.";
}
}
catch (InvalidOperationException ex)
{
TempData["FloorPlanError"] = ex.Message;
}
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteTransition(int floorPlanId, int floorPlanTransitionId)
{
try
{
await floorPlans.DeleteTransitionAsync(floorPlanId, floorPlanTransitionId);
TempData["FloorPlanMessage"] = "Transition deleted.";
}
catch (InvalidOperationException ex)
{
TempData["FloorPlanError"] = ex.Message;
}
return RedirectToAction(nameof(Edit), new { id = floorPlanId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddBlock(int floorPlanId, FloorPlanBlockEditViewModel model)

View File

@ -218,7 +218,7 @@ public interface IFloorPlanRepository
{
Task<IReadOnlyList<FloorPlan>> ListByProjectAsync(int projectId);
Task<FloorPlan?> GetAsync(int floorPlanId);
Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks)> GetEditorAsync(int floorPlanId);
Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks, IReadOnlyList<FloorPlanTransition> Transitions)> GetEditorAsync(int floorPlanId);
Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId);
Task<int> SavePlanAsync(FloorPlan floorPlan);
Task DeletePlanAsync(int floorPlanId);
@ -227,6 +227,8 @@ public interface IFloorPlanRepository
Task DeleteFloorAsync(int floorPlanFloorId);
Task<int> SaveBlockAsync(FloorPlanBlock block);
Task DeleteBlockAsync(int floorPlanBlockId);
Task<int> SaveTransitionAsync(FloorPlanTransition transition);
Task DeleteTransitionAsync(int floorPlanTransitionId);
}
public interface IWarningRepository
@ -552,14 +554,15 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
return await connection.QuerySingleOrDefaultAsync<FloorPlan>("dbo.FloorPlan_Get", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
}
public async Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks)> GetEditorAsync(int floorPlanId)
public async Task<(FloorPlan? FloorPlan, IReadOnlyList<FloorPlanFloor> Floors, IReadOnlyList<FloorPlanBlock> Blocks, IReadOnlyList<FloorPlanTransition> Transitions)> GetEditorAsync(int floorPlanId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync("dbo.FloorPlan_GetEditor", new { FloorPlanID = floorPlanId }, commandType: CommandType.StoredProcedure);
var floorPlan = await result.ReadSingleOrDefaultAsync<FloorPlan>();
var floors = (await result.ReadAsync<FloorPlanFloor>()).ToList();
var blocks = (await result.ReadAsync<FloorPlanBlock>()).ToList();
return (floorPlan, floors, blocks);
var transitions = (await result.ReadAsync<FloorPlanTransition>()).ToList();
return (floorPlan, floors, blocks, transitions);
}
public async Task<FloorPlanFloor?> GetFloorAsync(int floorPlanFloorId)
@ -645,6 +648,31 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.FloorPlanBlock_Delete", new { FloorPlanBlockID = floorPlanBlockId }, commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveTransitionAsync(FloorPlanTransition transition)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.FloorPlanTransition_Save",
new
{
transition.FloorPlanTransitionID,
transition.FloorPlanFloorID,
transition.FromLocationID,
transition.ToLocationID,
transition.TransitionType,
transition.Notes,
transition.IsLocked,
transition.DisplayLabel
},
commandType: CommandType.StoredProcedure);
}
public async Task DeleteTransitionAsync(int floorPlanTransitionId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.FloorPlanTransition_Delete", new { FloorPlanTransitionID = floorPlanTransitionId }, commandType: CommandType.StoredProcedure);
}
}
public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) : IWarningRepository

View File

@ -1195,6 +1195,56 @@ public sealed class FloorPlanBlock
public string DisplayLabel => string.IsNullOrWhiteSpace(LabelOverride) ? LocationName : LabelOverride;
}
public static class FloorPlanTransitionTypes
{
public const string Door = "Door";
public const string DoubleDoor = "Double Door";
public const string Archway = "Archway";
public const string OpenPassage = "Open Passage";
public const string FrenchDoors = "French Doors";
public const string SlidingDoors = "Sliding Doors";
public const string StairsUp = "Stairs Up";
public const string StairsDown = "Stairs Down";
public const string Ladder = "Ladder";
public const string SecretPassage = "Secret Passage";
public const string Window = "Window";
public static readonly IReadOnlyList<string> All =
[
Door,
DoubleDoor,
Archway,
OpenPassage,
FrenchDoors,
SlidingDoors,
StairsUp,
StairsDown,
Ladder,
SecretPassage,
Window
];
public static bool IsValid(string? transitionType) => All.Contains(transitionType ?? string.Empty, StringComparer.OrdinalIgnoreCase);
}
public sealed class FloorPlanTransition
{
public int FloorPlanTransitionID { get; set; }
public int FloorPlanFloorID { get; set; }
public int FromLocationID { get; set; }
public string FromLocationName { get; set; } = string.Empty;
public string FromLocationPath { get; set; } = string.Empty;
public int ToLocationID { get; set; }
public string ToLocationName { get; set; } = string.Empty;
public string ToLocationPath { get; set; } = string.Empty;
public string TransitionType { get; set; } = FloorPlanTransitionTypes.Door;
public string? Notes { get; set; }
public bool IsLocked { get; set; }
public string? DisplayLabel { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
}
public sealed class SceneAssetLocation
{
public int SceneAssetLocationID { get; set; }

View File

@ -191,6 +191,8 @@ public interface IFloorPlanService
Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model);
Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId);
Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId);
Task<int> SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model);
Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId);
Task DeleteBlockAsync(int floorPlanBlockId);
}
@ -7576,6 +7578,7 @@ public sealed class FloorPlanService(
},
Floors = data.Floors.Select(ToFloorEdit).ToList(),
Blocks = data.Blocks.Select(ToBlockEdit).ToList(),
Transitions = data.Transitions.Select(ToTransitionEdit).ToList(),
NewFloor = new FloorPlanFloorEditViewModel
{
FloorPlanID = data.FloorPlan.FloorPlanID,
@ -7591,9 +7594,15 @@ public sealed class FloorPlanService(
WidthCells = 2,
HeightCells = 1
},
NewTransition = new FloorPlanTransitionEditViewModel
{
FloorPlanFloorID = data.Floors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
TransitionType = FloorPlanTransitionTypes.Door
},
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
Locations = projectLocations,
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList()
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList(),
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList()
};
}
@ -7711,6 +7720,37 @@ public sealed class FloorPlanService(
public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId);
public async Task<int> SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model)
{
var data = await floorPlans.GetEditorAsync(floorPlanId);
if (data.FloorPlan is null)
{
throw new InvalidOperationException("Floor plan not found.");
}
var floor = data.Floors.FirstOrDefault(x => x.FloorPlanFloorID == model.FloorPlanFloorID)
?? throw new InvalidOperationException("Floor not found.");
ValidateTransition(model, floor, data.Blocks, data.Transitions);
var transitionId = await floorPlans.SaveTransitionAsync(ToTransition(model));
await activity.RecordAsync(data.FloorPlan.ProjectID, model.FloorPlanTransitionID == 0 ? "Created" : "Updated", "Floor Plan Transition", transitionId, model.TransitionType);
return transitionId;
}
public async Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId)
{
var data = await floorPlans.GetEditorAsync(floorPlanId);
if (data.FloorPlan is null)
{
throw new InvalidOperationException("Floor plan not found.");
}
var transition = data.Transitions.FirstOrDefault(x => x.FloorPlanTransitionID == floorPlanTransitionId)
?? throw new InvalidOperationException("Transition not found.");
await floorPlans.DeleteTransitionAsync(floorPlanTransitionId);
await activity.RecordAsync(data.FloorPlan.ProjectID, "Deleted", "Floor Plan Transition", floorPlanTransitionId, transition.TransitionType);
}
public async Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload)
{
var (plan, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
@ -7819,6 +7859,49 @@ public sealed class FloorPlanService(
}
}
private static void ValidateTransition(
FloorPlanTransitionEditViewModel model,
FloorPlanFloor floor,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions)
{
if (!FloorPlanTransitionTypes.IsValid(model.TransitionType))
{
throw new InvalidOperationException("Choose a valid transition type.");
}
if (!model.FromLocationID.HasValue || !model.ToLocationID.HasValue)
{
throw new InvalidOperationException("Choose both transition locations.");
}
if (model.FromLocationID.Value == model.ToLocationID.Value)
{
throw new InvalidOperationException("From and To locations must be different.");
}
var floorLocationIds = blocks
.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID)
.Select(x => x.LocationID)
.ToHashSet();
if (!floorLocationIds.Contains(model.FromLocationID.Value) || !floorLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("Transition locations must both exist on the active floor.");
}
var duplicate = transitions.Any(x => x.FloorPlanFloorID == floor.FloorPlanFloorID
&& x.FloorPlanTransitionID != model.FloorPlanTransitionID
&& x.FromLocationID == model.FromLocationID.Value
&& x.ToLocationID == model.ToLocationID.Value
&& string.Equals(x.TransitionType, model.TransitionType, StringComparison.OrdinalIgnoreCase));
if (duplicate)
{
throw new InvalidOperationException("That transition already exists on this floor.");
}
}
private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId)
{
var plan = await floorPlans.GetAsync(floorPlanId)
@ -8019,6 +8102,34 @@ public sealed class FloorPlanService(
Notes = model.Notes
};
private static FloorPlanTransitionEditViewModel ToTransitionEdit(FloorPlanTransition transition) => new()
{
FloorPlanTransitionID = transition.FloorPlanTransitionID,
FloorPlanFloorID = transition.FloorPlanFloorID,
FromLocationID = transition.FromLocationID,
FromLocationName = transition.FromLocationName,
FromLocationPath = transition.FromLocationPath,
ToLocationID = transition.ToLocationID,
ToLocationName = transition.ToLocationName,
ToLocationPath = transition.ToLocationPath,
TransitionType = transition.TransitionType,
Notes = transition.Notes,
IsLocked = transition.IsLocked,
DisplayLabel = transition.DisplayLabel
};
private static FloorPlanTransition ToTransition(FloorPlanTransitionEditViewModel model) => new()
{
FloorPlanTransitionID = model.FloorPlanTransitionID,
FloorPlanFloorID = model.FloorPlanFloorID,
FromLocationID = model.FromLocationID!.Value,
ToLocationID = model.ToLocationID!.Value,
TransitionType = model.TransitionType,
Notes = model.Notes,
IsLocked = model.IsLocked,
DisplayLabel = model.DisplayLabel
};
private static string BlockTypeLabel(string blockType) => blockType switch
{
FloorPlanBlockTypes.OpenSpace => "Open space",

View File

@ -0,0 +1,189 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.FloorPlanTransitions', N'U') IS NULL
BEGIN
CREATE TABLE dbo.FloorPlanTransitions
(
FloorPlanTransitionID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FloorPlanTransitions PRIMARY KEY,
FloorPlanFloorID int NOT NULL,
FromLocationID int NOT NULL,
ToLocationID int NOT NULL,
TransitionType nvarchar(50) NOT NULL,
Notes nvarchar(max) NULL,
IsLocked bit NOT NULL CONSTRAINT DF_FloorPlanTransitions_IsLocked DEFAULT 0,
DisplayLabel nvarchar(255) NULL,
CreatedDate datetime2 NOT NULL CONSTRAINT DF_FloorPlanTransitions_CreatedDate DEFAULT SYSUTCDATETIME(),
ModifiedDate datetime2 NULL
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanTransitions_FloorPlanFloors')
BEGIN
ALTER TABLE dbo.FloorPlanTransitions
ADD CONSTRAINT FK_FloorPlanTransitions_FloorPlanFloors FOREIGN KEY (FloorPlanFloorID) REFERENCES dbo.FloorPlanFloors(FloorPlanFloorID) ON DELETE CASCADE;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanTransitions_FromLocations')
BEGIN
ALTER TABLE dbo.FloorPlanTransitions
ADD CONSTRAINT FK_FloorPlanTransitions_FromLocations FOREIGN KEY (FromLocationID) REFERENCES dbo.Locations(LocationID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_FloorPlanTransitions_ToLocations')
BEGIN
ALTER TABLE dbo.FloorPlanTransitions
ADD CONSTRAINT FK_FloorPlanTransitions_ToLocations FOREIGN KEY (ToLocationID) REFERENCES dbo.Locations(LocationID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanTransitions_Floor' AND object_id = OBJECT_ID(N'dbo.FloorPlanTransitions'))
BEGIN
CREATE INDEX IX_FloorPlanTransitions_Floor ON dbo.FloorPlanTransitions(FloorPlanFloorID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_FloorPlanTransitions_Locations' AND object_id = OBJECT_ID(N'dbo.FloorPlanTransitions'))
BEGIN
CREATE INDEX IX_FloorPlanTransitions_Locations ON dbo.FloorPlanTransitions(FloorPlanFloorID, FromLocationID, ToLocationID, TransitionType);
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlan_GetEditor
@FloorPlanID int
AS
BEGIN
SET NOCOUNT ON;
EXEC dbo.FloorPlan_Get @FloorPlanID;
SELECT
fpf.FloorPlanFloorID,
fpf.FloorPlanID,
fpf.LocationID,
lp.LocationName,
lp.LocationPath,
fpf.Name,
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.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 dbo.LocationPaths 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.CreatedDate,
fpt.ModifiedDate
FROM dbo.FloorPlanTransitions fpt
INNER JOIN dbo.FloorPlanFloors fpf ON fpf.FloorPlanFloorID = fpt.FloorPlanFloorID
INNER JOIN dbo.LocationPaths fromPath ON fromPath.LocationID = fpt.FromLocationID
INNER JOIN dbo.LocationPaths 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.FloorPlanTransition_Save
@FloorPlanTransitionID int,
@FloorPlanFloorID int,
@FromLocationID int,
@ToLocationID int,
@TransitionType nvarchar(50),
@Notes nvarchar(max) = NULL,
@IsLocked bit = 0,
@DisplayLabel nvarchar(255) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET @TransitionType = NULLIF(LTRIM(RTRIM(@TransitionType)), N'');
SET @DisplayLabel = NULLIF(LTRIM(RTRIM(@DisplayLabel)), N'');
IF @TransitionType IS NULL
THROW 51020, 'Transition type is required.', 1;
IF @FromLocationID = @ToLocationID
THROW 51021, 'From and To locations must be different.', 1;
IF @FloorPlanTransitionID = 0
BEGIN
INSERT dbo.FloorPlanTransitions (FloorPlanFloorID, FromLocationID, ToLocationID, TransitionType, Notes, IsLocked, DisplayLabel)
VALUES (@FloorPlanFloorID, @FromLocationID, @ToLocationID, @TransitionType, @Notes, @IsLocked, @DisplayLabel);
SELECT CONVERT(int, SCOPE_IDENTITY());
RETURN;
END;
UPDATE dbo.FloorPlanTransitions
SET FromLocationID = @FromLocationID,
ToLocationID = @ToLocationID,
TransitionType = @TransitionType,
Notes = @Notes,
IsLocked = @IsLocked,
DisplayLabel = @DisplayLabel,
ModifiedDate = SYSUTCDATETIME()
WHERE FloorPlanTransitionID = @FloorPlanTransitionID
AND FloorPlanFloorID = @FloorPlanFloorID;
SELECT @FloorPlanTransitionID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.FloorPlanTransition_Delete
@FloorPlanTransitionID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.FloorPlanTransitions
WHERE FloorPlanTransitionID = @FloorPlanTransitionID;
END;
GO

View File

@ -1844,11 +1844,14 @@ public sealed class FloorPlanEditorViewModel
public FloorPlanEditViewModel FloorPlan { get; set; } = new();
public List<FloorPlanFloorEditViewModel> Floors { get; set; } = [];
public List<FloorPlanBlockEditViewModel> Blocks { get; set; } = [];
public List<FloorPlanTransitionEditViewModel> Transitions { get; set; } = [];
public FloorPlanFloorEditViewModel NewFloor { get; set; } = new();
public FloorPlanBlockEditViewModel NewBlock { get; set; } = new();
public FloorPlanTransitionEditViewModel NewTransition { get; set; } = new();
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
public IReadOnlyList<LocationItem> Locations { get; set; } = [];
public IReadOnlyList<SelectListItem> BlockTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> TransitionTypeOptions { get; set; } = [];
}
public sealed class FloorPlanFloorEditViewModel
@ -1942,6 +1945,35 @@ public sealed class FloorPlanBlockEditViewModel
public string DisplayLabel => string.IsNullOrWhiteSpace(LabelOverride) ? LocationName : LabelOverride;
}
public sealed class FloorPlanTransitionEditViewModel
{
public int FloorPlanTransitionID { get; set; }
public int FloorPlanFloorID { get; set; }
[Required]
public int? FromLocationID { get; set; }
public string FromLocationName { get; set; } = string.Empty;
public string FromLocationPath { get; set; } = string.Empty;
[Required]
public int? ToLocationID { get; set; }
public string ToLocationName { get; set; } = string.Empty;
public string ToLocationPath { get; set; } = string.Empty;
[Required, StringLength(50)]
public string TransitionType { get; set; } = FloorPlanTransitionTypes.Door;
public string? Notes { get; set; }
public bool IsLocked { get; set; }
[StringLength(255)]
public string? DisplayLabel { get; set; }
public string Label => string.IsNullOrWhiteSpace(DisplayLabel) ? TransitionType : DisplayLabel;
}
public sealed class WarningDashboardViewModel
{
public Project Project { get; set; } = new();

View File

@ -4,9 +4,50 @@
ViewData["ProjectSection"] = "Floor Plans";
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 selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : Model.Blocks.FirstOrDefault()?.FloorPlanBlockID ?? 0;
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
bool RangesOverlap(int aStart, int aLength, int bStart, int bLength) => aStart < bStart + bLength && bStart < aStart + aLength;
string SharedWallClasses(FloorPlanBlockEditViewModel block, IReadOnlyList<FloorPlanBlockEditViewModel> floorBlocks)
{
var classes = new List<string>();
foreach (var other in floorBlocks)
{
if (other.FloorPlanBlockID == block.FloorPlanBlockID || other.LocationID != block.LocationID)
{
continue;
}
if (block.X + block.WidthCells == other.X && RangesOverlap(block.Y, block.HeightCells, other.Y, other.HeightCells))
{
classes.Add("merge-east");
}
if (other.X + other.WidthCells == block.X && RangesOverlap(block.Y, block.HeightCells, other.Y, other.HeightCells))
{
classes.Add("merge-west");
}
if (block.Y + block.HeightCells == other.Y && RangesOverlap(block.X, block.WidthCells, other.X, other.WidthCells))
{
classes.Add("merge-south");
}
if (other.Y + other.HeightCells == block.Y && RangesOverlap(block.X, block.WidthCells, other.X, other.WidthCells))
{
classes.Add("merge-north");
}
}
return string.Join(" ", classes.Distinct());
}
string TransitionIcon(string transitionType) => transitionType switch
{
FloorPlanTransitionTypes.Archway => "Arch",
FloorPlanTransitionTypes.StairsUp => "Up",
FloorPlanTransitionTypes.StairsDown => "Down",
FloorPlanTransitionTypes.Ladder => "Ldr",
FloorPlanTransitionTypes.Window => "Win",
_ => "Door"
};
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -128,6 +169,7 @@ else
var floor = Model.Floors[i];
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
var floorBlocks = blocksByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
var floorTransitions = transitionsByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
<div class="floor-plan-floor @(isActive ? "active" : string.Empty)" data-floor-panel="@floor.FloorPlanFloorID">
<div class="floor-plan-grid-wrap">
<div class="floor-plan-grid"
@ -147,16 +189,29 @@ else
{
var blockIndex = Model.Blocks.FindIndex(x => x.FloorPlanBlockID == block.FloorPlanBlockID);
<button type="button"
class="@BlockClass(block.BlockType)"
class="@BlockClass(block.BlockType) @SharedWallClasses(block, floorBlocks)"
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
data-block="@block.FloorPlanBlockID"
data-block-index="@blockIndex"
data-floor-id="@block.FloorPlanFloorID"
data-location-id="@block.LocationID"
title="@block.LocationPath">
<span class="floor-plan-block-label">@block.DisplayLabel</span>
<span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>
</button>
}
@foreach (var transition in floorTransitions)
{
var tooltip = $"{transition.FromLocationName} to {transition.ToLocationName}: {transition.TransitionType}";
<div class="floor-plan-transition-marker"
data-transition-marker="@transition.FloorPlanTransitionID"
data-from-location-id="@transition.FromLocationID"
data-to-location-id="@transition.ToLocationID"
title="@tooltip">
<span class="floor-plan-transition-icon">@TransitionIcon(transition.TransitionType)</span>
<span class="floor-plan-transition-label">@transition.Label</span>
</div>
}
</div>
</div>
</div>
@ -169,6 +224,7 @@ else
<button type="button" class="floor-plan-toolbox-tab active" id="floor-plan-tool-tab-add" role="tab" aria-selected="true" aria-controls="floor-plan-tool-add" data-toolbox-tab="add">Add block</button>
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-selected" role="tab" aria-selected="false" aria-controls="floor-plan-tool-selected" data-toolbox-tab="selected">Selected block</button>
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-background" role="tab" aria-selected="false" aria-controls="floor-plan-tool-background" data-toolbox-tab="background">Background</button>
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-transitions" role="tab" aria-selected="false" aria-controls="floor-plan-tool-transitions" data-toolbox-tab="transitions">Transitions</button>
<button type="button" class="floor-plan-toolbox-tab" id="floor-plan-tool-tab-floors" role="tab" aria-selected="false" aria-controls="floor-plan-tool-floors" data-toolbox-tab="floors">Floors</button>
</div>
<div class="floor-plan-toolbox-body">
@ -234,7 +290,7 @@ else
<input asp-for="Blocks[i].LocationPath" type="hidden" />
<div class="mb-2">
<label class="form-label">Location</label>
<select asp-for="Blocks[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"></select>
<select asp-for="Blocks[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm" data-block-field="location"></select>
</div>
<div class="mb-2">
<label class="form-label">Type</label>
@ -383,6 +439,100 @@ else
}
</section>
<section class="floor-plan-tool-card" id="floor-plan-tool-transitions" role="tabpanel" aria-labelledby="floor-plan-tool-tab-transitions" data-toolbox-panel="transitions" hidden>
<h2>Transitions</h2>
<p class="muted">Create doors, archways, stairs, and other movement paths for <span data-current-floor-name>@activeFloor?.Name</span>.</p>
@foreach (var floor in Model.Floors)
{
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
var floorBlocks = blocksByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
var floorLocationOptions = floorBlocks
.Where(x => x.LocationID.HasValue)
.GroupBy(x => x.LocationID!.Value)
.Select(x => x.First())
.OrderBy(x => x.LocationName)
.ToList();
var floorTransitions = transitionsByFloor.GetValueOrDefault(floor.FloorPlanFloorID) ?? [];
<div class="floor-plan-transition-editor @(isActive ? string.Empty : "d-none")" data-transition-editor="@floor.FloorPlanFloorID">
<div class="floor-plan-tool-subsection">
<h3>Create transition</h3>
@if (floorLocationOptions.Count < 2)
{
<p class="muted">Add at least two placed Locations on this floor before creating a transition.</p>
}
else
{
<input type="hidden" name="FloorPlanTransitionID" value="0" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
<input type="hidden" name="FloorPlanFloorID" value="@floor.FloorPlanFloorID" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
<div class="mb-2">
<label class="form-label" for="transition-from-@floor.FloorPlanFloorID">From Location</label>
<select id="transition-from-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="FromLocationID" form="floor-plan-transition-add-@floor.FloorPlanFloorID">
<option value="">Choose a Location</option>
@foreach (var location in floorLocationOptions)
{
<option value="@location.LocationID">@location.LocationPath</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label" for="transition-to-@floor.FloorPlanFloorID">To Location</label>
<select id="transition-to-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="ToLocationID" form="floor-plan-transition-add-@floor.FloorPlanFloorID">
<option value="">Choose a Location</option>
@foreach (var location in floorLocationOptions)
{
<option value="@location.LocationID">@location.LocationPath</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label" for="transition-type-@floor.FloorPlanFloorID">Transition Type</label>
<select id="transition-type-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="TransitionType" asp-items="Model.TransitionTypeOptions" form="floor-plan-transition-add-@floor.FloorPlanFloorID"></select>
</div>
<div class="mb-2">
<label class="form-label" for="transition-label-@floor.FloorPlanFloorID">Display Label</label>
<input id="transition-label-@floor.FloorPlanFloorID" class="form-control form-control-sm" name="DisplayLabel" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
</div>
<div class="mb-2">
<label class="form-label" for="transition-notes-@floor.FloorPlanFloorID">Notes</label>
<textarea id="transition-notes-@floor.FloorPlanFloorID" class="form-control form-control-sm" name="Notes" rows="2" form="floor-plan-transition-add-@floor.FloorPlanFloorID"></textarea>
</div>
<label class="floor-plan-checkbox">
<input type="checkbox" name="IsLocked" value="true" form="floor-plan-transition-add-@floor.FloorPlanFloorID" />
Locked
</label>
<button class="btn btn-primary btn-sm w-100 mt-2" type="submit" form="floor-plan-transition-add-@floor.FloorPlanFloorID">Create transition</button>
}
</div>
<div class="floor-plan-tool-subsection">
<h3>Existing transitions</h3>
@if (!floorTransitions.Any())
{
<p class="muted">No transitions on this floor yet.</p>
}
else
{
<div class="floor-plan-transition-list">
@foreach (var transition in floorTransitions)
{
<article class="floor-plan-transition-item">
<div>
<strong>@transition.FromLocationName to @transition.ToLocationName</strong>
<span>@transition.TransitionType</span>
</div>
<div class="button-row">
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#edit-transition-@transition.FloorPlanTransitionID">Edit</button>
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#delete-transition-@transition.FloorPlanTransitionID">Delete</button>
</div>
</article>
}
</div>
}
</div>
</div>
}
</section>
<section class="floor-plan-tool-card" id="floor-plan-tool-floors" role="tabpanel" aria-labelledby="floor-plan-tool-tab-floors" data-toolbox-panel="floors" hidden>
<h2>Floors</h2>
<div class="floor-plan-tool-subsection">
@ -471,6 +621,10 @@ else
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
</form>
<form id="floor-plan-transition-add-@floor.FloorPlanFloorID" asp-action="SaveTransition" method="post">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
</form>
}
@foreach (var floor in Model.Floors)
@ -526,6 +680,100 @@ else
</div>
}
@foreach (var transition in Model.Transitions)
{
var transitionFloorBlocks = blocksByFloor.GetValueOrDefault(transition.FloorPlanFloorID) ?? [];
var transitionLocationOptions = transitionFloorBlocks
.Where(x => x.LocationID.HasValue)
.GroupBy(x => x.LocationID!.Value)
.Select(x => x.First())
.OrderBy(x => x.LocationName)
.ToList();
<div class="modal fade" id="edit-transition-@transition.FloorPlanTransitionID" tabindex="-1" aria-labelledby="edit-transition-title-@transition.FloorPlanTransitionID" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="edit-transition-title-@transition.FloorPlanTransitionID">Edit transition</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form asp-action="SaveTransition" method="post">
<div class="modal-body">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="FloorPlanTransitionID" value="@transition.FloorPlanTransitionID" />
<input type="hidden" name="FloorPlanFloorID" value="@transition.FloorPlanFloorID" />
<div class="mb-2">
<label class="form-label">From Location</label>
<select class="form-select form-select-sm" name="FromLocationID">
@foreach (var location in transitionLocationOptions)
{
<option value="@location.LocationID" selected="@(location.LocationID == transition.FromLocationID)">@location.LocationPath</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label">To Location</label>
<select class="form-select form-select-sm" name="ToLocationID">
@foreach (var location in transitionLocationOptions)
{
<option value="@location.LocationID" selected="@(location.LocationID == transition.ToLocationID)">@location.LocationPath</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label">Transition Type</label>
<select class="form-select form-select-sm" name="TransitionType">
@foreach (var option in Model.TransitionTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == transition.TransitionType)">@option.Text</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label">Display Label</label>
<input class="form-control form-control-sm" name="DisplayLabel" value="@transition.DisplayLabel" />
</div>
<div class="mb-2">
<label class="form-label">Notes</label>
<textarea class="form-control form-control-sm" name="Notes" rows="3">@transition.Notes</textarea>
</div>
<label class="floor-plan-checkbox">
<input type="checkbox" name="IsLocked" value="true" @(transition.IsLocked ? "checked" : string.Empty) />
Locked
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Save transition</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="delete-transition-@transition.FloorPlanTransitionID" tabindex="-1" aria-labelledby="delete-transition-title-@transition.FloorPlanTransitionID" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content plotline-confirm-modal">
<div class="modal-header">
<h2 class="modal-title fs-5" id="delete-transition-title-@transition.FloorPlanTransitionID">Delete transition?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete the <strong>@transition.TransitionType</strong> transition from <strong>@transition.FromLocationName</strong> to <strong>@transition.ToLocationName</strong>?</p>
<p>Locations and blocks will not be deleted.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form asp-action="DeleteTransition" method="post" data-no-delete-confirm="true">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanTransitionId" value="@transition.FloorPlanTransitionID" />
<button class="btn btn-danger" type="submit">Delete transition</button>
</form>
</div>
</div>
</div>
</div>
}
@foreach (var block in Model.Blocks)
{
<div class="modal fade" id="delete-block-@block.FloorPlanBlockID" tabindex="-1" aria-labelledby="delete-block-title-@block.FloorPlanBlockID" aria-hidden="true">
@ -564,6 +812,7 @@ else
const panels = [...editor.querySelectorAll("[data-floor-panel]")];
const floorEditors = [...editor.querySelectorAll("[data-floor-editor]")];
const backgroundEditors = [...editor.querySelectorAll("[data-background-editor]")];
const transitionEditors = [...editor.querySelectorAll("[data-transition-editor]")];
const blocks = [...editor.querySelectorAll("[data-block]")];
const noSelection = editor.querySelector("[data-no-selection]");
const warning = editor.querySelector("[data-overlap-warning]");
@ -701,6 +950,11 @@ else
panel.classList.toggle("d-none", !active);
panel.hidden = !active;
});
transitionEditors.forEach(panel => {
const active = panel.dataset.transitionEditor === floorId;
panel.classList.toggle("d-none", !active);
panel.hidden = !active;
});
const activeTab = tabs.find(tab => tab.dataset.floorTab === floorId);
if (addBlockFloor) addBlockFloor.value = floorId;
if (activeTab) {
@ -817,6 +1071,7 @@ else
y: panel?.querySelector("[data-block-field='y']"),
w: panel?.querySelector("[data-block-field='w']"),
h: panel?.querySelector("[data-block-field='h']"),
location: panel?.querySelector("[data-block-field='location']"),
type: panel?.querySelector("[data-block-field='type']")
};
}
@ -860,6 +1115,51 @@ else
checkOverlaps();
}
function rangesOverlap(aStart, aLength, bStart, bLength) {
return aStart < bStart + bLength && bStart < aStart + aLength;
}
function refreshSharedWalls(activePanel) {
const visibleBlocks = [...activePanel.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) }));
visibleBlocks.forEach(item => item.block.classList.remove("merge-east", "merge-west", "merge-north", "merge-south"));
visibleBlocks.forEach(a => {
visibleBlocks.forEach(b => {
if (a === b || !a.block.dataset.locationId || a.block.dataset.locationId !== b.block.dataset.locationId) return;
if (a.x + a.w === b.x && rangesOverlap(a.y, a.h, b.y, b.h)) a.block.classList.add("merge-east");
if (b.x + b.w === a.x && rangesOverlap(a.y, a.h, b.y, b.h)) a.block.classList.add("merge-west");
if (a.y + a.h === b.y && rangesOverlap(a.x, a.w, b.x, b.w)) a.block.classList.add("merge-south");
if (b.y + b.h === a.y && rangesOverlap(a.x, a.w, b.x, b.w)) a.block.classList.add("merge-north");
});
});
return visibleBlocks;
}
function locationCenter(blocksForLocation) {
if (blocksForLocation.length === 0) return null;
const minX = Math.min(...blocksForLocation.map(item => item.x));
const minY = Math.min(...blocksForLocation.map(item => item.y));
const maxX = Math.max(...blocksForLocation.map(item => item.x + item.w));
const maxY = Math.max(...blocksForLocation.map(item => item.y + item.h));
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
}
function positionTransitions(activePanel, visibleBlocks) {
activePanel.querySelectorAll("[data-transition-marker]").forEach(marker => {
const fromBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.fromLocationId);
const toBlocks = visibleBlocks.filter(item => item.block.dataset.locationId === marker.dataset.toLocationId);
const fromCenter = locationCenter(fromBlocks);
const toCenter = locationCenter(toBlocks);
if (!fromCenter || !toCenter) {
marker.hidden = true;
return;
}
marker.hidden = false;
marker.style.setProperty("--transition-x", (fromCenter.x + toCenter.x) / 2);
marker.style.setProperty("--transition-y", (fromCenter.y + toCenter.y) / 2);
});
}
function cellFromPointer(grid, event) {
const rect = grid.getBoundingClientRect();
const gridWidth = Number(grid.dataset.gridWidth || 24);
@ -873,7 +1173,8 @@ else
function checkOverlaps() {
const activePanel = panels.find(panel => panel.classList.contains("active"));
if (!activePanel) return;
const visibleBlocks = [...activePanel.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) }));
const visibleBlocks = refreshSharedWalls(activePanel);
positionTransitions(activePanel, visibleBlocks);
let overlaps = false;
visibleBlocks.forEach(a => {
a.block.classList.remove("overlap");
@ -981,10 +1282,20 @@ else
});
});
editor.querySelectorAll("[data-block-field='location']").forEach(select => {
select.addEventListener("change", () => {
if (!selectedBlock) return;
selectedBlock.dataset.locationId = select.value || "";
checkOverlaps();
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;
if (input.matches("[data-background-field]")) return;
if (input.matches("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h'],[data-block-field='type']")) return;
if (input.matches("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h'],[data-block-field='type'],[data-block-field='location']")) return;
const eventName = input.tagName === "TEXTAREA" || input.type === "text" ? "input" : "change";
input.addEventListener(eventName, markDirty);
});

View File

@ -4662,11 +4662,67 @@ body.dragging-location [data-drag-type="location"] {
z-index: 3;
}
.floor-plan-block.merge-east {
border-right-color: transparent;
}
.floor-plan-block.merge-west {
border-left-color: transparent;
}
.floor-plan-block.merge-north {
border-top-color: transparent;
}
.floor-plan-block.merge-south {
border-bottom-color: transparent;
}
.floor-plan-block.selected.merge-east,
.floor-plan-block.selected.merge-west,
.floor-plan-block.selected.merge-north,
.floor-plan-block.selected.merge-south {
outline-offset: 0;
}
.floor-plan-block.overlap {
border-color: #b45544;
box-shadow: inset 0 0 0 2px rgba(180, 85, 68, .18);
}
.floor-plan-transition-marker {
align-items: center;
background: rgba(255, 250, 242, .94);
border: 1px solid rgba(47, 111, 99, .3);
border-radius: 999px;
box-shadow: 0 4px 12px rgba(31, 37, 34, .16);
color: #12332c;
display: inline-flex;
gap: 5px;
left: calc(var(--transition-x, 0) * var(--cell-size));
max-width: 150px;
padding: 3px 7px;
pointer-events: none;
position: absolute;
top: calc(var(--transition-y, 0) * var(--cell-size));
transform: translate(-50%, -50%);
z-index: 4;
}
.floor-plan-transition-icon {
font-size: .68rem;
font-weight: 900;
line-height: 1;
}
.floor-plan-transition-label {
font-size: .68rem;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.floor-plan-block--room {
background: #d9eee7;
}
@ -4726,7 +4782,7 @@ body.dragging-location [data-drag-type="location"] {
.floor-plan-toolbox-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #d8ded9);
}
@ -4808,6 +4864,29 @@ body.dragging-location [data-drag-type="location"] {
text-align: right;
}
.floor-plan-transition-list {
display: grid;
gap: 8px;
}
.floor-plan-transition-item {
border: 1px solid rgba(47, 111, 99, .14);
border-radius: 8px;
display: grid;
gap: 8px;
padding: 9px;
}
.floor-plan-transition-item strong,
.floor-plan-transition-item span {
display: block;
font-size: .84rem;
}
.floor-plan-transition-item span {
color: var(--muted-text, #5d6f65);
}
.floor-plan-checkbox {
align-items: center;
display: inline-flex;
@ -4872,6 +4951,18 @@ body.dragging-location [data-drag-type="location"] {
color: #f1f6f3;
}
[data-bs-theme="dark"] .floor-plan-transition-marker,
.dark .floor-plan-transition-marker {
background: rgba(42, 35, 30, .95);
border-color: rgba(212, 154, 98, .42);
color: #f4eadc;
}
[data-bs-theme="dark"] .floor-plan-transition-item,
.dark .floor-plan-transition-item {
border-color: rgba(212, 154, 98, .22);
}
[data-bs-theme="dark"] .floor-plan-block--room,
.dark .floor-plan-block--room {
background: #29584f;

File diff suppressed because one or more lines are too long