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 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 `${status}`;
}
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 = '
Page introuvable.
';
}
window.addEventListener('hashchange', router);
window.addEventListener('DOMContentLoaded', router);
// ---------------- Liste des runs ----------------
async function renderList() {
app.innerHTML = 'Chargement...
';
const runs = await api(API);
if (runs.length === 0) {
app.innerHTML = `
Aucun run pour le moment.
Lancer un nouveau run
`;
return;
}
const rows = runs.map((r) => `
| ${r.id.slice(0, 8)} |
${escapeHtml(r.originalFilename)} |
${statusBadge(r.status)} |
${new Date(r.createdAt).toLocaleString('fr-FR')} |
${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'} |
${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'} |
`).join('');
app.innerHTML = `
| ID | Fichier | Statut |
Cree le | Niveaux OK | Ecart volume (L0 -> dernier) |
${rows}
`;
}
// ---------------- Nouveau run ----------------
function renderNew() {
app.innerHTML = `
Nouveau run
`;
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]');
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;
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 = 'Chargement...
';
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('
');
}
function stepCell(l) {
const c = l.clouds[0];
if (!c || c.spatialStep === null) return 'complet';
return fmt(c.spatialStep, 5) + ' m';
}
function levelRow(l) {
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
const statusTxt = l.status === 'ok' ? '' : `${escapeHtml(l.message || '')}
`;
return `
| ${l.level} |
${stepCell(l)} |
${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'} |
${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'} |
${pointsCell(l)} |
${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'} |
${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'} |
${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'} |
${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? 'a verifier' : ''}${statusTxt} |
`;
}
function paintDetail(run) {
const r = run.report;
const isCompare = r && r.mode === 'cloud_compare';
const levelsTable = r ? `
| Niveau | Pas spatial | Grille volume |
Max edge (remplissage) | Points |
Volume | Surface |
Matching cells | Statut |
${r.levels.map((l) => levelRow(l)).join('')}
` : '';
const densityBlock = r && r.params.densityMatch ? `
Appariement de densite (niveau 0)
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`})
` : '';
const params = r ? `
${!isCompare ? `- Zref
- ${fmt(r.params.zref, 4)} (${r.params.zrefSource})
` : ''}
- Pas initial
- ${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})
- Grille volume
- x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau
- Remplissage trous
- x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})
- Facteur / etapes
- x${r.params.factor} sur ${r.params.steps}
${densityBlock}` : '';
const heightmaps = r ? r.levels.flatMap((l) => l.clouds.filter((c) => c.imageFile).map((c) => ({ level: l.level, c }))).map(({ level, c }) => `
Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}
`).join('') : '';
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => `
`).join('') : '';
const downloads = run.status === 'done' ? `
` : '';
const errorBlock = run.error ? `${escapeHtml(run.error)}
` : '';
app.innerHTML = `
${escapeHtml(run.originalFilename)}
${statusBadge(run.status)}
${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') : ''}
${errorBlock}
${params}
${levelsTable}
${downloads}
${heightmaps ? `Cartes de hauteur par niveau
${heightmaps}
` : ''}
${summaryCharts ? `Graphiques de synthese
${summaryCharts}
` : ''}
Journal
${escapeHtml(run.log || '')}
`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}