loko/streetup/assets/static/assets/unified_map_layers.js

822 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
];
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
'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'],
THEMATIC_COLOR_EXPR
],
'fill-opacity': 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',
// 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',
'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,
};
// ─── 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_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,
];