Ajouter la fiche de dalle IGN et la rose des vents, imposer la génération

Un clic sélectionne toujours la dalle sous le curseur (cadre sur la carte),
générée ou non : emprise, rendu et recalage des passes s'affichent tout de
suite ; la fiche IGN (date et heure du scan LiDAR, capteurs, mission,
édition, nombre de points, lien de téléchargement du .copc.laz) arrive à
part via /api/map/ign, mise en cache, sans jamais bloquer la sélection.

Une rose des vents donne la couleur de chaque orientation de pente du
relief orienté, avec la même formule CIELAB que le rendu.

La génération impose la classification IGN (sol) et le raccord de 100 m
avec les dalles voisines : plus de réglage dans la carte, défauts CLI
alignés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 01:57:04 +02:00
parent d32502b74e
commit f836408d6f
7 changed files with 427 additions and 109 deletions

View File

@ -93,6 +93,11 @@ _MAP_HTML = """<!DOCTYPE html>
<div class="card-body" id="tileBody"></div>
</div>
<div id="rose" class="card" hidden title="Relief orienté : la teinte donne l'orientation de la pente, la clarté le relief local">
<canvas id="roseCanvas" width="132" height="132"></canvas>
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>
</div>
<div id="usecard" class="card" hidden>
<div class="card-head">
<span class="card-title">Fond d'imagerie XYZ</span>
@ -112,19 +117,8 @@ _MAP_HTML = """<!DOCTYPE html>
<div id="genOpts">
<div class="gen-row"><span class="gen-lbl">Couches</span>
<select id="genViz" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
<div class="gen-row"><span class="gen-lbl">Classification</span>
<select id="genClass" title="Méthode de classification du sol">
<option value="ign">IGN (pré-classée)</option>
<option value="auto">Auto</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select></div>
<div class="gen-row"><span class="gen-lbl">Classes IGN</span>
<input id="genIgnClasses" class="gen-input" value="sol"
title="Classes LAS du MNT IGN : sol(2), unclassified(1), eau(9), virtuel(66), pont(17), sursol(64) — séparées par des virgules"></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<label class="chk"><input type="checkbox" id="genReclass"> Reclasser le sol (même méthode)</label>
<label class="chk"><input type="checkbox" id="genEdge"> Raccord des bords (bande 100 m)</label>
<div class="meta">Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)</div>
</div>
<div id="genClassInfo" class="meta"></div>
<div class="card-foot" id="genActions">
@ -186,7 +180,22 @@ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(-
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
#tilecard { left: 12px; top: 12px; width: 320px; max-width: calc(100vw - 24px); }
#tilecard { left: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px); overflow-y: auto; }
.info-sec { margin-top: 10px; }
.info-sec h4 { margin: 0 0 4px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--muted); }
.info-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; font-size: 12.5px; }
.info-kv dt { color: var(--muted); }
.info-kv dd { margin: 0; overflow-wrap: anywhere; }
.info-dl { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: 6px;
background: rgba(90, 160, 255, 0.18); color: var(--text); text-decoration: none; font-weight: 600; }
.info-dl:hover { background: rgba(90, 160, 255, 0.3); }
#rose { right: 12px; bottom: 190px; padding: 8px 8px 6px; display: flex; flex-direction: column;
align-items: center; gap: 2px; z-index: 800; }
#rose canvas { width: 132px; height: 132px; }
.rose-cap { font-size: 11px; text-align: center; color: var(--text); line-height: 1.3; }
.rose-cap span { color: var(--muted); font-size: 10.5px; }
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: 910; }
.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
@ -592,6 +601,7 @@ function applyLayers() {
base.setOpacity(STATE.base.opacity);
const container = base.getContainer && base.getContainer();
if (container) container.classList.toggle('base-dark', STATE.base.dark);
updateRose();
}
function moveLayer(key, delta) {
@ -753,34 +763,180 @@ function renderBaseRow() {
}
// --- fiche dalle ----------------------------------------------------------
function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ Générer cette dalle</button></div>'
: '';
const card = el('tilecard');
if (!data || !data.found) {
el('tileTitle').textContent = 'Aucune dalle ici';
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false;
return;
let tileSel = null; // cadre de la dalle sélectionnée
function esc(v) {
return String(v == null ? '' : v).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
function fmtDate(v, withTime) {
if (!v) return '—';
const d = new Date(v);
if (isNaN(d)) return esc(v);
const opt = withTime ? { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' }
: { dateStyle: 'long', timeZone: 'Europe/Paris' };
return d.toLocaleString('fr-FR', opt);
}
function kv(rows) {
return '<dl class="info-kv">' + rows.filter(r => r[1] != null && r[1] !== '')
.map(r => '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>').join('') + '</dl>';
}
function selectTileOutline(data) {
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
if (!data || !data.corners) return;
tileSel = L.polygon(data.corners, { color: '#ffd24a', weight: 2, fill: false,
dashArray: '6 4', interactive: false }).addTo(map);
}
function ignSection(ign) {
if (!ign) return '<div class="meta">Chargement des informations IGN…</div>';
if (ign.error) return '<div class="meta">Catalogue IGN injoignable pour le moment.</div>';
if (!ign.found) return '<div class="meta">Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).</div>';
const start = ign.acquisition_start, end = ign.acquisition_end;
let when = fmtDate(start, true);
if (start && end && String(start).slice(0, 10) === String(end).slice(0, 10) && String(end).length > 10) {
when = fmtDate(start, true) + ' → ' + new Date(end).toLocaleTimeString('fr-FR',
{ hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
} else if (end && String(end).slice(0, 10) !== String(start || '').slice(0, 10)) {
when = fmtDate(start) + ' → ' + fmtDate(end);
}
const pts = ign.points ? Number(ign.points).toLocaleString('fr-FR') + ' points' : null;
return kv([
['Scan LiDAR', when],
['Capteur', (ign.sensors || []).map(esc).join('<br>')],
['Mission', esc(ign.mission)],
['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('pour ' + ign.acquisition_owner)]
.filter(Boolean).map(esc).join(' ')],
['Édition IGN', ign.edition_date ? fmtDate(ign.edition_date) : null],
['Classement', esc(ign.classification_process)],
['Nuage', pts],
]) + (ign.download_url
? '<a class="info-dl" href="' + esc(ign.download_url) + '" target="_blank" rel="noopener">⤓ Télécharger le nuage LiDAR IGN (.copc.laz)</a>'
: '') + (ign.stac_url
? '<div class="meta"><a href="' + esc(ign.stac_url) + '" target="_blank" rel="noopener">Fiche du catalogue</a></div>' : '');
}
function calageSection(c) {
if (!c || !c.beams || !c.beams.length) return '';
const offs = Object.entries(c.offsets_cm || {}).map(([k, v]) => k + ' : ' + (v > 0 ? '+' : '') + String(v).replace('.', ',') + ' cm');
const lines = Object.values(c.lines_rms_cm || {});
return '<div class="info-sec"><h4>Recalage vertical des passes</h4>' + kv([
['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
['Décalages', offs.length ? offs.join('<br>') : 'aucun ≥ 0,5 cm'],
['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
]) + '</div>';
}
function renderTileCard(data, ign) {
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ ' + (data.found ? 'Régénérer' : 'Générer') + ' cette dalle</button></div>'
: '';
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
const viz = (data.layers || []).map(k => {
const info = META.layers.find(l => l.key === k);
return info ? info.label : k;
}).join(', ');
el('tileBody').innerHTML =
'<div class="meta"><b>' + data.name + '</b></div>' +
'<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' +
' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' +
(data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') +
'<div class="meta">Couches : ' + (viz || '—') + '</div>' + regenBtn;
const bb = data.l93_bbox || [];
let html = '<div class="meta"><b>' + esc(data.name) + '</b></div>' +
'<div class="info-sec"><h4>Emprise</h4>' + kv([
['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') +
'<br>Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null],
['Surface', '1 km × 1 km'],
]) + '</div>' +
'<div class="info-sec"><h4>Acquisition LiDAR (IGN)</h4>' + ignSection(ign) + '</div>';
if (data.found) {
const viz = (data.layers || []).map(k => {
const info = META.layers.find(l => l.key === k);
return info ? info.label : k;
}).join(', ');
html += '<div class="info-sec"><h4>Rendu</h4>' + kv([
['Résolution', String(data.resolution).replace('.', ',') + ' m/px'],
['Classification', esc(data.method || 'inconnue')],
['Généré le', esc(data.generated || '—')],
['Couches', esc(viz || '—')],
]) + '</div>' + calageSection(data.calage);
} else {
html += '<div class="info-sec"><div class="meta">Pas encore de rendu sur cette dalle.</div></div>';
}
el('tileBody').innerHTML = html + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false;
el('tilecard').hidden = false;
}
function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
if (!data) {
el('tileTitle').textContent = 'Dalle';
el('tileBody').innerHTML = '<div class="meta">Serveur injoignable : réessayez dans un instant.</div>';
el('tilecard').hidden = false;
return;
}
selectTileOutline(data);
renderTileCard(data, null);
// Fiche IGN à part : la sélection s'affiche sans attendre le catalogue.
const key = data.col + '_' + data.row;
showTileInfo.current = key;
fetch('api/map/ign?col=' + data.col + '&row=' + data.row)
.then(r => r.ok ? r.json() : { error: 'HTTP ' + r.status })
.catch(() => ({ error: 'réseau' }))
.then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
}
// --- rose des vents du relief orienté --------------------------------------
// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
// chroma 60 · L*(100−L*)/2500, à clarté fixe.
function labToRgb(L, a, b) {
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const finv = t => (t > 6 / 29 ? t * t * t : 3 * (6 / 29) * (6 / 29) * (t - 4 / 29));
const X = 0.95047 * finv(fx), Y = finv(fy), Z = 1.08883 * finv(fz);
const lin = [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z,
0.0557 * X - 0.2040 * Y + 1.0570 * Z];
return lin.map(c => {
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, v)) * 255);
});
}
function drawRose() {
const cv = el('roseCanvas');
if (!cv || drawRose.done) return;
drawRose.done = true;
const dpr = window.devicePixelRatio || 1, size = 132;
cv.width = size * dpr; cv.height = size * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
const c = size / 2, rOut = 50, rIn = 22, L = 64;
const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
const img = ctx.createImageData(size * dpr, size * dpr);
for (let py = 0; py < size * dpr; py++) {
for (let px = 0; px < size * dpr; px++) {
const x = px / dpr - c, y = py / dpr - c, r = Math.hypot(x, y);
if (r > rOut || r < rIn) continue;
const compass = (Math.atan2(x, -y) * 180 / Math.PI + 360) % 360; // 0 = N, sens horaire
const h = ((compass + 90) % 360) * Math.PI / 180;
const [R, G, B] = labToRgb(L, chroma * Math.cos(h), chroma * Math.sin(h));
const i = (py * size * dpr + px) * 4;
img.data[i] = R; img.data[i + 1] = G; img.data[i + 2] = B; img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '600 11px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
for (const [t, deg] of labels) {
const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
ctx.fillText(t, x, y);
}
}
function updateRose() {
const on = !!(STATE && STATE.on && STATE.on.relief_oriente && LAYERS.get('relief_oriente'));
el('rose').hidden = !on;
if (on) drawRose();
}
// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
@ -984,10 +1140,6 @@ function genLaunch() {
genApi('POST', 'api/generate', {
tiles: tiles,
regenerate: el('genRegen').checked,
reclassify: el('genReclass').checked,
ground_class: el('genClass').value,
ign_classes: el('genIgnClasses').value.trim() || 'sol',
edge_buffer: el('genEdge').checked,
viz: genSelectedViz(),
}).then(d => {
genSel.clear();
@ -1161,12 +1313,16 @@ map.on('click', (e) => {
if (genDrawing || Date.now() < genIgnoreClickUntil) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null)
.then(d => showTileInfo(d, e.latlng)).catch(() => {});
.catch(() => null)
.then(d => showTileInfo(d, e.latlng));
});
el('cardToggle').addEventListener('click', () =>
el('layercard').classList.toggle('collapsed'));
el('tileClose').addEventListener('click', () => { el('tilecard').hidden = true; });
el('tileClose').addEventListener('click', () => {
el('tilecard').hidden = true;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
el('btnUse').addEventListener('click', renderUseCard);
el('btnShare').addEventListener('click', () => {