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