// 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 { mapLevelsApi } from '../api' import { withBase } from '../utils/basePath' // 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 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 levelFor(levelid) { if (levelid === null || levelid === undefined) return null return state.levels[levelid] || null } function fetchLevels() { inflight = mapLevelsApi.list() .then(({ data }) => { 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 }) .finally(() => { inflight = null }) return inflight } // 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 fetchLevels() } // Re-read after the levels admin changes something. export function reloadMapConfig() { return fetchLevels() } 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, dimensionsFor, hasLevel, levelName, levelOptions, setCurrentLevel, } }