Buildings and levels for the floor map, and make every identifier searchable
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user