Implemented the focused Floor Plans refinement pass.

Changed:
[Edit.cshtml (line 172)](C:/Source/PlotLine/PlotLine/Views/FloorPlans/Edit.cshtml:172)Added optional grid coordinate toggle with localStorage persistence.
Added adjacent-floor ghost/reference overlay selector.
Added overlap-aware Stairs Up/Down target filtering.
Added explicit fallback button: “Show all locations on floor above/below”.
Kept door/window same-floor adjacency filtering and Hidden/Open/Secret Passage unrestricted behaviour.

[site.css (line 4534)](C:/Source/PlotLine/PlotLine/wwwroot/css/site.css:4534)Added subtle coordinate labels, ghost floor blocks, warning styling, and dark-theme support.

[site.min.css](C:/Source/PlotLine/PlotLine/wwwroot/css/site.min.css)Synced minified CSS.

Database changes: none.
Stored procedures modified: none.
No Features, no transition redesign, no schema changes.
This commit is contained in:
Nick Beckley 2026-06-21 21:51:47 +01:00
parent 89c381e6bc
commit 1786d8c7b1
3 changed files with 312 additions and 9 deletions

View File

@ -167,6 +167,18 @@
}
</div>
<div class="button-row">
<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>
<span class="floor-plan-save-status" data-save-status>Saved</span>
<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>
@ -508,6 +520,7 @@
<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>
@ -890,6 +903,8 @@
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 initialSelectedBlockId = options.selectedBlockId || "@selectedBlockId";
const saveStatusText = {
saved: "Saved",
@ -938,6 +953,8 @@
let saveAgain = false;
let refreshAfterSave = false;
const backgroundTimers = new Map();
const gridCoordinatesStorageKey = "floorPlan.showGridCoordinates";
const referenceFloorStorageKey = "floorPlan.referenceFloor";
function setToolboxTab(tabName) {
toolboxTabs.forEach(tab => {
@ -958,6 +975,118 @@
saveStatus.textContent = message || saveStatusText[state] || state;
}
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 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);
@ -1131,6 +1260,9 @@
grid.querySelectorAll("[data-block]").forEach(block => writeBlock(block, readBlock(block)));
checkOverlaps();
refreshGridCoordinateLabels();
refreshReferenceOverlay();
refreshTransitionDestination(floorId);
}
function setActiveFloor(floorId) {
@ -1154,6 +1286,8 @@
}
filterAddBlockLocations(activeTab?.dataset.floorLocationId || "");
checkOverlaps();
refreshReferenceOverlay();
refreshTransitionDestination(floorId);
}
function backgroundFields(floorId) {
@ -1296,6 +1430,7 @@
if (!selectedBlock) return;
selectedBlock.dataset.locationId = locationSelect.value || "";
checkOverlaps();
refreshTransitionDestination(selectedBlock.dataset.floorId);
markDirty();
});
locationWrap.append(locationSelect);
@ -1528,6 +1663,10 @@
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) }));
}
@ -1565,18 +1704,20 @@
}
}
function refreshTransitionDestination(floorId) {
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);
positionPanel?.classList.toggle("d-none", !stair);
fallbackButton?.classList.add("d-none");
if (!fromLocationId) {
setTransitionOptions(toSelect, [], message, "Choose a From Location first.");
return;
@ -1587,15 +1728,41 @@
if (stair) {
const currentFloor = tabs.find(tab => tab.dataset.floorTab === String(floorId));
const currentLevel = Number(currentFloor?.dataset.floorOrder || 0);
const targetLevel = currentLevel + (normalizeTransitionType(type) === "stairsup" ? 1 : -1);
const isUp = normalizeTransitionType(type) === "stairsup";
const targetLevel = currentLevel + (isUp ? 1 : -1);
const targetFloor = tabs.find(tab => Number(tab.dataset.floorOrder || 0) === targetLevel);
emptyMessage = normalizeTransitionType(type) === "stairsup"
emptyMessage = isUp
? "No floor exists above this level."
: "No floor exists below this level.";
if (targetFloor) {
options = locationOptionsFromBlocks(blockItemsForFloor(targetFloor.dataset.floorTab))
.filter(option => option.id !== fromLocationId);
emptyMessage = options.length ? "" : "No locations are placed on the adjacent floor.";
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 (isUnrestrictedTransition(type)) {
options = locationOptionsFromBlocks([...editor.querySelectorAll("[data-block]")].map(block => ({ block, ...readBlock(block) })))
@ -1881,6 +2048,26 @@
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", !isStairTransition(select.value));
@ -1960,11 +2147,19 @@
});
editor.addEventListener("pointerup", () => {
if (pointerState) markDirty();
if (pointerState) {
const floorId = pointerState.block.dataset.floorId;
markDirty();
refreshTransitionDestination(floorId);
}
pointerState = null;
});
editor.addEventListener("pointercancel", () => {
if (pointerState) markDirty();
if (pointerState) {
const floorId = pointerState.block.dataset.floorId;
markDirty();
refreshTransitionDestination(floorId);
}
pointerState = null;
});
@ -1995,6 +2190,7 @@
if (!selectedBlock) return;
selectedBlock.dataset.locationId = select.value || "";
checkOverlaps();
refreshTransitionDestination(selectedBlock.dataset.floorId);
markDirty();
});
});
@ -2054,6 +2250,8 @@
} else if (tabs[0]) {
setActiveFloor(tabs[0].dataset.floorTab);
}
refreshGridCoordinateLabels();
refreshReferenceOverlay();
checkOverlaps();
};

View File

@ -4531,6 +4531,26 @@ body.dragging-location [data-drag-type="location"] {
line-height: 1.1;
}
.floor-plan-toolbar-check,
.floor-plan-reference-control {
align-items: center;
color: var(--muted-color, #6b756f);
display: inline-flex;
font-size: .82rem;
font-weight: 700;
gap: 6px;
}
.floor-plan-reference-control {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.floor-plan-reference-control .form-select {
min-width: 180px;
}
.floor-plan-save-status {
border: 1px solid rgba(47, 111, 99, .2);
border-radius: 999px;
@ -4594,6 +4614,71 @@ body.dragging-location [data-drag-type="location"] {
width: calc(var(--grid-width) * var(--cell-size));
}
.floor-plan-coordinate-label {
color: rgba(33, 61, 55, .42);
font-size: .62rem;
font-weight: 800;
line-height: 1;
pointer-events: none;
position: absolute;
text-shadow: 0 1px 0 rgba(255, 255, 255, .7);
user-select: none;
z-index: 5;
}
.floor-plan-coordinate-label--column {
left: calc(var(--coord-x) * var(--cell-size) + 4px);
top: 3px;
}
.floor-plan-coordinate-label--row {
left: 4px;
top: calc(var(--coord-y) * var(--cell-size) + 4px);
}
.floor-plan-reference-overlay {
inset: 0;
pointer-events: none;
position: absolute;
z-index: 1;
}
.floor-plan-reference-block {
align-items: center;
background: rgba(47, 111, 99, .08);
border: 1px dashed rgba(47, 111, 99, .34);
border-radius: 2px;
color: rgba(27, 55, 49, .46);
display: flex;
font-size: .62rem;
font-weight: 800;
height: calc(var(--h) * var(--cell-size));
justify-content: center;
left: calc(var(--x) * var(--cell-size));
line-height: 1.05;
overflow: hidden;
padding: 3px;
position: absolute;
text-align: center;
top: calc(var(--y) * var(--cell-size));
width: calc(var(--w) * var(--cell-size));
}
.floor-plan-reference-warning {
background: rgba(251, 236, 211, .92);
border: 1px solid rgba(184, 118, 43, .34);
border-radius: 999px;
color: #7b4b12;
font-size: .76rem;
font-weight: 800;
left: 10px;
padding: 4px 10px;
pointer-events: none;
position: absolute;
top: 10px;
z-index: 6;
}
.floor-plan-background-image {
height: auto;
left: 0;
@ -5066,6 +5151,26 @@ body.dragging-location [data-drag-type="location"] {
color: #f1f6f3;
}
[data-bs-theme="dark"] .floor-plan-coordinate-label,
.dark .floor-plan-coordinate-label {
color: rgba(244, 234, 220, .46);
text-shadow: 0 1px 0 rgba(0, 0, 0, .55);
}
[data-bs-theme="dark"] .floor-plan-reference-block,
.dark .floor-plan-reference-block {
background: rgba(131, 199, 242, .08);
border-color: rgba(131, 199, 242, .32);
color: rgba(244, 234, 220, .46);
}
[data-bs-theme="dark"] .floor-plan-reference-warning,
.dark .floor-plan-reference-warning {
background: rgba(84, 57, 25, .92);
border-color: rgba(244, 177, 100, .32);
color: #ffd9a5;
}
[data-bs-theme="dark"] .floor-plan-transition-marker,
.dark .floor-plan-transition-marker {
--transition-marker-ink: #f4eadc;

File diff suppressed because one or more lines are too long