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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user