From 75a562d6a4c59821b2f4bfa21d4f79d1c7de56e4 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 17:26:41 +0200 Subject: [PATCH] Sortir l'interface de la carte en vrais fichiers web/map.{html,css,js} Co-Authored-By: Claude Haiku 4.5 --- lidar_pipeline/mapui.py | 1622 +--------------------------- lidar_pipeline/tests/test_mapui.py | 37 + lidar_pipeline/web/map.css | 198 ++++ lidar_pipeline/web/map.html | 160 +++ lidar_pipeline/web/map.js | 1237 +++++++++++++++++++++ setup.py | 1 + 6 files changed, 1649 insertions(+), 1606 deletions(-) create mode 100644 lidar_pipeline/tests/test_mapui.py create mode 100644 lidar_pipeline/web/map.css create mode 100644 lidar_pipeline/web/map.html create mode 100644 lidar_pipeline/web/map.js diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py index 01aa188..94b3087 100644 --- a/lidar_pipeline/mapui.py +++ b/lidar_pipeline/mapui.py @@ -1,9 +1,9 @@ """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. +Le HTML, le CSS et le JS vivent dans `web/` (vrais fichiers, relus à l'import) ; +`write_map_assets()` les bâche 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 @@ -11,6 +11,18 @@ from pathlib import Path ASSETS_DIRNAME = "mapserve_assets" +WEB_DIR = Path(__file__).resolve().parent / "web" + + +def _read_web(name): + """Fichier de l'interface (HTML/CSS/JS), lu une fois à l'import.""" + return (WEB_DIR / name).read_text(encoding="utf-8") + + +_MAP_HTML = _read_web("map.html") +_MAP_CSS = _read_web("map.css") +_MAP_JS = _read_web("map.js") + def ui_version(): """Hash court du CSS+JS : sert d'URL de version pour les assets.""" @@ -38,1605 +50,3 @@ def render_html(): return _MAP_HTML.replace("__ASSETS_V__", ui_version()) -_MAP_HTML = """ - - - - -Carte LiDAR - - - - - -
- -
-
- - Couches - -
-
-
- - - Fond OpenStreetMap - -
- -
-
-
- Affichage - - -
- -
- - -
-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - -""" - - -_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); } -#printcard { right: 60px; bottom: 12px; width: 340px; max-width: calc(100vw - 84px); } -#printTitle { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text); - border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; } -.print-hint { margin: 4px 0; font-size: 12px; opacity: 0.75; } -.print-msg { font-size: 12px; margin-left: 8px; } -.print-handle { display: flex; align-items: center; justify-content: center; cursor: move; - font-size: 20px; color: #1b1b1b; background: #ffd166; border: 2px solid #1b1b1b; - border-radius: 50%; box-shadow: 0 1px 6px rgba(0,0,0,0.5); } -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); } - #printcard { width: calc(100vw - 84px); } -} -""" - - -_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 = ''; -const SVG_EYE_OFF = ''; - -// 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: '© OpenStreetMap' -}).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 => '').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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 '
' + rows.filter(r => r[1] != null && r[1] !== '') - .map(r => '
' + r[0] + '
' + r[1] + '
').join('') + '
'; -} - -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 '
Chargement des informations IGN…
'; - if (ign.error) return '
Catalogue IGN injoignable pour le moment.
'; - if (!ign.found) return '
Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).
'; - 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('
')], - ['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 - ? '⤓ Télécharger le nuage LiDAR IGN (.copc.laz)' - : '') + (ign.stac_url - ? '' : ''); -} - -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 '

Recalage vertical des passes

' + kv([ - ['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'], - ['Décalages', offs.length ? offs.join('
') : 'aucun ≥ 0,5 cm'], - ['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null], - ]) + '
'; -} - -function renderTileCard(data, ign) { - const regenBtn = (GEN.available && GEN.allowed) - ? '
' - : ''; - el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row; - const bb = data.l93_bbox || []; - let html = '
' + esc(data.name) + '
' + - '

Emprise

' + kv([ - ['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') + - '
Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null], - ['Surface', '1 km × 1 km'], - ]) + '
' + - '

Acquisition LiDAR (IGN)

' + ignSection(ign) + '
'; - 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 += '

Rendu

' + kv([ - ['Résolution', String(data.resolution).replace('.', ',') + ' m/px'], - ['Classification', esc(data.method || 'inconnue')], - ['Généré le', esc(data.generated || '—')], - ['Couches', esc(viz || '—')], - ]) + '
' + calageSection(data.calage); - } else { - html += '
Pas encore de rendu sur cette dalle.
'; - } - 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 = '
Serveur injoignable : réessayez dans un instant.
'; - 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 => - '').join(''); - el('useBody').innerHTML = - '
' + - '
' + - '
URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' + - '
' + - '
TileJSON :
' + - '' + - '
JOSM (toutes les couches) : ' + origin + '/tiles/josm.imagery.xml
' + - '
Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
'; - 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 => - '').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))); - -// --- export PDF (planche d'impression terrain) --- -// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte -// se déplace librement autour, on le fait glisser à la souris ou au doigt. -// Géométrie exacte calculée par le serveur (projection Lambert 93). -const PRINT_LS = 'lidar-print'; -const PRINT = { on: false, rect: null, handle: null, seq: 0, center: null, corners: null, drag: null }; -map.createPane('printFrame').style.zIndex = '690'; - -function printSettings() { - return { paper: el('printPaper').value, orient: el('printOrient').value, - scale: el('printScale').value, title: el('printTitle').value.trim() }; -} - -function printQuery(s, withTitle) { - const c = PRINT.center || map.getCenter(); - const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), - paper: s.paper, orient: s.orient, scale: s.scale }); - if (withTitle && s.title) q.set('title', s.title); - return q.toString(); -} - -function printSave() { - const s = printSettings(); - if (PRINT.center) s.center = [PRINT.center.lat, PRINT.center.lng]; - try { localStorage.setItem(PRINT_LS, JSON.stringify(s)); } catch (e) { /* privé */ } -} - -function printLoad() { - try { - const s = JSON.parse(localStorage.getItem(PRINT_LS) || '{}'); - for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'], - ['scale', 'printScale'], ['title', 'printTitle']]) { - if (s[k] != null) el(id).value = s[k]; - } - if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center); - } catch (e) { /* privé ou illisible : réglages par défaut */ } -} - -// Recadre sur le cadre en laissant libre la bande basse couverte par les -// cartes (couches, export…) : sinon le bas du cadre passe dessous. -function printFit() { - if (!PRINT.corners) return; - const box = map.getContainer().getBoundingClientRect(); - let bottom = 0; - for (const c of document.querySelectorAll('.card')) { - if (c.hidden || !c.offsetParent) continue; - const r = c.getBoundingClientRect(); - if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top); - } - map.fitBounds(L.latLngBounds(PRINT.corners), - { paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] }); -} - -function printDraw(corners) { - PRINT.corners = corners; - if (PRINT.rect) PRINT.rect.setLatLngs(corners); - else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: '#ffd166', weight: 2, - dashArray: '8 5', fill: false, interactive: false }).addTo(map); - if (PRINT.handle) { PRINT.handle.setLatLng(PRINT.center); return; } - PRINT.handle = L.marker(PRINT.center, { - pane: 'printFrame', draggable: true, autoPan: true, keyboard: false, - title: 'Faire glisser pour déplacer la zone imprimée', - icon: L.divIcon({ className: 'print-handle', html: '✥', iconSize: [34, 34] }), - }).addTo(map); - PRINT.handle.on('dragstart', printDragStart); - PRINT.handle.on('drag', printDragMove); - PRINT.handle.on('dragend', printDragEnd); -} - -// fit : recadrer la vue sur le cadre une fois la géométrie reçue. -function printRefreshFrame(fit) { - if (!PRINT.on) return; - const seq = ++PRINT.seq; - fetch('api/export/frame?' + printQuery(printSettings(), false)) - .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))) - .then(d => { - if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return; - printDraw(d.corners); - el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m'; - if (fit === true) printFit(); - }) - .catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; }); -} - -// Glisser la poignée : le cadre suit en direct (translation en pixels de calque, -// stables pendant le défilement automatique), -// géométrie exacte redemandée au serveur au relâchement. -function printDragStart() { - PRINT.drag = { start: map.latLngToLayerPoint(PRINT.handle.getLatLng()), - corners: PRINT.corners.map(c => map.latLngToLayerPoint(c)) }; -} - -function printDragMove() { - const d = PRINT.drag; - if (!d) return; - const delta = map.latLngToLayerPoint(PRINT.handle.getLatLng()).subtract(d.start); - PRINT.rect.setLatLngs(d.corners.map(p => map.layerPointToLatLng(p.add(delta)))); -} - -function printDragEnd() { - PRINT.drag = null; - PRINT.center = PRINT.handle.getLatLng(); - PRINT.corners = PRINT.rect.getLatLngs()[0]; - printSave(); - printRefreshFrame(); -} - -function printCenterHere() { - PRINT.center = map.getCenter(); - printSave(); - printRefreshFrame(); -} - -function printSetOpen(on) { - PRINT.on = on; - el('printcard').hidden = !on; - el('btnPrint').classList.toggle('accent', on); - if (on) { - // cadre gardé s'il est encore dans la vue, sinon posé au centre - if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter(); - printRefreshFrame(true); - } else { - if (PRINT.rect) PRINT.rect.remove(); - if (PRINT.handle) PRINT.handle.remove(); - PRINT.rect = PRINT.handle = PRINT.corners = null; - } -} - -function printExport() { - const btn = el('printGo'); - btn.disabled = true; - el('printMsg').textContent = 'Génération du PDF…'; - fetch('api/export/pdf?' + printQuery(printSettings(), true)) - .then(r => { - if (!r.ok) { - return r.json().catch(() => ({})).then(d => { - throw new Error((d && d.detail) || ('HTTP ' + r.status)); - }); - } - const m = /filename="([^"]+)"/.exec(r.headers.get('Content-Disposition') || ''); - return r.blob().then(b => ({ b: b, name: m ? m[1] : 'relief.pdf' })); - }) - .then(({ b, name }) => { - const a = document.createElement('a'); - a.href = URL.createObjectURL(b); - a.download = name; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(a.href), 10000); - el('printMsg').textContent = 'Téléchargé : ' + name; - }) - .catch(e => { el('printMsg').textContent = 'Échec : ' + e.message; }) - .finally(() => { btn.disabled = false; }); -} - -el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on)); -el('printClose').addEventListener('click', () => printSetOpen(false)); -el('printGo').addEventListener('click', printExport); -el('printHere').addEventListener('click', printCenterHere); -el('printFit').addEventListener('click', printFit); -for (const id of ['printPaper', 'printOrient', 'printScale']) { - el(id).addEventListener('change', () => { printSave(); printRefreshFrame(true); }); -} -el('printTitle').addEventListener('change', printSave); -printLoad(); - -// --- 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); - } -}); -""" diff --git a/lidar_pipeline/tests/test_mapui.py b/lidar_pipeline/tests/test_mapui.py new file mode 100644 index 0000000..1dd2b00 --- /dev/null +++ b/lidar_pipeline/tests/test_mapui.py @@ -0,0 +1,37 @@ +"""Chargeur de l'interface : fichiers web/ du paquet, versions, syntaxe JS.""" +import shutil +import subprocess + +import pytest + + +def test_constants_are_read_from_web_files(): + from lidar_pipeline import mapui + for name, text in (("map.html", mapui._MAP_HTML), ("map.css", mapui._MAP_CSS), + ("map.js", mapui._MAP_JS)): + assert (mapui.WEB_DIR / name).read_text(encoding="utf-8") == text + + +def test_web_files_ship_with_installed_package(): + # Image complète : paquet installé par pip (package_data) — les trois + # fichiers doivent y être, sinon l'interface est vide en production. + import lidar_pipeline + from pathlib import Path + web = Path(lidar_pipeline.__file__).resolve().parent / "web" + for name in ("map.html", "map.css", "map.js"): + assert (web / name).is_file(), name + + +def test_markers_kept(): + from lidar_pipeline import mapui + assert "__ASSETS_V__" in mapui._MAP_HTML + assert "__UI_VERSION__" in mapui._MAP_JS + assert "__ASSETS_V__" not in mapui.render_html() + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node absent") +def test_js_syntax(): + from lidar_pipeline import mapui + res = subprocess.run(["node", "--check", str(mapui.WEB_DIR / "map.js")], + capture_output=True, text=True) + assert res.returncode == 0, res.stderr diff --git a/lidar_pipeline/web/map.css b/lidar_pipeline/web/map.css new file mode 100644 index 0000000..18fdbb7 --- /dev/null +++ b/lidar_pipeline/web/map.css @@ -0,0 +1,198 @@ +/* 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); } +#printcard { right: 60px; bottom: 12px; width: 340px; max-width: calc(100vw - 84px); } +#printTitle { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text); + border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; } +.print-hint { margin: 4px 0; font-size: 12px; opacity: 0.75; } +.print-msg { font-size: 12px; margin-left: 8px; } +.print-handle { display: flex; align-items: center; justify-content: center; cursor: move; + font-size: 20px; color: #1b1b1b; background: #ffd166; border: 2px solid #1b1b1b; + border-radius: 50%; box-shadow: 0 1px 6px rgba(0,0,0,0.5); } +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); } + #printcard { width: calc(100vw - 84px); } +} diff --git a/lidar_pipeline/web/map.html b/lidar_pipeline/web/map.html new file mode 100644 index 0000000..c7b99bc --- /dev/null +++ b/lidar_pipeline/web/map.html @@ -0,0 +1,160 @@ + + + + + +Carte LiDAR + + + + + +
+ +
+
+ + Couches + +
+
+
+ + + Fond OpenStreetMap + +
+ +
+
+
+ Affichage + + +
+ +
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/lidar_pipeline/web/map.js b/lidar_pipeline/web/map.js new file mode 100644 index 0000000..6aac911 --- /dev/null +++ b/lidar_pipeline/web/map.js @@ -0,0 +1,1237 @@ +'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 = ''; +const SVG_EYE_OFF = ''; + +// 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: '© OpenStreetMap' +}).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 => '').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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 '
' + rows.filter(r => r[1] != null && r[1] !== '') + .map(r => '
' + r[0] + '
' + r[1] + '
').join('') + '
'; +} + +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 '
Chargement des informations IGN…
'; + if (ign.error) return '
Catalogue IGN injoignable pour le moment.
'; + if (!ign.found) return '
Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).
'; + 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('
')], + ['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 + ? '⤓ Télécharger le nuage LiDAR IGN (.copc.laz)' + : '') + (ign.stac_url + ? '' : ''); +} + +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 '

Recalage vertical des passes

' + kv([ + ['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'], + ['Décalages', offs.length ? offs.join('
') : 'aucun ≥ 0,5 cm'], + ['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null], + ]) + '
'; +} + +function renderTileCard(data, ign) { + const regenBtn = (GEN.available && GEN.allowed) + ? '
' + : ''; + el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row; + const bb = data.l93_bbox || []; + let html = '
' + esc(data.name) + '
' + + '

Emprise

' + kv([ + ['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') + + '
Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null], + ['Surface', '1 km × 1 km'], + ]) + '
' + + '

Acquisition LiDAR (IGN)

' + ignSection(ign) + '
'; + 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 += '

Rendu

' + kv([ + ['Résolution', String(data.resolution).replace('.', ',') + ' m/px'], + ['Classification', esc(data.method || 'inconnue')], + ['Généré le', esc(data.generated || '—')], + ['Couches', esc(viz || '—')], + ]) + '
' + calageSection(data.calage); + } else { + html += '
Pas encore de rendu sur cette dalle.
'; + } + 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 = '
Serveur injoignable : réessayez dans un instant.
'; + 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 => + '').join(''); + el('useBody').innerHTML = + '
' + + '
' + + '
URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' + + '
' + + '
TileJSON :
' + + '' + + '
JOSM (toutes les couches) : ' + origin + '/tiles/josm.imagery.xml
' + + '
Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
'; + 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 => + '').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))); + +// --- export PDF (planche d'impression terrain) --- +// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte +// se déplace librement autour, on le fait glisser à la souris ou au doigt. +// Géométrie exacte calculée par le serveur (projection Lambert 93). +const PRINT_LS = 'lidar-print'; +const PRINT = { on: false, rect: null, handle: null, seq: 0, center: null, corners: null, drag: null }; +map.createPane('printFrame').style.zIndex = '690'; + +function printSettings() { + return { paper: el('printPaper').value, orient: el('printOrient').value, + scale: el('printScale').value, title: el('printTitle').value.trim() }; +} + +function printQuery(s, withTitle) { + const c = PRINT.center || map.getCenter(); + const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), + paper: s.paper, orient: s.orient, scale: s.scale }); + if (withTitle && s.title) q.set('title', s.title); + return q.toString(); +} + +function printSave() { + const s = printSettings(); + if (PRINT.center) s.center = [PRINT.center.lat, PRINT.center.lng]; + try { localStorage.setItem(PRINT_LS, JSON.stringify(s)); } catch (e) { /* privé */ } +} + +function printLoad() { + try { + const s = JSON.parse(localStorage.getItem(PRINT_LS) || '{}'); + for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'], + ['scale', 'printScale'], ['title', 'printTitle']]) { + if (s[k] != null) el(id).value = s[k]; + } + if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center); + } catch (e) { /* privé ou illisible : réglages par défaut */ } +} + +// Recadre sur le cadre en laissant libre la bande basse couverte par les +// cartes (couches, export…) : sinon le bas du cadre passe dessous. +function printFit() { + if (!PRINT.corners) return; + const box = map.getContainer().getBoundingClientRect(); + let bottom = 0; + for (const c of document.querySelectorAll('.card')) { + if (c.hidden || !c.offsetParent) continue; + const r = c.getBoundingClientRect(); + if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top); + } + map.fitBounds(L.latLngBounds(PRINT.corners), + { paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] }); +} + +function printDraw(corners) { + PRINT.corners = corners; + if (PRINT.rect) PRINT.rect.setLatLngs(corners); + else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: '#ffd166', weight: 2, + dashArray: '8 5', fill: false, interactive: false }).addTo(map); + if (PRINT.handle) { PRINT.handle.setLatLng(PRINT.center); return; } + PRINT.handle = L.marker(PRINT.center, { + pane: 'printFrame', draggable: true, autoPan: true, keyboard: false, + title: 'Faire glisser pour déplacer la zone imprimée', + icon: L.divIcon({ className: 'print-handle', html: '✥', iconSize: [34, 34] }), + }).addTo(map); + PRINT.handle.on('dragstart', printDragStart); + PRINT.handle.on('drag', printDragMove); + PRINT.handle.on('dragend', printDragEnd); +} + +// fit : recadrer la vue sur le cadre une fois la géométrie reçue. +function printRefreshFrame(fit) { + if (!PRINT.on) return; + const seq = ++PRINT.seq; + fetch('api/export/frame?' + printQuery(printSettings(), false)) + .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))) + .then(d => { + if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return; + printDraw(d.corners); + el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m'; + if (fit === true) printFit(); + }) + .catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; }); +} + +// Glisser la poignée : le cadre suit en direct (translation en pixels de calque, +// stables pendant le défilement automatique), +// géométrie exacte redemandée au serveur au relâchement. +function printDragStart() { + PRINT.drag = { start: map.latLngToLayerPoint(PRINT.handle.getLatLng()), + corners: PRINT.corners.map(c => map.latLngToLayerPoint(c)) }; +} + +function printDragMove() { + const d = PRINT.drag; + if (!d) return; + const delta = map.latLngToLayerPoint(PRINT.handle.getLatLng()).subtract(d.start); + PRINT.rect.setLatLngs(d.corners.map(p => map.layerPointToLatLng(p.add(delta)))); +} + +function printDragEnd() { + PRINT.drag = null; + PRINT.center = PRINT.handle.getLatLng(); + PRINT.corners = PRINT.rect.getLatLngs()[0]; + printSave(); + printRefreshFrame(); +} + +function printCenterHere() { + PRINT.center = map.getCenter(); + printSave(); + printRefreshFrame(); +} + +function printSetOpen(on) { + PRINT.on = on; + el('printcard').hidden = !on; + el('btnPrint').classList.toggle('accent', on); + if (on) { + // cadre gardé s'il est encore dans la vue, sinon posé au centre + if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter(); + printRefreshFrame(true); + } else { + if (PRINT.rect) PRINT.rect.remove(); + if (PRINT.handle) PRINT.handle.remove(); + PRINT.rect = PRINT.handle = PRINT.corners = null; + } +} + +function printExport() { + const btn = el('printGo'); + btn.disabled = true; + el('printMsg').textContent = 'Génération du PDF…'; + fetch('api/export/pdf?' + printQuery(printSettings(), true)) + .then(r => { + if (!r.ok) { + return r.json().catch(() => ({})).then(d => { + throw new Error((d && d.detail) || ('HTTP ' + r.status)); + }); + } + const m = /filename="([^"]+)"/.exec(r.headers.get('Content-Disposition') || ''); + return r.blob().then(b => ({ b: b, name: m ? m[1] : 'relief.pdf' })); + }) + .then(({ b, name }) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(b); + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 10000); + el('printMsg').textContent = 'Téléchargé : ' + name; + }) + .catch(e => { el('printMsg').textContent = 'Échec : ' + e.message; }) + .finally(() => { btn.disabled = false; }); +} + +el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on)); +el('printClose').addEventListener('click', () => printSetOpen(false)); +el('printGo').addEventListener('click', printExport); +el('printHere').addEventListener('click', printCenterHere); +el('printFit').addEventListener('click', printFit); +for (const id of ['printPaper', 'printOrient', 'printScale']) { + el(id).addEventListener('change', () => { printSave(); printRefreshFrame(true); }); +} +el('printTitle').addEventListener('change', printSave); +printLoad(); + +// --- 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); + } +}); diff --git a/setup.py b/setup.py index 147d722..f162c09 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,7 @@ setup( description='Pipeline LiDAR pour détection archéologique', packages=find_packages(), package_data={'lidar_pipeline': [ + 'web/*', 'assets/vendor/*', 'assets/vendor/leaflet/*', 'assets/vendor/leaflet/images/*',