style(home): refine activity symbol sizes and prevent marker overlap with collision detection

This commit is contained in:
kdeterme 2026-09-21 18:50:15 +02:00
parent f534758f91
commit c726d8f37c
2 changed files with 167 additions and 138 deletions

View file

@ -49,6 +49,16 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
map.addControl(new maplibregl.NavigationControl(), 'top-left'); map.addControl(new maplibregl.NavigationControl(), 'top-left');
let currentMode = 'heatmap'; // 'heatmap' or 'points' 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 = { const OPERATION_ICONS = {
'intervention': { 'intervention': {
@ -69,59 +79,90 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
} }
}; };
function registerOperationIcon(mapInstance, opKey, iconInfo, size = 32) { function registerOperationBadge(mapInstance, thematicKey, thematicColor, opKey, iconInfo) {
const imageName = `op-icon-${opKey}`; const imageName = `marker-${thematicKey}-${opKey}`;
if (mapInstance.hasImage(imageName)) return Promise.resolve(); if (mapInstance.hasImage(imageName)) return Promise.resolve();
const canvasSize = 44;
const center = canvasSize / 2;
const radius = 16;
const iconSize = 19;
return new Promise((resolve) => { return new Promise((resolve) => {
const img = new Image(); const img = new Image();
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="-2.5 -2.5 21 21" fill="#ffffff">${iconInfo.svg}</svg>`; const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="-2.5 -2.5 21 21" fill="#ffffff">${iconInfo.svg}</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 = () => { img.onload = () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = size; canvas.width = canvasSize;
canvas.height = size; canvas.height = canvasSize;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size); drawBadge(ctx, (c) => {
c.drawImage(img, center - (iconSize / 2), center - (iconSize / 2), iconSize, iconSize);
// Ombre douce pour faire ressortir l'icône blanche sur n'importe quel fond thématique });
ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; const imgData = ctx.getImageData(0, 0, canvasSize, canvasSize);
ctx.shadowBlur = 2.5;
ctx.shadowOffsetY = 1;
ctx.drawImage(img, 0, 0, size, size);
const imgData = ctx.getImageData(0, 0, size, size);
if (!mapInstance.hasImage(imageName)) { if (!mapInstance.hasImage(imageName)) {
mapInstance.addImage(imageName, imgData); mapInstance.addImage(imageName, imgData, { pixelRatio: 2 });
} }
resolve(); resolve();
}; };
img.onerror = () => { img.onerror = () => {
// Fallback dessin via police bootstrap-icons
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = size; canvas.width = canvasSize;
canvas.height = size; canvas.height = canvasSize;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size); drawBadge(ctx, (c) => {
ctx.font = `${Math.round(size * 0.62)}px "bootstrap-icons"`; c.font = '12px "bootstrap-icons"';
ctx.fillStyle = '#ffffff'; c.fillStyle = '#ffffff';
ctx.textAlign = 'center'; c.textAlign = 'center';
ctx.textBaseline = 'middle'; c.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; c.fillText(String.fromCodePoint(parseInt(iconInfo.codepoint, 16)), center, center);
ctx.shadowBlur = 2.5; });
ctx.shadowOffsetY = 1; const imgData = ctx.getImageData(0, 0, canvasSize, canvasSize);
ctx.fillText(String.fromCodePoint(parseInt(iconInfo.codepoint, 16)), size / 2, size / 2);
const imgData = ctx.getImageData(0, 0, size, size);
if (!mapInstance.hasImage(imageName)) { if (!mapInstance.hasImage(imageName)) {
mapInstance.addImage(imageName, imgData); mapInstance.addImage(imageName, imgData, { pixelRatio: 2 });
} }
resolve(); resolve();
}; };
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}); });
} }
map.on('load', async () => { 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([ const [maskData, actData] = await Promise.all([
fetch('/common/territory-mask-geojson/') fetch('/common/territory-mask-geojson/')
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
@ -129,9 +170,11 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
fetch('/api/activity-geojson/') fetch('/api/activity-geojson/')
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.catch(e => { console.error("Could not load activity geojson:", e); return 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 // 1. Appliquer le masque de mise en lumière du territoire communal
if (maskData) { if (maskData) {
try { try {
@ -258,7 +301,7 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
13, 26, 13, 26,
16, 42 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': [ 'heatmap-opacity': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
13, 0.85, 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({ map.addLayer({
id: 'activity-points', 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', type: 'symbol',
source: 'activity-data', source: 'activity-data',
layout: { layout: {
'visibility': 'visible', 'visibility': 'visible',
'icon-image': [ 'icon-image': [
'match', 'concat',
['get', 'type'], 'marker-',
'intervention', 'op-icon-intervention', ['match', ['get', 'thematic_key'],
'observation', 'op-icon-observation', 'nature', 'nature',
'inspection', 'op-icon-inspection', 'lighting', 'lighting',
'control', 'op-icon-control', 'sign', 'sign',
'op-icon-intervention' 'roads', 'roads',
'structures', 'structures',
'default'
],
'-',
['match', ['get', 'type'],
'intervention', 'intervention',
'observation', 'observation',
'inspection', 'inspection',
'control', 'control',
'intervention'
]
], ],
'icon-size': [ 'icon-size': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
10, 0.38, 10, 0.58, // ~13px
13, 0.55, 13, 0.72, // ~16px
15, 0.72, 15, 0.88, // ~19px
18, 1.0 18, 1.10 // ~24px
], ],
'icon-allow-overlap': true, 'icon-allow-overlap': false,
'icon-ignore-placement': true, 'icon-ignore-placement': false,
'icon-padding': 3,
'icon-anchor': 'center' 'icon-anchor': 'center'
}, },
paint: { paint: {
// En mode Heatmap : les symboles s'estompent à distance pour dégager la vue thermique
'icon-opacity': [ 'icon-opacity': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
13.2, 0, 14.2, 0,
14.8, 1 15.4, 1
] ]
} }
}); });
@ -378,27 +393,14 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
} }
if (map.getLayer('activity-points')) { if (map.getLayer('activity-points')) {
map.setLayoutProperty('activity-points', 'visibility', 'visible'); map.setLayoutProperty('activity-points', 'visibility', 'visible');
map.setPaintProperty('activity-points', 'circle-opacity', [ map.setPaintProperty('activity-points', 'icon-opacity', [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
13.2, 0, 14.2, 0,
14.8, 0.95 15.4, 1
]);
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
]); ]);
} }
} else { } else {
// Mode Points pur // Mode Points pur : affichage net de tous les symboles sans heatmap
btnPoints.classList.add('btn-primary', 'active'); btnPoints.classList.add('btn-primary', 'active');
btnPoints.classList.remove('btn-outline-primary'); btnPoints.classList.remove('btn-outline-primary');
btnHeatmap.classList.remove('btn-primary', 'active'); btnHeatmap.classList.remove('btn-primary', 'active');
@ -409,12 +411,7 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
} }
if (map.getLayer('activity-points')) { if (map.getLayer('activity-points')) {
map.setLayoutProperty('activity-points', 'visibility', 'visible'); map.setLayoutProperty('activity-points', 'visibility', 'visible');
map.setPaintProperty('activity-points', 'circle-opacity', 0.95); map.setPaintProperty('activity-points', 'icon-opacity', 1);
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);
} }
} }
renderLegend(); renderLegend();
@ -425,12 +422,12 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
// Clic pour afficher les détails du point // Clic pour afficher les détails du point
map.on('click', function(e) { map.on('click', function(e) {
const radiusInPixels = 14; const radiusInPixels = 12;
const features = map.queryRenderedFeatures([ const features = map.queryRenderedFeatures([
[e.point.x - radiusInPixels, e.point.y - radiusInPixels], [e.point.x - radiusInPixels, e.point.y - radiusInPixels],
[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; if (!features.length) return;
@ -439,6 +436,17 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
const p = f.properties; const p = f.properties;
if (!p || !p.title) return; 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 = { const thematicConfig = {
'nature': { color: '#10b981', label: 'Nature' }, 'nature': { color: '#10b981', label: 'Nature' },
'lighting': { color: '#f59e0b', label: 'Éclairage' }, 'lighting': { color: '#f59e0b', label: 'Éclairage' },
@ -446,34 +454,57 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
'roads': { color: '#06b6d4', label: 'Voirie' }, 'roads': { color: '#06b6d4', label: 'Voirie' },
'structures': { color: '#8b5cf6', label: 'Ouvrages' }, 'structures': { color: '#8b5cf6', label: 'Ouvrages' },
}; };
const th = thematicConfig[p.thematic_key] || { color: '#64748b', label: p.thematic || 'Patrimoine' };
const opConfig = { const opConfig = {
'intervention': { icon: 'bi-tools', label: 'Intervention' }, 'intervention': { icon: 'bi-tools', label: 'Intervention' },
'observation': { icon: 'bi-eye-fill', label: 'Observation' }, 'observation': { icon: 'bi-eye-fill', label: 'Observation' },
'inspection': { icon: 'bi-search', label: 'Inspection' }, 'inspection': { icon: 'bi-search', label: 'Inspection' },
'control': { icon: 'bi-clipboard-check', label: 'Contrôle' }, '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 = ` function renderFeatureCard(itemFeat, isMultiple) {
<div style="min-width: 220px; font-family: system-ui, -apple-system, sans-serif;"> const props = itemFeat.properties || {};
<div class="d-flex align-items-center justify-content-between mb-2"> const th = thematicConfig[props.thematic_key] || { color: '#64748b', label: props.thematic || 'Patrimoine' };
<span class="badge text-white fw-semibold" style="background-color: ${th.color}; font-size: 0.74rem; padding: 4px 8px;"> const op = opConfig[props.type] || { icon: props.icon || 'bi-geo-alt', label: props.type_label || props.type };
<i class="bi ${op.icon} me-1.5"></i>${op.label} — ${th.label}
return `
<div class="${isMultiple ? 'border rounded p-2 mb-2 bg-light' : ''}">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="badge text-white fw-semibold" style="background-color: ${th.color}; font-size: 0.72rem; padding: 3px 6px;">
<i class="bi ${op.icon} me-1"></i>${op.label} — ${th.label}
</span> </span>
<small class="text-muted ms-2">${p.date || ''}</small> <small class="text-muted ms-2" style="font-size: 0.70rem;">${props.date || ''}</small>
</div> </div>
<div class="fw-bold mb-1 text-dark" style="font-size: 0.92rem;">${p.title}</div> <div class="fw-bold mb-1 text-dark" style="font-size: 0.88rem; line-height: 1.25;">${props.title || '-'}</div>
<div class="text-secondary small mb-2"><i class="bi bi-geo-alt me-1"></i>${p.address || p.code}</div> <div class="text-secondary small mb-1.5" style="font-size: 0.75rem;"><i class="bi bi-geo-alt me-1"></i>${props.address || props.code}</div>
<div class="d-flex align-items-center justify-content-between mt-2 pt-2 border-top"> <div class="d-flex align-items-center justify-content-between pt-1 border-top">
${statusFormatter(p.status)} ${statusFormatter(props.status)}
${p.url && p.url !== '#' ? `<a href="${p.url}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.75rem;">Voir</a>` : ''} ${props.url && props.url !== '#' ? `<a href="${props.url}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">Voir</a>` : ''}
</div> </div>
</div> </div>
`; `;
}
new maplibregl.Popup({ closeButton: true, offset: 12 }) let popupContent = '';
if (colocated.length > 1) {
popupContent = `
<div style="min-width: 240px; max-width: 290px; font-family: system-ui, -apple-system, sans-serif;">
<div class="d-flex align-items-center justify-content-between mb-2 pb-1 border-bottom">
<span class="fw-semibold text-dark" style="font-size: 0.80rem;"><i class="bi bi-geo-alt-fill text-primary me-1"></i>${colocated.length} opérations ici</span>
</div>
<div style="max-height: 280px; overflow-y: auto;">
${colocated.map(cf => renderFeatureCard(cf, true)).join('')}
</div>
</div>
`;
} else {
popupContent = `
<div style="min-width: 230px; font-family: system-ui, -apple-system, sans-serif;">
${renderFeatureCard(f, false)}
</div>
`;
}
new maplibregl.Popup({ closeButton: true, offset: 10 })
.setLngLat(f.geometry.coordinates || e.lngLat) .setLngLat(f.geometry.coordinates || e.lngLat)
.setHTML(popupContent) .setHTML(popupContent)
.addTo(map); .addTo(map);
@ -481,8 +512,6 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
map.on('mouseenter', 'activity-points', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseenter', 'activity-points', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'activity-points', () => { map.getCanvas().style.cursor = ''; }); map.on('mouseleave', 'activity-points', () => { map.getCanvas().style.cursor = ''; });
map.on('mouseenter', 'activity-points-icons', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'activity-points-icons', () => { map.getCanvas().style.cursor = ''; });
function renderLegend() { function renderLegend() {
const legendContainer = document.getElementById('activity-map-legend') || document.getElementById('map-legend'); const legendContainer = document.getElementById('activity-map-legend') || document.getElementById('map-legend');

View file

@ -186,5 +186,5 @@
</script> </script>
<script src="{% static 'interventions/status_colors.js' %}"></script> <script src="{% static 'interventions/status_colors.js' %}"></script>
<script src="{% static 'home/home_index.js' %}?v=20260921_2"></script> <script src="{% static 'home/home_index.js' %}?v=20260921_3"></script>
{% endblock script %} {% endblock script %}