Files
lidar_rendu/lidar_pipeline/web/map.js
2026-09-27 17:26:41 +02:00

1238 lines
50 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);
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;
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', {
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: '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,
dark: b.dark !== undefined ? !!b.dark : true },
};
}
function loadState(meta) {
const def = defaultState(meta);
let st = def;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
const s = JSON.parse(raw);
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, s.base || {}) };
}
} catch (e) { st = def; }
// 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() {
try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
}
// --- 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);
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
+ ':' + (STATE.base.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();
if (container) container.classList.toggle('base-dark', STATE.base.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 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: '#ffd24a', weight: 2,
fill: false, dashArray: '6 4',
interactive: false }).addTo(map);
}
function ignSection(ign) {
if (!ign) return '<div class="meta">Chargement des informations IGN…</div>';
if (ign.error) return '<div class="meta">Catalogue IGN injoignable pour le moment.</div>';
if (!ign.found) return '<div class="meta">Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).</div>';
const start = ign.acquisition_start, end = ign.acquisition_end;
let when = fmtDate(start, true);
if (start && end && String(start).slice(0, 10) === String(end).slice(0, 10) && String(end).length > 10) {
when = fmtDate(start, true) + ' → ' + new Date(end).toLocaleTimeString('fr-FR',
{ hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
} else if (end && String(end).slice(0, 10) !== String(start || '').slice(0, 10)) {
when = fmtDate(start) + ' → ' + fmtDate(end);
}
const pts = ign.points ? Number(ign.points).toLocaleString('fr-FR') + ' points' : null;
return kv([
['Scan LiDAR', when],
['Capteur', (ign.sensors || []).map(esc).join('<br>')],
['Mission', esc(ign.mission)],
['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('pour ' + ign.acquisition_owner)]
.filter(Boolean).map(esc).join(' ')],
['Édition IGN', ign.edition_date ? fmtDate(ign.edition_date) : null],
['Classement', esc(ign.classification_process)],
['Nuage', pts],
]) + (ign.download_url
? '<a class="info-dl" href="' + esc(ign.download_url) + '" target="_blank" rel="noopener">⤓ Télécharger le nuage LiDAR IGN (.copc.laz)</a>'
: '') + (ign.stac_url
? '<div class="meta"><a href="' + esc(ign.stac_url) + '" target="_blank" rel="noopener">Fiche du catalogue</a></div>' : '');
}
function calageSection(c) {
if (!c || !c.beams || !c.beams.length) return '';
const offs = Object.entries(c.offsets_cm || {}).map(([k, v]) => k + ' : ' + (v > 0 ? '+' : '') + String(v).replace('.', ',') + ' cm');
const lines = Object.values(c.lines_rms_cm || {});
return '<div class="info-sec"><h4>Recalage vertical des passes</h4>' + kv([
['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
['Décalages', offs.length ? offs.join('<br>') : 'aucun ≥ 0,5 cm'],
['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
]) + '</div>';
}
function renderTileCard(data, ign) {
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ ' + (data.found ? 'Régénérer' : 'Générer') + ' cette dalle</button></div>'
: '';
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
const 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);
el('tilecard').hidden = false;
}
function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
if (!data) {
el('tileTitle').textContent = 'Dalle';
el('tileBody').innerHTML = '<div class="meta">Serveur injoignable : réessayez dans un instant.</div>';
el('tilecard').hidden = false;
return;
}
selectTileOutline(data);
renderTileCard(data, null);
// Fiche IGN à part : la sélection s'affiche sans attendre le catalogue.
const key = data.col + '_' + data.row;
showTileInfo.current = key;
fetch('api/map/ign?col=' + data.col + '&row=' + data.row)
.then(r => r.ok ? r.json() : { error: 'HTTP ' + r.status })
.catch(() => ({ error: 'réseau' }))
.then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
}
// --- rose des vents du relief orienté --------------------------------------
// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
// chroma 60 · L*(100−L*)/2500, à clarté fixe.
function labToRgb(L, a, b) {
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const finv = t => (t > 6 / 29 ? t * t * t : 3 * (6 / 29) * (6 / 29) * (t - 4 / 29));
const X = 0.95047 * finv(fx), Y = finv(fy), Z = 1.08883 * finv(fz);
const lin = [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z,
0.0557 * X - 0.2040 * Y + 1.0570 * Z];
return lin.map(c => {
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, v)) * 255);
});
}
function drawRose() {
const cv = el('roseCanvas');
if (!cv || drawRose.done) return;
drawRose.done = true;
const dpr = window.devicePixelRatio || 1, size = 132;
cv.width = size * dpr; cv.height = size * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
const c = size / 2, rOut = 50, rIn = 22, L = 64;
const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
const img = ctx.createImageData(size * dpr, size * dpr);
for (let py = 0; py < size * dpr; py++) {
for (let px = 0; px < size * dpr; px++) {
const x = px / dpr - c, y = py / dpr - c, r = Math.hypot(x, y);
if (r > rOut || r < rIn) continue;
const compass = (Math.atan2(x, -y) * 180 / Math.PI + 360) % 360; // 0 = N, sens horaire
const h = ((compass + 90) % 360) * Math.PI / 180;
const [R, G, B] = labToRgb(L, chroma * Math.cos(h), chroma * Math.sin(h));
const i = (py * size * dpr + px) * 4;
img.data[i] = R; img.data[i + 1] = G; img.data[i + 2] = B; img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '600 11px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
for (const [t, deg] of labels) {
const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
ctx.fillText(t, x, y);
}
}
function updateRose() {
const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
&& LAYERS.get('relief_oriente'));
el('rose').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="use-row"><select id="useLayer">' + opts + '</select>' +
'<button id="useCopy">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();
el('usecard').hidden = false;
}
// --- 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;
el('gencard').hidden = false;
}
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 }]);
genOpenCard('1 dalle sélectionnée — options puis « Générer ».');
el('tilecard').hidden = true;
})
.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: '#4cc9f0', 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();
}).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('genClose').addEventListener('click', () => {
el('gencard').hidden = true;
if (genDrawing) genSetDrawing(false);
});
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 };
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];
try { localStorage.setItem(PRINT_LS, JSON.stringify(s)); } catch (e) { /* privé */ }
}
function printLoad() {
try {
const s = JSON.parse(localStorage.getItem(PRINT_LS) || '{}');
for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'],
['scale', 'printScale'], ['title', 'printTitle']]) {
if (s[k] != null) el(id).value = s[k];
}
if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center);
} catch (e) { /* privé ou illisible : réglages par défaut */ }
}
// Recadre sur le cadre en laissant libre la bande basse couverte par les
// cartes (couches, export…) : sinon le bas du cadre passe dessous.
function printFit() {
if (!PRINT.corners) return;
const box = map.getContainer().getBoundingClientRect();
let bottom = 0;
for (const c of document.querySelectorAll('.card')) {
if (c.hidden || !c.offsetParent) continue;
const r = c.getBoundingClientRect();
if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top);
}
map.fitBounds(L.latLngBounds(PRINT.corners),
{ paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] });
}
function printDraw(corners) {
PRINT.corners = corners;
if (PRINT.rect) PRINT.rect.setLatLngs(corners);
else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: '#ffd166', 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.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();
}
function printCenterHere() {
PRINT.center = map.getCenter();
printSave();
printRefreshFrame();
}
function printSetOpen(on) {
PRINT.on = on;
el('printcard').hidden = !on;
el('btnPrint').classList.toggle('accent', on);
if (on) {
// cadre gardé s'il est encore dans la vue, sinon posé au centre
if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter();
printRefreshFrame(true);
} 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('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
el('printClose').addEventListener('click', () => printSetOpen(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();
// --- 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();
el('stat').textContent = META.layers.length + ' couche(s)';
const hash = parseHash();
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
else fitData();
buildLayers();
renderPanel();
renderBaseRow();
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;
el('btnZone').hidden = !show;
el('btnComplete').hidden = !show;
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
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) => {
if (genDrawing || Date.now() < genIgnoreClickUntil) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => showTileInfo(d, e.latlng));
});
el('cardToggle').addEventListener('click', () =>
el('layercard').classList.toggle('collapsed'));
el('tileClose').addEventListener('click', () => {
el('tilecard').hidden = true;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
el('btnUse').addEventListener('click', renderUseCard);
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.
try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
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;
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('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: '#39d98a', fillColor: '#39d98a', 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) => {
// P : relief → précision → les deux → relief (hors saisie de texte)
const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
&& META && STATE && precisionKey()) {
const i = VIEW_KEYS.indexOf(STATE.mode);
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return;
}
if (e.key === 'Escape') {
el('tilecard').hidden = true; el('usecard').hidden = true;
if (genDrawing) genSetDrawing(false);
}
});