L'ancienne carte n'est pas une carte à tuiles : une div Leaflet par dalle,
rotée en CSS pour coller la grille Lambert 93 sur le Web Mercator, trois
paliers d'images choisis à la main, un plafond d'images pleine résolution et
une mosaïque d'overview pour boucher les trous au dézoom. Des centaines de
nœuds DOM, des AVIF de 2500² à 5000² décodés dans le navigateur, un LOD
maison — et rien de réutilisable hors de cette page.
Nouvelle image légère (Pillow + pyproj, ni GDAL ni PDAL, port 8975) servant
une pyramide XYZ EPSG:3857 au schéma OpenStreetMap, rendue à la demande
depuis les dalles et mise en cache dans output/index_xyz/ :
- tiles.py : grille XYZ, reprojection par transformation projective dalle par
dalle (calage mesuré < 1 px), choix du palier source parmi ceux que le
pipeline produit déjà (vignette, intermédiaire, quadrant, dalle), cache
périmé dès qu'une dalle contributrice est plus récente, marqueur .empty
pour les zones sans donnée, cache d'images sources à budget mémoire ;
- mapserve.py : /tiles/{couche}/{z}/{x}/{y}.png (256 px canonique) et
@2x.webp (512 px, interface), TileJSON, WMTS, josm.imagery.xml, CORS —
les rendus deviennent un fond d'imagerie pour JOSM, iD, QGIS, uMap ;
- mapui.py : une L.tileLayer par couche dans un pane isolé — LOD, cache et
animation natifs de Leaflet ; pile réordonnable (glisser avec barre
d'insertion, ou boutons ▲▼ au doigt), modes de fusion CSS par couche,
configuration figeable comme défaut de tous les navigateurs.
Deux amonts pour les déploiements en deux machines : LIDAR_SOURCE_URL
(webapp du pipeline — inventaire complet, dalles rapatriées à la demande) et
LIDAR_MAPS_URL (autre instance carte). Charge bornée et réglable, taillée
par défaut pour une petite machine : 2 rendus et 2 téléchargements
simultanés, 192 Mo de cache de sources.
Mesuré sur 849 dalles réelles : première tuile d'une zone 0,9–3,2 s
(téléchargement compris), ~1 ms ensuite ; un chemin OSM se superpose
exactement à la trace du rendu de pente, et les coutures entre tuiles
restent sous le bruit naturel du terrain.
L'interface historique (port 8973) n'est pas touchée : génération et export
y restent, les deux cartes coexistent.
857 lines
37 KiB
Python
857 lines
37 KiB
Python
"""Interface de la carte à tuiles XYZ (image lidar-maps).
|
||
|
||
Même convention que `index.py` : le CSS et le JS vivent dans des constantes,
|
||
sont écrits par `write_map_assets()` et bâchés dans l'image au build. La page
|
||
est une coquille minimale : tout l'état vient de `/api/map/meta`, les couches
|
||
sont des `L.tileLayer` standard — le navigateur gère LOD, cache et animation.
|
||
"""
|
||
|
||
import hashlib
|
||
from pathlib import Path
|
||
|
||
ASSETS_DIRNAME = "mapserve_assets"
|
||
|
||
|
||
def ui_version():
|
||
"""Hash court du CSS+JS : sert d'URL de version pour les assets."""
|
||
return hashlib.sha1((_MAP_CSS + _MAP_JS).encode("utf-8")).hexdigest()[:10]
|
||
|
||
|
||
def write_map_assets(assets_dir):
|
||
"""Écrit app.css / app.js (+ Leaflet vendorisé) dans `assets_dir`."""
|
||
import shutil
|
||
assets_dir = Path(assets_dir)
|
||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||
(assets_dir / "app.css").write_text(_MAP_CSS, encoding="utf-8")
|
||
(assets_dir / "app.js").write_text(
|
||
_MAP_JS.replace("__UI_VERSION__", ui_version()), encoding="utf-8")
|
||
src_vendor = Path(__file__).parent / "assets" / "vendor"
|
||
dst_vendor = assets_dir / "vendor"
|
||
if src_vendor.is_dir():
|
||
shutil.rmtree(dst_vendor, ignore_errors=True)
|
||
shutil.copytree(src_vendor, dst_vendor)
|
||
return assets_dir
|
||
|
||
|
||
def render_html():
|
||
"""Coquille HTML (données chargées par /api/map/meta)."""
|
||
return _MAP_HTML.replace("__ASSETS_V__", ui_version())
|
||
|
||
|
||
_MAP_HTML = """<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
||
<title>Carte LiDAR</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3E%3Crect width=%2716%27 height=%2716%27 rx=%273%27 fill=%27%230b0e13%27/%3E%3Cpath d=%27M2 11l4-5 3 4 2-2 3 3z%27 fill=%27%235aa9e6%27/%3E%3C/svg%3E">
|
||
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css"/>
|
||
<link rel="stylesheet" href="assets/app.css?v=__ASSETS_V__"/>
|
||
</head>
|
||
<body>
|
||
<div id="map"></div>
|
||
|
||
<div id="layercard" class="card">
|
||
<div class="card-head">
|
||
<button id="cardToggle" class="icon-btn" title="Replier / déplier">▾</button>
|
||
<span class="card-title">Couches</span>
|
||
<span id="stat" class="card-stat"></span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="layer-row" id="baseRow">
|
||
<span class="grip"></span>
|
||
<button class="eye on" id="baseToggle" title="Afficher / masquer le fond de carte"></button>
|
||
<span class="layer-name" id="baseName" title="Cliquer : fond clair / assombri">Fond OpenStreetMap</span>
|
||
<span class="pct" id="basePct"></span>
|
||
<div class="layer-ctl">
|
||
<input type="range" id="baseOpacity" min="0" max="100" step="1" title="Opacité du fond">
|
||
</div>
|
||
</div>
|
||
<div class="stack-edge" id="edgeTop">Haut de pile — devant</div>
|
||
<div id="layers"></div>
|
||
<div class="stack-edge" id="edgeBottom">Bas de pile — derrière</div>
|
||
<div class="stack-row">
|
||
<span>Fusion de la pile sur le fond</span>
|
||
<select id="stackBlend" class="blend" title="Mode de fusion de l'ensemble des couches LiDAR sur le fond de carte"></select>
|
||
</div>
|
||
<div class="card-foot">
|
||
<button id="btnShare" class="link-btn" title="Copier un lien vers cette vue">⎘ Partager la vue</button>
|
||
<button id="btnUse" class="link-btn" title="Utiliser ces rendus comme fond d'imagerie (JOSM, QGIS, iD…)">⤓ Utiliser dans JOSM / QGIS</button>
|
||
</div>
|
||
<div class="card-foot">
|
||
<button id="btnDefault" class="link-btn" title="Figer l'état courant (ordre, visibilité, opacités, fusions) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
|
||
<button id="btnReset" class="link-btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tilecard" class="card" hidden>
|
||
<div class="card-head">
|
||
<span class="card-title" id="tileTitle">Dalle</span>
|
||
<button id="tileClose" class="icon-btn" title="Fermer">×</button>
|
||
</div>
|
||
<div class="card-body" id="tileBody"></div>
|
||
</div>
|
||
|
||
<div id="usecard" class="card" hidden>
|
||
<div class="card-head">
|
||
<span class="card-title">Fond d'imagerie XYZ</span>
|
||
<button id="useClose" class="icon-btn" title="Fermer">×</button>
|
||
</div>
|
||
<div class="card-body" id="useBody"></div>
|
||
</div>
|
||
|
||
<nav id="tools">
|
||
<button id="zoomIn" title="Zoomer">+</button>
|
||
<button id="zoomOut" title="Dézoomer">−</button>
|
||
<button id="zoomFit" title="Recadrer sur les données">⤢</button>
|
||
<button id="gps" title="Centrer sur ma position">⌖</button>
|
||
</nav>
|
||
<div id="toast" class="toast" hidden></div>
|
||
|
||
<script src="assets/vendor/leaflet/leaflet.js"></script>
|
||
<script src="assets/app.js?v=__ASSETS_V__"></script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
_MAP_CSS = """/* Carte LiDAR — tuiles XYZ, habillage minimal type carte grand public. */
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #0b0e13;
|
||
--card: rgba(16, 20, 27, 0.94);
|
||
--border: rgba(255, 255, 255, 0.10);
|
||
--text: #d9dee6;
|
||
--muted: #8b94a3;
|
||
--accent: #5aa9e6;
|
||
--radius: 12px;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
[hidden] { display: none !important; }
|
||
html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
|
||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||
#map { position: absolute; inset: 0; background: var(--bg); }
|
||
.leaflet-container { background: var(--bg); font: inherit; }
|
||
/* Fond OSM assombri pour laisser ressortir les rendus LiDAR. */
|
||
.leaflet-tile-pane .leaflet-layer.base-dark .leaflet-tile { filter: brightness(0.38) contrast(1.1) saturate(0.7); }
|
||
|
||
.card { position: fixed; z-index: 900; background: var(--card); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||
#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
|
||
#tilecard { left: 12px; top: 12px; width: 320px; max-width: calc(100vw - 24px); }
|
||
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); }
|
||
.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||
.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
||
.card-stat { margin-left: auto; font-size: 11px; color: var(--muted); }
|
||
.card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; }
|
||
#layercard.collapsed .card-body { display: none; }
|
||
#layercard.collapsed #cardToggle { transform: rotate(-90deg); }
|
||
.card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); }
|
||
|
||
button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
|
||
button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
|
||
.icon-btn { border: none; background: none; color: var(--muted); padding: 2px 6px; font-size: 14px; }
|
||
.icon-btn:hover { color: var(--text); background: none; }
|
||
.link-btn { flex: 1; font-size: 11.5px; }
|
||
|
||
/* Une couche tient sur deux lignes : identité (poignée, œil, nom, ordre) puis
|
||
réglages (fusion + opacité). Les boutons ▲▼ doublent le glisser-déposer —
|
||
seuls utilisables au doigt. */
|
||
.layer-row { display: grid; grid-template-columns: 14px 24px minmax(0, 1fr) auto 34px;
|
||
align-items: center; gap: 6px; padding: 5px 4px; border-radius: 8px; user-select: none; }
|
||
.layer-ctl { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 84px;
|
||
gap: 6px; align-items: center; padding: 2px 0 2px 38px; }
|
||
.layer-move { display: inline-flex; gap: 2px; }
|
||
.layer-move button { padding: 0 5px; font-size: 10px; line-height: 16px; color: var(--muted);
|
||
background: rgba(255, 255, 255, 0.05); }
|
||
.layer-move button:hover:not(:disabled) { color: var(--text); }
|
||
.layer-move button:disabled { opacity: 0.25; cursor: default; }
|
||
.blend { background: rgba(255, 255, 255, 0.06); color: var(--muted);
|
||
border: 1px solid var(--border); border-radius: 6px; padding: 2px 4px;
|
||
font: inherit; font-size: 11px; min-width: 0; width: 100%; height: 22px; }
|
||
.blend:hover { color: var(--text); }
|
||
.blend option { background-color: #10141b; color: var(--text); }
|
||
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
|
||
/* Réorganisation : la destination doit être lisible AVANT de lâcher. Une
|
||
barre d'insertion épaisse marque le point de chute, la ligne déplacée
|
||
s'estompe, et à l'arrivée la couche clignote pour que l'œil la retrouve. */
|
||
.layer-row.dragging { opacity: 0.35; outline: 1px dashed var(--accent); }
|
||
.layer-row.drop-before { box-shadow: inset 0 3px 0 0 var(--accent); }
|
||
.layer-row.drop-after { box-shadow: inset 0 -3px 0 0 var(--accent); }
|
||
.layer-row.drop-before::before, .layer-row.drop-after::before {
|
||
content: '▸'; position: absolute; margin-left: -12px; color: var(--accent);
|
||
font-size: 11px; line-height: 1;
|
||
}
|
||
.layer-row.drop-before::before { margin-top: -14px; }
|
||
.layer-row.drop-after::before { margin-top: 14px; }
|
||
.layer-row { position: relative; }
|
||
.layer-row.just-moved { animation: moved 1s ease-out; }
|
||
@keyframes moved {
|
||
0% { background: rgba(90, 169, 230, 0.38); }
|
||
100% { background: transparent; }
|
||
}
|
||
body.reordering, body.reordering * { cursor: grabbing !important; }
|
||
.stack-edge {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase;
|
||
color: var(--muted); padding: 2px 6px;
|
||
}
|
||
.stack-edge::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||
.grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; }
|
||
.grip::before { content: '⠿'; }
|
||
#baseRow .grip::before { content: ''; }
|
||
.eye { width: 24px; height: 24px; padding: 0; border: none; background: none; color: var(--muted);
|
||
display: flex; align-items: center; justify-content: center; }
|
||
.eye.on { color: var(--accent); }
|
||
.eye svg { width: 16px; height: 16px; }
|
||
.layer-name { font-size: 12.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.layer-row.on .layer-name { color: var(--text); }
|
||
.pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
|
||
background: transparent; cursor: pointer; }
|
||
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
|
||
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%;
|
||
background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; }
|
||
input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
|
||
input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; }
|
||
|
||
#tools { position: fixed; right: 12px; bottom: 12px; z-index: 900; display: flex; flex-direction: column; gap: 6px; }
|
||
#tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card);
|
||
border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||
#gps.busy { color: var(--accent); border-color: var(--accent); }
|
||
.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
|
||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||
padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; }
|
||
|
||
.meta { font-size: 11.5px; color: var(--muted); margin: 2px 0; }
|
||
.meta b { color: var(--text); font-weight: 600; }
|
||
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
|
||
background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 6px;
|
||
padding: 6px 8px; word-break: break-all; margin: 4px 0 8px; }
|
||
.use-row { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; }
|
||
.use-row select { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 12px; }
|
||
.use-note { font-size: 11px; color: var(--muted); line-height: 1.5; }
|
||
.leaflet-control-attribution { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; }
|
||
.leaflet-control-attribution a { color: var(--accent) !important; }
|
||
@media (max-width: 720px) {
|
||
#layercard { width: calc(100vw - 24px); }
|
||
#tilecard { width: calc(100vw - 24px); }
|
||
}
|
||
"""
|
||
|
||
|
||
_MAP_JS = r"""'use strict';
|
||
/* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation.
|
||
Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier
|
||
d'image, aucune rotation CSS, aucune mosaïque maison. */
|
||
|
||
const UI_VERSION = "__UI_VERSION__";
|
||
const LS_KEY = 'lidarMapLayers_v1';
|
||
const el = (id) => document.getElementById(id);
|
||
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||
|
||
// Modes de fusion proposés (CSS mix-blend-mode) : la superposition de couches
|
||
// ne se réduit pas à de la transparence — « Produit » assombrit le relief sans
|
||
// délaver la rampe du dessous, « Écran » éclaircit, « Superposé »/« Doux »
|
||
// creusent le contraste local, « Différence » fait ressortir les écarts.
|
||
const BLEND_MODES = [
|
||
['normal', 'Normal'],
|
||
['multiply', 'Produit'],
|
||
['screen', 'Écran'],
|
||
['overlay', 'Superposé'],
|
||
['soft-light', 'Doux'],
|
||
['hard-light', 'Lumière crue'],
|
||
['difference', 'Différence'],
|
||
['luminosity', 'Luminosité'],
|
||
];
|
||
const BLEND_KEYS = BLEND_MODES.map(m => m[0]);
|
||
const validBlend = (v) => (BLEND_KEYS.includes(v) ? v : null);
|
||
const blendOptions = (current) => BLEND_MODES.map(
|
||
m => '<option value="' + m[0] + '"' + (m[0] === current ? ' selected' : '') +
|
||
'>' + m[1] + '</option>').join('');
|
||
|
||
let META = null;
|
||
let STATE = null;
|
||
const LAYERS = new Map(); // clé → L.TileLayer
|
||
let dragKey = null;
|
||
|
||
const map = L.map('map', {
|
||
zoomControl: false,
|
||
attributionControl: true,
|
||
minZoom: 3,
|
||
maxZoom: 22,
|
||
zoomSnap: 0.5,
|
||
wheelPxPerZoomLevel: 90,
|
||
worldCopyJump: false,
|
||
preferCanvas: false
|
||
});
|
||
map.attributionControl.setPrefix('');
|
||
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
|
||
|
||
const base = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 22, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
}).addTo(map);
|
||
|
||
function toast(msg, ms) {
|
||
const t = el('toast');
|
||
t.textContent = msg;
|
||
t.hidden = false;
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500);
|
||
}
|
||
|
||
// --- état persisté --------------------------------------------------------
|
||
function defaultState(meta) {
|
||
// `default_order` n'existe que si une configuration a été figée depuis la
|
||
// carte (POST /api/map/defaults) ; sinon on part du registre du pipeline.
|
||
const order = (Array.isArray(meta.default_order) && meta.default_order.length)
|
||
? meta.default_order.filter(k => meta.layers.some(l => l.key === k))
|
||
: meta.layers.map(l => l.key);
|
||
for (const l of meta.layers) if (!order.includes(l.key)) order.push(l.key);
|
||
const on = {}, opacity = {}, blend = {};
|
||
const wanted = (meta.default_layers || []).filter(k => order.includes(k));
|
||
// Ordre figé depuis la carte : respecté tel quel. Sinon, les couches
|
||
// allumées par défaut passent en bas de pile (bas → haut).
|
||
const stacked = (Array.isArray(meta.default_order) && meta.default_order.length)
|
||
? order
|
||
: wanted.concat(order.filter(k => !wanted.includes(k)));
|
||
for (const k of stacked) {
|
||
on[k] = wanted.includes(k);
|
||
const o = Number((meta.default_opacity || {})[k]);
|
||
opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : 1;
|
||
blend[k] = (meta.default_blend || {})[k] || 'normal';
|
||
}
|
||
const b = meta.default_base || {};
|
||
return {
|
||
order: stacked, on, opacity, blend,
|
||
base: { on: b.on !== undefined ? !!b.on : true,
|
||
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
|
||
dark: b.dark !== undefined ? !!b.dark : true },
|
||
stackBlend: validBlend(meta.default_stack_blend) || 'normal',
|
||
};
|
||
}
|
||
|
||
function loadState(meta) {
|
||
const def = defaultState(meta);
|
||
let st = def;
|
||
try {
|
||
const raw = localStorage.getItem(LS_KEY);
|
||
if (raw) {
|
||
const s = JSON.parse(raw);
|
||
const order = (Array.isArray(s.order) ? s.order : []).filter(k => def.order.includes(k));
|
||
for (const k of def.order) if (!order.includes(k)) order.push(k);
|
||
const on = {}, opacity = {}, blend = {};
|
||
for (const k of order) {
|
||
on[k] = !!(s.on && s.on[k]);
|
||
const o = Number(s.opacity ? s.opacity[k] : NaN);
|
||
opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : def.opacity[k];
|
||
blend[k] = (s.blend && s.blend[k]) || def.blend[k] || 'normal';
|
||
}
|
||
st = { order, on, opacity, blend,
|
||
base: Object.assign({}, def.base, s.base || {}),
|
||
stackBlend: validBlend(s.stackBlend) || 'normal' };
|
||
}
|
||
} catch (e) { st = def; }
|
||
const hash = parseHash();
|
||
if (hash && hash.layers) {
|
||
const order = hash.layers.order.filter(k => def.order.includes(k));
|
||
for (const k of def.order) if (!order.includes(k)) order.push(k);
|
||
st = { order, on: hash.layers.on, opacity: hash.layers.opacity,
|
||
blend: Object.assign({}, st.blend, hash.layers.blend || {}),
|
||
base: hash.base || st.base,
|
||
stackBlend: hash.stackBlend || st.stackBlend || 'normal' };
|
||
for (const k of order) {
|
||
if (st.on[k] === undefined) st.on[k] = false;
|
||
if (st.opacity[k] === undefined) st.opacity[k] = 1;
|
||
if (!st.blend[k]) st.blend[k] = def.blend[k] || 'normal';
|
||
}
|
||
}
|
||
return st;
|
||
}
|
||
|
||
function saveState() {
|
||
try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
|
||
}
|
||
|
||
// --- hash de vue ----------------------------------------------------------
|
||
// #z/lat/lng&L=clé:opacité:visible,...&B=on:opacité:sombre
|
||
function parseHash() {
|
||
const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
|
||
if (!m) return null;
|
||
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
|
||
layers: null, base: null };
|
||
const seg = {};
|
||
for (const part of m[4].split('&')) {
|
||
const i = part.indexOf('=');
|
||
if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
|
||
}
|
||
if (seg.L) {
|
||
// clé:opacité:visible[:fusion] — le 4e champ est optionnel (liens anciens)
|
||
const order = [], on = {}, opacity = {}, blend = {};
|
||
for (const item of seg.L.split(',')) {
|
||
const f = item.split(':');
|
||
if (f.length < 3) continue;
|
||
order.push(f[0]);
|
||
on[f[0]] = f[2] === '1';
|
||
const o = Number(f[1]);
|
||
opacity[f[0]] = Number.isFinite(o) ? Math.min(100, Math.max(0, o)) / 100 : 1;
|
||
const b = validBlend(f[3]);
|
||
if (b) blend[f[0]] = b;
|
||
}
|
||
if (order.length) out.layers = { order, on, opacity, blend };
|
||
}
|
||
if (seg.S) out.stackBlend = validBlend(seg.S) || 'normal';
|
||
if (seg.B) {
|
||
const f = seg.B.split(':');
|
||
if (f.length === 3) {
|
||
const o = Number(f[1]);
|
||
out.base = { on: f[0] === '1', opacity: Number.isFinite(o) ? o / 100 : 0.85, dark: f[2] === '1' };
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function viewHash(full) {
|
||
const c = map.getCenter();
|
||
let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6);
|
||
if (full) {
|
||
h += '&L=' + STATE.order.map(k =>
|
||
k + ':' + Math.round(STATE.opacity[k] * 100) + ':' + (STATE.on[k] ? '1' : '0')
|
||
+ ':' + (STATE.blend[k] || 'normal')).join(',');
|
||
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
|
||
+ ':' + (STATE.base.dark ? '1' : '0');
|
||
h += '&S=' + (STATE.stackBlend || 'normal');
|
||
}
|
||
return h;
|
||
}
|
||
|
||
let hashTimer = null;
|
||
function saveHashSoon() {
|
||
clearTimeout(hashTimer);
|
||
hashTimer = setTimeout(() => {
|
||
history.replaceState(null, '', location.pathname + location.search + viewHash(false));
|
||
}, 350);
|
||
}
|
||
|
||
// --- couches --------------------------------------------------------------
|
||
function tileUrl(key) {
|
||
return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
|
||
}
|
||
|
||
function buildLayers() {
|
||
// Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la
|
||
// pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers
|
||
// les zones sans donnée. La fusion de la pile ENTIÈRE sur le fond reste
|
||
// réglable à part (STATE.stackBlend).
|
||
const stack = map.createPane('lidarStack');
|
||
stack.style.zIndex = 400;
|
||
stack.style.isolation = 'isolate';
|
||
META.layers.forEach((info) => {
|
||
const pane = 'lidar-' + info.key;
|
||
map.createPane(pane, stack);
|
||
const layer = L.tileLayer(tileUrl(info.key), {
|
||
pane: pane,
|
||
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
|
||
// pas de requêtes inutiles sur le reste du monde.
|
||
bounds: META.bounds
|
||
? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]])
|
||
: undefined,
|
||
tileSize: META.tile_size,
|
||
zoomOffset: META.zoom_offset,
|
||
minNativeZoom: META.min_zoom,
|
||
maxNativeZoom: META.max_native_zoom,
|
||
minZoom: 0,
|
||
maxZoom: 22,
|
||
keepBuffer: 2,
|
||
updateWhenZooming: false,
|
||
crossOrigin: false,
|
||
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||
attribution: META.attribution
|
||
});
|
||
LAYERS.set(info.key, layer);
|
||
});
|
||
applyLayers();
|
||
}
|
||
|
||
function applyLayers() {
|
||
const stack = map.getPane('lidarStack');
|
||
if (stack) stack.style.mixBlendMode = STATE.stackBlend || 'normal';
|
||
let z = 400;
|
||
for (const key of STATE.order) {
|
||
const layer = LAYERS.get(key);
|
||
if (!layer) continue;
|
||
const pane = map.getPane('lidar-' + key);
|
||
z += 1;
|
||
pane.style.zIndex = String(z);
|
||
pane.style.mixBlendMode = STATE.blend[key] || 'normal';
|
||
if (STATE.on[key]) {
|
||
if (!map.hasLayer(layer)) layer.addTo(map);
|
||
layer.setOpacity(STATE.opacity[key]);
|
||
} else if (map.hasLayer(layer)) {
|
||
map.removeLayer(layer);
|
||
}
|
||
}
|
||
if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
|
||
else if (map.hasLayer(base)) map.removeLayer(base);
|
||
base.setOpacity(STATE.base.opacity);
|
||
const container = base.getContainer && base.getContainer();
|
||
if (container) container.classList.toggle('base-dark', STATE.base.dark);
|
||
}
|
||
|
||
function moveLayer(key, delta) {
|
||
// STATE.order est bas → haut ; le panneau affiche haut en premier.
|
||
const ord = STATE.order;
|
||
const i = ord.indexOf(key);
|
||
const j = i + delta;
|
||
if (i < 0 || j < 0 || j >= ord.length) return;
|
||
ord.splice(j, 0, ord.splice(i, 1)[0]);
|
||
saveState(); renderPanel(key); applyLayers();
|
||
}
|
||
|
||
// Insère `src` avant ou après `target` dans l'ordre AFFICHÉ (haut → bas).
|
||
function reorderRelative(src, target, before) {
|
||
const top = STATE.order.slice().reverse();
|
||
const from = top.indexOf(src);
|
||
if (from < 0) return;
|
||
top.splice(from, 1);
|
||
let at = top.indexOf(target);
|
||
if (at < 0) return;
|
||
if (!before) at += 1;
|
||
top.splice(at, 0, src);
|
||
STATE.order = top.reverse();
|
||
saveState(); renderPanel(src); applyLayers();
|
||
}
|
||
|
||
function clearDropMarks() {
|
||
document.querySelectorAll('.layer-row.drop-before, .layer-row.drop-after')
|
||
.forEach(r => r.classList.remove('drop-before', 'drop-after'));
|
||
}
|
||
|
||
function renderPanel(movedKey) {
|
||
const host = el('layers');
|
||
host.innerHTML = '';
|
||
const labels = new Map(META.layers.map(l => [l.key, l]));
|
||
// Haut de pile en premier (STATE.order est bas → haut).
|
||
const topFirst = STATE.order.slice().reverse();
|
||
topFirst.forEach((key, idx) => {
|
||
const info = labels.get(key);
|
||
if (!info) return;
|
||
const on = !!STATE.on[key];
|
||
const row = document.createElement('div');
|
||
row.className = 'layer-row' + (on ? ' on' : '')
|
||
+ (key === movedKey ? ' just-moved' : '');
|
||
row.dataset.layer = key;
|
||
row.title = info.description || info.label;
|
||
row.innerHTML =
|
||
'<span class="grip" title="Glisser pour réordonner"></span>' +
|
||
'<button class="eye' + (on ? ' on' : '') + '" title="Afficher / masquer">' +
|
||
(on ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
|
||
'<span class="layer-name">' + info.label + '</span>' +
|
||
'<span class="layer-move">' +
|
||
'<button class="up" title="Monter dans la pile"' +
|
||
(idx === 0 ? ' disabled' : '') + '>▲</button>' +
|
||
'<button class="down" title="Descendre dans la pile"' +
|
||
(idx === topFirst.length - 1 ? ' disabled' : '') + '>▼</button>' +
|
||
'</span>' +
|
||
'<span class="pct">' + Math.round(STATE.opacity[key] * 100) + '%</span>' +
|
||
(on ? '<div class="layer-ctl">' +
|
||
'<select class="blend" title="Mode de fusion avec les couches du dessous">' +
|
||
blendOptions(STATE.blend[key] || 'normal') + '</select>' +
|
||
'<input type="range" min="0" max="100" step="1" value="' +
|
||
Math.round(STATE.opacity[key] * 100) + '" title="Opacité">' +
|
||
'</div>' : '');
|
||
row.draggable = false;
|
||
row.addEventListener('mousedown', (e) => {
|
||
row.draggable = !!(e.target.closest && e.target.closest('.grip'));
|
||
});
|
||
row.querySelector('.eye').addEventListener('click', () => {
|
||
STATE.on[key] = !STATE.on[key];
|
||
if (STATE.on[key] && STATE.opacity[key] === 0) STATE.opacity[key] = 1;
|
||
saveState(); renderPanel(); applyLayers();
|
||
});
|
||
row.querySelector('.up').addEventListener('click', () => moveLayer(key, 1));
|
||
row.querySelector('.down').addEventListener('click', () => moveLayer(key, -1));
|
||
const blend = row.querySelector('.blend');
|
||
if (blend) blend.addEventListener('change', () => {
|
||
STATE.blend[key] = blend.value;
|
||
saveState();
|
||
const pane = map.getPane('lidar-' + key);
|
||
if (pane) pane.style.mixBlendMode = blend.value;
|
||
});
|
||
const range = row.querySelector('input[type="range"]');
|
||
const pct = row.querySelector('.pct');
|
||
if (range) {
|
||
range.addEventListener('input', () => {
|
||
STATE.opacity[key] = Number(range.value) / 100;
|
||
pct.textContent = range.value + '%';
|
||
const layer = LAYERS.get(key);
|
||
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.opacity[key]);
|
||
});
|
||
range.addEventListener('change', saveState);
|
||
}
|
||
row.addEventListener('dragstart', (e) => {
|
||
dragKey = key;
|
||
row.classList.add('dragging');
|
||
document.body.classList.add('reordering');
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', key); // exigé par Firefox
|
||
}
|
||
});
|
||
row.addEventListener('dragend', () => {
|
||
dragKey = null;
|
||
row.classList.remove('dragging');
|
||
row.draggable = false;
|
||
document.body.classList.remove('reordering');
|
||
clearDropMarks();
|
||
});
|
||
row.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
if (!dragKey || dragKey === key) return;
|
||
// Moitié haute survolée → insertion au-dessus, sinon en dessous.
|
||
const box = row.getBoundingClientRect();
|
||
const before = (e.clientY - box.top) < box.height / 2;
|
||
clearDropMarks();
|
||
row.classList.add(before ? 'drop-before' : 'drop-after');
|
||
});
|
||
row.addEventListener('dragleave', () => {
|
||
row.classList.remove('drop-before', 'drop-after');
|
||
});
|
||
row.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
if (!dragKey || dragKey === key) { clearDropMarks(); return; }
|
||
const before = row.classList.contains('drop-before');
|
||
const src = dragKey;
|
||
clearDropMarks();
|
||
document.body.classList.remove('reordering');
|
||
reorderRelative(src, key, before);
|
||
});
|
||
host.appendChild(row);
|
||
});
|
||
// La couche qui vient d'être déplacée est ramenée dans le champ de vision
|
||
// (le panneau défile dès qu'il y a beaucoup de couches).
|
||
if (movedKey) {
|
||
const moved = host.querySelector('.layer-row.just-moved');
|
||
if (moved && moved.scrollIntoView) moved.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
function renderBaseRow() {
|
||
const b = STATE.base;
|
||
const sel = el('stackBlend');
|
||
if (sel && !sel.options.length) {
|
||
sel.innerHTML = blendOptions(STATE.stackBlend || 'normal');
|
||
sel.addEventListener('change', () => {
|
||
STATE.stackBlend = sel.value;
|
||
saveState(); applyLayers();
|
||
});
|
||
} else if (sel) {
|
||
sel.value = STATE.stackBlend || 'normal';
|
||
}
|
||
const toggle = el('baseToggle');
|
||
toggle.classList.toggle('on', b.on);
|
||
toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF;
|
||
el('baseOpacity').value = Math.round(b.opacity * 100);
|
||
el('basePct').textContent = Math.round(b.opacity * 100) + '%';
|
||
el('baseRow').classList.toggle('on', b.on);
|
||
}
|
||
|
||
// --- fiche dalle ----------------------------------------------------------
|
||
function showTileInfo(data) {
|
||
const card = el('tilecard');
|
||
if (!data || !data.found) {
|
||
el('tileTitle').textContent = 'Aucune dalle ici';
|
||
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>';
|
||
card.hidden = false;
|
||
return;
|
||
}
|
||
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
|
||
const viz = (data.layers || []).map(k => {
|
||
const info = META.layers.find(l => l.key === k);
|
||
return info ? info.label : k;
|
||
}).join(', ');
|
||
el('tileBody').innerHTML =
|
||
'<div class="meta"><b>' + data.name + '</b></div>' +
|
||
'<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' +
|
||
' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' +
|
||
(data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') +
|
||
'<div class="meta">Couches : ' + (viz || '—') + '</div>';
|
||
card.hidden = false;
|
||
}
|
||
|
||
// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
|
||
function renderUseCard() {
|
||
const origin = location.origin;
|
||
const opts = META.layers.map(l =>
|
||
'<option value="' + l.key + '">' + l.label + '</option>').join('');
|
||
el('useBody').innerHTML =
|
||
'<div class="use-row"><select id="useLayer">' + opts + '</select>' +
|
||
'<button id="useCopy">Copier</button></div>' +
|
||
'<div class="meta">URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :</div>' +
|
||
'<div class="mono" id="useUrl"></div>' +
|
||
'<div class="meta">TileJSON : <a id="useJson" href="#"></a></div>' +
|
||
'<div class="meta">WMTS : <a href="' + origin + '/tiles/wmts.xml">' + origin + '/tiles/wmts.xml</a></div>' +
|
||
'<div class="meta">JOSM (toutes les couches) : <a href="' + origin + '/tiles/josm.imagery.xml">' + origin + '/tiles/josm.imagery.xml</a></div>' +
|
||
'<div class="use-note">Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '</div>';
|
||
const sel = el('useLayer');
|
||
const refresh = () => {
|
||
const key = sel.value;
|
||
el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png';
|
||
const a = el('useJson');
|
||
a.textContent = origin + '/tiles/' + key + '.json';
|
||
a.href = origin + '/tiles/' + key + '.json';
|
||
};
|
||
sel.addEventListener('change', refresh);
|
||
el('useCopy').addEventListener('click', () => {
|
||
const text = el('useUrl').textContent;
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(() => toast('URL copiée'),
|
||
() => window.prompt('URL XYZ :', text));
|
||
} else { window.prompt('URL XYZ :', text); }
|
||
});
|
||
refresh();
|
||
el('usecard').hidden = false;
|
||
}
|
||
|
||
// --- démarrage ------------------------------------------------------------
|
||
function fitData() {
|
||
if (META.bounds) {
|
||
map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]],
|
||
{ padding: [30, 30] });
|
||
} else {
|
||
map.setView([46.6, 2.5], 6);
|
||
}
|
||
}
|
||
|
||
fetch('api/map/meta').then(r => r.json()).then(meta => {
|
||
META = meta;
|
||
if (!META.layers.length) {
|
||
toast('Aucune visualisation disponible dans le cache.', 10000);
|
||
}
|
||
STATE = loadState(META);
|
||
// L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
|
||
// suite : ce qui est à l'écran est ce qui sera rechargé.
|
||
saveState();
|
||
el('stat').textContent = META.layers.length + ' couche(s)';
|
||
const hash = parseHash();
|
||
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
|
||
else fitData();
|
||
buildLayers();
|
||
renderPanel();
|
||
renderBaseRow();
|
||
}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
|
||
|
||
let applyingHash = false;
|
||
window.addEventListener('hashchange', () => {
|
||
// Lien partagé collé dans l'onglet courant : la vue (et l'état des couches
|
||
// s'il est présent) est appliquée sans rechargement.
|
||
if (applyingHash || !META) return;
|
||
const h = parseHash();
|
||
if (!h) return;
|
||
applyingHash = true;
|
||
map.setView([h.lat, h.lng], h.zoom);
|
||
if (h.layers) {
|
||
STATE = loadState(META);
|
||
saveState(); renderPanel(); renderBaseRow(); applyLayers();
|
||
}
|
||
setTimeout(() => { applyingHash = false; }, 0);
|
||
});
|
||
|
||
map.on('moveend zoomend', saveHashSoon);
|
||
map.on('click', (e) => {
|
||
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
|
||
.then(r => r.ok ? r.json() : null).then(showTileInfo).catch(() => {});
|
||
});
|
||
|
||
el('cardToggle').addEventListener('click', () =>
|
||
el('layercard').classList.toggle('collapsed'));
|
||
el('tileClose').addEventListener('click', () => { el('tilecard').hidden = true; });
|
||
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
|
||
el('btnUse').addEventListener('click', renderUseCard);
|
||
el('btnShare').addEventListener('click', () => {
|
||
const url = location.origin + location.pathname + viewHash(true);
|
||
history.replaceState(null, '', url);
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(url).then(() => toast('Lien copié'),
|
||
() => window.prompt('Lien :', url));
|
||
} else { window.prompt('Lien :', url); }
|
||
});
|
||
el('btnDefault').addEventListener('click', () => {
|
||
const body = {
|
||
order: STATE.order,
|
||
on: STATE.order.filter(k => STATE.on[k]),
|
||
opacity: Object.fromEntries(STATE.order.map(k => [k, STATE.opacity[k]])),
|
||
blend: Object.fromEntries(STATE.order.map(k => [k, STATE.blend[k] || 'normal'])),
|
||
base: STATE.base,
|
||
stack_blend: STATE.stackBlend || 'normal',
|
||
};
|
||
fetch('api/map/defaults', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
}).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
|
||
.then(() => { META.default_order = STATE.order.slice(); toast('Configuration enregistrée comme défaut'); })
|
||
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
|
||
});
|
||
|
||
el('btnReset').addEventListener('click', () => {
|
||
// Oublie l'état local et reprend la configuration servie par le serveur.
|
||
try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
|
||
fetch('api/map/meta').then(r => r.json()).then(meta => {
|
||
META = meta;
|
||
STATE = defaultState(META);
|
||
saveState(); renderPanel(); renderBaseRow(); applyLayers();
|
||
toast('Configuration par défaut rétablie');
|
||
}).catch(() => toast('Serveur injoignable', 6000));
|
||
});
|
||
|
||
el('baseToggle').addEventListener('click', () => {
|
||
STATE.base.on = !STATE.base.on;
|
||
saveState(); renderBaseRow(); applyLayers();
|
||
});
|
||
el('baseName').addEventListener('click', () => {
|
||
STATE.base.dark = !STATE.base.dark;
|
||
saveState(); applyLayers();
|
||
});
|
||
el('baseOpacity').addEventListener('input', (e) => {
|
||
STATE.base.opacity = Number(e.target.value) / 100;
|
||
el('basePct').textContent = e.target.value + '%';
|
||
base.setOpacity(STATE.base.opacity);
|
||
});
|
||
el('baseOpacity').addEventListener('change', saveState);
|
||
el('zoomIn').addEventListener('click', () => map.zoomIn(1));
|
||
el('zoomOut').addEventListener('click', () => map.zoomOut(1));
|
||
el('zoomFit').addEventListener('click', fitData);
|
||
el('gps').addEventListener('click', () => {
|
||
if (!('geolocation' in navigator)) {
|
||
toast('Géolocalisation indisponible (HTTPS requis).', 6000);
|
||
return;
|
||
}
|
||
const btn = el('gps');
|
||
btn.classList.add('busy');
|
||
navigator.geolocation.getCurrentPosition((pos) => {
|
||
btn.classList.remove('busy');
|
||
const ll = [pos.coords.latitude, pos.coords.longitude];
|
||
map.setView(ll, Math.max(map.getZoom(), 16));
|
||
if (window._gpsMarker) window._gpsMarker.remove();
|
||
window._gpsMarker = L.circleMarker(ll, {
|
||
radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9
|
||
}).addTo(map);
|
||
}, (err) => {
|
||
btn.classList.remove('busy');
|
||
const why = err && err.code === 1 ? 'Autorisation refusée'
|
||
: err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible';
|
||
toast(why + ' (la géolocalisation exige HTTPS).', 6000);
|
||
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { el('tilecard').hidden = true; el('usecard').hidden = true; }
|
||
});
|
||
"""
|