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

@@ -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>