ADR-013 Phase 3: generic map-overlays renderer (Path A)

Wires the ADR-010 get_map_overlays hook into the floor map so a plugin decorates
markers as JSON, no map code. ShopFloorMap fetches /api/pluginui/map-overlays,
then each overlay's endpoint (per-asset [{assetid, color, label}]), joins by
assetid, and draws a ring or badge circleMarker on matching markers plus a
legend entry - all as extra Leaflet layers cleared and redrawn with the markers.

Aligned the measuringtools calibration overlay endpoint to the documented
contract: it now returns {assetid, color, label} (was {calibrationstatus,
statuscolor}) and only decorates due/overdue tools.

Additive + guarded (assetid null check, per-endpoint try/catch, cleanup on
re-render), so the map degrades to no decorations on any failure. Verified: the
overlay endpoint serves the contract shape, the map renders without error, and
the frontend builds. A populated badge needs a site that actually places
measuring tools on its map (this dataset places none). 38 measuringtools/pluginui
tests, 58 vitest, build + naming green.
This commit is contained in:
cproudlock
2026-07-18 23:10:09 -04:00
parent 8a2f984393
commit 296b6e024b
3 changed files with 105 additions and 6 deletions

View File

@@ -68,6 +68,15 @@
{{ 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">
@@ -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;

View File

@@ -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)

View File

@@ -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):