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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user