Porter la génération de tuiles dans lidar-maps et retirer l'ancienne webapp

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

View File

@ -101,7 +101,50 @@ _MAP_HTML = """<!DOCTYPE html>
<div class="card-body" id="useBody"></div>
</div>
<div id="gencard" class="card" hidden>
<div class="card-head">
<span class="card-title">Génération</span>
<span id="genBadge" class="card-stat"></span>
<button id="genClose" class="icon-btn" title="Fermer">×</button>
</div>
<div class="card-body">
<div id="genMsg" class="meta">Dessinez une zone (+) ou complétez les dalles existantes (⤒).</div>
<div id="genOpts">
<div class="gen-row"><span class="gen-lbl">Couches</span>
<select id="genViz" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
<div class="gen-row"><span class="gen-lbl">Classification</span>
<select id="genClass" title="Méthode de classification du sol">
<option value="ign">IGN (pré-classée)</option>
<option value="auto">Auto</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select></div>
<div class="gen-row"><span class="gen-lbl">Classes IGN</span>
<input id="genIgnClasses" class="gen-input" value="sol"
title="Classes LAS du MNT IGN : sol(2), unclassified(1), eau(9), virtuel(66), pont(17), sursol(64) — séparées par des virgules"></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<label class="chk"><input type="checkbox" id="genReclass"> Reclasser le sol (même méthode)</label>
<label class="chk"><input type="checkbox" id="genEdge"> Raccord des bords (bande 100 m)</label>
</div>
<div id="genClassInfo" class="meta"></div>
<div class="card-foot" id="genActions">
<button id="genGo" class="primary" title="Télécharge (géoplateforme IGN) puis traite les dalles sélectionnées">Générer</button>
<button id="genClear" title="Vider la sélection de dalles">Effacer la sélection</button>
</div>
<div id="genRun" hidden>
<div class="meta" id="genProgress"></div>
<pre id="genLog" class="gen-log"></pre>
<div class="card-foot">
<button id="genStop" title="SIGTERM au pipeline (nettoyage propre), SIGKILL en repli">Arrêter</button>
<button id="genQueueClear" title="Retirer les demandes en attente (le run en cours continue)">Vider la file</button>
</div>
</div>
</div>
</div>
<nav id="tools">
<button id="btnZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+</button>
<button id="btnComplete" title="Compléter : traiter toutes les dalles déjà téléchargées qui manquent des couches choisies" hidden>⤒</button>
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomFit" title="Recadrer sur les données">⤢</button>
@ -238,9 +281,41 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
.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); }
}
"""
@ -449,15 +524,18 @@ 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).
const stack = map.createPane('lidarStack');
// 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 pane = 'lidar-' + info.key;
map.createPane(pane, stack);
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: pane,
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
@ -664,11 +742,17 @@ function renderBaseRow() {
}
// --- fiche dalle ----------------------------------------------------------
function showTileInfo(data) {
function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ Générer cette dalle</button></div>'
: '';
const card = el('tilecard');
if (!data || !data.found) {
el('tileTitle').textContent = 'Aucune dalle ici';
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>';
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false;
return;
}
@ -682,7 +766,9 @@ function showTileInfo(data) {
'<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' +
' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' +
(data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') +
'<div class="meta">Couches : ' + (viz || '—') + '</div>';
'<div class="meta">Couches : ' + (viz || '—') + '</div>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false;
}
@ -720,6 +806,288 @@ function renderUseCard() {
el('usecard').hidden = false;
}
// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
// Portée de la webapp historique : dessin de zone (+), passe « tout
// compléter » (⤒), sélection cumulée (clic fiche dalle), options de run,
// file d'attente et progression dalle par dalle (cadres sur la carte).
const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
const genSel = new Set(); // clés "col,row" des dalles retenues
const genCorners = new Map(); // clé → coins GPS [[lat, lon] × 4]
const genPolys = new Map(); // clé → polygone de surlignage
const genFrames = new Map(); // nom → cadre coloré de dalle en cours
let genDrawing = false, genDrawStart = null, genDrawRect = null;
let genIgnoreClickUntil = 0;
let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670]]) {
map.createPane(pane).style.zIndex = String(z);
}
function genKey(c, r) { return c + ',' + r; }
function genApi(method, url, body) {
return fetch(url, {
method: method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
}).then(r => {
if (!r.ok) {
return r.json().catch(() => ({})).then(d => {
throw new Error((d && d.detail) || ('HTTP ' + r.status));
});
}
return r.json();
});
}
function genOpenCard(msg) {
if (msg !== undefined) el('genMsg').textContent = msg;
el('gencard').hidden = false;
}
function genFillViz() {
el('genViz').innerHTML = META.layers.map(l =>
'<option value="' + (l.step || l.key) + '">' + l.label + '</option>').join('');
}
function genSelectedViz() {
return Array.from(el('genViz').selectedOptions).map(o => o.value);
}
function genRefreshSel() {
for (const poly of genPolys.values()) poly.remove();
genPolys.clear();
for (const [key, corners] of genCorners.entries()) {
if (!genSel.has(key) || !corners || corners.length < 4) continue;
const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
}).addTo(map);
poly.on('click', (e) => {
L.DomEvent.stopPropagation(e);
genRemoveCell(key);
toast('Dalle retirée de la sélection');
});
genPolys.set(key, poly);
}
el('genGo').disabled = !genSel.size;
if (genSel.size) {
el('genMsg').textContent = genSel.size + ' dalle(s) sélectionnée(s) — ' +
'cliquez un surlignage pour retirer une dalle, réglez les options puis « Générer ».';
}
}
function genRemoveCell(key) {
genSel.delete(key);
genCorners.delete(key);
genRefreshSel();
}
function genAddCells(cells) {
let added = 0;
for (const c of (cells || [])) {
const k = genKey(c.col, c.row);
if (!genSel.has(k)) { genSel.add(k); added++; }
if (c.corners) genCorners.set(k, c.corners);
}
genRefreshSel();
return added;
}
function genAddCellFromQuery() {
if (!lastTileQuery) return;
genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(d => {
genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
el('tilecard').hidden = true;
})
.catch(msg => toast('Échec : ' + msg.message, 6000));
}
function genSetDrawing(on) {
genDrawing = on;
document.body.classList.toggle('drawing', on);
el('btnZone').classList.toggle('accent', on);
if (on) map.dragging.disable(); else map.dragging.enable();
if (!on && genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
genDrawStart = null;
if (on) genOpenCard('Dessinez un rectangle sur la carte (re-cliquez + pour annuler).');
}
map.on('mousedown', (e) => {
if (!genDrawing) return;
genDrawStart = e.latlng;
if (genDrawRect) genDrawRect.remove();
genDrawRect = L.rectangle([genDrawStart, genDrawStart], {
pane: 'genDraw', color: '#4cc9f0', weight: 2, dashArray: '6 4',
fillOpacity: 0.05, interactive: false,
}).addTo(map);
});
map.on('mousemove', (e) => {
if (!genDrawing || !genDrawStart || !genDrawRect) return;
genDrawRect.setBounds(L.latLngBounds(genDrawStart, e.latlng));
});
map.on('mouseup', (e) => {
if (!genDrawing || !genDrawStart) return;
const b = L.latLngBounds(genDrawStart, e.latlng);
genDrawStart = null;
genIgnoreClickUntil = Date.now() + 400; // pas de fiche dalle après un dessin
if (Math.abs(b.getWest() - b.getEast()) < 1e-9 ||
Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
genSetDrawing(false);
if (genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
genOpenCard('Analyse de la zone…');
genApi('POST', 'api/preview', {
bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
genOpenCard((added ? added + ' nouvelle(s) dalle(s) retenue(s)'
: 'zone déjà couverte — cochez « Régénérer » pour forcer') +
(d.capped ? ' — zone plafonnée, resserrez le rectangle' : '') + '.');
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
});
function genComplete() {
genOpenCard('Analyse des dalles déjà téléchargées…');
genApi('POST', 'api/preview', {
all_missing: true,
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
genOpenCard((added || d.count || 0) + ' dalle(s) à compléter' +
(d.capped ? ' — plafonnées à 10 000 par run' : '') + '.');
if (genPolys.size) {
const bounds = L.latLngBounds([]);
genPolys.forEach(p => bounds.extend(p.getBounds()));
map.fitBounds(bounds, { padding: [40, 40] });
}
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
}
function genLaunch() {
const tiles = [...genSel].map(k => k.split(',').map(Number));
if (!tiles.length) return;
genApi('POST', 'api/generate', {
tiles: tiles,
regenerate: el('genRegen').checked,
reclassify: el('genReclass').checked,
ground_class: el('genClass').value,
ign_classes: el('genIgnClasses').value.trim() || 'sol',
edge_buffer: el('genEdge').checked,
viz: genSelectedViz(),
}).then(d => {
genSel.clear();
genCorners.clear();
genRefreshSel();
GEN.wasRunning = true;
genPollNow();
toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)')
: ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)'));
}).catch(msg => toast('Échec : ' + msg.message, 6000));
}
// Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en
// attente, rouge = échec) — redraw complet à chaque sondage, c'est peu.
function genDrawFrames(tiles) {
for (const poly of genFrames.values()) poly.remove();
genFrames.clear();
for (const t of (tiles || [])) {
if (t.state === 'done' || !t.corners || t.corners.length < 4) continue;
const cls = t.state === 'running' ? 'gen-frame-running'
: t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending';
const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), {
pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15,
interactive: false,
}).addTo(map);
genFrames.set(t.name || JSON.stringify(t.corners[0]), poly);
}
}
// Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de
// tuile neuf) et la maintenance de pyramide est relancée côté serveur.
function genAfterRun() {
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
buildLayers();
renderPanel();
renderBaseRow();
applyLayers();
}).catch(() => {});
fetch('api/map/background', { method: 'POST' }).catch(() => {});
}
function genUpdateStatus(d) {
const queued = (d.queue || []).length;
if (d.running || queued) {
el('genActions').hidden = true;
el('genOpts').hidden = true;
el('genRun').hidden = false;
const tiles = d.tiles || [];
const done = tiles.filter(t => t.state === 'done').length;
const failed = tiles.filter(t => t.state === 'failed').length;
let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)')
: 'Démarrage du run suivant…';
if (failed) txt += ' • ' + failed + ' échec(s)';
if (queued) txt += ' • ' + queued + ' demande(s) en file';
el('genProgress').textContent = txt;
el('genLog').textContent = (d.log || []).slice(-12).join('\n');
genDrawFrames(tiles);
GEN.wasRunning = true;
} else {
el('genRun').hidden = true;
el('genActions').hidden = !GEN.available;
el('genOpts').hidden = false;
genDrawFrames(null);
if (GEN.wasRunning) {
GEN.wasRunning = false;
toast(d.returncode === 0 ? 'Génération terminée'
: 'Génération interrompue (code ' + d.returncode + ')', 6000);
genAfterRun();
}
}
el('genBadge').textContent = d.running ? '● en cours'
: queued ? ('⏱ ' + queued) : '';
el('btnZone').classList.toggle('busy', !!d.running || !!queued);
}
function genPollNow() {
if (!GEN.available) return;
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
if (d) genUpdateStatus(d);
genPollSoon();
}).catch(() => genPollSoon());
}
function genPollSoon() {
clearTimeout(GEN.timer);
GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000);
}
el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
el('btnComplete').addEventListener('click', genComplete);
el('genClose').addEventListener('click', () => {
el('gencard').hidden = true;
if (genDrawing) genSetDrawing(false);
});
el('genClear').addEventListener('click', () => {
genSel.clear();
genCorners.clear();
genRefreshSel();
genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).');
});
el('genGo').addEventListener('click', genLaunch);
el('genStop').addEventListener('click', () =>
genApi('POST', 'api/stop')
.then(() => toast('Arrêt demandé'))
.catch(msg => toast('Échec : ' + msg.message, 6000)));
el('genQueueClear').addEventListener('click', () =>
genApi('POST', 'api/queue/clear')
.then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
.catch(msg => toast('Échec : ' + msg.message, 6000)));
// --- démarrage ------------------------------------------------------------
function fitData() {
if (META.bounds) {
@ -746,6 +1114,19 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
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;
@ -766,8 +1147,10 @@ window.addEventListener('hashchange', () => {
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(showTileInfo).catch(() => {});
.then(r => r.ok ? r.json() : null)
.then(d => showTileInfo(d, e.latlng)).catch(() => {});
});
el('cardToggle').addEventListener('click', () =>
@ -851,6 +1234,9 @@ el('gps').addEventListener('click', () => {
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { el('tilecard').hidden = true; el('usecard').hidden = true; }
if (e.key === 'Escape') {
el('tilecard').hidden = true; el('usecard').hidden = true;
if (genDrawing) genSetDrawing(false);
}
});
"""