Files
lidar_rendu/lidar_pipeline/mapui.py
Antoine Jacquin 0a2ccee958 Porter la génération de tuiles dans lidar-maps et retirer l'ancienne webapp
La carte à tuiles XYZ devient la seule interface : l'API de génération
(preview, generate, status, stop, file, cell), le job unique + file
persistante, la délégation LIDAR_GENERATION_URL et les garde-fous
(token, LIDAR_REGEN_CIDR) passent de webapp.py à mapserve.py ; l'interface
gagne les boutons + Zone / ⤒ Compléter / régénération à la dalle, les
options de run et la progression cadre par cadre. mapserve sert aussi
l'inventaire /api/tiles et les dalles en statique versionné : le worker
image complète remplace la webapp sur le 8973 du générateur, le Pi
n'exécute plus que lidar-maps. index.py se réduit aux registres partagés
+ vignettes/sous-tuiles/inventaire (l'UI HTML/JS et les mosaïques
d'overview partent avec export.py et les compose/scripts de la webapp).
2026-09-23 23:21:05 +02:00

1243 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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>
<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>
<div class="gen-row"><span class="gen-lbl">Classification</span>
<select id="genClass" title="Méthode de classification du sol">
<option value="ign">IGN (pré-classée)</option>
<option value="auto">Auto</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select></div>
<div class="gen-row"><span class="gen-lbl">Classes IGN</span>
<input id="genIgnClasses" class="gen-input" value="sol"
title="Classes LAS du MNT IGN : sol(2), unclassified(1), eau(9), virtuel(66), pont(17), sursol(64) — séparées par des virgules"></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<label class="chk"><input type="checkbox" id="genReclass"> Reclasser le sol (même méthode)</label>
<label class="chk"><input type="checkbox" id="genEdge"> Raccord des bords (bande 100 m)</label>
</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="tools">
<button id="btnZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+</button>
<button id="btnComplete" title="Compléter : traiter toutes les dalles déjà téléchargées qui manquent des couches choisies" hidden>⤒</button>
<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; }
/* --- génération de tuiles ------------------------------------------------ */
#gencard { right: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px); }
body.drawing { cursor: crosshair !important; }
body.drawing .leaflet-container { cursor: crosshair !important; }
.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: '&copy; <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). 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 = L.tileLayer(tileUrl(info.key), {
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);
}
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, latlng) {
lastTileQuery = latlng || lastTileQuery;
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)">↻ Générer cette dalle</button></div>'
: '';
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>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
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>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
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;
}
// --- 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,
reclassify: el('genReclass').checked,
ground_class: el('genClass').value,
ign_classes: el('genIgnClasses').value.trim() || 'sol',
edge_buffer: el('genEdge').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)
.then(d => showTileInfo(d, e.latlng)).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;
if (genDrawing) genSetDrawing(false);
}
});
"""