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

@@ -10,6 +10,17 @@ ADR-007 and ADR-002.
## [Unreleased]
## [0.11.1] - 2026-08-17
A patch release: the pagination-cap fixes, plus the documentation that missed the
0.11.0 installer. No application behaviour changed on the server and the plugin
contract stays at 0.20.0.
`docs/UPGRADE.md` is one of the documents the Windows installer puts on the box,
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
shipped the page an operator reads WHILE upgrading without the guidance about
that upgrade. An air-gapped server has no other way to reach it.
### Fixed
- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
@@ -25,6 +36,16 @@ ADR-007 and ADR-002.
`PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
a page at a time are unchanged - they page deliberately.
### Changed
- The installer now also ships `FLOOR-MAP.md`, the operator's page for loading
floor plans, placing markers and moving them when a plan changes. Same reason
the Windows runbooks ship: the box cannot reach the wiki.
- The bundled `UPGRADE.md` now carries the 0.11.0 section: an existing map is
preserved and nothing moves on screen, and replacing a blueprint with one of
different dimensions moves every marker on that level, so recalibrate from
landmarks rather than editing the level's width and height.
## [0.11.0] - 2026-08-17
The floor map became a set of drawings instead of one picture. A site can hold

View File

@@ -1,6 +1,6 @@
{
"name": "shopdb-frontend",
"version": "0.11.0",
"version": "0.11.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -33,6 +33,10 @@
--sidebar-bg: #00003d;
--sidebar-text: #ffffff;
--sidebar-width: 250px;
/* Vertical chrome around .main-content. A full-height page subtracts these;
hardcoding a different number is what put a scrollbar on the map. */
--main-pad-top: 20px;
--main-pad-bottom: 70px;
/* Hover variants */
--secondary-dark: #82503f;
@@ -243,7 +247,7 @@ h1, h2, h3, h4, h5, h6 {
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
padding: 20px 10px 70px 10px;
padding: var(--main-pad-top) 10px var(--main-pad-bottom) 10px;
overflow-x: hidden;
}

View File

@@ -437,8 +437,18 @@ function renderMarkers() {
overlayLayers.forEach(layer => layer.remove())
overlayLayers = []
// Markers belong to ONE level (ADR-017). This is the drawing being shown, and
// anything positioned against a different one is not drawn on it.
const drawnLevel = drawnLevelId() ?? null
props.machines.forEach(item => {
if (item.mapx == null || item.mapy == null) return
// A marker from another level, placed on THIS blueprint, looks entirely
// correct and points at the wrong part of the building - so it is omitted
// rather than approximated. A position with no level at all is omitted for
// the same reason: the map editor lists both, badged, so they can be fixed
// rather than silently misplaced here.
if ((item.levelid ?? null) !== drawnLevel) return
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
const leafletY = MAP_HEIGHT - item.mapy
@@ -599,6 +609,31 @@ watch(() => props.theme, (newTheme) => {
}
})
// Switching level changes the drawing, the coordinate space AND which markers
// belong on it. All three move together: the size is what marker coordinates
// mean, so swapping the image without the bounds would place every marker
// against the wrong scale, and keeping the markers would show the previous
// floor's assets on this floor's plan.
watch(() => drawnLevelId(), (levelid) => {
if (!map || !imageOverlay) return
MAP_WIDTH = dimensionsFor(levelid).width
MAP_HEIGHT = dimensionsFor(levelid).height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
const url = blueprintUrlFor(props.theme, levelid)
// A level with no blueprint is left blank rather than showing the previous
// one, which would be a floor plan labelled as somewhere it is not.
imageOverlay.setUrl(url || '')
imageOverlay.setBounds(bounds)
map.setMaxBounds(bounds)
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], map.getZoom())
renderMarkers()
loadOverlays()
})
onMounted(async () => {
// Load this facility's blueprint + dimensions before building the map so
// bounds and coordinate math use the right size. Falls back to defaults.

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 {

View File

@@ -95,7 +95,8 @@ python3 "$REPO/scripts/generate_sbom.py" "$REPO" -o "$OUT/sbom.cdx.json"
echo "==> Staging docs ..."
mkdir -p "$OUT/docs"
for doc in openapi.json llms.txt api-inventory.json \
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md; do
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md \
FLOOR-MAP.md; do
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
done
# Never let a missing optional doc fail the build - the loop's last test governs

View File

@@ -63,7 +63,7 @@ __contract_version__ = '0.20.0'
# plugin-contract version above are distinct series with independent
# bump rules. Not part of the shopdb.api contract surface, so it is
# not re-exported there.
__version__ = '0.11.0'
__version__ = '0.11.1'
def create_app(config_name: str = None) -> Flask:

View File

@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
search_bp = Blueprint('search', __name__)
# EVERY searcher below truncates with .limit(). An unordered LIMIT lets the
# database return a DIFFERENT subset of the matching rows between two identical
# requests - the same search run twice came back with different hits, which reads
# as the search being broken rather than as a missing ORDER BY. Each query
# therefore ends with a TOTAL order: a display key plus the primary key, so ties
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
def _word_match(query, *columns):
"""SQL clause matching rows that contain EVERY word of the query, each word
in any of the columns, in any order.
@@ -213,7 +221,7 @@ def _search_applications(query, search_term):
apps = Application.query.filter(
Application.isactive == True,
_word_match(query, Application.appname, Application.appdescription)
).limit(10).all()
).order_by(Application.appname, Application.appid).limit(10).all()
for app in apps:
relevance = 20
@@ -258,7 +266,8 @@ def _search_knowledgebase(query, search_term):
KnowledgeBase.appid.notin_(retired)),
_word_match(query, KnowledgeBase.shortdescription,
KnowledgeBase.keywords)
).limit(20).all()
).order_by(KnowledgeBase.clicks.desc(),
KnowledgeBase.linkid).limit(20).all()
for kb in kb_articles:
relevance = 10 + (kb.clicks or 0) * 0.1
@@ -378,7 +387,7 @@ def _search_assets(query, search_term):
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.notes,
Asset.gaugelabreference, Asset.maintenancereference)
).limit(15).all()
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
for asset in assets:
relevance = 15
@@ -421,7 +430,7 @@ def _search_measuringtools(query, search_term):
Asset.isactive == True,
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.gaugelabreference)
).limit(15).all()
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
for asset in assets:
relevance = 15
@@ -461,7 +470,8 @@ def _search_usbdevices(query, search_term):
USBDevice.isactive == True,
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
USBDevice.label, USBDevice.productname)
).limit(10).all()
).order_by(USBDevice.serialnumber,
USBDevice.usbdeviceid).limit(10).all()
for device in devices:
relevance = 20
@@ -507,7 +517,8 @@ def _search_printeditems(query, search_term):
PrintedItem.isactive == True,
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
PrintedItem.itemname, PrintedItem.itemdescription)
).limit(10).all()
).order_by(PrintedItem.itemcode,
PrintedItem.printeditemid).limit(10).all()
for item in items:
relevance = 20
@@ -563,7 +574,7 @@ def _search_customfields(query, search_term):
CustomField.searchable == True,
CustomField.isactive == True,
_word_match(query, CustomFieldValue.value),
).limit(15).all()
).order_by(Asset.assetnumber, CustomField.fieldid).limit(15).all()
for asset, field in rows:
result = _get_asset_result(asset, query, relevance=40)
@@ -583,7 +594,8 @@ def _search_by_ip(query, search_term):
).options(
joinedload(Communication.asset).joinedload(Asset.assettype),
joinedload(Communication.asset).joinedload(Asset.location),
).limit(10).all()
).order_by(Communication.ipaddress,
Communication.communicationid).limit(10).all()
seen_assets = set()
for comm in comms:
@@ -643,7 +655,7 @@ def _search_hostnames(query, search_term):
).options(
joinedload(Computer.asset).joinedload(Asset.assettype),
joinedload(Computer.asset).joinedload(Asset.location),
).limit(10).all()
).order_by(Computer.hostname, Computer.computerid).limit(10).all()
for comp in computers:
if comp.asset and comp.asset.isactive:
@@ -666,7 +678,7 @@ def _search_hostnames(query, search_term):
).options(
joinedload(Printer.asset).joinedload(Asset.assettype),
joinedload(Printer.asset).joinedload(Asset.location),
).limit(10).all()
).order_by(Printer.hostname, Printer.printerid).limit(10).all()
for printer in printers:
if printer.asset and printer.asset.isactive:
@@ -689,7 +701,8 @@ def _search_hostnames(query, search_term):
).options(
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
joinedload(NetworkDevice.asset).joinedload(Asset.location),
).limit(10).all()
).order_by(NetworkDevice.hostname,
NetworkDevice.networkdeviceid).limit(10).all()
for device in devices:
if device.asset and device.asset.isactive:
@@ -773,7 +786,7 @@ def _search_vendor_model_type(query, search_term):
Asset.isactive == True,
_word_match(query, Vendor.vendor, Model.modelnumber,
MachineType.machinetype)
).limit(10).all()
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
for asset in machine_assets:
results.append(_get_asset_result(asset, query, 30))
@@ -801,7 +814,7 @@ def _search_vendor_model_type(query, search_term):
Asset.isactive == True,
_word_match(query, Vendor.vendor, Model.modelnumber,
PrinterType.printertype)
).limit(10).all()
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
for asset in printer_assets:
results.append(_get_asset_result(asset, query, 30))
@@ -827,7 +840,7 @@ def _search_vendor_model_type(query, search_term):
Asset.isactive == True,
_word_match(query, Vendor.vendor,
NetworkDeviceType.networkdevicetype)
).limit(10).all()
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
for asset in netdev_assets:
results.append(_get_asset_result(asset, query, 30))