loko/streetup/assets/static/assets/asset_generic_map.js
2026-07-22 14:48:40 +02:00

766 lines
30 KiB
JavaScript

// Asset Generic Detail Map - MapLibre with geometry display and editing support
// This module initializes a map for the generic asset detail view
(function() {
'use strict';
const MAPTILER_STYLE = window.MAP_STYLE_LIGHT;
const ORTHO_WMTS_URL = 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}';
const URBIS_WMS_URL = 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}';
let map = null;
let assetGeometry = null;
document.addEventListener('DOMContentLoaded', function() {
initializeMap();
});
function initializeMap() {
const mapContainer = document.getElementById('asset-map');
if (!mapContainer) return;
const container = document.getElementById('asset-detail-container');
if (!container) return;
// Get geometry from data attribute
const geometryJson = container.dataset.assetGeometry;
if (geometryJson) {
try {
assetGeometry = JSON.parse(geometryJson);
} catch (e) {
console.warn('Failed to parse asset geometry:', e);
}
}
// Fallback to lon/lat if no geometry
if (!assetGeometry) {
const lonElement = document.querySelector('.asset-editable-field[data-field="lon"] .field-value');
const latElement = document.querySelector('.asset-editable-field[data-field="lat"] .field-value');
if (lonElement && latElement) {
const lon = parseFloat(lonElement.textContent.trim());
const lat = parseFloat(latElement.textContent.trim());
if (!isNaN(lon) && !isNaN(lat)) {
assetGeometry = {
type: 'Point',
coordinates: [lon, lat]
};
}
}
}
// Calculate center and bounds (use Brussels center as default if no geometry)
const center = assetGeometry ? calculateCenter(assetGeometry) : [4.3517, 50.8503]; // Brussels center
const bounds = assetGeometry ? calculateBounds(assetGeometry) : null;
// Initialize MapLibre map
map = new maplibregl.Map({
container: 'asset-map',
style: MAPTILER_STYLE,
center: center,
zoom: assetGeometry ? 17 : 12, // Zoom out if no geometry
pitch: 0,
antialias: true
});
// Make map available globally for geometry editing
window.map = map;
// Add navigation controls
map.addControl(new maplibregl.NavigationControl(), 'top-left');
// Add layer switcher (Urbis default, Vlaanderen alternative)
addOrthoLayerSwitcher();
map.on('load', function() {
// Add asset geometry if available
if (assetGeometry) {
addAssetGeometry(assetGeometry);
// Fit to bounds if available
if (bounds) {
map.fitBounds(bounds, {
padding: 50,
maxZoom: 18
});
}
}
// Add sibling assets from the same intersection (trafficlights only)
const thematicCode = container.dataset.thematicCode;
if (thematicCode === 'trafficlights') {
addIntersectionAssetsLayer(container);
addConnectionLinesLayer();
}
});
}
function calculateCenter(geometry) {
if (!geometry) return [4.3517, 50.8503]; // Default Brussels center
switch (geometry.type) {
case 'Point':
return geometry.coordinates;
case 'LineString':
// Return midpoint of the line
const lineCoords = geometry.coordinates;
const midIndex = Math.floor(lineCoords.length / 2);
return lineCoords[midIndex] || lineCoords[0];
case 'Polygon':
// Return centroid of the polygon
const polyCoords = geometry.coordinates[0];
if (!polyCoords || polyCoords.length === 0) return [4.3517, 50.8503];
let sumLon = 0, sumLat = 0;
polyCoords.forEach(coord => {
sumLon += coord[0];
sumLat += coord[1];
});
return [sumLon / polyCoords.length, sumLat / polyCoords.length];
case 'MultiPoint':
const firstPoint = geometry.coordinates[0];
return firstPoint || [4.3517, 50.8503];
case 'MultiLineString':
const firstLine = geometry.coordinates[0];
if (firstLine && firstLine.length > 0) {
return firstLine[Math.floor(firstLine.length / 2)];
}
return [4.3517, 50.8503];
case 'MultiPolygon':
const firstPoly = geometry.coordinates[0];
if (firstPoly && firstPoly[0] && firstPoly[0].length > 0) {
let sumLon = 0, sumLat = 0;
firstPoly[0].forEach(coord => {
sumLon += coord[0];
sumLat += coord[1];
});
return [sumLon / firstPoly[0].length, sumLat / firstPoly[0].length];
}
return [4.3517, 50.8503];
default:
return [4.3517, 50.8503];
}
}
function calculateBounds(geometry) {
if (!geometry) return null;
let allCoords = [];
function extractCoords(coords) {
if (Array.isArray(coords[0])) {
coords.forEach(extractCoords);
} else {
allCoords.push(coords);
}
}
extractCoords(geometry.coordinates);
if (allCoords.length === 0) return null;
let minLon = Infinity, maxLon = -Infinity;
let minLat = Infinity, maxLat = -Infinity;
allCoords.forEach(coord => {
minLon = Math.min(minLon, coord[0]);
maxLon = Math.max(maxLon, coord[0]);
minLat = Math.min(minLat, coord[1]);
maxLat = Math.max(maxLat, coord[1]);
});
// Add padding for single point
if (minLon === maxLon && minLat === maxLat) {
const offset = 0.001;
return [[minLon - offset, minLat - offset], [maxLon + offset, maxLat + offset]];
}
return [[minLon, minLat], [maxLon, maxLat]];
}
function addAssetGeometry(geometry) {
if (!map || !geometry) return;
const geojson = {
type: 'Feature',
geometry: geometry,
properties: {}
};
// Add source
map.addSource('asset-geometry', {
type: 'geojson',
data: geojson
});
// Add layers based on geometry type
switch (geometry.type) {
case 'Point':
case 'MultiPoint':
addPointLayer();
break;
case 'LineString':
case 'MultiLineString':
addLineLayer();
break;
case 'Polygon':
case 'MultiPolygon':
addPolygonLayer();
break;
default:
// Try to add all layer types
addPolygonLayer();
addLineLayer();
addPointLayer();
}
}
function addPointLayer() {
// Outer glow ring (yellow highlight for current asset)
map.addLayer({
id: 'asset-point-outer',
type: 'circle',
source: 'asset-geometry',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 14,
'circle-color': '#FFD700',
'circle-opacity': 0.5,
'circle-stroke-width': 0
}
});
// Middle ring (white border)
map.addLayer({
id: 'asset-point-middle',
type: 'circle',
source: 'asset-geometry',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 11,
'circle-color': '#ffffff',
'circle-stroke-width': 0
}
});
// Inner circle (orange fill, blue stroke)
map.addLayer({
id: 'asset-point-inner',
type: 'circle',
source: 'asset-geometry',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 8,
'circle-color': '#FF6B00',
'circle-stroke-width': 2,
'circle-stroke-color': '#0056b3'
}
});
}
function addLineLayer() {
// Line outline (white border)
map.addLayer({
id: 'asset-line-outline',
type: 'line',
source: 'asset-geometry',
filter: ['any', ['==', ['geometry-type'], 'LineString'], ['==', ['geometry-type'], 'MultiLineString']],
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': '#ffffff',
'line-width': 3.5
}
});
// Line fill (blue)
map.addLayer({
id: 'asset-line-fill',
type: 'line',
source: 'asset-geometry',
filter: ['any', ['==', ['geometry-type'], 'LineString'], ['==', ['geometry-type'], 'MultiLineString']],
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': '#007bff',
'line-width': 2
}
});
}
function addPolygonLayer() {
// Polygon fill
map.addLayer({
id: 'asset-polygon-fill',
type: 'fill',
source: 'asset-geometry',
filter: ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']],
paint: {
'fill-color': '#007bff',
'fill-opacity': 0.3
}
});
// Polygon outline
map.addLayer({
id: 'asset-polygon-outline',
type: 'line',
source: 'asset-geometry',
filter: ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']],
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': '#007bff',
'line-width': 2
}
});
}
// Add sibling assets from the same intersection as semi-transparent markers
function addIntersectionAssetsLayer(container) {
const el = document.getElementById('intersection-assets-geojson');
if (!el) return;
let intersectionGeoJSON;
try {
intersectionGeoJSON = JSON.parse(el.textContent);
} catch (e) {
return;
}
const tlIconUrl = container.dataset.tlIconUrl;
const simplePoleIconUrl = container.dataset.simplePoleIconUrl;
const cabinetIconUrl = container.dataset.cabinetIconUrl;
const radarIconUrl = container.dataset.radarIconUrl;
// Load image at full opacity onto a fixed-size canvas.
// Opacity is controlled via MapLibre's icon-opacity paint property.
function loadImageToCanvas(url, size) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
const scale = Math.min(size / img.width, size / img.height);
const w = img.width * scale, h = img.height * scale;
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
resolve(ctx.getImageData(0, 0, size, size));
};
img.onerror = reject;
img.src = url;
});
}
function registerBootstrapGlyphSdfIcon(map, iconName, codepoint, size = 32, color = '#333333') {
if (map.hasImage(iconName)) return Promise.resolve();
return document.fonts.ready.then(() => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size);
ctx.font = `${Math.round(size * 0.85)}px "bootstrap-icons"`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String.fromCodePoint(parseInt(codepoint, 16)), size / 2, size / 2);
const imageData = ctx.getImageData(0, 0, size, size);
if (!map.hasImage(iconName)) {
map.addImage(iconName, imageData);
}
});
}
const iconSize = ['interpolate', ['linear'], ['zoom'], 8, 0.1, 14, 0.5, 22, 1];
const promises = [
tlIconUrl ? loadImageToCanvas(tlIconUrl, 64) : Promise.resolve(null),
simplePoleIconUrl ? loadImageToCanvas(simplePoleIconUrl, 64) : Promise.resolve(null),
cabinetIconUrl ? loadImageToCanvas(cabinetIconUrl, 32) : Promise.resolve(null),
radarIconUrl ? loadImageToCanvas(radarIconUrl, 64) : Promise.resolve(null),
registerBootstrapGlyphSdfIcon(map, 'icon-trafficlightsequipment', 'f1d5', 32, '#331110')
];
Promise.all(promises).then(([tlImg, poleImg, cabinetImg, radarImg]) => {
if (tlImg && !map.hasImage('tl-icon')) map.addImage('tl-icon', tlImg);
if (poleImg && !map.hasImage('pole-icon')) map.addImage('pole-icon', poleImg);
if (cabinetImg && !map.hasImage('cabinet-icon')) map.addImage('cabinet-icon', cabinetImg);
if (radarImg && !map.hasImage('radar-icon')) map.addImage('radar-icon', radarImg);
// ── Sibling assets source ──────────────────────────────────────────
const hasSiblings = intersectionGeoJSON && intersectionGeoJSON.features && intersectionGeoJSON.features.length > 0;
if (hasSiblings && !map.getSource('intersection-assets')) {
map.addSource('intersection-assets', { type: 'geojson', data: intersectionGeoJSON });
}
if (hasSiblings) {
// Sibling poles (semi-transparent)
if ((tlImg || poleImg) && !map.getLayer('sibling-poles')) {
map.addLayer({
id: 'sibling-poles',
type: 'symbol',
source: 'intersection-assets',
filter: ['match', ['get', 'asset_type'], ['trafficlightpole', 'trafficlightradarpole'], true, false],
layout: {
'icon-image': [
'match',
['get', 'asset_type'],
'trafficlightradarpole', 'pole-icon',
'tl-icon'
],
'icon-size': iconSize,
'icon-allow-overlap': true,
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 10,
'text-anchor': 'top',
'text-offset': [0, 1.2],
'text-optional': true,
},
paint: {
'icon-opacity': 0.55,
'text-color': '#555555',
'text-halo-color': 'rgba(255,255,255,0.8)',
'text-halo-width': 1.5,
'text-opacity': 0.7,
},
});
}
// Sibling electrical cabinets (semi-transparent)
if (cabinetImg && !map.getLayer('sibling-cabinets')) {
map.addLayer({
id: 'sibling-cabinets',
type: 'symbol',
source: 'intersection-assets',
filter: ['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
layout: {
'icon-image': 'cabinet-icon',
'icon-size': iconSize,
'icon-allow-overlap': true,
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 10,
'text-anchor': 'top',
'text-offset': [0, 1.2],
'text-optional': true,
},
paint: {
'icon-opacity': 0.55,
'text-color': '#555555',
'text-halo-color': 'rgba(255,255,255,0.8)',
'text-halo-width': 1.5,
'text-opacity': 0.7,
},
});
}
// Sibling radars (semi-transparent)
if (radarImg && !map.getLayer('sibling-radars')) {
map.addLayer({
id: 'sibling-radars',
type: 'symbol',
source: 'intersection-assets',
filter: ['==', ['get', 'asset_type'], 'trafficlightradar'],
layout: {
'icon-image': 'radar-icon',
'icon-size': iconSize,
'icon-allow-overlap': true,
'icon-offset': [
'case',
['coalesce', ['get', 'has_support'], false], ['literal', [0, -25]],
['literal', [0, 0]]
],
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 10,
'text-anchor': 'top',
'text-offset': [0, 1.2],
'text-optional': true,
},
paint: {
'icon-opacity': 0.55,
'text-color': '#555555',
'text-halo-color': 'rgba(255,255,255,0.8)',
'text-halo-width': 1.5,
'text-opacity': 0.7,
},
});
}
// Sibling generic equipment (semi-transparent)
if (!map.getLayer('sibling-equipment')) {
map.addLayer({
id: 'sibling-equipment',
type: 'symbol',
source: 'intersection-assets',
filter: ['match', ['get', 'asset_type'], ['trafficlightcontroller', 'trafficlightdetector', 'trafficlightcontrollerhardware', 'trafficlightnetworkhardware', 'trafficlightvoltagehardware'], true, false],
layout: {
'icon-image': 'icon-trafficlightsequipment',
'icon-size': iconSize,
'icon-allow-overlap': true,
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Regular'],
'text-size': 10,
'text-anchor': 'top',
'text-offset': [0, 1.2],
'text-optional': true,
},
paint: {
'icon-opacity': 0.55,
'text-color': '#555555',
'text-halo-color': 'rgba(255,255,255,0.8)',
'text-halo-width': 1.5,
'text-opacity': 0.7,
},
});
}
// Sibling cables (line geometry)
if (!map.getLayer('sibling-cables')) {
map.addLayer({
id: 'sibling-cables',
type: 'line',
source: 'intersection-assets',
filter: ['==', ['get', 'asset_type'], 'trafficlightcable'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-color': ['coalesce', ['get', 'color'], '#e53935'],
'line-width': 2,
'line-opacity': 0.7,
'line-dasharray': [2.5, 2]
},
});
}
// Click to navigate to sibling assets
['sibling-poles', 'sibling-cabinets', 'sibling-radars', 'sibling-equipment', 'sibling-cables'].forEach(layerId => {
if (!map.getLayer(layerId)) return;
map.on('click', layerId, (e) => {
if (e.features.length > 0) {
const url = e.features[0].properties.detail_url;
if (url) window.location.href = url;
}
});
map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
});
}
// ── Current asset icon (full opacity, on top of highlight rings) ──
const assetModel = container.dataset.assetModel;
const hasSupport = container.dataset.hasSupport === 'true';
const currentIconImage =
assetModel === 'trafficlightpole' && tlImg ? 'tl-icon' :
assetModel === 'trafficlightradarpole' && poleImg ? 'pole-icon' :
assetModel === 'trafficlightelectricalcabinet' && cabinetImg ? 'cabinet-icon' :
assetModel === 'trafficlightradar' && radarImg ? 'radar-icon' :
['trafficlightcontroller', 'trafficlightdetector', 'trafficlightcontrollerhardware', 'trafficlightnetworkhardware', 'trafficlightvoltagehardware'].includes(assetModel) ? 'icon-trafficlightsequipment' :
null;
if (currentIconImage && !map.getLayer('current-asset-icon')) {
map.addLayer({
id: 'current-asset-icon',
type: 'symbol',
source: 'asset-geometry',
filter: ['==', ['geometry-type'], 'Point'],
layout: {
'icon-image': currentIconImage,
'icon-size': iconSize,
'icon-allow-overlap': true,
'icon-offset': (assetModel === 'trafficlightradar' && hasSupport) ? [0, -25] : [0, 0]
},
paint: {
'icon-opacity': 1.0,
},
});
}
// Bring highlight rings and icon to the top so they render above all other layers
bringAssetLayersToTop();
}).catch(e => console.warn('Failed to load asset icons:', e));
}
function addConnectionLinesLayer() {
const el = document.getElementById('connection-lines-geojson');
if (!el) return;
let connectionGeoJSON;
try {
connectionGeoJSON = JSON.parse(el.textContent);
} catch (e) {
return;
}
if (connectionGeoJSON && connectionGeoJSON.features && connectionGeoJSON.features.length > 0) {
if (!map.getSource('connection-lines')) {
map.addSource('connection-lines', {
type: 'geojson',
data: connectionGeoJSON
});
}
if (!map.getLayer('connection-lines-layer')) {
map.addLayer({
id: 'connection-lines-layer',
type: 'line',
source: 'connection-lines',
layout: {
'line-cap': 'round',
'line-join': 'round'
},
paint: {
'line-color': '#007bff',
'line-width': 2,
'line-opacity': 0.8,
'line-dasharray': [2, 2]
}
});
}
}
}
// Add orthophoto WMTS source
// All layers that must always sit above the orthophoto
const ASSET_LAYER_ORDER = [
'connection-lines-layer',
'sibling-cables', 'sibling-poles', 'sibling-cabinets', 'sibling-radars', 'sibling-equipment',
'asset-polygon-fill', 'asset-polygon-outline',
'asset-line-outline', 'asset-line-fill',
'asset-point-outer', 'asset-point-middle', 'asset-point-inner',
'current-asset-icon',
];
function bringAssetLayersToTop() {
ASSET_LAYER_ORDER.forEach(id => {
if (map.getLayer(id)) map.moveLayer(id);
});
}
function addOrthoLayerSwitcher() {
if (typeof LayerSwitcherControl === 'undefined') return;
const layerSwitcher = new LayerSwitcherControl({
layers: [
{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [window.MAP_ORTHO_WMS_URL || URBIS_WMS_URL],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: [ORTHO_WMTS_URL],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}
]
});
map.addControl(layerSwitcher, 'top-left');
// The LayerSwitcherControl appends sublayer selectors to document.body with
// position:fixed and hardcoded coordinates designed for fullscreen maps.
// We move each selector into #asset-map (position:relative) and switch to
// position:absolute so it stays anchored to the map div.
const mapEl = document.getElementById('asset-map');
if (mapEl) {
// Wait one tick for the control to finish rendering
setTimeout(() => {
document.querySelectorAll('.layer-switcher-sublayer-selector').forEach(sel => {
// Only move selectors that are still attached to body
if (sel.parentNode === document.body) {
mapEl.appendChild(sel);
sel.style.position = 'absolute';
sel.style.left = '46px';
sel.style.top = '10px';
sel.style.zIndex = '10';
}
});
}, 0);
}
// After any ortho interaction, put asset layers back on top
map.getContainer().addEventListener('click', (e) => {
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn')) {
setTimeout(bringAssetLayersToTop, 150);
}
}, true);
}
// Function to refresh the map with new geometry (called after editing)
window.refreshAssetMap = function(newGeometry) {
if (!map) return;
// Remove existing source and layers
const layerIds = ['asset-point-outer', 'asset-point-middle', 'asset-point-inner',
'asset-line-outline', 'asset-line-fill',
'asset-polygon-fill', 'asset-polygon-outline',
'current-asset-icon'];
layerIds.forEach(id => {
if (map.getLayer(id)) {
map.removeLayer(id);
}
});
if (map.getSource('asset-geometry')) {
map.removeSource('asset-geometry');
}
// Add new geometry
if (newGeometry) {
addAssetGeometry(newGeometry);
const bounds = calculateBounds(newGeometry);
if (bounds) {
map.fitBounds(bounds, {
padding: 50,
maxZoom: 18
});
}
}
};
})();