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 `${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 = `

Runs

+ Nouveau run
${rows}
IDFichierStatut Cree leNiveaux OKEcart volume (L0 -> dernier)
`; } 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 = `

Nouveau run

Le volume est calcule entre le nuage et un plan horizontal (altitude min par defaut).

Parametres CloudCompare (mirroir de la boite "Compute Volume")

Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.

`; 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 = '

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'; } /** 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}
${fmt(abs, 1)}% abs · ${fmtPct(rel, 1)}`; } function levelRow(l, l0) { 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' : '-'} ${pointsCell(l)} ${l.status === 'ok' ? metricCell(l.volume, l0 && l0.volume, 'm3', 4) : '-'} ${l.status === 'ok' ? metricCell(l.addedVolume, l0 && l0.addedVolume, 'm3', 4) : '-'} ${l.status === 'ok' ? metricCell(l.removedVolume, l0 && l0.removedVolume, 'm3', 4) : '-'} ${l.status === 'ok' ? metricCell(l.surface, l0 && l0.surface, 'm2', 3) : '-'} ${statusBadge(l.status)}${statusTxt} `; } 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 ? `
${r.levels.map((l) => levelRow(l, l0)).join('')}
NiveauPas spatialGrille volume Points VolumeVolume ajouteVolume retire SurfaceStatut

% abs = valeur / niveau 0 · % relatif = ecart vs niveau 0 (+ ou -)

` : ''; 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 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 ? `
${!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 (${r.params.gridStepMultiplierSource})
Cellules vides
${strategyLabels[r.params.emptyCellStrategy] || r.params.emptyCellStrategy}
${r.params.emptyCellStrategy === 'interpolate' ? `
Remplissage trous
x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})
` : ''} ${r.params.emptyCellStrategy === 'custom_height' ? `
Hauteur personnalisee
${fmt(r.params.customHeightValue, 4)}
` : ''} ${r.params.emptyCellStrategy === 'kriging' ? `
Krigeage KNN
${r.params.krigingKnn}
` : ''}
Hauteur de cellule
${projLabels[r.params.projectionType] || r.params.projectionType}
Direction
${dirLabels[r.params.vertDir]}
Facteur / etapes
x${r.params.factor} sur ${r.params.steps}
${densityBlock}` : ''; const diffmaps = r ? r.levels.filter((l) => l.diffMapImage).map((l) => `

Niveau ${l.level}

`).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' ? `
Nuages assembles (.bin) Rapport (.csv) Dossier complet (.zip)
` : ''; 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} ${diffmaps ? `

Cartes de comparaison par niveau

${diffmaps}
` : ''} ${summaryCharts ? `

Graphiques de synthese

${summaryCharts}
` : ''}

Journal

${escapeHtml(run.log || '')}
`; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }