diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue index c6918a1..675ce4b 100644 --- a/frontend/src/components/ShopFloorMap.vue +++ b/frontend/src/components/ShopFloorMap.vue @@ -68,6 +68,15 @@ {{ assetTypeLabels[assetType] || assetType }} + + + + {{ entry.label }} +
@@ -88,6 +97,7 @@ import L from 'leaflet' import 'leaflet/dist/leaflet.css' import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes' +import api from '../api' const props = defineProps({ machines: { type: Array, default: () => [] }, @@ -114,6 +124,13 @@ 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: '', @@ -360,10 +377,77 @@ function getDetailRoute(machine) { return `${basePath}/${machine.machineid}` } +// 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 + // 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 @@ -498,6 +582,7 @@ function renderMarkers() { marker.on('click', () => emit('markerClick', item)) marker.addTo(map) + applyOverlays(item, leafletY, leafletX) // Build search data const searchData = props.assetTypeMode @@ -565,6 +650,7 @@ onMounted(async () => { MAP_WIDTH = mapConfig.width MAP_HEIGHT = mapConfig.height initMap() + loadOverlays() }) onUnmounted(() => { @@ -667,6 +753,13 @@ onUnmounted(() => { 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; diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py index 36ad821..89c6fc6 100644 --- a/plugins/measuringtools/api/routes.py +++ b/plugins/measuringtools/api/routes.py @@ -363,13 +363,18 @@ def map_overlay(): """ today = date.today() query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True) + # Overlay contract (ADR-010): [{assetid, color, label}] joined by assetid. + # Only decorate tools that are actually due/overdue - a marker with no entry + # gets no badge. data = [] for tool in query.all(): status = derive_status(tool.nextcalibrationdate, today) + if status not in ('overdue', 'duesoon'): + continue data.append({ 'assetid': tool.assetid, - 'calibrationstatus': status, - 'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']), + 'color': STATUS_COLORS.get(status, STATUS_COLORS['unknown']), + 'label': 'Overdue' if status == 'overdue' else 'Due soon', }) return success_response(data) diff --git a/tests/test_plugins/test_measuringtools.py b/tests/test_plugins/test_measuringtools.py index 1143a57..cee5794 100644 --- a/tests/test_plugins/test_measuringtools.py +++ b/tests/test_plugins/test_measuringtools.py @@ -447,10 +447,11 @@ def test_map_overlay_shape_and_derivation(client, auth_headers): response = client.get('/api/measuringtools/map-overlay') assert response.status_code == 200, response.get_json() rows = response.get_json()['data'] + # ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools. row = next(r for r in rows if r['assetid'] == assetid) - assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'} - assert row['calibrationstatus'] == 'overdue' - assert row['statuscolor'] == STATUS_COLORS['overdue'] + assert set(row) == {'assetid', 'color', 'label'} + assert row['label'] == 'Overdue' + assert row['color'] == STATUS_COLORS['overdue'] def test_map_overlay_excludes_inactive(client, auth_headers):