Implemented the next Floor Plans enhancement pass.

This commit is contained in:
Nick Beckley 2026-06-21 21:18:14 +01:00
parent 9fc4374df3
commit f57913014c
6 changed files with 490 additions and 41 deletions

View File

@ -663,7 +663,8 @@ public sealed class FloorPlanRepository(ISqlConnectionFactory connectionFactory)
transition.TransitionType,
transition.Notes,
transition.IsLocked,
transition.DisplayLabel
transition.DisplayLabel,
transition.PositionWithinLocation
},
commandType: CommandType.StoredProcedure);
}

View File

@ -1201,8 +1201,12 @@ public static class FloorPlanTransitionTypes
public const string DoubleDoor = "Double Door";
public const string Archway = "Archway";
public const string OpenPassage = "Open Passage";
public const string HiddenPassage = "Hidden Passage";
public const string FrenchDoors = "French Doors";
public const string SlidingDoors = "Sliding Doors";
public const string ExteriorDoor = "Exterior Door";
public const string HiddenDoor = "Hidden Door";
public const string BlockedDoor = "Blocked Door";
public const string StairsUp = "Stairs Up";
public const string StairsDown = "Stairs Down";
public const string Ladder = "Ladder";
@ -1215,8 +1219,12 @@ public static class FloorPlanTransitionTypes
DoubleDoor,
Archway,
OpenPassage,
HiddenPassage,
FrenchDoors,
SlidingDoors,
ExteriorDoor,
HiddenDoor,
BlockedDoor,
StairsUp,
StairsDown,
Ladder,
@ -1224,7 +1232,44 @@ public static class FloorPlanTransitionTypes
Window
];
public static bool IsValid(string? transitionType) => All.Contains(transitionType ?? string.Empty, StringComparer.OrdinalIgnoreCase);
public static bool IsValid(string? transitionType)
{
if (All.Contains(transitionType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
return true;
}
var normalized = new string((transitionType ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
return normalized is "frenchdoor" or "slidingdoor";
}
}
public static class FloorPlanTransitionPositions
{
public const string Centre = "Centre";
public const string North = "North";
public const string South = "South";
public const string East = "East";
public const string West = "West";
public const string NorthWest = "North West";
public const string NorthEast = "North East";
public const string SouthWest = "South West";
public const string SouthEast = "South East";
public static readonly IReadOnlyList<string> All =
[
Centre,
North,
South,
East,
West,
NorthWest,
NorthEast,
SouthWest,
SouthEast
];
public static bool IsValid(string? position) => All.Contains(position ?? string.Empty, StringComparer.OrdinalIgnoreCase);
}
public sealed class FloorPlanTransition
@ -1241,6 +1286,7 @@ public sealed class FloorPlanTransition
public string? Notes { get; set; }
public bool IsLocked { get; set; }
public string? DisplayLabel { get; set; }
public string PositionWithinLocation { get; set; } = FloorPlanTransitionPositions.Centre;
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedDate { get; set; }
}

View File

@ -7598,7 +7598,8 @@ public sealed class FloorPlanService(
NewTransition = new FloorPlanTransitionEditViewModel
{
FloorPlanFloorID = orderedFloors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
TransitionType = FloorPlanTransitionTypes.Door
TransitionType = FloorPlanTransitionTypes.Door,
PositionWithinLocation = FloorPlanTransitionPositions.Centre
},
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
Locations = projectLocations,
@ -7732,7 +7733,11 @@ public sealed class FloorPlanService(
var floor = data.Floors.FirstOrDefault(x => x.FloorPlanFloorID == model.FloorPlanFloorID)
?? throw new InvalidOperationException("Floor not found.");
ValidateTransition(model, floor, data.Blocks, data.Transitions);
ValidateTransition(model, floor, data.Blocks, data.Transitions, data.Floors);
if (!IsStairTransition(model.TransitionType))
{
model.PositionWithinLocation = FloorPlanTransitionPositions.Centre;
}
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;
@ -7860,11 +7865,43 @@ public sealed class FloorPlanService(
}
}
private static string NormalizeTransitionType(string? transitionType)
=> new((transitionType ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private static bool IsSameFloorAdjacentTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "door"
or "doubledoor"
or "frenchdoor"
or "frenchdoors"
or "slidingdoor"
or "slidingdoors"
or "window"
or "archway"
or "exteriordoor"
or "hiddendoor"
or "blockeddoor";
private static bool IsUnrestrictedTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "hiddenpassage" or "openpassage" or "secretpassage";
private static bool IsStairTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "stairsup" or "stairsdown";
private static bool BlocksTouchAlongEdge(FloorPlanBlock a, FloorPlanBlock b)
{
var verticalTouch = a.X + a.WidthCells == b.X || b.X + b.WidthCells == a.X;
var verticalOverlap = a.Y < b.Y + b.HeightCells && b.Y < a.Y + a.HeightCells;
var horizontalTouch = a.Y + a.HeightCells == b.Y || b.Y + b.HeightCells == a.Y;
var horizontalOverlap = a.X < b.X + b.WidthCells && b.X < a.X + a.WidthCells;
return (verticalTouch && verticalOverlap) || (horizontalTouch && horizontalOverlap);
}
private static void ValidateTransition(
FloorPlanTransitionEditViewModel model,
FloorPlanFloor floor,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions)
IReadOnlyList<FloorPlanTransition> transitions,
IReadOnlyList<FloorPlanFloor> floors)
{
if (!FloorPlanTransitionTypes.IsValid(model.TransitionType))
{
@ -7881,16 +7918,65 @@ public sealed class FloorPlanService(
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))
if (!FloorPlanTransitionPositions.IsValid(model.PositionWithinLocation))
{
throw new InvalidOperationException("Transition locations must both exist on the active floor.");
throw new InvalidOperationException("Choose a valid stair position.");
}
var currentFloorBlocks = blocks
.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID)
.ToList();
var floorLocationIds = currentFloorBlocks.Select(x => x.LocationID).ToHashSet();
if (!floorLocationIds.Contains(model.FromLocationID.Value))
{
throw new InvalidOperationException("From Location must exist on the active floor.");
}
if (IsStairTransition(model.TransitionType))
{
var targetLevel = floor.SortOrder + (NormalizeTransitionType(model.TransitionType) == "stairsup" ? 1 : -1);
var targetFloorIds = floors.Where(x => x.SortOrder == targetLevel).Select(x => x.FloorPlanFloorID).ToHashSet();
var targetLocationIds = blocks
.Where(x => targetFloorIds.Contains(x.FloorPlanFloorID))
.Select(x => x.LocationID)
.ToHashSet();
if (!targetLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("Stairs must connect to a location on the adjacent floor level.");
}
return;
}
if (IsUnrestrictedTransition(model.TransitionType))
{
var planLocationIds = blocks.Select(x => x.LocationID).ToHashSet();
if (!planLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("To Location must exist in this floor plan.");
}
return;
}
if (!IsSameFloorAdjacentTransition(model.TransitionType))
{
throw new InvalidOperationException("Choose a valid transition type.");
}
if (!floorLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("To Location must exist on the active floor.");
}
var fromBlocks = currentFloorBlocks.Where(x => x.LocationID == model.FromLocationID.Value).ToList();
var toBlocks = currentFloorBlocks.Where(x => x.LocationID == model.ToLocationID.Value).ToList();
if (!fromBlocks.Any(from => toBlocks.Any(to => BlocksTouchAlongEdge(from, to))))
{
throw new InvalidOperationException("Door and window transitions must connect adjacent locations.");
}
}
private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId)
@ -8111,7 +8197,10 @@ public sealed class FloorPlanService(
TransitionType = transition.TransitionType,
Notes = transition.Notes,
IsLocked = transition.IsLocked,
DisplayLabel = transition.DisplayLabel
DisplayLabel = transition.DisplayLabel,
PositionWithinLocation = string.IsNullOrWhiteSpace(transition.PositionWithinLocation)
? FloorPlanTransitionPositions.Centre
: transition.PositionWithinLocation
};
private static FloorPlanTransition ToTransition(FloorPlanTransitionEditViewModel model) => new()
@ -8123,7 +8212,10 @@ public sealed class FloorPlanService(
TransitionType = model.TransitionType,
Notes = model.Notes,
IsLocked = model.IsLocked,
DisplayLabel = model.DisplayLabel
DisplayLabel = model.DisplayLabel,
PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation)
? FloorPlanTransitionPositions.Centre
: model.PositionWithinLocation
};
private static string BlockTypeLabel(string blockType) => blockType switch

View File

@ -0,0 +1,145 @@
IF COL_LENGTH(N'dbo.FloorPlanTransitions', N'PositionWithinLocation') IS NULL
BEGIN
ALTER TABLE dbo.FloorPlanTransitions
ADD PositionWithinLocation nvarchar(20) NOT NULL
CONSTRAINT DF_FloorPlanTransitions_PositionWithinLocation DEFAULT N'Centre';
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.PositionWithinLocation,
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,
@PositionWithinLocation nvarchar(20) = N'Centre'
AS
BEGIN
SET NOCOUNT ON;
IF @FloorPlanTransitionID = 0
BEGIN
INSERT dbo.FloorPlanTransitions
(
FloorPlanFloorID,
FromLocationID,
ToLocationID,
TransitionType,
Notes,
IsLocked,
DisplayLabel,
PositionWithinLocation
)
VALUES
(
@FloorPlanFloorID,
@FromLocationID,
@ToLocationID,
@TransitionType,
@Notes,
@IsLocked,
@DisplayLabel,
COALESCE(NULLIF(@PositionWithinLocation, N''), N'Centre')
);
SELECT CONVERT(int, SCOPE_IDENTITY()) AS FloorPlanTransitionID;
RETURN;
END;
UPDATE dbo.FloorPlanTransitions
SET
FromLocationID = @FromLocationID,
ToLocationID = @ToLocationID,
TransitionType = @TransitionType,
Notes = @Notes,
IsLocked = @IsLocked,
DisplayLabel = @DisplayLabel,
PositionWithinLocation = COALESCE(NULLIF(@PositionWithinLocation, N''), N'Centre'),
ModifiedDate = SYSUTCDATETIME()
WHERE FloorPlanTransitionID = @FloorPlanTransitionID
AND FloorPlanFloorID = @FloorPlanFloorID;
SELECT @FloorPlanTransitionID AS FloorPlanTransitionID;
END;
GO

View File

@ -1975,6 +1975,9 @@ public sealed class FloorPlanTransitionEditViewModel
[StringLength(255)]
public string? DisplayLabel { get; set; }
[StringLength(20)]
public string PositionWithinLocation { get; set; } = FloorPlanTransitionPositions.Centre;
public string Label => string.IsNullOrWhiteSpace(DisplayLabel) ? TransitionType : DisplayLabel;
}

View File

@ -28,10 +28,10 @@
"doubledoor" => "double-door",
"window" => "window",
"archway" or "opening" => "archway",
"secretdoor" => "secret-door",
"secretdoor" or "hiddendoor" => "secret-door",
"blockeddoor" => "blocked-door",
"exteriordoor" => "exterior-door",
"stairs" or "stair" => "stairs",
"stairs" or "stair" or "stairsup" or "stairsdown" => "stairs",
"passage" => "passage",
_ => "other"
};
@ -50,6 +50,10 @@
"passage" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M3 7h12""/><path class=""marker-line"" d=""M3 11h12""/><path class=""marker-line"" d=""M11 5l4 4-4 4""/><path class=""marker-line"" d=""M7 5L3 9l4 4""/></svg>",
_ => @"<span aria-hidden=""true"">x</span>"
};
var transitionPositions = FloorPlanTransitionPositions.All
.Select(x => new SelectListItem(x, x))
.ToList();
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -159,7 +163,7 @@
@foreach (var floor in Model.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name))
{
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name" data-floor-location-id="@floor.LocationID">@floor.Name</button>
<button type="button" class="floor-plan-tab @(isActive ? "active" : string.Empty)" data-floor-tab="@floor.FloorPlanFloorID" data-floor-name="@floor.Name" data-floor-order="@floor.SortOrder" data-floor-location-id="@floor.LocationID">@floor.Name</button>
}
</div>
<div class="button-row">
@ -177,7 +181,7 @@
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-floor @(isActive ? "active" : string.Empty)" data-floor-panel="@floor.FloorPlanFloorID" data-floor-order="@floor.SortOrder">
<div class="floor-plan-grid-wrap">
<div class="floor-plan-grid"
style="--grid-width:@floor.GridWidth;--grid-height:@floor.GridHeight;"
@ -202,6 +206,8 @@
data-block-index="@blockIndex"
data-floor-id="@block.FloorPlanFloorID"
data-location-id="@block.LocationID"
data-location-name="@block.LocationName"
data-location-path="@block.LocationPath"
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>
@ -216,6 +222,8 @@
data-from-location-id="@transition.FromLocationID"
data-to-location-id="@transition.ToLocationID"
data-transition-type="@markerType"
data-transition-kind="@transition.TransitionType"
data-position-within-location="@transition.PositionWithinLocation"
title="@transitionDetails"
aria-label="@transitionDetails">
@Html.Raw(TransitionMarkerSvg(markerType))
@ -472,17 +480,21 @@
</h3>
<div id="transition-create-@floor.FloorPlanFloorID" class="accordion-collapse collapse show" aria-labelledby="transition-create-heading-@floor.FloorPlanFloorID" data-bs-parent="#transition-accordion-@floor.FloorPlanFloorID">
<div class="accordion-body">
@if (floorLocationOptions.Count < 2)
@if (floorLocationOptions.Count < 1)
{
<p class="muted mb-0">Add at least two placed Locations on this floor before creating a transition.</p>
<p class="muted mb-0">Add at least one placed Location 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-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" data-transition-type-select="@floor.FloorPlanFloorID"></select>
</div>
<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">
<select id="transition-from-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="FromLocationID" form="floor-plan-transition-add-@floor.FloorPlanFloorID" data-transition-from-select="@floor.FloorPlanFloorID">
<option value="">Choose a Location</option>
@foreach (var location in floorLocationOptions)
{
@ -492,17 +504,14 @@
</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 id="transition-to-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="ToLocationID" form="floor-plan-transition-add-@floor.FloorPlanFloorID" data-transition-to-select="@floor.FloorPlanFloorID">
<option value="">Choose a From Location first</option>
</select>
<div class="form-text" data-transition-to-message="@floor.FloorPlanFloorID"></div>
</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 class="mb-2 d-none" data-transition-position-panel="@floor.FloorPlanFloorID">
<label class="form-label" for="transition-position-@floor.FloorPlanFloorID">Position Within Location</label>
<select id="transition-position-@floor.FloorPlanFloorID" class="form-select form-select-sm" name="PositionWithinLocation" asp-items="transitionPositions" 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>
@ -605,7 +614,7 @@
</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="@floor.FloorPlanFloorID" />
<input asp-for="Floors[i].SortOrder" class="form-control form-control-sm" min="-10" max="50" step="1" data-floor-level-input="@floor.FloorPlanFloorID" />
<div class="form-text">Used to order floors and later to connect stairs up/down.</div>
</div>
<p class="muted">Location: @(string.IsNullOrWhiteSpace(floor.LocationPath) ? "A child Location will be created or linked on save." : floor.LocationPath)</p>
@ -720,8 +729,7 @@
@foreach (var transition in Model.Transitions)
{
var transitionFloorBlocks = blocksByFloor.GetValueOrDefault(transition.FloorPlanFloorID) ?? [];
var transitionLocationOptions = transitionFloorBlocks
var transitionLocationOptions = Model.Blocks
.Where(x => x.LocationID.HasValue)
.GroupBy(x => x.LocationID!.Value)
.Select(x => x.First())
@ -759,13 +767,22 @@
</div>
<div class="mb-2">
<label class="form-label">Transition Type</label>
<select class="form-select form-select-sm" name="TransitionType">
<select class="form-select form-select-sm" name="TransitionType" data-edit-transition-type>
@foreach (var option in Model.TransitionTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == transition.TransitionType)">@option.Text</option>
}
</select>
</div>
<div class="mb-2" data-edit-transition-position-panel>
<label class="form-label">Position Within Location</label>
<select class="form-select form-select-sm" name="PositionWithinLocation">
@foreach (var option in transitionPositions)
{
<option value="@option.Value" selected="@(option.Value == transition.PositionWithinLocation)">@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" />
@ -1489,6 +1506,116 @@
return aStart < bStart + bLength && bStart < aStart + aLength;
}
function normalizeTransitionType(type) {
return (type || "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
function isStairTransition(type) {
return normalizeTransitionType(type) === "stairsup" || normalizeTransitionType(type) === "stairsdown";
}
function isUnrestrictedTransition(type) {
return ["hiddenpassage", "openpassage", "secretpassage"].includes(normalizeTransitionType(type));
}
function blocksTouchAlongEdge(a, b) {
const verticalTouch = a.x + a.w === b.x || b.x + b.w === a.x;
const verticalOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
const horizontalTouch = a.y + a.h === b.y || b.y + b.h === a.y;
const horizontalOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
return (verticalTouch && verticalOverlap) || (horizontalTouch && horizontalOverlap);
}
function blockItemsForFloor(floorId) {
return [...editor.querySelectorAll(`[data-block][data-floor-id="${floorId}"]`)].map(block => ({ block, ...readBlock(block) }));
}
function locationOptionsFromBlocks(blockItems) {
const byLocation = new Map();
blockItems.forEach(item => {
const id = item.block.dataset.locationId;
if (!id || byLocation.has(id)) return;
byLocation.set(id, {
id,
label: item.block.dataset.locationPath || item.block.dataset.locationName || item.block.textContent.trim()
});
});
return [...byLocation.values()].sort((a, b) => a.label.localeCompare(b.label));
}
function setTransitionOptions(select, options, messageElement, emptyMessage) {
const previous = select.value;
select.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = options.length ? "Choose a Location" : emptyMessage;
select.append(placeholder);
options.forEach(option => {
const element = document.createElement("option");
element.value = option.id;
element.textContent = option.label;
element.selected = option.id === previous;
select.append(element);
});
select.disabled = options.length === 0;
if (messageElement) {
messageElement.textContent = options.length ? "" : emptyMessage;
}
}
function refreshTransitionDestination(floorId) {
const typeSelect = editor.querySelector(`[data-transition-type-select="${floorId}"]`);
const fromSelect = editor.querySelector(`[data-transition-from-select="${floorId}"]`);
const toSelect = editor.querySelector(`[data-transition-to-select="${floorId}"]`);
const message = editor.querySelector(`[data-transition-to-message="${floorId}"]`);
const positionPanel = editor.querySelector(`[data-transition-position-panel="${floorId}"]`);
if (!typeSelect || !fromSelect || !toSelect) return;
const type = typeSelect.value;
const fromLocationId = fromSelect.value;
const stair = isStairTransition(type);
positionPanel?.classList.toggle("d-none", !stair);
if (!fromLocationId) {
setTransitionOptions(toSelect, [], message, "Choose a From Location first.");
return;
}
let options = [];
let emptyMessage = "No available locations.";
if (stair) {
const currentFloor = tabs.find(tab => tab.dataset.floorTab === String(floorId));
const currentLevel = Number(currentFloor?.dataset.floorOrder || 0);
const targetLevel = currentLevel + (normalizeTransitionType(type) === "stairsup" ? 1 : -1);
const targetFloor = tabs.find(tab => Number(tab.dataset.floorOrder || 0) === targetLevel);
emptyMessage = normalizeTransitionType(type) === "stairsup"
? "No floor exists above this level."
: "No floor exists below this level.";
if (targetFloor) {
options = locationOptionsFromBlocks(blockItemsForFloor(targetFloor.dataset.floorTab))
.filter(option => option.id !== fromLocationId);
emptyMessage = options.length ? "" : "No locations are placed on the adjacent floor.";
}
} else if (isUnrestrictedTransition(type)) {
options = locationOptionsFromBlocks([...editor.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) })))
.filter(option => option.id !== fromLocationId);
} else {
const floorBlocks = blockItemsForFloor(floorId);
const fromBlocks = floorBlocks.filter(item => item.block.dataset.locationId === fromLocationId);
const adjacentIds = new Set();
fromBlocks.forEach(from => {
floorBlocks.forEach(to => {
const toLocationId = to.block.dataset.locationId;
if (!toLocationId || toLocationId === fromLocationId) return;
if (blocksTouchAlongEdge(from, to)) adjacentIds.add(toLocationId);
});
});
options = locationOptionsFromBlocks(floorBlocks).filter(option => adjacentIds.has(option.id));
emptyMessage = "No adjacent locations are available.";
}
setTransitionOptions(toSelect, options, message, emptyMessage);
}
function subtractRange(segments, start, end) {
return segments.flatMap(segment => {
const clippedStart = Math.max(segment.start, start);
@ -1618,28 +1745,49 @@
return best;
}
function positionWithinLocation(blocksForLocation, position) {
const center = locationCenter(blocksForLocation);
if (!center) 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));
const inset = 0.35;
const normalized = (position || "Centre").toLowerCase().replace(/\s+/g, "");
const x = normalized.includes("west") ? minX + inset : normalized.includes("east") ? maxX - inset : center.x;
const y = normalized.includes("north") ? minY + inset : normalized.includes("south") ? maxY - inset : center.y;
return { x, y };
}
function positionTransitions(activePanel, visibleBlocks) {
const markerPositions = [];
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);
if (fromBlocks.length === 0 || toBlocks.length === 0) {
if (fromBlocks.length === 0 || (!isStairTransition(marker.dataset.transitionKind) && toBlocks.length === 0)) {
marker.hidden = true;
return;
}
const sharedEdge = sharedTransitionEdge(fromBlocks, toBlocks);
const stairPlacement = isStairTransition(marker.dataset.transitionKind)
? positionWithinLocation(fromBlocks, marker.dataset.positionWithinLocation)
: null;
const sharedEdge = stairPlacement ? null : sharedTransitionEdge(fromBlocks, toBlocks);
const fromCenter = locationCenter(fromBlocks);
const toCenter = locationCenter(toBlocks);
marker.hidden = false;
marker.classList.toggle("is-shared-edge", !!sharedEdge);
marker.classList.toggle("is-inside-location", !!stairPlacement);
marker.classList.toggle("is-horizontal", sharedEdge?.orientation === "horizontal");
marker.classList.toggle("is-vertical", sharedEdge?.orientation === "vertical");
const pairKey = [marker.dataset.fromLocationId, marker.dataset.toLocationId].sort().join("-");
const key = sharedEdge
const key = stairPlacement
? `${pairKey}:stair:${marker.dataset.positionWithinLocation || "Centre"}:${stairPlacement.x}:${stairPlacement.y}`
: sharedEdge
? `${pairKey}:${sharedEdge.orientation}:${sharedEdge.x}:${sharedEdge.y}:${sharedEdge.start}:${sharedEdge.end}`
: `${pairKey}:fallback`;
markerPositions.push({ marker, sharedEdge, fromCenter, toCenter, key });
markerPositions.push({ marker, stairPlacement, sharedEdge, fromCenter, toCenter, key });
});
const groups = markerPositions.reduce((map, item) => {
@ -1653,7 +1801,10 @@
const fraction = (index + 1) / (group.length + 1);
let x;
let y;
if (item.sharedEdge?.orientation === "vertical") {
if (item.stairPlacement) {
x = item.stairPlacement.x;
y = item.stairPlacement.y;
} else if (item.sharedEdge?.orientation === "vertical") {
x = item.sharedEdge.x;
y = item.sharedEdge.start + item.sharedEdge.length * fraction;
} else if (item.sharedEdge?.orientation === "horizontal") {
@ -1723,6 +1874,17 @@
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
editor.querySelectorAll("[data-transition-type-select],[data-transition-from-select]").forEach(input => {
const floorId = input.dataset.transitionTypeSelect || input.dataset.transitionFromSelect;
input.addEventListener("change", () => refreshTransitionDestination(floorId));
refreshTransitionDestination(floorId);
});
document.querySelectorAll("[data-edit-transition-type]").forEach(select => {
const panel = select.closest(".modal-body")?.querySelector("[data-edit-transition-position-panel]");
const refreshPositionPanel = () => panel?.classList.toggle("d-none", !isStairTransition(select.value));
select.addEventListener("change", refreshPositionPanel);
refreshPositionPanel();
});
addBlockForm?.addEventListener("submit", addBlockAsync);
editor.addEventListener("floor-plan-restore-state", event => restoreWorkspaceState(event.detail || {}));
workspace?.addEventListener("submit", async event => {
@ -1848,7 +2010,7 @@
});
});
editor.querySelectorAll("[data-floor-level]").forEach(input => {
editor.querySelectorAll("[data-floor-level-input]").forEach(input => {
const updateFloorLevel = (clampValue = false) => {
const parsed = Number(input.value);
if (!Number.isFinite(parsed)) return;
@ -1866,7 +2028,7 @@
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-floor-width],[data-floor-height],[data-floor-level]")) return;
if (input.matches("[data-floor-width],[data-floor-height],[data-floor-level-input]")) 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);