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 }}
|
{{ assetTypeLabels[assetType] || assetType }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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>
|
||||||
|
|
||||||
<div class="picker-controls" v-if="pickerMode">
|
<div class="picker-controls" v-if="pickerMode">
|
||||||
@@ -88,6 +97,7 @@ import L from 'leaflet'
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
machines: { type: Array, default: () => [] },
|
machines: { type: Array, default: () => [] },
|
||||||
@@ -114,6 +124,13 @@ let pickerMarker = null
|
|||||||
let markerLayer = null
|
let markerLayer = null
|
||||||
let canvasRenderer = 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({
|
const filters = ref({
|
||||||
machinetype: '',
|
machinetype: '',
|
||||||
businessunit: '',
|
businessunit: '',
|
||||||
@@ -360,10 +377,77 @@ function getDetailRoute(machine) {
|
|||||||
return `${basePath}/${machine.machineid}`
|
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() {
|
function renderMarkers() {
|
||||||
// Clear existing markers
|
// Clear existing markers + overlay decoration layers
|
||||||
markers.value.forEach(m => m.marker.remove())
|
markers.value.forEach(m => m.marker.remove())
|
||||||
markers.value = []
|
markers.value = []
|
||||||
|
overlayLayers.forEach(layer => layer.remove())
|
||||||
|
overlayLayers = []
|
||||||
|
|
||||||
props.machines.forEach(item => {
|
props.machines.forEach(item => {
|
||||||
if (item.mapx == null || item.mapy == null) return
|
if (item.mapx == null || item.mapy == null) return
|
||||||
@@ -498,6 +582,7 @@ function renderMarkers() {
|
|||||||
marker.on('click', () => emit('markerClick', item))
|
marker.on('click', () => emit('markerClick', item))
|
||||||
|
|
||||||
marker.addTo(map)
|
marker.addTo(map)
|
||||||
|
applyOverlays(item, leafletY, leafletX)
|
||||||
|
|
||||||
// Build search data
|
// Build search data
|
||||||
const searchData = props.assetTypeMode
|
const searchData = props.assetTypeMode
|
||||||
@@ -565,6 +650,7 @@ onMounted(async () => {
|
|||||||
MAP_WIDTH = mapConfig.width
|
MAP_WIDTH = mapConfig.width
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_HEIGHT = mapConfig.height
|
||||||
initMap()
|
initMap()
|
||||||
|
loadOverlays()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -667,6 +753,13 @@ onUnmounted(() => {
|
|||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
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 {
|
.map-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
|
|||||||
@@ -363,13 +363,18 @@ def map_overlay():
|
|||||||
"""
|
"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True)
|
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 = []
|
data = []
|
||||||
for tool in query.all():
|
for tool in query.all():
|
||||||
status = derive_status(tool.nextcalibrationdate, today)
|
status = derive_status(tool.nextcalibrationdate, today)
|
||||||
|
if status not in ('overdue', 'duesoon'):
|
||||||
|
continue
|
||||||
data.append({
|
data.append({
|
||||||
'assetid': tool.assetid,
|
'assetid': tool.assetid,
|
||||||
'calibrationstatus': status,
|
'color': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
||||||
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
'label': 'Overdue' if status == 'overdue' else 'Due soon',
|
||||||
})
|
})
|
||||||
return success_response(data)
|
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')
|
response = client.get('/api/measuringtools/map-overlay')
|
||||||
assert response.status_code == 200, response.get_json()
|
assert response.status_code == 200, response.get_json()
|
||||||
rows = response.get_json()['data']
|
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)
|
row = next(r for r in rows if r['assetid'] == assetid)
|
||||||
assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'}
|
assert set(row) == {'assetid', 'color', 'label'}
|
||||||
assert row['calibrationstatus'] == 'overdue'
|
assert row['label'] == 'Overdue'
|
||||||
assert row['statuscolor'] == STATUS_COLORS['overdue']
|
assert row['color'] == STATUS_COLORS['overdue']
|
||||||
|
|
||||||
|
|
||||||
def test_map_overlay_excludes_inactive(client, auth_headers):
|
def test_map_overlay_excludes_inactive(client, auth_headers):
|
||||||
|
|||||||
Reference in New Issue
Block a user