898 lines
39 KiB
JavaScript
898 lines
39 KiB
JavaScript
// unified_map_layers.js
|
||
// Layer definitions and icon loading for the unified assets page.
|
||
//
|
||
// Render order enforced by UNIFIED_LAYERS (bottom → top):
|
||
// 1. fill — polygon locations (streets / areas)
|
||
// 2. fill — polygon assets (road surfaces, nature green spaces)
|
||
// 3. line — location outlines
|
||
// 4. circle — point locations (traffic-light intersections, …)
|
||
// 5. symbol — point assets (thematic icons from Bootstrap Icons font)
|
||
|
||
// ─── Icon helpers ─────────────────────────────────────────────────────────────
|
||
|
||
/** * Normalises an image from a URL onto a fixed-size canvas so that MapLibre's
|
||
* `icon-size` is a true pixel-size multiplier regardless of the source image's
|
||
* intrinsic dimensions. Matches the helper used in trafficlights_index.html.
|
||
*
|
||
* @param {string} url
|
||
* @param {number} [size=64]
|
||
* @returns {Promise<ImageData>}
|
||
*/
|
||
function loadNormalisedIcon(url, size = 64) {
|
||
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;
|
||
const 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(new Error('Failed to load icon: ' + url));
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
/** * Renders a Bootstrap Icons font glyph onto a canvas and registers it in the
|
||
* map as a non-SDF sprite. The glyph is drawn in the given colour so it is
|
||
* always visible regardless of whether MapLibre's icon-color works for custom
|
||
* images.
|
||
*
|
||
* Relies on the `bootstrap-icons` font-face already being loaded by CSS.
|
||
* Uses `document.fonts.ready` so registration is deferred until fonts are
|
||
* available.
|
||
*
|
||
* @param {maplibregl.Map} map
|
||
* @param {string} iconName – name to register with map.addImage()
|
||
* @param {string} codepoint – hex codepoint string, e.g. 'f453'
|
||
* @param {number} [size=32] – canvas size in pixels
|
||
* @param {string} [color='#333333'] – fill colour for the glyph
|
||
* @returns {Promise<void>}
|
||
*/
|
||
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; // thematic colour baked in — no reliance on icon-color SDF
|
||
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); // no sdf:true — colour is baked in
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Loads one icon per thematic into the map.
|
||
*
|
||
* Priority:
|
||
* 1. If `UNIFIED_ICON_URLS` (injected by the Django template) provides a URL
|
||
* for a thematic, the image is loaded via `loadNormalisedIcon` and kept as
|
||
* a full-colour (non-SDF) sprite — it retains its original colours.
|
||
* 2. All other thematics use Bootstrap Icons font glyphs rendered as SDF
|
||
* sprites, recoloured at runtime via the `icon-color` paint property.
|
||
*
|
||
* @param {maplibregl.Map} map
|
||
*/
|
||
function loadUnifiedMapIcons(map) {
|
||
// ── File-based icons (full colour, non-SDF) ─────────────────────────────
|
||
// UNIFIED_ICON_URLS is set in the inline <script> of unified_index.html
|
||
// using {% static %} so the URL is resolved server-side.
|
||
const iconUrls = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {};
|
||
|
||
// trafficlights: traffic_light_icon.svg (56 px)
|
||
if (iconUrls.trafficlights && !map.hasImage('icon-trafficlights')) {
|
||
loadNormalisedIcon(iconUrls.trafficlights, 48)
|
||
.then(data => { if (!map.hasImage('icon-trafficlights')) map.addImage('icon-trafficlights', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load trafficlights icon', e));
|
||
}
|
||
|
||
// trafficlightradar: radar_preventif_icon.svg (48 px)
|
||
if (iconUrls.trafficlightradar && !map.hasImage('icon-trafficlightradar')) {
|
||
loadNormalisedIcon(iconUrls.trafficlightradar, 48)
|
||
.then(data => { if (!map.hasImage('icon-trafficlightradar')) map.addImage('icon-trafficlightradar', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load trafficlightradar icon', e));
|
||
}
|
||
|
||
// trafficlightradarpole: radar_preventif_icon.svg reused at smaller size (32 px)
|
||
if (iconUrls.trafficlightradarpole && !map.hasImage('icon-trafficlightradarpole')) {
|
||
loadNormalisedIcon(iconUrls.trafficlightradarpole, 32)
|
||
.then(data => { if (!map.hasImage('icon-trafficlightradarpole')) map.addImage('icon-trafficlightradarpole', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load trafficlightradarpole icon', e));
|
||
}
|
||
|
||
// trafficlightelectricalcabinet: electrical-panel.png (32 px — armoires tricolores)
|
||
if (iconUrls.trafficlightelectricalcabinet && !map.hasImage('icon-trafficlightelectricalcabinet')) {
|
||
loadNormalisedIcon(iconUrls.trafficlightelectricalcabinet, 32)
|
||
.then(data => { if (!map.hasImage('icon-trafficlightelectricalcabinet')) map.addImage('icon-trafficlightelectricalcabinet', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load trafficlightelectricalcabinet icon', e));
|
||
}
|
||
|
||
// publiclighting: dedicated lamp-post SVG (56 px)
|
||
if (iconUrls.publiclighting && !map.hasImage('icon-publiclighting')) {
|
||
loadNormalisedIcon(iconUrls.publiclighting, 56)
|
||
.then(data => { if (!map.hasImage('icon-publiclighting')) map.addImage('icon-publiclighting', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load publiclighting icon', e));
|
||
}
|
||
|
||
// publiclightingelectricalcabinet: electrical-panel.png (32 px — armoires EP)
|
||
if (iconUrls.publiclightingelectricalcabinet && !map.hasImage('icon-publiclightingelectricalcabinet')) {
|
||
loadNormalisedIcon(iconUrls.publiclightingelectricalcabinet, 32)
|
||
.then(data => { if (!map.hasImage('icon-publiclightingelectricalcabinet')) map.addImage('icon-publiclightingelectricalcabinet', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load publiclightingelectricalcabinet icon', e));
|
||
}
|
||
|
||
// nature: dedicated deciduous tree SVG (56 px)
|
||
if (iconUrls.nature && !map.hasImage('icon-nature')) {
|
||
loadNormalisedIcon(iconUrls.nature, 56)
|
||
.then(data => { if (!map.hasImage('icon-nature')) map.addImage('icon-nature', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load nature icon', e));
|
||
}
|
||
|
||
// structures: dedicated bridge/tunnel SVG (52 px)
|
||
if (iconUrls.structures && !map.hasImage('icon-structures')) {
|
||
loadNormalisedIcon(iconUrls.structures, 52)
|
||
.then(data => { if (!map.hasImage('icon-structures')) map.addImage('icon-structures', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load structures icon', e));
|
||
}
|
||
|
||
// roaddrain: dedicated rectangular grille SVG (46 px)
|
||
if (iconUrls.roaddrain && !map.hasImage('icon-roaddrain')) {
|
||
loadNormalisedIcon(iconUrls.roaddrain, 46)
|
||
.then(data => { if (!map.hasImage('icon-roaddrain')) map.addImage('icon-roaddrain', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
|
||
}
|
||
|
||
// roadconcreteblockset: dedicated concrete block SVG (42 px)
|
||
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
|
||
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
|
||
.then(data => { if (!map.hasImage('icon-roadconcreteblockset')) map.addImage('icon-roadconcreteblockset', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load roadconcreteblockset icon', e));
|
||
}
|
||
|
||
// naturebench: dedicated bench SVG (36 px)
|
||
if (iconUrls.naturebench && !map.hasImage('icon-naturebench')) {
|
||
loadNormalisedIcon(iconUrls.naturebench, 36)
|
||
.then(data => { if (!map.hasImage('icon-naturebench')) map.addImage('icon-naturebench', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
|
||
}
|
||
|
||
// artworks: dedicated fountain/statue SVGs
|
||
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
|
||
loadNormalisedIcon(iconUrls.artworkfountain, 48)
|
||
.then(data => { if (!map.hasImage('icon-artworkfountain')) map.addImage('icon-artworkfountain', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load artworkfountain icon', e));
|
||
}
|
||
if (iconUrls.artworkmonument && !map.hasImage('icon-artworkmonument')) {
|
||
loadNormalisedIcon(iconUrls.artworkmonument, 48)
|
||
.then(data => { if (!map.hasImage('icon-artworkmonument')) map.addImage('icon-artworkmonument', data); })
|
||
.catch(e => console.warn('unified_map_layers: could not load artworkmonument icon', e));
|
||
}
|
||
// If the SVG above is unavailable, publiclighting falls back to Bootstrap glyph.
|
||
|
||
// ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ──────────
|
||
// codepoints extracted from bootstrap-icons.min.css
|
||
// Build the set of icon names that have a file-based URL being loaded asynchronously.
|
||
// These must be excluded from glyph registration entirely — not just skipped via
|
||
// map.hasImage() — because loadNormalisedIcon is async and the glyph loop runs
|
||
// synchronously before the file-based image has had a chance to register.
|
||
const FILE_ICON_NAMES = new Set(Object.keys(iconUrls).map(k => 'icon-' + k));
|
||
|
||
const GLYPH_ICONS = [
|
||
{ name: 'icon-publiclighting', codepoint: 'f453', color: '#fdd835' }, // lamp-fill fallback — jaune
|
||
{ name: 'icon-publiclightingelectricalcabinet', codepoint: 'f453', color: '#fdd835' }, // lamp-fill fallback — jaune
|
||
{ name: 'icon-trafficlights', codepoint: 'f336', color: '#e53935' }, // exclamation-octagon — rouge (fallback)
|
||
{ name: 'icon-trafficlightsequipment', codepoint: 'f1d5', color: '#331110' }, // generic TL equipment — rouge
|
||
{ name: 'icon-trafficlightradar', codepoint: 'f1d5', color: '#e53935' }, // radar preventif fallback — rouge
|
||
{ name: 'icon-trafficlightradarpole', codepoint: 'f4e0', color: '#e53935' }, // radar pole fallback — rouge (signpost-split)
|
||
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' }, // sign-stop-fill — bleu
|
||
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' }, // tree-fill — vert
|
||
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' }, // tree-fill fallback — vert
|
||
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' }, // droplet-fill — bleu GIEP
|
||
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' }, // water — bleu drain
|
||
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' }, // building-fill — marron
|
||
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' }, // broadcast-pin — bleu cyan
|
||
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' }, // trash3-fill — gris
|
||
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' }, // palette-fill — corail
|
||
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, // water — bleu fontaine
|
||
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, // person-standing — statue
|
||
{ name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet
|
||
{ name: 'icon-publicbuildings', codepoint: 'f1dd', color: '#2B579A' }, // building — bleu nuit
|
||
{ name: 'icon-publicbuilding', codepoint: 'f1dd', color: '#2B579A' }, // building — bleu nuit
|
||
{ name: 'icon-outdoorfacilities', codepoint: 'f302', color: '#198754' }, // dribbble/dribbble icon — vert plein air
|
||
{ name: 'icon-outdoor', codepoint: 'f302', color: '#198754' },
|
||
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
|
||
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
|
||
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
|
||
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement
|
||
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement
|
||
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' }, // bicycle — vert mobilité active
|
||
{ name: 'icon-parkingspot-scooter_drop_zone', codepoint: 'f8ab', color: '#10b981' }, // scooter — vert mobilité active
|
||
{ name: 'icon-parkingspot-pmr', codepoint: 'f8fb', color: '#0d6efd' }, // person-wheelchair — bleu voiture PMR
|
||
{ name: 'icon-parkingspot-ev_charging', codepoint: 'f839', color: '#0d6efd' }, // ev-station-fill — bleu voiture IRVE
|
||
{ name: 'icon-parkingspot-delivery', codepoint: 'f820', color: '#f59e0b' }, // truck-front-fill — orange camion livraison
|
||
{ name: 'icon-parkingspot-kiss_and_ride', codepoint: 'f291', color: '#0d6efd' }, // clock-fill — bleu voiture CD
|
||
{ name: 'icon-parkingspot-carsharing', codepoint: 'f7e0', color: '#0d6efd' }, // car-front-fill — bleu voiture autopartage
|
||
{ name: 'icon-parkinglocation', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement
|
||
];
|
||
|
||
|
||
|
||
|
||
|
||
GLYPH_ICONS.forEach(({ name, codepoint, color }) => {
|
||
// Skip if a file-based icon URL is being loaded for this name (async race condition)
|
||
if (FILE_ICON_NAMES.has(name)) return;
|
||
if (map.hasImage(name)) return;
|
||
registerBootstrapGlyphSdfIcon(map, name, codepoint, 32, color)
|
||
.catch(e => console.warn('unified_map_layers: could not register glyph icon', name, e));
|
||
});
|
||
|
||
registerCrossSdfIcons(map);
|
||
}
|
||
|
||
function registerCrossSdfIcons(map) {
|
||
if (map.hasImage('cross-sdf') && map.hasImage('cross-sdf-border')) {
|
||
return;
|
||
}
|
||
const sz = 36;
|
||
const pad = 6;
|
||
|
||
// 1. Fill cross SDF
|
||
if (!map.hasImage('cross-sdf')) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = sz; canvas.height = sz;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, sz, sz);
|
||
ctx.strokeStyle = '#000000';
|
||
ctx.lineWidth = 6;
|
||
ctx.lineCap = 'round';
|
||
ctx.beginPath();
|
||
// Horizontal
|
||
ctx.moveTo(pad, sz / 2);
|
||
ctx.lineTo(sz - pad, sz / 2);
|
||
// Vertical
|
||
ctx.moveTo(sz / 2, pad);
|
||
ctx.lineTo(sz / 2, sz - pad);
|
||
ctx.stroke();
|
||
map.addImage('cross-sdf', { width: sz, height: sz, data: ctx.getImageData(0, 0, sz, sz).data }, { sdf: true });
|
||
}
|
||
|
||
// 2. Border cross SDF
|
||
if (!map.hasImage('cross-sdf-border')) {
|
||
const bc = document.createElement('canvas');
|
||
bc.width = sz; bc.height = sz;
|
||
const bctx = bc.getContext('2d');
|
||
bctx.clearRect(0, 0, sz, sz);
|
||
bctx.strokeStyle = '#000000';
|
||
bctx.lineWidth = 12;
|
||
bctx.lineCap = 'round';
|
||
bctx.beginPath();
|
||
// Horizontal
|
||
bctx.moveTo(pad, sz / 2);
|
||
bctx.lineTo(sz - pad, sz / 2);
|
||
// Vertical
|
||
bctx.moveTo(sz / 2, pad);
|
||
bctx.lineTo(sz / 2, sz - pad);
|
||
bctx.stroke();
|
||
map.addImage('cross-sdf-border', { width: sz, height: sz, data: bctx.getImageData(0, 0, sz, sz).data }, { sdf: true });
|
||
}
|
||
}
|
||
|
||
// ─── Shared colour expression ─────────────────────────────────────────────────
|
||
// Used in both the asset fill layer and the asset symbol layer.
|
||
// eslint-disable-next-line no-unused-vars (referenced by UNIFIED_LAYERS below)
|
||
const THEMATIC_COLOR_EXPR = [
|
||
'match', ['get', 'thematic'],
|
||
'publiclighting', '#fdd835', // yellow — street lights
|
||
'sign', '#0022ff', // blue — road signs
|
||
'trafficlights', '#e53935', // red — traffic signals
|
||
'nature', '#2e7d32', // green — green spaces
|
||
'structures', '#6d4c41', // brown — structures
|
||
'its', '#0288d1', // cyan-blue — intelligent transport
|
||
'clean', '#5e5e5e', // grey — cleanliness
|
||
'artworks', '#f24141', // coral — artworks / monuments
|
||
'controlcenters', '#7b1fa2', // purple — control centres
|
||
'publicbuildings', '#2B579A', // navy blue — public buildings
|
||
'buildings', '#2B579A', // navy blue — public buildings
|
||
'parking', '#0d6efd', // blue — parking & mobility
|
||
'roads', [
|
||
|
||
'match', ['get', 'asset_type'],
|
||
'roadway', '#4a4a4a',
|
||
'sidewalk', '#979898ff',
|
||
'#607d8b' // fallback for roads
|
||
],
|
||
'#0022ff' // fallback
|
||
];
|
||
|
||
// ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
|
||
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
|
||
const _unified_locations_fill = {
|
||
id: 'locations',
|
||
type: 'fill',
|
||
source: 'locations',
|
||
paint: {
|
||
'fill-color': '#0094ff',
|
||
'fill-opacity': 0, // contour only — outline handled by locations-outline
|
||
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
||
},
|
||
};
|
||
|
||
// ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
|
||
const _unified_assets_fill = {
|
||
id: 'assets-fill',
|
||
type: 'fill',
|
||
source: 'assets',
|
||
filter: ['any',
|
||
['==', ['geometry-type'], 'Polygon'],
|
||
['==', ['geometry-type'], 'MultiPolygon']
|
||
],
|
||
paint: {
|
||
'fill-color': [
|
||
// Nature surfaces use a per-feature 'color' property; all others
|
||
// use the thematic colour.
|
||
'case',
|
||
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||
[
|
||
'match', ['get', 'structure_type'],
|
||
['massif drainant', 'tranchee drainante', 'collecteur en beton poreux', 'empierrement sedimentation'], '#8d6e63', // earthy gravel brown
|
||
['jardin de pluie', 'noue', 'toiture verte', 'bande filtrante', 'espace vert sur dalle'], '#009688', // teal vegetated
|
||
['bassin sec/infiltration', 'bassin en eau', 'bassin orage', 'citerne', 'toiture stockante', 'SAUL-vide', 'SAUL-terre'], '#0277bd', // deep water basins
|
||
'#0288d1' // fallback blue
|
||
],
|
||
['==', ['get', 'thematic'], 'nature'],
|
||
['coalesce', ['get', 'color'], '#2e7d32'],
|
||
['==', ['get', 'thematic'], 'parking'],
|
||
[
|
||
'match', ['get', 'spot_type'],
|
||
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
|
||
'delivery', '#f59e0b', // Orange/Ambre camion livraison
|
||
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
|
||
],
|
||
THEMATIC_COLOR_EXPR
|
||
],
|
||
|
||
'fill-opacity': [
|
||
'case',
|
||
['==', ['get', 'thematic'], 'parking'], 0.35,
|
||
0.7
|
||
],
|
||
|
||
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
||
'fill-outline-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
'#00000000'
|
||
],
|
||
},
|
||
minzoom: 8,
|
||
};
|
||
|
||
// ─── Layer 3 — location outline (line layer for polygon location borders) ─────
|
||
const _unified_locations_outline = {
|
||
id: 'locations-outline',
|
||
type: 'line',
|
||
source: 'locations',
|
||
paint: {
|
||
'line-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
'#0094ff'
|
||
],
|
||
'line-width': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], 3,
|
||
1
|
||
],
|
||
'line-opacity': 0.8,
|
||
},
|
||
};
|
||
|
||
// ─── Layer 4 — location circles (point locations: traffic-light intersections) ─
|
||
const _unified_locations_circle = {
|
||
id: 'locations-circle',
|
||
type: 'circle',
|
||
source: 'locations',
|
||
filter: [
|
||
'all',
|
||
['==', ['geometry-type'], 'Point'],
|
||
['!', ['coalesce', ['get', 'is_radar'], false]]
|
||
], // ne pas rendre de cercles aux vertex des polygones ni aux radars
|
||
paint: {
|
||
'circle-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
7, 2,
|
||
14, 5,
|
||
19, 10
|
||
],
|
||
// Colour by intersection status (matches trafficlights_index.html)
|
||
'circle-color': [
|
||
'match', ['get', 'status'],
|
||
'active', '#0094ff',
|
||
'in_preparation', '#f59f00',
|
||
'under_repair', '#f76707',
|
||
'voluntarily_out_of_service', '#e03131',
|
||
'inactive', '#e03131',
|
||
'archived', '#6c757d',
|
||
'#0094ff'
|
||
],
|
||
'circle-stroke-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
'#ffffff'
|
||
],
|
||
'circle-stroke-width': 1.5,
|
||
'circle-opacity': 0.85,
|
||
},
|
||
minzoom: 7,
|
||
};
|
||
|
||
// ─── Layer 4b — location diamonds (point locations representing radars) ─────
|
||
const _unified_locations_radar_outline = {
|
||
id: 'locations-radar-outline',
|
||
type: 'symbol',
|
||
source: 'locations',
|
||
filter: [
|
||
'all',
|
||
['==', ['geometry-type'], 'Point'],
|
||
['coalesce', ['get', 'is_radar'], false]
|
||
],
|
||
layout: {
|
||
'icon-image': 'cross-sdf-border',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 14, 0.4, 19, 0.8],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
},
|
||
paint: {
|
||
'icon-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
'#ffffff'
|
||
]
|
||
},
|
||
minzoom: 7,
|
||
};
|
||
|
||
const _unified_locations_radar = {
|
||
id: 'locations-radar',
|
||
type: 'symbol',
|
||
source: 'locations',
|
||
filter: [
|
||
'all',
|
||
['==', ['geometry-type'], 'Point'],
|
||
['coalesce', ['get', 'is_radar'], false]
|
||
],
|
||
layout: {
|
||
'icon-image': 'cross-sdf',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 14, 0.4, 19, 0.8],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
},
|
||
paint: {
|
||
'icon-color': [
|
||
'match', ['get', 'status'],
|
||
'active', '#0094ff',
|
||
'in_preparation', '#f59f00',
|
||
'under_repair', '#f76707',
|
||
'voluntarily_out_of_service', '#e03131',
|
||
'inactive', '#e03131',
|
||
'archived', '#6c757d',
|
||
'#0094ff'
|
||
]
|
||
},
|
||
minzoom: 7,
|
||
};
|
||
|
||
// ─── Layer 5 — asset symbols (point assets with per-thematic Bootstrap icons) ──
|
||
// SDF icons are recoloured at runtime by `icon-color` using THEMATIC_COLOR_EXPR.
|
||
const _unified_assets_symbol = {
|
||
id: 'assets',
|
||
type: 'symbol',
|
||
source: 'assets',
|
||
filter: [
|
||
'all',
|
||
['!=', ['get', 'asset_type'], 'signpanel'],
|
||
['!=', ['get', 'asset_type'], 'signpole'],
|
||
['any',
|
||
['!=', ['geometry-type'], 'Point'],
|
||
['match', ['get', 'asset_type'], ['roadway', 'sidewalk'], false, true]
|
||
]
|
||
],
|
||
layout: {
|
||
'icon-image': [
|
||
// asset_type takes priority (differentiates sub-types within a thematic)
|
||
'case',
|
||
['==', ['get', 'asset_type'], 'trafficlightpole'], 'icon-trafficlights',
|
||
['==', ['get', 'asset_type'], 'trafficlightradar'], 'icon-trafficlightradar',
|
||
['==', ['get', 'asset_type'], 'trafficlightradarpole'], 'icon-trafficlightradarpole',
|
||
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'], 'icon-trafficlightelectricalcabinet',
|
||
['==', ['get', 'asset_type'], 'trafficlightcontroller'], 'icon-trafficlightsequipment',
|
||
['==', ['get', 'asset_type'], 'trafficlightdetector'], 'icon-trafficlightsequipment',
|
||
['==', ['get', 'asset_type'], 'trafficlightcontrollerhardware'], 'icon-trafficlightsequipment',
|
||
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
|
||
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
|
||
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet',
|
||
['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain',
|
||
['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument',
|
||
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
|
||
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
|
||
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
|
||
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
|
||
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
|
||
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
|
||
['==', ['get', 'spot_type'], 'pmr'], 'icon-parkingspot-pmr',
|
||
['==', ['get', 'spot_type'], 'ev_charging'], 'icon-parkingspot-ev_charging',
|
||
['==', ['get', 'spot_type'], 'delivery'], 'icon-parkingspot-delivery',
|
||
['==', ['get', 'spot_type'], 'kiss_and_ride'], 'icon-parkingspot-kiss_and_ride',
|
||
['==', ['get', 'spot_type'], 'carsharing'], 'icon-parkingspot-carsharing',
|
||
['match', ['get', 'spot_type'], ['bicycle', 'cargo_bike'], true, false], 'icon-parkingspot-bicycle',
|
||
['==', ['get', 'asset_type'], 'parkingspot'], 'icon-parkingspot',
|
||
|
||
|
||
// fallback: match by thematic
|
||
[
|
||
'match', ['get', 'thematic'],
|
||
'publiclighting', 'icon-publiclighting',
|
||
'sign', 'icon-sign',
|
||
// Non-pole trafficlights assets use the alternate icon.
|
||
'trafficlights', 'icon-trafficlightsequipment',
|
||
'nature', 'icon-nature',
|
||
'structures', 'icon-structures',
|
||
'its', 'icon-its',
|
||
'clean', 'icon-clean',
|
||
'artworks', 'icon-artworks',
|
||
'controlcenters', 'icon-controlcenters',
|
||
'publicbuildings', 'icon-publicbuildings',
|
||
'publicbuilding', 'icon-publicbuilding',
|
||
'parking', 'icon-parking',
|
||
'roads', 'icon-roads',
|
||
'icon-roads' // fallback
|
||
]
|
||
|
||
],
|
||
'icon-size': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
8, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.12, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.18, 0.4],
|
||
12, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.22, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.34, 0.6],
|
||
16, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.45, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.62, 0.8],
|
||
22, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.95, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05, 1.2]
|
||
],
|
||
'icon-anchor': [
|
||
'case',
|
||
['==', ['get', 'thematic'], 'publiclighting'], 'bottom',
|
||
['==', ['get', 'thematic'], 'trafficlights'], 'bottom',
|
||
['==', ['get', 'thematic'], 'nature'], 'bottom',
|
||
'center'
|
||
],
|
||
'icon-offset': [
|
||
'case',
|
||
['==', ['get', 'thematic'], 'publiclighting'], ['literal', [0, -2]],
|
||
['==', ['get', 'thematic'], 'trafficlights'], [
|
||
'case',
|
||
['all', ['==', ['get', 'asset_type'], 'trafficlightradar'], ['coalesce', ['get', 'has_support'], false]], ['literal', [0, -25]],
|
||
['literal', [0, 0]]
|
||
],
|
||
['==', ['get', 'thematic'], 'nature'], ['literal', [0, 5]],
|
||
['literal', [0, 0]]
|
||
],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
// Labels temporarily disabled
|
||
// 'text-field': ['step', ['zoom'], '', 16, ['coalesce', ['get', 'name'], '']],
|
||
'text-field': '',
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-size': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
16, 9,
|
||
20, 12
|
||
],
|
||
'text-anchor': 'top',
|
||
'text-offset': [0, 1],
|
||
'text-allow-overlap': false,
|
||
'text-optional': true,
|
||
},
|
||
paint: {
|
||
'icon-color': THEMATIC_COLOR_EXPR,
|
||
'icon-opacity': [
|
||
'case',
|
||
['all', ['==', ['get', 'thematic'], 'roads'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
|
||
['all', ['==', ['get', 'asset_type'], 'naturerwiasset'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
|
||
['==', ['get', 'asset_type'], 'naturegreensurface'], 0,
|
||
['boolean', ['feature-state', 'highlighted'], false], 1,
|
||
0.85
|
||
],
|
||
'text-color': '#333333',
|
||
'text-halo-color': 'rgba(255,255,255,0.85)',
|
||
'text-halo-width': 2,
|
||
},
|
||
minzoom: 8,
|
||
};
|
||
|
||
// Unified sign rendering scale (divide poles/panels by 5 on the unified map only).
|
||
const UNIFIED_SIGN_SYMBOL_SCALE = 0.4;
|
||
|
||
// Sign poles circle layer (all zoom levels, matching sign app behavior)
|
||
const _unified_sign_poles_circle = {
|
||
id: 'sign-poles',
|
||
type: 'circle',
|
||
source: 'assets',
|
||
filter: ['==', ['get', 'asset_type'], 'signpole'],
|
||
paint: {
|
||
'circle-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
8, 1.5 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
13, 3 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
17, 5 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
22, 6 * UNIFIED_SIGN_SYMBOL_SCALE
|
||
],
|
||
'circle-color': '#888888',
|
||
'circle-opacity': 0.8,
|
||
'circle-stroke-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
'#333333'
|
||
],
|
||
'circle-stroke-width': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
8, 0,
|
||
13, 1 * UNIFIED_SIGN_SYMBOL_SCALE
|
||
]
|
||
},
|
||
minzoom: 8,
|
||
};
|
||
|
||
// Sign panels as circles at low zoom (same approach as sign app)
|
||
const _unified_sign_panels_circles = {
|
||
id: 'sign-panels-circles',
|
||
type: 'circle',
|
||
source: 'assets',
|
||
filter: ['==', ['get', 'asset_type'], 'signpanel'],
|
||
paint: {
|
||
'circle-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
10, 4 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
16, 8 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
17, 10 * UNIFIED_SIGN_SYMBOL_SCALE
|
||
],
|
||
'circle-color': '#0022ff',
|
||
'circle-stroke-color': '#0011aa',
|
||
'circle-stroke-width': 1 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
'circle-opacity': 0.8
|
||
},
|
||
maxzoom: 17,
|
||
};
|
||
|
||
// Sign poles as vertical lines at high zoom
|
||
const _unified_sign_poles_vertical = {
|
||
id: 'sign-poles-vertical',
|
||
type: 'symbol',
|
||
source: 'assets',
|
||
filter: ['==', ['get', 'asset_type'], 'signpole'],
|
||
layout: {
|
||
'icon-image': 'sign-pole-vertical',
|
||
'icon-anchor': 'top',
|
||
'icon-offset': ['literal', [0, -64]],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'icon-size': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
17, 0.6 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
22, 1.0 * UNIFIED_SIGN_SYMBOL_SCALE
|
||
],
|
||
'symbol-z-order': 'viewport-y'
|
||
},
|
||
paint: {
|
||
'icon-opacity': 0.9
|
||
},
|
||
minzoom: 17,
|
||
};
|
||
|
||
// Sign panels as symbols at high zoom (text sprite first, then sign icon fallback)
|
||
const _unified_sign_panels_symbols = {
|
||
id: 'sign-panels-symbols',
|
||
type: 'symbol',
|
||
source: 'assets',
|
||
filter: ['==', ['get', 'asset_type'], 'signpanel'],
|
||
layout: {
|
||
'icon-image': [
|
||
'coalesce',
|
||
['get', 'panel_text_image_id'],
|
||
['get', 'panel_icon_image_scaled'],
|
||
['get', 'panel_icon_image'],
|
||
'road-sign-default'
|
||
],
|
||
'icon-size': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
17, ['*', 0.3 * UNIFIED_SIGN_SYMBOL_SCALE, ['to-number', ['coalesce', ['get', 'panel_scale_factor'], 1.0], 1.0]],
|
||
22, ['*', 0.8 * UNIFIED_SIGN_SYMBOL_SCALE, ['to-number', ['coalesce', ['get', 'panel_scale_factor'], 1.0], 1.0]]
|
||
],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'icon-offset': [
|
||
'coalesce',
|
||
['get', 'panel_text_offset'],
|
||
['get', 'panel_icon_offset'],
|
||
['literal', [0, -64]]
|
||
],
|
||
'symbol-z-order': 'viewport-y',
|
||
'icon-rotate': 0
|
||
},
|
||
paint: {
|
||
'icon-opacity': 0.8
|
||
},
|
||
minzoom: 17,
|
||
};
|
||
|
||
// Orientation arrows for sign poles
|
||
const _unified_sign_poles_orientation = {
|
||
id: 'sign-poles-orientation',
|
||
type: 'symbol',
|
||
source: 'assets',
|
||
filter: [
|
||
'all',
|
||
['==', ['get', 'asset_type'], 'signpole'],
|
||
['has', 'orientation'],
|
||
['==', ['get', 'show_orientation_arrow'], true]
|
||
],
|
||
layout: {
|
||
'icon-image': 'orientation-arrow',
|
||
'icon-size': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
17, 0.5 * UNIFIED_SIGN_SYMBOL_SCALE,
|
||
22, 1.3 * UNIFIED_SIGN_SYMBOL_SCALE
|
||
],
|
||
'icon-rotation-alignment': 'map',
|
||
'icon-pitch-alignment': 'map',
|
||
'icon-rotate': ['to-number', ['coalesce', ['get', 'orientation'], 0], 0],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'icon-offset': ['literal', [-1, -17]],
|
||
'symbol-z-order': 'viewport-y'
|
||
},
|
||
paint: {
|
||
'icon-opacity': 0.9
|
||
},
|
||
minzoom: 17,
|
||
};
|
||
|
||
// ─── Layer 3b — asset line (line geometries for linear assets like cables, ditches) ──
|
||
const _unified_assets_line = {
|
||
id: 'assets-line',
|
||
type: 'line',
|
||
source: 'assets',
|
||
filter: ['any',
|
||
['==', ['geometry-type'], 'LineString'],
|
||
['==', ['geometry-type'], 'MultiLineString']
|
||
],
|
||
paint: {
|
||
'line-color': [
|
||
'case',
|
||
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||
[
|
||
'match', ['get', 'structure_type'],
|
||
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif'], '#8d6e63',
|
||
['noue', 'jardin de pluie', 'bande filtrante'], '#009688',
|
||
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein'], '#0277bd',
|
||
'#0288d1'
|
||
],
|
||
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
|
||
THEMATIC_COLOR_EXPR
|
||
],
|
||
'line-width': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], 5,
|
||
3
|
||
],
|
||
'line-opacity': 0.8,
|
||
},
|
||
minzoom: 8,
|
||
};
|
||
|
||
// ─── Layer 4b — road assets circles (point roadway/sidewalk assets) ──────────
|
||
const _unified_assets_point = {
|
||
id: 'assets-point',
|
||
type: 'circle',
|
||
source: 'assets',
|
||
filter: [
|
||
'all',
|
||
['==', ['geometry-type'], 'Point'],
|
||
['match', ['get', 'asset_type'], ['roadway', 'sidewalk'], true, false]
|
||
],
|
||
paint: {
|
||
'circle-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
10, 4,
|
||
18, 8
|
||
],
|
||
'circle-color': [
|
||
'match', ['get', 'asset_type'],
|
||
'roadway', '#4a4a4a',
|
||
'sidewalk', '#255d88ff',
|
||
'#9e9e9e'
|
||
],
|
||
'circle-stroke-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
'#ffffff'
|
||
],
|
||
'circle-stroke-width': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], 2,
|
||
0
|
||
],
|
||
'circle-opacity': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], 1.0,
|
||
0.85
|
||
],
|
||
},
|
||
minzoom: 8,
|
||
};
|
||
|
||
// ─── Layer 3c — asset polygon outline (line layer for polygon asset borders) ───
|
||
const _unified_assets_polygon_outline = {
|
||
id: 'assets-polygon-outline',
|
||
type: 'line',
|
||
source: 'assets',
|
||
filter: ['any',
|
||
['==', ['geometry-type'], 'Polygon'],
|
||
['==', ['geometry-type'], 'MultiPolygon']
|
||
],
|
||
paint: {
|
||
'line-color': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||
['==', ['get', 'thematic'], 'parking'],
|
||
[
|
||
'match', ['get', 'spot_type'],
|
||
['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active
|
||
'delivery', '#f59e0b', // Orange/Ambre camion livraison
|
||
'#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking)
|
||
],
|
||
THEMATIC_COLOR_EXPR
|
||
],
|
||
|
||
'line-width': [
|
||
'case',
|
||
['boolean', ['feature-state', 'highlighted'], false], 4,
|
||
2
|
||
],
|
||
'line-opacity': 0.9,
|
||
},
|
||
minzoom: 8,
|
||
};
|
||
|
||
// ─── Ordered layer list ───────────────────────────────────────────────────────
|
||
// MapLibre renders layers in insertion order, so the array below defines the
|
||
// visual stacking: fills at the bottom, then lines, then circles/symbols on top.
|
||
const UNIFIED_LAYERS = [
|
||
_unified_locations_fill, // 1. polygon fills (bottom)
|
||
_unified_assets_fill, // 2. polygon fills
|
||
_unified_locations_outline, // 3. lines
|
||
_unified_assets_line, // 3b. asset lines
|
||
_unified_assets_polygon_outline, // 3c. polygon asset outlines
|
||
_unified_locations_circle, // 4. circles
|
||
_unified_locations_radar_outline,
|
||
_unified_locations_radar,
|
||
_unified_assets_point, // 4b. road assets circles
|
||
_unified_sign_poles_circle,
|
||
_unified_assets_symbol,
|
||
_unified_sign_panels_circles,
|
||
_unified_sign_poles_orientation,
|
||
_unified_sign_poles_vertical,
|
||
_unified_sign_panels_symbols,
|
||
];
|
||
|