script-volume-auto/public/app.js
Nicolas Fryder 79daa30a8a Implementation initiale : calcul de volume 2.5D CloudCompare et test de robustesse par decimation spatiale progressive
- Backend NestJS orchestrant CloudCompare CLI (stats, decimation x2 sur 5 etapes, volume, assemblage .bin)
- Scripts Python (laspy, scipy, matplotlib) pour la conversion LAS/LAZ et la generation d'images
- Frontend web simple (Tailwind CDN) avec historique des runs
- Image Docker (debian:trixie-slim + cloudcompare apt + xvfb) prete pour deploiement Coolify
- Lint/typecheck/tests unitaires integres comme portes de qualite au build Docker
- CLAUDE.md documentant les comportements CloudCompare CLI verifies empiriquement
2026-07-10 13:55:25 +02:00

238 lines
12 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);
}
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 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>
</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>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ---------------- 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-1">Nuage de points (.las, .laz, .copc.laz, .bin)</label>
<input required 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" />
</div>
<details class="border border-slate-800 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary>
<div class="mt-3 space-y-3">
<div>
<label class="block text-sm mb-1">Plan de reference Z (override)</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>
</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>`;
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
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 levelRow(l, id) {
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">${l.spatialStep !== null ? fmt(l.spatialStep, 5) + ' m' : 'complet'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}</td>
<td class="py-2 px-3">${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? '<span class="badge bg-amber-600/30 text-amber-300 ml-1">a verifier</span>' : ''}${statusTxt}</td>
</tr>`;
}
function paintDetail(run) {
const r = run.report;
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">Points</th>
<th class="py-2 px-3">% points vs L0</th><th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
</tr>
</thead>
<tbody>${r.levels.map((l) => levelRow(l, run.id)).join('')}</tbody>
</table>
</div>` : '';
const params = r ? `
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
<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>${fmt(r.params.gridStep, 5)} m</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>` : '';
const heightmaps = r ? r.levels.filter((l) => l.imageFile).map((l) => `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
<img src="${API}/${run.id}/images/${l.imageFile.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">Nuage assemble (.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>
</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">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}
${heightmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de hauteur par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${heightmaps}</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]));
}