diff --git a/loko/home/static/home/home_index.js b/loko/home/static/home/home_index.js index 6c6db09..1c6e6a6 100644 --- a/loko/home/static/home/home_index.js +++ b/loko/home/static/home/home_index.js @@ -49,6 +49,16 @@ if (mapContainer && typeof maplibregl !== 'undefined') { map.addControl(new maplibregl.NavigationControl(), 'top-left'); let currentMode = 'heatmap'; // 'heatmap' or 'points' + let activityGeojsonData = null; + + const THEMATIC_COLORS = { + 'nature': '#10b981', + 'lighting': '#f59e0b', + 'sign': '#3b82f6', + 'roads': '#06b6d4', + 'structures': '#8b5cf6', + 'default': '#64748b' + }; const OPERATION_ICONS = { 'intervention': { @@ -69,59 +79,90 @@ if (mapContainer && typeof maplibregl !== 'undefined') { } }; - function registerOperationIcon(mapInstance, opKey, iconInfo, size = 32) { - const imageName = `op-icon-${opKey}`; + function registerOperationBadge(mapInstance, thematicKey, thematicColor, opKey, iconInfo) { + const imageName = `marker-${thematicKey}-${opKey}`; if (mapInstance.hasImage(imageName)) return Promise.resolve(); + const canvasSize = 44; + const center = canvasSize / 2; + const radius = 16; + const iconSize = 19; + return new Promise((resolve) => { const img = new Image(); - const svg = ``; + const svg = ``; + + function drawBadge(ctx, drawIconFn) { + ctx.clearRect(0, 0, canvasSize, canvasSize); + + // 1. Ombre subtile sous le badge pour décoller du fond cartographique clair + ctx.shadowColor = 'rgba(0, 0, 0, 0.30)'; + ctx.shadowBlur = 3.5; + ctx.shadowOffsetY = 1.2; + + // 2. Disque plein de la couleur de la thématique + ctx.beginPath(); + ctx.arc(center, center, radius, 0, 2 * Math.PI); + ctx.fillStyle = thematicColor; + ctx.fill(); + + // 3. Bordure blanche nette + ctx.shadowColor = 'transparent'; + ctx.lineWidth = 2.2; + ctx.strokeStyle = '#ffffff'; + ctx.stroke(); + + // 4. Dessin de l'icône au centre + drawIconFn(ctx); + } + img.onload = () => { const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; + canvas.width = canvasSize; + canvas.height = canvasSize; const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, size, size); - - // Ombre douce pour faire ressortir l'icône blanche sur n'importe quel fond thématique - ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; - ctx.shadowBlur = 2.5; - ctx.shadowOffsetY = 1; - - ctx.drawImage(img, 0, 0, size, size); - const imgData = ctx.getImageData(0, 0, size, size); + drawBadge(ctx, (c) => { + c.drawImage(img, center - (iconSize / 2), center - (iconSize / 2), iconSize, iconSize); + }); + const imgData = ctx.getImageData(0, 0, canvasSize, canvasSize); if (!mapInstance.hasImage(imageName)) { - mapInstance.addImage(imageName, imgData); + mapInstance.addImage(imageName, imgData, { pixelRatio: 2 }); } resolve(); }; + img.onerror = () => { - // Fallback dessin via police bootstrap-icons const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; + canvas.width = canvasSize; + canvas.height = canvasSize; const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, size, size); - ctx.font = `${Math.round(size * 0.62)}px "bootstrap-icons"`; - ctx.fillStyle = '#ffffff'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; - ctx.shadowBlur = 2.5; - ctx.shadowOffsetY = 1; - ctx.fillText(String.fromCodePoint(parseInt(iconInfo.codepoint, 16)), size / 2, size / 2); - const imgData = ctx.getImageData(0, 0, size, size); + drawBadge(ctx, (c) => { + c.font = '12px "bootstrap-icons"'; + c.fillStyle = '#ffffff'; + c.textAlign = 'center'; + c.textBaseline = 'middle'; + c.fillText(String.fromCodePoint(parseInt(iconInfo.codepoint, 16)), center, center); + }); + const imgData = ctx.getImageData(0, 0, canvasSize, canvasSize); if (!mapInstance.hasImage(imageName)) { - mapInstance.addImage(imageName, imgData); + mapInstance.addImage(imageName, imgData, { pixelRatio: 2 }); } resolve(); }; + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }); } map.on('load', async () => { - // Enregistrer les icônes d'opérations et lancer les requêtes GeoJSON en parallèle (gain réseau immédiat) + // Enregistrer les badges d'opérations et lancer les requêtes GeoJSON en parallèle (gain réseau immédiat) + const badgePromises = []; + for (const [tKey, tColor] of Object.entries(THEMATIC_COLORS)) { + for (const [opKey, iconInfo] of Object.entries(OPERATION_ICONS)) { + badgePromises.push(registerOperationBadge(map, tKey, tColor, opKey, iconInfo)); + } + } + const [maskData, actData] = await Promise.all([ fetch('/common/territory-mask-geojson/') .then(r => r.ok ? r.json() : null) @@ -129,9 +170,11 @@ if (mapContainer && typeof maplibregl !== 'undefined') { fetch('/api/activity-geojson/') .then(r => r.ok ? r.json() : null) .catch(e => { console.error("Could not load activity geojson:", e); return null; }), - ...Object.entries(OPERATION_ICONS).map(([key, info]) => registerOperationIcon(map, key, info, 32)) + ...badgePromises ]); + activityGeojsonData = actData; + // 1. Appliquer le masque de mise en lumière du territoire communal if (maskData) { try { @@ -258,7 +301,7 @@ if (mapContainer && typeof maplibregl !== 'undefined') { 13, 26, 16, 42 ], - // En mode Heatmap, l'intensité s'estompe en zoomant + // En mode Heatmap, l'intensité s'estompe en zoomant vers la rue 'heatmap-opacity': [ 'interpolate', ['linear'], ['zoom'], 13, 0.85, @@ -267,80 +310,52 @@ if (mapContainer && typeof maplibregl !== 'undefined') { } }); - // Layer B: Cercles représentant les points avec couleur selon la thématique + // Layer B: Symboles d'opérations (disque thématique + icône intégrée) + // Taille affinée et non-superposition stricte via le moteur de collision MapLibre map.addLayer({ id: 'activity-points', - type: 'circle', - source: 'activity-data', - layout: { - 'visibility': 'visible' - }, - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 10, 6.5, - 13, 9.5, - 15, 12.5, - 18, 17 - ], - 'circle-color': [ - 'match', - ['get', 'thematic_key'], - 'nature', '#10b981', - 'lighting', '#f59e0b', - 'sign', '#3b82f6', - 'roads', '#06b6d4', - 'structures', '#8b5cf6', - '#64748b' - ], - 'circle-stroke-width': 2, - 'circle-stroke-color': '#ffffff', - // Transition d'opacité fluide: invisibles au grand zoom, émergent dès zoom 13.2 - 'circle-opacity': [ - 'interpolate', ['linear'], ['zoom'], - 13.2, 0, - 14.8, 0.95 - ], - 'circle-stroke-opacity': [ - 'interpolate', ['linear'], ['zoom'], - 13.2, 0, - 14.8, 1 - ] - } - }); - - // Layer C: Symboles de type d'opération positionnés à l'intérieur des cercles - map.addLayer({ - id: 'activity-points-icons', type: 'symbol', source: 'activity-data', layout: { 'visibility': 'visible', 'icon-image': [ - 'match', - ['get', 'type'], - 'intervention', 'op-icon-intervention', - 'observation', 'op-icon-observation', - 'inspection', 'op-icon-inspection', - 'control', 'op-icon-control', - 'op-icon-intervention' + 'concat', + 'marker-', + ['match', ['get', 'thematic_key'], + 'nature', 'nature', + 'lighting', 'lighting', + 'sign', 'sign', + 'roads', 'roads', + 'structures', 'structures', + 'default' + ], + '-', + ['match', ['get', 'type'], + 'intervention', 'intervention', + 'observation', 'observation', + 'inspection', 'inspection', + 'control', 'control', + 'intervention' + ] ], 'icon-size': [ 'interpolate', ['linear'], ['zoom'], - 10, 0.38, - 13, 0.55, - 15, 0.72, - 18, 1.0 + 10, 0.58, // ~13px + 13, 0.72, // ~16px + 15, 0.88, // ~19px + 18, 1.10 // ~24px ], - 'icon-allow-overlap': true, - 'icon-ignore-placement': true, + 'icon-allow-overlap': false, + 'icon-ignore-placement': false, + 'icon-padding': 3, 'icon-anchor': 'center' }, paint: { + // En mode Heatmap : les symboles s'estompent à distance pour dégager la vue thermique 'icon-opacity': [ 'interpolate', ['linear'], ['zoom'], - 13.2, 0, - 14.8, 1 + 14.2, 0, + 15.4, 1 ] } }); @@ -378,27 +393,14 @@ if (mapContainer && typeof maplibregl !== 'undefined') { } if (map.getLayer('activity-points')) { map.setLayoutProperty('activity-points', 'visibility', 'visible'); - map.setPaintProperty('activity-points', 'circle-opacity', [ + map.setPaintProperty('activity-points', 'icon-opacity', [ 'interpolate', ['linear'], ['zoom'], - 13.2, 0, - 14.8, 0.95 - ]); - map.setPaintProperty('activity-points', 'circle-stroke-opacity', [ - 'interpolate', ['linear'], ['zoom'], - 13.2, 0, - 14.8, 1 - ]); - } - if (map.getLayer('activity-points-icons')) { - map.setLayoutProperty('activity-points-icons', 'visibility', 'visible'); - map.setPaintProperty('activity-points-icons', 'icon-opacity', [ - 'interpolate', ['linear'], ['zoom'], - 13.2, 0, - 14.8, 1 + 14.2, 0, + 15.4, 1 ]); } } else { - // Mode Points pur + // Mode Points pur : affichage net de tous les symboles sans heatmap btnPoints.classList.add('btn-primary', 'active'); btnPoints.classList.remove('btn-outline-primary'); btnHeatmap.classList.remove('btn-primary', 'active'); @@ -409,12 +411,7 @@ if (mapContainer && typeof maplibregl !== 'undefined') { } if (map.getLayer('activity-points')) { map.setLayoutProperty('activity-points', 'visibility', 'visible'); - map.setPaintProperty('activity-points', 'circle-opacity', 0.95); - map.setPaintProperty('activity-points', 'circle-stroke-opacity', 1); - } - if (map.getLayer('activity-points-icons')) { - map.setLayoutProperty('activity-points-icons', 'visibility', 'visible'); - map.setPaintProperty('activity-points-icons', 'icon-opacity', 1); + map.setPaintProperty('activity-points', 'icon-opacity', 1); } } renderLegend(); @@ -425,12 +422,12 @@ if (mapContainer && typeof maplibregl !== 'undefined') { // Clic pour afficher les détails du point map.on('click', function(e) { - const radiusInPixels = 14; + const radiusInPixels = 12; const features = map.queryRenderedFeatures([ [e.point.x - radiusInPixels, e.point.y - radiusInPixels], [e.point.x + radiusInPixels, e.point.y + radiusInPixels] ], { - layers: ['activity-points', 'activity-points-icons', 'activity-heatmap'] + layers: ['activity-points'] }); if (!features.length) return; @@ -439,6 +436,17 @@ if (mapContainer && typeof maplibregl !== 'undefined') { const p = f.properties; if (!p || !p.title) return; + // Trouver toutes les opérations situées exactement à la même adresse (si présentes) + const coords = f.geometry && f.geometry.coordinates; + let colocated = [f]; + if (coords && activityGeojsonData && Array.isArray(activityGeojsonData.features)) { + colocated = activityGeojsonData.features.filter(feat => { + const c = feat.geometry && feat.geometry.coordinates; + return c && Math.abs(c[0] - coords[0]) < 0.00008 && Math.abs(c[1] - coords[1]) < 0.00008; + }); + if (!colocated.length) colocated = [f]; + } + const thematicConfig = { 'nature': { color: '#10b981', label: 'Nature' }, 'lighting': { color: '#f59e0b', label: 'Éclairage' }, @@ -446,34 +454,57 @@ if (mapContainer && typeof maplibregl !== 'undefined') { 'roads': { color: '#06b6d4', label: 'Voirie' }, 'structures': { color: '#8b5cf6', label: 'Ouvrages' }, }; - const th = thematicConfig[p.thematic_key] || { color: '#64748b', label: p.thematic || 'Patrimoine' }; - const opConfig = { 'intervention': { icon: 'bi-tools', label: 'Intervention' }, 'observation': { icon: 'bi-eye-fill', label: 'Observation' }, 'inspection': { icon: 'bi-search', label: 'Inspection' }, 'control': { icon: 'bi-clipboard-check', label: 'Contrôle' }, }; - const op = opConfig[p.type] || { icon: p.icon || 'bi-geo-alt', label: p.type_label || p.type }; - const popupContent = ` -