script-volume-auto/public/app.js
Nicolas Fryder dfba8e7897 Reorganise le formulaire de run selon les roles Ceil/Ground de CloudCompare
Regroupe chaque nuage avec son role dans le calcul de volume (Ceil = surface
superieure, Ground = plan Z constant ou second nuage), cote a cote comme dans
la boite de dialogue "Compute Volume" du GUI, au lieu d'un unique bloc de
parametres partages en dessous des deux fichiers. La hauteur de reference Z
(mode plan constant) est deplacee dans le panneau Ground plutot que noyee dans
les parametres avances.

Garde les reglages de grille/remplissage (grid step, empty fill, cell height,
direction) dans une section UNIQUE et partagee : cote CLI CloudCompare
(-PROJ/-EMPTY_FILL/-VERT_DIR), ce sont des reglages appliques a toute la
grille, pas un parametre par nuage - les dupliquer par role induirait en
erreur sur ce que le backend supporte reellement.

Ajoute un tooltip (title) sur chaque champ expliquant sa correspondance avec
le flag CLI CloudCompare concerne, et des labels reprenant la terminologie
du GUI (Ceil/Ground, Fill empty cells with, Cell height...).

Verifie que toutes les options exposees correspondent a des flags CLI
CloudCompare reellement supportes par le backend (aucune option inventee).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 18:50:25 +02:00

453 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const API = '/api/runs';
const app = document.getElementById('app');
let pollTimer = null;
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
async function api(path, opts) {
const res = await fetch(path, opts);
if (!res.ok) {
let msg = res.statusText;
try { const j = await res.json(); msg = j.message || j.error || msg; } catch {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function fmt(n, digits = 4) {
if (n === null || n === undefined) return '-';
if (typeof n !== 'number') return String(n);
return n.toLocaleString('fr-FR', { maximumFractionDigits: digits });
}
function fmtPct(n, digits = 1) {
if (n === null || n === undefined || !Number.isFinite(n)) return '-';
const sign = n > 0 ? '+' : '';
return sign + n.toLocaleString('fr-FR', { maximumFractionDigits: digits }) + '%';
}
function roleLabel(role) {
return role === 'top' ? 'haut' : role === 'bottom' ? 'bas' : '';
}
function statusBadge(status) {
const map = {
pending: 'bg-slate-700 text-slate-200',
running: 'bg-blue-600/30 text-blue-300',
done: 'bg-emerald-600/30 text-emerald-300',
error: 'bg-red-600/30 text-red-300',
};
return `<span class="badge ${map[status] || 'bg-slate-700'}">${status}</span>`;
}
function router() {
const hash = location.hash || '#/';
stopPolling();
if (hash === '#/' || hash === '') return renderList();
if (hash === '#/new') return renderNew();
const m = hash.match(/^#\/runs\/([^/]+)$/);
if (m) return renderDetail(m[1]);
app.innerHTML = '<p class="text-slate-400">Page introuvable.</p>';
}
window.addEventListener('hashchange', router);
window.addEventListener('DOMContentLoaded', router);
// ---------------- Liste des runs ----------------
async function renderList() {
app.innerHTML = '<p class="text-slate-400">Chargement...</p>';
const runs = await api(API);
if (runs.length === 0) {
app.innerHTML = `
<div class="text-center py-20">
<p class="text-slate-400 mb-4">Aucun run pour le moment.</p>
<a href="#/new" class="inline-block bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer un nouveau run</a>
</div>`;
return;
}
const rows = runs.map((r) => `
<tr class="border-b border-slate-800 hover:bg-slate-900/50 cursor-pointer" onclick="location.hash='#/runs/${r.id}'">
<td class="py-2 px-3 font-mono text-xs text-slate-400">${r.id.slice(0, 8)}</td>
<td class="py-2 px-3">${escapeHtml(r.originalFilename)}</td>
<td class="py-2 px-3">${statusBadge(r.status)}</td>
<td class="py-2 px-3 text-sm text-slate-400">${new Date(r.createdAt).toLocaleString('fr-FR')}</td>
<td class="py-2 px-3 text-sm">${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'}</td>
<td class="py-2 px-3 text-sm">${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'}</td>
<td class="py-2 px-3 text-right">
<button class="text-red-400 hover:text-red-300 text-xs" onclick="event.stopPropagation(); deleteRun('${r.id}')">Supprimer</button>
</td>
</tr>`).join('');
app.innerHTML = `
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold">Runs</h1>
<a href="#/new" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium text-sm">+ Nouveau run</a>
</div>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-left">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
<tr>
<th class="py-2 px-3">ID</th><th class="py-2 px-3">Fichier</th><th class="py-2 px-3">Statut</th>
<th class="py-2 px-3">Cree le</th><th class="py-2 px-3">Niveaux OK</th><th class="py-2 px-3">Ecart volume (L0 -> dernier)</th>
<th class="py-2 px-3"></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
async function deleteRun(id) {
if (!confirm('Supprimer definitivement ce run (nuages, images, rapports) ?')) return;
try {
await api(`${API}/${id}`, { method: 'DELETE' });
renderList();
} catch (err) {
alert(`Echec de la suppression : ${err.message}`);
}
}
// ---------------- Nouveau run ----------------
// Layout inspire de la boite de dialogue "Compute Volume" de CloudCompare : les nuages sont
// presentes selon leurs 2 roles dans le calcul (Ceil = surface superieure, Ground = reference
// inferieure - un plan Z constant ou un second nuage), cote a cote comme dans le GUI CC. A la
// difference de CC (qui liste les nuages deja charges dans des combobox), on utilise des boutons
// de selection de fichier puisque l'app fonctionne par upload. Les parametres de grille/
// remplissage restent dans UNE section partagee : cote CLI CloudCompare, -PROJ/-EMPTY_FILL/
// -VERT_DIR sont des reglages uniques appliques a toute la grille (pas un reglage par nuage,
// verifie dans les sources - voir CLAUDE.md), donc les dupliquer par role induirait en erreur.
function renderNew() {
const tip = (text) => `title="${text.replace(/"/g, '&quot;')}"`;
app.innerHTML = `
<h1 class="text-xl font-semibold mb-6">Nouveau run</h1>
<form id="upload-form" class="space-y-5 max-w-2xl">
<div>
<label class="block text-sm font-medium mb-2">Type de comparaison</label>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 text-sm">
<label class="flex items-center gap-2 cursor-pointer" ${tip('Compare le nuage a un plan horizontal (Z constant). CloudCompare : -CONST_HEIGHT. Ne donne un volume correct QUE si le nuage ne contient que lobjet mesure, sans terrain environnant.')}>
<input type="radio" name="mode" value="const_height" checked />
Plan de reference Z constant
</label>
<label class="flex items-center gap-2 cursor-pointer" ${tip('Compare 2 nuages entre eux (ex: surface dun amas vs son socle/plan dajustement). Les densites sont appariees avant de commencer la decimation.')}>
<input type="radio" name="mode" value="cloud_compare" />
Comparaison a un second nuage
</label>
</div>
</div>
<div id="mode-const-height" class="border border-slate-800 rounded-lg overflow-hidden">
<div class="grid sm:grid-cols-2 divide-y sm:divide-y-0 divide-slate-800 sm:divide-x">
<div class="p-4">
<p class="text-[11px] uppercase tracking-wide text-slate-500 mb-1">Ceil <span class="text-slate-600 normal-case">(surface superieure)</span></p>
<label class="block text-sm font-medium mb-1" ${tip('Nuage charge par -O et compare au plan Ground. Cest ce nuage, jamais decime, qui constitue le niveau 0.')}>Nuage de points</label>
<input type="file" name="file" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
<p class="text-xs text-slate-500 mt-1">.las, .laz, .copc.laz ou .bin</p>
</div>
<div class="p-4">
<p class="text-[11px] uppercase tracking-wide text-slate-500 mb-1">Ground <span class="text-slate-600 normal-case">(plan de reference)</span></p>
<label class="block text-sm font-medium mb-1" ${tip('Hauteur (m) du plan Z constant utilise comme Ground (CloudCompare : -CONST_HEIGHT). Auto = 1er percentile des altitudes du nuage : plus robuste quun minimum brut, insensible a un point aberrant isole.')}>Hauteur constante (m)</label>
<input type="number" step="any" name="zrefOverride" placeholder="auto = 1er percentile de Z"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
</div>
</div>
<div id="mode-cloud-compare" class="hidden border border-slate-800 rounded-lg overflow-hidden">
<div class="grid sm:grid-cols-2 divide-y sm:divide-y-0 divide-slate-800 sm:divide-x">
<div class="p-4">
<p class="text-[11px] uppercase tracking-wide text-slate-500 mb-1">Ceil <span class="text-slate-600 normal-case">(surface superieure)</span></p>
<label class="block text-sm font-medium mb-1" ${tip('Nuage charge en premier -> role Ceil dans le calcul de volume CloudCompare (-VOLUME).')}>Sommet de l'amas</label>
<input type="file" name="fileTop" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
<p class="text-xs text-slate-500 mt-1">.las, .laz, .copc.laz ou .bin</p>
</div>
<div class="p-4">
<p class="text-[11px] uppercase tracking-wide text-slate-500 mb-1">Ground <span class="text-slate-600 normal-case">(limite inferieure)</span></p>
<label class="block text-sm font-medium mb-1" ${tip('Nuage charge en second -> role Ground dans le calcul de volume CloudCompare (-VOLUME). Base, socle ou plan dajustement sous lamas.')}>Base / socle de l'amas</label>
<input type="file" name="fileBottom" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
<p class="text-xs text-slate-500 mt-1">.las, .laz, .copc.laz ou .bin</p>
</div>
</div>
<p class="text-xs text-slate-500 p-3 border-t border-slate-800">Les densites des deux nuages sont d'abord appariees (le plus dense est decime pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape.</p>
</div>
<details class="border border-slate-800 rounded-lg p-3" open>
<summary class="cursor-pointer text-sm text-slate-400">Grille &amp; remplissage (mirroir de la boite "Compute Volume" de CloudCompare)</summary>
<p class="text-xs text-slate-500 mt-2">Ces reglages s'appliquent a la grille de calcul entiere, identiquement pour Ceil et Ground (CloudCompare n'a pas de reglage separe par nuage pour la grille).</p>
<div class="mt-3 space-y-4">
<div>
<label class="block text-sm mb-1" ${tip('Distance minimale entre points au niveau 0 (avant toute decimation). Auto = distance mediane au plus proche voisin (mode Z constant) ou la plus grande des 2 densites Ceil/Ground (mode comparaison, pour aligner les nuages).')}>Pas spatial initial (m)</label>
<input type="number" step="any" name="initialStepOverride" placeholder="auto = distance mediane au plus proche voisin"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
<div>
<label class="block text-sm mb-1" ${tip('Cell size de la grille de calcul (CloudCompare : -GRID_STEP) = pas spatial du niveau x ce nombre. Recalcule a chaque niveau pour rester adapte a la densite du nuage decime.')}>Multiplicateur de grille (grid step)</label>
<input type="number" step="any" min="0.1" name="gridStepMultiplierOverride" placeholder="auto = 2"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
<div>
<label class="block text-sm mb-1" ${tip('Strategie de comblement des cellules sans point avant le calcul de volume (CloudCompare : -EMPTY_FILL). -VOLUME na pas cette option nativement : le nuage est dabord rasterise (-RASTERIZE) puis comble.')}>Remplissage des cellules vides (Fill empty cells with)</label>
<select name="emptyCellStrategy" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
<option value="interpolate" selected>Interpolation Delaunay, bornee (INTERP)</option>
<option value="leave_empty">Laisser vide (LEAVE_EMPTY)</option>
<option value="min_height">Hauteur minimum des voisins (MIN_H)</option>
<option value="max_height">Hauteur maximum des voisins (MAX_H)</option>
<option value="custom_height">Hauteur personnalisee (CUSTOM_H)</option>
<option value="kriging">Krigeage (KRIGING)</option>
</select>
</div>
<div id="max-edge-wrap">
<label class="block text-sm mb-1" ${tip('Longueur darete maximale de linterpolation Delaunay (CloudCompare : -MAX_EDGE_LENGTH). Bornee pour ne pas combler les concavites reelles du contour de lamas.')}>Distance max de remplissage / Delaunay (m)</label>
<input type="number" step="any" name="maxEdgeLengthOverride" placeholder="auto = grille du niveau 0 x3, fixe sur tous les niveaux"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
<p class="text-xs text-slate-500 mt-1">Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.</p>
</div>
<div id="custom-height-wrap" class="hidden">
<label class="block text-sm mb-1" ${tip('Valeur (m) utilisee pour combler les cellules vides (CloudCompare : -CUSTOM_HEIGHT).')}>Hauteur personnalisee (m)</label>
<input type="number" step="any" name="customHeightValue" placeholder="ex: 0"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
<div id="kriging-knn-wrap" class="hidden">
<label class="block text-sm mb-1" ${tip('Nombre de plus proches voisins utilises par le krigeage (CloudCompare : -KRIGING_KNN).')}>Krigeage : nombre de voisins (KNN)</label>
<input type="number" step="1" min="1" name="krigingKnn" placeholder="auto = 8"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
<div>
<label class="block text-sm mb-1" ${tip('Comment la hauteur dune cellule est calculee a partir des points quelle contient (CloudCompare : -PROJ, "Cell height" dans le GUI).')}>Hauteur de cellule (projection)</label>
<select name="projectionType" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
<option value="avg" selected>Hauteur moyenne (AVG)</option>
<option value="min">Hauteur minimum (MIN)</option>
<option value="max">Hauteur maximum (MAX)</option>
<option value="med">Hauteur mediane (MED)</option>
</select>
</div>
<div>
<label class="block text-sm mb-1" ${tip('Axe le long duquel la grille 2.5D est projetee (CloudCompare : -VERT_DIR). Z par defaut = vue de dessus.')}>Direction de projection</label>
<select name="vertDir" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
<option value="2" selected>Z</option>
<option value="0">X</option>
<option value="1">Y</option>
</select>
</div>
</div>
</details>
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer le run</button>
<p id="upload-error" class="text-red-400 text-sm hidden"></p>
</form>`;
const form = document.getElementById('upload-form');
const constDiv = document.getElementById('mode-const-height');
const compareDiv = document.getElementById('mode-cloud-compare');
const constFileInput = form.querySelector('input[name=file]');
const topFileInput = form.querySelector('input[name=fileTop]');
const bottomFileInput = form.querySelector('input[name=fileBottom]');
const strategySelect = form.querySelector('select[name=emptyCellStrategy]');
const maxEdgeWrap = document.getElementById('max-edge-wrap');
const customHeightWrap = document.getElementById('custom-height-wrap');
const krigingWrap = document.getElementById('kriging-knn-wrap');
form.querySelectorAll('input[name=mode]').forEach((radio) => {
radio.addEventListener('change', () => {
const isCompare = form.mode.value === 'cloud_compare';
constDiv.classList.toggle('hidden', isCompare);
compareDiv.classList.toggle('hidden', !isCompare);
constFileInput.required = !isCompare;
topFileInput.required = isCompare;
bottomFileInput.required = isCompare;
});
});
constFileInput.required = true;
strategySelect.addEventListener('change', () => {
maxEdgeWrap.classList.toggle('hidden', strategySelect.value !== 'interpolate');
customHeightWrap.classList.toggle('hidden', strategySelect.value !== 'custom_height');
krigingWrap.classList.toggle('hidden', strategySelect.value !== 'kriging');
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const errEl = document.getElementById('upload-error');
errEl.classList.add('hidden');
const btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = 'Envoi...';
try {
const res = await fetch(API, { method: 'POST', body: fd });
const json = await res.json();
if (!res.ok) throw new Error(json.message || 'Echec de l\'envoi');
location.hash = `#/runs/${json.id}`;
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Lancer le run';
}
});
}
// ---------------- Detail d'un run ----------------
async function renderDetail(id) {
app.innerHTML = '<p class="text-slate-400">Chargement...</p>';
await loadDetail(id);
pollTimer = setInterval(async () => {
const run = await api(`${API}/${id}`);
if (run.status === 'pending' || run.status === 'running') {
paintDetail(run);
} else {
stopPolling();
paintDetail(run);
}
}, 2000);
}
async function loadDetail(id) {
const run = await api(`${API}/${id}`);
paintDetail(run);
}
function pointsCell(l) {
if (l.status !== 'ok') return '-';
return l.clouds.map((c) => {
const label = c.role === 'unique' ? '' : `${roleLabel(c.role)}: `;
return `${label}${c.pointCount.toLocaleString('fr-FR')} (${fmt(c.pointRatio * 100, 1)}%)`;
}).join('<br/>');
}
function stepCell(l) {
const c = l.clouds[0];
if (!c || c.spatialStep === null) return 'complet';
return fmt(c.spatialStep, 5) + ' m';
}
/** Cellule valeur + % absolu vs L0 + % relatif vs L0 (+ ou -). */
function metricCell(value, l0value, unit, digits) {
if (value === null || value === undefined) return '-';
const main = `${fmt(value, digits)} ${unit}`;
if (l0value === null || l0value === undefined || l0value === 0) return main;
const abs = (value / l0value) * 100;
const rel = ((value - l0value) / l0value) * 100;
return `${main}<br/><span class="text-xs text-slate-400">${fmt(abs, 1)}% abs &middot; ${fmtPct(rel, 1)}</span>`;
}
function levelRow(l, l0) {
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
const statusTxt = l.status === 'ok' ? '' : `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(l.message || '')}</div>`;
return `
<tr class="border-b border-slate-800 ${warnCls}">
<td class="py-2 px-3 font-medium">${l.level}</td>
<td class="py-2 px-3">${stepCell(l)}</td>
<td class="py-2 px-3">${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
<td class="py-2 px-3">${pointsCell(l)}</td>
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.volume, l0 && l0.volume, 'm3', 4) : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.addedVolume, l0 && l0.addedVolume, 'm3', 4) : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.removedVolume, l0 && l0.removedVolume, 'm3', 4) : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.surface, l0 && l0.surface, 'm2', 3) : '-'}</td>
<td class="py-2 px-3">${statusBadge(l.status)}${statusTxt}</td>
</tr>`;
}
function paintDetail(run) {
const r = run.report;
const isCompare = r && r.mode === 'cloud_compare';
const l0 = r ? r.levels.find((l) => l.level === 0 && l.status === 'ok') : null;
const levelsTable = r ? `
<div class="overflow-x-auto border border-slate-800 rounded-lg mt-4">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
<tr>
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th>
<th class="py-2 px-3">Points</th>
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Volume ajoute</th><th class="py-2 px-3">Volume retire</th>
<th class="py-2 px-3">Surface</th><th class="py-2 px-3">Statut</th>
</tr>
</thead>
<tbody>${r.levels.map((l) => levelRow(l, l0)).join('')}</tbody>
</table>
<p class="text-xs text-slate-500 p-2">% abs = valeur / niveau 0 &middot; % relatif = ecart vs niveau 0 (+ ou -)</p>
</div>` : '';
const densityBlock = r && r.params.densityMatch ? `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3 text-sm mt-4">
<p class="text-slate-400 text-xs mb-1">Appariement de densite (niveau 0)</p>
<p>Pas haut: ${fmt(r.params.densityMatch.topSpacing, 5)} m &middot; Pas bas: ${fmt(r.params.densityMatch.bottomSpacing, 5)} m
&middot; Pas apparie: ${fmt(r.params.densityMatch.matchedSpacing, 5)} m
(${r.params.densityMatch.preDecimatedRole === 'none' ? 'aucun nuage decime, deja comparables' : `nuage ${roleLabel(r.params.densityMatch.preDecimatedRole)} decime pour matcher`})</p>
</div>` : '';
const strategyLabels = {
leave_empty: 'Laisser vide',
min_height: 'Hauteur minimum',
max_height: 'Hauteur maximum',
custom_height: 'Hauteur personnalisee',
interpolate: 'Interpoler (Delaunay)',
kriging: 'Krigeage',
};
const projLabels = { avg: 'Moyenne', min: 'Minimum', max: 'Maximum', med: 'Mediane' };
const dirLabels = { 0: 'X', 1: 'Y', 2: 'Z' };
const params = r ? `
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
${!isCompare ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>` : ''}
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Pas initial</dt><dd>${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial (${r.params.gridStepMultiplierSource})</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Cellules vides</dt><dd>${strategyLabels[r.params.emptyCellStrategy] || r.params.emptyCellStrategy}</dd></div>
${r.params.emptyCellStrategy === 'interpolate' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>` : ''}
${r.params.emptyCellStrategy === 'custom_height' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Hauteur personnalisee</dt><dd>${fmt(r.params.customHeightValue, 4)}</dd></div>` : ''}
${r.params.emptyCellStrategy === 'kriging' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Krigeage KNN</dt><dd>${r.params.krigingKnn}</dd></div>` : ''}
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Hauteur de cellule</dt><dd>${projLabels[r.params.projectionType] || r.params.projectionType}</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Direction</dt><dd>${dirLabels[r.params.vertDir]}</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
</dl>
${densityBlock}` : '';
const diffmaps = r ? r.levels.filter((l) => l.diffMapImage).map((l) => `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
<img src="${API}/${run.id}/images/${l.diffMapImage.split('/').pop()}" class="w-full rounded" loading="lazy" />
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${l.level}</p>
</div>`).join('') : '';
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
<img src="${API}/${run.id}/images/${f}" class="w-full rounded" loading="lazy" onerror="this.parentElement.style.display='none'" />
</div>`).join('') : '';
const downloads = run.status === 'done' ? `
<div class="flex flex-wrap gap-2 mt-4">
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuages assembles (.bin)</a>
<a href="${API}/${run.id}/download/csv" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Rapport (.csv)</a>
<a href="${API}/${run.id}/download/zip" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Dossier complet (.zip)</a>
<button class="text-red-400 hover:text-red-300 text-sm ml-auto" onclick="deleteRun('${run.id}').then(() => location.hash = '#/')">Supprimer ce run</button>
</div>` : '';
const errorBlock = run.error ? `<div class="bg-red-950/40 border border-red-900 rounded-lg p-3 text-sm text-red-300 mt-4">${escapeHtml(run.error)}</div>` : '';
app.innerHTML = `
<div class="flex items-center justify-between mb-2">
<h1 class="text-xl font-semibold">${escapeHtml(run.originalFilename)}</h1>
${statusBadge(run.status)}
</div>
<p class="text-slate-400 text-sm mb-4">
${r ? (isCompare ? 'Mode : comparaison a un second nuage' : 'Mode : plan de reference Z constant') : ''}
&middot; Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''}
</p>
${errorBlock}
${params}
${levelsTable}
${downloads}
${diffmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de comparaison par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${diffmaps}</div>` : ''}
${summaryCharts ? `<h2 class="text-lg font-semibold mt-8 mb-3">Graphiques de synthese</h2><div class="grid grid-cols-1 md:grid-cols-3 gap-3">${summaryCharts}</div>` : ''}
<h2 class="text-lg font-semibold mt-8 mb-2">Journal</h2>
<pre class="bg-black/60 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 overflow-x-auto max-h-64 overflow-y-auto">${escapeHtml(run.log || '')}</pre>
`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}