- Bouton "Supprimer" sur chaque run (liste + detail), DELETE deja expose cote API. - Tous les parametres CloudCompare mirroires dans l'UI (mirroir de la boite "Compute Volume" du GUI) : strategie de remplissage (leave_empty/min/max/custom/interpolate/kriging), hauteur de cellule (moyenne/min/max/mediane, -PROJ), direction de projection (X/Y/Z, -VERT_DIR), multiplicateur de grille. Nuage comble desormais exporte en ASCII (pas .bin) : -VOLUME le lit nativement, evite un aller-retour inutile puisqu'il faut de toute facon un export ASCII pour la carte de comparaison. - Tableau : volume/surface/volume ajoute/volume retire avec % absolu et % relatif vs niveau 0. Matching cells retire du tableau et des graphiques (mais toujours calcule/stocke, juste plus affiche). - Carte de comparaison unique par niveau (bleu->rouge façon CloudCompare, colormap jet) a la place des heightmaps separees par nuage : diff = ceil - ground (ou ceil - plan constant), calculee nous-memes par binning numpy sur une grille commune. - README + CLAUDE.md : avertissement explicite sur la persistance Coolify quand le build pack est "Application"/Dockerfile plutot que "Docker Compose" (docker-compose.yml et son volume nomme sont alors completement ignores par Coolify, il faut un volume "Storages" configure manuellement). - Valide en local avec les 2 vrais nuages utilisateur (mode comparaison) : resultats identiques a avant (14 244 -> 14 722 m3 selon le niveau), cartes de comparaison generees a chaque niveau, bouton suppression teste via l'UI.
432 lines
22 KiB
JavaScript
432 lines
22 KiB
JavaScript
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 ----------------
|
|
function renderNew() {
|
|
app.innerHTML = `
|
|
<h1 class="text-xl font-semibold mb-6">Nouveau run</h1>
|
|
<form id="upload-form" class="space-y-5 max-w-xl">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Type de comparaison</label>
|
|
<div class="flex gap-4 text-sm">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" name="mode" value="const_height" checked />
|
|
Plan de reference Z constant
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" name="mode" value="cloud_compare" />
|
|
Comparaison a un second nuage
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="mode-const-height">
|
|
<label class="block text-sm font-medium mb-1">Nuage de points (.las, .laz, .copc.laz, .bin)</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">Le volume est calcule entre le nuage et un plan horizontal (altitude min par defaut).</p>
|
|
</div>
|
|
|
|
<div id="mode-cloud-compare" class="hidden space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">Surface superieure (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" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">Limite inferieure (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" />
|
|
</div>
|
|
<p class="text-xs text-slate-500">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">Parametres CloudCompare (mirroir de la boite "Compute Volume")</summary>
|
|
<div class="mt-3 space-y-4">
|
|
<div id="zref-override-wrap">
|
|
<label class="block text-sm mb-1">Plan de reference Z / hauteur par defaut (override, mode Z constant uniquement)</label>
|
|
<input type="number" step="any" name="zrefOverride" placeholder="auto = Zmin de l'echantillon"
|
|
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">Pas spatial initial en metres (override)</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">Multiplicateur de grille (grid step = pas spatial x ce nombre)</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">Cellules vides (Empty cells)</label>
|
|
<select name="emptyCellStrategy" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
|
<option value="interpolate" selected>Interpoler (Delaunay, borne)</option>
|
|
<option value="leave_empty">Laisser vide</option>
|
|
<option value="min_height">Hauteur minimum</option>
|
|
<option value="max_height">Hauteur maximum</option>
|
|
<option value="custom_height">Hauteur personnalisee</option>
|
|
<option value="kriging">Krigeage</option>
|
|
</select>
|
|
</div>
|
|
<div id="max-edge-wrap">
|
|
<label class="block text-sm mb-1">Distance max de remplissage / Delaunay (m)</label>
|
|
<input type="number" step="any" name="maxEdgeLengthOverride" placeholder="auto = grille du niveau x3"
|
|
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">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">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">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</option>
|
|
<option value="min">Hauteur minimum</option>
|
|
<option value="max">Hauteur maximum</option>
|
|
<option value="med">Hauteur mediane</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm mb-1">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 zrefWrap = document.getElementById('zref-override-wrap');
|
|
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);
|
|
zrefWrap.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 · ${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 · % 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 · Pas bas: ${fmt(r.params.densityMatch.bottomSpacing, 5)} m
|
|
· 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') : ''}
|
|
· 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|