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

@@ -3,6 +3,8 @@
<div class="page-header">
<h2>Map Editor</h2>
<div class="header-actions">
<button class="btn btn-secondary" @click="openTransform">Recalibrate level</button>
<button class="btn btn-secondary" @click="openSnapshots">Undo history</button>
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
</div>
</div>
@@ -12,6 +14,13 @@
<div class="asset-panel">
<div class="panel-header">
<h3>Assets</h3>
<select v-if="levelOptions().length > 1" v-model.number="editingLevelId"
class="form-control" title="Which drawing you are placing on">
<option v-for="option in levelOptions()" :key="option.levelid"
:value="option.levelid">{{ option.label }}</option>
</select>
<input v-model="search" class="form-control" type="search"
placeholder="Search name or asset number" />
<select v-model="filterType" class="form-control">
<option value="">All Types</option>
<option value="machine">Machines</option>
@@ -25,7 +34,15 @@
<div class="asset-filter">
<label class="filter-checkbox">
<input type="checkbox" v-model="showUnplacedOnly" />
Show unplaced only
Unplaced only
</label>
<label class="filter-checkbox" title="Positions a transform moved, which nobody has confirmed against the current drawing yet">
<input type="checkbox" v-model="showUnverifiedOnly" />
Needs review ({{ unverifiedCount }})
</label>
<label class="filter-checkbox" title="Only assets on the level you are editing">
<input type="checkbox" v-model="thisLevelOnly" />
This level only
</label>
</div>
@@ -47,6 +64,15 @@
<div class="asset-meta">
<span class="badge badge-sm">{{ asset.assettype }}</span>
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
<span v-if="asset.levelid && asset.levelid !== editingLevelId"
class="badge badge-sm badge-other-level"
:title="'On ' + levelName(asset.levelid)">{{ levelName(asset.levelid) }}</span>
<span v-if="asset.mapx && !asset.levelid" class="badge badge-sm badge-warning"
title="Has a position but no level, so it cannot be drawn">no level</span>
<button v-if="asset.mapx && asset.levelid && !asset.mapverifiedat"
class="badge badge-sm badge-review" type="button"
title="This position came from a transform. Click to confirm it is right."
@click.stop="markReviewed(asset)">confirm</button>
</div>
</div>
</div>
@@ -96,19 +122,114 @@
:theme="currentTheme"
:pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
:levelid="editingLevelId"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</div>
</div>
<!-- Recalibrate: move every marker on this level by a transform read off two
landmarks. Deriving it from the image dimensions instead would stretch
one axis and be wrong everywhere, so the numbers come from features
visible on both drawings. -->
<div v-if="showTransform" class="modal-overlay">
<div class="modal modal-wide">
<div class="modal-header"><h3>Recalibrate {{ levelName(editingLevelId) }}</h3></div>
<div class="modal-body">
<p class="input-hint">
Pick two features present on both the old and the new drawing. A
building corner plus something central beats two corners: a long
baseline makes the derived scale more forgiving.
</p>
<table class="data-table">
<thead>
<tr><th>Landmark</th><th>Old X</th><th>Old Y</th><th>New X</th><th>New Y</th></tr>
</thead>
<tbody>
<tr v-for="(mark, index) in landmarks" :key="index">
<td>{{ index + 1 }}</td>
<td><input v-model.number="mark.fromx" type="number" class="form-control" /></td>
<td><input v-model.number="mark.fromy" type="number" class="form-control" /></td>
<td><input v-model.number="mark.tox" type="number" class="form-control" /></td>
<td><input v-model.number="mark.toy" type="number" class="form-control" /></td>
</tr>
</tbody>
</table>
<button class="btn btn-small btn-secondary" @click="landmarks.push({})">
Add a third landmark
</button>
<div v-if="preview" class="transform-preview">
<h4>Preview</h4>
<p class="mono">
X: scale {{ preview.transform.scalex.toFixed(4) }}, offset {{ Math.round(preview.transform.offsetx) }}<br />
Y: scale {{ preview.transform.scaley.toFixed(4) }}, offset {{ Math.round(preview.transform.offsety) }}
</p>
<p>
{{ preview.assetcount }} marker(s) would move.
<strong v-if="preview.outofboundscount" class="warn">
{{ preview.outofboundscount }} would land outside the drawing.
</strong>
</p>
<p class="input-hint">
A scale near 1 means the drawing shifted rather than rescaled. A
scale far from 1 on an axis that only gained canvas is the sign of a
bad landmark pair.
</p>
</div>
<div v-if="transformError" class="error-message">{{ transformError }}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showTransform = false">Cancel</button>
<button class="btn btn-secondary" @click="runTransform(true)" :disabled="working">Preview</button>
<button class="btn btn-primary" @click="runTransform(false)" :disabled="working || !preview">
Move {{ preview ? preview.assetcount : 0 }} marker(s)
</button>
</div>
</div>
</div>
<!-- Undo. Every bulk write snapshots first, and a restore snapshots too, so a
second attempt is always possible. -->
<div v-if="showSnapshots" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Undo history</h3></div>
<div class="modal-body">
<table class="data-table">
<thead><tr><th>When</th><th>What</th><th>Markers</th><th></th></tr></thead>
<tbody>
<tr v-for="snapshot in snapshots" :key="snapshot.snapshotid">
<td class="mono">{{ (snapshot.createddate || '').slice(0, 16).replace('T', ' ') }}</td>
<td>{{ snapshot.reason }}</td>
<td>{{ snapshot.assetcount }}</td>
<td>
<button class="btn btn-small" @click="restore(snapshot)" :disabled="working">
{{ snapshot.restoredat ? 'Restore again' : 'Restore' }}
</button>
</td>
</tr>
<tr v-if="!snapshots.length">
<td colspan="4" class="empty">Nothing to undo yet.</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showSnapshots = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { assetsApi, mapPositionsApi } from '../api'
import { loadMapConfig, levelOptions, levelName, setCurrentLevel,
state as mapConfig } from '../composables/mapConfig'
import { currentTheme } from '../stores/theme'
import { useToast } from '../composables/toast'
const toast = useToast()
@@ -119,6 +240,25 @@ const selectedAsset = ref(null)
const pickedPosition = ref(null)
const filterType = ref('')
const showUnplacedOnly = ref(false)
// A transform is a guess: it moves markers but cannot know which machines
// actually moved, so everything it touches needs confirming against the drawing.
// This is the working queue for that.
const showUnverifiedOnly = ref(false)
const thisLevelOnly = ref(true)
const search = ref('')
// Which drawing is being placed on. Every position saved from here belongs to it.
const editingLevelId = ref(null)
// Recalibration state. Two landmarks is the minimum that determines both axes;
// a third is offered because points picked by eye carry a few pixels of error and
// three let it cancel rather than accumulate.
const showTransform = ref(false)
const landmarks = ref([{}, {}])
const preview = ref(null)
const transformError = ref('')
const showSnapshots = ref(false)
const snapshots = ref([])
const working = ref(false)
const filteredAssets = computed(() => {
let result = assets.value
@@ -131,17 +271,48 @@ const filteredAssets = computed(() => {
result = result.filter(a => !a.mapx || !a.mapy)
}
if (showUnverifiedOnly.value) {
// Placed, on a level, and never confirmed. An unplaced asset is not
// "unreviewed" - it is simply not on the map yet.
result = result.filter(a => a.mapx && a.levelid && !a.mapverifiedat)
}
if (thisLevelOnly.value && editingLevelId.value) {
// Unplaced assets stay visible whatever the level filter says: they are the
// ones you are here to place, and they belong to no level yet.
result = result.filter(a => !a.mapx || a.levelid === editingLevelId.value)
}
const term = search.value.trim().toLowerCase()
if (term) {
result = result.filter(a =>
(a.name || '').toLowerCase().includes(term) ||
(a.assetnumber || '').toLowerCase().includes(term))
}
return result
})
// Only markers on the level being drawn. Markers from another level would appear
// at coordinates that mean nothing on this one.
const placedAssets = computed(() => {
return assets.value.filter(a => a.mapx && a.mapy)
return assets.value.filter(a => a.mapx && a.mapy &&
a.levelid === editingLevelId.value)
})
const unverifiedCount = computed(() =>
assets.value.filter(a => a.mapx && a.levelid && !a.mapverifiedat).length)
onMounted(async () => {
await loadMapConfig()
editingLevelId.value = mapConfig.defaultlevelid
await loadAssets()
})
watch(editingLevelId, (levelid) => {
if (levelid) setCurrentLevel(levelid)
})
async function loadAssets() {
loading.value = true
try {
@@ -154,6 +325,80 @@ async function loadAssets() {
}
}
function openTransform() {
preview.value = null
transformError.value = ''
landmarks.value = [{}, {}]
showTransform.value = true
}
async function runTransform(dryrun) {
working.value = true
transformError.value = ''
try {
const { data } = await mapPositionsApi.transform({
levelid: editingLevelId.value,
landmarks: landmarks.value.filter(mark =>
[mark.fromx, mark.fromy, mark.tox, mark.toy].every(
value => value !== undefined && value !== null && value !== '')),
dryrun,
})
if (dryrun) {
preview.value = data.data
} else {
showTransform.value = false
preview.value = null
await loadAssets()
// Straight into the review queue: every moved marker is now unconfirmed,
// and that is the work the transform created.
showUnverifiedOnly.value = true
toast.success(data.message || 'Markers moved')
}
} catch (err) {
transformError.value = err?.response?.data?.data?.error?.message
|| 'The transform could not be applied'
} finally {
working.value = false
}
}
async function openSnapshots() {
showSnapshots.value = true
try {
const { data } = await mapPositionsApi.snapshots()
snapshots.value = data.data || []
} catch (err) {
snapshots.value = []
}
}
async function restore(snapshot) {
if (!confirm(`Restore ${snapshot.assetcount} marker position(s) from ` +
`"${snapshot.reason}"? The current positions are snapshotted first.`)) return
working.value = true
try {
const { data } = await mapPositionsApi.restore(snapshot.snapshotid)
await loadAssets()
await openSnapshots()
toast.success(data.message || 'Positions restored')
} catch (err) {
toast.error('Could not restore that snapshot')
} finally {
working.value = false
}
}
// Confirm a marker is in the right place without moving it - the common case in
// a review pass, and the reason the queue empties.
async function markReviewed(asset) {
try {
await mapPositionsApi.verify([asset.assetid])
asset.mapverifiedat = new Date().toISOString()
} catch (err) {
toast.error('Could not mark that reviewed')
}
}
function getTypeIcon(assettype) {
const icons = {
'machine': Cog,
@@ -184,16 +429,23 @@ async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
// Through the bulk endpoint, which requires a level and stamps the review
// state: placing a marker by hand IS the confirmation, and a snapshot is
// taken so the placement can be undone.
await mapPositionsApi.setPositions([{
assetid: selectedAsset.value.assetid,
mapx: Math.round(pickedPosition.value.left),
mapy: Math.round(pickedPosition.value.top)
})
mapy: Math.round(pickedPosition.value.top),
levelid: editingLevelId.value,
}])
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = Math.round(pickedPosition.value.left)
asset.mapy = Math.round(pickedPosition.value.top)
asset.levelid = editingLevelId.value
asset.mapverifiedat = new Date().toISOString()
}
selectedAsset.value = null
@@ -409,4 +661,31 @@ function cancelEdit() {
text-align: center;
color: var(--text-light);
}
.badge-review {
background: var(--warning);
color: #1a1a1a;
border: none;
cursor: pointer;
}
.badge-other-level {
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.transform-preview {
margin-top: 1rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.transform-preview h4 { margin: 0 0 0.5rem; }
.mono { font-family: monospace; }
.warn { color: var(--warning); }
.modal-wide { min-width: 44rem; }
.input-hint { display: block; color: var(--text-light); font-size: 0.8rem; }
</style>