Buildings and levels for the floor map, and make every identifier searchable
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
This commit is contained in:
cproudlock
2026-08-17 12:55:51 -04:00
parent 7d9a54ca0f
commit 3324dbd91e
60 changed files with 5313 additions and 895 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>