loko/streetup/observations/templates/observations/observations_index_mobile.html
2026-07-22 14:48:40 +02:00

898 lines
30 KiB
HTML

{% extends "mobile/mobile_base.html" %}
{% load static %}
{% load i18n %}
{% block extra_head %}
<!-- MapLibre GL -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style>
/* === CARTE === */
#map-overlay {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 2000;
background: #fff;
}
#map-overlay.active {
display: block;
}
#observation-map {
width: 100%;
height: 100%;
}
#map-close-btn {
position: absolute;
top: 0.6rem; right: 0.6rem;
z-index: 2100;
}
/* FAB bouton carte : au-dessus du footer */
#map-fab-btn {
position: fixed;
bottom: 4.5rem;
right: 1rem;
z-index: 1050;
border-radius: 50%;
width: 3.25rem;
height: 3.25rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
padding: 0;
}
.maplibregl-popup-content a {
color: #0d6efd;
font-weight: 600;
text-decoration: none;
}
.observation-card {
cursor: pointer;
transition: background-color 0.2s;
}
.observation-card:hover {
background-color: #f8f9fa;
}
.badge {
font-size: 0.75rem;
}
.form-select, .form-control {
font-size: 0.9rem;
}
/* Boutons de filtre : même largeur et centrage du texte */
#typeFilterPanel .btn-outline-type,
#statusFilterPanel .btn-outline-status {
width: 110px;
text-align: center;
white-space: nowrap;
overflow: hidden;
}
/* Type button colors and styles */
.btn-outline-type-incident {
--bs-btn-color: #dc3545;
--bs-btn-border-color: #dc3545;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #dc3545;
--bs-btn-hover-border-color: #dc3545;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #dc3545;
--bs-btn-active-border-color: #dc3545;
}
.btn-check:checked + .btn-outline-type-incident {
background-color: #dc3545;
border-color: #dc3545;
color: #fff;
}
.btn-outline-type-inventory {
--bs-btn-color: #6c757d;
--bs-btn-border-color: #6c757d;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #6c757d;
--bs-btn-hover-border-color: #6c757d;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #6c757d;
--bs-btn-active-border-color: #6c757d;
}
.btn-check:checked + .btn-outline-type-inventory {
background-color: #6c757d;
border-color: #6c757d;
color: #fff;
}
.btn-outline-type-remark {
--bs-btn-color: #0dcaf0;
--bs-btn-border-color: #0dcaf0;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #0dcaf0;
--bs-btn-hover-border-color: #0dcaf0;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #0dcaf0;
--bs-btn-active-border-color: #0dcaf0;
}
.btn-check:checked + .btn-outline-type-remark {
background-color: #0dcaf0;
border-color: #0dcaf0;
color: #000;
}
/* Status button colors */
.btn-outline-status-in_preparation {
--bs-btn-color: #6c757d;
--bs-btn-border-color: #6c757d;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #6c757d;
--bs-btn-hover-border-color: #6c757d;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #6c757d;
--bs-btn-active-border-color: #6c757d;
}
.btn-check:checked + .btn-outline-status-in_preparation {
background-color: #6c757d;
border-color: #6c757d;
color: #fff;
}
.btn-outline-status-to_process {
--bs-btn-color: #dc3545;
--bs-btn-border-color: #dc3545;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #dc3545;
--bs-btn-hover-border-color: #dc3545;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #dc3545;
--bs-btn-active-border-color: #dc3545;
}
.btn-check:checked + .btn-outline-status-to_process {
background-color: #dc3545;
border-color: #dc3545;
color: #fff;
}
.btn-outline-status-in_progress {
--bs-btn-color: #ffc107;
--bs-btn-border-color: #ffc107;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #ffc107;
--bs-btn-hover-border-color: #ffc107;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #ffc107;
--bs-btn-active-border-color: #ffc107;
}
.btn-check:checked + .btn-outline-status-in_progress {
background-color: #ffc107;
border-color: #ffc107;
color: #000;
}
.btn-outline-status-processed {
--bs-btn-color: #28a745;
--bs-btn-border-color: #28a745;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #28a745;
--bs-btn-hover-border-color: #28a745;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #28a745;
--bs-btn-active-border-color: #28a745;
}
.btn-check:checked + .btn-outline-status-processed {
background-color: #28a745;
border-color: #28a745;
color: #fff;
}
.btn-outline-status-archived {
--bs-btn-color: #000000;
--bs-btn-border-color: #000000;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #000000;
--bs-btn-hover-border-color: #000000;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #000000;
--bs-btn-active-border-color: #000000;
}
.btn-check:checked + .btn-outline-status-archived {
background-color: #000000;
border-color: #000000;
color: #fff;
}
.btn-outline-status-closed {
--bs-btn-color: #343a40;
--bs-btn-border-color: #343a40;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #343a40;
--bs-btn-hover-border-color: #343a40;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #343a40;
--bs-btn-active-border-color: #343a40;
}
.btn-check:checked + .btn-outline-status-closed {
background-color: #343a40;
border-color: #343a40;
color: #fff;
}
.btn-outline-status-not_relevant {
--bs-btn-color: #6c757d;
--bs-btn-border-color: #6c757d;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #6c757d;
--bs-btn-hover-border-color: #6c757d;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #6c757d;
--bs-btn-active-border-color: #6c757d;
}
.btn-check:checked + .btn-outline-status-not_relevant {
background-color: #6c757d;
border-color: #6c757d;
color: #fff;
}
.btn-outline-status-to_redirect {
--bs-btn-color: #ffc107;
--bs-btn-border-color: #ffc107;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #ffc107;
--bs-btn-hover-border-color: #ffc107;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #ffc107;
--bs-btn-active-border-color: #ffc107;
}
.btn-check:checked + .btn-outline-status-to_redirect {
background-color: #ffc107;
border-color: #ffc107;
color: #000;
}
.btn-outline-status-duplicate {
--bs-btn-color: #0dcaf0;
--bs-btn-border-color: #0dcaf0;
--bs-btn-hover-color: #000;
--bs-btn-hover-bg: #0dcaf0;
--bs-btn-hover-border-color: #0dcaf0;
--bs-btn-active-color: #000;
--bs-btn-active-bg: #0dcaf0;
--bs-btn-active-border-color: #0dcaf0;
}
.btn-check:checked + .btn-outline-status-duplicate {
background-color: #0dcaf0;
border-color: #0dcaf0;
color: #000;
}
/* Spinner de chargement */
#loading-spinner {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
#observations-list {
position: relative;
min-height: 100px;
}
#observations-list.loading {
opacity: 0.3;
pointer-events: none;
}
</style>
{% endblock extra_head %}
{% block title %}
{% translate "Observations" %}
{% endblock title %}
{% block content %}
<!-- Filters -->
<form method="get" class="d-flex flex-column gap-2 mb-3">
<!-- Add observation button -->
<div class="d-grid mb-3">
<a href="{% url 'mobile:observation_add_mobile' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% translate "Ajouter une observation" %}
</a>
</div>
<!-- Filter buttons -->
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="form-check m-0">
<input
type="checkbox"
class="form-check-input"
name="mine"
id="filter-mine"
value="1"
{% if show_mine_only %}checked{% endif %}
data-autosubmit
>
<label class="form-check-label" for="filter-mine">
<i class="bi bi-person"></i> {% translate "Mes observations" %}
</label>
</div>
<div class="d-flex align-items-center gap-2">
{% if type_filters %}
<button id="toggle-type-filter"
class="btn btn-outline-secondary btn-sm"
type="button"
aria-expanded="false"
aria-controls="typeFilterPanel"
title="{% translate 'Filtrer par type' %}">
<i class="bi bi-tag"></i>
<span id="typeCountBadge" class="badge text-bg-primary ms-1 d-none">0</span>
</button>
{% endif %}
{% if status_filters %}
<button id="toggle-status-filter"
class="btn btn-outline-secondary btn-sm"
type="button"
aria-expanded="false"
aria-controls="statusFilterPanel"
title="{% translate 'Filtrer par statut' %}">
<i class="bi bi-funnel"></i>
<span id="statusCountBadge" class="badge text-bg-primary ms-1 d-none">0</span>
</button>
{% endif %}
</div>
</div>
<!-- Type filter panel -->
{% if type_filters %}
<div id="typeFilterPanel" class="collapse mb-2">
<p class="mb-1 fw-semibold small text-uppercase text-muted">{% translate "Types" %}</p>
<div class="d-flex flex-wrap gap-2">
{% for type in type_filters %}
{% with field_id="type-filter-"|add:type.key %}
<input
type="checkbox"
class="btn-check"
name="type"
id="{{ field_id }}"
value="{{ type.key }}"
{% if type.checked %}checked{% endif %}
data-type-filter
autocomplete="off"
>
<label class="btn btn-outline-type btn-outline-type-{{ type.key }} btn-sm" for="{{ field_id }}">
{% if type.key == 'incident' %}
<i class="bi bi-exclamation-triangle-fill"></i>
{% elif type.key == 'inventory' %}
<i class="bi bi-clipboard-check"></i>
{% elif type.key == 'remark' %}
<i class="bi bi-chat-left-text"></i>
{% endif %}
{{ type.label }}
</label>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Status filter panel -->
{% if status_filters %}
<div id="statusFilterPanel" class="collapse mb-2">
<p class="mb-1 fw-semibold small text-uppercase text-muted">{% translate "Statuts" %}</p>
<div class="d-flex flex-wrap gap-2">
{% for status in status_filters %}
{% with field_id="status-filter-"|add:status.key %}
<input
type="checkbox"
class="btn-check"
name="status"
id="{{ field_id }}"
value="{{ status.key }}"
{% if status.checked %}checked{% endif %}
data-status-filter
autocomplete="off"
>
<label class="btn btn-outline-status btn-outline-status-{{ status.key }} btn-sm" for="{{ field_id }}">{{ status.label }}</label>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Search field -->
<div class="input-group">
<button type="button" class="btn btn-outline-secondary" id="clearSearch" title="Effacer" aria-label="Effacer la recherche">
<i class="bi bi-x"></i>
</button>
<input
type="text"
name="q"
id="searchInput"
class="form-control"
placeholder="{% translate 'Rechercher...' %}"
value="{{ search_query }}"
>
<button type="submit" class="btn btn-primary" title="Rechercher">
<i class="bi bi-search"></i>
</button>
</div>
</form>
<!-- Bouton flottant carte -->
<button id="map-fab-btn" class="btn btn-primary" title="{% translate 'Voir la carte' %}" aria-label="{% translate 'Voir la carte' %}">
<i class="bi bi-map fs-5"></i>
</button>
<!-- Overlay pleine page : carte -->
<div id="map-overlay" role="dialog" aria-modal="true" aria-label="{% translate 'Carte des observations' %}">
<button id="map-close-btn" class="btn btn-primary shadow-sm" aria-label="{% translate 'Fermer la carte' %}">
<i class="bi bi-x-lg"></i>
</button>
<div id="observation-map"></div>
</div>
<!-- Observations list -->
<div id="observations-list">
<!-- Loading spinner -->
<div id="loading-spinner" class="d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
<div id="observations-list-content">
{% include 'observations/_observations_list_mobile.html' %}
</div>
</div>
{% endblock content %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form[method="get"]');
if (!form) return;
const input = document.getElementById('searchInput');
const clearBtn = document.getElementById('clearSearch');
const listContainer = document.getElementById('observations-list');
const listContent = document.getElementById('observations-list-content');
const spinner = document.getElementById('loading-spinner');
// Latence pour éviter les soumissions multiples rapides
let submitTimer = null;
const SUBMIT_DELAY = 250; // 250ms de latence
let requestCounter = 0;
function showSpinner() {
if (spinner) spinner.classList.remove('d-none');
if (listContainer) listContainer.classList.add('loading');
}
function hideSpinner() {
if (spinner) spinner.classList.add('d-none');
if (listContainer) listContainer.classList.remove('loading');
}
async function submitForm(preserveFocus) {
const requestId = ++requestCounter;
clearTimeout(submitTimer);
submitTimer = setTimeout(async () => {
showSpinner();
const formData = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value !== '') params.append(key, value);
}
const queryString = params.toString();
const action = form.getAttribute('action') || window.location.pathname;
const targetUrl = queryString ? `${action}?${queryString}` : action;
try {
const response = await fetch(targetUrl, {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (requestId !== requestCounter) return;
if (payload.html !== undefined && (listContent || listContainer)) {
if (listContent) {
listContent.innerHTML = payload.html;
} else {
listContainer.innerHTML = payload.html; // fallback
}
initCardsIfNeeded();
}
// Mettre à jour les marqueurs de la carte
if (payload.markers !== undefined) {
document.dispatchEvent(new CustomEvent('observations-markers-updated', {
detail: { markers: payload.markers }
}));
}
const historyUrl = queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname;
window.history.replaceState(null, '', historyUrl);
if (preserveFocus && input) {
input.focus({ preventScroll: true });
const len = input.value.length;
if (typeof input.setSelectionRange === 'function') input.setSelectionRange(len, len);
}
} catch (error) {
console.error('AJAX error:', error);
form.removeEventListener('submit', handleSubmit);
form.submit(); // fallback full reload
} finally {
hideSpinner();
}
}, SUBMIT_DELAY);
}
async function handleSubmit(event) {
event.preventDefault();
const preserveFocus = !!input && (document.activeElement === input);
await submitForm(preserveFocus);
}
form.addEventListener('submit', handleSubmit);
// Clear search button
if (clearBtn && input) {
clearBtn.addEventListener('click', function() {
input.value = '';
submitForm(true);
});
}
// Autosubmit on generic data-autosubmit elements (e.g., mine checkbox)
const autoSubmitTargets = form.querySelectorAll('[data-autosubmit]');
autoSubmitTargets.forEach((el) => {
el.addEventListener('change', () => submitForm(false));
});
// Search input with debounce
if (input) {
input.addEventListener('input', () => {
const value = (input.value || '').trim();
if (value.length === 0 || value.length >= 2) {
submitForm(true);
}
});
}
// Gestion de la persistence des panneaux de filtres
const STORAGE_KEY_TYPE = 'obs_typeFilterOpen';
const STORAGE_KEY_STATUS = 'obs_statusFilterOpen';
// Type filter collapse
const typePanel = document.getElementById('typeFilterPanel');
const typeBtn = document.getElementById('toggle-type-filter');
const typeBadge = document.getElementById('typeCountBadge');
const typeChecks = Array.from(form.querySelectorAll('input[data-type-filter]'));
if (typePanel && typeBtn) {
const typeCollapse = bootstrap.Collapse.getOrCreateInstance(typePanel, { toggle: false });
// Restaurer l'état depuis sessionStorage
const typeWasOpen = sessionStorage.getItem(STORAGE_KEY_TYPE) === 'true';
if (typeWasOpen) {
typeCollapse.show();
}
// Sauvegarder l'état lors des changements
typePanel.addEventListener('shown.bs.collapse', () => {
typeBtn.setAttribute('aria-expanded', 'true');
sessionStorage.setItem(STORAGE_KEY_TYPE, 'true');
});
typePanel.addEventListener('hidden.bs.collapse', () => {
typeBtn.setAttribute('aria-expanded', 'false');
sessionStorage.setItem(STORAGE_KEY_TYPE, 'false');
});
typeBtn.addEventListener('click', () => typeCollapse.toggle());
}
function updateTypeCountBadge() {
const count = typeChecks.filter(c => c.checked).length;
if (!typeBadge) return;
if (count > 0) {
typeBadge.textContent = count;
typeBadge.classList.remove('d-none');
} else {
typeBadge.classList.add('d-none');
}
}
// Type checkboxes: at least one must be checked + autosubmit avec latence
typeChecks.forEach((checkbox) => {
checkbox.addEventListener('change', (event) => {
if (!event.target.checked) {
const otherChecked = typeChecks.some((cb) => cb !== event.target && cb.checked);
if (!otherChecked) {
event.target.checked = true;
return;
}
}
updateTypeCountBadge();
submitForm(false);
});
});
updateTypeCountBadge();
// Status filter collapse
const statusPanel = document.getElementById('statusFilterPanel');
const statusBtn = document.getElementById('toggle-status-filter');
const statusBadge = document.getElementById('statusCountBadge');
const statusChecks = Array.from(form.querySelectorAll('input[data-status-filter]'));
if (statusPanel && statusBtn) {
const statusCollapse = bootstrap.Collapse.getOrCreateInstance(statusPanel, { toggle: false });
// Restaurer l'état depuis sessionStorage
const statusWasOpen = sessionStorage.getItem(STORAGE_KEY_STATUS) === 'true';
if (statusWasOpen) {
statusCollapse.show();
}
// Sauvegarder l'état lors des changements
statusPanel.addEventListener('shown.bs.collapse', () => {
statusBtn.setAttribute('aria-expanded', 'true');
sessionStorage.setItem(STORAGE_KEY_STATUS, 'true');
});
statusPanel.addEventListener('hidden.bs.collapse', () => {
statusBtn.setAttribute('aria-expanded', 'false');
sessionStorage.setItem(STORAGE_KEY_STATUS, 'false');
});
statusBtn.addEventListener('click', () => statusCollapse.toggle());
}
function updateStatusCountBadge() {
const count = statusChecks.filter(c => c.checked).length;
if (!statusBadge) return;
if (count > 0) {
statusBadge.textContent = count;
statusBadge.classList.remove('d-none');
} else {
statusBadge.classList.add('d-none');
}
}
// Status checkboxes: at least one must be checked + autosubmit avec latence
statusChecks.forEach((checkbox) => {
checkbox.addEventListener('change', (event) => {
if (!event.target.checked) {
const otherChecked = statusChecks.some((cb) => cb !== event.target && cb.checked);
if (!otherChecked) {
event.target.checked = true;
return;
}
}
updateStatusCountBadge();
submitForm(false);
});
});
updateStatusCountBadge();
// Clickable cards - initial setup
document.querySelectorAll('.card[data-href]').forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', e => {
if (e.target.closest('a, button, input, label, .form-check')) return;
window.location.href = card.dataset.href;
});
});
// Function to reinitialize cards after AJAX load
function initCardsIfNeeded() {
document.querySelectorAll('.card[data-href]').forEach(card => {
if (card._clickInitDone) return;
card._clickInitDone = true;
card.style.cursor = 'pointer';
card.addEventListener('click', e => {
if (e.target.closest('a, button, input, label, .form-check')) return;
window.location.href = card.dataset.href;
});
});
}
});
</script>
<!-- MapLibre GL JS -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script>
(function () {
const STATUS_COLORS = {
'secondary': '#6c757d',
'danger': '#dc3545',
'warning': '#ffc107',
'success': '#198754',
'info': '#0dcaf0',
'dark': '#212529',
};
const STATUS_TEXT_DARK = new Set(['warning', 'info']);
let currentMarkers = {{ markers_json|safe }};
let map = null;
let fitOnOpen = true;
const SOURCE_ID = 'observations-points';
const LAYER_ID = 'observations-circles';
function hexColor(bsColor) {
return STATUS_COLORS[bsColor] || '#6c757d';
}
function toGeoJSON(markersData) {
return {
type: 'FeatureCollection',
features: markersData.map(m => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [m.lon, m.lat] },
properties: {
id: m.id,
code: m.code,
description: m.description,
status_label: m.status_label,
status_color: m.status_color,
observation_type: m.observation_type,
url: m.url,
fill: hexColor(m.status_color),
},
})),
};
}
function buildPopupHtml(features) {
return features.map((f, idx) => {
const p = f.properties;
const fill = hexColor(p.status_color);
const txtCol = STATUS_TEXT_DARK.has(p.status_color) ? '#000' : '#fff';
const sep = idx > 0 ? '<hr style="margin:5px 0">' : '';
return `${sep}
<div style="margin:2px 0">
<a href="${p.url}">${p.code}</a>
<span style="display:inline-block;background:${fill};color:${txtCol};
border-radius:4px;padding:1px 6px;font-size:0.7rem;margin-left:4px">
${p.status_label}
</span>
<div style="font-size:0.82rem;color:#555;margin-top:2px">${p.description}</div>
</div>`;
}).join('');
}
function updateSource(markersData) {
if (!map) return;
const src = map.getSource(SOURCE_ID);
if (src) src.setData(toGeoJSON(markersData));
}
function initMap() {
if (map) return;
map = new maplibregl.Map({
container: 'observation-map',
style: window.MAP_STYLE_LIGHT,
center: [4.35, 50.85],
zoom: 11,
touchPitch: false,
});
map.addControl(new maplibregl.NavigationControl(), 'bottom-right');
map.addControl(new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: false,
showUserHeading: true,
fitBoundsOptions: { maxZoom: 17 },
}), 'bottom-right');
map.on('load', () => {
map.addSource(SOURCE_ID, {
type: 'geojson',
data: toGeoJSON(currentMarkers),
});
map.addLayer({
id: LAYER_ID,
type: 'circle',
source: SOURCE_ID,
paint: {
'circle-radius': 9,
'circle-color': ['get', 'fill'],
'circle-opacity': 0.92,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
},
});
map.on('mouseenter', LAYER_ID, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', LAYER_ID, () => { map.getCanvas().style.cursor = ''; });
map.on('click', LAYER_ID, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_ID] });
if (!features.length) return;
const seen = new Set();
const unique = features.filter(f => {
if (seen.has(f.properties.id)) return false;
seen.add(f.properties.id); return true;
});
new maplibregl.Popup({ closeButton: true, maxWidth: '320px' })
.setLngLat(e.lngLat)
.setHTML(buildPopupHtml(unique))
.addTo(map);
});
if (fitOnOpen && currentMarkers.length > 0) {
fitOnOpen = false;
fitBounds(currentMarkers);
}
});
}
function fitBounds(markersData) {
if (!markersData.length) return;
if (markersData.length === 1) {
map.flyTo({ center: [markersData[0].lon, markersData[0].lat], zoom: 16 });
return;
}
const lons = markersData.map(m => m.lon);
const lats = markersData.map(m => m.lat);
map.fitBounds(
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
{ padding: 50, maxZoom: 16 }
);
}
function openMap() {
const overlay = document.getElementById('map-overlay');
overlay.style.display = 'block';
if (!map) {
initMap();
} else {
updateSource(currentMarkers);
setTimeout(() => map.resize(), 80);
}
}
function closeMap() {
document.getElementById('map-overlay').style.display = 'none';
}
document.getElementById('map-fab-btn').addEventListener('click', openMap);
document.getElementById('map-close-btn').addEventListener('click', closeMap);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMap(); });
document.addEventListener('observations-markers-updated', e => {
currentMarkers = e.detail.markers || [];
fitOnOpen = false;
const overlay = document.getElementById('map-overlay');
if (map && overlay && overlay.style.display !== 'none') {
updateSource(currentMarkers);
}
});
})();
</script>
{% endblock extra_scripts %}