Le pipeline régénère l'inventaire après chaque dalle par défaut (tous lanceurs, sauf --no-index), avec une passe différée quand l'anti-rebond ignore une dalle. La carte surveille l'inventaire toutes les 10 s et place les tuiles des dalles modifiées en tête de file de maintenance : la pyramide suit le rendu au lieu d'attendre la fin du lot. Stockage réduit pour le Raspberry Pi : seuls les niveaux standard pairs jusqu'à z16 sont écrits sur disque, en AVIF ; les autres, dont le niveau le plus fin (~75 % de la pyramide), sont rendus à la volée avec un cache mémoire, y compris en mode cache seule. L'interface ne demande que les niveaux pairs et réduit ceux du niveau supérieur aux zooms impairs. Mesuré sur 8 dalles : ~24 Mo de pyramide avant, 1,2 Mo après. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1427 lines
62 KiB
Python
1427 lines
62 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="rose" class="card" hidden title="Relief orienté : la teinte donne l'orientation de la pente, la clarté le relief local">
|
||
<canvas id="roseCanvas" width="132" height="132"></canvas>
|
||
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></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>
|
||
|
||
<div id="gencard" class="card" hidden>
|
||
<div class="card-head">
|
||
<span class="card-title">Génération</span>
|
||
<span id="genBadge" class="card-stat"></span>
|
||
<button id="genClose" class="icon-btn" title="Fermer">×</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="genMsg" class="meta">Dessinez une zone (+) ou complétez les dalles existantes (⤒).</div>
|
||
<div id="genOpts">
|
||
<div class="gen-row"><span class="gen-lbl">Couches</span>
|
||
<select id="genViz" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
|
||
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
|
||
<div class="meta">Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)</div>
|
||
</div>
|
||
<div id="genClassInfo" class="meta"></div>
|
||
<div class="card-foot" id="genActions">
|
||
<button id="genGo" class="primary" title="Télécharge (géoplateforme IGN) puis traite les dalles sélectionnées">Générer</button>
|
||
<button id="genClear" title="Vider la sélection de dalles">Effacer la sélection</button>
|
||
</div>
|
||
<div id="genRun" hidden>
|
||
<div class="meta" id="genProgress"></div>
|
||
<pre id="genLog" class="gen-log"></pre>
|
||
<div class="card-foot">
|
||
<button id="genStop" title="SIGTERM au pipeline (nettoyage propre), SIGKILL en repli">Arrêter</button>
|
||
<button id="genQueueClear" title="Retirer les demandes en attente (le run en cours continue)">Vider la file</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav id="gentools">
|
||
<button id="btnZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
|
||
<button id="btnComplete" title="Compléter : traiter toutes les dalles déjà téléchargées qui manquent des couches choisies" hidden>⤒ Compléter</button>
|
||
</nav>
|
||
<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: 340px; max-width: calc(100vw - 24px);
|
||
max-height: calc(100vh - 24px); overflow-y: auto; }
|
||
.info-sec { margin-top: 10px; }
|
||
.info-sec h4 { margin: 0 0 4px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;
|
||
text-transform: uppercase; color: var(--muted); }
|
||
.info-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; font-size: 12.5px; }
|
||
.info-kv dt { color: var(--muted); }
|
||
.info-kv dd { margin: 0; overflow-wrap: anywhere; }
|
||
.info-dl { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: 6px;
|
||
background: rgba(90, 160, 255, 0.18); color: var(--text); text-decoration: none; font-weight: 600; }
|
||
.info-dl:hover { background: rgba(90, 160, 255, 0.3); }
|
||
#rose { right: 12px; bottom: 190px; padding: 8px 8px 6px; display: flex; flex-direction: column;
|
||
align-items: center; gap: 2px; z-index: 800; }
|
||
#rose canvas { width: 132px; height: 132px; }
|
||
.rose-cap { font-size: 11px; text-align: center; color: var(--text); line-height: 1.3; }
|
||
.rose-cap span { color: var(--muted); font-size: 10.5px; }
|
||
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: 910; }
|
||
.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; }
|
||
|
||
/* --- génération de tuiles ------------------------------------------------ */
|
||
#gencard { right: 12px; top: 56px; width: 340px; max-width: calc(100vw - 24px); }
|
||
body.drawing { cursor: crosshair !important; }
|
||
body.drawing .leaflet-container { cursor: crosshair !important; }
|
||
/* Barre de génération : en HAUT à droite de l'écran, boutons accentués —
|
||
c'est l'entrée principale du traitement, elle ne se confond pas avec les
|
||
icônes de navigation (zoom/GPS) du coin bas droit. */
|
||
#gentools { position: fixed; top: 12px; right: 12px; z-index: 900; display: flex; gap: 8px; }
|
||
#gentools button { background: var(--card); color: var(--accent); border: 1px solid var(--accent);
|
||
border-radius: 10px; padding: 8px 14px; font: inherit; font-size: 13px; font-weight: 600;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||
#gentools button:hover { background: var(--accent); color: #0b0e13; }
|
||
#gentools button.accent { background: var(--accent); color: #0b0e13; }
|
||
.gen-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px;
|
||
align-items: center; margin: 4px 0; font-size: 11.5px; color: var(--muted); }
|
||
#genViz { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 3px 4px; font: inherit; font-size: 11.5px; }
|
||
#genViz option { background-color: #10141b; }
|
||
#genClass, .gen-input { 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: 11.5px; width: 100%; height: 24px; }
|
||
.chk { display: flex; gap: 7px; align-items: center; font-size: 11.5px;
|
||
color: var(--muted); margin: 4px 0; cursor: pointer; }
|
||
.chk input { accent-color: var(--accent); }
|
||
button.primary { background: var(--accent); border-color: var(--accent); color: #0b0e13; font-weight: 600; }
|
||
button.primary:hover { filter: brightness(1.12); }
|
||
.gen-log { font: 10.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--muted);
|
||
background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 6px;
|
||
padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap;
|
||
word-break: break-all; margin: 6px 0; }
|
||
#tools button.accent { color: var(--accent); border-color: var(--accent); }
|
||
#tools button.busy { color: var(--accent); border-color: var(--accent); }
|
||
/* Cadres des dalles du run : orange pulsant = rendu en cours, bleu = en
|
||
attente, rouge = échec. Les dalles faites n'ont pas de cadre. */
|
||
@keyframes genpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||
.gen-frame-running { stroke: #f4a261; fill: #f4a26122; animation: genpulse 1.4s ease-in-out infinite; }
|
||
.gen-frame-pending { stroke: #4cc9f0; fill: #4cc9f014; stroke-dasharray: 6 4; }
|
||
.gen-frame-failed { stroke: #e76f51; fill: #e76f5133; }
|
||
.gen-sel { stroke: var(--accent); fill: #5aa9e61f; cursor: pointer; }
|
||
@media (max-width: 720px) {
|
||
#layercard { width: calc(100vw - 24px); }
|
||
#tilecard { width: calc(100vw - 24px); }
|
||
#gencard { 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 : '');
|
||
}
|
||
|
||
// Couche à niveaux standard pairs seulement (stockage réduit côté serveur) :
|
||
// à un zoom impair, les tuiles du niveau pair SUPÉRIEUR sont affichées
|
||
// réduites de moitié (nettes) ; au-delà du zoom natif, le niveau inférieur.
|
||
// Le niveau de tuile Leaflet est ici la numérotation standard (zoomOffset −1
|
||
// avec des tuiles de 512 px).
|
||
const EvenLevelTileLayer = L.TileLayer.extend({
|
||
_clampZoom(zoom) {
|
||
let z = L.TileLayer.prototype._clampZoom.call(this, zoom);
|
||
if (this.options.evenLevels && z % 2) {
|
||
const maxN = this.options.maxNativeZoom;
|
||
z = (maxN !== undefined && z + 1 > maxN) ? z - 1 : z + 1;
|
||
}
|
||
return z;
|
||
}
|
||
});
|
||
|
||
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). Les panneaux sont réutilisés si déjà
|
||
// créés (rafraîchissement après un run de génération).
|
||
const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
|
||
stack.style.zIndex = 400;
|
||
stack.style.isolation = 'isolate';
|
||
META.layers.forEach((info) => {
|
||
const paneName = 'lidar-' + info.key;
|
||
const pane = map.getPane(paneName) || map.createPane(paneName, stack);
|
||
const existing = LAYERS.get(info.key);
|
||
if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); }
|
||
const layer = new EvenLevelTileLayer(tileUrl(info.key), {
|
||
evenLevels: !!META.even_levels,
|
||
pane: paneName,
|
||
// 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);
|
||
updateRose();
|
||
}
|
||
|
||
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 ----------------------------------------------------------
|
||
let tileSel = null; // cadre de la dalle sélectionnée
|
||
|
||
function esc(v) {
|
||
return String(v == null ? '' : v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
}
|
||
|
||
function fmtDate(v, withTime) {
|
||
if (!v) return '—';
|
||
const d = new Date(v);
|
||
if (isNaN(d)) return esc(v);
|
||
const opt = withTime ? { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' }
|
||
: { dateStyle: 'long', timeZone: 'Europe/Paris' };
|
||
return d.toLocaleString('fr-FR', opt);
|
||
}
|
||
|
||
function kv(rows) {
|
||
return '<dl class="info-kv">' + rows.filter(r => r[1] != null && r[1] !== '')
|
||
.map(r => '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>').join('') + '</dl>';
|
||
}
|
||
|
||
function selectTileOutline(data) {
|
||
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
|
||
if (!data || !data.corners) return;
|
||
tileSel = L.polygon(data.corners, { color: '#ffd24a', weight: 2, fill: false,
|
||
dashArray: '6 4', interactive: false }).addTo(map);
|
||
}
|
||
|
||
function ignSection(ign) {
|
||
if (!ign) return '<div class="meta">Chargement des informations IGN…</div>';
|
||
if (ign.error) return '<div class="meta">Catalogue IGN injoignable pour le moment.</div>';
|
||
if (!ign.found) return '<div class="meta">Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).</div>';
|
||
const start = ign.acquisition_start, end = ign.acquisition_end;
|
||
let when = fmtDate(start, true);
|
||
if (start && end && String(start).slice(0, 10) === String(end).slice(0, 10) && String(end).length > 10) {
|
||
when = fmtDate(start, true) + ' → ' + new Date(end).toLocaleTimeString('fr-FR',
|
||
{ hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||
} else if (end && String(end).slice(0, 10) !== String(start || '').slice(0, 10)) {
|
||
when = fmtDate(start) + ' → ' + fmtDate(end);
|
||
}
|
||
const pts = ign.points ? Number(ign.points).toLocaleString('fr-FR') + ' points' : null;
|
||
return kv([
|
||
['Scan LiDAR', when],
|
||
['Capteur', (ign.sensors || []).map(esc).join('<br>')],
|
||
['Mission', esc(ign.mission)],
|
||
['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('pour ' + ign.acquisition_owner)]
|
||
.filter(Boolean).map(esc).join(' ')],
|
||
['Édition IGN', ign.edition_date ? fmtDate(ign.edition_date) : null],
|
||
['Classement', esc(ign.classification_process)],
|
||
['Nuage', pts],
|
||
]) + (ign.download_url
|
||
? '<a class="info-dl" href="' + esc(ign.download_url) + '" target="_blank" rel="noopener">⤓ Télécharger le nuage LiDAR IGN (.copc.laz)</a>'
|
||
: '') + (ign.stac_url
|
||
? '<div class="meta"><a href="' + esc(ign.stac_url) + '" target="_blank" rel="noopener">Fiche du catalogue</a></div>' : '');
|
||
}
|
||
|
||
function calageSection(c) {
|
||
if (!c || !c.beams || !c.beams.length) return '';
|
||
const offs = Object.entries(c.offsets_cm || {}).map(([k, v]) => k + ' : ' + (v > 0 ? '+' : '') + String(v).replace('.', ',') + ' cm');
|
||
const lines = Object.values(c.lines_rms_cm || {});
|
||
return '<div class="info-sec"><h4>Recalage vertical des passes</h4>' + kv([
|
||
['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
|
||
['Décalages', offs.length ? offs.join('<br>') : 'aucun ≥ 0,5 cm'],
|
||
['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
|
||
]) + '</div>';
|
||
}
|
||
|
||
function renderTileCard(data, ign) {
|
||
const regenBtn = (GEN.available && GEN.allowed)
|
||
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ ' + (data.found ? 'Régénérer' : 'Générer') + ' cette dalle</button></div>'
|
||
: '';
|
||
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
|
||
const bb = data.l93_bbox || [];
|
||
let html = '<div class="meta"><b>' + esc(data.name) + '</b></div>' +
|
||
'<div class="info-sec"><h4>Emprise</h4>' + kv([
|
||
['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') +
|
||
'<br>Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null],
|
||
['Surface', '1 km × 1 km'],
|
||
]) + '</div>' +
|
||
'<div class="info-sec"><h4>Acquisition LiDAR (IGN)</h4>' + ignSection(ign) + '</div>';
|
||
if (data.found) {
|
||
const viz = (data.layers || []).map(k => {
|
||
const info = META.layers.find(l => l.key === k);
|
||
return info ? info.label : k;
|
||
}).join(', ');
|
||
html += '<div class="info-sec"><h4>Rendu</h4>' + kv([
|
||
['Résolution', String(data.resolution).replace('.', ',') + ' m/px'],
|
||
['Classification', esc(data.method || 'inconnue')],
|
||
['Généré le', esc(data.generated || '—')],
|
||
['Couches', esc(viz || '—')],
|
||
]) + '</div>' + calageSection(data.calage);
|
||
} else {
|
||
html += '<div class="info-sec"><div class="meta">Pas encore de rendu sur cette dalle.</div></div>';
|
||
}
|
||
el('tileBody').innerHTML = html + regenBtn;
|
||
const btn = el('tileRegen');
|
||
if (btn) btn.addEventListener('click', genAddCellFromQuery);
|
||
el('tilecard').hidden = false;
|
||
}
|
||
|
||
function showTileInfo(data, latlng) {
|
||
lastTileQuery = latlng || lastTileQuery;
|
||
if (!data) {
|
||
el('tileTitle').textContent = 'Dalle';
|
||
el('tileBody').innerHTML = '<div class="meta">Serveur injoignable : réessayez dans un instant.</div>';
|
||
el('tilecard').hidden = false;
|
||
return;
|
||
}
|
||
selectTileOutline(data);
|
||
renderTileCard(data, null);
|
||
// Fiche IGN à part : la sélection s'affiche sans attendre le catalogue.
|
||
const key = data.col + '_' + data.row;
|
||
showTileInfo.current = key;
|
||
fetch('api/map/ign?col=' + data.col + '&row=' + data.row)
|
||
.then(r => r.ok ? r.json() : { error: 'HTTP ' + r.status })
|
||
.catch(() => ({ error: 'réseau' }))
|
||
.then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
|
||
}
|
||
|
||
// --- rose des vents du relief orienté --------------------------------------
|
||
// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
|
||
// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
|
||
// chroma 60 · L*(100−L*)/2500, à clarté fixe.
|
||
function labToRgb(L, a, b) {
|
||
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
|
||
const finv = t => (t > 6 / 29 ? t * t * t : 3 * (6 / 29) * (6 / 29) * (t - 4 / 29));
|
||
const X = 0.95047 * finv(fx), Y = finv(fy), Z = 1.08883 * finv(fz);
|
||
const lin = [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z,
|
||
0.0557 * X - 0.2040 * Y + 1.0570 * Z];
|
||
return lin.map(c => {
|
||
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;
|
||
return Math.round(Math.min(1, Math.max(0, v)) * 255);
|
||
});
|
||
}
|
||
|
||
function drawRose() {
|
||
const cv = el('roseCanvas');
|
||
if (!cv || drawRose.done) return;
|
||
drawRose.done = true;
|
||
const dpr = window.devicePixelRatio || 1, size = 132;
|
||
cv.width = size * dpr; cv.height = size * dpr;
|
||
const ctx = cv.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
const c = size / 2, rOut = 50, rIn = 22, L = 64;
|
||
const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
|
||
const img = ctx.createImageData(size * dpr, size * dpr);
|
||
for (let py = 0; py < size * dpr; py++) {
|
||
for (let px = 0; px < size * dpr; px++) {
|
||
const x = px / dpr - c, y = py / dpr - c, r = Math.hypot(x, y);
|
||
if (r > rOut || r < rIn) continue;
|
||
const compass = (Math.atan2(x, -y) * 180 / Math.PI + 360) % 360; // 0 = N, sens horaire
|
||
const h = ((compass + 90) % 360) * Math.PI / 180;
|
||
const [R, G, B] = labToRgb(L, chroma * Math.cos(h), chroma * Math.sin(h));
|
||
const i = (py * size * dpr + px) * 4;
|
||
img.data[i] = R; img.data[i + 1] = G; img.data[i + 2] = B; img.data[i + 3] = 255;
|
||
}
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.font = '600 11px system-ui, sans-serif';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
|
||
for (const [t, deg] of labels) {
|
||
const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
|
||
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
|
||
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
|
||
if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
|
||
ctx.fillText(t, x, y);
|
||
}
|
||
}
|
||
|
||
function updateRose() {
|
||
const on = !!(STATE && STATE.on && STATE.on.relief_oriente && LAYERS.get('relief_oriente'));
|
||
el('rose').hidden = !on;
|
||
if (on) drawRose();
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
|
||
// Portée de la webapp historique : dessin de zone (+), passe « tout
|
||
// compléter » (⤒), sélection cumulée (clic fiche dalle), options de run,
|
||
// file d'attente et progression dalle par dalle (cadres sur la carte).
|
||
const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
|
||
const genSel = new Set(); // clés "col,row" des dalles retenues
|
||
const genCorners = new Map(); // clé → coins GPS [[lat, lon] × 4]
|
||
const genPolys = new Map(); // clé → polygone de surlignage
|
||
const genFrames = new Map(); // nom → cadre coloré de dalle en cours
|
||
let genDrawing = false, genDrawStart = null, genDrawRect = null;
|
||
let genIgnoreClickUntil = 0;
|
||
let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
|
||
|
||
for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670]]) {
|
||
map.createPane(pane).style.zIndex = String(z);
|
||
}
|
||
|
||
function genKey(c, r) { return c + ',' + r; }
|
||
|
||
function genApi(method, url, body) {
|
||
return fetch(url, {
|
||
method: method,
|
||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
}).then(r => {
|
||
if (!r.ok) {
|
||
return r.json().catch(() => ({})).then(d => {
|
||
throw new Error((d && d.detail) || ('HTTP ' + r.status));
|
||
});
|
||
}
|
||
return r.json();
|
||
});
|
||
}
|
||
|
||
function genOpenCard(msg) {
|
||
if (msg !== undefined) el('genMsg').textContent = msg;
|
||
el('gencard').hidden = false;
|
||
}
|
||
|
||
function genFillViz() {
|
||
el('genViz').innerHTML = META.layers.map(l =>
|
||
'<option value="' + (l.step || l.key) + '">' + l.label + '</option>').join('');
|
||
}
|
||
|
||
function genSelectedViz() {
|
||
return Array.from(el('genViz').selectedOptions).map(o => o.value);
|
||
}
|
||
|
||
function genRefreshSel() {
|
||
for (const poly of genPolys.values()) poly.remove();
|
||
genPolys.clear();
|
||
for (const [key, corners] of genCorners.entries()) {
|
||
if (!genSel.has(key) || !corners || corners.length < 4) continue;
|
||
const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
|
||
pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
|
||
}).addTo(map);
|
||
poly.on('click', (e) => {
|
||
L.DomEvent.stopPropagation(e);
|
||
genRemoveCell(key);
|
||
toast('Dalle retirée de la sélection');
|
||
});
|
||
genPolys.set(key, poly);
|
||
}
|
||
el('genGo').disabled = !genSel.size;
|
||
if (genSel.size) {
|
||
el('genMsg').textContent = genSel.size + ' dalle(s) sélectionnée(s) — ' +
|
||
'cliquez un surlignage pour retirer une dalle, réglez les options puis « Générer ».';
|
||
}
|
||
}
|
||
|
||
function genRemoveCell(key) {
|
||
genSel.delete(key);
|
||
genCorners.delete(key);
|
||
genRefreshSel();
|
||
}
|
||
|
||
function genAddCells(cells) {
|
||
let added = 0;
|
||
for (const c of (cells || [])) {
|
||
const k = genKey(c.col, c.row);
|
||
if (!genSel.has(k)) { genSel.add(k); added++; }
|
||
if (c.corners) genCorners.set(k, c.corners);
|
||
}
|
||
genRefreshSel();
|
||
return added;
|
||
}
|
||
|
||
function genAddCellFromQuery() {
|
||
if (!lastTileQuery) return;
|
||
genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
|
||
.then(d => {
|
||
genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
|
||
genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
|
||
el('tilecard').hidden = true;
|
||
})
|
||
.catch(msg => toast('Échec : ' + msg.message, 6000));
|
||
}
|
||
|
||
function genSetDrawing(on) {
|
||
genDrawing = on;
|
||
document.body.classList.toggle('drawing', on);
|
||
el('btnZone').classList.toggle('accent', on);
|
||
if (on) map.dragging.disable(); else map.dragging.enable();
|
||
if (!on && genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
|
||
genDrawStart = null;
|
||
if (on) genOpenCard('Dessinez un rectangle sur la carte (re-cliquez + pour annuler).');
|
||
}
|
||
|
||
map.on('mousedown', (e) => {
|
||
if (!genDrawing) return;
|
||
genDrawStart = e.latlng;
|
||
if (genDrawRect) genDrawRect.remove();
|
||
genDrawRect = L.rectangle([genDrawStart, genDrawStart], {
|
||
pane: 'genDraw', color: '#4cc9f0', weight: 2, dashArray: '6 4',
|
||
fillOpacity: 0.05, interactive: false,
|
||
}).addTo(map);
|
||
});
|
||
map.on('mousemove', (e) => {
|
||
if (!genDrawing || !genDrawStart || !genDrawRect) return;
|
||
genDrawRect.setBounds(L.latLngBounds(genDrawStart, e.latlng));
|
||
});
|
||
map.on('mouseup', (e) => {
|
||
if (!genDrawing || !genDrawStart) return;
|
||
const b = L.latLngBounds(genDrawStart, e.latlng);
|
||
genDrawStart = null;
|
||
genIgnoreClickUntil = Date.now() + 400; // pas de fiche dalle après un dessin
|
||
if (Math.abs(b.getWest() - b.getEast()) < 1e-9 ||
|
||
Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
|
||
genSetDrawing(false);
|
||
if (genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
|
||
genOpenCard('Analyse de la zone…');
|
||
genApi('POST', 'api/preview', {
|
||
bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
|
||
regenerate: el('genRegen').checked,
|
||
viz: genSelectedViz(),
|
||
}).then(d => {
|
||
const added = genAddCells(d.cells);
|
||
genOpenCard((added ? added + ' nouvelle(s) dalle(s) retenue(s)'
|
||
: 'zone déjà couverte — cochez « Régénérer » pour forcer') +
|
||
(d.capped ? ' — zone plafonnée, resserrez le rectangle' : '') + '.');
|
||
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
|
||
});
|
||
|
||
function genComplete() {
|
||
genOpenCard('Analyse des dalles déjà téléchargées…');
|
||
genApi('POST', 'api/preview', {
|
||
all_missing: true,
|
||
regenerate: el('genRegen').checked,
|
||
viz: genSelectedViz(),
|
||
}).then(d => {
|
||
const added = genAddCells(d.cells);
|
||
genOpenCard((added || d.count || 0) + ' dalle(s) à compléter' +
|
||
(d.capped ? ' — plafonnées à 10 000 par run' : '') + '.');
|
||
if (genPolys.size) {
|
||
const bounds = L.latLngBounds([]);
|
||
genPolys.forEach(p => bounds.extend(p.getBounds()));
|
||
map.fitBounds(bounds, { padding: [40, 40] });
|
||
}
|
||
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
|
||
}
|
||
|
||
function genLaunch() {
|
||
const tiles = [...genSel].map(k => k.split(',').map(Number));
|
||
if (!tiles.length) return;
|
||
genApi('POST', 'api/generate', {
|
||
tiles: tiles,
|
||
regenerate: el('genRegen').checked,
|
||
viz: genSelectedViz(),
|
||
}).then(d => {
|
||
genSel.clear();
|
||
genCorners.clear();
|
||
genRefreshSel();
|
||
GEN.wasRunning = true;
|
||
genPollNow();
|
||
toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)')
|
||
: ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)'));
|
||
}).catch(msg => toast('Échec : ' + msg.message, 6000));
|
||
}
|
||
|
||
// Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en
|
||
// attente, rouge = échec) — redraw complet à chaque sondage, c'est peu.
|
||
function genDrawFrames(tiles) {
|
||
for (const poly of genFrames.values()) poly.remove();
|
||
genFrames.clear();
|
||
for (const t of (tiles || [])) {
|
||
if (t.state === 'done' || !t.corners || t.corners.length < 4) continue;
|
||
const cls = t.state === 'running' ? 'gen-frame-running'
|
||
: t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending';
|
||
const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), {
|
||
pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15,
|
||
interactive: false,
|
||
}).addTo(map);
|
||
genFrames.set(t.name || JSON.stringify(t.corners[0]), poly);
|
||
}
|
||
}
|
||
|
||
// Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de
|
||
// tuile neuf) et la maintenance de pyramide est relancée côté serveur.
|
||
function genAfterRun() {
|
||
fetch('api/map/meta').then(r => r.json()).then(meta => {
|
||
META = meta;
|
||
buildLayers();
|
||
renderPanel();
|
||
renderBaseRow();
|
||
applyLayers();
|
||
}).catch(() => {});
|
||
fetch('api/map/background', { method: 'POST' }).catch(() => {});
|
||
}
|
||
|
||
function genUpdateStatus(d) {
|
||
const queued = (d.queue || []).length;
|
||
if (d.running || queued) {
|
||
el('genActions').hidden = true;
|
||
el('genOpts').hidden = true;
|
||
el('genRun').hidden = false;
|
||
const tiles = d.tiles || [];
|
||
const done = tiles.filter(t => t.state === 'done').length;
|
||
const failed = tiles.filter(t => t.state === 'failed').length;
|
||
let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)')
|
||
: 'Démarrage du run suivant…';
|
||
if (failed) txt += ' • ' + failed + ' échec(s)';
|
||
if (queued) txt += ' • ' + queued + ' demande(s) en file';
|
||
el('genProgress').textContent = txt;
|
||
el('genLog').textContent = (d.log || []).slice(-12).join('\n');
|
||
genDrawFrames(tiles);
|
||
GEN.wasRunning = true;
|
||
} else {
|
||
el('genRun').hidden = true;
|
||
el('genActions').hidden = !GEN.available;
|
||
el('genOpts').hidden = false;
|
||
genDrawFrames(null);
|
||
if (GEN.wasRunning) {
|
||
GEN.wasRunning = false;
|
||
toast(d.returncode === 0 ? 'Génération terminée'
|
||
: 'Génération interrompue (code ' + d.returncode + ')', 6000);
|
||
genAfterRun();
|
||
}
|
||
}
|
||
el('genBadge').textContent = d.running ? '● en cours'
|
||
: queued ? ('⏱ ' + queued) : '';
|
||
el('btnZone').classList.toggle('busy', !!d.running || !!queued);
|
||
}
|
||
|
||
function genPollNow() {
|
||
if (!GEN.available) return;
|
||
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
|
||
if (d) genUpdateStatus(d);
|
||
genPollSoon();
|
||
}).catch(() => genPollSoon());
|
||
}
|
||
|
||
function genPollSoon() {
|
||
clearTimeout(GEN.timer);
|
||
GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000);
|
||
}
|
||
|
||
el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
|
||
el('btnComplete').addEventListener('click', genComplete);
|
||
el('genClose').addEventListener('click', () => {
|
||
el('gencard').hidden = true;
|
||
if (genDrawing) genSetDrawing(false);
|
||
});
|
||
el('genClear').addEventListener('click', () => {
|
||
genSel.clear();
|
||
genCorners.clear();
|
||
genRefreshSel();
|
||
genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).');
|
||
});
|
||
el('genGo').addEventListener('click', genLaunch);
|
||
el('genStop').addEventListener('click', () =>
|
||
genApi('POST', 'api/stop')
|
||
.then(() => toast('Arrêt demandé'))
|
||
.catch(msg => toast('Échec : ' + msg.message, 6000)));
|
||
el('genQueueClear').addEventListener('click', () =>
|
||
genApi('POST', 'api/queue/clear')
|
||
.then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
|
||
.catch(msg => toast('Échec : ' + msg.message, 6000)));
|
||
|
||
// --- 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();
|
||
genFillViz();
|
||
// Droits de génération : boutons affichés si le serveur peut générer
|
||
// (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
|
||
// réseau autorisé (LIDAR_REGEN_CIDR) — sinon masqués silencieusement.
|
||
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
|
||
if (!d) return;
|
||
GEN.available = !!d.available;
|
||
GEN.allowed = d.regen_allowed !== false;
|
||
const show = GEN.available && GEN.allowed;
|
||
el('btnZone').hidden = !show;
|
||
el('btnComplete').hidden = !show;
|
||
if (show) { genUpdateStatus(d); genPollNow(); }
|
||
}).catch(() => {});
|
||
}).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) => {
|
||
if (genDrawing || Date.now() < genIgnoreClickUntil) return;
|
||
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
|
||
.then(r => r.ok ? r.json() : null)
|
||
.catch(() => null)
|
||
.then(d => showTileInfo(d, e.latlng));
|
||
});
|
||
|
||
el('cardToggle').addEventListener('click', () =>
|
||
el('layercard').classList.toggle('collapsed'));
|
||
el('tileClose').addEventListener('click', () => {
|
||
el('tilecard').hidden = true;
|
||
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
|
||
});
|
||
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;
|
||
if (genDrawing) genSetDrawing(false);
|
||
}
|
||
});
|
||
"""
|