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>
1697 lines
70 KiB
JavaScript
1697 lines
70 KiB
JavaScript
'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: '© <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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
|
||
}
|
||
});
|