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

@@ -1,42 +1,59 @@
// Facility floor-map blueprint config, read from the settings table so each
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
// Missing keys fall back to the generic placeholder so a fresh or offline
// install still renders; each site uploads its own blueprint in Settings.
// Which drawing renders a marker, and at what native size (ADR-017).
//
// This used to hold ONE blueprint and ONE pixel size, read from four settings,
// because a site had one floor map. It now holds every level of every building,
// because `assets.mapx`/`mapy` are pixels in a specific level's space and the
// same coordinates mean different places on different drawings.
//
// THE RULE THIS FILE ENFORCES: a position without a level is not rendered on the
// default level. `blueprintUrlFor(theme, levelid)` returns null for an unknown
// level, and every caller must show "level unknown" rather than draw something.
// Falling back would put one building's ground floor behind a marker positioned
// for another building's mezzanine - it renders perfectly and points at the
// wrong place, which is worse than rendering nothing.
import { reactive } from 'vue'
import { settingsApi } from '../api'
import { mapLevelsApi } from '../api'
import { withBase } from '../utils/basePath'
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
const DEFAULTS = {
blueprintLight: '/static/images/floorplan-placeholder.svg',
blueprintDark: '/static/images/floorplan-placeholder.svg',
width: 3300,
height: 2550
}
// Used until the levels load, and on a fresh install with none configured, so a
// map still draws something rather than breaking.
const PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
const FALLBACK_WIDTH = 3300
const FALLBACK_HEIGHT = 2550
// Shared reactive config. Import as `state` to read width/height/blueprint.
export const state = reactive({ ...DEFAULTS, loaded: false })
export const state = reactive({
buildings: [],
// Flat index by levelid, because every hover preview resolves an arbitrary
// asset's level and has no idea which building it is in.
levels: {},
defaultlevelid: null,
currentlevelid: null,
loaded: false,
})
let inflight = null
function applySetting(key, value) {
if (value === null || value === undefined || value === '') return
if (key === 'map_blueprint_light') state.blueprintLight = value
else if (key === 'map_blueprint_dark') state.blueprintDark = value
else if (key === 'map_width') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.width = n
} else if (key === 'map_height') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.height = n
}
function levelFor(levelid) {
if (levelid === null || levelid === undefined) return null
return state.levels[levelid] || null
}
function fetchConfig() {
inflight = settingsApi.list({ category: 'map' })
function fetchLevels() {
inflight = mapLevelsApi.list()
.then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value))
const payload = data.data || {}
state.buildings = payload.buildings || []
state.levels = {}
state.buildings.forEach(building => {
;(building.levels || []).forEach(level => {
state.levels[level.levelid] = { ...level, buildingname: building.buildingname }
})
})
state.defaultlevelid = payload.defaultlevelid || null
if (!state.currentlevelid || !state.levels[state.currentlevelid]) {
state.currentlevelid = state.defaultlevelid
}
state.loaded = true
})
.catch(() => { state.loaded = true })
@@ -44,26 +61,95 @@ function fetchConfig() {
return inflight
}
// Fetch the map config once (shared across all map components). Returns a
// promise that resolves when state is populated, so a caller can await it
// before initializing a Leaflet map that needs the dimensions.
// Fetch once, shared across every map component. Await it before initialising a
// Leaflet map, which needs the dimensions to set its bounds.
export function loadMapConfig() {
if (state.loaded) return Promise.resolve()
if (inflight) return inflight
return fetchConfig()
return fetchLevels()
}
// Re-read config from the server after a map setting changes.
// Re-read after the levels admin changes something.
export function reloadMapConfig() {
return fetchConfig()
return fetchLevels()
}
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
export function setCurrentLevel(levelid) {
if (state.levels[levelid]) state.currentlevelid = levelid
}
/**
* Blueprint URL for one level in one theme, or null when the level is unknown.
*
* Falls back to the OTHER theme's image before giving up, because a site that
* uploaded only a light blueprint should still render in dark mode - a
* hard-to-read floor plan beats no floor plan.
*/
export function blueprintUrlFor(theme, levelid) {
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
if (!level) return null
const wanted = theme === 'light' ? level.blueprintlight : level.blueprintdark
const other = theme === 'light' ? level.blueprintdark : level.blueprintlight
const chosen = wanted || other
return withBase(chosen || PLACEHOLDER)
}
/**
* Native pixel size of a level, which is what its marker coordinates mean.
*
* Returns the fallback for an unknown level so arithmetic does not divide by
* undefined, but callers deciding WHETHER to draw must ask `hasLevel` - these
* numbers are a safe default, not evidence the level exists.
*/
export function dimensionsFor(levelid) {
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
return {
width: level?.mapwidth || FALLBACK_WIDTH,
height: level?.mapheight || FALLBACK_HEIGHT,
}
}
export function hasLevel(levelid) {
return !!levelFor(levelid)
}
export function levelName(levelid) {
const level = levelFor(levelid)
if (!level) return null
// Qualified by building only when there is more than one, so a single-building
// site is not made to read "Main / Ground floor" everywhere.
return state.buildings.length > 1
? `${level.buildingname} / ${level.levelname}`
: level.levelname
}
// Every level flat, in building then level order, for a selector.
export function levelOptions() {
const options = []
state.buildings.forEach(building => {
;(building.levels || []).forEach(level => {
options.push({
levelid: level.levelid,
levelname: level.levelname,
buildingname: building.buildingname,
label: state.buildings.length > 1
? `${building.buildingname} / ${level.levelname}`
: level.levelname,
})
})
})
return options
}
export function useMapConfig() {
loadMapConfig()
return { state, blueprintUrlFor }
return {
state,
blueprintUrlFor,
dimensionsFor,
hasLevel,
levelName,
levelOptions,
setCurrentLevel,
}
}

View File

@@ -0,0 +1,144 @@
// The invariant the whole map model rests on: a position without a level is not
// drawn on the default level.
//
// Nothing else catches this. Rewriting the composable left four components
// reading a property that no longer existed, which is `undefined` rather than a
// compile error - Vite built it happily and every marker would have been
// positioned at NaN percent. A build proves the code parses, not that a marker
// lands anywhere.
import { describe, it, expect, vi, beforeEach } from 'vitest'
const list = vi.fn()
vi.mock('../api', () => ({ mapLevelsApi: { list: (...args) => list(...args) } }))
const {
state, loadMapConfig, reloadMapConfig, blueprintUrlFor, dimensionsFor,
hasLevel, levelName, levelOptions, setCurrentLevel,
} = await import('./mapConfig')
// Two buildings, three levels, sized like the real before and after: the ground
// floor at 3300x2550 and a second floor at 3308x4000.
const PAYLOAD = {
data: {
data: {
buildings: [
{
buildingid: 1, buildingname: 'Main', levels: [
{ levelid: 1, levelname: 'Ground floor', sortorder: 0, mapwidth: 3300,
mapheight: 2550, blueprintlight: '/x/g-light.png',
blueprintdark: '/x/g-dark.png', isdefault: true },
{ levelid: 2, levelname: 'Second floor', sortorder: 1, mapwidth: 3308,
mapheight: 4000, blueprintlight: '/x/2-light.png',
blueprintdark: null, isdefault: false },
],
},
{
buildingid: 2, buildingname: 'Annex', levels: [
{ levelid: 3, levelname: 'Ground floor', sortorder: 0, mapwidth: 1200,
mapheight: 900, blueprintlight: '/x/a-light.png',
blueprintdark: '/x/a-dark.png', isdefault: false },
],
},
],
defaultlevelid: 1,
},
},
}
beforeEach(async () => {
list.mockReset()
list.mockResolvedValue(PAYLOAD)
state.loaded = false
state.currentlevelid = null
await reloadMapConfig()
})
describe('loading', () => {
it('indexes every level of every building and adopts the default', () => {
expect(Object.keys(state.levels)).toEqual(['1', '2', '3'])
expect(state.defaultlevelid).toBe(1)
expect(state.currentlevelid).toBe(1)
})
it('fetches once across concurrent callers', async () => {
list.mockClear()
state.loaded = false
await Promise.all([loadMapConfig(), loadMapConfig(), loadMapConfig()])
expect(list).toHaveBeenCalledTimes(1)
})
it('survives an unreachable server without hanging every map on the page', async () => {
list.mockReset()
list.mockRejectedValue(new Error('network'))
state.loaded = false
await reloadMapConfig()
expect(state.loaded).toBe(true)
})
})
describe('dimensions belong to the level, not the site', () => {
it('returns each level its own native size', () => {
expect(dimensionsFor(1)).toEqual({ width: 3300, height: 2550 })
expect(dimensionsFor(2)).toEqual({ width: 3308, height: 4000 })
expect(dimensionsFor(3)).toEqual({ width: 1200, height: 900 })
})
it('does not report one level size for another', () => {
// The bug this guards: a marker on level 2 measured against level 1's
// height renders at 2550/4000 of the way down - plausible, and wrong.
expect(dimensionsFor(2).height).not.toBe(dimensionsFor(1).height)
})
})
describe('a position without a level is not drawn', () => {
it('has no blueprint for a null level', () => {
expect(blueprintUrlFor('light', null)).toBeNull()
expect(hasLevel(null)).toBe(false)
})
it('has no blueprint for a level this instance does not know', () => {
expect(blueprintUrlFor('light', 99)).toBeNull()
expect(hasLevel(99)).toBe(false)
})
it('never substitutes the default level for a missing one', () => {
const groundfloor = blueprintUrlFor('light', 1)
expect(groundfloor).toContain('g-light.png')
// The failure mode: returning the default blueprint for an unknown level.
expect(blueprintUrlFor('light', null)).not.toBe(groundfloor)
expect(blueprintUrlFor('light', 99)).not.toBe(groundfloor)
})
})
describe('blueprints', () => {
it('serves the theme asked for', () => {
expect(blueprintUrlFor('light', 1)).toContain('g-light.png')
expect(blueprintUrlFor('dark', 1)).toContain('g-dark.png')
})
it('falls back to the other theme rather than showing nothing', () => {
// Level 2 has no dark blueprint. A hard-to-read floor plan beats no floor
// plan, and a site that uploaded one image should still work in both themes.
expect(blueprintUrlFor('dark', 2)).toContain('2-light.png')
})
})
describe('naming and selection', () => {
it('qualifies a level by building only when there is more than one', () => {
// Both buildings have a 'Ground floor', so the name alone is ambiguous.
expect(levelName(1)).toBe('Main / Ground floor')
expect(levelName(3)).toBe('Annex / Ground floor')
})
it('offers every level in building then level order', () => {
expect(levelOptions().map(option => option.levelid)).toEqual([1, 2, 3])
})
it('refuses to make an unknown level current', () => {
setCurrentLevel(99)
expect(state.currentlevelid).toBe(1)
setCurrentLevel(2)
expect(state.currentlevelid).toBe(2)
})
})

View File

@@ -52,7 +52,9 @@ export const searchDomains = [
{ key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' },
{ key: 'notification', label: 'Notifications' },
{ key: 'subnet', label: 'Subnets' }
{ key: 'subnet', label: 'Subnets' },
{ key: 'usb_device', label: 'USB Devices' },
{ key: 'printed_item', label: 'Printed Items' }
]
export function useSystemSettings() {