Buildings and levels for the floor map, and make every identifier searchable

This commit is contained in:
2026-08-17 13:03:54 -04:00
parent 47fb65fdd2
commit fbd99041b0
54 changed files with 4173 additions and 880 deletions

View File

@@ -1,101 +1,416 @@
<template>
<div>
<div class="page-header">
<h2>Floor Map</h2>
<h2>Floor Maps</h2>
<button class="btn btn-secondary" @click="showAddBuilding = true">Add building</button>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<p class="setting-description">
Each level is one drawing with its own blueprint and its own pixel size.
Marker positions are pixels in the level's own space, so the level id below
is what a position belongs to - it is worth knowing when you run a
transform or ask about a marker that is in the wrong place.
</p>
<div class="setting-row">
<label>
<span>Blueprint image (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
</label>
<div v-if="loading" class="empty">Loading...</div>
<div v-for="building in buildings" :key="building.buildingid" class="section-card">
<div class="building-header">
<input
v-model="building.buildingname"
class="building-name"
@blur="renameBuilding(building)"
:disabled="saving"
/>
<span class="muted">{{ building.levels.length }} level(s)</span>
<button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
</div>
<table class="data-table">
<thead>
<tr>
<th title="What a marker position on this level refers to">Level id</th>
<th>Name</th>
<th>Order</th>
<th>Native size</th>
<th>Markers</th>
<th>Light</th>
<th>Dark</th>
<th>Default</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="level in building.levels" :key="level.levelid">
<td><code class="levelid">{{ level.levelid }}</code></td>
<td>
<input
v-model="level.levelname"
class="form-control"
@blur="renameLevel(level)"
:disabled="saving"
/>
</td>
<td>
<input
v-model.number="level.sortorder"
type="number"
class="form-control order-input"
@blur="saveLevel(level, { sortorder: level.sortorder })"
:disabled="saving"
title="Lower sorts first. Ground 0, first floor 1, mezzanine 5 between them later."
/>
</td>
<td>
<span class="mono">{{ level.mapwidth }} x {{ level.mapheight }}</span>
<small v-if="level.assetcount" class="input-hint">
fixed while {{ level.assetcount }} marker(s) are placed
</small>
<input
v-else
v-model.number="level.mapwidth"
type="number"
class="form-control size-input"
@blur="saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })"
:disabled="saving"
/>
</td>
<td>
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
</td>
<td>
<img v-if="level.blueprintlight" :src="withBase(level.blueprintlight)"
class="map-thumb" alt="light blueprint" />
<input type="file" accept="image/*" class="file-input"
@change="upload(level, 'light', $event)" :disabled="uploading" />
</td>
<td>
<img v-if="level.blueprintdark" :src="withBase(level.blueprintdark)"
class="map-thumb map-thumb-dark" alt="dark blueprint" />
<input type="file" accept="image/*" class="file-input"
@change="upload(level, 'dark', $event)" :disabled="uploading" />
</td>
<td>
<input type="radio" :checked="level.isdefault" name="defaultlevel"
@change="saveLevel(level, { isdefault: true })"
title="Where an asset with no level lands, and what the map opens on" />
</td>
<td class="actions">
<button class="btn btn-small btn-danger" @click="remove(level)"
:disabled="saving">Remove</button>
</td>
</tr>
<tr v-if="!building.levels.length">
<td colspan="9" class="empty">No levels yet. Add one, then upload its blueprint.</td>
</tr>
</tbody>
</table>
</div>
<!-- Add building -->
<div v-if="showAddBuilding" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Add building</h3></div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input v-model="newBuilding" class="form-control" placeholder="Annex"
@keyup.enter="addBuilding" />
</div>
</div>
<div class="setting-row">
<label>
<span>Blueprint image (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
</label>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddBuilding = false">Cancel</button>
<button class="btn btn-primary" @click="addBuilding" :disabled="!newBuilding.trim()">Add</button>
</div>
</div>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
<!-- Add level -->
<div v-if="addLevelFor" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Add level to {{ addLevelFor.buildingname }}</h3></div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input v-model="newLevel.levelname" class="form-control"
placeholder="Second floor" @keyup.enter="addLevel" />
<small class="input-hint">
Whatever the building calls it. Basement, Ground, Mezzanine, Roof.
</small>
</div>
<div class="form-group">
<label>Sort order</label>
<input v-model.number="newLevel.sortorder" type="number" class="form-control" />
<small class="input-hint">
Lower sorts first, and gaps are fine - leaving room lets a mezzanine
slot in later without renumbering anything.
</small>
</div>
<p class="input-hint">
Upload the blueprint after creating it. An empty level takes its pixel
size from the image, so there is nothing to measure by hand.
</p>
</div>
<div class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
<div class="modal-footer">
<button class="btn btn-secondary" @click="addLevelFor = null">Cancel</button>
<button class="btn btn-primary" @click="addLevel"
:disabled="!newLevel.levelname.trim()">Add</button>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
<div v-if="notice" class="settings-success">{{ notice }}</div>
</div>
</template>
<script setup>
import { withBase } from '../../utils/basePath'
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
// Buildings and levels admin (ADR-017). This page replaced four site-wide
// settings that described a single blueprint, which could not express a second
// level drawn at a different size, let alone a second building.
//
// The level id is deliberately on screen. It is what `assets.levelid` points at,
// what a landmark transform takes as an argument, and the first thing worth
// knowing when a marker draws on the wrong drawing.
import { ref, onMounted } from 'vue'
const {
settings, saving, mapUploading, error, success,
loadSettings, saveSetting, uploadBlueprint,
} = useSystemSettings()
import { mapLevelsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { reloadMapConfig } from '@/composables/mapConfig'
onMounted(loadSettings)
const buildings = ref([])
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const error = ref('')
const notice = ref('')
const showAddBuilding = ref(false)
const newBuilding = ref('')
const addLevelFor = ref(null)
const newLevel = ref({ levelname: '', sortorder: 0 })
onMounted(load)
async function load() {
loading.value = true
try {
const { data } = await mapLevelsApi.list()
buildings.value = data.data.buildings || []
} catch (err) {
error.value = message(err, 'Could not load the levels')
} finally {
loading.value = false
}
}
function message(err, fallback) {
return err?.response?.data?.data?.error?.message || fallback
}
function report(text) {
notice.value = text
error.value = ''
// Long enough to read a sentence about what did not happen.
setTimeout(() => { notice.value = '' }, 8000)
}
async function addBuilding() {
const name = newBuilding.value.trim()
if (!name) return
saving.value = true
try {
await mapLevelsApi.createBuilding({
buildingname: name,
sortorder: buildings.value.length,
})
showAddBuilding.value = false
newBuilding.value = ''
await load()
report(`Building "${name}" added. Add its levels next.`)
} catch (err) {
error.value = message(err, 'Could not add the building')
} finally {
saving.value = false
}
}
function startAddLevel(building) {
addLevelFor.value = building
// Default to one past the last, so the common case needs no thought and the
// uncommon one is still editable.
newLevel.value = {
levelname: '',
sortorder: (building.levels.at(-1)?.sortorder ?? -1) + 1,
}
}
async function addLevel() {
const name = newLevel.value.levelname.trim()
if (!name || !addLevelFor.value) return
saving.value = true
try {
const { data } = await mapLevelsApi.create({
buildingid: addLevelFor.value.buildingid,
levelname: name,
sortorder: newLevel.value.sortorder,
})
addLevelFor.value = null
await load()
report(`"${name}" created as level ${data.data.levelid}. Upload its blueprint to set its size.`)
} catch (err) {
error.value = message(err, 'Could not add the level')
} finally {
saving.value = false
}
}
async function renameBuilding(building) {
const name = (building.buildingname || '').trim()
if (!name) {
await load()
return
}
saving.value = true
try {
await mapLevelsApi.updateBuilding(building.buildingid, { buildingname: name })
report(`Building renamed to "${name}".`)
} catch (err) {
error.value = message(err, 'Could not rename the building')
await load()
} finally {
saving.value = false
}
}
async function renameLevel(level) {
const name = (level.levelname || '').trim()
if (!name) {
await load()
return
}
await saveLevel(level, { levelname: name })
}
async function saveLevel(level, payload) {
saving.value = true
try {
const { data } = await mapLevelsApi.update(level.levelid, payload)
// The server reports when a change leaves existing positions in an old
// coordinate space. Surfacing that verbatim matters more than a tidy
// message: it names the markers that are now wrong.
const warnings = data.data?.warnings
await load()
await reloadMapConfig()
report(warnings?.length ? warnings.join(' ') : 'Saved.')
} catch (err) {
error.value = message(err, 'Could not save the level')
await load()
} finally {
saving.value = false
}
}
async function upload(level, theme, event) {
const file = event.target.files?.[0]
if (!file) return
uploading.value = true
try {
const { data } = await mapLevelsApi.uploadBlueprint(level.levelid, theme, file)
await load()
await reloadMapConfig()
// sizenote is the interesting case: the image disagrees with the stored
// dimensions AND markers are already placed, so the server refused to
// change the coordinate space out from under them.
report(data.data.sizenote
|| `Blueprint uploaded (${data.data.detectedwidth} x ${data.data.detectedheight}).`)
} catch (err) {
error.value = message(err, 'Could not upload the blueprint')
} finally {
uploading.value = false
event.target.value = ''
}
}
async function remove(level) {
const placed = level.assetcount
? ` It has ${level.assetcount} marker(s) on it, which the server will refuse.`
: ''
if (!confirm(`Remove "${level.levelname}" (level ${level.levelid})?${placed}`)) return
saving.value = true
try {
await mapLevelsApi.remove(level.levelid)
await load()
report(`Level ${level.levelid} removed.`)
} catch (err) {
error.value = message(err, 'Could not remove the level')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.setting-description {
color: var(--text-light);
max-width: 60rem;
margin-bottom: 1rem;
}
.building-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.building-name {
font-size: 1.05rem;
font-weight: 600;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.25rem 0.4rem;
color: var(--text);
}
.building-name:hover,
.building-name:focus {
border-color: var(--border);
background: var(--bg);
}
.levelid {
font-family: monospace;
font-size: 0.95rem;
padding: 0.1rem 0.4rem;
background: var(--bg);
border-radius: 3px;
}
.mono { font-family: monospace; }
.order-input { width: 4.5rem; }
.size-input { width: 6rem; }
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
.map-thumb {
max-width: 5rem;
max-height: 3rem;
border: 1px solid var(--border);
display: block;
}
.map-thumb-dark { background: #222; }
.muted { color: var(--text-light); }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.input-hint { display: block; color: var(--text-light); font-size: 0.75rem; }
</style>