'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 = ''; const SVG_EYE_OFF = ''; // 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: '© OpenStreetMap' }).addTo(map); function toast(msg, ms) { const t = el('toast'); t.textContent = msg; t.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500); } // --- 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('
  • ' + esc(s) + '
  • '); } 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 => '').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]) => '').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 '
    ' + rows.filter(r => r[1] != null && r[1] !== '') .map(r => '
    ' + r[0] + '
    ' + r[1] + '
    ').join('') + '
    '; } function selectTileOutline(data) { if (tileSel) { map.removeLayer(tileSel); tileSel = null; } if (!data || !data.corners) return; // 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 '
    Loading IGN information…
    '; if (ign.error) return '
    IGN catalogue unreachable for now.
    '; if (!ign.found) return '
    Tile not published by IGN (outside LiDAR HD coverage or not released yet).
    '; 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('
    ')], ['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 ? '⤓ Download the IGN LiDAR point cloud (.copc.laz)' : '') + (ign.stac_url ? '
    Catalogue record
    ' : ''); } 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 '

    Vertical alignment of flight strips

    ' + kv([ ['Strips', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'], ['Offsets', offs.length ? offs.join('
    ') : 'none ≥ 0.5 cm'], ['Scan lines', lines.length ? 'corrected, at most ' + String(Math.max(...lines)) + ' cm RMS' : null], ]) + '
    '; } function renderTileCard(data, ign) { const regenBtn = (GEN.available && GEN.allowed) ? '
    ' : ''; el('tileTitle').textContent = 'Tile ' + data.col + ' · ' + data.row; const bb = data.l93_bbox || []; let html = '
    ' + esc(data.name) + '
    ' + '

    Extent

    ' + kv([ ['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('en-GB') + ' – ' + bb[2].toLocaleString('en-GB') + '
    Y ' + bb[1].toLocaleString('en-GB') + ' – ' + bb[3].toLocaleString('en-GB') : null], ['Area', '1 km × 1 km'], ]) + '
    ' + '

    LiDAR acquisition (IGN)

    ' + ignSection(ign) + '
    '; if (data.found) { const viz = (data.layers || []).map(k => { const info = META.layers.find(l => l.key === k); return info ? info.label : k; }).join(', '); html += '

    Rendering

    ' + kv([ ['Resolution', String(data.resolution) + ' m/px'], ['Classification', esc(data.method || 'unknown')], ['Generated', esc(data.generated || '—')], ['Layers', esc(viz || '—')], ]) + '
    ' + calageSection(data.calage); } else { html += '
    No rendering on this tile yet.
    '; } 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 = '
    Server unreachable: try again in a moment.
    '; 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 => '').join(''); el('useBody').innerHTML = '
    ' + '
    ' + '
    XYZ URL (JOSM "TMS", QGIS "XYZ Tiles", uMap, MapLibre):
    ' + '
    ' + '
    TileJSON:
    ' + '
    WMTS: ' + origin + '/tiles/wmts.xml
    ' + '
    JOSM (all layers): ' + origin + '/tiles/josm.imagery.xml
    ' + '
    Max native zoom ' + META.max_native_zoom + '. ' + META.attribution_text + '
    '; const sel = el('useLayer'); const refresh = () => { const key = sel.value; el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png'; const a = el('useJson'); a.textContent = origin + '/tiles/' + key + '.json'; a.href = origin + '/tiles/' + key + '.json'; }; sel.addEventListener('change', refresh); el('useCopy').addEventListener('click', () => { const text = el('useUrl').textContent; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => toast('URL 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 => '').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); } });