"""Interface de la carte à tuiles XYZ (image lidar-maps). Même convention que `index.py` : le CSS et le JS vivent dans des constantes, sont écrits par `write_map_assets()` et bâchés dans l'image au build. La page est une coquille minimale : tout l'état vient de `/api/map/meta`, les couches sont des `L.tileLayer` standard — le navigateur gère LOD, cache et animation. """ import hashlib from pathlib import Path ASSETS_DIRNAME = "mapserve_assets" def ui_version(): """Hash court du CSS+JS : sert d'URL de version pour les assets.""" return hashlib.sha1((_MAP_CSS + _MAP_JS).encode("utf-8")).hexdigest()[:10] def write_map_assets(assets_dir): """Écrit app.css / app.js (+ Leaflet vendorisé) dans `assets_dir`.""" import shutil assets_dir = Path(assets_dir) assets_dir.mkdir(parents=True, exist_ok=True) (assets_dir / "app.css").write_text(_MAP_CSS, encoding="utf-8") (assets_dir / "app.js").write_text( _MAP_JS.replace("__UI_VERSION__", ui_version()), encoding="utf-8") src_vendor = Path(__file__).parent / "assets" / "vendor" dst_vendor = assets_dir / "vendor" if src_vendor.is_dir(): shutil.rmtree(dst_vendor, ignore_errors=True) shutil.copytree(src_vendor, dst_vendor) return assets_dir def render_html(): """Coquille HTML (données chargées par /api/map/meta).""" return _MAP_HTML.replace("__ASSETS_V__", ui_version()) _MAP_HTML = """ Carte LiDAR
Couches
Fond OpenStreetMap
Haut de pile — devant
Bas de pile — derrière
Fusion de la pile sur le fond
""" _MAP_CSS = """/* Carte LiDAR — tuiles XYZ, habillage minimal type carte grand public. */ :root { color-scheme: dark; --bg: #0b0e13; --card: rgba(16, 20, 27, 0.94); --border: rgba(255, 255, 255, 0.10); --text: #d9dee6; --muted: #8b94a3; --accent: #5aa9e6; --radius: 12px; } * { box-sizing: border-box; margin: 0; padding: 0; } [hidden] { display: none !important; } html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } #map { position: absolute; inset: 0; background: var(--bg); } .leaflet-container { background: var(--bg); font: inherit; } /* Fond OSM assombri pour laisser ressortir les rendus LiDAR. */ .leaflet-tile-pane .leaflet-layer.base-dark .leaflet-tile { filter: brightness(0.38) contrast(1.1) saturate(0.7); } .card { position: fixed; z-index: 900; background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } #layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); } #tilecard { left: 12px; top: 12px; width: 320px; max-width: calc(100vw - 24px); } #usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); } .card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); } .card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } .card-stat { margin-left: auto; font-size: 11px; color: var(--muted); } .card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; } #layercard.collapsed .card-body { display: none; } #layercard.collapsed #cardToggle { transform: rotate(-90deg); } .card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); } button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; } button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); } .icon-btn { border: none; background: none; color: var(--muted); padding: 2px 6px; font-size: 14px; } .icon-btn:hover { color: var(--text); background: none; } .link-btn { flex: 1; font-size: 11.5px; } /* Une couche tient sur deux lignes : identité (poignée, œil, nom, ordre) puis réglages (fusion + opacité). Les boutons ▲▼ doublent le glisser-déposer — seuls utilisables au doigt. */ .layer-row { display: grid; grid-template-columns: 14px 24px minmax(0, 1fr) auto 34px; align-items: center; gap: 6px; padding: 5px 4px; border-radius: 8px; user-select: none; } .layer-ctl { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 84px; gap: 6px; align-items: center; padding: 2px 0 2px 38px; } .layer-move { display: inline-flex; gap: 2px; } .layer-move button { padding: 0 5px; font-size: 10px; line-height: 16px; color: var(--muted); background: rgba(255, 255, 255, 0.05); } .layer-move button:hover:not(:disabled) { color: var(--text); } .layer-move button:disabled { opacity: 0.25; cursor: default; } .blend { background: rgba(255, 255, 255, 0.06); color: var(--muted); border: 1px solid var(--border); border-radius: 6px; padding: 2px 4px; font: inherit; font-size: 11px; min-width: 0; width: 100%; height: 22px; } .blend:hover { color: var(--text); } .blend option { background-color: #10141b; color: var(--text); } .layer-row:hover { background: rgba(255, 255, 255, 0.05); } /* Réorganisation : la destination doit être lisible AVANT de lâcher. Une barre d'insertion épaisse marque le point de chute, la ligne déplacée s'estompe, et à l'arrivée la couche clignote pour que l'œil la retrouve. */ .layer-row.dragging { opacity: 0.35; outline: 1px dashed var(--accent); } .layer-row.drop-before { box-shadow: inset 0 3px 0 0 var(--accent); } .layer-row.drop-after { box-shadow: inset 0 -3px 0 0 var(--accent); } .layer-row.drop-before::before, .layer-row.drop-after::before { content: '▸'; position: absolute; margin-left: -12px; color: var(--accent); font-size: 11px; line-height: 1; } .layer-row.drop-before::before { margin-top: -14px; } .layer-row.drop-after::before { margin-top: 14px; } .layer-row { position: relative; } .layer-row.just-moved { animation: moved 1s ease-out; } @keyframes moved { 0% { background: rgba(90, 169, 230, 0.38); } 100% { background: transparent; } } body.reordering, body.reordering * { cursor: grabbing !important; } .stack-edge { display: flex; align-items: center; gap: 6px; font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); padding: 2px 6px; } .stack-edge::after { content: ''; flex: 1; height: 1px; background: var(--border); } .grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; } .grip::before { content: '⠿'; } #baseRow .grip::before { content: ''; } .eye { width: 24px; height: 24px; padding: 0; border: none; background: none; color: var(--muted); display: flex; align-items: center; justify-content: center; } .eye.on { color: var(--accent); } .eye svg { width: 16px; height: 16px; } .layer-name { font-size: 12.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .layer-row.on .layer-name { color: var(--text); } .pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; background: transparent; cursor: pointer; } input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; } input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); } input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; } #tools { position: fixed; right: 12px; bottom: 12px; z-index: 900; display: flex; flex-direction: column; gap: 6px; } #tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card); border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); } #gps.busy { color: var(--accent); border-color: var(--accent); } .toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; } .meta { font-size: 11.5px; color: var(--muted); margin: 2px 0; } .meta b { color: var(--text); font-weight: 600; } .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; word-break: break-all; margin: 4px 0 8px; } .use-row { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; } .use-row select { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 12px; } .use-note { font-size: 11px; color: var(--muted); line-height: 1.5; } .leaflet-control-attribution { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; } .leaflet-control-attribution a { color: var(--accent) !important; } /* --- génération de tuiles ------------------------------------------------ */ #gencard { right: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px); } body.drawing { cursor: crosshair !important; } body.drawing .leaflet-container { cursor: crosshair !important; } .gen-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px; align-items: center; margin: 4px 0; font-size: 11.5px; color: var(--muted); } #genViz { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 3px 4px; font: inherit; font-size: 11.5px; } #genViz option { background-color: #10141b; } #genClass, .gen-input { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 11.5px; width: 100%; height: 24px; } .chk { display: flex; gap: 7px; align-items: center; font-size: 11.5px; color: var(--muted); margin: 4px 0; cursor: pointer; } .chk input { accent-color: var(--accent); } button.primary { background: var(--accent); border-color: var(--accent); color: #0b0e13; font-weight: 600; } button.primary:hover { filter: brightness(1.12); } .gen-log { font: 10.5px/1.5 ui-monospace, "SF Mono", Menlo, monospace; color: var(--muted); background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; max-height: 130px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin: 6px 0; } #tools button.accent { color: var(--accent); border-color: var(--accent); } #tools button.busy { color: var(--accent); border-color: var(--accent); } /* Cadres des dalles du run : orange pulsant = rendu en cours, bleu = en attente, rouge = échec. Les dalles faites n'ont pas de cadre. */ @keyframes genpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } .gen-frame-running { stroke: #f4a261; fill: #f4a26122; animation: genpulse 1.4s ease-in-out infinite; } .gen-frame-pending { stroke: #4cc9f0; fill: #4cc9f014; stroke-dasharray: 6 4; } .gen-frame-failed { stroke: #e76f51; fill: #e76f5133; } .gen-sel { stroke: var(--accent); fill: #5aa9e61f; cursor: pointer; } @media (max-width: 720px) { #layercard { width: calc(100vw - 24px); } #tilecard { width: calc(100vw - 24px); } #gencard { width: calc(100vw - 24px); } } """ _MAP_JS = r"""'use strict'; /* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation. Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier d'image, aucune rotation CSS, aucune mosaïque maison. */ const UI_VERSION = "__UI_VERSION__"; const LS_KEY = 'lidarMapLayers_v1'; const el = (id) => document.getElementById(id); const SVG_EYE = ''; const SVG_EYE_OFF = ''; // Modes de fusion proposés (CSS mix-blend-mode) : la superposition de couches // ne se réduit pas à de la transparence — « Produit » assombrit le relief sans // délaver la rampe du dessous, « Écran » éclaircit, « Superposé »/« Doux » // creusent le contraste local, « Différence » fait ressortir les écarts. const BLEND_MODES = [ ['normal', 'Normal'], ['multiply', 'Produit'], ['screen', 'Écran'], ['overlay', 'Superposé'], ['soft-light', 'Doux'], ['hard-light', 'Lumière crue'], ['difference', 'Différence'], ['luminosity', 'Luminosité'], ]; const BLEND_KEYS = BLEND_MODES.map(m => m[0]); const validBlend = (v) => (BLEND_KEYS.includes(v) ? v : null); const blendOptions = (current) => BLEND_MODES.map( m => '').join(''); let META = null; let STATE = null; const LAYERS = new Map(); // clé → L.TileLayer let dragKey = null; const map = L.map('map', { zoomControl: false, attributionControl: true, minZoom: 3, maxZoom: 22, zoomSnap: 0.5, wheelPxPerZoomLevel: 90, worldCopyJump: false, preferCanvas: false }); map.attributionControl.setPrefix(''); L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map); const base = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 22, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark', attribution: '© 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 defaultState(meta) { // `default_order` n'existe que si une configuration a été figée depuis la // carte (POST /api/map/defaults) ; sinon on part du registre du pipeline. const order = (Array.isArray(meta.default_order) && meta.default_order.length) ? meta.default_order.filter(k => meta.layers.some(l => l.key === k)) : meta.layers.map(l => l.key); for (const l of meta.layers) if (!order.includes(l.key)) order.push(l.key); const on = {}, opacity = {}, blend = {}; const wanted = (meta.default_layers || []).filter(k => order.includes(k)); // Ordre figé depuis la carte : respecté tel quel. Sinon, les couches // allumées par défaut passent en bas de pile (bas → haut). const stacked = (Array.isArray(meta.default_order) && meta.default_order.length) ? order : wanted.concat(order.filter(k => !wanted.includes(k))); for (const k of stacked) { on[k] = wanted.includes(k); const o = Number((meta.default_opacity || {})[k]); opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : 1; blend[k] = (meta.default_blend || {})[k] || 'normal'; } const b = meta.default_base || {}; return { order: stacked, on, opacity, blend, base: { on: b.on !== undefined ? !!b.on : true, opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85, dark: b.dark !== undefined ? !!b.dark : true }, stackBlend: validBlend(meta.default_stack_blend) || 'normal', }; } function loadState(meta) { const def = defaultState(meta); let st = def; try { const raw = localStorage.getItem(LS_KEY); if (raw) { const s = JSON.parse(raw); const order = (Array.isArray(s.order) ? s.order : []).filter(k => def.order.includes(k)); for (const k of def.order) if (!order.includes(k)) order.push(k); const on = {}, opacity = {}, blend = {}; for (const k of order) { on[k] = !!(s.on && s.on[k]); const o = Number(s.opacity ? s.opacity[k] : NaN); opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : def.opacity[k]; blend[k] = (s.blend && s.blend[k]) || def.blend[k] || 'normal'; } st = { order, on, opacity, blend, base: Object.assign({}, def.base, s.base || {}), stackBlend: validBlend(s.stackBlend) || 'normal' }; } } catch (e) { st = def; } const hash = parseHash(); if (hash && hash.layers) { const order = hash.layers.order.filter(k => def.order.includes(k)); for (const k of def.order) if (!order.includes(k)) order.push(k); st = { order, on: hash.layers.on, opacity: hash.layers.opacity, blend: Object.assign({}, st.blend, hash.layers.blend || {}), base: hash.base || st.base, stackBlend: hash.stackBlend || st.stackBlend || 'normal' }; for (const k of order) { if (st.on[k] === undefined) st.on[k] = false; if (st.opacity[k] === undefined) st.opacity[k] = 1; if (!st.blend[k]) st.blend[k] = def.blend[k] || 'normal'; } } return st; } function saveState() { try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ } } // --- hash de vue ---------------------------------------------------------- // #z/lat/lng&L=clé:opacité:visible,...&B=on:opacité:sombre function parseHash() { const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || ''); if (!m) return null; const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]), layers: null, base: null }; const seg = {}; for (const part of m[4].split('&')) { const i = part.indexOf('='); if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1); } if (seg.L) { // clé:opacité:visible[:fusion] — le 4e champ est optionnel (liens anciens) const order = [], on = {}, opacity = {}, blend = {}; for (const item of seg.L.split(',')) { const f = item.split(':'); if (f.length < 3) continue; order.push(f[0]); on[f[0]] = f[2] === '1'; const o = Number(f[1]); opacity[f[0]] = Number.isFinite(o) ? Math.min(100, Math.max(0, o)) / 100 : 1; const b = validBlend(f[3]); if (b) blend[f[0]] = b; } if (order.length) out.layers = { order, on, opacity, blend }; } if (seg.S) out.stackBlend = validBlend(seg.S) || 'normal'; if (seg.B) { const f = seg.B.split(':'); if (f.length === 3) { const o = Number(f[1]); out.base = { on: f[0] === '1', opacity: Number.isFinite(o) ? o / 100 : 0.85, dark: f[2] === '1' }; } } return out; } function viewHash(full) { const c = map.getCenter(); let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6); if (full) { h += '&L=' + STATE.order.map(k => k + ':' + Math.round(STATE.opacity[k] * 100) + ':' + (STATE.on[k] ? '1' : '0') + ':' + (STATE.blend[k] || 'normal')).join(','); h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100) + ':' + (STATE.base.dark ? '1' : '0'); h += '&S=' + (STATE.stackBlend || 'normal'); } return h; } let hashTimer = null; function saveHashSoon() { clearTimeout(hashTimer); hashTimer = setTimeout(() => { history.replaceState(null, '', location.pathname + location.search + viewHash(false)); }, 350); } // --- couches -------------------------------------------------------------- function tileUrl(key) { return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : ''); } function buildLayers() { // Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la // pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers // les zones sans donnée. La fusion de la pile ENTIÈRE sur le fond reste // réglable à part (STATE.stackBlend). Les panneaux sont réutilisés si déjà // créés (rafraîchissement après un run de génération). const stack = map.getPane('lidarStack') || map.createPane('lidarStack'); stack.style.zIndex = 400; stack.style.isolation = 'isolate'; META.layers.forEach((info) => { const paneName = 'lidar-' + info.key; const pane = map.getPane(paneName) || map.createPane(paneName, stack); const existing = LAYERS.get(info.key); if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); } const layer = L.tileLayer(tileUrl(info.key), { pane: paneName, // Emprise des dalles : Leaflet ne demande aucune tuile en dehors — // pas de requêtes inutiles sur le reste du monde. bounds: META.bounds ? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]) : undefined, tileSize: META.tile_size, zoomOffset: META.zoom_offset, minNativeZoom: META.min_zoom, maxNativeZoom: META.max_native_zoom, minZoom: 0, maxZoom: 22, keepBuffer: 2, updateWhenZooming: false, crossOrigin: false, errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', attribution: META.attribution }); LAYERS.set(info.key, layer); }); applyLayers(); } function applyLayers() { const stack = map.getPane('lidarStack'); if (stack) stack.style.mixBlendMode = STATE.stackBlend || 'normal'; let z = 400; for (const key of STATE.order) { const layer = LAYERS.get(key); if (!layer) continue; const pane = map.getPane('lidar-' + key); z += 1; pane.style.zIndex = String(z); pane.style.mixBlendMode = STATE.blend[key] || 'normal'; if (STATE.on[key]) { if (!map.hasLayer(layer)) layer.addTo(map); layer.setOpacity(STATE.opacity[key]); } else if (map.hasLayer(layer)) { map.removeLayer(layer); } } if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); } else if (map.hasLayer(base)) map.removeLayer(base); base.setOpacity(STATE.base.opacity); const container = base.getContainer && base.getContainer(); if (container) container.classList.toggle('base-dark', STATE.base.dark); } function moveLayer(key, delta) { // STATE.order est bas → haut ; le panneau affiche haut en premier. const ord = STATE.order; const i = ord.indexOf(key); const j = i + delta; if (i < 0 || j < 0 || j >= ord.length) return; ord.splice(j, 0, ord.splice(i, 1)[0]); saveState(); renderPanel(key); applyLayers(); } // Insère `src` avant ou après `target` dans l'ordre AFFICHÉ (haut → bas). function reorderRelative(src, target, before) { const top = STATE.order.slice().reverse(); const from = top.indexOf(src); if (from < 0) return; top.splice(from, 1); let at = top.indexOf(target); if (at < 0) return; if (!before) at += 1; top.splice(at, 0, src); STATE.order = top.reverse(); saveState(); renderPanel(src); applyLayers(); } function clearDropMarks() { document.querySelectorAll('.layer-row.drop-before, .layer-row.drop-after') .forEach(r => r.classList.remove('drop-before', 'drop-after')); } function renderPanel(movedKey) { const host = el('layers'); host.innerHTML = ''; const labels = new Map(META.layers.map(l => [l.key, l])); // Haut de pile en premier (STATE.order est bas → haut). const topFirst = STATE.order.slice().reverse(); topFirst.forEach((key, idx) => { const info = labels.get(key); if (!info) return; const on = !!STATE.on[key]; const row = document.createElement('div'); row.className = 'layer-row' + (on ? ' on' : '') + (key === movedKey ? ' just-moved' : ''); row.dataset.layer = key; row.title = info.description || info.label; row.innerHTML = '' + '' + '' + info.label + '' + '' + '' + '' + '' + '' + Math.round(STATE.opacity[key] * 100) + '%' + (on ? '
' + '' + '' + '
' : ''); row.draggable = false; row.addEventListener('mousedown', (e) => { row.draggable = !!(e.target.closest && e.target.closest('.grip')); }); row.querySelector('.eye').addEventListener('click', () => { STATE.on[key] = !STATE.on[key]; if (STATE.on[key] && STATE.opacity[key] === 0) STATE.opacity[key] = 1; saveState(); renderPanel(); applyLayers(); }); row.querySelector('.up').addEventListener('click', () => moveLayer(key, 1)); row.querySelector('.down').addEventListener('click', () => moveLayer(key, -1)); const blend = row.querySelector('.blend'); if (blend) blend.addEventListener('change', () => { STATE.blend[key] = blend.value; saveState(); const pane = map.getPane('lidar-' + key); if (pane) pane.style.mixBlendMode = blend.value; }); const range = row.querySelector('input[type="range"]'); const pct = row.querySelector('.pct'); if (range) { range.addEventListener('input', () => { STATE.opacity[key] = Number(range.value) / 100; pct.textContent = range.value + '%'; const layer = LAYERS.get(key); if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.opacity[key]); }); range.addEventListener('change', saveState); } row.addEventListener('dragstart', (e) => { dragKey = key; row.classList.add('dragging'); document.body.classList.add('reordering'); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', key); // exigé par Firefox } }); row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; document.body.classList.remove('reordering'); clearDropMarks(); }); row.addEventListener('dragover', (e) => { e.preventDefault(); if (!dragKey || dragKey === key) return; // Moitié haute survolée → insertion au-dessus, sinon en dessous. const box = row.getBoundingClientRect(); const before = (e.clientY - box.top) < box.height / 2; clearDropMarks(); row.classList.add(before ? 'drop-before' : 'drop-after'); }); row.addEventListener('dragleave', () => { row.classList.remove('drop-before', 'drop-after'); }); row.addEventListener('drop', (e) => { e.preventDefault(); if (!dragKey || dragKey === key) { clearDropMarks(); return; } const before = row.classList.contains('drop-before'); const src = dragKey; clearDropMarks(); document.body.classList.remove('reordering'); reorderRelative(src, key, before); }); host.appendChild(row); }); // La couche qui vient d'être déplacée est ramenée dans le champ de vision // (le panneau défile dès qu'il y a beaucoup de couches). if (movedKey) { const moved = host.querySelector('.layer-row.just-moved'); if (moved && moved.scrollIntoView) moved.scrollIntoView({ block: 'nearest' }); } } function renderBaseRow() { const b = STATE.base; const sel = el('stackBlend'); if (sel && !sel.options.length) { sel.innerHTML = blendOptions(STATE.stackBlend || 'normal'); sel.addEventListener('change', () => { STATE.stackBlend = sel.value; saveState(); applyLayers(); }); } else if (sel) { sel.value = STATE.stackBlend || 'normal'; } const toggle = el('baseToggle'); toggle.classList.toggle('on', b.on); toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF; el('baseOpacity').value = Math.round(b.opacity * 100); el('basePct').textContent = Math.round(b.opacity * 100) + '%'; el('baseRow').classList.toggle('on', b.on); } // --- fiche dalle ---------------------------------------------------------- function showTileInfo(data, latlng) { lastTileQuery = latlng || lastTileQuery; const regenBtn = (GEN.available && GEN.allowed) ? '
' : ''; const card = el('tilecard'); if (!data || !data.found) { el('tileTitle').textContent = 'Aucune dalle ici'; el('tileBody').innerHTML = '
Pas de rendu disponible à cet endroit.
' + regenBtn; const btn = el('tileRegen'); if (btn) btn.addEventListener('click', genAddCellFromQuery); card.hidden = false; return; } el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row; const viz = (data.layers || []).map(k => { const info = META.layers.find(l => l.key === k); return info ? info.label : k; }).join(', '); el('tileBody').innerHTML = '
' + data.name + '
' + '
Résolution : ' + String(data.resolution).replace('.', ',') + ' m/px' + ' · Classification : ' + (data.method || 'inconnue') + '
' + (data.generated ? '
Généré le ' + data.generated + '
' : '') + '
Couches : ' + (viz || '—') + '
' + regenBtn; const btn = el('tileRegen'); if (btn) btn.addEventListener('click', genAddCellFromQuery); card.hidden = false; } // --- fond d'imagerie externe (JOSM, QGIS, iD…) ---------------------------- function renderUseCard() { const origin = location.origin; const opts = META.layers.map(l => '').join(''); el('useBody').innerHTML = '
' + '
' + '
URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' + '
' + '
TileJSON :
' + '
WMTS : ' + origin + '/tiles/wmts.xml
' + '
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]]) { 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, reclassify: el('genReclass').checked, ground_class: el('genClass').value, ign_classes: el('genIgnClasses').value.trim() || 'sol', edge_buffer: el('genEdge').checked, viz: genSelectedViz(), }).then(d => { genSel.clear(); genCorners.clear(); genRefreshSel(); GEN.wasRunning = true; genPollNow(); toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)') : ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)')); }).catch(msg => toast('Échec : ' + msg.message, 6000)); } // Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en // attente, rouge = échec) — redraw complet à chaque sondage, c'est peu. function genDrawFrames(tiles) { for (const poly of genFrames.values()) poly.remove(); genFrames.clear(); for (const t of (tiles || [])) { if (t.state === 'done' || !t.corners || t.corners.length < 4) continue; const cls = t.state === 'running' ? 'gen-frame-running' : t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending'; const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), { pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15, interactive: false, }).addTo(map); genFrames.set(t.name || JSON.stringify(t.corners[0]), poly); } } // Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de // tuile neuf) et la maintenance de pyramide est relancée côté serveur. function genAfterRun() { fetch('api/map/meta').then(r => r.json()).then(meta => { META = meta; buildLayers(); renderPanel(); renderBaseRow(); applyLayers(); }).catch(() => {}); fetch('api/map/background', { method: 'POST' }).catch(() => {}); } function genUpdateStatus(d) { const queued = (d.queue || []).length; if (d.running || queued) { el('genActions').hidden = true; el('genOpts').hidden = true; el('genRun').hidden = false; const tiles = d.tiles || []; const done = tiles.filter(t => t.state === 'done').length; const failed = tiles.filter(t => t.state === 'failed').length; let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)') : 'Démarrage du run suivant…'; if (failed) txt += ' • ' + failed + ' échec(s)'; if (queued) txt += ' • ' + queued + ' demande(s) en file'; el('genProgress').textContent = txt; el('genLog').textContent = (d.log || []).slice(-12).join('\n'); genDrawFrames(tiles); GEN.wasRunning = true; } else { el('genRun').hidden = true; el('genActions').hidden = !GEN.available; el('genOpts').hidden = false; genDrawFrames(null); if (GEN.wasRunning) { GEN.wasRunning = false; toast(d.returncode === 0 ? 'Génération terminée' : 'Génération interrompue (code ' + d.returncode + ')', 6000); genAfterRun(); } } el('genBadge').textContent = d.running ? '● en cours' : queued ? ('⏱ ' + queued) : ''; el('btnZone').classList.toggle('busy', !!d.running || !!queued); } function genPollNow() { if (!GEN.available) return; fetch('api/status').then(r => r.ok ? r.json() : null).then(d => { if (d) genUpdateStatus(d); genPollSoon(); }).catch(() => genPollSoon()); } function genPollSoon() { clearTimeout(GEN.timer); GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000); } el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing)); el('btnComplete').addEventListener('click', genComplete); el('genClose').addEventListener('click', () => { el('gencard').hidden = true; if (genDrawing) genSetDrawing(false); }); el('genClear').addEventListener('click', () => { genSel.clear(); genCorners.clear(); genRefreshSel(); genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).'); }); el('genGo').addEventListener('click', genLaunch); el('genStop').addEventListener('click', () => genApi('POST', 'api/stop') .then(() => toast('Arrêt demandé')) .catch(msg => toast('Échec : ' + msg.message, 6000))); el('genQueueClear').addEventListener('click', () => genApi('POST', 'api/queue/clear') .then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)')) .catch(msg => toast('Échec : ' + msg.message, 6000))); // --- démarrage ------------------------------------------------------------ function fitData() { if (META.bounds) { map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]], { padding: [30, 30] }); } else { map.setView([46.6, 2.5], 6); } } fetch('api/map/meta').then(r => r.json()).then(meta => { META = meta; if (!META.layers.length) { toast('Aucune visualisation disponible dans le cache.', 10000); } STATE = loadState(META); // L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de // suite : ce qui est à l'écran est ce qui sera rechargé. saveState(); el('stat').textContent = META.layers.length + ' couche(s)'; const hash = parseHash(); if (hash) map.setView([hash.lat, hash.lng], hash.zoom); else fitData(); buildLayers(); renderPanel(); renderBaseRow(); genFillViz(); // Droits de génération : boutons affichés si le serveur peut générer // (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du // réseau autorisé (LIDAR_REGEN_CIDR) — sinon masqués silencieusement. fetch('api/status').then(r => r.ok ? r.json() : null).then(d => { if (!d) return; GEN.available = !!d.available; GEN.allowed = d.regen_allowed !== false; const show = GEN.available && GEN.allowed; el('btnZone').hidden = !show; el('btnComplete').hidden = !show; if (show) { genUpdateStatus(d); genPollNow(); } }).catch(() => {}); }).catch(() => toast('Serveur de tuiles injoignable.', 10000)); let applyingHash = false; window.addEventListener('hashchange', () => { // Lien partagé collé dans l'onglet courant : la vue (et l'état des couches // s'il est présent) est appliquée sans rechargement. if (applyingHash || !META) return; const h = parseHash(); if (!h) return; applyingHash = true; map.setView([h.lat, h.lng], h.zoom); if (h.layers) { STATE = loadState(META); saveState(); renderPanel(); renderBaseRow(); applyLayers(); } setTimeout(() => { applyingHash = false; }, 0); }); map.on('moveend zoomend', saveHashSoon); map.on('click', (e) => { if (genDrawing || Date.now() < genIgnoreClickUntil) return; fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng) .then(r => r.ok ? r.json() : null) .then(d => showTileInfo(d, e.latlng)).catch(() => {}); }); el('cardToggle').addEventListener('click', () => el('layercard').classList.toggle('collapsed')); el('tileClose').addEventListener('click', () => { el('tilecard').hidden = true; }); el('useClose').addEventListener('click', () => { el('usecard').hidden = true; }); el('btnUse').addEventListener('click', renderUseCard); el('btnShare').addEventListener('click', () => { const url = location.origin + location.pathname + viewHash(true); history.replaceState(null, '', url); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(() => toast('Lien copié'), () => window.prompt('Lien :', url)); } else { window.prompt('Lien :', url); } }); el('btnDefault').addEventListener('click', () => { const body = { order: STATE.order, on: STATE.order.filter(k => STATE.on[k]), opacity: Object.fromEntries(STATE.order.map(k => [k, STATE.opacity[k]])), blend: Object.fromEntries(STATE.order.map(k => [k, STATE.blend[k] || 'normal'])), base: STATE.base, stack_blend: STATE.stackBlend || 'normal', }; fetch('api/map/defaults', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status)) .then(() => { META.default_order = STATE.order.slice(); toast('Configuration enregistrée comme défaut'); }) .catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000)); }); el('btnReset').addEventListener('click', () => { // Oublie l'état local et reprend la configuration servie par le serveur. try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ } fetch('api/map/meta').then(r => r.json()).then(meta => { META = meta; STATE = defaultState(META); saveState(); renderPanel(); renderBaseRow(); applyLayers(); toast('Configuration par défaut rétablie'); }).catch(() => toast('Serveur injoignable', 6000)); }); el('baseToggle').addEventListener('click', () => { STATE.base.on = !STATE.base.on; saveState(); renderBaseRow(); applyLayers(); }); el('baseName').addEventListener('click', () => { STATE.base.dark = !STATE.base.dark; saveState(); applyLayers(); }); el('baseOpacity').addEventListener('input', (e) => { STATE.base.opacity = Number(e.target.value) / 100; el('basePct').textContent = e.target.value + '%'; base.setOpacity(STATE.base.opacity); }); el('baseOpacity').addEventListener('change', saveState); el('zoomIn').addEventListener('click', () => map.zoomIn(1)); el('zoomOut').addEventListener('click', () => map.zoomOut(1)); el('zoomFit').addEventListener('click', fitData); el('gps').addEventListener('click', () => { if (!('geolocation' in navigator)) { toast('Géolocalisation indisponible (HTTPS requis).', 6000); return; } const btn = el('gps'); btn.classList.add('busy'); navigator.geolocation.getCurrentPosition((pos) => { btn.classList.remove('busy'); const ll = [pos.coords.latitude, pos.coords.longitude]; map.setView(ll, Math.max(map.getZoom(), 16)); if (window._gpsMarker) window._gpsMarker.remove(); window._gpsMarker = L.circleMarker(ll, { radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9 }).addTo(map); }, (err) => { btn.classList.remove('busy'); const why = err && err.code === 1 ? 'Autorisation refusée' : err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible'; toast(why + ' (la géolocalisation exige HTTPS).', 6000); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { el('tilecard').hidden = true; el('usecard').hidden = true; if (genDrawing) genSetDrawing(false); } }); """