feat: add MapLibre visualization for inspections list via geolocated asset markers in a modal
This commit is contained in:
parent
7731ece2ea
commit
66269d0462
3 changed files with 297 additions and 3 deletions
|
|
@ -154,6 +154,24 @@ class AssetInspection(models.Model):
|
||||||
return _get_asset_thematic(self.asset)
|
return _get_asset_thematic(self.asset)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lon_lat(self):
|
||||||
|
"""Retourne les coordonnées (lon, lat) WGS84 du centroïde de la cible s'il est géolocalisé."""
|
||||||
|
if not self.asset:
|
||||||
|
return None
|
||||||
|
if hasattr(self.asset, 'lon') and hasattr(self.asset, 'lat') and self.asset.lon and self.asset.lat:
|
||||||
|
return (float(self.asset.lon), float(self.asset.lat))
|
||||||
|
if hasattr(self.asset, 'geom') and self.asset.geom:
|
||||||
|
g = self.asset.geom
|
||||||
|
if g.srid and g.srid != 4326:
|
||||||
|
try:
|
||||||
|
g = g.transform(4326, clone=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
centroid = g.centroid
|
||||||
|
return (float(centroid.x), float(centroid.y))
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thematic_icon(self):
|
def thematic_icon(self):
|
||||||
th = self.thematic
|
th = self.thematic
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
{% extends base_template|default:"base.html" %}
|
{% extends base_template|default:"base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n static l10n %}
|
||||||
|
|
||||||
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
|
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
|
@ -91,6 +95,17 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton Flottant (FAB) Carte des résultats -->
|
||||||
|
<button type="button" class="btn btn-primary rounded-circle shadow-lg position-fixed bottom-0 end-0 m-4 d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 56px; height: 56px; z-index: 1050; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease;"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#inspectionsMapModal"
|
||||||
|
id="btn-open-inspections-map-fab"
|
||||||
|
title="{% translate 'Carte des résultats' %}"
|
||||||
|
aria-label="{% translate 'Carte des résultats' %}">
|
||||||
|
<i class="bi bi-map fs-4"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
{% if inspections %}
|
{% if inspections %}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
{% for insp in inspections %}
|
{% for insp in inspections %}
|
||||||
|
|
@ -394,4 +409,263 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<!-- Modale Carte des Inspections filtrées -->
|
||||||
|
<div class="modal fade" id="inspectionsMapModal" tabindex="-1" aria-labelledby="inspectionsMapModalLabel" aria-hidden="true" style="z-index: 1070;">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered mx-auto" style="width: 92vw; max-width: 1200px;">
|
||||||
|
<div class="modal-content shadow-lg border-0 rounded-3">
|
||||||
|
<div class="modal-header bg-dark text-white py-2.5">
|
||||||
|
<h5 class="modal-title fs-6 fw-bold d-flex align-items-center gap-2" id="inspectionsMapModalLabel">
|
||||||
|
<i class="bi bi-map text-info fs-5"></i>
|
||||||
|
{% translate "Carte des résultats d'inspections" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 position-relative">
|
||||||
|
<div id="inspections-list-modal-map" style="width: 100%; height: 75vh; border-radius: 0 0 0.5rem 0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'common/maplibre-gl.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const modalEl = document.getElementById('inspectionsMapModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
let mapModalInstance = null;
|
||||||
|
|
||||||
|
const rawInspectionsData = [
|
||||||
|
{% for insp in inspections %}
|
||||||
|
{% if insp.lon_lat %}
|
||||||
|
{
|
||||||
|
id: {{ insp.id }},
|
||||||
|
lon: {{ insp.lon_lat.0|unlocalize }},
|
||||||
|
lat: {{ insp.lon_lat.1|unlocalize }},
|
||||||
|
title: "{{ insp.target_display|escapejs }}",
|
||||||
|
date: "{{ insp.inspection_date|date:'d/m/Y H:i' }}",
|
||||||
|
date_raw: "{{ insp.inspection_date|date:'Y-m-d H:i:s' }}",
|
||||||
|
result: "{{ insp.get_result_status_display|escapejs }}",
|
||||||
|
result_code: "{{ insp.result_status|escapejs }}",
|
||||||
|
badge_class: "{{ insp.status_badge_class|escapejs }}",
|
||||||
|
inspector: "{{ insp.inspector.get_full_name|default:insp.inspector.username|escapejs }}",
|
||||||
|
nature: "{{ insp.get_nature_display|escapejs }}",
|
||||||
|
summary: "{{ insp.details_summary|escapejs }}"
|
||||||
|
}{% if not forloop.last %},{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Regroupement des inspections par emplacement (lon, lat)
|
||||||
|
const groupedMap = {};
|
||||||
|
rawInspectionsData.forEach(item => {
|
||||||
|
const key = item.lon.toFixed(6) + '_' + item.lat.toFixed(6);
|
||||||
|
if (!groupedMap[key]) {
|
||||||
|
groupedMap[key] = {
|
||||||
|
lon: item.lon,
|
||||||
|
lat: item.lat,
|
||||||
|
title: item.title,
|
||||||
|
inspections: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
groupedMap[key].inspections.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedList = Object.values(groupedMap);
|
||||||
|
groupedList.forEach(grp => {
|
||||||
|
grp.inspections.sort((a, b) => b.date_raw.localeCompare(a.date_raw));
|
||||||
|
grp.latest_result_code = grp.inspections[0].result_code;
|
||||||
|
grp.inspections_count = grp.inspections.length;
|
||||||
|
grp.inspections_json = JSON.stringify(grp.inspections);
|
||||||
|
});
|
||||||
|
|
||||||
|
modalEl.addEventListener('shown.bs.modal', function() {
|
||||||
|
if (!mapModalInstance) {
|
||||||
|
mapModalInstance = new maplibregl.Map({
|
||||||
|
container: 'inspections-list-modal-map',
|
||||||
|
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
|
center: [4.35, 50.85],
|
||||||
|
zoom: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.addControl(new maplibregl.NavigationControl(), 'bottom-right');
|
||||||
|
|
||||||
|
mapModalInstance.on('load', function() {
|
||||||
|
const geojson = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: groupedList.map(m => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [m.lon, m.lat]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
title: m.title,
|
||||||
|
latest_result_code: m.latest_result_code,
|
||||||
|
inspections_count: m.inspections_count,
|
||||||
|
inspections_json: m.inspections_json
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
mapModalInstance.addSource('inspections-list-modal-source', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: geojson
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.addLayer({
|
||||||
|
id: 'inspections-list-modal-layer',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'inspections-list-modal-source',
|
||||||
|
paint: {
|
||||||
|
'circle-color': [
|
||||||
|
'match',
|
||||||
|
['get', 'latest_result_code'],
|
||||||
|
'compliant', '#198754',
|
||||||
|
'non_compliant', '#ffc107',
|
||||||
|
'damaged', '#dc3545',
|
||||||
|
'missing', '#212529',
|
||||||
|
'updated', '#0dcaf0',
|
||||||
|
'#0d6efd'
|
||||||
|
],
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.on('mouseenter', 'inspections-list-modal-layer', function() {
|
||||||
|
mapModalInstance.getCanvas().style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
mapModalInstance.on('mouseleave', 'inspections-list-modal-layer', function() {
|
||||||
|
mapModalInstance.getCanvas().style.cursor = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.on('click', 'inspections-list-modal-layer', function(e) {
|
||||||
|
if (!e.features || !e.features.length) return;
|
||||||
|
|
||||||
|
const clickPoint = e.point;
|
||||||
|
const rendered = mapModalInstance.queryRenderedFeatures(clickPoint, { layers: ['inspections-list-modal-layer'] });
|
||||||
|
|
||||||
|
let combinedInspections = [];
|
||||||
|
let popupTitle = '';
|
||||||
|
rendered.forEach(f => {
|
||||||
|
try {
|
||||||
|
const insps = JSON.parse(f.properties.inspections_json || '[]');
|
||||||
|
if (!popupTitle && f.properties.title) popupTitle = f.properties.title;
|
||||||
|
combinedInspections = combinedInspections.concat(insps);
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Déduplication & tri par date décroissante
|
||||||
|
const dedup = {};
|
||||||
|
combinedInspections.forEach(item => { dedup[item.id] = item; });
|
||||||
|
const sortedInspections = Object.values(dedup).sort((a, b) => b.date_raw.localeCompare(a.date_raw));
|
||||||
|
|
||||||
|
if (!sortedInspections.length) return;
|
||||||
|
|
||||||
|
const coords = e.features[0].geometry.coordinates.slice();
|
||||||
|
let contentHtml = '';
|
||||||
|
|
||||||
|
if (sortedInspections.length === 1) {
|
||||||
|
const insp = sortedInspections[0];
|
||||||
|
contentHtml = `
|
||||||
|
<div class="p-1" style="min-width: 240px;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1 gap-2">
|
||||||
|
<span class="badge ${insp.badge_class}">${insp.result}</span>
|
||||||
|
<small class="text-muted">${insp.date}</small>
|
||||||
|
</div>
|
||||||
|
<h6 class="fw-bold mb-1 text-dark">${insp.title}</h6>
|
||||||
|
<div class="small text-muted mb-1"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
|
||||||
|
<p class="small text-secondary mb-2">${insp.summary}</p>
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-primary w-100 py-1 open-detail-from-modal" data-insp-id="${insp.id}">
|
||||||
|
<i class="bi bi-eye me-1"></i>Détail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
let rowsHtml = '';
|
||||||
|
sortedInspections.forEach(insp => {
|
||||||
|
rowsHtml += `
|
||||||
|
<div class="p-2 border-bottom d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<div class="min-w-0 flex-grow-1">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-1 mb-1">
|
||||||
|
<span class="badge ${insp.badge_class} text-truncate flex-shrink-0" style="font-size: 0.7rem; max-width: 140px;">${insp.result}</span>
|
||||||
|
<span class="fw-semibold text-primary text-nowrap insp-date-modal-link open-detail-from-modal" style="cursor: pointer; font-size: 0.78rem;" data-insp-id="${insp.id}">
|
||||||
|
📅 ${insp.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted text-truncate" style="max-width: 220px;"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1 flex-shrink-0 open-detail-from-modal ms-1" data-insp-id="${insp.id}" style="font-size: 0.75rem; border-radius: 0.375rem;" title="Voir l'inspection">
|
||||||
|
<i class="bi bi-eye me-1"></i>Détail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
contentHtml = `
|
||||||
|
<div class="p-1" style="min-width: 320px; max-width: 360px;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2 pb-1 border-bottom">
|
||||||
|
<h6 class="fw-bold mb-0 text-dark text-truncate" style="max-width: 230px;">${popupTitle || 'Équipement'}</h6>
|
||||||
|
<span class="badge bg-secondary rounded-pill small">${sortedInspections.length} inspections</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto" style="max-height: 240px;">
|
||||||
|
${rowsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
new maplibregl.Popup({ closeButton: true, maxWidth: '380px' })
|
||||||
|
.setLngLat(coords)
|
||||||
|
.setHTML(contentHtml)
|
||||||
|
.addTo(mapModalInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupedList.length > 0) {
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
|
||||||
|
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mapModalInstance.resize();
|
||||||
|
if (groupedList.length > 0) {
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
|
||||||
|
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('.open-detail-from-modal');
|
||||||
|
if (btn) {
|
||||||
|
const inspId = btn.getAttribute('data-insp-id');
|
||||||
|
const bsMapModal = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (bsMapModal) bsMapModal.hide();
|
||||||
|
const detailModalEl = document.getElementById('inspectionDetailModal' + inspId);
|
||||||
|
if (detailModalEl) {
|
||||||
|
detailModalEl.setAttribute('data-opened-from-map', 'true');
|
||||||
|
setTimeout(() => {
|
||||||
|
const bsDetailModal = bootstrap.Modal.getInstance(detailModalEl) || new bootstrap.Modal(detailModalEl);
|
||||||
|
bsDetailModal.show();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('hidden.bs.modal', function(e) {
|
||||||
|
const modalTarget = e.target;
|
||||||
|
if (modalTarget && modalTarget.getAttribute('data-opened-from-map') === 'true') {
|
||||||
|
modalTarget.removeAttribute('data-opened-from-map');
|
||||||
|
setTimeout(() => {
|
||||||
|
const bsMapModal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
|
||||||
|
bsMapModal.show();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ def get_user_inspectable_thematics(user):
|
||||||
Si l'utilisateur n'a pas de UserConfig, retourne None.
|
Si l'utilisateur n'a pas de UserConfig, retourne None.
|
||||||
"""
|
"""
|
||||||
if hasattr(user, 'config') and user.config:
|
if hasattr(user, 'config') and user.config:
|
||||||
return user.config.get_inspectable_thematics().order_by('name_fr')
|
res = user.config.get_inspectable_thematics()
|
||||||
|
return res.order_by('name_fr') if res is not None else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,7 +32,8 @@ def get_user_viewable_thematics(user):
|
||||||
Si l'utilisateur n'a pas de UserConfig, retourne None.
|
Si l'utilisateur n'a pas de UserConfig, retourne None.
|
||||||
"""
|
"""
|
||||||
if hasattr(user, 'config') and user.config:
|
if hasattr(user, 'config') and user.config:
|
||||||
return user.config.get_viewable_assets_thematics().order_by('name_fr')
|
res = user.config.get_viewable_assets_thematics()
|
||||||
|
return res.order_by('name_fr') if res is not None else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue