'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 = '';
const SVG_EYE_OFF = '';
// Affichage : une couche principale (relief orienté) et la couche
// « précision » (densité de points sol), montrée seule ou comparée : une
// barre glissante sépare deux couches (relief, précision ou OSM seul) de
// part et d'autre. Touche P : mode suivant.
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['compare', 'Comparer']];
const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
const COMPARE_SIDES = [['relief', 'Relief'], ['precision', 'Précision'], ['osm', 'OSM seul']];
const validSide = (v) => (COMPARE_SIDES.some(s => s[0] === v) ? v : null);
const DEFAULT_COMPARE = { left: 'relief', right: 'precision', pos: 50 };
function validCompare(c) {
c = c || {};
const pos = Number(c.pos);
return { left: validSide(c.left) || DEFAULT_COMPARE.left,
right: validSide(c.right) || DEFAULT_COMPARE.right,
pos: Number.isFinite(pos) ? Math.min(95, Math.max(5, pos)) : DEFAULT_COMPARE.pos };
}
// 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;
// --- 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: '© OpenStreetMap'
}).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',
compare: validCompare(null),
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,
// Ancien mode « les deux » (produit) mémorisé : relief.
mode: validMode(s.mode === 'both' ? 'relief' : s.mode) || def.mode,
compare: validCompare(s.compare),
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;
if (hash.compare) st.compare = hash.compare;
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&C=gauche:droite:position&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, compare: 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(':');
// Ancien mode « les deux » (produit) : s'ouvre en relief. L'opacité qui
// suivait (P=mode:opacité) est ignorée.
const mode = validMode(f[0] === 'both' ? 'relief' : f[0]);
if (mode) out.view = { mode };
}
if (seg.C) {
const f = seg.C.split(':');
out.compare = validCompare({ left: f[0], right: f[1], pos: f[2] });
}
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;
if (STATE.mode === 'compare') {
h += '&C=' + STATE.compare.left + ':' + STATE.compare.right + ':' + Math.round(STATE.compare.pos);
}
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() {
// Pile LiDAR commune (z 400). 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;
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
});
// Le conteneur de tuiles n'existe qu'après l'ajout : la découpe de la
// comparaison lui est appliquée à ce moment-là.
layer.on('add', applyCompareClip);
LAYERS.set(info.key, layer);
});
applyLayers();
}
// Mode réellement appliqué : sans couche précision servie, relief seul.
function effectiveMode() {
return precisionKey() ? STATE.mode : 'relief';
}
// Couche affichée pour un côté de la comparaison (null = OSM seul).
function sideLayerKey(side) {
return side === 'relief' ? STATE.main : side === 'precision' ? precisionKey() : null;
}
function applyLayers() {
const prec = precisionKey();
const mode = effectiveMode();
const wanted = new Set(
mode === 'relief' ? [STATE.main]
: mode === 'precision' ? [prec]
: [sideLayerKey(STATE.compare.left), sideLayerKey(STATE.compare.right)]);
for (const [key, layer] of LAYERS) {
const pane = map.getPane('lidar-' + key);
if (pane) pane.style.zIndex = key === prec ? '402' : '401';
if (wanted.has(key)) { if (!map.hasLayer(layer)) layer.addTo(map); layer.setOpacity(1); }
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);
applyCompareClip();
updateRose();
}
// Plage de la séparation, en px du conteneur de la carte : la poignée reste
// dans la partie visible de la carte, jamais sous le panneau (ordinateur :
// panneau ouvert ou bande d'icônes, toute la hauteur) ni sous la colonne
// d'outils. COMPARE_MARGIN = demi-largeur de la poignée + marge.
const COMPARE_MARGIN = 24;
function compareBounds() {
const r = map.getContainer().getBoundingClientRect();
let min = COMPARE_MARGIN, max = r.width - COMPARE_MARGIN;
if (!isPhone()) {
min = Math.max(min, el('panel').getBoundingClientRect().right - r.left + COMPARE_MARGIN);
max = Math.min(max, el('tools').getBoundingClientRect().left - r.left - COMPARE_MARGIN);
}
// Fenêtre trop étroite : la séparation reste au milieu de la plage.
if (min > max) min = max = (min + max) / 2;
return { left: r.left, min, max };
}
// Découpe des couches de part et d'autre de la barre, en coordonnées de
// calque (les conteneurs de tuiles se déplacent avec la carte) : recalculée
// à chaque déplacement. Même couche des deux côtés : aucune découpe.
// STATE.compare.pos garde la valeur demandée (lien, glisser) ; la séparation
// affichée, barre et découpe ensemble, est bornée à compareBounds().
function applyCompareClip() {
if (!STATE) return;
const on = effectiveMode() === 'compare';
el('compareBar').hidden = !on;
const size = map.getSize();
let x = size.x * (on ? STATE.compare.pos : 50) / 100;
const bounds = on ? compareBounds() : null;
if (bounds) x = Math.min(bounds.max, Math.max(bounds.min, x));
const nw = map.containerPointToLayerPoint([0, 0]);
const se = map.containerPointToLayerPoint(size);
const cx = map.containerPointToLayerPoint([x, 0]).x;
const leftKey = sideLayerKey(STATE.compare.left), rightKey = sideLayerKey(STATE.compare.right);
for (const [key, layer] of LAYERS) {
const c = layer.getContainer && layer.getContainer();
if (!c) continue;
let clip = '';
if (on && leftKey !== rightKey) {
if (key === leftKey) clip = `polygon(${nw.x}px ${nw.y}px, ${cx}px ${nw.y}px, ${cx}px ${se.y}px, ${nw.x}px ${se.y}px)`;
else if (key === rightKey) clip = `polygon(${cx}px ${nw.y}px, ${se.x}px ${nw.y}px, ${se.x}px ${se.y}px, ${cx}px ${se.y}px)`;
}
c.style.clipPath = clip;
}
if (on) el('compareBar').style.left = (bounds.left + x) + 'px';
}
map.on('move', applyCompareClip);
map.on('resize zoomend', applyCompareClip);
// Panneau replié, déplié, volet redimensionné : la plage de la barre change.
document.addEventListener('panel:tab', applyCompareClip);
(function compareDrag() {
const bar = el('compareBar');
let dragging = false;
bar.addEventListener('pointerdown', (e) => { dragging = true; bar.setPointerCapture(e.pointerId); e.preventDefault(); });
bar.addEventListener('pointermove', (e) => {
if (!dragging) return;
const b = compareBounds();
const x = Math.min(b.max, Math.max(b.min, e.clientX - b.left));
STATE.compare.pos = Math.min(95, Math.max(5, 100 * x / map.getSize().x));
applyCompareClip();
});
const end = () => { if (dragging) { dragging = false; saveState(); } };
bar.addEventListener('pointerup', end);
bar.addEventListener('pointercancel', end);
})();
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 => '').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('compareCtl').hidden = mode !== 'compare';
for (const [id, side] of [['compareLeft', 'left'], ['compareRight', 'right']]) {
const sel = el(id);
sel.innerHTML = COMPARE_SIDES.map(([k, lbl]) => '').join('');
}
el('precLegend').hidden = mode === 'relief'
|| (mode === 'compare' && STATE.compare.left !== 'precision' && STATE.compare.right !== 'precision');
}
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 '
' + rows.filter(r => r[1] != null && r[1] !== '')
.map(r => '- ' + r[0] + '
- ' + r[1] + '
').join('') + '
';
}
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 'Chargement des informations IGN…
';
if (ign.error) return 'Catalogue IGN injoignable pour le moment.
';
if (!ign.found) return 'Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).
';
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('
')],
['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
? '⤓ Télécharger le nuage LiDAR IGN (.copc.laz)'
: '') + (ign.stac_url
? '' : '');
}
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 'Recalage vertical des passes
' + kv([
['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
['Décalages', offs.length ? offs.join('
') : 'aucun ≥ 0,5 cm'],
['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
]) + '';
}
function renderTileCard(data, ign) {
const regenBtn = (GEN.available && GEN.allowed)
? ''
: '';
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
const bb = data.l93_bbox || [];
let html = '' + esc(data.name) + '
' +
'Emprise
' + kv([
['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') +
'
Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null],
['Surface', '1 km × 1 km'],
]) + '' +
'Acquisition LiDAR (IGN)
' + ignSection(ign) + '';
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 += 'Rendu
' + kv([
['Résolution', String(data.resolution).replace('.', ',') + ' m/px'],
['Classification', esc(data.method || 'inconnue')],
['Généré le', esc(data.generated || '—')],
['Couches', esc(viz || '—')],
]) + '' + calageSection(data.calage);
} else {
html += 'Pas encore de rendu sur cette dalle.
';
}
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 = 'Serveur injoignable : réessayez dans un instant.
';
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 SELECTED_TILE = null; // données de la dalle sélectionnée (ou null)
// Libellé de l'onglet : « Dalle » sans sélection, « Dalle col · row » avec.
function tileTabLabel() {
return SELECTED_TILE ? 'Dalle ' + SELECTED_TILE.col + ' · ' + SELECTED_TILE.row : 'Dalle';
}
// Rafraîchit l'onglet Dalle (libellé + bascule hint/fiche) qu'il soit visible
// ou non : son contenu doit être à jour dès qu'on l'ouvre.
function renderTabTile() {
el('tileTabLbl').textContent = tileTabLabel();
el('tileHint').hidden = !!SELECTED_TILE;
el('tileCard').hidden = !SELECTED_TILE;
if (SELECTED_TILE) showTileInfo(SELECTED_TILE, lastTileQuery);
}
// Sélectionne une dalle (contour + fiche dans l'onglet Dalle) sans changer
// l'onglet affiché.
function selectTile(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
SELECTED_TILE = data;
selectTileOutline(data);
renderTabTile();
}
// Désélectionne (re-clic sur la même dalle, Échap) : retire le contour et
// repasse l'onglet Dalle sur son indication.
function clearTileSelection() {
SELECTED_TILE = null;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
renderTabTile();
}
// --- 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() {
// Visible dès que le relief est affiché : seul, ou d'un côté de la barre.
const mode = STATE && effectiveMode();
const reliefShown = mode === 'relief'
|| (mode === 'compare' && (STATE.compare.left === 'relief' || STATE.compare.right === 'relief'));
const on = !!(STATE && STATE.main === 'relief_oriente' && reliefShown
&& 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 =>
'').join('');
el('useBody').innerHTML =
'' +
'
' +
'URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' +
'' +
'' +
'' +
'' +
'Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
';
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 =>
'').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 }]);
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);
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', 'tile', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
}
// Onglet réellement visible, ou null (panneau replié ou volet fermé —
// 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 (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));
});
for (const t of ['view', 'tile', '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;
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 || h.compare) {
STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
}
setTimeout(() => { applyingHash = false; }, 0);
});
map.on('moveend zoomend', saveHashSoon);
map.on('click', (e) => {
// Pas de sélection 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 => {
if (!d) { toast('Serveur injoignable : réessayez dans un instant.', 5000); return; }
// Re-clic sur la dalle déjà sélectionnée : désélection. Sinon la
// sélection se déplace, sans changer l'onglet affiché.
if (SELECTED_TILE && SELECTED_TILE.col === d.col && SELECTED_TILE.row === d.row) {
clearTileSelection();
} else {
selectTile(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,
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_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();
});
for (const [id, side] of [['compareLeft', 'left'], ['compareRight', 'right']]) {
el(id).addEventListener('change', (e) => {
STATE.compare[side] = validSide(e.target.value) || STATE.compare[side];
saveState(); renderPanel(); applyLayers();
});
}
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 → comparer → 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–5 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-5]$/.test(e.key)) {
const tab = ['view', 'tile', 'print', 'gen', 'share'][Number(e.key) - 1];
if (panelTabs().includes(tab)) openTab(tab);
return;
}
if (e.key === 'Escape') {
if (genDrawing) { genSetDrawing(false); return; }
if (SELECTED_TILE) { clearTileSelection(); return; }
if (isPhone()) setSheet('closed'); else setCollapsed(true);
}
});