Files
lidar_rendu/lidar_pipeline/mapui.py

1441 lines
64 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="view-row">
<span class="view-label">Affichage</span>
<span class="layer-name" id="mainName"></span>
<select id="mainSel" class="view-sel" title="Couche d'affichage principal" hidden></select>
</div>
<div id="precBlock" hidden>
<div class="mode-seg" id="modeSeg" role="group" aria-label="Relief, précision ou les deux (touche P)">
<button type="button" data-mode="relief" title="Relief seul (touche P : mode suivant)">Relief</button>
<button type="button" data-mode="precision" title="Précision géométrique seule : densité de points sol">Précision</button>
<button type="button" data-mode="both" title="Précision en produit sur le relief : les zones peu fiables s'assombrissent">Les deux</button>
</div>
<div class="prec-ctl" id="precCtl" hidden>
<span>Opacité de la précision</span>
<input type="range" id="precOpacity" min="0" max="100" step="1" title="Opacité de la précision sur le relief">
<span class="pct" id="precPct"></span>
</div>
<div class="prec-legend" id="precLegend" hidden>
<div class="prec-bar" id="precBar"></div>
<div class="prec-ticks"><span>≤ 0,35</span><span>4</span><span>≥ 45 pts/m²</span></div>
<div class="prec-cap">Points sol par m² : sombre = relief interpolé, noir = aucun point</div>
</div>
</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'affichage courant (couche principale, mode, opacité de la précision, fond) 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>
<div id="tileload" class="tileload" hidden aria-live="polite">
<span class="tl-spin"></span>
<span id="tileloadText"></span>
<span class="tl-bar"><span id="tileloadBar"></span></span>
</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. Filtre posé sur
le conteneur de la couche, pas sur chaque tuile : même rendu (opérations
par pixel), mais une seule passe de filtre au lieu d'une par tuile —
−30 % de CPU (moteur de rendu + GPU) en déplacement, mesuré sous Chrome. */
.leaflet-tile-pane .leaflet-layer.base-dark { 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; }
/* Ligne du fond de carte : œil, nom, opacité. */
.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-row:hover { background: rgba(255, 255, 255, 0.05); }
/* Affichage : une couche principale, la précision seule ou superposée. */
.view-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center;
gap: 8px; padding: 8px 4px 4px; border-top: 1px solid var(--border); margin-top: 4px; }
.view-label { font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.view-row .layer-name { color: var(--text); }
.view-sel { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 2px 4px; font: inherit; font-size: 12px; min-width: 0; width: 100%; height: 24px; }
.view-sel option { background-color: #10141b; color: var(--text); }
.mode-seg { display: grid; grid-template-columns: repeat(3, 1fr); margin: 6px 4px 2px;
border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.mode-seg button { border: none; border-radius: 0; background: rgba(255, 255, 255, 0.04);
color: var(--muted); padding: 6px 4px; font-size: 12px; }
.mode-seg button + button { border-left: 1px solid var(--border); }
.mode-seg button:hover { color: var(--text); background: rgba(255, 255, 255, 0.1); }
.mode-seg button[aria-pressed="true"] { background: var(--accent); color: #0b0e13; font-weight: 600; }
.mode-seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.prec-ctl { display: grid; grid-template-columns: auto minmax(0, 1fr) 34px; gap: 8px;
align-items: center; padding: 6px 4px 0; font-size: 11.5px; color: var(--muted); }
.prec-legend { padding: 8px 4px 2px; }
.prec-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px;
border: 1px solid var(--border); border-radius: 3px; overflow: hidden; }
.prec-ticks { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--muted);
font-variant-numeric: tabular-nums; margin-top: 2px; }
.prec-cap { font-size: 10.5px; color: var(--muted); margin-top: 2px; }
.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); }
/* Indicateur de chargement : tuiles restantes pour finir la vue courante
(fond + LiDAR), barre de progression du lot en cours. */
.tileload { position: fixed; left: 50%; top: 12px; transform: translateX(-50%); z-index: 905;
display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: 999px;
background: var(--card); border: 1px solid var(--border); font-size: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); pointer-events: none;
transition: opacity 0.3s ease; white-space: nowrap; }
.tileload.done { opacity: 0; }
.tl-spin { width: 12px; height: 12px; border-radius: 50%; flex: none;
border: 2px solid rgba(90, 169, 230, 0.25); border-top-color: var(--accent);
animation: tlspin 0.8s linear infinite; }
.tileload.done .tl-spin { animation: none; border-color: var(--accent); }
@keyframes tlspin { to { transform: rotate(360deg); } }
.tl-bar { width: 60px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.1);
overflow: hidden; flex: none; }
.tl-bar span { display: block; height: 100%; width: 0; background: var(--accent);
transition: width 0.2s ease; }
.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 = 'lidarMapView_v2';
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>';
// Affichage : une couche principale (relief orienté) et la couche
// « précision » (densité de points sol), montrée seule ou superposée en
// « produit » (mix-blend-mode multiply) sur la principale : les zones peu
// fiables s'assombrissent sans masquer le relief. Touche P : mode suivant.
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['both', 'Les deux']];
const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
const clamp01 = (v, d) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : d;
};
// Paliers de la précision : niveau k à partir de 0,25 × 2^(k/2) pts/m²
// (visualizations.density_levels), gris = colormap « gray » sur 0..15.
const PREC_LEVELS = 16;
let META = null;
let STATE = null;
const LAYERS = new Map(); // clé → L.TileLayer
const map = L.map('map', {
zoomControl: false,
attributionControl: true,
minZoom: 3,
// Zoom plafonné au natif (19 = 0,2 m/px, 1 px écran = 1 px LiDAR) : toute
// la pyramide est générée d'avance, jamais de tuile agrandie ni étirée.
maxZoom: 19,
zoomSnap: 0.5,
wheelPxPerZoomLevel: 90,
worldCopyJump: false,
preferCanvas: false
});
map.attributionControl.setPrefix('');
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
// Pendant l'animation de zoom, transformations SEULES (aucun niveau créé,
// tuiles chargées à la fin) : un niveau neuf n'est pas composé par Chrome
// (vide au départ, il est animé sur le thread principal, gelé ~90 ms par le
// chargement). Le fond sautait alors que le relief orienté, qui garde son
// niveau pair, glissait normalement — les deux couches se désynchronisaient.
// Leaflet 1.9 déclenche aussi « zoom » dès le DÉBUT de l'animation : même
// garde dans _resetView tant que la carte anime.
const ZoomAnimTileLayer = L.TileLayer.extend({
_animateZoom(e) { this._setView(e.center, e.zoom, true, true); },
_resetView(e) {
if (this._map && this._map._animatingZoom) {
this._setView(this._map.getCenter(), this._map.getZoom(), true, true);
return;
}
L.TileLayer.prototype._resetView.call(this, e);
}
});
// --- chargement des tuiles -------------------------------------------------
// Compteur recalculé depuis l'état réel des tuiles de chaque couche (tuiles
// du niveau courant pas encore chargées) plutôt que par +1/−1 sur les
// événements : une tuile retirée avant la fin de son chargement (déplacement
// rapide) ne déclenche ni « tileload » ni « tileerror », un compteur par
// événements dériverait. Lot : de la première tuile en attente au retour à
// zéro ; la barre donne la part déjà chargée du lot.
const TILE_LOAD = { layers: new Set(), batch: 0, done: 0, raf: 0, hideT: 0 };
function tilesPending() {
let n = 0;
for (const layer of TILE_LOAD.layers) {
if (!layer._map) continue;
for (const key in layer._tiles) {
const t = layer._tiles[key];
if (t.current && !t.loaded) n += 1;
}
}
return n;
}
function updateTileLoad() {
TILE_LOAD.raf = 0;
const box = el('tileload');
if (!box) return;
const pending = tilesPending();
if (pending > 0) {
clearTimeout(TILE_LOAD.hideT);
TILE_LOAD.hideT = 0;
if (TILE_LOAD.batch === 0) TILE_LOAD.done = 0;
TILE_LOAD.batch = Math.max(TILE_LOAD.batch, TILE_LOAD.done + pending);
el('tileloadText').textContent = pending + (pending > 1 ? ' tuiles restantes' : ' tuile restante');
el('tileloadBar').style.width = Math.round(100 * TILE_LOAD.done / TILE_LOAD.batch) + '%';
box.classList.remove('done');
box.hidden = false;
} else if (TILE_LOAD.batch > 0 && !TILE_LOAD.hideT) {
TILE_LOAD.batch = 0;
el('tileloadText').textContent = 'Vue chargée';
el('tileloadBar').style.width = '100%';
box.classList.add('done');
TILE_LOAD.hideT = setTimeout(() => { box.hidden = true; TILE_LOAD.hideT = 0; }, 600);
}
}
function scheduleTileLoad() {
if (!TILE_LOAD.raf) TILE_LOAD.raf = requestAnimationFrame(updateTileLoad);
}
ZoomAnimTileLayer.addInitHook(function () {
TILE_LOAD.layers.add(this);
const onDone = () => { TILE_LOAD.done += 1; scheduleTileLoad(); };
this.on('tileload', onDone);
this.on('tileerror', onDone);
this.on('tileloadstart tileunload load add remove', scheduleTileLoad);
});
const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
// Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
updateWhenZooming: false,
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 precisionKey() {
const p = META && META.precision_layer;
return p && META.layers.some(l => l.key === p) ? p : null;
}
function mainKeys(meta) {
return meta.layers.map(l => l.key).filter(k => k !== meta.precision_layer);
}
function validMain(meta, k) {
return mainKeys(meta).includes(k) ? k : null;
}
function defaultState(meta) {
const b = meta.default_base || {};
return {
main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
mode: validMode(meta.default_mode) || 'relief',
precOpacity: clamp01(meta.default_precision_opacity, 0.6),
base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
dark: b.dark !== undefined ? !!b.dark : true },
};
}
function loadState(meta) {
const def = defaultState(meta);
let st = def;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
const s = JSON.parse(raw);
st = { main: validMain(meta, s.main) || def.main,
mode: validMode(s.mode) || def.mode,
precOpacity: clamp01(s.precOpacity, def.precOpacity),
base: Object.assign({}, def.base, s.base || {}) };
}
} catch (e) { st = def; }
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la
// pile de couches sont ignorés, la vue s'ouvre alors sur l'état courant).
const hash = parseHash();
if (hash) {
if (hash.main && validMain(meta, hash.main)) st.main = hash.main;
if (hash.view) { st.mode = hash.view.mode; st.precOpacity = hash.view.opacity; }
if (hash.base) st.base = hash.base;
}
return st;
}
function saveState() {
try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
}
// --- hash de vue ----------------------------------------------------------
// #z/lat/lng&M=principale&P=mode:opacité&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]),
main: null, view: 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.M) out.main = seg.M;
if (seg.P) {
const f = seg.P.split(':');
const mode = validMode(f[0]);
if (mode) out.view = { mode, opacity: clamp01(Number(f[1]) / 100, 0.6) };
}
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) {
if (STATE.main) h += '&M=' + STATE.main;
h += '&P=' + STATE.mode + ':' + Math.round(STATE.precOpacity * 100);
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
+ ':' + (STATE.base.dark ? '1' : '0');
}
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). Exception : le niveau natif lui-même (19,
// impair — 0,2 m/px) est demandé tel quel, rendu à la volée par le serveur.
// Le replier sur le niveau pair inférieur plafonnait l'affichage à 0,4 m/px,
// étiré ensuite par le navigateur : la pleine résolution n'était jamais vue.
// Au-delà du natif, Leaflet étire le niveau natif (jamais de tuile agrandie
// côté serveur). Le niveau de tuile Leaflet est ici la numérotation standard
// (zoomOffset −1 avec des tuiles de 512 px).
const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
_clampZoom(zoom) {
let z = L.TileLayer.prototype._clampZoom.call(this, zoom);
const maxN = this.options.maxNativeZoom;
if (this.options.evenLevels && z % 2 && (maxN === undefined || z < maxN)) z += 1;
return z;
}
});
function buildLayers() {
// Conteneur isolé : `isolation: isolate` cantonne le « produit » de la
// précision à la couche principale. Sans lui, il assombrirait aussi le fond
// OSM. 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: 19,
// Tuiles de 512 px : une rangée de réserve hors écran vaut les deux
// rangées de 256 px du fond. Deux rangées doublaient les images
// décodées gardées en mémoire (1 Mo chacune) pour peu de gain.
keepBuffer: 1,
updateWhenZooming: false,
crossOrigin: false,
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
attribution: META.attribution
});
LAYERS.set(info.key, layer);
});
applyLayers();
}
// Mode réellement appliqué : sans couche précision servie, relief seul.
function effectiveMode() {
return precisionKey() ? STATE.mode : 'relief';
}
function applyLayers() {
const prec = precisionKey();
const mode = effectiveMode();
for (const [key, layer] of LAYERS) {
const pane = map.getPane('lidar-' + key);
let show, opacity = 1, blend = 'normal';
if (key === prec) {
show = mode !== 'relief';
if (mode === 'both') { opacity = STATE.precOpacity; blend = 'multiply'; }
} else {
show = key === STATE.main && mode !== 'precision';
}
if (pane) {
pane.style.zIndex = key === prec ? '402' : '401';
pane.style.mixBlendMode = blend;
}
if (show) {
if (!map.hasLayer(layer)) layer.addTo(map);
layer.setOpacity(opacity);
} 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 setMode(mode, announce) {
if (!validMode(mode)) return;
STATE.mode = mode;
saveState(); renderPanel(); applyLayers();
if (announce) toast('Affichage : ' + VIEW_MODES.find(m => m[0] === mode)[1], 1200);
}
function renderPrecLegend() {
const bar = el('precBar');
if (bar.childElementCount) return;
for (let k = 0; k < PREC_LEVELS; k++) {
const g = Math.round(255 * k / (PREC_LEVELS - 1));
const lo = 0.25 * Math.pow(2, k / 2);
const hi = 0.25 * Math.pow(2, (k + 1) / 2);
const cell = document.createElement('i');
cell.style.background = 'rgb(' + g + ',' + g + ',' + g + ')';
cell.title = k === 0 ? '≤ ' + hi.toFixed(2).replace('.', ',') + ' pt/m²'
: k === PREC_LEVELS - 1 ? '≥ ' + Math.round(lo) + ' pts/m²'
: lo.toFixed(lo < 10 ? 1 : 0).replace('.', ',') + '–'
+ hi.toFixed(hi < 10 ? 1 : 0).replace('.', ',') + ' pts/m²';
bar.appendChild(cell);
}
}
function renderPanel() {
const labels = new Map(META.layers.map(l => [l.key, l]));
const mains = mainKeys(META);
const info = labels.get(STATE.main);
const name = el('mainName'), sel = el('mainSel');
name.textContent = info ? info.label : 'Aucune couche';
name.title = info ? (info.description || info.label) : '';
// Plusieurs couches principales possibles : menu ; sinon simple libellé.
const many = mains.length > 1;
name.hidden = many;
sel.hidden = !many;
if (many) {
sel.innerHTML = mains.map(k => '<option value="' + k + '"' + (k === STATE.main ? ' selected' : '')
+ '>' + labels.get(k).label + '</option>').join('');
}
const prec = precisionKey();
el('precBlock').hidden = !prec;
if (!prec) return;
renderPrecLegend();
const mode = effectiveMode();
el('modeSeg').querySelectorAll('button').forEach(b =>
b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
el('precCtl').hidden = mode !== 'both';
el('precOpacity').value = Math.round(STATE.precOpacity * 100);
el('precPct').textContent = Math.round(STATE.precOpacity * 100) + '%';
el('precLegend').hidden = mode === 'relief';
}
function renderBaseRow() {
const b = STATE.base;
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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;
// Panneau dédié au-dessus de la pile LiDAR : dans l'overlayPane par défaut
// (z-index 400, comme lidarStack), le contour passait sous les tuiles.
tileSel = L.polygon(data.corners, { pane: 'tileSel', 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.main === 'relief_oriente' && effectiveMode() !== 'precision'
&& 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], ['tileSel', 680]]) {
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.main || h.view || h.base) {
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 = {
main: STATE.main,
mode: STATE.mode,
precision_opacity: STATE.precOpacity,
base: STATE.base,
};
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_main = STATE.main; META.default_mode = STATE.mode;
META.default_precision_opacity = STATE.precOpacity; META.default_base = STATE.base;
toast('Affichage enregistré comme défaut');
})
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
});
el('modeSeg').querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => setMode(b.dataset.mode)));
el('mainSel').addEventListener('change', (e) => {
STATE.main = e.target.value;
saveState(); renderPanel(); applyLayers();
});
el('precOpacity').addEventListener('input', (e) => {
STATE.precOpacity = Number(e.target.value) / 100;
el('precPct').textContent = e.target.value + '%';
const layer = LAYERS.get(precisionKey());
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.precOpacity);
});
el('precOpacity').addEventListener('change', saveState);
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) => {
// P : relief → précision → les deux → relief (hors saisie de texte)
const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
&& META && STATE && precisionKey()) {
const i = VIEW_KEYS.indexOf(STATE.mode);
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return;
}
if (e.key === 'Escape') {
el('tilecard').hidden = true; el('usecard').hidden = true;
if (genDrawing) genSetDrawing(false);
}
});
"""