Fix the levels viewer: markers were drawn on whichever plan was showing

This commit is contained in:
2026-08-17 15:01:09 -04:00
parent ece0e15f03
commit 4c3d5746e8
11 changed files with 214 additions and 38 deletions

View File

@@ -492,7 +492,7 @@ function cancelEdit() {
.map-editor {
display: flex;
flex-direction: column;
height: calc(100vh - 40px);
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
}
.header-actions {

View File

@@ -12,6 +12,17 @@
<template v-else>
<!-- Filter Controls -->
<div class="map-filters">
<select
v-if="levelChoices.length > 1"
v-model.number="shownLevelId"
@change="onLevelChange"
title="Which floor plan to show"
>
<option v-for="level in levelChoices" :key="level.levelid" :value="level.levelid">
{{ level.label }}
</option>
</select>
<select v-model="selectedType" @change="onTypeChange">
<option value="">All Asset Types</option>
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
@@ -60,6 +71,7 @@
</div>
<ShopFloorMap
:levelid="shownLevelId"
:machines="filteredAssets"
:machinetypes="[]"
:businessunits="businessunits"
@@ -82,7 +94,8 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, state as mapConfig } from '@/composables/mapConfig'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, levelOptions,
setCurrentLevel, state as mapConfig } from '@/composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors'
@@ -104,6 +117,41 @@ const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
// Which floor plan is on screen. Every marker coordinate is pixels of ONE level
// (ADR-017), so this decides both the drawing and which markers belong on it.
const shownLevelId = ref(null)
const levelChoices = computed(() => levelOptions())
function onLevelChange() {
// Keep the shared composable in step, so a map-position picker opened later
// starts on the level being looked at rather than the site default.
setCurrentLevel(shownLevelId.value)
}
// A search whose only matches are on another floor would otherwise show an empty
// map: the assets matched, the filter counted them, and nothing was drawn.
// Follow the results to the level that actually holds them - the level with the
// most matches, so a search matching several floors lands on the best one.
function followSearchAcrossLevels() {
if (!searchQuery.value.trim()) return
const matches = filteredAssets.value.filter(a => a.mapx != null && a.mapy != null)
if (!matches.length) return
if (matches.some(a => (a.levelid ?? null) === shownLevelId.value)) return
const tally = new Map()
matches.forEach(asset => {
const levelid = asset.levelid ?? null
if (levelid === null) return
tally.set(levelid, (tally.get(levelid) || 0) + 1)
})
if (!tally.size) return
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
shownLevelId.value = best
setCurrentLevel(best)
}
const exporting = ref(false)
let searchTimeout = null
@@ -245,12 +293,12 @@ async function exportPdf() {
// blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper.
blueprintUrl: blueprintUrlFor('light', mapConfig.currentlevelid),
mapWidth: dimensionsFor(mapConfig.currentlevelid).width,
mapHeight: dimensionsFor(mapConfig.currentlevelid).height,
blueprintUrl: blueprintUrlFor('light', shownLevelId.value),
mapWidth: dimensionsFor(shownLevelId.value).width,
mapHeight: dimensionsFor(shownLevelId.value).height,
// Named on the sheet, because a floor plan with no level on it is not
// identifiable once it is printed and carried to the floor.
levelname: levelName(mapConfig.currentlevelid),
levelname: levelName(shownLevelId.value),
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,
@@ -265,7 +313,8 @@ async function exportPdf() {
}
onMounted(async () => {
loadMapConfig()
await loadMapConfig()
shownLevelId.value = mapConfig.currentlevelid ?? mapConfig.defaultlevelid ?? null
try {
const response = await assetsApi.getMap()
const data = response.data.data || {}
@@ -305,6 +354,7 @@ function updateMapLayers() {
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
followSearchAcrossLevels()
updateMapLayers()
}, 300)
}
@@ -318,7 +368,7 @@ function handleMarkerClick(asset) {
.map-page {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
}
.map-page .page-header {

View File

@@ -213,9 +213,18 @@ async function search(q) {
}
function performSearch() {
if (searchInput.value.trim()) {
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
const term = searchInput.value.trim()
if (!term) return
// Searching a term that is ALREADY in the URL is a duplicate navigation: the
// router aborts it, so the route watcher never fires and the button does
// nothing at all. Re-run the query directly in that case. The sidebar box
// never hits this, because it is always navigating from somewhere else -
// which is why the two search boxes appeared to behave differently.
if (term === route.query.q) {
search(term)
return
}
router.push({ path: '/search', query: { q: term } })
}
async function openKBArticle(result) {

View File

@@ -62,18 +62,36 @@
/>
</td>
<td>
<span class="mono">{{ level.mapwidth }} x {{ level.mapheight }}</span>
<div class="size-fields">
<input
v-model.number="level.mapwidth"
type="number"
min="1"
class="form-control size-input"
aria-label="Blueprint width in pixels"
@blur="saveSize(level)"
:disabled="saving"
/>
<span class="size-x">x</span>
<input
v-model.number="level.mapheight"
type="number"
min="1"
class="form-control size-input"
aria-label="Blueprint height in pixels"
@blur="saveSize(level)"
:disabled="saving"
/>
</div>
<small v-if="level.assetcount" class="input-hint">
fixed while {{ level.assetcount }} marker(s) are placed
Taken from the image while a level is empty. Changing it now
re-scales where all {{ level.assetcount }} marker(s) sit relative
to the drawing - set the new size, then Recalibrate from
landmarks in the map editor.
</small>
<small v-else class="input-hint">
Set from the blueprint you upload.
</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>
@@ -311,6 +329,29 @@ async function saveLevel(level, payload) {
}
}
async function saveSize(level) {
if (!(level.mapwidth > 0) || !(level.mapheight > 0)) {
error.value = 'Width and height must both be positive.'
await load()
return
}
// A level with markers keeps its coordinates when the size changes, so every
// marker moves relative to the drawing. That is sometimes exactly right (a
// re-export of the same plan at a new resolution) and sometimes the start of
// a recalibration, but it is never something to do by accident.
if (level.assetcount) {
const ok = window.confirm(
`${level.levelname} has ${level.assetcount} marker(s) placed against ` +
`${level.mapwidth} x ${level.mapheight}. Changing the size moves all of ` +
`them relative to the drawing. Recalibrate from landmarks afterwards. Continue?`)
if (!ok) {
await load()
return
}
}
await saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })
}
async function upload(level, theme, event) {
const file = event.target.files?.[0]
if (!file) return
@@ -398,6 +439,8 @@ async function remove(level) {
.mono { font-family: monospace; }
.order-input { width: 4.5rem; }
.size-input { width: 6rem; }
.size-fields { display: flex; align-items: center; gap: 0.35rem; }
.size-x { color: var(--text-light); }
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
.map-thumb {