Implemented the next Floor Plans enhancement pass.
This commit is contained in:
parent
9fc4374df3
commit
f57913014c
@ -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);
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
145
PlotLine/Sql/077_FloorPlanStairTransitionPlacement.sql
Normal file
145
PlotLine/Sql/077_FloorPlanStairTransitionPlacement.sql
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user