Files
shopdb-flask/frontend/src/components/ShopFloorMap.vue
cproudlock 89248407e7
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Make the map editor accept a click, and unclutter its panel
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.
2026-08-17 15:20:44 -04:00

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>