Files
shopdb-flask/frontend/src/components/ShopFloorMap.vue

799 lines
23 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">
<!-- 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, 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
})
const emit = defineEmits(['markerClick', 'positionPicked'])
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: ''
})
// Map dimensions - facility blueprint size, loaded from settings before
// initMap runs (mutable so the loaded values replace the fallback defaults).
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.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), 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)
// Picker mode: click to set position
if (props.pickerMode) {
map.on('click', handleMapClick)
// Show initial position if provided
if (props.initialPosition) {
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
}
} else {
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 = []
props.machines.forEach(item => {
if (item.mapx == null || item.mapy == null) 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))
// Marker rings are keyed on the surface, so they have to be redrawn too.
renderMarkers()
}
})
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 = mapConfig.width
MAP_HEIGHT = mapConfig.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>