Nick Beckley 92ab8f3202 Done. The Selected Block editor now uses grid-style coordinate dropdowns in [Edit.cshtml (line 423)](C:/Source/PlotLine/PlotLine/Views/FloorPlans/Edit.cshtml:423).
What changed:
X is now a dropdown showing A, B, C, AA, etc., while still saving 0, 1, 2, etc.
Y is now a dropdown showing 1, 2, 3, etc., while still saving 0, 1, 2, etc.
Options are limited to valid positions for the active floor and current block size.
Dragging, manual dropdown changes, block resizing, and grid resizing all keep the controls synced through the existing block write/clamp path.
Verification: dotnet build PlotLine.slnx passes with 0 warnings and 0 errors. No database, rendering model, transition logic, or AJAX architecture changes.
2026-06-22 14:43:09 +01:00

2659 lines
160 KiB
Plaintext

@model FloorPlanEditorViewModel
@{
ViewData["Title"] = Model.FloorPlan.FloorPlanID == 0 ? "New Floor Plan" : Model.FloorPlan.Name;
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 firstBlockIdByFloorLocation = Model.Blocks
.Where(x => x.LocationID.HasValue)
.GroupBy(x => $"{x.FloorPlanFloorID}:{x.LocationID!.Value}")
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First().FloorPlanBlockID);
var occupancyTokensByFloorLocation = Model.OccupancyTokens
.GroupBy(x => $"{x.SceneID}:{x.FloorPlanFloorID}:{x.LocationID}")
.ToDictionary(x => x.Key, x => x.OrderBy(token => token.EntityType == "Character" ? 0 : 1).ThenBy(token => token.DisplayName).ToList());
var occupancyTokenCountByScene = Model.OccupancyTokens
.GroupBy(x => x.SceneID)
.ToDictionary(x => x.Key, x => x.Count());
var occupancyTokenCountBySceneFloor = Model.OccupancyTokens
.GroupBy(x => $"{x.SceneID}:{x.FloorPlanFloorID}")
.ToDictionary(x => x.Key, x => x.Count());
var activeOccupancyScene = Model.OccupancyScenes.FirstOrDefault();
var selectedBlockId = int.TryParse(Context.Request.Query["selectedBlockId"], out var parsedSelectedBlockId) ? parsedSelectedBlockId : 0;
string BlockClass(string blockType) => $"floor-plan-block floor-plan-block--{blockType.ToLowerInvariant()}";
string CssNumber(decimal value) => value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
string TransitionDetails(FloorPlanTransitionEditViewModel transition)
{
var details = $"{transition.FromLocationName} -> {transition.ToLocationName}\n{transition.TransitionType}";
return string.IsNullOrWhiteSpace(transition.DisplayLabel) ? details : $"{details}\n{transition.DisplayLabel}";
}
string TransitionMarkerType(string? transitionType)
{
var normalized = (transitionType ?? string.Empty)
.Trim()
.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
.ToLowerInvariant();
return normalized switch
{
"door" => "door",
"doubledoor" => "double-door",
"frenchdoor" or "frenchdoors" => "french-door",
"slidingdoor" or "slidingdoors" => "sliding-door",
"window" => "window",
"archway" or "opening" => "archway",
"secretdoor" or "hiddendoor" => "secret-door",
"blockeddoor" => "blocked-door",
"exteriordoor" => "exterior-door",
"stairs" or "stair" or "stairsup" or "stairsdown" => "stairs",
"ladder" or "ladders" => "ladder",
"passage" or "openpassage" => "passage",
"hiddenpassage" or "secretpassage" => "secret-route",
_ => "other"
};
}
string TransitionMarkerSvg(string markerType) => markerType switch
{
"door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h6""/><path class=""marker-line"" d=""M10 9h6""/><path class=""marker-arc"" d=""M8 9a6 6 0 0 1 6-6""/></svg>",
"double-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h4""/><path class=""marker-line"" d=""M12 9h4""/><path class=""marker-arc"" d=""M6 9a4 4 0 0 1 4-4""/><path class=""marker-arc"" d=""M12 9a4 4 0 0 0-4-4""/></svg>",
"french-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line marker-strong"" d=""M2 9h3""/><path class=""marker-line marker-strong"" d=""M13 9h3""/><path class=""marker-arc"" d=""M5 9a4 4 0 0 1 4-4""/><path class=""marker-arc"" d=""M13 9a4 4 0 0 0-4-4""/><path class=""marker-line"" d=""M9 5v8""/></svg>",
"sliding-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h14""/><path class=""marker-line marker-track"" d=""M4 6h10""/><path class=""marker-line marker-track"" d=""M9 6l3 3""/></svg>",
"window" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-window"" d=""M3 7h12""/><path class=""marker-window"" d=""M3 11h12""/><path class=""marker-line"" d=""M5 9h8""/></svg>",
"archway" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 14h4""/><path class=""marker-line"" d=""M12 14h4""/><path class=""marker-arc"" d=""M6 14a3 3 0 0 1 6 0""/></svg>",
"secret-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line marker-dotted"" d=""M2 9h6""/><path class=""marker-line marker-dotted"" d=""M10 9h6""/><path class=""marker-arc marker-dotted"" d=""M8 9a6 6 0 0 1 6-6""/></svg>",
"blocked-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M2 9h14""/><path class=""marker-blocked"" d=""M6 5l6 8""/><path class=""marker-blocked"" d=""M12 5l-6 8""/></svg>",
"exterior-door" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line marker-strong"" d=""M2 9h6""/><path class=""marker-line marker-strong"" d=""M10 9h6""/><path class=""marker-arc marker-strong"" d=""M8 9a6 6 0 0 1 6-6""/></svg>",
"stairs" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M4 13h10""/><path class=""marker-line"" d=""M5 10h8""/><path class=""marker-line"" d=""M6 7h6""/><path class=""marker-line"" d=""M7 4h4""/></svg>",
"ladder" => @"<svg viewBox=""0 0 18 18"" aria-hidden=""true"" focusable=""false""><path class=""marker-line"" d=""M6 3v12""/><path class=""marker-line"" d=""M12 3v12""/><path class=""marker-line"" d=""M6 5h6""/><path class=""marker-line"" d=""M6 8h6""/><path class=""marker-line"" d=""M6 11h6""/><path class=""marker-line"" d=""M6 14h6""/></svg>",
"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">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Floor Plans</a>
<span>@Model.FloorPlan.Name</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading compact">
<div>
<p class="eyebrow">Location floor plan</p>
<h1>@Model.FloorPlan.Name</h1>
</div>
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
</div>
@if (TempData["FloorPlanMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["FloorPlanError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<div data-floor-plan-workspace data-floor-plan-id="@Model.FloorPlan.FloorPlanID">
@if (!Model.Floors.Any())
{
<section class="empty-panel">
<h2>No floors yet</h2>
<p>Add a floor to start arranging linked locations.</p>
<div class="floor-plan-tool-card mt-3">
<h3>Add floor</h3>
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" form="floor-plan-add-floor-form" />
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label">New floor</label>
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<label class="form-label">Level</label>
<input asp-for="NewFloor.SortOrder" class="form-control form-control-sm" name="SortOrder" min="-10" max="50" step="1" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<label class="form-label">Width</label>
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<label class="form-label">Height</label>
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
<div class="col-md-2">
<button class="btn btn-outline-primary btn-sm w-100" type="submit" form="floor-plan-add-floor-form">Add floor</button>
</div>
</div>
<p class="form-text mb-0">Used to order floors and later to connect stairs up/down.</p>
</div>
</section>
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
}
else
{
<form asp-action="SaveLayout" method="post" class="floor-plan-editor" data-floor-plan-editor>
<input asp-for="FloorPlan.FloorPlanID" type="hidden" />
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input asp-for="FloorPlan.ProjectID" type="hidden" />
<input asp-for="FloorPlan.LocationID" type="hidden" />
<input asp-for="FloorPlan.Name" type="hidden" />
<input asp-for="FloorPlan.Description" type="hidden" />
<section class="floor-plan-editor-toolbar">
<div class="floor-plan-toolbar-main">
<div class="floor-plan-toolbar-floor-group">
<div class="floor-plan-active-floor">
<span class="muted">Active floor</span>
<strong data-current-floor-name>@activeFloor?.Name</strong>
</div>
<div class="floor-plan-tabs" role="tablist" aria-label="Floors">
@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-order="@floor.SortOrder" data-floor-location-id="@floor.LocationID">@floor.Name</button>
}
</div>
</div>
<div class="floor-plan-toolbar-control-group">
<label class="floor-plan-toolbar-check">
<input type="checkbox" data-grid-coordinates-toggle />
Show grid coordinates
</label>
<label class="floor-plan-reference-control">
<span class="muted">Reference floor</span>
<select class="form-select form-select-sm" data-reference-floor-select>
<option value="none">None</option>
<option value="below">Floor below</option>
<option value="above">Floor above</option>
</select>
</label>
</div>
<div class="floor-plan-toolbar-action-group">
<span class="floor-plan-save-status" data-save-status>Saved</span>
<div class="btn-group btn-group-sm" role="group" aria-label="Floor plan mode">
<button class="btn btn-outline-secondary active" type="button" data-floor-plan-mode="structure">Structure</button>
<button class="btn btn-outline-secondary" type="button" data-floor-plan-mode="occupancy">Occupancy</button>
</div>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Back to floor plans</a>
<button class="btn btn-primary btn-sm" type="submit" data-save-layout-button>Save layout</button>
</div>
</div>
<div class="floor-plan-occupancy-scene-row" data-occupancy-controls hidden>
<label class="floor-plan-reference-control">
<span class="muted">Scene</span>
<select class="form-select form-select-sm" data-occupancy-scene-select>
@foreach (var scene in Model.OccupancyScenes)
{
<option value="@scene.SceneID" data-initial-floor-id="@scene.InitialFloorPlanFloorID" selected="@(scene.SceneID == activeOccupancyScene?.SceneID)">@scene.Label</option>
}
</select>
</label>
<button class="btn btn-outline-secondary btn-sm" type="button" data-occupancy-prev>Previous Scene</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-occupancy-next>Next Scene</button>
<div class="floor-plan-occupancy-warning-panel" data-occupancy-warning-panel hidden>
<strong>Occupancy Warnings</strong>
<ul>
@foreach (var warning in Model.OccupancyWarnings)
{
if (string.Equals(warning.Message, "Some occupants are on other floors.", StringComparison.OrdinalIgnoreCase))
{
continue;
}
<li class="floor-plan-occupancy-warning floor-plan-occupancy-warning--@warning.Severity"
data-occupancy-warning
data-occupancy-scene="@warning.SceneID"
data-occupancy-floor="@warning.FloorPlanFloorID">
@warning.Message
</li>
}
</ul>
</div>
</div>
</section>
<div class="floor-plan-layout">
<section class="floor-plan-stage">
@for (var i = 0; i < Model.Floors.Count; i++)
{
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" 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;"
data-floor-grid="@floor.FloorPlanFloorID"
data-grid-width="@floor.GridWidth"
data-grid-height="@floor.GridHeight">
@if (!string.IsNullOrWhiteSpace(floor.BackgroundImagePath))
{
<img class="floor-plan-background-image @(floor.BackgroundLocked ? "is-locked" : string.Empty)"
src="@floor.BackgroundImagePath"
alt=""
style="--bg-opacity:@CssNumber(floor.BackgroundOpacity);--bg-scale:@CssNumber(floor.BackgroundScale);--bg-offset-x:@CssNumber(floor.BackgroundOffsetX)px;--bg-offset-y:@CssNumber(floor.BackgroundOffsetY)px;"
data-background-image="@floor.FloorPlanFloorID" />
}
@foreach (var block in floorBlocks)
{
var blockIndex = Model.Blocks.FindIndex(x => x.FloorPlanBlockID == block.FloorPlanBlockID);
<button type="button"
class="@BlockClass(block.BlockType)"
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"
data-location-name="@block.LocationName"
data-location-path="@block.LocationPath"
data-location-title="@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>
</button>
if (block.LocationID.HasValue && firstBlockIdByFloorLocation.GetValueOrDefault($"{block.FloorPlanFloorID}:{block.LocationID.Value}") == block.FloorPlanBlockID)
{
var locationTokens = Model.OccupancyScenes
.SelectMany(scene => occupancyTokensByFloorLocation.GetValueOrDefault($"{scene.SceneID}:{block.FloorPlanFloorID}:{block.LocationID.Value}") ?? [])
.ToList();
if (locationTokens.Any())
{
foreach (var group in locationTokens.GroupBy(x => x.SceneID))
{
var tokens = group.ToList();
var tokenTitle = string.Join(", ", tokens.Select(x => x.DisplayName));
<div class="floor-plan-occupancy-tray"
style="--x:@block.X;--y:@block.Y;--w:@block.WidthCells;--h:@block.HeightCells;"
data-occupancy-tray
data-occupancy-floor="@block.FloorPlanFloorID"
data-occupancy-location="@block.LocationID"
data-occupancy-scene="@group.Key"
title="@tokenTitle">
@foreach (var token in tokens.Take(3))
{
var imagePath = !string.IsNullOrWhiteSpace(token.ThumbnailPath) ? token.ThumbnailPath : token.ImagePath;
<span class="floor-plan-occupancy-token floor-plan-occupancy-token--@token.EntityType.ToLowerInvariant()" title="@token.DisplayName">
@if (!string.IsNullOrWhiteSpace(imagePath))
{
<img src="@imagePath" alt="@token.DisplayName" loading="lazy" />
}
else
{
<span>@AvatarHelper.Initials(token.DisplayName)</span>
}
</span>
}
@if (tokens.Count > 3)
{
<span class="floor-plan-occupancy-more">+@(tokens.Count - 3)</span>
}
</div>
}
}
}
}
@foreach (var scene in Model.OccupancyScenes)
{
var totalTokens = occupancyTokenCountByScene.GetValueOrDefault(scene.SceneID);
var floorTokens = occupancyTokenCountBySceneFloor.GetValueOrDefault($"{scene.SceneID}:{floor.FloorPlanFloorID}");
if (totalTokens == 0)
{
<div class="floor-plan-occupancy-message" data-occupancy-message data-occupancy-scene="@scene.SceneID" data-occupancy-floor="@floor.FloorPlanFloorID" hidden>No occupancy has been set for this scene.</div>
}
else if (floorTokens == 0)
{
<div class="floor-plan-occupancy-message" data-occupancy-message data-occupancy-scene="@scene.SceneID" data-occupancy-floor="@floor.FloorPlanFloorID" hidden>Scene has occupancy, but none of the assigned locations are placed on this floor.</div>
}
else if (floorTokens < totalTokens)
{
<div class="floor-plan-occupancy-message" data-occupancy-message data-occupancy-scene="@scene.SceneID" data-occupancy-floor="@floor.FloorPlanFloorID" hidden>Some occupants are on other floors.</div>
}
}
@foreach (var transition in floorTransitions)
{
var transitionDetails = TransitionDetails(transition);
var markerType = TransitionMarkerType(transition.TransitionType);
<div class="floor-plan-transition-marker floor-plan-transition-marker--@markerType"
data-transition-marker="@transition.FloorPlanTransitionID"
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))
</div>
}
</div>
</div>
</div>
}
</section>
<aside class="floor-plan-side-panel">
<div class="floor-plan-toolbox" data-toolbox>
<div class="floor-plan-toolbox-tabs" role="tablist" aria-label="Floor plan tools">
<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">
<section class="floor-plan-tool-card active" id="floor-plan-tool-add" role="tabpanel" aria-labelledby="floor-plan-tool-tab-add" data-toolbox-panel="add">
<h2>Add block</h2>
<p class="muted">Adds a block to <span data-current-floor-name>@activeFloor?.Name</span>. Use a child Location or create a new room under this floor.</p>
<input type="hidden" name="FloorPlanBlockID" value="0" form="floor-plan-add-block-form" />
<input type="hidden" name="FloorPlanFloorID" value="@activeFloor?.FloorPlanFloorID" data-add-block-floor-id form="floor-plan-add-block-form" />
<input type="hidden" name="X" value="0" form="floor-plan-add-block-form" />
<input type="hidden" name="Y" value="0" form="floor-plan-add-block-form" />
<input type="hidden" name="WidthCells" value="1" data-add-block-width form="floor-plan-add-block-form" />
<input type="hidden" name="HeightCells" value="1" data-add-block-height form="floor-plan-add-block-form" />
<div class="floor-plan-choice-row" role="group" aria-label="Add block location mode">
<label><input type="radio" name="AddBlockLocationMode" value="Existing" checked form="floor-plan-add-block-form" data-add-block-mode /> Use existing location</label>
<label><input type="radio" name="AddBlockLocationMode" value="Create" form="floor-plan-add-block-form" data-add-block-mode /> Create new room/location</label>
</div>
<div class="mb-2" data-existing-location-panel>
<label class="form-label" for="add-block-location">Existing child Location</label>
<select id="add-block-location" class="form-select form-select-sm" name="LocationID" form="floor-plan-add-block-form" data-add-block-location>
<option value="">Choose a child Location</option>
@foreach (var location in Model.Locations)
{
<option value="@location.LocationID" data-parent-location-id="@location.ParentLocationID">@location.LocationPath</option>
}
</select>
</div>
<div class="mb-2 d-none" data-new-location-panel>
<label class="form-label" for="add-block-new-location">New room/location name</label>
<input id="add-block-new-location" class="form-control form-control-sm" name="NewLocationName" form="floor-plan-add-block-form" />
</div>
<div class="mb-2">
<label class="form-label" for="add-block-type">Block type</label>
<select id="add-block-type" class="form-select form-select-sm" name="BlockType" asp-items="Model.BlockTypeOptions" form="floor-plan-add-block-form"></select>
</div>
<div class="mb-2">
<label class="form-label" for="add-block-size-preset">Size preset</label>
<select id="add-block-size-preset" class="form-select form-select-sm" data-size-preset>
<option value="1x1">Small Room 1 x 1</option>
<option value="2x1" selected>Medium Room 2 x 1</option>
<option value="2x2">Large Room 2 x 2</option>
<option value="1x3">Corridor 1 x 3</option>
<option value="3x2">Wide Hall 3 x 2</option>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="add-block-label">Optional label override</label>
<input id="add-block-label" class="form-control form-control-sm" name="LabelOverride" form="floor-plan-add-block-form" />
</div>
<button class="btn btn-primary btn-sm w-100" type="submit" form="floor-plan-add-block-form">Add block</button>
</section>
<section class="floor-plan-tool-card" id="floor-plan-tool-selected" role="tabpanel" aria-labelledby="floor-plan-tool-tab-selected" data-toolbox-panel="selected" hidden>
<h2>Selected block</h2>
<p class="muted" data-no-selection>Select a block on the grid to edit its details.</p>
<div class="floor-plan-overlap-warning d-none" data-overlap-warning>Some blocks overlap on this floor.</div>
@for (var i = 0; i < Model.Blocks.Count; i++)
{
<div class="floor-plan-block-editor d-none" data-block-editor="@Model.Blocks[i].FloorPlanBlockID">
<input asp-for="Blocks[i].FloorPlanBlockID" type="hidden" />
<input asp-for="Blocks[i].FloorPlanFloorID" type="hidden" data-block-field="floor" />
<input asp-for="Blocks[i].LocationName" type="hidden" />
<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" data-block-field="location"></select>
</div>
<div class="mb-2">
<label class="form-label">Type</label>
<select asp-for="Blocks[i].BlockType" asp-items="Model.BlockTypeOptions" class="form-select form-select-sm" data-block-field="type"></select>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">X</label>
<select class="form-select form-select-sm"
id="Blocks_@(i)__X"
name="Blocks[@i].X"
data-block-field="x"
data-coordinate-axis="x"
data-coordinate-value="@Model.Blocks[i].X"></select>
</div>
<div class="col-6">
<label class="form-label">Y</label>
<select class="form-select form-select-sm"
id="Blocks_@(i)__Y"
name="Blocks[@i].Y"
data-block-field="y"
data-coordinate-axis="y"
data-coordinate-value="@Model.Blocks[i].Y"></select>
</div>
<div class="col-6">
<label class="form-label">Width</label>
<input asp-for="Blocks[i].WidthCells" class="form-control form-control-sm" min="1" max="80" data-block-field="w" />
</div>
<div class="col-6">
<label class="form-label">Height</label>
<input asp-for="Blocks[i].HeightCells" class="form-control form-control-sm" min="1" max="80" data-block-field="h" />
</div>
</div>
<div class="mt-2">
<label class="form-label">Label override</label>
<input asp-for="Blocks[i].LabelOverride" class="form-control form-control-sm" />
</div>
<div class="mt-2">
<label class="form-label">Notes</label>
<textarea asp-for="Blocks[i].Notes" class="form-control form-control-sm" rows="3"></textarea>
</div>
<div class="button-row mt-3">
<button class="btn btn-outline-danger btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#delete-block-@Model.Blocks[i].FloorPlanBlockID">Delete block</button>
</div>
</div>
}
</section>
<section class="floor-plan-tool-card" id="floor-plan-tool-background" role="tabpanel" aria-labelledby="floor-plan-tool-tab-background" data-toolbox-panel="background" hidden>
<h2>Background</h2>
<p class="muted">Settings affect only <span data-current-floor-name>@activeFloor?.Name</span>.</p>
@for (var i = 0; i < Model.Floors.Count; i++)
{
var floor = Model.Floors[i];
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<div class="floor-plan-background-editor @(isActive ? string.Empty : "d-none")" data-background-editor="@floor.FloorPlanFloorID">
@if (string.IsNullOrWhiteSpace(floor.BackgroundImagePath))
{
<p class="muted">Upload a map, sketch, or floor-plan image to trace over. The image sits behind the grid and blocks.</p>
<div class="mb-3">
<label class="form-label" for="background-image-@floor.FloorPlanFloorID">Background image</label>
<input id="background-image-@floor.FloorPlanFloorID"
class="form-control form-control-sm"
type="file"
name="backgroundImage"
accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp"
form="floor-plan-background-upload-@floor.FloorPlanFloorID" />
</div>
<button class="btn btn-primary btn-sm w-100" type="submit" form="floor-plan-background-upload-@floor.FloorPlanFloorID">Upload background</button>
}
else
{
<p class="floor-plan-background-file">Background: <strong>@floor.BackgroundImageOriginalFileName</strong></p>
<div class="mb-2">
<label class="form-label" for="background-opacity-@floor.FloorPlanFloorID">Opacity</label>
<div class="floor-plan-background-range">
<input id="background-opacity-@floor.FloorPlanFloorID"
class="form-range"
type="range"
name="BackgroundOpacity"
min="0.05"
max="1"
step="0.05"
value="@CssNumber(floor.BackgroundOpacity)"
data-background-field="opacity"
data-background-floor="@floor.FloorPlanFloorID"
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
<span data-background-opacity-value="@floor.FloorPlanFloorID">@CssNumber(floor.BackgroundOpacity)</span>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label" for="background-scale-@floor.FloorPlanFloorID">Scale</label>
<input id="background-scale-@floor.FloorPlanFloorID"
class="form-control form-control-sm"
type="number"
name="BackgroundScale"
min="0.1"
max="5"
step="0.05"
value="@CssNumber(floor.BackgroundScale)"
data-background-field="scale"
data-background-floor="@floor.FloorPlanFloorID"
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
</div>
<div class="col-6">
<label class="form-label" for="background-offset-x-@floor.FloorPlanFloorID">Offset X</label>
<input id="background-offset-x-@floor.FloorPlanFloorID"
class="form-control form-control-sm"
type="number"
name="BackgroundOffsetX"
min="-10000"
max="10000"
step="1"
value="@CssNumber(floor.BackgroundOffsetX)"
data-background-field="offsetX"
data-background-floor="@floor.FloorPlanFloorID"
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
</div>
<div class="col-6">
<label class="form-label" for="background-offset-y-@floor.FloorPlanFloorID">Offset Y</label>
<input id="background-offset-y-@floor.FloorPlanFloorID"
class="form-control form-control-sm"
type="number"
name="BackgroundOffsetY"
min="-10000"
max="10000"
step="1"
value="@CssNumber(floor.BackgroundOffsetY)"
data-background-field="offsetY"
data-background-floor="@floor.FloorPlanFloorID"
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
</div>
<div class="col-6 d-flex align-items-end">
<label class="floor-plan-checkbox">
<input type="checkbox"
name="BackgroundLocked"
value="true"
@(floor.BackgroundLocked ? "checked" : string.Empty)
data-background-field="locked"
data-background-floor="@floor.FloorPlanFloorID"
form="floor-plan-background-settings-@floor.FloorPlanFloorID" />
Locked
</label>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-outline-secondary btn-sm" type="submit" form="floor-plan-background-reset-@floor.FloorPlanFloorID">Reset position</button>
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#remove-background-@floor.FloorPlanFloorID">Remove background</button>
</div>
}
</div>
}
</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="accordion floor-plan-transition-accordion" id="transition-accordion-@floor.FloorPlanFloorID">
<div class="accordion-item">
<h3 class="accordion-header" id="transition-create-heading-@floor.FloorPlanFloorID">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#transition-create-@floor.FloorPlanFloorID" aria-expanded="true" aria-controls="transition-create-@floor.FloorPlanFloorID">
Create transition
</button>
</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 < 1)
{
<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" data-transition-from-select="@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" 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>
<button class="btn btn-link btn-sm px-0 d-none" type="button" data-transition-stair-fallback="@floor.FloorPlanFloorID">Show all locations on adjacent floor</button>
</div>
<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>
<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>
</div>
<div class="accordion-item">
<h3 class="accordion-header" id="transition-existing-heading-@floor.FloorPlanFloorID">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#transition-existing-@floor.FloorPlanFloorID" aria-expanded="false" aria-controls="transition-existing-@floor.FloorPlanFloorID">
Existing transitions
</button>
</h3>
<div id="transition-existing-@floor.FloorPlanFloorID" class="accordion-collapse collapse" aria-labelledby="transition-existing-heading-@floor.FloorPlanFloorID" data-bs-parent="#transition-accordion-@floor.FloorPlanFloorID">
<div class="accordion-body">
@if (!floorTransitions.Any())
{
<p class="muted mb-0">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 -> @transition.ToLocationName</strong>
<span>@transition.TransitionType</span>
@if (!string.IsNullOrWhiteSpace(transition.DisplayLabel))
{
<span>@transition.DisplayLabel</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>
</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">
<h3>Add floor</h3>
<input asp-for="NewFloor.FloorPlanID" type="hidden" name="FloorPlanID" form="floor-plan-add-floor-form" />
<div class="mb-2">
<label class="form-label">New floor</label>
<input asp-for="NewFloor.Name" class="form-control form-control-sm" name="Name" form="floor-plan-add-floor-form" />
</div>
<div class="mb-2">
<label class="form-label">Level</label>
<input asp-for="NewFloor.SortOrder" class="form-control form-control-sm" name="SortOrder" min="-10" max="50" step="1" form="floor-plan-add-floor-form" />
<div class="form-text">Used to order floors and later to connect stairs up/down.</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Width</label>
<input asp-for="NewFloor.GridWidth" class="form-control form-control-sm" name="GridWidth" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
<div class="col-6">
<label class="form-label">Height</label>
<input asp-for="NewFloor.GridHeight" class="form-control form-control-sm" name="GridHeight" min="8" max="80" form="floor-plan-add-floor-form" />
</div>
</div>
<button class="btn btn-outline-primary btn-sm w-100 mt-2" type="submit" form="floor-plan-add-floor-form">Add floor</button>
</div>
<div class="floor-plan-tool-subsection">
<h3>Current floor</h3>
@for (var i = 0; i < Model.Floors.Count; i++)
{
var floor = Model.Floors[i];
var isActive = floor.FloorPlanFloorID == activeFloor?.FloorPlanFloorID;
<div class="floor-plan-floor-editor @(isActive ? string.Empty : "d-none")" data-floor-editor="@floor.FloorPlanFloorID">
<input asp-for="Floors[i].FloorPlanFloorID" type="hidden" />
<input asp-for="Floors[i].FloorPlanID" type="hidden" />
<div class="mb-2">
<label class="form-label">Floor name</label>
<input asp-for="Floors[i].Name" class="form-control form-control-sm" />
</div>
<div class="mb-2">
<label class="form-label">Description</label>
<textarea asp-for="Floors[i].Description" class="form-control form-control-sm" rows="2"></textarea>
</div>
<div class="mb-2">
<label class="form-label">Root Location</label>
<select asp-for="Floors[i].LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm" data-floor-location-select="@floor.FloorPlanFloorID"></select>
</div>
<div class="mb-2">
<label class="form-label">Level</label>
<input asp-for="Floors[i].SortOrder" class="form-control form-control-sm" min="-10" max="50" step="1" data-floor-level-input="@floor.FloorPlanFloorID" />
<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>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Grid width</label>
<input asp-for="Floors[i].GridWidth" class="form-control form-control-sm" min="8" max="80" data-floor-width="@floor.FloorPlanFloorID" />
</div>
<div class="col-6">
<label class="form-label">Grid height</label>
<input asp-for="Floors[i].GridHeight" class="form-control form-control-sm" min="8" max="80" data-floor-height="@floor.FloorPlanFloorID" />
</div>
</div>
<button class="btn btn-outline-danger btn-sm w-100 mt-2"
type="button"
data-bs-toggle="modal"
data-bs-target="#delete-floor-@floor.FloorPlanFloorID">Delete floor</button>
</div>
}
</div>
</section>
</div>
</div>
</aside>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit" data-save-layout-button>Save layout</button>
</div>
</form>
<form id="floor-plan-add-block-form" asp-action="AddBlock" method="post">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
</form>
<form id="floor-plan-add-floor-form" asp-action="AddFloor" method="post"></form>
@foreach (var floor in Model.Floors)
{
<form id="floor-plan-background-upload-@floor.FloorPlanFloorID" asp-action="UploadBackground" method="post" enctype="multipart/form-data">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
</form>
<form id="floor-plan-background-settings-@floor.FloorPlanFloorID" asp-action="SaveBackgroundSettings" method="post" data-background-settings-form="@floor.FloorPlanFloorID">
<input type="hidden" name="FloorPlanID" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="FloorPlanFloorID" value="@floor.FloorPlanFloorID" />
</form>
<form id="floor-plan-background-reset-@floor.FloorPlanFloorID" asp-action="ResetBackground" method="post">
<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)
{
var placedBlockCount = Model.Blocks.Count(x => x.FloorPlanFloorID == floor.FloorPlanFloorID);
<div class="modal fade" id="delete-floor-@floor.FloorPlanFloorID" tabindex="-1" aria-labelledby="delete-floor-title-@floor.FloorPlanFloorID" 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-floor-title-@floor.FloorPlanFloorID">Delete floor?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete <strong>@floor.Name</strong>?</p>
<p>This deletes @placedBlockCount placed block(s) on this floor. Linked Locations 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="DeleteFloor" method="post" data-no-delete-confirm="true">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
<button class="btn btn-danger" type="submit">Delete floor</button>
</form>
</div>
</div>
</div>
</div>
}
@foreach (var floor in Model.Floors.Where(x => !string.IsNullOrWhiteSpace(x.BackgroundImagePath)))
{
<div class="modal fade" id="remove-background-@floor.FloorPlanFloorID" tabindex="-1" aria-labelledby="remove-background-title-@floor.FloorPlanFloorID" 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="remove-background-title-@floor.FloorPlanFloorID">Remove background?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Remove the background image for <strong>@floor.Name</strong>?</p>
<p>This removes only the tracing image from this floor. Blocks and linked Locations 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="RemoveBackground" method="post" data-no-delete-confirm="true">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanFloorId" value="@floor.FloorPlanFloorID" />
<button class="btn btn-danger" type="submit">Remove background</button>
</form>
</div>
</div>
</div>
</div>
}
@foreach (var transition in Model.Transitions)
{
var transitionLocationOptions = Model.Blocks
.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" 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" />
</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">
<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-block-title-@block.FloorPlanBlockID">Delete placed block?</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Remove the placed block for <strong>@block.DisplayLabel</strong>?</p>
<p>Only this block is removed from the floor plan. The linked Location 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="DeleteBlock" method="post" data-no-delete-confirm="true">
<input type="hidden" name="floorPlanId" value="@Model.FloorPlan.FloorPlanID" />
<input type="hidden" name="floorPlanBlockId" value="@block.FloorPlanBlockID" />
<button class="btn btn-danger" type="submit">Delete block</button>
</form>
</div>
</div>
</div>
</div>
}
}
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
window.initializeFloorPlanEditor = (options = {}) => {
const editor = document.querySelector("[data-floor-plan-editor]");
if (!editor) return;
if (editor.dataset.floorPlanInitialized === "true") return;
editor.dataset.floorPlanInitialized = "true";
const workspace = editor.closest("[data-floor-plan-workspace]");
const tabs = [...editor.querySelectorAll("[data-floor-tab]")];
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]")];
let blocks = [...editor.querySelectorAll("[data-block]")];
const noSelection = editor.querySelector("[data-no-selection]");
const warning = editor.querySelector("[data-overlap-warning]");
const addBlockFloor = document.querySelector("[data-add-block-floor-id]");
const addBlockFloorNames = [...document.querySelectorAll("[data-current-floor-name]")];
const addBlockLocation = document.querySelector("[data-add-block-location]");
const addBlockModes = [...document.querySelectorAll("[data-add-block-mode]")];
const addBlockType = document.querySelector("#add-block-type");
const existingLocationPanel = document.querySelector("[data-existing-location-panel]");
const newLocationPanel = document.querySelector("[data-new-location-panel]");
const sizePreset = document.querySelector("[data-size-preset]");
const addBlockWidth = document.querySelector("[data-add-block-width]");
const addBlockHeight = document.querySelector("[data-add-block-height]");
const addBlockForm = document.querySelector("#floor-plan-add-block-form");
const toolboxTabs = [...editor.querySelectorAll("[data-toolbox-tab]")];
const toolboxPanels = [...editor.querySelectorAll("[data-toolbox-panel]")];
const saveStatus = editor.querySelector("[data-save-status]");
const saveButtons = [...editor.querySelectorAll("[data-save-layout-button]")];
const gridCoordinatesToggle = editor.querySelector("[data-grid-coordinates-toggle]");
const referenceFloorSelect = editor.querySelector("[data-reference-floor-select]");
const modeButtons = [...editor.querySelectorAll("[data-floor-plan-mode]")];
const occupancyControls = [...editor.querySelectorAll("[data-occupancy-controls]")];
const occupancySceneSelect = editor.querySelector("[data-occupancy-scene-select]");
const occupancyPrev = editor.querySelector("[data-occupancy-prev]");
const occupancyNext = editor.querySelector("[data-occupancy-next]");
const occupancyTrays = [...editor.querySelectorAll("[data-occupancy-tray]")];
const occupancyMessages = [...editor.querySelectorAll("[data-occupancy-message]")];
const occupancyWarningPanel = editor.querySelector("[data-occupancy-warning-panel]");
const occupancyWarnings = [...editor.querySelectorAll("[data-occupancy-warning]")];
const initialSelectedBlockId = options.selectedBlockId || "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
dirty: "Unsaved changes",
saving: "Saving...",
failed: "Save failed"
};
const sizePresets = {
Room: [
["1x1", "Small Room 1 x 1"],
["2x1", "Medium Room 2 x 1"],
["2x2", "Large Room 2 x 2"],
["3x1", "Long Room 3 x 1"],
["3x2", "Grand Room 3 x 2"]
],
Corridor: [
["1x2", "Short Corridor 1 x 2"],
["1x4", "Long Corridor 1 x 4"],
["2x3", "Wide Corridor 2 x 3"],
["4x1", "Horizontal Corridor 4 x 1"],
["5x1", "Gallery 5 x 1"]
],
OpenSpace: [
["4x4", "Courtyard 4 x 4"],
["6x5", "Large Courtyard 6 x 5"],
["6x4", "Garden Area 6 x 4"],
["2x2", "Lightwell 2 x 2"]
],
Exterior: [
["3x3", "Yard 3 x 3"],
["4x2", "Drive 4 x 2"],
["6x4", "Garden 6 x 4"],
["4x1", "Terrace 4 x 1"]
],
Other: [
["1x1", "Small 1 x 1"],
["2x1", "Medium 2 x 1"],
["2x2", "Large 2 x 2"],
["3x2", "Wide 3 x 2"]
]
};
let selectedBlock = null;
let pointerState = null;
let saveTimer = null;
let saveInFlight = false;
let saveAgain = false;
let refreshAfterSave = false;
let geometryRefreshQueued = false;
const backgroundTimers = new Map();
const gridCoordinatesStorageKey = "floorPlan.showGridCoordinates";
const referenceFloorStorageKey = "floorPlan.referenceFloor";
function setToolboxTab(tabName) {
toolboxTabs.forEach(tab => {
const active = tab.dataset.toolboxTab === tabName;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
toolboxPanels.forEach(panel => {
const active = panel.dataset.toolboxPanel === tabName;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
}
function setSaveStatus(state, message) {
if (!saveStatus) return;
saveStatus.dataset.saveState = state;
saveStatus.textContent = message || saveStatusText[state] || state;
}
function selectedOccupancySceneId() {
return occupancySceneSelect?.value || "";
}
function refreshOccupancyTrays() {
const sceneId = selectedOccupancySceneId();
const showOccupancy = editor.classList.contains("floor-plan-editor--occupancy");
const activeFloorId = tabs.find(tab => tab.classList.contains("active"))?.dataset.floorTab || "";
occupancyTrays.forEach(tray => {
tray.hidden = !showOccupancy || tray.dataset.occupancyScene !== sceneId || tray.dataset.occupancyFloor !== activeFloorId;
});
occupancyMessages.forEach(message => {
message.hidden = !showOccupancy || message.dataset.occupancyScene !== sceneId || message.dataset.occupancyFloor !== activeFloorId;
});
let visibleWarnings = 0;
occupancyWarnings.forEach(warning => {
const warningFloorId = warning.dataset.occupancyFloor || "";
const visible = showOccupancy
&& warning.dataset.occupancyScene === sceneId
&& (!warningFloorId || warningFloorId === activeFloorId);
warning.hidden = !visible;
if (visible) visibleWarnings++;
});
if (occupancyWarningPanel) {
occupancyWarningPanel.hidden = !showOccupancy || visibleWarnings === 0;
}
}
function setFloorPlanMode(mode) {
const isOccupancy = mode === "occupancy";
editor.classList.toggle("floor-plan-editor--occupancy", isOccupancy);
modeButtons.forEach(button => {
const active = button.dataset.floorPlanMode === mode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
occupancyControls.forEach(control => {
control.hidden = !isOccupancy;
});
saveButtons.forEach(button => {
button.hidden = isOccupancy;
});
if (isOccupancy) {
pointerState = null;
blocks.forEach(item => item.classList.remove("selected"));
if (noSelection) noSelection.classList.remove("d-none");
}
refreshOccupancyTrays();
}
function moveOccupancyScene(direction) {
if (!occupancySceneSelect || occupancySceneSelect.options.length === 0) {
return;
}
const nextIndex = Math.max(0, Math.min(occupancySceneSelect.options.length - 1, occupancySceneSelect.selectedIndex + direction));
occupancySceneSelect.selectedIndex = nextIndex;
applyOccupancySceneFloor();
refreshOccupancyTrays();
}
function applyOccupancySceneFloor() {
const option = occupancySceneSelect?.selectedOptions?.[0];
const initialFloorId = option?.dataset.initialFloorId;
if (initialFloorId) {
setActiveFloor(initialFloorId);
}
}
function gridColumnLabel(index) {
let label = "";
let value = index + 1;
while (value > 0) {
value--;
label = String.fromCharCode(65 + (value % 26)) + label;
value = Math.floor(value / 26);
}
return label;
}
function populateCoordinateSelect(select, count, labelForValue, selectedValue) {
if (!select) return;
const selected = String(Math.max(0, Math.min(Number(selectedValue || 0), Math.max(count - 1, 0))));
const existingValues = [...select.options].map(option => option.value).join("|");
const nextValues = Array.from({ length: Math.max(count, 1) }, (_, index) => String(index)).join("|");
if (existingValues !== nextValues) {
select.replaceChildren(...Array.from({ length: Math.max(count, 1) }, (_, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = labelForValue(index);
return option;
}));
}
select.value = selected;
select.dataset.coordinateValue = selected;
}
function refreshGridCoordinateLabels() {
const show = gridCoordinatesToggle?.checked === true;
panels.forEach(panel => {
const grid = panel.querySelector("[data-floor-grid]");
if (!grid) return;
grid.classList.toggle("show-grid-coordinates", show);
grid.querySelectorAll("[data-grid-coordinate-label]").forEach(label => label.remove());
if (!show) return;
const width = Number(grid.dataset.gridWidth || 0);
const height = Number(grid.dataset.gridHeight || 0);
for (let x = 0; x < width; x++) {
const label = document.createElement("span");
label.dataset.gridCoordinateLabel = "column";
label.className = "floor-plan-coordinate-label floor-plan-coordinate-label--column";
label.style.setProperty("--coord-x", x);
label.textContent = gridColumnLabel(x);
grid.append(label);
}
for (let y = 0; y < height; y++) {
const label = document.createElement("span");
label.dataset.gridCoordinateLabel = "row";
label.className = "floor-plan-coordinate-label floor-plan-coordinate-label--row";
label.style.setProperty("--coord-y", y);
label.textContent = String(y + 1);
grid.append(label);
}
});
}
function floorForLevel(currentFloorId, offset) {
const current = tabs.find(tab => tab.dataset.floorTab === String(currentFloorId));
if (!current) return null;
const targetLevel = Number(current.dataset.floorOrder || 0) + offset;
return tabs.find(tab => Number(tab.dataset.floorOrder || 0) === targetLevel) || null;
}
function referenceFloorForActivePanel() {
const activePanel = panels.find(panel => panel.classList.contains("active"));
if (!activePanel || !referenceFloorSelect || referenceFloorSelect.value === "none") return null;
return floorForLevel(activePanel.dataset.floorPanel, referenceFloorSelect.value === "above" ? 1 : -1);
}
function refreshReferenceFloorOptions() {
if (!referenceFloorSelect) return;
const activePanel = panels.find(panel => panel.classList.contains("active"));
const belowOption = [...referenceFloorSelect.options].find(option => option.value === "below");
const aboveOption = [...referenceFloorSelect.options].find(option => option.value === "above");
const belowFloor = activePanel ? floorForLevel(activePanel.dataset.floorPanel, -1) : null;
const aboveFloor = activePanel ? floorForLevel(activePanel.dataset.floorPanel, 1) : null;
if (belowOption) {
belowOption.disabled = !belowFloor;
belowOption.textContent = belowFloor ? `Floor below (${belowFloor.textContent.trim()})` : "No floor below";
}
if (aboveOption) {
aboveOption.disabled = !aboveFloor;
aboveOption.textContent = aboveFloor ? `Floor above (${aboveFloor.textContent.trim()})` : "No floor above";
}
if (referenceFloorSelect.selectedOptions[0]?.disabled) {
referenceFloorSelect.value = "none";
window.localStorage?.setItem(referenceFloorStorageKey, "none");
}
}
function refreshReferenceOverlay() {
refreshReferenceFloorOptions();
panels.forEach(panel => {
panel.querySelectorAll("[data-reference-floor-overlay], [data-reference-grid-warning]").forEach(item => item.remove());
});
const activePanel = panels.find(panel => panel.classList.contains("active"));
const grid = activePanel?.querySelector("[data-floor-grid]");
const referenceFloor = referenceFloorForActivePanel();
if (!activePanel || !grid || !referenceFloor) return;
const referenceGrid = editor.querySelector(`[data-floor-grid="${referenceFloor.dataset.floorTab}"]`);
const overlay = document.createElement("div");
overlay.className = "floor-plan-reference-overlay";
overlay.dataset.referenceFloorOverlay = referenceFloor.dataset.floorTab;
overlay.setAttribute("aria-hidden", "true");
blockItemsForFloor(referenceFloor.dataset.floorTab).forEach(item => {
const block = document.createElement("span");
block.className = "floor-plan-reference-block";
block.style.setProperty("--x", item.x);
block.style.setProperty("--y", item.y);
block.style.setProperty("--w", item.w);
block.style.setProperty("--h", item.h);
block.textContent = item.block.dataset.locationName || item.block.textContent.trim();
overlay.append(block);
});
grid.prepend(overlay);
if (referenceGrid && (referenceGrid.dataset.gridWidth !== grid.dataset.gridWidth || referenceGrid.dataset.gridHeight !== grid.dataset.gridHeight)) {
const warning = document.createElement("div");
warning.className = "floor-plan-reference-warning";
warning.dataset.referenceGridWarning = "true";
warning.textContent = "Reference floor uses a different grid size.";
grid.append(warning);
}
}
function markDirty() {
setSaveStatus("dirty");
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayoutAsync(), 1000);
}
function workspaceState(overrides = {}) {
const activePanel = panels.find(panel => panel.classList.contains("active"));
const gridWrap = activePanel?.querySelector(".floor-plan-grid-wrap");
const toolboxBody = editor.querySelector(".floor-plan-toolbox-body");
return {
activeFloorId: activePanel?.dataset.floorPanel || tabs.find(tab => tab.classList.contains("active"))?.dataset.floorTab || "",
activeToolboxTab: toolboxTabs.find(tab => tab.classList.contains("active"))?.dataset.toolboxTab || "add",
selectedBlockId: selectedBlock?.dataset.block || "",
floorPlanMode: modeButtons.find(button => button.classList.contains("active"))?.dataset.floorPlanMode || "structure",
occupancySceneId: selectedOccupancySceneId(),
pageX: window.scrollX,
pageY: window.scrollY,
gridLeft: gridWrap?.scrollLeft || 0,
gridTop: gridWrap?.scrollTop || 0,
toolboxTop: toolboxBody?.scrollTop || 0,
...overrides
};
}
function showWorkspaceError(message) {
setSaveStatus("failed", message || "Save failed");
let alert = editor.querySelector("[data-workspace-error]");
if (!alert) {
alert = document.createElement("div");
alert.className = "alert alert-danger";
alert.dataset.workspaceError = "true";
editor.prepend(alert);
}
alert.textContent = message || "Save failed";
}
function clearWorkspaceError() {
editor.querySelector("[data-workspace-error]")?.remove();
}
function cleanupModalArtifacts() {
document.querySelectorAll(".modal-backdrop").forEach(backdrop => backdrop.remove());
document.body.classList.remove("modal-open");
document.body.style.removeProperty("overflow");
document.body.style.removeProperty("padding-right");
}
function restoreWorkspaceState(state) {
if (state.activeFloorId) {
setActiveFloor(String(state.activeFloorId));
}
if (!panels.some(panel => panel.classList.contains("active")) && tabs[0]) {
setActiveFloor(tabs[0].dataset.floorTab);
}
if (state.activeToolboxTab) {
setToolboxTab(state.activeToolboxTab);
}
if (state.occupancySceneId && occupancySceneSelect) {
occupancySceneSelect.value = String(state.occupancySceneId);
}
if (state.floorPlanMode) {
setFloorPlanMode(state.floorPlanMode);
}
if (state.selectedBlockId) {
const block = blocks.find(item => item.dataset.block === String(state.selectedBlockId));
if (block) {
selectBlock(block, { showEditor: state.activeToolboxTab === "selected" });
}
}
const activePanel = panels.find(panel => panel.classList.contains("active"));
const gridWrap = activePanel?.querySelector(".floor-plan-grid-wrap");
const toolboxBody = editor.querySelector(".floor-plan-toolbox-body");
if (gridWrap) {
gridWrap.scrollLeft = state.gridLeft || 0;
gridWrap.scrollTop = state.gridTop || 0;
}
if (toolboxBody) {
toolboxBody.scrollTop = state.toolboxTop || 0;
}
window.scrollTo(state.pageX || 0, state.pageY || 0);
document.documentElement.scrollTop = state.pageY || 0;
document.body.scrollTop = state.pageY || 0;
}
async function refreshWorkspaceAsync(state) {
const floorPlanId = workspace?.dataset.floorPlanId;
if (!workspace || !floorPlanId) return;
const response = await fetch(`/FloorPlans/Edit/${floorPlanId}`, {
method: "GET",
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin"
});
const html = await response.text();
if (!response.ok) {
throw new Error("The floor plan workspace could not be refreshed.");
}
const doc = new DOMParser().parseFromString(html, "text/html");
const nextWorkspace = doc.querySelector("[data-floor-plan-workspace]");
if (!nextWorkspace) {
throw new Error("The refreshed floor plan workspace was not found.");
}
cleanupModalArtifacts();
workspace.replaceWith(nextWorkspace);
window.initializeFloorPlanEditor({
activeFloorId: state.activeFloorId,
activeToolboxTab: state.activeToolboxTab,
selectedBlockId: state.selectedBlockId,
floorPlanMode: state.floorPlanMode,
occupancySceneId: state.occupancySceneId
});
const nextEditor = document.querySelector("[data-floor-plan-editor]");
const nextStatus = nextEditor?.querySelector("[data-save-status]");
if (nextStatus) {
nextStatus.dataset.saveState = "saved";
nextStatus.textContent = "Saved";
}
window.requestAnimationFrame(() => {
const refreshedEditor = document.querySelector("[data-floor-plan-editor]");
if (!refreshedEditor) return;
refreshedEditor.dispatchEvent(new CustomEvent("floor-plan-restore-state", { detail: state }));
});
}
async function saveLayoutAsync() {
window.clearTimeout(saveTimer);
if (saveInFlight) {
saveAgain = true;
return;
}
saveInFlight = true;
saveButtons.forEach(button => button.disabled = true);
setSaveStatus("saving");
try {
const response = await fetch(editor.action, {
method: "POST",
body: new FormData(editor),
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin"
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.ok === false) {
throw new Error(result.message || "Unable to save layout.");
}
clearWorkspaceError();
setSaveStatus("saved");
if (refreshAfterSave) {
refreshAfterSave = false;
await refreshWorkspaceAsync(workspaceState());
}
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
showWorkspaceError(error.message || "Save failed");
} finally {
saveInFlight = false;
saveButtons.forEach(button => button.disabled = false);
if (saveAgain) {
saveAgain = false;
markDirty();
}
}
}
function updateFloorGrid(floorId) {
const grid = editor.querySelector(`[data-floor-grid="${floorId}"]`);
const widthInput = editor.querySelector(`[data-floor-width="${floorId}"]`);
const heightInput = editor.querySelector(`[data-floor-height="${floorId}"]`);
if (!grid || !widthInput || !heightInput) return;
const width = Math.max(8, Math.min(Number(widthInput.value || 24), 80));
const height = Math.max(8, Math.min(Number(heightInput.value || 16), 80));
widthInput.value = width;
heightInput.value = height;
grid.dataset.gridWidth = width;
grid.dataset.gridHeight = height;
grid.style.setProperty("--grid-width", width);
grid.style.setProperty("--grid-height", height);
grid.querySelectorAll("[data-block]").forEach(block => writeBlock(block, readBlock(block)));
checkOverlaps();
refreshGridCoordinateLabels();
refreshReferenceOverlay();
refreshTransitionDestination(floorId);
}
function setActiveFloor(floorId) {
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.floorTab === floorId));
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.floorPanel === floorId));
floorEditors.forEach(panel => panel.classList.toggle("d-none", panel.dataset.floorEditor !== floorId));
backgroundEditors.forEach(panel => {
const active = panel.dataset.backgroundEditor === floorId;
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) {
addBlockFloorNames.forEach(item => item.textContent = activeTab.dataset.floorName || activeTab.textContent || "the current floor");
}
filterAddBlockLocations(activeTab?.dataset.floorLocationId || "");
checkOverlaps();
refreshReferenceOverlay();
refreshTransitionDestination(floorId);
refreshOccupancyTrays();
}
function backgroundFields(floorId) {
const panel = editor.querySelector(`[data-background-editor="${floorId}"]`);
return {
form: document.querySelector(`[data-background-settings-form="${floorId}"]`),
image: editor.querySelector(`[data-background-image="${floorId}"]`),
opacity: panel?.querySelector("[data-background-field='opacity']"),
scale: panel?.querySelector("[data-background-field='scale']"),
offsetX: panel?.querySelector("[data-background-field='offsetX']"),
offsetY: panel?.querySelector("[data-background-field='offsetY']"),
locked: panel?.querySelector("[data-background-field='locked']"),
opacityValue: editor.querySelector(`[data-background-opacity-value="${floorId}"]`)
};
}
function updateBackgroundPreview(floorId) {
const fields = backgroundFields(floorId);
if (!fields.image) return;
const opacity = Number(fields.opacity?.value || 0.35);
const scale = Number(fields.scale?.value || 1);
const offsetX = Number(fields.offsetX?.value || 0);
const offsetY = Number(fields.offsetY?.value || 0);
fields.image.style.setProperty("--bg-opacity", Math.max(0.05, Math.min(opacity, 1)));
fields.image.style.setProperty("--bg-scale", Math.max(0.1, Math.min(scale, 5)));
fields.image.style.setProperty("--bg-offset-x", `${Math.max(-10000, Math.min(offsetX, 10000))}px`);
fields.image.style.setProperty("--bg-offset-y", `${Math.max(-10000, Math.min(offsetY, 10000))}px`);
fields.image.classList.toggle("is-locked", fields.locked?.checked !== false);
if (fields.opacityValue) fields.opacityValue.textContent = fields.opacity?.value || "0.35";
}
function markBackgroundDirty(floorId) {
updateBackgroundPreview(floorId);
setSaveStatus("dirty");
window.clearTimeout(backgroundTimers.get(floorId));
backgroundTimers.set(floorId, window.setTimeout(() => saveBackgroundSettingsAsync(floorId), 1000));
}
async function saveBackgroundSettingsAsync(floorId) {
const fields = backgroundFields(floorId);
if (!fields.form) return;
window.clearTimeout(backgroundTimers.get(floorId));
setSaveStatus("saving");
try {
const response = await fetch(fields.form.action, {
method: "POST",
body: new FormData(fields.form),
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin"
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.ok === false) {
throw new Error(result.message || "Unable to save background settings.");
}
setSaveStatus("saved");
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
}
}
function filterAddBlockLocations(floorLocationId) {
if (!addBlockLocation) return;
let firstValue = "";
[...addBlockLocation.options].forEach(option => {
if (!option.value) {
option.hidden = false;
option.disabled = false;
return;
}
const matches = option.dataset.parentLocationId === floorLocationId;
option.hidden = !matches;
option.disabled = !matches;
if (matches && !firstValue) firstValue = option.value;
});
if (!addBlockLocation.value || addBlockLocation.selectedOptions[0]?.disabled) {
addBlockLocation.value = firstValue;
}
}
function updateAddBlockMode() {
const selectedMode = addBlockModes.find(input => input.checked)?.value || "Existing";
const isCreate = selectedMode === "Create";
existingLocationPanel?.classList.toggle("d-none", isCreate);
newLocationPanel?.classList.toggle("d-none", !isCreate);
}
function attachBlockEvents(block) {
block.addEventListener("pointerdown", event => {
if (editor.classList.contains("floor-plan-editor--occupancy")) return;
selectBlock(block);
const grid = gridFor(block);
if (!grid) return;
const start = cellFromPointer(grid, event);
const current = readBlock(block);
const resizing = event.target.matches("[data-resize-handle]");
pointerState = { block, grid, start, current, resizing };
block.setPointerCapture(event.pointerId);
event.preventDefault();
});
}
function appendHidden(panel, name, value, field) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value ?? "";
if (field) input.dataset.blockField = field;
panel.append(input);
return input;
}
function createBlockEditor(block) {
const selectedPanel = editor.querySelector("#floor-plan-tool-selected");
if (!selectedPanel) return null;
const index = editor.querySelectorAll("[data-block-editor]").length;
const panel = document.createElement("div");
panel.className = "floor-plan-block-editor d-none";
panel.dataset.blockEditor = block.floorPlanBlockID;
appendHidden(panel, `Blocks[${index}].FloorPlanBlockID`, block.floorPlanBlockID);
appendHidden(panel, `Blocks[${index}].FloorPlanFloorID`, block.floorPlanFloorID, "floor");
appendHidden(panel, `Blocks[${index}].LocationName`, block.locationName || "");
appendHidden(panel, `Blocks[${index}].LocationPath`, block.locationPath || "");
const locationTemplate = editor.querySelector("[data-block-field='location']");
const typeTemplate = editor.querySelector("[data-block-field='type']");
const locationWrap = document.createElement("div");
locationWrap.className = "mb-2";
locationWrap.innerHTML = `<label class="form-label">Location</label>`;
const locationSelect = locationTemplate?.cloneNode(true) || document.createElement("select");
locationSelect.name = `Blocks[${index}].LocationID`;
locationSelect.dataset.blockField = "location";
locationSelect.value = block.locationID || "";
locationSelect.addEventListener("change", () => {
if (!selectedBlock) return;
selectedBlock.dataset.locationId = locationSelect.value || "";
checkOverlaps();
refreshTransitionDestination(selectedBlock.dataset.floorId);
markDirty();
});
locationWrap.append(locationSelect);
panel.append(locationWrap);
const typeWrap = document.createElement("div");
typeWrap.className = "mb-2";
typeWrap.innerHTML = `<label class="form-label">Type</label>`;
const typeSelect = typeTemplate?.cloneNode(true) || document.createElement("select");
typeSelect.name = `Blocks[${index}].BlockType`;
typeSelect.dataset.blockField = "type";
typeSelect.value = block.blockType || "Room";
typeSelect.addEventListener("change", () => {
if (!selectedBlock) return;
selectedBlock.className = selectedBlock.className
.split(" ")
.filter(name => !name.startsWith("floor-plan-block--"))
.concat(`floor-plan-block--${typeSelect.value.toLowerCase()}`)
.join(" ");
markDirty();
});
typeWrap.append(typeSelect);
panel.append(typeWrap);
const gridFields = document.createElement("div");
gridFields.className = "row g-2";
[
["X", "x", block.x, "X"],
["Y", "y", block.y, "Y"],
["Width", "w", block.widthCells, "WidthCells"],
["Height", "h", block.heightCells, "HeightCells"]
].forEach(([label, field, value, property]) => {
const wrap = document.createElement("div");
wrap.className = "col-6";
wrap.innerHTML = `<label class="form-label">${label}</label>`;
const input = field === "x" || field === "y" ? document.createElement("select") : document.createElement("input");
input.className = field === "x" || field === "y" ? "form-select form-select-sm" : "form-control form-control-sm";
if (input instanceof HTMLInputElement) {
input.type = "number";
input.min = "1";
input.max = "80";
} else {
input.dataset.coordinateAxis = field;
input.dataset.coordinateValue = value;
}
input.name = `Blocks[${index}].${property}`;
input.value = value;
input.dataset.blockField = field;
const updateBlockField = () => {
if (!selectedBlock) return;
writeBlock(selectedBlock, readBlock(selectedBlock));
markDirty();
};
input.addEventListener("input", updateBlockField);
input.addEventListener("change", updateBlockField);
wrap.append(input);
gridFields.append(wrap);
});
panel.append(gridFields);
const labelWrap = document.createElement("div");
labelWrap.className = "mt-2";
labelWrap.innerHTML = `<label class="form-label">Label override</label>`;
const labelInput = document.createElement("input");
labelInput.className = "form-control form-control-sm";
labelInput.name = `Blocks[${index}].LabelOverride`;
labelInput.value = block.labelOverride || "";
labelInput.addEventListener("input", markDirty);
labelWrap.append(labelInput);
panel.append(labelWrap);
const notesWrap = document.createElement("div");
notesWrap.className = "mt-2";
notesWrap.innerHTML = `<label class="form-label">Notes</label>`;
const notesInput = document.createElement("textarea");
notesInput.className = "form-control form-control-sm";
notesInput.name = `Blocks[${index}].Notes`;
notesInput.rows = 3;
notesInput.value = block.notes || "";
notesInput.addEventListener("input", markDirty);
notesWrap.append(notesInput);
panel.append(notesWrap);
selectedPanel.append(panel);
return panel;
}
function addBlockToGrid(block) {
const grid = editor.querySelector(`[data-floor-grid="${block.floorPlanFloorID}"]`);
if (!grid) return null;
const button = document.createElement("button");
button.type = "button";
button.className = `floor-plan-block floor-plan-block--${(block.blockType || "Room").toLowerCase()}`;
button.style.setProperty("--x", block.x);
button.style.setProperty("--y", block.y);
button.style.setProperty("--w", block.widthCells);
button.style.setProperty("--h", block.heightCells);
button.dataset.block = block.floorPlanBlockID;
button.dataset.blockIndex = blocks.length;
button.dataset.floorId = block.floorPlanFloorID;
button.dataset.locationId = block.locationID || "";
button.dataset.locationName = block.locationName || "";
button.dataset.locationPath = block.locationPath || "";
button.dataset.locationTitle = block.locationPath || "";
button.title = block.locationPath || "";
button.innerHTML = `<span class="floor-plan-block-label"></span><span class="floor-plan-resize-handle" data-resize-handle aria-hidden="true"></span>`;
button.querySelector(".floor-plan-block-label").textContent = block.displayLabel || block.locationName || "New block";
grid.append(button);
blocks.push(button);
attachBlockEvents(button);
createBlockEditor(block);
writeBlock(button, readBlock(button));
checkOverlaps();
return button;
}
async function addBlockAsync(event) {
event.preventDefault();
if (!addBlockForm) return;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
const scrollState = workspaceState();
setSaveStatus("saving");
try {
const response = await fetch(addBlockForm.action, {
method: "POST",
body: new FormData(addBlockForm),
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin"
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.ok === false || !result.block) {
throw new Error(result.message || "Unable to add block.");
}
scrollState.selectedBlockId = result.block.floorPlanBlockID;
await refreshWorkspaceAsync(scrollState);
} catch (error) {
setSaveStatus("failed", error.message || "Save failed");
}
}
function selectBlock(block, options = {}) {
const showEditor = options.showEditor !== false;
selectedBlock = block;
blocks.forEach(item => item.classList.toggle("selected", item === block));
editor.querySelectorAll("[data-block-editor]").forEach(panel => panel.classList.add("d-none"));
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
if (panel) panel.classList.remove("d-none");
if (noSelection) noSelection.classList.add("d-none");
if (showEditor) {
setToolboxTab("selected");
}
}
function fieldsFor(block) {
const panel = editor.querySelector(`[data-block-editor="${block.dataset.block}"]`);
return {
panel,
floor: panel?.querySelector("[data-block-field='floor']"),
x: panel?.querySelector("[data-block-field='x']"),
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']")
};
}
function gridFor(block) {
return editor.querySelector(`[data-floor-grid="${block.dataset.floorId}"]`);
}
function clampBlock(block, values) {
const grid = gridFor(block);
const gridWidth = Number(grid?.dataset.gridWidth || 24);
const gridHeight = Number(grid?.dataset.gridHeight || 16);
const w = Math.max(1, Math.min(Number(values.w), gridWidth));
const h = Math.max(1, Math.min(Number(values.h), gridHeight));
const x = Math.max(0, Math.min(Number(values.x), gridWidth - w));
const y = Math.max(0, Math.min(Number(values.y), gridHeight - h));
return { x, y, w, h };
}
function readBlock(block) {
const fields = fieldsFor(block);
return {
x: Number(fields.x?.value || fields.x?.dataset.coordinateValue || block.style.getPropertyValue("--x") || 0),
y: Number(fields.y?.value || fields.y?.dataset.coordinateValue || block.style.getPropertyValue("--y") || 0),
w: Number(fields.w?.value || block.style.getPropertyValue("--w") || 1),
h: Number(fields.h?.value || block.style.getPropertyValue("--h") || 1)
};
}
function writeBlock(block, values) {
const next = clampBlock(block, values);
const grid = gridFor(block);
const gridWidth = Number(grid?.dataset.gridWidth || 24);
const gridHeight = Number(grid?.dataset.gridHeight || 16);
const maxXOptions = Math.max(gridWidth - next.w + 1, 1);
const maxYOptions = Math.max(gridHeight - next.h + 1, 1);
block.style.setProperty("--x", next.x);
block.style.setProperty("--y", next.y);
block.style.setProperty("--w", next.w);
block.style.setProperty("--h", next.h);
const fields = fieldsFor(block);
populateCoordinateSelect(fields.x, maxXOptions, gridColumnLabel, next.x);
populateCoordinateSelect(fields.y, maxYOptions, value => String(value + 1), next.y);
if (fields.w) fields.w.value = next.w;
if (fields.h) fields.h.value = next.h;
scheduleGeometryRefresh();
}
function scheduleGeometryRefresh() {
if (geometryRefreshQueued) return;
geometryRefreshQueued = true;
window.requestAnimationFrame(() => {
geometryRefreshQueued = false;
checkOverlaps();
});
}
function rangesOverlap(aStart, aLength, bStart, bLength) {
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 isLadderTransition(type) {
return ["ladder", "ladders"].includes(normalizeTransitionType(type));
}
function isInsideLocationTransition(type) {
return isStairTransition(type) || isLadderTransition(type);
}
function isUnrestrictedTransition(type) {
return ["hiddenpassage", "openpassage", "secretpassage"].includes(normalizeTransitionType(type));
}
function isSecretRouteTransition(type) {
return ["hiddenpassage", "secretpassage"].includes(normalizeTransitionType(type));
}
function isVisibleEntranceTransition(type) {
return [
"door",
"doubledoor",
"frenchdoor",
"frenchdoors",
"slidingdoor",
"slidingdoors",
"exteriordoor",
"archway",
"openpassage",
"stairsup",
"stairsdown"
].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 blocksOverlapArea(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
function blockItemsForFloor(floorId) {
return [...editor.querySelectorAll(`[data-block][data-floor-id="${floorId}"]`)].map(block => ({ block, ...readBlock(block) }));
}
function floorNameFor(floorId) {
const tab = tabs.find(item => item.dataset.floorTab === String(floorId));
return tab?.dataset.floorName || tab?.textContent.trim() || "Floor";
}
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 ladderLocationOptions(currentFloorId, fromLocationId) {
const currentFloor = tabs.find(tab => tab.dataset.floorTab === String(currentFloorId));
const currentLevel = Number(currentFloor?.dataset.floorOrder || 0);
const floorsAbove = tabs
.filter(tab => Number(tab.dataset.floorOrder || 0) > currentLevel)
.sort((a, b) => Number(a.dataset.floorOrder || 0) - Number(b.dataset.floorOrder || 0)
|| (a.dataset.floorName || a.textContent.trim()).localeCompare(b.dataset.floorName || b.textContent.trim()));
const options = [];
floorsAbove.forEach(floor => {
locationOptionsFromBlocks(blockItemsForFloor(floor.dataset.floorTab))
.filter(option => option.id !== fromLocationId)
.forEach(option => {
const floorName = floorNameFor(floor.dataset.floorTab);
const parts = option.label.split(" > ").map(part => part.trim()).filter(Boolean);
const floorIndex = parts.lastIndexOf(floorName);
const locationLabel = floorIndex >= 0 && parts.length > floorIndex + 1
? parts.slice(floorIndex + 1).join(" > ")
: parts[parts.length - 1] || option.label;
options.push({
id: option.id,
label: `${floorName} > ${locationLabel}`,
level: Number(floor.dataset.floorOrder || 0),
floorName
});
});
});
return {
floorsAbove,
options: options.sort((a, b) => a.level - b.level
|| a.floorName.localeCompare(b.floorName)
|| 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, showAllStairTargets = false) {
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}"]`);
const fallbackButton = editor.querySelector(`[data-transition-stair-fallback="${floorId}"]`);
if (!typeSelect || !fromSelect || !toSelect) return;
const type = typeSelect.value;
const fromLocationId = fromSelect.value;
const stair = isStairTransition(type);
const ladder = isLadderTransition(type);
positionPanel?.classList.toggle("d-none", !isInsideLocationTransition(type));
fallbackButton?.classList.add("d-none");
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 isUp = normalizeTransitionType(type) === "stairsup";
const targetLevel = currentLevel + (isUp ? 1 : -1);
const targetFloor = tabs.find(tab => Number(tab.dataset.floorOrder || 0) === targetLevel);
emptyMessage = isUp
? "No floor exists above this level."
: "No floor exists below this level.";
if (targetFloor) {
const fromBlocks = blockItemsForFloor(floorId)
.filter(item => item.block.dataset.locationId === fromLocationId);
const targetBlocks = blockItemsForFloor(targetFloor.dataset.floorTab);
const overlappingLocationIds = new Set();
if (!showAllStairTargets) {
targetBlocks.forEach(target => {
const targetLocationId = target.block.dataset.locationId;
if (!targetLocationId || targetLocationId === fromLocationId) return;
if (fromBlocks.some(from => blocksOverlapArea(from, target))) {
overlappingLocationIds.add(targetLocationId);
}
});
}
options = locationOptionsFromBlocks(targetBlocks)
.filter(option => option.id !== fromLocationId)
.filter(option => showAllStairTargets || overlappingLocationIds.has(option.id));
emptyMessage = isUp
? "No locations directly above this location."
: "No locations directly below this location.";
if (!options.length && locationOptionsFromBlocks(targetBlocks).some(option => option.id !== fromLocationId)) {
fallbackButton?.classList.remove("d-none");
if (fallbackButton) {
fallbackButton.textContent = isUp
? "Show all locations on floor above"
: "Show all locations on floor below";
}
}
}
} else if (ladder) {
const ladderTargets = ladderLocationOptions(floorId, fromLocationId);
options = ladderTargets.options;
emptyMessage = ladderTargets.floorsAbove.length
? "No placed locations exist on floors above."
: "No floors above this level.";
} 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);
const clippedEnd = Math.min(segment.end, end);
if (clippedStart >= clippedEnd) return [segment];
return [
{ start: segment.start, end: clippedStart },
{ start: clippedEnd, end: segment.end }
].filter(next => next.end > next.start);
});
}
function renderWallSegment(block, side, start, end, total) {
if (end <= start || total <= 0) return;
const segment = document.createElement("span");
segment.dataset.wallSegment = side;
segment.className = `floor-plan-wall-segment floor-plan-wall-segment--${side}`;
segment.style.setProperty("--wall-start", `${(start / total) * 100}%`);
segment.style.setProperty("--wall-length", `${((end - start) / total) * 100}%`);
block.append(segment);
}
function visibleEdgeSegments(item, visibleBlocks, side) {
const axisStart = side === "east" || side === "west" ? item.y : item.x;
const axisLength = side === "east" || side === "west" ? item.h : item.w;
let segments = [{ start: 0, end: axisLength }];
visibleBlocks.forEach(other => {
if (item === other || !item.block.dataset.locationId || item.block.dataset.locationId !== other.block.dataset.locationId) return;
let touches = false;
let overlapStart = 0;
let overlapEnd = 0;
if (side === "east" && item.x + item.w === other.x) {
touches = true;
overlapStart = Math.max(item.y, other.y) - axisStart;
overlapEnd = Math.min(item.y + item.h, other.y + other.h) - axisStart;
} else if (side === "west" && other.x + other.w === item.x) {
touches = true;
overlapStart = Math.max(item.y, other.y) - axisStart;
overlapEnd = Math.min(item.y + item.h, other.y + other.h) - axisStart;
} else if (side === "south" && item.y + item.h === other.y) {
touches = true;
overlapStart = Math.max(item.x, other.x) - axisStart;
overlapEnd = Math.min(item.x + item.w, other.x + other.w) - axisStart;
} else if (side === "north" && other.y + other.h === item.y) {
touches = true;
overlapStart = Math.max(item.x, other.x) - axisStart;
overlapEnd = Math.min(item.x + item.w, other.x + other.w) - axisStart;
}
if (touches && overlapStart < overlapEnd) {
segments = subtractRange(segments, overlapStart, overlapEnd);
}
});
return segments;
}
function refreshWallSegments(visibleBlocks) {
visibleBlocks.forEach(item => {
item.block.querySelectorAll("[data-wall-segment]").forEach(segment => segment.remove());
["north", "east", "south", "west"].forEach(side => {
const total = side === "east" || side === "west" ? item.h : item.w;
visibleEdgeSegments(item, visibleBlocks, side).forEach(segment => {
renderWallSegment(item.block, side, segment.start, segment.end, total);
});
});
});
}
function refreshSharedWalls(activePanel) {
const visibleBlocks = [...activePanel.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) }));
refreshWallSegments(visibleBlocks);
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 refreshHiddenLocationStyles(visibleBlocks) {
const visibleEntranceLocationIds = new Set();
editor.querySelectorAll("[data-transition-marker]").forEach(marker => {
if (!isVisibleEntranceTransition(marker.dataset.transitionKind)) return;
if (marker.dataset.fromLocationId) visibleEntranceLocationIds.add(marker.dataset.fromLocationId);
if (marker.dataset.toLocationId) visibleEntranceLocationIds.add(marker.dataset.toLocationId);
});
visibleBlocks.forEach(item => {
const locationId = item.block.dataset.locationId;
const isHidden = !!locationId && !visibleEntranceLocationIds.has(locationId);
item.block.classList.toggle("is-hidden-location", isHidden);
const baseTitle = item.block.dataset.locationTitle || item.block.dataset.locationPath || item.block.dataset.locationName || "";
item.block.title = isHidden
? `${baseTitle}${baseTitle ? "\n" : ""}Hidden location: no visible entrance`
: baseTitle;
});
}
function renderSecretRoute(activePanel, marker, fromCenter, toCenter) {
if (!fromCenter || !toCenter) return;
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length <= 0) return;
const route = document.createElement("span");
route.className = "floor-plan-secret-route";
route.dataset.secretRoute = marker.dataset.transitionMarker || "";
route.style.setProperty("--route-x", fromCenter.x);
route.style.setProperty("--route-y", fromCenter.y);
route.style.setProperty("--route-length", length);
route.style.setProperty("--route-angle", `${Math.atan2(dy, dx)}rad`);
route.title = marker.title || "";
route.setAttribute("aria-hidden", "true");
activePanel.querySelector("[data-floor-grid]")?.append(route);
}
function sharedTransitionEdge(fromBlocks, toBlocks) {
let best = null;
fromBlocks.forEach(from => {
toBlocks.forEach(to => {
if (from.x + from.w === to.x || to.x + to.w === from.x) {
const start = Math.max(from.y, to.y);
const end = Math.min(from.y + from.h, to.y + to.h);
const length = end - start;
if (length > 0 && (!best || length > best.length)) {
best = {
length,
orientation: "vertical",
start,
end,
x: from.x + from.w === to.x ? to.x : from.x,
y: start + length / 2
};
}
}
if (from.y + from.h === to.y || to.y + to.h === from.y) {
const start = Math.max(from.x, to.x);
const end = Math.min(from.x + from.w, to.x + to.w);
const length = end - start;
if (length > 0 && (!best || length > best.length)) {
best = {
length,
orientation: "horizontal",
start,
end,
x: start + length / 2,
y: from.y + from.h === to.y ? to.y : from.y
};
}
}
});
});
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-secret-route]").forEach(route => route.remove());
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 (isSecretRouteTransition(marker.dataset.transitionKind)) {
marker.hidden = true;
if (fromCenter && toCenter) {
renderSecretRoute(activePanel, marker, fromCenter, toCenter);
}
return;
}
if (fromBlocks.length === 0 || (!isInsideLocationTransition(marker.dataset.transitionKind) && toBlocks.length === 0)) {
marker.hidden = true;
return;
}
const insidePlacement = isInsideLocationTransition(marker.dataset.transitionKind)
? positionWithinLocation(fromBlocks, marker.dataset.positionWithinLocation)
: null;
const sharedEdge = insidePlacement ? null : sharedTransitionEdge(fromBlocks, toBlocks);
marker.hidden = false;
marker.classList.toggle("is-shared-edge", !!sharedEdge);
marker.classList.toggle("is-inside-location", !!insidePlacement);
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 = insidePlacement
? `${pairKey}:inside:${normalizeTransitionType(marker.dataset.transitionKind)}:${marker.dataset.positionWithinLocation || "Centre"}:${insidePlacement.x}:${insidePlacement.y}`
: sharedEdge
? `${pairKey}:${sharedEdge.orientation}:${sharedEdge.x}:${sharedEdge.y}:${sharedEdge.start}:${sharedEdge.end}`
: `${pairKey}:fallback`;
markerPositions.push({ marker, insidePlacement, sharedEdge, fromCenter, toCenter, key });
});
const groups = markerPositions.reduce((map, item) => {
if (!map.has(item.key)) map.set(item.key, []);
map.get(item.key).push(item);
return map;
}, new Map());
groups.forEach(group => {
group.forEach((item, index) => {
const fraction = (index + 1) / (group.length + 1);
let x;
let y;
if (item.insidePlacement) {
x = item.insidePlacement.x;
y = item.insidePlacement.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") {
x = item.sharedEdge.start + item.sharedEdge.length * fraction;
y = item.sharedEdge.y;
} else {
x = item.fromCenter.x + (item.toCenter.x - item.fromCenter.x) * fraction;
y = item.fromCenter.y + (item.toCenter.y - item.fromCenter.y) * fraction;
}
item.marker.style.setProperty("--transition-x", x);
item.marker.style.setProperty("--transition-y", y);
});
});
}
function cellFromPointer(grid, event) {
const rect = grid.getBoundingClientRect();
const gridWidth = Number(grid.dataset.gridWidth || 24);
const gridHeight = Number(grid.dataset.gridHeight || 16);
return {
x: Math.floor((event.clientX - rect.left) / (rect.width / gridWidth)),
y: Math.floor((event.clientY - rect.top) / (rect.height / gridHeight))
};
}
function checkOverlaps() {
const activePanel = panels.find(panel => panel.classList.contains("active"));
if (!activePanel) return;
const visibleBlocks = refreshSharedWalls(activePanel);
positionTransitions(activePanel, visibleBlocks);
refreshHiddenLocationStyles(visibleBlocks);
let overlaps = false;
visibleBlocks.forEach(a => {
a.block.classList.remove("overlap");
visibleBlocks.forEach(b => {
if (a === b) return;
const separated = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
if (!separated) {
overlaps = true;
a.block.classList.add("overlap");
b.block.classList.add("overlap");
}
});
});
warning?.classList.toggle("d-none", !overlaps);
}
function applySizePreset() {
const [width, height] = (sizePreset?.value || "2x1").split("x");
if (addBlockWidth) addBlockWidth.value = width || "2";
if (addBlockHeight) addBlockHeight.value = height || "1";
}
function updateSizePresets() {
if (!sizePreset) return;
const presets = sizePresets[addBlockType?.value || "Room"] || sizePresets.Room;
sizePreset.innerHTML = "";
presets.forEach(([value, label], index) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
option.selected = index === 0;
sizePreset.append(option);
});
applySizePreset();
}
tabs.forEach(tab => tab.addEventListener("click", () => setActiveFloor(tab.dataset.floorTab)));
toolboxTabs.forEach(tab => tab.addEventListener("click", () => setToolboxTab(tab.dataset.toolboxTab)));
modeButtons.forEach(button => button.addEventListener("click", () => setFloorPlanMode(button.dataset.floorPlanMode)));
occupancySceneSelect?.addEventListener("change", () => {
applyOccupancySceneFloor();
refreshOccupancyTrays();
});
occupancyPrev?.addEventListener("click", () => moveOccupancyScene(-1));
occupancyNext?.addEventListener("click", () => moveOccupancyScene(1));
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);
});
editor.querySelectorAll("[data-transition-stair-fallback]").forEach(button => {
button.addEventListener("click", () => refreshTransitionDestination(button.dataset.transitionStairFallback, true));
});
if (gridCoordinatesToggle) {
gridCoordinatesToggle.checked = window.localStorage?.getItem(gridCoordinatesStorageKey) === "true";
gridCoordinatesToggle.addEventListener("change", () => {
window.localStorage?.setItem(gridCoordinatesStorageKey, gridCoordinatesToggle.checked ? "true" : "false");
refreshGridCoordinateLabels();
});
}
if (referenceFloorSelect) {
const storedReferenceFloor = window.localStorage?.getItem(referenceFloorStorageKey);
if (storedReferenceFloor && [...referenceFloorSelect.options].some(option => option.value === storedReferenceFloor)) {
referenceFloorSelect.value = storedReferenceFloor;
}
referenceFloorSelect.addEventListener("change", () => {
window.localStorage?.setItem(referenceFloorStorageKey, referenceFloorSelect.value);
refreshReferenceOverlay();
});
}
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", !isInsideLocationTransition(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 => {
if (event.defaultPrevented) return;
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
if (form === editor || form.id === "floor-plan-add-block-form" || form.matches("[data-background-settings-form]")) return;
event.preventDefault();
const state = workspaceState();
setSaveStatus("saving");
try {
const response = await fetch(form.action, {
method: (form.method || "POST").toUpperCase(),
body: new FormData(form),
credentials: "same-origin"
});
if (!response.ok) {
throw new Error("Save failed");
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, "text/html");
const nextWorkspace = doc.querySelector("[data-floor-plan-workspace]");
if (!nextWorkspace) {
throw new Error("The floor plan workspace could not be refreshed.");
}
cleanupModalArtifacts();
workspace.replaceWith(nextWorkspace);
window.initializeFloorPlanEditor({
activeFloorId: state.activeFloorId,
activeToolboxTab: state.activeToolboxTab,
selectedBlockId: state.selectedBlockId,
floorPlanMode: state.floorPlanMode,
occupancySceneId: state.occupancySceneId
});
window.requestAnimationFrame(() => {
const refreshedEditor = document.querySelector("[data-floor-plan-editor]");
refreshedEditor?.dispatchEvent(new CustomEvent("floor-plan-restore-state", { detail: state }));
});
} catch (error) {
showWorkspaceError(error.message || "Save failed");
}
});
editor.addEventListener("submit", event => {
event.preventDefault();
saveLayoutAsync();
});
blocks.forEach(block => {
attachBlockEvents(block);
writeBlock(block, readBlock(block));
});
editor.addEventListener("pointermove", event => {
if (!pointerState) return;
if (editor.classList.contains("floor-plan-editor--occupancy")) {
pointerState = null;
return;
}
const currentCell = cellFromPointer(pointerState.grid, event);
const dx = currentCell.x - pointerState.start.x;
const dy = currentCell.y - pointerState.start.y;
if (pointerState.resizing) {
writeBlock(pointerState.block, {
...pointerState.current,
w: pointerState.current.w + dx,
h: pointerState.current.h + dy
});
} else {
writeBlock(pointerState.block, {
...pointerState.current,
x: pointerState.current.x + dx,
y: pointerState.current.y + dy
});
}
});
editor.addEventListener("pointerup", () => {
if (pointerState) {
const floorId = pointerState.block.dataset.floorId;
checkOverlaps();
markDirty();
refreshTransitionDestination(floorId);
}
pointerState = null;
});
editor.addEventListener("pointercancel", () => {
if (pointerState) {
const floorId = pointerState.block.dataset.floorId;
checkOverlaps();
markDirty();
refreshTransitionDestination(floorId);
}
pointerState = null;
});
editor.querySelectorAll("[data-block-field='x'],[data-block-field='y'],[data-block-field='w'],[data-block-field='h']").forEach(input => {
const updateBlockField = () => {
if (!selectedBlock) return;
writeBlock(selectedBlock, readBlock(selectedBlock));
markDirty();
};
input.addEventListener("input", updateBlockField);
input.addEventListener("change", updateBlockField);
});
editor.querySelectorAll("[data-block-field='type']").forEach(select => {
select.addEventListener("change", () => {
if (!selectedBlock) return;
selectedBlock.className = selectedBlock.className
.split(" ")
.filter(name => !name.startsWith("floor-plan-block--"))
.concat(`floor-plan-block--${select.value.toLowerCase()}`)
.join(" ");
markDirty();
});
});
editor.querySelectorAll("[data-block-field='location']").forEach(select => {
select.addEventListener("change", () => {
if (!selectedBlock) return;
selectedBlock.dataset.locationId = select.value || "";
checkOverlaps();
refreshTransitionDestination(selectedBlock.dataset.floorId);
markDirty();
});
});
editor.querySelectorAll("[data-floor-width],[data-floor-height]").forEach(input => {
input.addEventListener("input", () => {
const floorId = input.dataset.floorWidth || input.dataset.floorHeight;
updateFloorGrid(floorId);
markDirty();
});
input.addEventListener("change", () => {
const floorId = input.dataset.floorWidth || input.dataset.floorHeight;
updateFloorGrid(floorId);
markDirty();
});
});
editor.querySelectorAll("[data-floor-level-input]").forEach(input => {
const updateFloorLevel = (clampValue = false) => {
const parsed = Number(input.value);
if (!Number.isFinite(parsed)) return;
if (clampValue && Number.isFinite(parsed)) {
input.value = Math.max(-10, Math.min(parsed, 50));
}
refreshAfterSave = true;
markDirty();
};
input.addEventListener("input", () => updateFloorLevel());
input.addEventListener("change", () => updateFloorLevel(true));
});
editor.querySelectorAll("[data-floor-location-select]").forEach(input => {
input.addEventListener("change", () => {
refreshAfterSave = true;
markDirty();
});
});
editor.querySelectorAll("input, select, textarea").forEach(input => {
if (input.matches("[form='floor-plan-add-block-form'], [form='floor-plan-add-floor-form']")) return;
if (input.matches("[form^='floor-plan-background-'], [form^='floor-plan-transition-add-']")) return;
if (input.matches("[data-background-field]")) 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);
});
editor.querySelectorAll("[data-background-field]").forEach(input => {
const eventName = input.type === "checkbox" ? "change" : "input";
input.addEventListener(eventName, () => markBackgroundDirty(input.dataset.backgroundFloor));
});
sizePreset?.addEventListener("change", applySizePreset);
addBlockType?.addEventListener("change", updateSizePresets);
updateSizePresets();
addBlockModes.forEach(input => input.addEventListener("change", updateAddBlockMode));
updateAddBlockMode();
const initialBlock = blocks.find(block => block.dataset.block === initialSelectedBlockId);
if (initialBlock) {
setActiveFloor(initialBlock.dataset.floorId);
selectBlock(initialBlock);
} else if (tabs[0]) {
setActiveFloor(tabs[0].dataset.floorTab);
}
refreshGridCoordinateLabels();
refreshReferenceOverlay();
refreshOccupancyTrays();
checkOverlaps();
};
window.initializeFloorPlanEditor();
</script>
}