API tokens: any user mints named, optionally-expiring tokens (shopdb_pat_..., sha256-stored, secret shown once) at Settings > API Tokens; a before-request shim swaps a valid PAT for a request-scoped JWT of its owner, so the entire existing auth/authz/import-mode stack works unchanged and revoked/expired tokens 401 cleanly. Built for long-running scripts - the legacy import no longer dies when a login JWT expires. Migration 7d21_apitokens; create/revoke audit-logged. Audited integration gaps fixed: Asset.to_dict serializes measuring tools (typedata + pluginid - relationship links to tools resolve); map subtype filter/colors and MapEditor include them; dashboard totals count them; warranty links use a new by-asset route; the measuringtools ADR-010 hooks are real (corrected presentation token, implemented map-overlay endpoint); the login avatar resolves through the employee-photo helper. 737 tests pass; naming green; frontend builds; both features verified live end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
791 lines
22 KiB
Vue
791 lines
22 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="{ background: 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="{ background: 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="{ background: color }"></span>
|
|
{{ assetTypeLabels[assetType] || assetType }}
|
|
</span>
|
|
</template>
|
|
</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, assetDetailRoute } from '../utils/assetTypes'
|
|
|
|
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
|
|
|
|
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
|
|
})
|
|
|
|
// Get subtype ID from asset based on asset type
|
|
function getSubtypeId(asset) {
|
|
if (!asset.typedata) return null
|
|
// Normalize network_device -> network device so the subtype id resolves.
|
|
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
|
|
if (typeLower === 'machine') return asset.typedata.machinetypeid
|
|
if (typeLower === 'computer') return asset.typedata.computertypeid
|
|
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
|
if (typeLower === 'printer') return asset.typedata.printertypeid
|
|
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
|
return null
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Get the detail page route based on machine category
|
|
// Extensible for future addon types (network, cameras, etc.)
|
|
function getDetailRoute(machine) {
|
|
const category = machine.category?.toLowerCase() || ''
|
|
const routeMap = {
|
|
'machine': '/machines',
|
|
'pc': '/pcs',
|
|
'printer': '/printers',
|
|
// Future addon routes can be added here:
|
|
// 'network': '/network',
|
|
// 'camera': '/cameras',
|
|
}
|
|
const basePath = routeMap[category] || '/machines'
|
|
return `${basePath}/${machine.machineid}`
|
|
}
|
|
|
|
function renderMarkers() {
|
|
// Clear existing markers
|
|
markers.value.forEach(m => m.marker.remove())
|
|
markers.value = []
|
|
|
|
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, detailRoute
|
|
|
|
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]) || '#BDBDBD'
|
|
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}`
|
|
}
|
|
detailRoute = getAssetDetailRoute(item)
|
|
} else {
|
|
// Legacy machine mode
|
|
typeName = item.machinetype || ''
|
|
color = getTypeColor(typeName)
|
|
displayName = item.alias || item.machinenumber || 'Unknown'
|
|
detailRoute = getDetailRoute(item)
|
|
}
|
|
|
|
// 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,
|
|
color: 'rgba(255,255,255,0.8)',
|
|
weight: 2,
|
|
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'
|
|
})
|
|
|
|
// Click popup (detailed info)
|
|
let popupContent
|
|
if (props.assetTypeMode) {
|
|
popupContent = `
|
|
<div class="marker-popup">
|
|
<strong>${displayName}</strong>
|
|
<div class="popup-details">
|
|
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
|
|
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
|
|
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
|
<div><span class="label">Location:</span> ${item.location || '-'}</div>
|
|
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
|
|
</div>
|
|
<a href="${detailRoute}" class="popup-link">View Details</a>
|
|
</div>
|
|
`
|
|
} else {
|
|
popupContent = `
|
|
<div class="marker-popup">
|
|
<strong>${displayName}</strong>
|
|
<div class="popup-details">
|
|
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
|
|
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
|
<div><span class="label">Category:</span> ${item.category || '-'}</div>
|
|
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
|
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
|
|
<div><span class="label">Model:</span> ${item.model || '-'}</div>
|
|
</div>
|
|
<a href="${detailRoute}" class="popup-link">View Details</a>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
marker.bindPopup(popupContent)
|
|
marker.on('click', () => emit('markerClick', item))
|
|
|
|
marker.addTo(map)
|
|
|
|
// 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()
|
|
}
|
|
|
|
// Get detail route for unified asset format (shared util = single source).
|
|
function getAssetDetailRoute(asset) {
|
|
return assetDetailRoute(asset)
|
|
}
|
|
|
|
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))
|
|
}
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
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%;
|
|
border: 2px solid var(--bg-card);
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
.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(.marker-popup) {
|
|
min-width: 320px;
|
|
}
|
|
|
|
:deep(.marker-popup strong) {
|
|
display: block;
|
|
margin-bottom: 0.625rem;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
:deep(.popup-details) {
|
|
font-size: 1.125rem;
|
|
line-height: 1.9;
|
|
}
|
|
|
|
:deep(.popup-details .label) {
|
|
color: #666;
|
|
font-weight: 500;
|
|
}
|
|
|
|
:deep(.popup-link) {
|
|
display: inline-block;
|
|
margin-top: 1rem;
|
|
color: #1976d2;
|
|
text-decoration: none;
|
|
font-size: 1.125rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
:deep(.popup-link:hover) {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
: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>
|