Files
lidar_rendu/lidar_pipeline/web/map.js
Antoine Jacquin 72c50dca99 Fix round 2 : fermer le cadre d'export sous la fiche détaillée en toutes circonstances, ne pas la recadrer au retour, réarmer BOOTSTRAPPING sur échec
panelVisibleTab() renvoie null tant que la fiche détaillée est ouverte,
quel que soit ce qui redéclenche panelRender (grip, redimensionnement,
changement phoneQuery…) : le cadre d'export ne rouvre plus derrière elle.
Rétablir l'onglet Export après un retour (restoreTabAfterDetails / M5)
repose désormais le cadre sans recadrer la vue ni le recentrer
(nouveau drapeau RESTORING_TAB, distingué d'une ouverture explicite par
l'utilisateur). BOOTSTRAPPING est aussi réarmé quand /api/map/meta échoue,
pour qu'une ouverture explicite ultérieure de l'onglet recadre bien la vue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 19:21:50 +02:00

1573 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation.
Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier
d'image, aucune rotation CSS, aucune mosaïque maison. */
const UI_VERSION = "__UI_VERSION__";
const LS_KEY = 'lidarMapView_v2';
const el = (id) => document.getElementById(id);
// --- stockage local ---------------------------------------------------------
// Tous les accès passent ici : en navigation privée (ou stockage bloqué),
// localStorage lève — la carte démarre alors sur ses défauts.
function lsGet(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw == null ? fallback : JSON.parse(raw);
} catch (e) { return fallback; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* privé */ }
}
function lsDel(key) {
try { localStorage.removeItem(key); } catch (e) { /* privé */ }
}
// --- fin stockage ------------------------------------------------------------
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
// Affichage : une couche principale (relief orienté) et la couche
// « précision » (densité de points sol), montrée seule ou superposée en
// « produit » (mix-blend-mode multiply) sur la principale : les zones peu
// fiables s'assombrissent sans masquer le relief. Touche P : mode suivant.
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['both', 'Les deux']];
const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
const clamp01 = (v, d) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : d;
};
// Paliers de la précision : niveau k à partir de 0,25 × 2^(k/2) pts/m²
// (visualizations.density_levels), gris = colormap « gray » sur 0..15.
const PREC_LEVELS = 16;
let META = null;
let STATE = null;
// Vrai jusqu'au premier rendu du panneau au démarrage : évite qu'un onglet
// Export mémorisé recadre la vue par-dessus un lien partagé ou l'URL initiale
// (cf. printSetOpen). Remis à faux dans le .then() ET le .catch() du chargement
// de /api/map/meta (sinon un serveur injoignable au démarrage le laisse
// bloqué à vrai pour toujours : l'onglet Export ne recadrerait plus jamais).
let BOOTSTRAPPING = true;
// Vrai le temps d'un panelRender() qui RÉTABLIT un onglet déjà connu
// (restoreTabAfterDetails, retour de la fiche détaillée) plutôt que d'en
// ouvrir un nouveau à la demande de l'utilisateur : le cadre d'export ne doit
// alors ni recadrer la vue, ni recentrer le cadre lui-même (cf. printSetOpen).
let RESTORING_TAB = false;
// --- thème ------------------------------------------------------------------
// auto = suit le système ; ☀/☾ force clair ou sombre ; 3ᵉ appui : auto.
const THEME_LS = 'lidar-theme';
const THEME_NEXT = { auto: 'light', light: 'dark', dark: 'auto' };
const THEME_ICON = { auto: '◐', light: '☀', dark: '☾' };
const THEME_TITLE = { auto: 'Thème : suit le système', light: 'Thème : clair', dark: 'Thème : sombre' };
const darkQuery = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
function themeIsDark() {
const t = document.documentElement.dataset.theme;
return t === 'dark' || (t !== 'light' && !!(darkQuery && darkQuery.matches));
}
function applyTheme(pref) {
const p = THEME_NEXT[pref] ? pref : 'auto';
if (p === 'auto') delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = p;
const btn = el('btnTheme');
if (btn) { btn.textContent = THEME_ICON[p]; btn.title = THEME_TITLE[p] + ' (cliquer pour changer)'; }
if (typeof applyLayers === 'function' && STATE) applyLayers(); // fond OSM assombri
redrawRose();
return p;
}
// Rose de la légende : ses libellés cardinaux prennent la couleur du texte,
// elle est donc redessinée à chaque changement de thème (si déjà dessinée).
function redrawRose() {
if (!drawRose.done) return;
drawRose.done = false;
drawRose();
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
let THEME = applyTheme(lsGet(THEME_LS, 'auto'));
const LAYERS = new Map(); // clé → L.TileLayer
const map = L.map('map', {
zoomControl: false,
attributionControl: true,
minZoom: 3,
// Zoom plafonné au natif (19 = 0,2 m/px, 1 px écran = 1 px LiDAR) : toute
// la pyramide est générée d'avance, jamais de tuile agrandie ni étirée.
maxZoom: 19,
zoomSnap: 0.5,
wheelPxPerZoomLevel: 90,
worldCopyJump: false,
preferCanvas: false
});
map.attributionControl.setPrefix('');
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
// Pendant l'animation de zoom, transformations SEULES (aucun niveau créé,
// tuiles chargées à la fin) : un niveau neuf n'est pas composé par Chrome
// (vide au départ, il est animé sur le thread principal, gelé ~90 ms par le
// chargement). Le fond sautait alors que le relief orienté, qui garde son
// niveau pair, glissait normalement — les deux couches se désynchronisaient.
// Leaflet 1.9 déclenche aussi « zoom » dès le DÉBUT de l'animation : même
// garde dans _resetView tant que la carte anime.
const ZoomAnimTileLayer = L.TileLayer.extend({
_animateZoom(e) { this._setView(e.center, e.zoom, true, true); },
_resetView(e) {
if (this._map && this._map._animatingZoom) {
this._setView(this._map.getCenter(), this._map.getZoom(), true, true);
return;
}
L.TileLayer.prototype._resetView.call(this, e);
}
});
// --- chargement des tuiles -------------------------------------------------
// Compteur recalculé depuis l'état réel des tuiles de chaque couche (tuiles
// du niveau courant pas encore chargées) plutôt que par +1/−1 sur les
// événements : une tuile retirée avant la fin de son chargement (déplacement
// rapide) ne déclenche ni « tileload » ni « tileerror », un compteur par
// événements dériverait. Lot : de la première tuile en attente au retour à
// zéro ; la barre donne la part déjà chargée du lot.
const TILE_LOAD = { layers: new Set(), batch: 0, done: 0, raf: 0, hideT: 0 };
function tilesPending() {
let n = 0;
for (const layer of TILE_LOAD.layers) {
if (!layer._map) continue;
for (const key in layer._tiles) {
const t = layer._tiles[key];
if (t.current && !t.loaded) n += 1;
}
}
return n;
}
function updateTileLoad() {
TILE_LOAD.raf = 0;
const box = el('tileload');
if (!box) return;
const pending = tilesPending();
if (pending > 0) {
clearTimeout(TILE_LOAD.hideT);
TILE_LOAD.hideT = 0;
if (TILE_LOAD.batch === 0) TILE_LOAD.done = 0;
TILE_LOAD.batch = Math.max(TILE_LOAD.batch, TILE_LOAD.done + pending);
el('tileloadText').textContent = pending + (pending > 1 ? ' tuiles restantes' : ' tuile restante');
el('tileloadBar').style.width = Math.round(100 * TILE_LOAD.done / TILE_LOAD.batch) + '%';
box.classList.remove('done');
box.hidden = false;
} else if (TILE_LOAD.batch > 0 && !TILE_LOAD.hideT) {
TILE_LOAD.batch = 0;
el('tileloadText').textContent = 'Vue chargée';
el('tileloadBar').style.width = '100%';
box.classList.add('done');
TILE_LOAD.hideT = setTimeout(() => { box.hidden = true; TILE_LOAD.hideT = 0; }, 600);
}
}
function scheduleTileLoad() {
if (!TILE_LOAD.raf) TILE_LOAD.raf = requestAnimationFrame(updateTileLoad);
}
ZoomAnimTileLayer.addInitHook(function () {
TILE_LOAD.layers.add(this);
const onDone = () => { TILE_LOAD.done += 1; scheduleTileLoad(); };
this.on('tileload', onDone);
this.on('tileerror', onDone);
this.on('tileloadstart tileunload load add remove', scheduleTileLoad);
});
const base = new ZoomAnimTileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
// Classe posée dès la création d'après le thème courant (pas un « dark » figé) :
// sinon le fond flashe sombre en thème clair le temps que /api/map/meta réponde
// (et pour de bon si l'appel échoue). applyLayers() la corrige ensuite.
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: themeIsDark() ? 'base-dark' : '',
// Sinon _setView recrée un niveau en cours d'animation malgré noUpdate.
updateWhenZooming: false,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
function toast(msg, ms) {
const t = el('toast');
t.textContent = msg;
t.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500);
}
// --- état persisté --------------------------------------------------------
function precisionKey() {
const p = META && META.precision_layer;
return p && META.layers.some(l => l.key === p) ? p : null;
}
function mainKeys(meta) {
return meta.layers.map(l => l.key).filter(k => k !== meta.precision_layer);
}
function validMain(meta, k) {
return mainKeys(meta).includes(k) ? k : null;
}
function defaultState(meta) {
const b = meta.default_base || {};
return {
main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
mode: validMode(meta.default_mode) || 'relief',
precOpacity: clamp01(meta.default_precision_opacity, 0.6),
base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
// null = suit le thème (choix explicite seulement si b.dark est déjà
// un booléen posé côté serveur).
dark: b.dark == null ? null : !!b.dark },
};
}
function loadState(meta) {
const def = defaultState(meta);
let st = def;
const s = lsGet(LS_KEY, null);
if (s) {
// Ancien format (avant v3) : l'ex-webapp persistait base.dark:true à chaque
// sauvegarde, jamais un vrai choix de l'utilisateur — ignoré ici (le fond
// suit alors le thème) ; v3 seulement écrit un dark explicite (voir saveState).
const sBase = Object.assign({}, s.base || {});
if (s.v !== 3) delete sBase.dark;
st = { main: validMain(meta, s.main) || def.main,
mode: validMode(s.mode) || def.mode,
precOpacity: clamp01(s.precOpacity, def.precOpacity),
base: Object.assign({}, def.base, sBase) };
}
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la
// pile de couches sont ignorés, la vue s'ouvre alors sur l'état courant).
const hash = parseHash();
if (hash) {
if (hash.main && validMain(meta, hash.main)) st.main = hash.main;
if (hash.view) { st.mode = hash.view.mode; st.precOpacity = hash.view.opacity; }
if (hash.base) st.base = hash.base;
}
return st;
}
function saveState() {
// v: 3 marque un format où base.dark, quand posé, est un vrai choix de
// l'utilisateur (voir loadState) — jamais mélangé à STATE lui-même.
lsSet(LS_KEY, Object.assign({ v: 3 }, STATE));
}
// --- hash de vue ----------------------------------------------------------
// #z/lat/lng&M=principale&P=mode:opacité&B=on:opacité:sombre
function parseHash() {
const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
if (!m) return null;
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
main: null, view: null, base: null };
const seg = {};
for (const part of m[4].split('&')) {
const i = part.indexOf('=');
if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
}
if (seg.M) out.main = seg.M;
if (seg.P) {
const f = seg.P.split(':');
const mode = validMode(f[0]);
if (mode) out.view = { mode, opacity: clamp01(Number(f[1]) / 100, 0.6) };
}
if (seg.B) {
const f = seg.B.split(':');
if (f.length === 3) {
const o = Number(f[1]);
out.base = { on: f[0] === '1', opacity: Number.isFinite(o) ? o / 100 : 0.85, dark: f[2] === '1' };
}
}
return out;
}
function viewHash(full) {
const c = map.getCenter();
let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6);
if (full) {
if (STATE.main) h += '&M=' + STATE.main;
h += '&P=' + STATE.mode + ':' + Math.round(STATE.precOpacity * 100);
const dark = STATE.base.dark === null || STATE.base.dark === undefined ? themeIsDark() : STATE.base.dark;
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
+ ':' + (dark ? '1' : '0');
}
return h;
}
let hashTimer = null;
function saveHashSoon() {
clearTimeout(hashTimer);
hashTimer = setTimeout(() => {
history.replaceState(null, '', location.pathname + location.search + viewHash(false));
}, 350);
}
// --- couches --------------------------------------------------------------
function tileUrl(key) {
return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
}
// Couche à niveaux standard pairs seulement (stockage réduit côté serveur) :
// à un zoom impair, les tuiles du niveau pair SUPÉRIEUR sont affichées
// réduites de moitié (nettes). Exception : le niveau natif lui-même (19,
// impair — 0,2 m/px) est demandé tel quel, rendu à la volée par le serveur.
// Le replier sur le niveau pair inférieur plafonnait l'affichage à 0,4 m/px,
// étiré ensuite par le navigateur : la pleine résolution n'était jamais vue.
// Au-delà du natif, Leaflet étire le niveau natif (jamais de tuile agrandie
// côté serveur). Le niveau de tuile Leaflet est ici la numérotation standard
// (zoomOffset −1 avec des tuiles de 512 px).
const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
_clampZoom(zoom) {
let z = L.TileLayer.prototype._clampZoom.call(this, zoom);
const maxN = this.options.maxNativeZoom;
if (this.options.evenLevels && z % 2 && (maxN === undefined || z < maxN)) z += 1;
return z;
}
});
function buildLayers() {
// Conteneur isolé : `isolation: isolate` cantonne le « produit » de la
// précision à la couche principale. Sans lui, il assombrirait aussi le fond
// OSM. Les panneaux sont réutilisés si déjà créés (rafraîchissement après
// un run de génération).
const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
stack.style.zIndex = 400;
stack.style.isolation = 'isolate';
META.layers.forEach((info) => {
const paneName = 'lidar-' + info.key;
const pane = map.getPane(paneName) || map.createPane(paneName, stack);
const existing = LAYERS.get(info.key);
if (existing) { if (map.hasLayer(existing)) map.removeLayer(existing); }
const layer = new EvenLevelTileLayer(tileUrl(info.key), {
evenLevels: !!META.even_levels,
pane: paneName,
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
// pas de requêtes inutiles sur le reste du monde.
bounds: META.bounds
? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]])
: undefined,
tileSize: META.tile_size,
zoomOffset: META.zoom_offset,
minNativeZoom: META.min_zoom,
maxNativeZoom: META.max_native_zoom,
minZoom: 0,
maxZoom: 19,
// Tuiles de 512 px : une rangée de réserve hors écran vaut les deux
// rangées de 256 px du fond. Deux rangées doublaient les images
// décodées gardées en mémoire (1 Mo chacune) pour peu de gain.
keepBuffer: 1,
updateWhenZooming: false,
crossOrigin: false,
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
attribution: META.attribution
});
LAYERS.set(info.key, layer);
});
applyLayers();
}
// Mode réellement appliqué : sans couche précision servie, relief seul.
function effectiveMode() {
return precisionKey() ? STATE.mode : 'relief';
}
function applyLayers() {
const prec = precisionKey();
const mode = effectiveMode();
for (const [key, layer] of LAYERS) {
const pane = map.getPane('lidar-' + key);
let show, opacity = 1, blend = 'normal';
if (key === prec) {
show = mode !== 'relief';
if (mode === 'both') { opacity = STATE.precOpacity; blend = 'multiply'; }
} else {
show = key === STATE.main && mode !== 'precision';
}
if (pane) {
pane.style.zIndex = key === prec ? '402' : '401';
pane.style.mixBlendMode = blend;
}
if (show) {
if (!map.hasLayer(layer)) layer.addTo(map);
layer.setOpacity(opacity);
} else if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
}
if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
else if (map.hasLayer(base)) map.removeLayer(base);
base.setOpacity(STATE.base.opacity);
const container = base.getContainer && base.getContainer();
const dark = STATE.base.dark === null || STATE.base.dark === undefined ? themeIsDark() : STATE.base.dark;
if (container) container.classList.toggle('base-dark', dark);
updateRose();
}
function setMode(mode, announce) {
if (!validMode(mode)) return;
STATE.mode = mode;
saveState(); renderPanel(); applyLayers();
if (announce) toast('Affichage : ' + VIEW_MODES.find(m => m[0] === mode)[1], 1200);
}
function renderPrecLegend() {
const bar = el('precBar');
if (bar.childElementCount) return;
for (let k = 0; k < PREC_LEVELS; k++) {
const g = Math.round(255 * k / (PREC_LEVELS - 1));
const lo = 0.25 * Math.pow(2, k / 2);
const hi = 0.25 * Math.pow(2, (k + 1) / 2);
const cell = document.createElement('i');
cell.style.background = 'rgb(' + g + ',' + g + ',' + g + ')';
cell.title = k === 0 ? '≤ ' + hi.toFixed(2).replace('.', ',') + ' pt/m²'
: k === PREC_LEVELS - 1 ? '≥ ' + Math.round(lo) + ' pts/m²'
: lo.toFixed(lo < 10 ? 1 : 0).replace('.', ',') + '–'
+ hi.toFixed(hi < 10 ? 1 : 0).replace('.', ',') + ' pts/m²';
bar.appendChild(cell);
}
}
function renderPanel() {
const labels = new Map(META.layers.map(l => [l.key, l]));
const mains = mainKeys(META);
const info = labels.get(STATE.main);
const name = el('mainName'), sel = el('mainSel');
name.textContent = info ? info.label : 'Aucune couche';
name.title = info ? (info.description || info.label) : '';
// Plusieurs couches principales possibles : menu ; sinon simple libellé.
const many = mains.length > 1;
name.hidden = many;
sel.hidden = !many;
if (many) {
sel.innerHTML = mains.map(k => '<option value="' + k + '"' + (k === STATE.main ? ' selected' : '')
+ '>' + labels.get(k).label + '</option>').join('');
}
const prec = precisionKey();
el('precBlock').hidden = !prec;
if (!prec) return;
renderPrecLegend();
const mode = effectiveMode();
el('modeSeg').querySelectorAll('button').forEach(b =>
b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
el('precCtl').hidden = mode !== 'both';
el('precOpacity').value = Math.round(STATE.precOpacity * 100);
el('precPct').textContent = Math.round(STATE.precOpacity * 100) + '%';
el('precLegend').hidden = mode === 'relief';
}
function renderBaseRow() {
const b = STATE.base;
const toggle = el('baseToggle');
toggle.classList.toggle('on', b.on);
toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF;
el('baseOpacity').value = Math.round(b.opacity * 100);
el('basePct').textContent = Math.round(b.opacity * 100) + '%';
el('baseRow').classList.toggle('on', b.on);
}
// --- fiche dalle ----------------------------------------------------------
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 fmtDensity(q) {
if (!q || q.ground_density == null) return 'non renseignée';
return q.ground_density.toFixed(q.ground_density < 10 ? 1 : 0).replace('.', ',') + ' pts sol/m²';
}
function fmtAcq(q) {
if (!q || !q.acq_start) return 'non renseignée';
return q.acq_end && q.acq_end.slice(0, 10) !== q.acq_start.slice(0, 10)
? fmtDate(q.acq_start) + ' → ' + fmtDate(q.acq_end) : fmtDate(q.acq_start);
}
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;
// Panneau dédié au-dessus de la pile LiDAR : dans l'overlayPane par défaut
// (z-index 400, comme lidarStack), le contour passait sous les tuiles.
tileSel = L.polygon(data.corners, { pane: 'tileSel', color: cssVar('--tile-sel'), 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="row"><button id="tileRegen" class="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 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);
}
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>';
return;
}
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); });
}
let tilePopup = null;
let tileReturnTab = null; // onglet à rouvrir après « ← Retour »
function showTilePopup(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
// Un nouveau clic pendant que la fiche détaillée d'une AUTRE dalle est
// ouverte : on la referme (sinon le contour bouge sur la carte alors que
// la fiche affiche encore l'ancienne dalle).
if (!el('tileView').hidden) restoreTabAfterDetails();
if (tilePopup) tilePopup.remove();
const html = !data
? '<div class="tile-pop">Serveur injoignable : réessayez dans un instant.</div>'
: '<div class="tile-pop"><b>Dalle ' + data.col + ' · ' + data.row + '</b><br>'
+ (data.found ? '' : 'Pas encore de rendu<br>')
+ 'Acquisition : ' + fmtAcq(data.quality) + '<br>'
+ 'Densité : ' + fmtDensity(data.quality)
+ '<div class="row"><button class="btn" id="tilePopDetails">Détails</button></div></div>';
tilePopup = L.popup({ maxWidth: 260, autoPanPaddingTopLeft: [isPhone() ? 12 : el('panel').getBoundingClientRect().right + 12, 12] })
.setLatLng(latlng).setContent(html).openOn(map);
if (!data) return;
selectTileOutline(data);
showTilePopup.data = data;
const btn = el('tilePopDetails');
if (btn) btn.addEventListener('click', openTileDetails);
tilePopup.on('remove', () => {
tilePopup = null;
if (el('tileView').hidden && tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
}
function openTileDetails() {
const data = showTilePopup.data;
if (!data) return;
if (tilePopup) { tilePopup.off('remove'); tilePopup.remove(); tilePopup = null; }
tileReturnTab = PANEL.tab;
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = true;
el('tileView').hidden = false;
// La fiche masque l'onglet Export : son cadre et sa poignée n'ont rien à
// faire sur la carte tant qu'elle est ouverte (restoreTabAfterDetails les
// rétablit via panel:tab en revenant sur l'onglet Export).
printSetOpen(false);
if (isPhone() && PANEL.sheet === 'closed') setSheet('half');
if (!isPhone() && PANEL.collapsed) setCollapsed(false);
showTileInfo(data, lastTileQuery);
}
// Masque #tileView et retire le contour de sélection, sans toucher aux
// onglets (utilisé par closeTileViews et par openTab, qui ne doit pas
// se rappeler lui-même via closeTileViews).
function hideTileView() {
el('tileView').hidden = true;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
}
// Referme la fiche détaillée et rétablit l'onglet d'origine SANS passer par
// openTab : celui-ci retouche la hauteur du volet quand l'onglet visé est déjà
// l'onglet actif (règle « re-toucher l'onglet actif ») — ce qui n'a pas lieu
// d'être ici, on rouvre simplement ce qui était affiché avant.
function restoreTabAfterDetails() {
hideTileView();
PANEL.tab = PANEL.want = tileReturnTab || 'view';
panelSave();
// Ce rendu RÉTABLIT l'onglet Export tel qu'il était (cadre à sa place,
// vue inchangée) : ni recadrage de la vue, ni recentrage du cadre — cf.
// printSetOpen, qui lit ce drapeau via le listener panel:tab.
RESTORING_TAB = true;
panelRender();
RESTORING_TAB = false;
}
// Ferme la bulle, sinon la fiche ; renvoie true si quelque chose a été fermé.
function closeTileViews() {
if (tilePopup) { tilePopup.remove(); tilePopup = null; return true; }
if (!el('tileView').hidden) {
restoreTabAfterDetails();
return true;
}
return false;
}
el('tileBack').addEventListener('click', closeTileViews);
// --- 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);
});
}
const ROSE_SIZE = 96; // taille affichée (px CSS), cf. .legend canvas
function drawRose() {
const cv = el('roseCanvas');
if (!cv || drawRose.done) return;
drawRose.done = true;
const dpr = window.devicePixelRatio || 1, size = ROSE_SIZE, k = 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 * k, rIn = 22 * k, 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.textAlign = 'center'; ctx.textBaseline = 'middle';
const text = cssVar('--text') || '#1d232b';
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) * k;
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
// intercardinaux sur l'anneau coloré : sombres ; cardinaux hors anneau : couleur du texte
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : text;
ctx.font = t.length > 1 ? '600 8px system-ui, sans-serif' : '700 10px system-ui, sans-serif';
ctx.fillText(t, x, y);
}
}
function updateRose() {
const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
&& LAYERS.get('relief_oriente'));
el('legendBlock').hidden = !on;
if (on) drawRose();
}
// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
function renderUseCard() {
const origin = location.origin;
const opts = META.layers.map(l =>
'<option value="' + l.key + '">' + l.label + '</option>').join('');
el('useBody').innerHTML =
'<div class="row"><select id="useLayer" class="field">' + opts + '</select>' +
'<button id="useCopy" class="btn btn-primary">Copier</button></div>' +
'<div class="meta">URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :</div>' +
'<div class="mono" id="useUrl"></div>' +
'<div class="meta">TileJSON : <a id="useJson" href="#"></a></div>' +
'<div class="meta">WMTS : <a href="' + origin + '/tiles/wmts.xml">' + origin + '/tiles/wmts.xml</a></div>' +
'<div class="meta">JOSM (toutes les couches) : <a href="' + origin + '/tiles/josm.imagery.xml">' + origin + '/tiles/josm.imagery.xml</a></div>' +
'<div class="use-note">Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '</div>';
const sel = el('useLayer');
const refresh = () => {
const key = sel.value;
el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png';
const a = el('useJson');
a.textContent = origin + '/tiles/' + key + '.json';
a.href = origin + '/tiles/' + key + '.json';
};
sel.addEventListener('change', refresh);
el('useCopy').addEventListener('click', () => {
const text = el('useUrl').textContent;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => toast('URL copiée'),
() => window.prompt('URL XYZ :', text));
} else { window.prompt('URL XYZ :', text); }
});
refresh();
}
// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
// Portée de la webapp historique : dessin de zone (+), passe « tout
// compléter » (⤒), sélection cumulée (clic fiche dalle), options de run,
// file d'attente et progression dalle par dalle (cadres sur la carte).
const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
const genSel = new Set(); // clés "col,row" des dalles retenues
const genCorners = new Map(); // clé → coins GPS [[lat, lon] × 4]
const genPolys = new Map(); // clé → polygone de surlignage
const genFrames = new Map(); // nom → cadre coloré de dalle en cours
let genDrawing = false, genDrawStart = null, genDrawRect = null;
let genIgnoreClickUntil = 0;
let lastTileQuery = null; // latlng du dernier clic (fiche dalle)
for (const [pane, z] of [['genSel', 650], ['genFrames', 660], ['genDraw', 670], ['tileSel', 680]]) {
map.createPane(pane).style.zIndex = String(z);
}
function genKey(c, r) { return c + ',' + r; }
function genApi(method, url, body) {
return fetch(url, {
method: method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
}).then(r => {
if (!r.ok) {
return r.json().catch(() => ({})).then(d => {
throw new Error((d && d.detail) || ('HTTP ' + r.status));
});
}
return r.json();
});
}
function genOpenCard(msg) {
if (msg !== undefined) el('genMsg').textContent = msg;
// Onglet déjà visible : pas de openTab, qui changerait la hauteur du volet.
if (panelVisibleTab() !== 'gen') openTab('gen');
}
function genFillViz() {
el('genViz').innerHTML = META.layers.map(l =>
'<option value="' + (l.step || l.key) + '">' + l.label + '</option>').join('');
}
function genSelectedViz() {
return Array.from(el('genViz').selectedOptions).map(o => o.value);
}
function genRefreshSel() {
for (const poly of genPolys.values()) poly.remove();
genPolys.clear();
for (const [key, corners] of genCorners.entries()) {
if (!genSel.has(key) || !corners || corners.length < 4) continue;
const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
}).addTo(map);
poly.on('click', (e) => {
L.DomEvent.stopPropagation(e);
genRemoveCell(key);
toast('Dalle retirée de la sélection');
});
genPolys.set(key, poly);
}
el('genGo').disabled = !genSel.size;
if (genSel.size) {
el('genMsg').textContent = genSel.size + ' dalle(s) sélectionnée(s) — ' +
'cliquez un surlignage pour retirer une dalle, réglez les options puis « Générer ».';
}
}
function genRemoveCell(key) {
genSel.delete(key);
genCorners.delete(key);
genRefreshSel();
}
function genAddCells(cells) {
let added = 0;
for (const c of (cells || [])) {
const k = genKey(c.col, c.row);
if (!genSel.has(k)) { genSel.add(k); added++; }
if (c.corners) genCorners.set(k, c.corners);
}
genRefreshSel();
return added;
}
function genAddCellFromQuery() {
if (!lastTileQuery) return;
genApi('GET', 'api/cell?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(d => {
genAddCells([{ col: d.col, row: d.row, corners: d.corners }]);
closeTileViews();
genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
})
.catch(msg => toast('Échec : ' + msg.message, 6000));
}
function genSetDrawing(on) {
genDrawing = on;
document.body.classList.toggle('drawing', on);
el('btnZone').classList.toggle('accent', on);
if (on) map.dragging.disable(); else map.dragging.enable();
if (!on && genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
genDrawStart = null;
if (on) genOpenCard('Dessinez un rectangle sur la carte (re-cliquez + pour annuler).');
}
map.on('mousedown', (e) => {
if (!genDrawing) return;
genDrawStart = e.latlng;
if (genDrawRect) genDrawRect.remove();
genDrawRect = L.rectangle([genDrawStart, genDrawStart], {
pane: 'genDraw', color: cssVar('--gen-pending'), weight: 2, dashArray: '6 4',
fillOpacity: 0.05, interactive: false,
}).addTo(map);
});
map.on('mousemove', (e) => {
if (!genDrawing || !genDrawStart || !genDrawRect) return;
genDrawRect.setBounds(L.latLngBounds(genDrawStart, e.latlng));
});
map.on('mouseup', (e) => {
if (!genDrawing || !genDrawStart) return;
const b = L.latLngBounds(genDrawStart, e.latlng);
genDrawStart = null;
genIgnoreClickUntil = Date.now() + 400; // pas de fiche dalle après un dessin
if (Math.abs(b.getWest() - b.getEast()) < 1e-9 ||
Math.abs(b.getNorth() - b.getSouth()) < 1e-9) return;
genSetDrawing(false);
if (genDrawRect) { genDrawRect.remove(); genDrawRect = null; }
genOpenCard('Analyse de la zone…');
genApi('POST', 'api/preview', {
bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
genOpenCard((added ? added + ' nouvelle(s) dalle(s) retenue(s)'
: 'zone déjà couverte — cochez « Régénérer » pour forcer') +
(d.capped ? ' — zone plafonnée, resserrez le rectangle' : '') + '.');
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
});
function genComplete() {
genOpenCard('Analyse des dalles déjà téléchargées…');
genApi('POST', 'api/preview', {
all_missing: true,
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
genOpenCard((added || d.count || 0) + ' dalle(s) à compléter' +
(d.capped ? ' — plafonnées à 10 000 par run' : '') + '.');
if (genPolys.size) {
const bounds = L.latLngBounds([]);
genPolys.forEach(p => bounds.extend(p.getBounds()));
map.fitBounds(bounds, { padding: [40, 40] });
}
}).catch(msg => genOpenCard('Erreur : ' + msg.message));
}
function genLaunch() {
const tiles = [...genSel].map(k => k.split(',').map(Number));
if (!tiles.length) return;
genApi('POST', 'api/generate', {
tiles: tiles,
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
genSel.clear();
genCorners.clear();
genRefreshSel();
GEN.wasRunning = true;
genPollNow();
toast(d.demarré ? ('Génération lancée : ' + d.tuiles + ' dalle(s)')
: ('En file d\'attente (' + d.en_file + ') : ' + d.tuiles + ' dalle(s)'));
}).catch(msg => toast('Échec : ' + msg.message, 6000));
}
// Cadres des dalles du run (orange pulsant = rendu en cours, bleu = en
// attente, rouge = échec) — redraw complet à chaque sondage, c'est peu.
function genDrawFrames(tiles) {
for (const poly of genFrames.values()) poly.remove();
genFrames.clear();
for (const t of (tiles || [])) {
if (t.state === 'done' || !t.corners || t.corners.length < 4) continue;
const cls = t.state === 'running' ? 'gen-frame-running'
: t.state === 'failed' ? 'gen-frame-failed' : 'gen-frame-pending';
const poly = L.polygon(t.corners.map(c => [c[0], c[1]]), {
pane: 'genFrames', className: cls, weight: 2, fillOpacity: 0.15,
interactive: false,
}).addTo(map);
genFrames.set(t.name || JSON.stringify(t.corners[0]), poly);
}
}
// Fin de run : la carte se rafraîchit (nouvelles couches/dalles, stamp de
// tuile neuf) et la maintenance de pyramide est relancée côté serveur.
function genAfterRun() {
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
buildLayers();
renderPanel();
renderBaseRow();
applyLayers();
renderUseCard();
}).catch(() => {});
fetch('api/map/background', { method: 'POST' }).catch(() => {});
}
function genUpdateStatus(d) {
const queued = (d.queue || []).length;
if (d.running || queued) {
el('genActions').hidden = true;
el('genOpts').hidden = true;
el('genRun').hidden = false;
const tiles = d.tiles || [];
const done = tiles.filter(t => t.state === 'done').length;
const failed = tiles.filter(t => t.state === 'failed').length;
let txt = d.running ? ('Run en cours — ' + done + '/' + tiles.length + ' dalle(s)')
: 'Démarrage du run suivant…';
if (failed) txt += ' • ' + failed + ' échec(s)';
if (queued) txt += ' • ' + queued + ' demande(s) en file';
el('genProgress').textContent = txt;
el('genLog').textContent = (d.log || []).slice(-12).join('\n');
genDrawFrames(tiles);
GEN.wasRunning = true;
} else {
el('genRun').hidden = true;
el('genActions').hidden = !GEN.available;
el('genOpts').hidden = false;
genDrawFrames(null);
if (GEN.wasRunning) {
GEN.wasRunning = false;
toast(d.returncode === 0 ? 'Génération terminée'
: 'Génération interrompue (code ' + d.returncode + ')', 6000);
genAfterRun();
}
}
el('genBadge').textContent = d.running ? '● en cours'
: queued ? ('⏱ ' + queued) : '';
el('btnZone').classList.toggle('busy', !!d.running || !!queued);
}
function genPollNow() {
if (!GEN.available) return;
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
if (d) genUpdateStatus(d);
genPollSoon();
}).catch(() => genPollSoon());
}
function genPollSoon() {
clearTimeout(GEN.timer);
GEN.timer = setTimeout(genPollNow, GEN.wasRunning ? 3000 : 20000);
}
el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
el('btnComplete').addEventListener('click', genComplete);
el('genClear').addEventListener('click', () => {
genSel.clear();
genCorners.clear();
genRefreshSel();
genOpenCard('Sélection effacée. Dessinez une zone (+) ou complétez (⤒).');
});
el('genGo').addEventListener('click', genLaunch);
el('genStop').addEventListener('click', () =>
genApi('POST', 'api/stop')
.then(() => toast('Arrêt demandé'))
.catch(msg => toast('Échec : ' + msg.message, 6000)));
el('genQueueClear').addEventListener('click', () =>
genApi('POST', 'api/queue/clear')
.then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
.catch(msg => toast('Échec : ' + msg.message, 6000)));
// --- export PDF (planche d'impression terrain) ---
// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte
// se déplace librement autour, on le fait glisser à la souris ou au doigt.
// Géométrie exacte calculée par le serveur (projection Lambert 93).
const PRINT_LS = 'lidar-print';
const PRINT = { on: false, rect: null, handle: null, seq: 0, center: null, corners: null, drag: null,
dragging: false };
map.createPane('printFrame').style.zIndex = '690';
function printSettings() {
return { paper: el('printPaper').value, orient: el('printOrient').value,
scale: el('printScale').value, title: el('printTitle').value.trim() };
}
function printQuery(s, withTitle) {
const c = PRINT.center || map.getCenter();
const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6),
paper: s.paper, orient: s.orient, scale: s.scale });
if (withTitle && s.title) q.set('title', s.title);
return q.toString();
}
function printSave() {
const s = printSettings();
if (PRINT.center) s.center = [PRINT.center.lat, PRINT.center.lng];
lsSet(PRINT_LS, s);
}
function printLoad() {
const raw = lsGet(PRINT_LS, {});
// Valeur corrompue (clé partagée avec une autre appli, quota dépassé
// pendant l'écriture…) : objet non conforme ignoré plutôt que planté.
const s = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'],
['scale', 'printScale'], ['title', 'printTitle']]) {
if (typeof s[k] === 'string') el(id).value = s[k];
}
if (Array.isArray(s.center) && s.center.length === 2 &&
s.center.every((n) => typeof n === 'number' && Number.isFinite(n))) {
PRINT.center = L.latLng(s.center);
}
}
// Recadre sur le cadre en laissant libre la place du panneau (colonne à
// gauche sur ordinateur, volet en bas sur téléphone) : sinon il passe dessous.
function printFit() {
if (!PRINT.corners) return;
const r = el('panel').getBoundingClientRect();
const panelWidth = isPhone() ? 0 : r.right;
const sheetHeight = isPhone() ? r.height : 0;
map.fitBounds(L.latLngBounds(PRINT.corners),
{ paddingTopLeft: [panelWidth + 30, 60], paddingBottomRight: [70, sheetHeight + 20] });
}
function printDraw(corners) {
PRINT.corners = corners;
if (PRINT.rect) PRINT.rect.setLatLngs(corners);
else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: cssVar('--print'), weight: 2,
dashArray: '8 5', fill: false, interactive: false }).addTo(map);
if (PRINT.handle) { PRINT.handle.setLatLng(PRINT.center); return; }
PRINT.handle = L.marker(PRINT.center, {
pane: 'printFrame', draggable: true, autoPan: true, keyboard: false,
title: 'Faire glisser pour déplacer la zone imprimée',
icon: L.divIcon({ className: 'print-handle', html: '✥', iconSize: [34, 34] }),
}).addTo(map);
PRINT.handle.on('dragstart', printDragStart);
PRINT.handle.on('drag', printDragMove);
PRINT.handle.on('dragend', printDragEnd);
}
// fit : recadrer la vue sur le cadre une fois la géométrie reçue.
function printRefreshFrame(fit) {
if (!PRINT.on) return;
const seq = ++PRINT.seq;
fetch('api/export/frame?' + printQuery(printSettings(), false))
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
.then(d => {
if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return;
printDraw(d.corners);
el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m';
if (fit === true) printFit();
})
.catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; });
}
// Glisser la poignée : le cadre suit en direct (translation en pixels de calque,
// stables pendant le défilement automatique),
// géométrie exacte redemandée au serveur au relâchement.
function printDragStart() {
PRINT.dragging = true;
PRINT.drag = { start: map.latLngToLayerPoint(PRINT.handle.getLatLng()),
corners: PRINT.corners.map(c => map.latLngToLayerPoint(c)) };
}
function printDragMove() {
const d = PRINT.drag;
if (!d) return;
const delta = map.latLngToLayerPoint(PRINT.handle.getLatLng()).subtract(d.start);
PRINT.rect.setLatLngs(d.corners.map(p => map.layerPointToLatLng(p.add(delta))));
}
function printDragEnd() {
PRINT.drag = null;
PRINT.center = PRINT.handle.getLatLng();
PRINT.corners = PRINT.rect.getLatLngs()[0];
printSave();
printRefreshFrame();
setTimeout(() => { PRINT.dragging = false; }, 300);
}
function printCenterHere() {
PRINT.center = map.getCenter();
printSave();
printRefreshFrame();
}
// Cadre affiché tant que l'onglet Export PDF est visible (événement panel:tab,
// émis à chaque rendu du panneau : on ne fait rien si l'état est inchangé).
// explicit : vrai seulement pour une ouverture voulue par l'utilisateur
// (clic sur l'onglet, raccourci clavier) — faux pour un rendu qui RÉTABLIT
// un état déjà connu (démarrage avec onglet mémorisé, retour de la fiche
// détaillée) : dans ce cas ni recadrage de la vue sur le cadre, ni
// recentrage du cadre lui-même sur la vue (sinon un cadre posé ailleurs sur
// le terrain saute au centre de l'écran, ou le zoom d'un lien partagé est
// écrasé — cf. printRefreshFrame(fit)).
function printSetOpen(on, explicit) {
if (PRINT.on === on) return;
PRINT.on = on;
if (on) {
// Cadre recentré sur la vue seulement s'il est hors champ ET que
// l'ouverture est explicite ; sinon gardé où il était (ou posé au centre
// s'il n'a jamais existé — premier passage sans lidar-print sauvegardé).
if (!PRINT.center) PRINT.center = map.getCenter();
else if (explicit && !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter();
printRefreshFrame(!!explicit);
} else {
if (PRINT.rect) PRINT.rect.remove();
if (PRINT.handle) PRINT.handle.remove();
PRINT.rect = PRINT.handle = PRINT.corners = null;
}
}
function printExport() {
const btn = el('printGo');
btn.disabled = true;
el('printMsg').textContent = 'Génération du PDF…';
fetch('api/export/pdf?' + printQuery(printSettings(), true))
.then(r => {
if (!r.ok) {
return r.json().catch(() => ({})).then(d => {
throw new Error((d && d.detail) || ('HTTP ' + r.status));
});
}
const m = /filename="([^"]+)"/.exec(r.headers.get('Content-Disposition') || '');
return r.blob().then(b => ({ b: b, name: m ? m[1] : 'relief.pdf' }));
})
.then(({ b, name }) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
el('printMsg').textContent = 'Téléchargé : ' + name;
})
.catch(e => { el('printMsg').textContent = 'Échec : ' + e.message; })
.finally(() => { btn.disabled = false; });
}
el('printGo').addEventListener('click', printExport);
el('printHere').addEventListener('click', printCenterHere);
el('printFit').addEventListener('click', printFit);
for (const id of ['printPaper', 'printOrient', 'printScale']) {
el(id).addEventListener('change', () => { printSave(); printRefreshFrame(true); });
}
el('printTitle').addEventListener('change', printSave);
printLoad();
document.addEventListener('panel:tab', (e) => {
printSetOpen(e.detail.visible === 'print', !BOOTSTRAPPING && !RESTORING_TAB);
if (e.detail.visible !== 'gen' && genDrawing) genSetDrawing(false);
});
// --- panneau à onglets --------------------------------------------------------
const PANEL_LS = 'lidar-panel';
// Ne retient que les clés connues, du bon type et (pour sheet) dans la liste
// valide : une valeur corrompue casserait sinon le CSS (panel.dataset.sheet
// inconnu de map.css) ou l'onglet affiché.
function _sanitizedPanelLS() {
const raw = lsGet(PANEL_LS, {});
const s = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
const out = {};
if (typeof s.tab === 'string') out.tab = s.tab;
if (typeof s.collapsed === 'boolean') out.collapsed = s.collapsed;
if (['closed', 'half', 'full'].includes(s.sheet)) out.sheet = s.sheet;
return out;
}
const PANEL = Object.assign({ tab: 'view', collapsed: false, sheet: 'half' }, _sanitizedPanelLS());
// want = onglet choisi (mémorisé) ; tab = onglet affiché. Génération encore
// inconnue au démarrage (api/status) : Affichage en attendant, puis l'onglet
// choisi revient dès qu'il est proposé.
PANEL.want = PANEL.tab;
const phoneQuery = window.matchMedia('(max-width: 720px)');
const isPhone = () => phoneQuery.matches;
// Onglets proposés : Génération seulement si le générateur répond et que ce
// navigateur a le droit de lancer (même règle que les anciens boutons).
function panelTabs() {
return ['view', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
}
// Onglet réellement visible, ou null (panneau replié, volet fermé, ou fiche
// détaillée affichée par-dessus — auquel cas AUCUN onglet n'est à l'écran,
// pas même celui mémorisé : panel:tab doit fermer le cadre d'export s'il
// était ouvert, quel que soit ce qui a déclenché ce rendu du panneau — grip
// glissée/touchée, redimensionnement, changement phoneQuery, le
// setTimeout(panelRender, 220) d'openTab…).
function panelVisibleTab() {
if (!el('tileView').hidden) return null;
if (isPhone() ? PANEL.sheet === 'closed' : PANEL.collapsed) return null;
return PANEL.tab;
}
function panelSave() { lsSet(PANEL_LS, { tab: PANEL.want, collapsed: PANEL.collapsed, sheet: PANEL.sheet }); }
function panelRender() {
const tabs = panelTabs();
PANEL.tab = tabs.includes(PANEL.want) ? PANEL.want : 'view';
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b => {
b.hidden = !tabs.includes(b.dataset.tab);
b.setAttribute('aria-selected', String(b.dataset.tab === PANEL.tab));
});
if (el('tileView').hidden) {
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
}
const panel = el('panel');
// Repli en bande d'icônes : ordinateur seulement (le téléphone a son volet).
const collapsed = PANEL.collapsed && !isPhone();
panel.classList.toggle('collapsed', collapsed);
panel.dataset.sheet = PANEL.sheet;
document.body.classList.toggle('panel-collapsed', collapsed);
// Hauteur couverte par le volet, que l'échelle, l'attribution et les messages
// dégagent ; volet plein : la carte est cachée, rien à dégager (sinon ils
// remonteraient en haut de l'écran, sur les outils).
const sheetPx = isPhone() && PANEL.sheet !== 'full' ? panel.getBoundingClientRect().height : 0;
document.body.style.setProperty('--sheet-h', sheetPx + 'px');
document.dispatchEvent(new CustomEvent('panel:tab', { detail: { tab: PANEL.tab, visible: panelVisibleTab() } }));
}
function openTab(tab) {
if (!panelTabs().includes(tab)) return;
if (!el('tileView').hidden) hideTileView();
const before = PANEL.sheet;
// Onglet actif retouché sur téléphone : le volet change de hauteur.
if (isPhone() && tab === PANEL.tab && PANEL.sheet !== 'closed') {
PANEL.sheet = PANEL.sheet === 'half' ? 'full' : 'half';
} else if (isPhone() && PANEL.sheet === 'closed') {
PANEL.sheet = 'half';
}
const resized = isPhone() && PANEL.sheet !== before;
PANEL.want = PANEL.tab = tab;
PANEL.collapsed = false;
panelSave(); panelRender();
if (resized) setTimeout(panelRender, 220); // hauteur finale après la transition CSS
}
function setCollapsed(on) { PANEL.collapsed = !!on; panelSave(); panelRender(); map.invalidateSize(); }
function setSheet(state) {
if (!['closed', 'half', 'full'].includes(state)) return;
PANEL.sheet = state; panelSave();
panelRender();
setTimeout(panelRender, 220); // hauteur finale après la transition CSS
}
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b =>
b.addEventListener('click', () => openTab(b.dataset.tab)));
el('panelCollapse').addEventListener('click', () => setCollapsed(!PANEL.collapsed));
phoneQuery.addEventListener('change', panelRender);
window.addEventListener('resize', panelRender); // hauteur du volet en vh (rotation)
// Poignée du volet : glisser vers le haut / le bas, relâcher sur la hauteur
// la plus proche ; un simple toucher fait monter d'un cran.
(function sheetDrag() {
const grip = el('sheetGrip');
let start = null;
grip.addEventListener('pointerdown', (e) => {
start = { y: e.clientY, h: el('panel').getBoundingClientRect().height };
grip.setPointerCapture(e.pointerId);
el('panel').style.transition = 'none';
});
grip.addEventListener('pointermove', (e) => {
if (!start) return;
const h = Math.max(72, Math.min(window.innerHeight - 16, start.h + start.y - e.clientY));
el('panel').style.height = h + 'px';
});
const end = (e) => {
if (!start) return;
const moved = Math.abs(e.clientY - start.y) > 6;
const h = el('panel').getBoundingClientRect().height;
el('panel').style.height = ''; el('panel').style.transition = '';
start = null;
if (!moved) { setSheet(PANEL.sheet === 'closed' ? 'half' : PANEL.sheet === 'half' ? 'full' : 'half'); return; }
const stops = { closed: 72, half: window.innerHeight * 0.45, full: window.innerHeight - 16 };
const best = Object.entries(stops).sort((a, b) => Math.abs(a[1] - h) - Math.abs(b[1] - h))[0][0];
setSheet(best);
};
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
})();
// --- démarrage ------------------------------------------------------------
function fitData() {
if (META.bounds) {
map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]],
{ padding: [30, 30] });
} else {
map.setView([46.6, 2.5], 6);
}
}
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
if (!META.layers.length) {
toast('Aucune visualisation disponible dans le cache.', 10000);
}
STATE = loadState(META);
// L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
// suite : ce qui est à l'écran est ce qui sera rechargé.
saveState();
const hash = parseHash();
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
else fitData();
buildLayers();
renderPanel();
renderBaseRow();
panelRender();
BOOTSTRAPPING = false;
renderUseCard();
genFillViz();
// Droits de génération : boutons affichés si le serveur peut générer
// (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
// réseau autorisé (LIDAR_REGEN_CIDR) — sinon masqués silencieusement.
fetch('api/status').then(r => r.ok ? r.json() : null).then(d => {
if (!d) return;
GEN.available = !!d.available;
GEN.allowed = d.regen_allowed !== false;
const show = GEN.available && GEN.allowed;
panelRender();
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => {
toast('Serveur de tuiles injoignable.', 10000);
panelRender();
// Ce rendu-là compte aussi comme le premier (échec du chargement initial) :
// sans ce reset, un onglet Export mémorisé ne recadrerait plus JAMAIS la
// vue, même après une ouverture explicite ultérieure.
BOOTSTRAPPING = false;
});
let applyingHash = false;
window.addEventListener('hashchange', () => {
// Lien partagé collé dans l'onglet courant : la vue (et l'état des couches
// s'il est présent) est appliquée sans rechargement.
if (applyingHash || !META) return;
const h = parseHash();
if (!h) return;
applyingHash = true;
map.setView([h.lat, h.lng], h.zoom);
if (h.main || h.view || h.base) {
STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
}
setTimeout(() => { applyingHash = false; }, 0);
});
map.on('moveend zoomend', saveHashSoon);
map.on('click', (e) => {
// Pas de bulle pendant un tracé de zone ni juste après le relâchement de
// la poignée du cadre d'export.
if (genDrawing || Date.now() < genIgnoreClickUntil || PRINT.dragging) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => showTilePopup(d, e.latlng));
});
el('btnShare').addEventListener('click', () => {
const url = location.origin + location.pathname + viewHash(true);
history.replaceState(null, '', url);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(() => toast('Lien copié'),
() => window.prompt('Lien :', url));
} else { window.prompt('Lien :', url); }
});
el('btnDefault').addEventListener('click', () => {
const body = {
main: STATE.main,
mode: STATE.mode,
precision_opacity: STATE.precOpacity,
base: STATE.base,
};
fetch('api/map/defaults', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
.then(() => {
META.default_main = STATE.main; META.default_mode = STATE.mode;
META.default_precision_opacity = STATE.precOpacity; META.default_base = STATE.base;
toast('Affichage enregistré comme défaut');
})
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
});
el('modeSeg').querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => setMode(b.dataset.mode)));
el('mainSel').addEventListener('change', (e) => {
STATE.main = e.target.value;
saveState(); renderPanel(); applyLayers();
});
el('precOpacity').addEventListener('input', (e) => {
STATE.precOpacity = Number(e.target.value) / 100;
el('precPct').textContent = e.target.value + '%';
const layer = LAYERS.get(precisionKey());
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.precOpacity);
});
el('precOpacity').addEventListener('change', saveState);
el('btnReset').addEventListener('click', () => {
// Oublie l'état local et reprend la configuration servie par le serveur.
lsDel(LS_KEY);
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
STATE = defaultState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
toast('Configuration par défaut rétablie');
}).catch(() => toast('Serveur injoignable', 6000));
});
el('baseToggle').addEventListener('click', () => {
STATE.base.on = !STATE.base.on;
saveState(); renderBaseRow(); applyLayers();
});
el('baseName').addEventListener('click', () => {
STATE.base.dark = !(STATE.base.dark ?? themeIsDark());
saveState(); applyLayers();
});
el('baseOpacity').addEventListener('input', (e) => {
STATE.base.opacity = Number(e.target.value) / 100;
el('basePct').textContent = e.target.value + '%';
base.setOpacity(STATE.base.opacity);
});
el('baseOpacity').addEventListener('change', saveState);
el('btnTheme').addEventListener('click', () => {
THEME = applyTheme(THEME_NEXT[THEME]);
lsSet(THEME_LS, THEME);
});
if (darkQuery && darkQuery.addEventListener) {
darkQuery.addEventListener('change', () => {
if (THEME !== 'auto') return;
if (STATE) applyLayers();
redrawRose();
});
}
el('zoomIn').addEventListener('click', () => map.zoomIn(1));
el('zoomOut').addEventListener('click', () => map.zoomOut(1));
el('zoomFit').addEventListener('click', fitData);
el('gps').addEventListener('click', () => {
if (!('geolocation' in navigator)) {
toast('Géolocalisation indisponible (HTTPS requis).', 6000);
return;
}
const btn = el('gps');
btn.classList.add('busy');
navigator.geolocation.getCurrentPosition((pos) => {
btn.classList.remove('busy');
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, Math.max(map.getZoom(), 16));
if (window._gpsMarker) window._gpsMarker.remove();
window._gpsMarker = L.circleMarker(ll, {
radius: 6, color: cssVar('--gps'), fillColor: cssVar('--gps'), fillOpacity: 0.9
}).addTo(map);
}, (err) => {
btn.classList.remove('busy');
const why = err && err.code === 1 ? 'Autorisation refusée'
: err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible';
toast(why + ' (la géolocalisation exige HTTPS).', 6000);
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
});
document.addEventListener('keydown', (e) => {
const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
if (typing || e.ctrlKey || e.metaKey || e.altKey) {
if (e.key === 'Escape' && typing) e.target.blur();
return;
}
// P : relief → précision → les deux → relief
if ((e.key === 'p' || e.key === 'P') && META && STATE && precisionKey()) {
const i = VIEW_KEYS.indexOf(STATE.mode);
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return;
}
// 1–4 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-4]$/.test(e.key)) {
const tab = ['view', 'print', 'gen', 'share'][Number(e.key) - 1];
if (panelTabs().includes(tab)) openTab(tab);
return;
}
if (e.key === 'Escape') {
if (genDrawing) { genSetDrawing(false); return; }
if (closeTileViews()) return; // bulle / fiche de dalle (Task 4)
if (isPhone()) setSheet('closed'); else setCollapsed(true);
}
});