Clicking the map in the editor did nothing, ever. The click handler was bound inside `if (props.pickerMode)` at mount, and the editor mounts with no asset selected, so the handler was never attached; selecting an asset flipped the prop but nothing rebound it. The asset forms were unaffected because their picker mounts inside a modal that is already in picker mode, which is why this looked like an editor-only fault. The handler is now bound unconditionally and handleMapClick keeps its own picker-mode guard. Verified in a browser against the dev instance, both ways: with the old binding a click on a selected asset produced no position at all; with the fix the same click reports 1652, 1138. Markers are now drawn in picker mode too. Placing one relative to the machines already on the floor is the entire task, and the old code skipped rendering them whenever the map was a picker. Two things that looked like stray widgets: - The editor's panel header put a heading and three controls on one row inside a 320px panel, squeezing the search box until its placeholder read "Search na". The heading takes its own row and the controls share the next. - The legend drew its bar and border even with nothing to put in it, which read as an empty input box under the toolbar. It renders only when it has entries.
856 lines
26 KiB
Vue
856 lines
26 KiB
Vue
<template>
|
|
<div class="shopfloor-map">
|
|
<!-- Controls for legacy machine mode only (MapView handles filters for asset mode) -->
|
|
<div class="map-controls" v-if="!pickerMode && !assetTypeMode">
|
|
<div class="filters">
|
|
<select v-model="filters.machinetype" @change="applyFilters">
|
|
<option value="">All Types</option>
|
|
<option v-for="t in machinetypes" :key="t.machinetypeid" :value="t.machinetypeid">
|
|
{{ t.machinetype }}
|
|
</option>
|
|
</select>
|
|
|
|
<select v-model="filters.businessunit" @change="applyFilters">
|
|
<option value="">All Business Units</option>
|
|
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
|
{{ bu.businessunit }}
|
|
</option>
|
|
</select>
|
|
|
|
<select v-model="filters.status" @change="applyFilters">
|
|
<option value="">All Statuses</option>
|
|
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
|
|
{{ s.status }}
|
|
</option>
|
|
</select>
|
|
|
|
<input
|
|
type="text"
|
|
v-model="filters.search"
|
|
placeholder="Search..."
|
|
@input="debounceSearch"
|
|
/>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<span
|
|
v-for="t in visibleTypes"
|
|
:key="t.machinetypeid"
|
|
class="legend-item"
|
|
>
|
|
<span class="legend-dot" :style="legendDotStyle(getTypeColor(t.machinetype))"></span>
|
|
{{ t.machinetype }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
|
|
<div class="map-legend" v-if="!pickerMode && assetTypeMode && hasLegendEntries">
|
|
<!-- Show subtype legend when a specific type is selected -->
|
|
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
|
|
<span
|
|
v-for="(color, subtypeId) in visibleSubtypes"
|
|
:key="subtypeId"
|
|
class="legend-item"
|
|
>
|
|
<span class="legend-dot" :style="legendDotStyle(color)"></span>
|
|
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
|
|
</span>
|
|
</template>
|
|
<!-- Show asset type legend when no type is selected -->
|
|
<template v-else>
|
|
<span
|
|
v-for="(color, assetType) in visibleAssetTypes"
|
|
:key="assetType"
|
|
class="legend-item"
|
|
>
|
|
<span class="legend-dot" :style="legendDotStyle(color)"></span>
|
|
{{ assetTypeLabels[assetType] || assetType }}
|
|
</span>
|
|
</template>
|
|
<!-- Plugin-contributed overlay legend entries (ADR-010 get_map_overlays) -->
|
|
<span
|
|
v-for="(entry, i) in overlayLegend"
|
|
:key="`overlay-${i}`"
|
|
class="legend-item"
|
|
>
|
|
<span class="legend-dot legend-ring" :style="{ borderColor: entry.color }"></span>
|
|
{{ entry.label }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="picker-controls" v-if="pickerMode">
|
|
<span class="picker-message">Click on the map to set location</span>
|
|
<span v-if="pickedPosition" class="picker-coords">
|
|
Position: {{ pickedPosition.left }}, {{ pickedPosition.top }}
|
|
</span>
|
|
<button class="btn btn-secondary btn-sm" @click="clearPosition">Clear</button>
|
|
</div>
|
|
|
|
<div ref="mapContainer" class="map-container" :class="{ 'picker-active': pickerMode }"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
|
import L from 'leaflet'
|
|
import 'leaflet/dist/leaflet.css'
|
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, state as mapConfig } from '../composables/mapConfig'
|
|
import { assetTypeLabel } from '../utils/assetTypes'
|
|
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
|
import api from '../api'
|
|
|
|
const props = defineProps({
|
|
machines: { type: Array, default: () => [] },
|
|
machinetypes: { type: Array, default: () => [] },
|
|
businessunits: { type: Array, default: () => [] },
|
|
statuses: { type: Array, default: () => [] },
|
|
theme: { type: String, default: 'dark' },
|
|
pickerMode: { type: Boolean, default: false },
|
|
initialPosition: { type: Object, default: null }, // { left, top }
|
|
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
|
|
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
|
|
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
|
|
subtypeNames: { type: Object, default: () => ({}) }, // Map of subtype ID to name
|
|
// Which level this map is drawing (ADR-017). Defaults to the current level in
|
|
// the shared config, so an existing caller that has not been taught about
|
|
// levels still renders the level the user is looking at rather than nothing.
|
|
levelid: { type: Number, default: null }
|
|
})
|
|
|
|
const emit = defineEmits(['markerClick', 'positionPicked'])
|
|
|
|
// An empty legend still drew its bar and border, which looked like a stray input
|
|
// box under the toolbar. It renders only when it has something in it.
|
|
const hasLegendEntries = computed(() => {
|
|
if (props.selectedAssetType && Object.keys(visibleSubtypes.value).length) return true
|
|
if (Object.keys(visibleAssetTypes.value).length) return true
|
|
return overlayLegend.value.length > 0
|
|
})
|
|
|
|
const mapContainer = ref(null)
|
|
let map = null
|
|
let imageOverlay = null
|
|
const markers = ref([])
|
|
const pickedPosition = ref(null)
|
|
let pickerMarker = null
|
|
let markerLayer = null
|
|
let canvasRenderer = null
|
|
|
|
// ADR-010 map overlays (get_map_overlays): plugin-declared per-asset decoration
|
|
// (ring/badge) drawn on top of markers, plus legend entries. overlayLayers are
|
|
// the extra Leaflet layers, cleared and redrawn with the markers.
|
|
const overlayDecorations = ref([]) // [{ style, byAsset: Map(assetid -> {color,label}) }]
|
|
const overlayLegend = ref([]) // [{ label, color }] distinct entries
|
|
let overlayLayers = []
|
|
|
|
const filters = ref({
|
|
machinetype: '',
|
|
businessunit: '',
|
|
status: '',
|
|
search: ''
|
|
})
|
|
|
|
// The drawn level's native size, which is what its marker coordinates mean.
|
|
// Mutable because the levels load after this module does, and because switching
|
|
// level changes them.
|
|
function drawnLevelId() {
|
|
return props.levelid ?? mapConfig.currentlevelid
|
|
}
|
|
|
|
let MAP_WIDTH = dimensionsFor(drawnLevelId()).width
|
|
let MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
|
|
|
|
// Asset type colors (for unified map mode) - normalized lookup
|
|
const assetTypeColorsMap = {
|
|
'machine': '#F44336', // Red
|
|
'computer': '#2196F3', // Blue
|
|
'printer': '#4CAF50', // Green
|
|
'network device': '#FF9800', // Orange
|
|
'network_device': '#FF9800', // Orange (alternate key)
|
|
'measuring_tool': '#9C27B0', // Purple
|
|
'measuring tool': '#9C27B0' // Purple (normalized key)
|
|
}
|
|
|
|
// Get asset type color with case-insensitive lookup
|
|
function getAssetTypeColor(assettype) {
|
|
if (!assettype) return '#BDBDBD'
|
|
const normalized = assettype.toLowerCase()
|
|
return assetTypeColorsMap[normalized] || '#BDBDBD'
|
|
}
|
|
|
|
// Asset-type display labels come from the shared util (single source of truth).
|
|
const assetTypeLabels = new Proxy({}, {
|
|
get(target, prop) {
|
|
return typeof prop === 'string' ? assetTypeLabel(prop) : prop
|
|
}
|
|
})
|
|
|
|
// Type colors - distinct colors for each machine type
|
|
const typeColors = {
|
|
// Machining
|
|
'Mill': '#F44336', // Red
|
|
'Lathe': '#E91E63', // Pink
|
|
'Grinder': '#2196F3', // Blue
|
|
'Broach': '#00BCD4', // Cyan
|
|
'Hobbing': '#009688', // Teal
|
|
'Turn': '#FF5722', // Deep Orange (Mill Turn, Vertical Turn)
|
|
|
|
// Inspection & Measurement
|
|
'CMM': '#9C27B0', // Purple
|
|
'Measuring': '#7B1FA2', // Dark Purple
|
|
'Eddy': '#673AB7', // Deep Purple
|
|
'Inspection': '#8BC34A', // Light Green
|
|
|
|
// Heat Treatment & Processing
|
|
'Furnace': '#FF9800', // Orange
|
|
'Wash': '#4CAF50', // Green
|
|
'Wax': '#FFEB3B', // Yellow
|
|
|
|
// Automation
|
|
'Robot': '#3F51B5', // Indigo
|
|
'Deburr': '#5C6BC0', // Indigo Light
|
|
|
|
// Welding
|
|
'Welder': '#795548', // Brown
|
|
|
|
// IT/Network
|
|
'PC': '#607D8B', // Blue Grey
|
|
'Printer': '#78909C', // Blue Grey Light
|
|
'Switch': '#546E7A', // Blue Grey Dark
|
|
'Access Point': '#455A64', // Blue Grey Darker
|
|
'IDF': '#37474F', // Blue Grey Very Dark
|
|
|
|
// Other
|
|
'Saw': '#8D6E63', // Brown Light
|
|
'Press': '#A1887F', // Brown Lighter
|
|
'EDM': '#00ACC1', // Cyan Dark
|
|
'Drill': '#26A69A', // Teal Light
|
|
'CNC': '#66BB6A', // Green Light
|
|
'Assembly': '#CDDC39', // Lime
|
|
'Other': '#BDBDBD' // Grey
|
|
}
|
|
|
|
function getTypeColor(typeName) {
|
|
if (!typeName) return '#BDBDBD'
|
|
for (const [key, color] of Object.entries(typeColors)) {
|
|
if (typeName.toLowerCase().includes(key.toLowerCase())) {
|
|
return color
|
|
}
|
|
}
|
|
return '#BDBDBD' // Grey for unknown types
|
|
}
|
|
|
|
// Get unique visible types from machines
|
|
const visibleTypes = computed(() => {
|
|
const typeIds = new Set(props.machines.map(m => m.machinetypeid))
|
|
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
|
|
})
|
|
|
|
// Get unique visible asset types (for asset mode)
|
|
const visibleAssetTypes = computed(() => {
|
|
const types = new Set(props.machines.map(m => m.assettype).filter(Boolean))
|
|
const result = {}
|
|
for (const t of types) {
|
|
result[t] = getAssetTypeColor(t)
|
|
}
|
|
return result
|
|
})
|
|
|
|
// Legend swatches wear the same ring as the markers they stand for, so the key
|
|
// reads as a key. Without this the dot took a surface-colored border and a light
|
|
// color looked ringed in white next to its black-ringed marker.
|
|
function legendDotStyle(color) {
|
|
return { background: color, borderColor: markerRingColor(props.theme) }
|
|
}
|
|
|
|
// Get visible subtypes when a type is selected
|
|
const visibleSubtypes = computed(() => {
|
|
if (!props.selectedAssetType) return {}
|
|
const subtypeIds = new Set(
|
|
props.machines
|
|
.map(m => getSubtypeId(m))
|
|
.filter(id => id != null)
|
|
)
|
|
const result = {}
|
|
for (const id of subtypeIds) {
|
|
if (props.subtypeColors[id]) {
|
|
result[id] = props.subtypeColors[id]
|
|
}
|
|
}
|
|
return result
|
|
})
|
|
|
|
function initMap() {
|
|
if (!mapContainer.value) return
|
|
|
|
// Use canvas renderer for performance - renders all markers on a single <canvas>
|
|
canvasRenderer = L.canvas({ padding: 0.5 })
|
|
|
|
map = L.map(mapContainer.value, {
|
|
crs: L.CRS.Simple,
|
|
minZoom: -4,
|
|
maxZoom: 2,
|
|
attributionControl: false,
|
|
renderer: canvasRenderer
|
|
})
|
|
|
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
|
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme, drawnLevelId()), bounds)
|
|
imageOverlay.addTo(map)
|
|
|
|
// Set initial view - zoom out to show full floor plan
|
|
const initialZoom = -2
|
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
|
map.setMaxBounds(bounds)
|
|
|
|
// Bound UNCONDITIONALLY, and handleMapClick ignores the click when picker mode
|
|
// is off. Binding it only when pickerMode was true AT MOUNT meant a map that
|
|
// becomes a picker later never got a click handler at all: the map editor
|
|
// opens with nothing selected, so clicking to place a marker did nothing, for
|
|
// the whole life of the page. The asset forms only worked because their picker
|
|
// is mounted inside a modal that is already in picker mode.
|
|
map.on('click', handleMapClick)
|
|
|
|
if (props.pickerMode && props.initialPosition) {
|
|
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
|
}
|
|
|
|
// Existing markers are drawn in picker mode too: placing a marker relative to
|
|
// the ones already on the floor is the whole task. The forms pass no markers,
|
|
// so this costs them nothing.
|
|
renderMarkers()
|
|
}
|
|
|
|
function handleMapClick(e) {
|
|
if (!props.pickerMode) return
|
|
|
|
const leafletY = e.latlng.lat
|
|
const leafletX = e.latlng.lng
|
|
|
|
// Convert back to database coordinates
|
|
const dbLeft = Math.round(leafletX)
|
|
const dbTop = Math.round(MAP_HEIGHT - leafletY)
|
|
|
|
setPickerPosition(dbLeft, dbTop)
|
|
}
|
|
|
|
function setPickerPosition(left, top) {
|
|
// Remove old picker marker
|
|
if (pickerMarker) {
|
|
pickerMarker.remove()
|
|
}
|
|
|
|
pickedPosition.value = { left, top }
|
|
|
|
// Convert to Leaflet coordinates
|
|
const leafletY = MAP_HEIGHT - top
|
|
const leafletX = left
|
|
|
|
const icon = L.divIcon({
|
|
html: `<div class="picker-marker-dot"></div>`,
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 8],
|
|
className: 'picker-marker'
|
|
})
|
|
|
|
pickerMarker = L.marker([leafletY, leafletX], { icon, draggable: true })
|
|
pickerMarker.addTo(map)
|
|
|
|
// Allow dragging to fine-tune position
|
|
pickerMarker.on('dragend', () => {
|
|
const pos = pickerMarker.getLatLng()
|
|
const newLeft = Math.round(pos.lng)
|
|
const newTop = Math.round(MAP_HEIGHT - pos.lat)
|
|
pickedPosition.value = { left: newLeft, top: newTop }
|
|
emit('positionPicked', pickedPosition.value)
|
|
})
|
|
|
|
emit('positionPicked', pickedPosition.value)
|
|
}
|
|
|
|
function clearPosition() {
|
|
if (pickerMarker) {
|
|
pickerMarker.remove()
|
|
pickerMarker = null
|
|
}
|
|
pickedPosition.value = null
|
|
emit('positionPicked', null)
|
|
}
|
|
|
|
// Fetch the declared map overlays (ADR-010) and each overlay's per-asset
|
|
// decoration data, keyed by assetid for a fast join during rendering.
|
|
async function loadOverlays() {
|
|
let declared
|
|
try {
|
|
const response = await api.get('/pluginui/map-overlays')
|
|
declared = response.data.data || []
|
|
} catch (err) {
|
|
return
|
|
}
|
|
const decorations = []
|
|
const legend = []
|
|
const seen = new Set()
|
|
for (const overlay of declared) {
|
|
let items = []
|
|
try {
|
|
const endpoint = overlay.endpoint.replace(/^\/api(?=\/)/, '')
|
|
const response = await api.get(endpoint)
|
|
items = response.data.data || []
|
|
} catch (err) {
|
|
continue
|
|
}
|
|
const byAsset = new Map()
|
|
for (const item of items) {
|
|
byAsset.set(item.assetid, { color: item.color, label: item.label })
|
|
// distinct legend entries (label + color) for overlays that opt in
|
|
if (overlay.legend) {
|
|
const key = `${item.label}|${item.color}`
|
|
if (!seen.has(key)) {
|
|
seen.add(key)
|
|
legend.push({ label: item.label, color: item.color })
|
|
}
|
|
}
|
|
}
|
|
decorations.push({ style: overlay.style || 'badge', byAsset })
|
|
}
|
|
overlayDecorations.value = decorations
|
|
overlayLegend.value = legend
|
|
if (map) renderMarkers()
|
|
}
|
|
|
|
// Draw ring/badge decorations for one asset's marker.
|
|
function applyOverlays(item, leafletY, leafletX) {
|
|
if (item.assetid == null) return
|
|
overlayDecorations.value.forEach((overlay) => {
|
|
const dec = overlay.byAsset.get(item.assetid)
|
|
if (!dec) return
|
|
if (overlay.style === 'ring') {
|
|
const ring = L.circleMarker([leafletY, leafletX], {
|
|
radius: 9, fill: false, color: dec.color, weight: 2,
|
|
opacity: 0.9, interactive: false, renderer: canvasRenderer,
|
|
})
|
|
ring.addTo(map)
|
|
overlayLayers.push(ring)
|
|
} else {
|
|
const badge = L.circleMarker([leafletY + 4, leafletX + 4], {
|
|
radius: 3.5, fillColor: dec.color, color: '#fff', weight: 1,
|
|
fillOpacity: 1, interactive: false, renderer: canvasRenderer,
|
|
})
|
|
badge.addTo(map)
|
|
overlayLayers.push(badge)
|
|
}
|
|
})
|
|
}
|
|
|
|
function renderMarkers() {
|
|
// Clear existing markers + overlay decoration layers
|
|
markers.value.forEach(m => m.marker.remove())
|
|
markers.value = []
|
|
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
|
|
const leafletX = item.mapx
|
|
|
|
// Determine color based on mode
|
|
let color, typeName, displayName
|
|
|
|
if (props.assetTypeMode) {
|
|
// Unified asset mode - use subtype colors when a type is selected
|
|
if (props.selectedAssetType) {
|
|
const subtypeId = getSubtypeId(item)
|
|
color = (subtypeId && props.subtypeColors[subtypeId]) || UNSPECIFIED_COLOR
|
|
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
|
|
} else {
|
|
// Prefer the stored AssetType.color; fall back to the built-in map.
|
|
color = item.assettypecolor || getAssetTypeColor(item.assettype)
|
|
typeName = item.assettype || ''
|
|
}
|
|
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
|
|
// Collapsed dual-bay pair: show the combined '2007 / 2008' label.
|
|
if (item.dualpathpartner) {
|
|
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
|
}
|
|
} else {
|
|
// Legacy machine mode
|
|
typeName = item.machinetype || ''
|
|
color = getTypeColor(typeName)
|
|
displayName = item.alias || item.machinenumber || 'Unknown'
|
|
}
|
|
|
|
// Use circleMarker instead of divIcon marker - renders on canvas
|
|
// for much better performance (no DOM element per marker)
|
|
const marker = L.circleMarker([leafletY, leafletX], {
|
|
radius: 6,
|
|
fillColor: color,
|
|
// Ring keyed on the theme's surface: dark on the white blueprint, light on
|
|
// the dark one. A fixed white ring vanished against the light blueprint.
|
|
color: markerRingColor(props.theme),
|
|
weight: 1.25,
|
|
fillOpacity: 1,
|
|
renderer: canvasRenderer
|
|
})
|
|
|
|
// Build tooltip content
|
|
let tooltipLines = [`<strong>${displayName}</strong>`]
|
|
|
|
if (props.assetTypeMode) {
|
|
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
|
|
|
|
if (item.primaryip) {
|
|
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
|
|
}
|
|
if (item.typedata?.hostname) {
|
|
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
|
|
}
|
|
} else {
|
|
const category = item.category?.toLowerCase() || ''
|
|
|
|
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
|
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
|
}
|
|
|
|
if (item.vendor) {
|
|
tooltipLines.push(`<span style="color: #aaa;">${item.vendor}${item.model ? ' ' + item.model : ''}</span>`)
|
|
} else if (item.model) {
|
|
tooltipLines.push(`<span style="color: #aaa;">${item.model}</span>`)
|
|
}
|
|
|
|
if (category === 'printer') {
|
|
if (item.ipaddress) {
|
|
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.ipaddress}</span>`)
|
|
}
|
|
if (item.hostname) {
|
|
tooltipLines.push(`<span style="color: #8cf;">${item.hostname}</span>`)
|
|
}
|
|
} else {
|
|
if (item.connected_pc) {
|
|
tooltipLines.push(`<span style="color: #fc8;">PC: ${item.connected_pc}</span>`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (item.businessunit) {
|
|
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
|
|
}
|
|
|
|
const tooltipContent = tooltipLines.join('<br>')
|
|
marker.bindTooltip(tooltipContent, {
|
|
direction: 'top',
|
|
offset: [0, -12],
|
|
className: 'marker-tooltip'
|
|
})
|
|
|
|
// Hover is a glance; the click belongs to the consumer. No popup is bound:
|
|
// MapView routes to the detail page on click, so a popup opened and was
|
|
// discarded by the navigation in the same tick - it was never visible. In
|
|
// the editor and the picker forms it DID render, where a 'View Details'
|
|
// link only served to pull the user off an unsaved form.
|
|
marker.on('click', () => emit('markerClick', item))
|
|
|
|
marker.addTo(map)
|
|
applyOverlays(item, leafletY, leafletX)
|
|
|
|
// Build search data
|
|
const searchData = props.assetTypeMode
|
|
? `${item.assetnumber} ${item.name} ${item.displayname} ${item.assettype} ${item.status} ${item.businessunit} ${item.typedata?.hostname || ''} ${item.primaryip || ''}`.toLowerCase()
|
|
: `${item.machinenumber} ${item.alias} ${typeName} ${item.vendor} ${item.model} ${item.serialnumber} ${item.businessunit}`.toLowerCase()
|
|
|
|
markers.value.push({
|
|
marker,
|
|
machine: item,
|
|
searchData
|
|
})
|
|
})
|
|
|
|
applyFilters()
|
|
}
|
|
|
|
function applyFilters() {
|
|
const searchTerm = filters.value.search.toLowerCase()
|
|
|
|
markers.value.forEach(({ marker, machine, searchData }) => {
|
|
let visible = true
|
|
|
|
if (filters.value.machinetype && machine.machinetypeid !== filters.value.machinetype) {
|
|
visible = false
|
|
}
|
|
if (filters.value.businessunit && machine.businessunitid !== filters.value.businessunit) {
|
|
visible = false
|
|
}
|
|
if (filters.value.status && machine.statusid !== filters.value.status) {
|
|
visible = false
|
|
}
|
|
if (searchTerm && !searchData.includes(searchTerm)) {
|
|
visible = false
|
|
}
|
|
|
|
marker.setStyle({ fillOpacity: visible ? 1 : 0.15, opacity: visible ? 1 : 0.15 })
|
|
})
|
|
}
|
|
|
|
let searchTimeout = null
|
|
function debounceSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(applyFilters, 300)
|
|
}
|
|
|
|
watch(() => props.machines, (newVal, oldVal) => {
|
|
if (map && newVal !== oldVal) renderMarkers()
|
|
})
|
|
|
|
watch(() => props.theme, (newTheme) => {
|
|
if (imageOverlay && map) {
|
|
imageOverlay.setUrl(blueprintUrlFor(newTheme, drawnLevelId()))
|
|
// Marker rings are keyed on the surface, so they have to be redrawn too.
|
|
renderMarkers()
|
|
}
|
|
})
|
|
|
|
// 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.
|
|
await loadMapConfig()
|
|
MAP_WIDTH = dimensionsFor(drawnLevelId()).width
|
|
MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
|
|
initMap()
|
|
loadOverlays()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (map) {
|
|
map.remove()
|
|
map = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.shopfloor-map {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.map-controls {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem;
|
|
background: var(--bg-card);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filters select,
|
|
.filters input {
|
|
padding: 0.625rem 1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 1rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
|
|
.filters select {
|
|
cursor: pointer;
|
|
appearance: none;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 0.75rem center;
|
|
padding-right: 2.25rem;
|
|
min-width: 160px;
|
|
}
|
|
|
|
.filters input {
|
|
width: 280px;
|
|
}
|
|
|
|
.filters input::placeholder {
|
|
color: var(--text-light);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.filters input::placeholder {
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
flex-wrap: wrap;
|
|
font-size: 1.125rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.map-legend-bar {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
flex-wrap: wrap;
|
|
font-size: 1rem;
|
|
color: var(--text);
|
|
padding: 0.5rem 1rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
/* Ring color is bound inline (legendDotStyle) to match the markers. */
|
|
border: 1.5px solid var(--bg-card);
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
/* Overlay legend entries: a hollow ring, distinct from the solid type dots. */
|
|
.legend-dot.legend-ring {
|
|
background: transparent;
|
|
border-width: 3px;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.map-container {
|
|
flex: 1;
|
|
min-height: 600px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.map-container.picker-active {
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.picker-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
padding: 1rem;
|
|
background: var(--warning);
|
|
border-bottom: 1px solid var(--warning);
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
.picker-message {
|
|
font-weight: 500;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.picker-coords {
|
|
font-family: monospace;
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
padding: 0.375rem 0.75rem;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
:deep(.machine-marker) {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
}
|
|
|
|
:deep(.machine-marker-dot) {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
border: 2px solid rgba(255,255,255,0.8);
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
:deep(.picker-marker-dot) {
|
|
background: #ff0000;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--border);
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
:deep(.marker-tooltip) {
|
|
background: rgba(0, 0, 0, 0.92);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 14px 18px;
|
|
font-size: 1.125rem;
|
|
line-height: 1.7;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
max-width: 400px;
|
|
}
|
|
|
|
:deep(.marker-tooltip::before) {
|
|
border-top-color: rgba(0, 0, 0, 0.92);
|
|
}
|
|
|
|
/* Legend bar for asset type mode (no filters - parent handles them) */
|
|
.map-legend {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
flex-wrap: wrap;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
margin-bottom: 0.75rem;
|
|
font-size: 0.875rem;
|
|
color: var(--text);
|
|
}
|
|
</style>
|