Files
lidar_rendu/lidar_pipeline/web/map.js
Antoine fb892ea9f2 Translate the whole project to English and fix outdated comments and help
Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:16:45 +02:00

1697 lines
70 KiB
JavaScript
Raw Permalink 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';
/* LiDAR map — XYZ pyramid (EPSG:3857), one L.tileLayer per visualisation.
LOD, caching and animation are Leaflet's own: no image levels, no CSS
rotation, no home-made mosaic. */
const UI_VERSION = "__UI_VERSION__";
const LS_KEY = 'lidarMapView_v2';
const el = (id) => document.getElementById(id);
// --- local storage ----------------------------------------------------------
// Every access goes through here: in private browsing (or with storage
// blocked) localStorage throws — the map then starts from its defaults.
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) { /* private browsing */ }
}
function lsDel(key) {
try { localStorage.removeItem(key); } catch (e) { /* private browsing */ }
}
// --- end of storage ----------------------------------------------------------
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>';
// Display: one main layer (oriented relief) and the "precision" layer
// (ground point density), shown alone or compared: a sliding divider
// separates two layers (relief, precision or OSM only) on either side.
// P key: next mode.
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Precision'], ['compare', 'Compare']];
const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
const COMPARE_SIDES = [['relief', 'Relief'], ['precision', 'Precision'], ['osm', 'OSM only']];
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 };
}
// Precision levels: level k from 0.25 × 2^(k/2) pts/m²
// (visualizations.density_levels), grey = "gray" colormap over 0..15.
const PREC_LEVELS = 16;
let META = null;
let STATE = null;
// True until the panel's first render at startup: keeps a remembered Export
// tab from reframing the view over a shared link or the initial URL
// (see printSetOpen). Reset to false in both the .then() AND the .catch() of
// the /api/map/meta load (otherwise a server unreachable at startup leaves it
// stuck at true forever: the Export tab would never reframe again).
let BOOTSTRAPPING = true;
// --- theme ------------------------------------------------------------------
// auto = follows the system; ☀/☾ forces light or dark; third press: 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: 'Theme: follows the system', light: 'Theme: light', dark: 'Theme: dark' };
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] + ' (click to change)'; }
if (typeof applyLayers === 'function' && STATE) applyLayers(); // dimmed OSM base map
redrawRose();
return p;
}
// Legend rose: its cardinal labels use the text colour, so it is redrawn
// on every theme change (if already drawn).
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(); // key → L.TileLayer
const map = L.map('map', {
zoomControl: false,
attributionControl: true,
minZoom: 3,
// Zoom capped at native (19 = 0.2 m/px, 1 screen px = 1 LiDAR px): the whole
// pyramid is generated in advance, never an upscaled or stretched tile.
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);
// During the zoom animation, transforms ONLY (no level created, tiles loaded
// at the end): Chrome does not composite a fresh level (empty at first, it is
// animated on the main thread, frozen ~90 ms by loading). The base map jumped
// while the oriented relief, which keeps its even level, slid smoothly — the
// two layers went out of sync. Leaflet 1.9 also fires "zoom" at the START of
// the animation: same guard in _resetView while the map is animating.
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);
}
});
// --- tile loading -----------------------------------------------------------
// The counter is recomputed from the actual state of each layer's tiles
// (current-level tiles not loaded yet) rather than +1/−1 on events: a tile
// removed before it finishes loading (fast panning) fires neither "tileload"
// nor "tileerror", so an event-based counter would drift. Batch: from the
// first pending tile back to zero; the bar shows the share of the batch
// already loaded.
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 ? ' tiles left' : ' tile left');
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 = 'View loaded';
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', {
// Class set at creation from the current theme (not a hard-coded "dark"):
// otherwise the base map flashes dark in the light theme until /api/map/meta
// answers (and stays dark if the call fails). applyLayers() corrects it later.
maxZoom: 19, maxNativeZoom: 19, keepBuffer: 2, className: themeIsDark() ? 'base-dark' : '',
// Otherwise _setView recreates a level mid-animation despite 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);
}
// --- persisted state ------------------------------------------------------
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;
}
// Relief intensity: contrast applied to the main layer's container (a
// personal comfort setting, never saved as a server default nor applied to
// the exported PDF).
function clampIntensity(v) {
const n = Number(v);
return Number.isFinite(n) ? Math.round(Math.min(2, Math.max(0.5, n)) * 10) / 10 : 1;
}
const fmtIntensity = (v) => String(v) + '×';
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),
intensity: 1,
base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
// null = follows the theme (explicit choice only if b.dark is
// already a boolean set server-side).
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) {
// Old format (before v3): the former webapp persisted base.dark:true on
// every save, never a real user choice — ignored here (the base map then
// follows the theme); only v3 writes an explicit dark (see saveState).
const sBase = Object.assign({}, s.base || {});
if (s.v !== 3) delete sBase.dark;
st = { main: validMain(meta, s.main) || def.main,
// Old remembered "both" mode (product of the layers): relief.
mode: validMode(s.mode === 'both' ? 'relief' : s.mode) || def.mode,
compare: validCompare(s.compare),
intensity: clampIntensity(s.intensity),
base: Object.assign({}, def.base, sBase) };
}
// Shared link: takes precedence over the local state (old &L=… links from
// the layer stack are ignored, the view then opens on the current state).
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;
if (hash.intensity != null) st.intensity = hash.intensity;
// Full link (layer or mode in the hash) without &I: the link export only
// writes &I when ≠ 1 (viewHash), so its absence means 1×, never the
// comfort intensity left in this browser's localStorage.
else if (hash.main || hash.view) st.intensity = 1;
}
return st;
}
function saveState() {
// v: 3 marks a format where base.dark, when set, is a real user choice
// (see loadState) — never mixed into STATE itself.
lsSet(LS_KEY, Object.assign({ v: 3 }, STATE));
}
// --- view hash ------------------------------------------------------------
// #z/lat/lng&M=main&P=mode&C=left:right:position&B=on:opacity:dark&I=intensity
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, intensity: 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.I) out.intensity = clampIntensity(seg.I);
if (seg.P) {
const f = seg.P.split(':');
// Old "both" mode (product of the layers): opens as relief. The opacity
// that followed it (P=mode:opacity) is ignored.
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');
if (STATE.intensity !== 1) h += '&I=' + STATE.intensity;
}
return h;
}
let hashTimer = null;
function saveHashSoon() {
clearTimeout(hashTimer);
hashTimer = setTimeout(() => {
history.replaceState(null, '', location.pathname + location.search + viewHash(false));
}, 350);
}
// --- layers ---------------------------------------------------------------
function tileUrl(key) {
return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
}
// Layer with even standard levels only (reduced server-side storage): at an
// odd zoom, tiles from the NEXT even level up are shown scaled down by half
// (sharp). Exception: the native level itself (19, odd — 0.2 m/px) is
// requested as is, rendered on the fly by the server. Folding it onto the
// lower even level capped the display at 0.4 m/px, then stretched by the
// browser: full resolution was never seen. Beyond native, Leaflet stretches
// the native level (never an upscaled tile server-side). The Leaflet tile
// level here is the standard numbering (zoomOffset −1 with 512 px tiles).
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() {
// Shared LiDAR stack (z 400). Panes are reused if already created
// (refresh after a generation run).
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,
// Tile coverage: Leaflet requests no tile outside it — no useless
// requests for the rest of the world.
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,
// 512 px tiles: one off-screen buffer row equals the base map's two
// 256 px rows. Two rows doubled the decoded images kept in memory
// (1 MB each) for little gain.
keepBuffer: 1,
updateWhenZooming: false,
crossOrigin: false,
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
attribution: META.attribution
});
// The tile container only exists once added: the comparison clip is
// applied to it at that point.
layer.on('add', applyCompareClip);
LAYERS.set(info.key, layer);
});
applyLayers();
}
// Mode actually applied: without a served precision layer, relief only.
function effectiveMode() {
return precisionKey() ? STATE.mode : 'relief';
}
// Layer shown on one side of the comparison (null = OSM only).
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);
// Intensity (contrast): set on the relief layer's container only — the
// comparison clip (clipPath) stays independent, both CSS properties
// coexist on the same container.
for (const [key, layer] of LAYERS) {
const c = layer.getContainer && layer.getContainer();
if (c) c.style.filter = key === STATE.main && STATE.intensity !== 1 ? 'contrast(' + STATE.intensity + ')' : '';
}
applyCompareClip();
updateRose();
}
// Divider range, in px of the map container: the handle stays within the
// visible part of the map, never under the panel (desktop: open panel or
// icon strip, full height) nor under the tool column.
// COMPARE_MARGIN = half the handle width + margin.
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);
}
// Window too narrow: the divider stays in the middle of the range.
if (min > max) min = max = (min + max) / 2;
return { left: r.left, min, max };
}
// Clip the layers on either side of the divider, in layer coordinates (the
// tile containers move with the map): recomputed on every move. Same layer
// on both sides: no clip. STATE.compare.pos keeps the requested value (link,
// drag); the displayed divider, bar and clip together, is bounded by
// compareBounds(). Mode other than "compare" and layers already unclipped:
// nothing to do. Without this guard, every map move (map.on('move', …))
// re-reads and rewrites the style of every layer just to set empty
// clip-paths, on every frame, even when Compare has never been opened.
let compareClipsCleared = true;
function applyCompareClip() {
if (!STATE) return;
const on = effectiveMode() === 'compare';
if (!on && compareClipsCleared) { el('compareBar').hidden = true; return; }
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;
}
compareClipsCleared = !on;
if (on) el('compareBar').style.left = (bounds.left + x) + 'px';
}
map.on('move', applyCompareClip);
map.on('resize zoomend', applyCompareClip);
// Panel collapsed, expanded, sheet resized: the divider range changes.
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('Display: ' + 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) + ' pts/m²'
: k === PREC_LEVELS - 1 ? '≥ ' + Math.round(lo) + ' pts/m²'
: lo.toFixed(lo < 10 ? 1 : 0) + '–'
+ hi.toFixed(hi < 10 ? 1 : 0) + ' pts/m²';
bar.appendChild(cell);
}
}
// "How to read": sentences from VIZ_LEGENDS (reading field), served by
// /api/map/meta — same text as the PDF sheet legend.
function renderReading() {
const mode = effectiveMode();
const keys = mode === 'precision' ? [precisionKey()]
: mode === 'compare' ? [sideLayerKey(STATE.compare.left), sideLayerKey(STATE.compare.right)]
: [STATE.main];
const seen = new Set();
const items = [];
for (const k of keys) {
if (!k || seen.has(k)) continue;
seen.add(k);
const info = META.layers.find(l => l.key === k);
for (const s of (info && info.reading) || []) items.push('<li>' + esc(s) + '</li>');
}
el('readingList').innerHTML = items.join('');
el('readingBlock').hidden = !items.length;
}
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 : 'No layer';
name.title = info ? (info.description || info.label) : '';
// Several possible main layers: a menu; otherwise a plain label.
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;
el('reliefIntensity').value = STATE.intensity;
el('reliefIntensityVal').textContent = fmtIntensity(STATE.intensity);
el('intensityRow').hidden = effectiveMode() === 'precision';
renderReading();
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]) => '<option value="' + k + '"'
+ (k === STATE.compare[side] ? ' selected' : '') + '>' + lbl + '</option>').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);
}
// --- tile card ------------------------------------------------------------
let tileSel = null; // outline of the selected tile
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('en-GB', opt);
}
function fmtDensity(q) {
if (!q || q.ground_density == null) return 'not available';
return q.ground_density.toFixed(q.ground_density < 10 ? 1 : 0) + ' ground pts/m²';
}
function fmtAcq(q) {
if (!q || !q.acq_start) return 'not available';
return q.acq_end && q.acq_end.slice(0, 10) !== q.acq_start.slice(0, 10)
? fmtDate(q.acq_start) + ' → ' + fmtDate(q.acq_end) : fmtDate(q.acq_start);
}
function kv(rows) {
return '<dl class="info-kv">' + rows.filter(r => r[1] != null && r[1] !== '')
.map(r => '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>').join('') + '</dl>';
}
function selectTileOutline(data) {
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
if (!data || !data.corners) return;
// Dedicated pane above the LiDAR stack: in the default overlayPane
// (z-index 400, like lidarStack), the outline went under the tiles.
tileSel = L.polygon(data.corners, { pane: 'tileSel', color: cssVar('--tile-sel'), weight: 2,
fill: false, dashArray: '6 4',
interactive: false }).addTo(map);
}
function ignSection(ign) {
if (!ign) return '<div class="meta">Loading IGN information…</div>';
if (ign.error) return '<div class="meta">IGN catalogue unreachable for now.</div>';
if (!ign.found) return '<div class="meta">Tile not published by IGN (outside LiDAR HD coverage or not released yet).</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('en-GB',
{ 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('en-GB') + ' points' : null;
return kv([
['LiDAR scan', when],
['Sensor', (ign.sensors || []).map(esc).join('<br>')],
['Mission', esc(ign.mission)],
['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('for ' + ign.acquisition_owner)]
.filter(Boolean).map(esc).join(' ')],
['IGN release', ign.edition_date ? fmtDate(ign.edition_date) : null],
['Classification', esc(ign.classification_process)],
['Point cloud', pts],
]) + (ign.download_url
? '<a class="info-dl" href="' + esc(ign.download_url) + '" target="_blank" rel="noopener">⤓ Download the IGN LiDAR point cloud (.copc.laz)</a>'
: '') + (ign.stac_url
? '<div class="meta"><a href="' + esc(ign.stac_url) + '" target="_blank" rel="noopener">Catalogue record</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) + ' cm');
const lines = Object.values(c.lines_rms_cm || {});
return '<div class="info-sec"><h4>Vertical alignment of flight strips</h4>' + kv([
['Strips', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
['Offsets', offs.length ? offs.join('<br>') : 'none ≥ 0.5 cm'],
['Scan lines', lines.length ? 'corrected, at most ' + String(Math.max(...lines)) + ' cm RMS' : null],
]) + '</div>';
}
function renderTileCard(data, ign) {
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="row"><button id="tileRegen" class="btn" title="Add this tile to the generation selection (IGN download + processing)">↻ ' + (data.found ? 'Regenerate' : 'Generate') + ' this tile</button></div>'
: '';
el('tileTitle').textContent = 'Tile ' + 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>Extent</h4>' + kv([
['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('en-GB') + ' – ' + bb[2].toLocaleString('en-GB') +
'<br>Y ' + bb[1].toLocaleString('en-GB') + ' – ' + bb[3].toLocaleString('en-GB') : null],
['Area', '1 km × 1 km'],
]) + '</div>' +
'<div class="info-sec"><h4>LiDAR acquisition (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>Rendering</h4>' + kv([
['Resolution', String(data.resolution) + ' m/px'],
['Classification', esc(data.method || 'unknown')],
['Generated', esc(data.generated || '—')],
['Layers', esc(viz || '—')],
]) + '</div>' + calageSection(data.calage);
} else {
html += '<div class="info-sec"><div class="meta">No rendering on this tile yet.</div></div>';
}
el('tileBody').innerHTML = html + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
}
function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
if (!data) {
el('tileTitle').textContent = 'Tile';
el('tileBody').innerHTML = '<div class="meta">Server unreachable: try again in a moment.</div>';
return;
}
renderTileCard(data, null);
// IGN details fetched separately: the selection shows without waiting for the 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: 'network' }))
.then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
}
let SELECTED_TILE = null; // data of the selected tile (or null)
// The tab label stays "Tile" (5 tabs in 340 px: a variable label would
// overflow); col · row goes into the tab's title (hover) and into
// #tileTitle, always readable in the card itself.
function tileTabTitle() {
return (SELECTED_TILE ? 'Tile ' + SELECTED_TILE.col + ' · ' + SELECTED_TILE.row : 'Tile') + ' (2)';
}
// Refreshes the Tile tab (title + hint/card toggle) whether it is visible or
// not: its content must be up to date as soon as it is opened.
function renderTabTile() {
document.querySelector('button[data-tab="tile"]').title = tileTabTitle();
el('tileHint').hidden = !!SELECTED_TILE;
el('tileCard').hidden = !SELECTED_TILE;
if (SELECTED_TILE) showTileInfo(SELECTED_TILE, lastTileQuery);
}
// Selects a tile (outline + card in the Tile tab) without changing the
// displayed tab.
function selectTile(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
SELECTED_TILE = data;
selectTileOutline(data);
renderTabTile();
}
// Deselects (click on the same tile again, Escape): removes the outline and
// puts the Tile tab back on its hint.
function clearTileSelection() {
SELECTED_TILE = null;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
renderTabTile();
}
// --- oriented-relief compass rose ------------------------------------------
// Same colours as visualizations._relief_lut: CIELAB hue = orientation
// (aspect = compass bearing + 90°, the renderer's arctan2(dy, dx) frame),
// chroma 60 · L*(100−L*)/2500, at fixed lightness.
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; // displayed size (CSS px), see .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, clockwise
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], ['SW', 225], ['W', 270], ['NW', 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);
// intercardinals on the coloured ring: dark; cardinals outside the ring: text colour
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 whenever the relief is shown: alone, or on one side of the divider.
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();
}
// --- external imagery background (JOSM, QGIS, iD…) ------------------------
function renderUseCard() {
const origin = location.origin;
const opts = META.layers.map(l =>
'<option value="' + l.key + '">' + l.label + '</option>').join('');
el('useBody').innerHTML =
'<div class="row"><select id="useLayer" class="field">' + opts + '</select>' +
'<button id="useCopy" class="btn btn-primary">Copy</button></div>' +
'<div class="meta">XYZ URL (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 (all layers): <a href="' + origin + '/tiles/josm.imagery.xml">' + origin + '/tiles/josm.imagery.xml</a></div>' +
'<div class="use-note">Max native zoom ' + 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 copied'),
() => window.prompt('XYZ URL:', text));
} else { window.prompt('XYZ URL:', text); }
});
refresh();
}
// --- tile generation (API /api/status, /api/preview, /api/generate) -------
// Ported from the former webapp: area drawing (+), "complete everything"
// pass (⤒), cumulative selection (tile card button), run options, queue and
// tile-by-tile progress (frames on the map).
const GEN = { available: false, allowed: false, wasRunning: false, timer: null };
const genSel = new Set(); // "col,row" keys of the selected tiles
const genCorners = new Map(); // key → GPS corners [[lat, lon] × 4]
const genPolys = new Map(); // key → highlight polygon
const genFrames = new Map(); // name → coloured frame of a run tile
let genDrawing = false, genDrawStart = null, genDrawRect = null;
let genIgnoreClickUntil = 0;
let lastTileQuery = null; // latlng of the last click (tile card)
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;
// Tab already visible: no openTab, which would change the sheet height.
if (panelVisibleTab() !== 'gen') openTab('gen');
}
function genFillViz() {
el('genViz').innerHTML = META.layers.map(l =>
'<option value="' + (l.step || l.key) + '">' + l.label + '</option>').join('');
}
function genSelectedViz() {
return Array.from(el('genViz').selectedOptions).map(o => o.value);
}
function genRefreshSel() {
for (const poly of genPolys.values()) poly.remove();
genPolys.clear();
for (const [key, corners] of genCorners.entries()) {
if (!genSel.has(key) || !corners || corners.length < 4) continue;
const poly = L.polygon(corners.map(c => [c[0], c[1]]), {
pane: 'genSel', className: 'gen-sel', weight: 2, fillOpacity: 0.12,
}).addTo(map);
poly.on('click', (e) => {
L.DomEvent.stopPropagation(e);
genRemoveCell(key);
toast('Tile removed from the selection');
});
genPolys.set(key, poly);
}
el('genGo').disabled = !genSel.size;
if (genSel.size) {
el('genMsg').textContent = genSel.size + (genSel.size > 1 ? ' tiles' : ' tile') + ' selected — ' +
'click a highlight to remove a tile, set the options, then "Generate".';
}
}
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 tile selected — set the options, then "Generate".');
})
.catch(msg => toast('Failed: ' + 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('Draw a rectangle on the map (click + again to cancel).');
}
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; // no tile selection right after drawing
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('Analysing the area…');
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 + (added > 1 ? ' new tiles' : ' new tile') + ' selected'
: 'area already covered — tick "Regenerate" to force') +
(d.capped ? ' — area capped, draw a smaller rectangle' : '') + '.');
}).catch(msg => genOpenCard('Error: ' + msg.message));
});
function genComplete() {
genOpenCard('Analysing the already-downloaded tiles…');
genApi('POST', 'api/preview', {
all_missing: true,
regenerate: el('genRegen').checked,
viz: genSelectedViz(),
}).then(d => {
const added = genAddCells(d.cells);
const n = added || d.count || 0;
genOpenCard(n + (n === 1 ? ' tile' : ' tiles') + ' to complete' +
(d.capped ? ' — capped at 10,000 per run' : '') + '.');
if (genPolys.size) {
const bounds = L.latLngBounds([]);
genPolys.forEach(p => bounds.extend(p.getBounds()));
map.fitBounds(bounds, { padding: [40, 40] });
}
}).catch(msg => genOpenCard('Error: ' + 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é ? ('Generation started: ' + d.tuiles + ' tile(s)')
: ('Queued (' + d.en_file + '): ' + d.tuiles + ' tile(s)'));
}).catch(msg => toast('Failed: ' + msg.message, 6000));
}
// Frames of the run's tiles (pulsing orange = rendering, blue = pending,
// red = failed) — full redraw on every poll, it is cheap.
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);
}
}
// End of run: the map refreshes (new layers/tiles, fresh tile stamp) and the
// pyramid maintenance is restarted server-side.
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(() => {});
// Tile card left selected during the run: stale (resolution, generation
// date, layers…) — reload it without ever deselecting.
if (SELECTED_TILE && lastTileQuery) {
fetch('api/map/tile?lat=' + lastTileQuery.lat + '&lng=' + lastTileQuery.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => { if (d) selectTile(d, lastTileQuery); });
}
}
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 in progress — ' + done + '/' + tiles.length + ' tile(s)')
: 'Starting the next run…';
if (failed) txt += ' • ' + failed + ' failed';
if (queued) txt += ' • ' + queued + ' queued request(s)';
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 ? 'Generation finished'
: 'Generation interrupted (code ' + d.returncode + ')', 6000);
genAfterRun();
}
}
el('genBadge').textContent = d.running ? '● running'
: 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('Selection cleared. Draw an area (+) or complete (⤒).');
});
el('genGo').addEventListener('click', genLaunch);
el('genStop').addEventListener('click', () =>
genApi('POST', 'api/stop')
.then(() => toast('Stop requested'))
.catch(msg => toast('Failed: ' + msg.message, 6000)));
el('genQueueClear').addEventListener('click', () =>
genApi('POST', 'api/queue/clear')
.then(d => toast((d.retirées || 0) + ' request(s) removed'))
.catch(msg => toast('Failed: ' + msg.message, 6000)));
// --- PDF export (printed field sheet) ---
// Frame = printed area, anchored on the ground (centre PRINT.center): the map
// moves freely around it; drag it with the mouse or a finger.
// Exact geometry computed by the server (Lambert 93 projection).
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, {});
// Corrupted value (key shared with another app, quota exceeded while
// writing…): a malformed object is ignored rather than crashing.
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);
}
}
// Fits the view to the frame while leaving room for the panel (left column
// on desktop, bottom sheet on phone): otherwise the frame goes under it.
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: 'Drag to move the printed area',
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: fit the view to the frame once the geometry is received.
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 = ''; });
}
// Dragging the handle: the frame follows live (translation in layer pixels,
// stable during auto-panning); exact geometry requested again from the
// server on release.
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();
}
// Frame shown while the PDF export tab is visible (panel:tab event, fired on
// every panel render: nothing happens if the state is unchanged).
// explicit: false only for the startup render (BOOTSTRAPPING: a remembered
// Export tab restored over a shared link or the initial URL), true for every
// later render (tab click, keyboard shortcut, sheet reopened…). When false,
// neither is the view fitted to the frame, nor is the frame itself recentred
// on the view (otherwise a frame placed elsewhere on the ground jumps to the
// centre of the screen, or a shared link's zoom is overwritten — see
// printRefreshFrame(fit)).
function printSetOpen(on, explicit) {
if (PRINT.on === on) return;
PRINT.on = on;
if (on) {
// Frame recentred on the view only if it is out of view AND the opening
// is explicit; otherwise kept where it was (or placed at the centre if it
// never existed — first visit without a saved lidar-print).
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 = 'Generating the 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 = 'Downloaded: ' + name;
})
.catch(e => { el('printMsg').textContent = 'Failed: ' + 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);
});
// --- tabbed panel -------------------------------------------------------------
const PANEL_LS = 'lidar-panel';
// Keeps only known keys of the right type and (for sheet) within the valid
// list: a corrupted value would otherwise break the CSS (panel.dataset.sheet
// unknown to map.css) or the displayed tab.
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;
if (typeof s.reading === 'boolean') out.reading = s.reading;
return out;
}
const PANEL = Object.assign({ tab: 'view', collapsed: false, sheet: 'half', reading: false }, _sanitizedPanelLS());
// want = chosen (remembered) tab; tab = displayed tab. Generation still
// unknown at startup (api/status): Display meanwhile, then the chosen tab
// comes back as soon as it is offered.
PANEL.want = PANEL.tab;
const phoneQuery = window.matchMedia('(max-width: 720px)');
const isPhone = () => phoneQuery.matches;
// Offered tabs: Generation only if the generator answers and this browser
// is allowed to launch runs (same rule as the former buttons).
function panelTabs() {
return ['view', 'tile', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
}
// Tab actually visible, or null (panel collapsed or sheet closed — in which
// case NO tab is on screen, not even the remembered one: panel:tab must
// close the export frame if it was open, whatever triggered this panel
// render — grip dragged/tapped, resize, phoneQuery change, openTab's
// setTimeout(panelRender, 220)…).
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, reading: PANEL.reading }); }
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');
// Collapse into an icon strip: desktop only (the phone has its sheet).
const collapsed = PANEL.collapsed && !isPhone();
panel.classList.toggle('collapsed', collapsed);
panel.dataset.sheet = PANEL.sheet;
document.body.classList.toggle('panel-collapsed', collapsed);
// Height covered by the sheet, which the scale, attribution and messages
// stay clear of; full sheet: the map is hidden, nothing to clear (otherwise
// they would climb to the top of the screen, over the tools).
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;
// Active tab tapped again on phone: the sheet changes height.
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); // final height after the CSS transition
}
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); // final height after the CSS transition
}
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b =>
b.addEventListener('click', () => openTab(b.dataset.tab)));
el('panelCollapse').addEventListener('click', () => setCollapsed(!PANEL.collapsed));
// Open/closed state of "How to read" remembered separately (independent of
// the active tab).
el('readingBlock').open = !!PANEL.reading;
el('readingBlock').addEventListener('toggle', () => {
PANEL.reading = el('readingBlock').open;
panelSave();
});
phoneQuery.addEventListener('change', panelRender);
window.addEventListener('resize', panelRender); // sheet height in vh (rotation)
// Sheet grip: drag up / down, release onto the nearest height; a simple tap
// moves it up one step (full goes back to half).
(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);
})();
// --- startup --------------------------------------------------------------
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('No visualisation available in the cache.', 10000);
}
STATE = loadState(META);
// The chosen state (server defaults, then shared link) is persisted right
// away: what is on screen is what will be reloaded.
saveState();
const hash = parseHash();
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
else fitData();
buildLayers();
renderPanel();
renderBaseRow();
panelRender();
BOOTSTRAPPING = false;
renderUseCard();
genFillViz();
// Generation rights: the Generation tab is shown if the server can generate
// (local pipeline or LIDAR_GENERATION_URL) AND the browser comes from the
// allowed network (LIDAR_REGEN_CIDR) — otherwise silently hidden.
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();
// The tile card may have shown before /api/status answered ("Regenerate"
// button missing, GEN still unknown): render it again.
if (SELECTED_TILE) renderTabTile();
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => {
toast('Tile server unreachable.', 10000);
panelRender();
// This render also counts as the first one (initial load failed): without
// this reset, a remembered Export tab would NEVER reframe the view again,
// even after a later explicit opening.
BOOTSTRAPPING = false;
});
let applyingHash = false;
window.addEventListener('hashchange', () => {
// Shared link pasted into the current browser tab: the view (and the layer
// state, if present) is applied without reloading.
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 || h.intensity != null) {
STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
}
setTimeout(() => { applyingHash = false; }, 0);
});
map.on('moveend zoomend', saveHashSoon);
// Sequence token: a response arriving after a more recent click (double
// click, slow request) must never overwrite the selection state.
let tileClickSeq = 0;
map.on('click', (e) => {
// No selection while drawing an area nor right after releasing the export
// frame handle. A double click (Leaflet zoom) arrives here as two single
// clicks: the second (detail > 1) must not toggle back the selection the
// first one just made.
if (genDrawing || Date.now() < genIgnoreClickUntil || PRINT.dragging) return;
if (e.originalEvent && e.originalEvent.detail > 1) return;
const seq = ++tileClickSeq;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => {
if (seq !== tileClickSeq) return; // a more recent click already handled
if (!d) { toast('Server unreachable: try again in a moment.', 5000); return; }
// Click on the already-selected tile: deselect. Otherwise the selection
// moves, without changing the displayed tab.
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('Link copied'),
() => window.prompt('Link:', url));
} else { window.prompt('Link:', 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('Display saved as the default');
})
.catch(msg => toast('Saving failed: ' + msg, 6000));
});
el('modeSeg').querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => setMode(b.dataset.mode)));
el('reliefIntensity').addEventListener('input', (e) => {
STATE.intensity = clampIntensity(e.target.value);
el('reliefIntensityVal').textContent = fmtIntensity(STATE.intensity);
applyLayers();
});
el('reliefIntensity').addEventListener('change', saveState);
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', () => {
// Forgets the local state and takes the configuration served by the server.
lsDel(LS_KEY);
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
STATE = defaultState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
toast('Default configuration restored');
}).catch(() => toast('Server unreachable', 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('Geolocation unavailable (HTTPS required).', 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 ? 'Permission denied'
: err && err.code === 3 ? 'Timed out' : 'Position unavailable';
toast(why + ' (geolocation requires 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 → precision → compare → 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: panel tabs (Generation skipped when hidden)
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);
}
});