Sélection de tuiles au clic, file d'attente et rafraîchissement fiable

- Clic sur la carte pour choisir des dalles précises, même sans données
  existantes (via /api/cell) ; les zones dessinées s'ajoutent à la
  sélection au lieu de la remplacer
- Une demande lancée pendant un run part en file d'attente côté serveur
  et démarre à la fin du travail en cours (plus de refus 409), file
  vidable depuis l'interface
- Tuiles et interface toujours fraîches : images servies sans cache
  navigateur (revalidation 304), rechargement de la carte seulement une
  fois le rebuild de l'index terminé
- serve-webapp.sh : sous-commande update retirée, documentation de
  déploiement corrigée en conséquence
This commit is contained in:
Antoine Jacquin
2026-09-16 22:37:53 +02:00
parent 26c05319fd
commit f024514427
6 changed files with 636 additions and 128 deletions

View File

@ -12,8 +12,11 @@ Fonctionnalités :
réordonnancement par glisser-déposer (persisté en localStorage).
- Panneau d'infos par tuile : résolution, méthode de classification du sol
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
dessin d'un rectangle → téléchargement IGN + traitement.
- Génération via l'API web (./run.sh --serve) : dessin d'un rectangle
(téléchargement IGN + traitement) ou sélection cumulative au clic —
chaque tuile individuelle, avec ou sans données existantes ; les zones
et les clics s'additionnent, et une demande lancée pendant un run part
en file d'attente côté serveur (jamais de coupure du travail en place).
- Onglet Export : sélection de dalles sur la carte → mosaïque jointive
(image ou PDF multi-couches) téléchargeable pour consultation sur
téléphone (/api/export de la webapp, module export.py).
@ -1294,7 +1297,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<!-- Actions Zone / Sélection / Compléter : réservées à cet onglet -->
<div class="gen-actions">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnGenSel" title="Régénérer des tuiles précises : cliquer des dalles sur la carte pour les ajouter ou les retirer" hidden>↻ Sélectionner des tuiles</button>
<button id="btnGenSel" title="Sélectionner des tuiles précises, à générer ou régénérer : un clic sur la carte ajoute ou retire la dalle (les zones dessinées s'ajoutent à la sélection)" hidden>Sélectionner des tuiles</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
</div>
@ -1342,7 +1345,8 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<div class="queue-head">
<span class="panel-title">File de génération</span>
<span id="genQueueCount"></span>
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours" hidden>■</button>
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours (la file d'attente n'est pas touchée)" hidden>■</button>
<button id="genQueueDrop" class="icon-btn" title="Retirer les demandes en attente (elles démarreraient après le run en cours sinon)" hidden>⏏</button>
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div>
<div id="genQueueBody"></div>
@ -1779,6 +1783,9 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; }
.gen-task.running .gen-task-dot {
background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite;
}
.gen-task.queued .gen-task-dot {
background: var(--muted); animation: gen-pulse 2.2s ease-in-out infinite;
}
.gen-task.done .gen-task-dot { background: #39d98a; }
.gen-task.failed .gen-task-dot, .gen-task.stopped .gen-task-dot { background: var(--danger); }
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
@ -1788,6 +1795,7 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; }
}
.gen-task-state { font-size: 11px; white-space: nowrap; }
.gen-task.running .gen-task-state { color: var(--accent); }
.gen-task.queued .gen-task-state { color: var(--muted); }
.gen-task.done .gen-task-state { color: #39d98a; }
.gen-task.failed .gen-task-state, .gen-task.stopped .gen-task-state { color: var(--danger); }
.gen-task-time {
@ -2053,7 +2061,7 @@ function registerTile(t) {
}).addTo(map);
mk.on('click', () => {
if (exportMode) { toggleExportSel(t); return; }
if (genSelMode) { toggleGenSel(t); return; }
if (genSelMode) { toggleGenSelCell(t.col, t.row, t.corners); return; }
if (!genMode) showTileInfo(t);
});
const el = mk.getElement();
@ -2799,7 +2807,10 @@ function saveViewHashSoon() {
}
window.addEventListener('pagehide', () => { clearTimeout(viewHashTimer); saveViewHash(); });
map.on('moveend', () => { updateImages(); saveViewHashSoon(); });
map.on('click', hideTileInfo);
map.on('click', e => {
if (genSelMode) { pickGenSelCell(e.latlng); return; }
hideTileInfo();
});
map.whenReady(() => layoutTiles());
// ============================================================
@ -2975,12 +2986,16 @@ const genBadge = document.getElementById('genBadge');
const genStop = document.getElementById('genStop');
const genQueueBody = document.getElementById('genQueueBody');
const genQueueClear = document.getElementById('genQueueClear');
const genQueueDrop = document.getElementById('genQueueDrop');
const genQueueCount = document.getElementById('genQueueCount');
let genMode = false, drawStart = null, drawRect = null;
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
const ghostCells = [];
let genSelMode = false;
const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération
const genSel = new Set(); // clés "col,row" des dalles retenues (clics + zones cumulés)
const genSelCorners = new Map(); // clé "col,row" → coins GPS (dalles sans données)
const genSelPolys = new Map(); // clé "col,row" → polygone Leaflet de surlignage
const GEN_MAX_CELLS = 400; // garde-fou serveur : ~400 km² max par demande
// Affiche la barre de confirmation de génération, dans l'onglet Génération
// (bascule automatique sur cet onglet).
@ -3041,7 +3056,7 @@ function exitGenMode() {
genBtn.addEventListener('click', () => {
if (genMode) { exitGenMode(); return; }
clearGhosts();
clearGenSel();
pendingCompleteAll = false; // nouvelle zone : le scope global « Compléter » est levé
genBar.classList.remove('show');
genMode = true;
map.dragging.disable();
@ -3076,37 +3091,60 @@ map.on('mouseup', e => {
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
if (drawRect) { drawRect.remove(); drawRect = null; }
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = false; // zone dessinée : le scope global est levé
// La zone S'AJOUTE à la sélection en cours (zones successives, clics
// individuels) : les tuiles déjà retenues sont conservées, jamais perdues.
let added = 0;
for (const c of (d.cells || [])) {
const k = genSelKey(c.col, c.row);
if (!genSel.has(k)) { genSel.add(k); added++; }
if (c.corners) genSelCorners.set(k, c.corners);
}
refreshGenSelHighlights();
pendingTiles = genSelCells();
updateGenClassInfo();
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map)
));
if (!pendingTiles.length) {
if (!genSel.size) {
genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' +
(genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)');
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à ' +
(genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') +
(d.capped ? ' (zone tronquée à 400)' : '');
genGo.style.display = '';
genMsg.textContent = 'Zone : ' + added + ' tuile(s) ajoutée(s)' +
(d.capped ? ' (zone tronquée à 400)' : '') +
' — ' + genSel.size + ' tuile(s) sélectionnée(s) au total' +
(genSel.size > GEN_MAX_CELLS
? ' (maximum ' + GEN_MAX_CELLS + ' par lancement : retirez des tuiles)' : '');
genGo.style.display = genSel.size > GEN_MAX_CELLS ? 'none' : '';
}).catch(() => { showGenBar('API indisponible'); });
});
// « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une
// dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues
// (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de
// confirmation permet ensuite de choisir les couches et les options avant de
// lancer la régénération (--force --force-classification) sur la sélection.
// « Sélectionner des tuiles » : sélection cumulative, tuile par tuile — le
// clic sur la carte ajoute ou retire la dalle de 1 km sous le curseur, avec
// ou sans données existantes (téléchargement + génération à la validation ;
// les sous-tuiles 0,2 m comptent pour une seule dalle). Les zones dessinées
// s'ajoutent à la même sélection. La barre de confirmation permet de choisir
// les couches et les options avant de lancer (--force implicite sur une
// sélection clic par clic : retraitement ciblé des tuiles choisies).
function genSelKey(col, row) { return col + ',' + row; }
function refreshGenSelHighlights() {
const markerCells = new Set();
for (const entry of markers.values()) {
const k = genSelKey(entry.t.col, entry.t.row);
markerCells.add(k);
const div = entryDiv(entry);
if (!div) continue;
div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row)));
if (div) div.classList.toggle('genSel', genSel.has(k));
}
// Dalles sans données (aucun marqueur) : surlignage en polygone
for (const [k, poly] of genSelPolys) {
if (!genSel.has(k) || markerCells.has(k)) { poly.remove(); genSelPolys.delete(k); }
}
for (const k of genSel) {
if (markerCells.has(k) || genSelPolys.has(k)) continue;
const corners = genSelCorners.get(k);
if (!corners) continue;
genSelPolys.set(k, L.polygon(corners,
{ color: '#5aa9e6', weight: 1.5, fillOpacity: 0.15, pane: 'genDraw' }).addTo(map));
}
}
@ -3115,16 +3153,21 @@ function genSelCells() {
}
function updateGenSelBar() {
showGenBar(genSel.size
? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'
: 'Mode sélection : clic sur dalle = ajouter / retirer');
genGo.style.display = genSel.size ? '' : 'none';
showGenBar(!genSel.size
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile'
: genSel.size > GEN_MAX_CELLS
? genSel.size + ' tuile(s) sélectionnée(s) — maximum ' + GEN_MAX_CELLS + ' par lancement'
: genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer');
genGo.style.display = genSel.size && genSel.size <= GEN_MAX_CELLS ? '' : 'none';
}
function toggleGenSel(t) {
const key = genSelKey(t.col, t.row);
function toggleGenSelCell(col, row, corners) {
const key = genSelKey(col, row);
if (genSel.has(key)) genSel.delete(key);
else genSel.add(key);
else {
genSel.add(key);
if (corners) genSelCorners.set(key, corners);
}
refreshGenSelHighlights();
pendingTiles = genSelCells();
pendingRegen = true;
@ -3133,14 +3176,26 @@ function toggleGenSel(t) {
updateGenClassInfo();
}
// Clic sur la carte hors dalle affichée : le serveur convertit le point
// WGS84 en dalle L93 de 1 km — permet de sélectionner des tuiles qui
// n'existent pas encore (aucun marqueur, aucune donnée).
function pickGenSelCell(latlng) {
fetch('/api/cell?lat=' + latlng.lat + '&lng=' + latlng.lng)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d && d.col !== null && d.col !== undefined && d.row !== undefined)
toggleGenSelCell(d.col, d.row, d.corners);
}).catch(() => { /* hors ligne : le clic suivant réessaiera */ });
}
function setGenSelMode(on) {
genSelMode = on;
document.body.classList.toggle('genSel', on);
if (btnGenSel) {
btnGenSel.classList.toggle('primary', on);
btnGenSel.innerHTML = on
? '✓ Sélection en cours — cliquer des dalles'
: '↻ Sélectionner des tuiles';
? '✓ Sélection en cours — cliquer sur la carte'
: 'Sélectionner des tuiles';
}
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider).
@ -3149,7 +3204,7 @@ function setGenSelMode(on) {
}
if (hintChip) {
hintChip.innerHTML = on
? 'Mode sélection : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile<br>La sélection se cumule avec les zones dessinées — Échap pour terminer'
: hintDefaultHtml;
}
if (on) {
@ -3167,6 +3222,7 @@ function clearGenSel() {
if (genSelMode) setGenSelMode(false);
if (genSel.size) {
genSel.clear();
genSelCorners.clear();
refreshGenSelHighlights();
}
}
@ -3224,13 +3280,18 @@ genGo.addEventListener('click', () => {
bare_earth: genBareEarth ? genBareEarth.checked : false,
reclassify: genReclass ? genReclass.checked : false })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => {
.then(d => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
pendingRegen = false;
if (fromSel) clearGenSel();
return startGenTask(nTiles + ' tuile(s) — ' + verb);
const label = nTiles + ' tuile(s) — ' + verb;
// Un run est déjà en cours : la demande est partie en file d'attente
// côté serveur et démarrera à sa fin, sans couper le travail en place.
if (d && d['demarré'] === false && d.en_file)
return startGenTask(label, { qid: d.qid, position: d.en_file });
return startGenTask(label);
})
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
});
@ -3507,7 +3568,11 @@ function genTaskEl(t) {
label.title = t.label; label.textContent = t.label;
const state = document.createElement('span'); state.className = 'gen-task-state';
const tiles = t.tiles || [];
if (t.state === 'running') {
if (t.state === 'queued') {
state.textContent = t.position
? 'En file — position ' + t.position
: 'En file d\'attente';
} else if (t.state === 'running') {
const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length;
state.textContent = tiles.length
? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)'
@ -3559,21 +3624,27 @@ function renderGenQueue() {
// Plus récentes en haut
for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t));
const running = genTasks.filter(t => t.state === 'running').length;
const queued = genTasks.filter(t => t.state === 'queued').length;
genBadge.hidden = running === 0;
genBadge.textContent = running > 0 ? String(running) : '';
if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; }
const active = running + queued;
genQueueCount.textContent = genTasks.length
? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
? (active ? active + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)')
: '';
genQueue.hidden = genTasks.length === 0;
}
function startGenTask(labelText) {
function startGenTask(labelText, opts) {
const now = new Date();
const qid = opts ? opts.qid : undefined;
genTasks.push({
id: Date.now(), label: labelText,
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
state: 'running', returncode: null, log: [], logOpen: false, tiles: [],
// Demande partie en file d'attente serveur : suivie jusqu'à son tour
state: qid !== undefined ? 'queued' : 'running',
qid: qid, position: opts ? opts.position : undefined,
returncode: null, log: [], logOpen: false, tiles: [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
}
@ -3664,7 +3735,12 @@ function mergeTilesData(data) {
}
}
if (changed || metaChanged) renderLayerPanel();
if (changed) { syncTileLayers(); layoutTiles(); }
if (changed) {
syncTileLayers(); layoutTiles();
// Une dalle qui vient d'apparaître prend le relais du polygone de
// sélection (surlignage du marqueur désormais possible)
refreshGenSelHighlights();
}
}
function pollLiveTiles() {
const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : '';
@ -3683,8 +3759,31 @@ function pollGenStatus() {
// Réponse d'erreur (ex: machine de traitement injoignable) : ignorer,
// le sondage reprendra — ne pas marquer les tâches en échec.
if (typeof d.running !== 'boolean') return;
// Demandes en file côté serveur : bouton de retrait
if (genQueueDrop) genQueueDrop.hidden = !(d.queue && d.queue.length);
// Tâches en file : démarrent quand le serveur attaque leur run (qid),
// « Interrompu » si la demande est retirée sans avoir tourné.
const serverQids = new Set((d.queue || []).map(q => q.id));
for (const t of genTasks) {
if (t.state !== 'queued') continue;
const gone = !serverQids.has(t.qid);
if (d.running && d.qid === t.qid) {
// Le run de cette demande a démarré : les tâches « en cours » plus
// anciennes appartenaient au run précédent, forcément terminé (le
// serveur n'exécute qu'un run à la fois).
for (const o of genTasks) {
if (o !== t && o.state === 'running') { o.state = 'done'; o.returncode = 0; }
}
t.state = 'running'; t.started = d.started; t.position = undefined;
} else if (d.running && d.qid == null && gone) {
t.state = 'running'; t.started = d.started; t.position = undefined;
} else if (!d.running && gone) {
t.state = 'stopped';
}
}
const running = genTasks.filter(t => t.state === 'running');
if (!running.length) {
const queued = genTasks.filter(t => t.state === 'queued');
if (!running.length && !queued.length) {
clearInterval(genPollTimer); genPollTimer = null;
return;
}
@ -3695,8 +3794,14 @@ function pollGenStatus() {
if (d.tiles && d.tiles.length) t.tiles = d.tiles;
}
if (!d.running) {
clearInterval(genPollTimer); genPollTimer = null;
finishRunningTasks(d);
if (genTasks.some(t => t.state === 'queued')) {
// Des demandes attendent encore leur tour : poursuivre la veille
// (le rechargement de fin de run attendra la fin de la file).
saveGenTasks(); renderGenQueue();
return;
}
clearInterval(genPollTimer); genPollTimer = null;
// Sauvegarde la vue courante pour qu'elle survive au rechargement
// (sinon fitView() recadre sur l'ensemble des tuiles et le zoom part à zéro).
try {
@ -3715,23 +3820,31 @@ function pollGenStatus() {
// Après un run : reconstruire vignettes et carte depuis le cache à la demande
// (/api/sync — sur la webapp légère, les tuiles proviennent de la machine de
// traitement), puis recharger. /api/sync se réduit à un rebuild local : même
// flux partout. Rechargement de secours après 30 min.
// flux partout. Le rechargement attend la fin de CE rebuild (done >= started,
// horodatage serveur retourné au POST) : sans cela, un sondage trop tôt
// lirait le done du rebuild précédent et rechargerait une carte périmée.
// Rechargement de secours après 30 min.
function syncThenReload() {
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
const t0 = Date.now();
const check = setInterval(() => {
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) {
clearInterval(check);
location.reload();
}
}).catch(() => { clearInterval(check); location.reload(); });
}, 2000);
}).catch(() => location.reload());
fetch('/api/sync', { method: 'POST' })
.then(r => r.ok ? r.json() : null)
.then(d => {
const started = (d && d.started) || 0;
const t0 = Date.now();
const check = setInterval(() => {
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
const finished = s && !s.running
&& (!started || (s.done || 0) >= started);
if (finished || (s && s.error) || Date.now() - t0 > 30 * 60 * 1000) {
clearInterval(check);
location.reload();
}
}).catch(() => { clearInterval(check); location.reload(); });
}, 2000);
}).catch(() => location.reload());
}
genQueueClear.addEventListener('click', () => {
genTasks = genTasks.filter(t => t.state === 'running');
genTasks = genTasks.filter(t => t.state === 'running' || t.state === 'queued');
saveGenTasks(); renderGenQueue();
});
@ -3748,15 +3861,37 @@ if (genStop) genStop.addEventListener('click', () => {
});
});
// Retrait des demandes en file d'attente côté serveur : le run en cours
// continue, les demandes non démarrées sont abandonnées.
if (genQueueDrop) genQueueDrop.addEventListener('click', () => {
genQueueDrop.disabled = true;
fetch('/api/queue/clear', { method: 'POST' })
.then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => {
genQueueDrop.disabled = false;
genQueueDrop.hidden = true;
if (!genPollTimer) pollGenStatus(); // marquer les tâches « Interrompu »
})
.catch(msg => {
genQueueDrop.disabled = false;
showGenBar('Erreur file d\'attente : ' + msg);
});
});
renderGenQueue();
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
// adopte une génération lancée hors interface (API directe), ou réaffiche le
// dernier run terminé après un redémarrage du serveur (état rechargé du
// fichier .generation.job.json). La clé `started` évite les doublons au
// rechargement : un même run n'est adopté qu'une fois.
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
if (genTasks.some(t => t.state === 'running' || t.state === 'queued')) pollGenStatus();
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
if (!s || genTasks.some(t => t.started === s.started)) return;
// Déjà suivie par une tâche (en cours ou en file côté serveur) : pas
// d'adoption en doublon d'un run lancé depuis cet onglet.
const trackedQids = new Set(genTasks.filter(t => t.qid !== undefined).map(t => t.qid));
if (s.qid != null && trackedQids.has(s.qid)) return;
if ((s.queue || []).some(q => trackedQids.has(q.id))) return;
const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
if (s.running) {
genTasks.push({