"""Interface de la carte à tuiles XYZ (image lidar-maps).
Même convention que `index.py` : le CSS et le JS vivent dans des constantes,
sont écrits par `write_map_assets()` et bâchés dans l'image au build. La page
est une coquille minimale : tout l'état vient de `/api/map/meta`, les couches
sont des `L.tileLayer` standard — le navigateur gère LOD, cache et animation.
"""
import hashlib
from pathlib import Path
ASSETS_DIRNAME = "mapserve_assets"
def ui_version():
"""Hash court du CSS+JS : sert d'URL de version pour les assets."""
return hashlib.sha1((_MAP_CSS + _MAP_JS).encode("utf-8")).hexdigest()[:10]
def write_map_assets(assets_dir):
"""Écrit app.css / app.js (+ Leaflet vendorisé) dans `assets_dir`."""
import shutil
assets_dir = Path(assets_dir)
assets_dir.mkdir(parents=True, exist_ok=True)
(assets_dir / "app.css").write_text(_MAP_CSS, encoding="utf-8")
(assets_dir / "app.js").write_text(
_MAP_JS.replace("__UI_VERSION__", ui_version()), encoding="utf-8")
src_vendor = Path(__file__).parent / "assets" / "vendor"
dst_vendor = assets_dir / "vendor"
if src_vendor.is_dir():
shutil.rmtree(dst_vendor, ignore_errors=True)
shutil.copytree(src_vendor, dst_vendor)
return assets_dir
def render_html():
"""Coquille HTML (données chargées par /api/map/meta)."""
return _MAP_HTML.replace("__ASSETS_V__", ui_version())
_MAP_HTML = """
Carte LiDAR
▾
Couches
Haut de pile — devant
Bas de pile — derrière
Fusion de la pile sur le fond
+
−
⤢
⌖
"""
_MAP_CSS = """/* Carte LiDAR — tuiles XYZ, habillage minimal type carte grand public. */
:root {
color-scheme: dark;
--bg: #0b0e13;
--card: rgba(16, 20, 27, 0.94);
--border: rgba(255, 255, 255, 0.10);
--text: #d9dee6;
--muted: #8b94a3;
--accent: #5aa9e6;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
#map { position: absolute; inset: 0; background: var(--bg); }
.leaflet-container { background: var(--bg); font: inherit; }
/* Fond OSM assombri pour laisser ressortir les rendus LiDAR. */
.leaflet-tile-pane .leaflet-layer.base-dark .leaflet-tile { filter: brightness(0.38) contrast(1.1) saturate(0.7); }
.card { position: fixed; z-index: 900; background: var(--card); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
#tilecard { left: 12px; top: 12px; width: 320px; max-width: calc(100vw - 24px); }
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); }
.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
.card-stat { margin-left: auto; font-size: 11px; color: var(--muted); }
.card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; }
#layercard.collapsed .card-body { display: none; }
#layercard.collapsed #cardToggle { transform: rotate(-90deg); }
.card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); }
button { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 5px 10px; font: inherit; font-size: 12px; cursor: pointer; }
button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent); }
.icon-btn { border: none; background: none; color: var(--muted); padding: 2px 6px; font-size: 14px; }
.icon-btn:hover { color: var(--text); background: none; }
.link-btn { flex: 1; font-size: 11.5px; }
/* Une couche tient sur deux lignes : identité (poignée, œil, nom, ordre) puis
réglages (fusion + opacité). Les boutons ▲▼ doublent le glisser-déposer —
seuls utilisables au doigt. */
.layer-row { display: grid; grid-template-columns: 14px 24px minmax(0, 1fr) auto 34px;
align-items: center; gap: 6px; padding: 5px 4px; border-radius: 8px; user-select: none; }
.layer-ctl { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 84px;
gap: 6px; align-items: center; padding: 2px 0 2px 38px; }
.layer-move { display: inline-flex; gap: 2px; }
.layer-move button { padding: 0 5px; font-size: 10px; line-height: 16px; color: var(--muted);
background: rgba(255, 255, 255, 0.05); }
.layer-move button:hover:not(:disabled) { color: var(--text); }
.layer-move button:disabled { opacity: 0.25; cursor: default; }
.blend { background: rgba(255, 255, 255, 0.06); color: var(--muted);
border: 1px solid var(--border); border-radius: 6px; padding: 2px 4px;
font: inherit; font-size: 11px; min-width: 0; width: 100%; height: 22px; }
.blend:hover { color: var(--text); }
.blend option { background-color: #10141b; color: var(--text); }
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
/* Réorganisation : la destination doit être lisible AVANT de lâcher. Une
barre d'insertion épaisse marque le point de chute, la ligne déplacée
s'estompe, et à l'arrivée la couche clignote pour que l'œil la retrouve. */
.layer-row.dragging { opacity: 0.35; outline: 1px dashed var(--accent); }
.layer-row.drop-before { box-shadow: inset 0 3px 0 0 var(--accent); }
.layer-row.drop-after { box-shadow: inset 0 -3px 0 0 var(--accent); }
.layer-row.drop-before::before, .layer-row.drop-after::before {
content: '▸'; position: absolute; margin-left: -12px; color: var(--accent);
font-size: 11px; line-height: 1;
}
.layer-row.drop-before::before { margin-top: -14px; }
.layer-row.drop-after::before { margin-top: 14px; }
.layer-row { position: relative; }
.layer-row.just-moved { animation: moved 1s ease-out; }
@keyframes moved {
0% { background: rgba(90, 169, 230, 0.38); }
100% { background: transparent; }
}
body.reordering, body.reordering * { cursor: grabbing !important; }
.stack-edge {
display: flex; align-items: center; gap: 6px;
font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase;
color: var(--muted); padding: 2px 6px;
}
.stack-edge::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; }
.grip::before { content: '⠿'; }
#baseRow .grip::before { content: ''; }
.eye { width: 24px; height: 24px; padding: 0; border: none; background: none; color: var(--muted);
display: flex; align-items: center; justify-content: center; }
.eye.on { color: var(--accent); }
.eye svg { width: 16px; height: 16px; }
.layer-name { font-size: 12.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.layer-row.on .layer-name { color: var(--text); }
.pct { font-size: 10.5px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px;
background: transparent; cursor: pointer; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%;
background: var(--accent); margin-top: -4.5px; border: 2px solid #0b0e13; }
input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.18); }
input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid #0b0e13; }
#tools { position: fixed; right: 12px; bottom: 12px; z-index: 900; display: flex; flex-direction: column; gap: 6px; }
#tools button { width: 38px; height: 38px; padding: 0; font-size: 17px; background: var(--card);
border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
#gps.busy { color: var(--accent); border-color: var(--accent); }
.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: 1000;
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 7px 12px; font-size: 12px; color: var(--text); max-width: 80vw; text-align: center; }
.meta { font-size: 11.5px; color: var(--muted); margin: 2px 0; }
.meta b { color: var(--text); font-weight: 600; }
.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px;
background: rgba(255,255,255,0.06); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 8px; word-break: break-all; margin: 4px 0 8px; }
.use-row { display: flex; gap: 6px; align-items: center; margin-bottom: 2px; }
.use-row select { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; font: inherit; font-size: 12px; }
.use-note { font-size: 11px; color: var(--muted); line-height: 1.5; }
.leaflet-control-attribution { background: rgba(11,14,19,0.8) !important; color: var(--muted) !important; }
.leaflet-control-attribution a { color: var(--accent) !important; }
@media (max-width: 720px) {
#layercard { width: calc(100vw - 24px); }
#tilecard { width: calc(100vw - 24px); }
}
"""
_MAP_JS = r"""'use strict';
/* Carte LiDAR — pyramide XYZ (EPSG:3857), une L.tileLayer par visualisation.
Le LOD, le cache et l'animation sont ceux de Leaflet : aucun palier
d'image, aucune rotation CSS, aucune mosaïque maison. */
const UI_VERSION = "__UI_VERSION__";
const LS_KEY = 'lidarMapLayers_v1';
const el = (id) => document.getElementById(id);
const SVG_EYE = ' ';
const SVG_EYE_OFF = ' ';
// Modes de fusion proposés (CSS mix-blend-mode) : la superposition de couches
// ne se réduit pas à de la transparence — « Produit » assombrit le relief sans
// délaver la rampe du dessous, « Écran » éclaircit, « Superposé »/« Doux »
// creusent le contraste local, « Différence » fait ressortir les écarts.
const BLEND_MODES = [
['normal', 'Normal'],
['multiply', 'Produit'],
['screen', 'Écran'],
['overlay', 'Superposé'],
['soft-light', 'Doux'],
['hard-light', 'Lumière crue'],
['difference', 'Différence'],
['luminosity', 'Luminosité'],
];
const BLEND_KEYS = BLEND_MODES.map(m => m[0]);
const validBlend = (v) => (BLEND_KEYS.includes(v) ? v : null);
const blendOptions = (current) => BLEND_MODES.map(
m => '' + m[1] + ' ').join('');
let META = null;
let STATE = null;
const LAYERS = new Map(); // clé → L.TileLayer
let dragKey = null;
const map = L.map('map', {
zoomControl: false,
attributionControl: true,
minZoom: 3,
maxZoom: 22,
zoomSnap: 0.5,
wheelPxPerZoomLevel: 90,
worldCopyJump: false,
preferCanvas: false
});
map.attributionControl.setPrefix('');
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
const base = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22, maxNativeZoom: 19, keepBuffer: 2, className: 'base-dark',
attribution: '© OpenStreetMap '
}).addTo(map);
function toast(msg, ms) {
const t = el('toast');
t.textContent = msg;
t.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => { t.hidden = true; }, ms || 3500);
}
// --- état persisté --------------------------------------------------------
function defaultState(meta) {
// `default_order` n'existe que si une configuration a été figée depuis la
// carte (POST /api/map/defaults) ; sinon on part du registre du pipeline.
const order = (Array.isArray(meta.default_order) && meta.default_order.length)
? meta.default_order.filter(k => meta.layers.some(l => l.key === k))
: meta.layers.map(l => l.key);
for (const l of meta.layers) if (!order.includes(l.key)) order.push(l.key);
const on = {}, opacity = {}, blend = {};
const wanted = (meta.default_layers || []).filter(k => order.includes(k));
// Ordre figé depuis la carte : respecté tel quel. Sinon, les couches
// allumées par défaut passent en bas de pile (bas → haut).
const stacked = (Array.isArray(meta.default_order) && meta.default_order.length)
? order
: wanted.concat(order.filter(k => !wanted.includes(k)));
for (const k of stacked) {
on[k] = wanted.includes(k);
const o = Number((meta.default_opacity || {})[k]);
opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : 1;
blend[k] = (meta.default_blend || {})[k] || 'normal';
}
const b = meta.default_base || {};
return {
order: stacked, on, opacity, blend,
base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
dark: b.dark !== undefined ? !!b.dark : true },
stackBlend: validBlend(meta.default_stack_blend) || 'normal',
};
}
function loadState(meta) {
const def = defaultState(meta);
let st = def;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
const s = JSON.parse(raw);
const order = (Array.isArray(s.order) ? s.order : []).filter(k => def.order.includes(k));
for (const k of def.order) if (!order.includes(k)) order.push(k);
const on = {}, opacity = {}, blend = {};
for (const k of order) {
on[k] = !!(s.on && s.on[k]);
const o = Number(s.opacity ? s.opacity[k] : NaN);
opacity[k] = Number.isFinite(o) ? Math.min(1, Math.max(0, o)) : def.opacity[k];
blend[k] = (s.blend && s.blend[k]) || def.blend[k] || 'normal';
}
st = { order, on, opacity, blend,
base: Object.assign({}, def.base, s.base || {}),
stackBlend: validBlend(s.stackBlend) || 'normal' };
}
} catch (e) { st = def; }
const hash = parseHash();
if (hash && hash.layers) {
const order = hash.layers.order.filter(k => def.order.includes(k));
for (const k of def.order) if (!order.includes(k)) order.push(k);
st = { order, on: hash.layers.on, opacity: hash.layers.opacity,
blend: Object.assign({}, st.blend, hash.layers.blend || {}),
base: hash.base || st.base,
stackBlend: hash.stackBlend || st.stackBlend || 'normal' };
for (const k of order) {
if (st.on[k] === undefined) st.on[k] = false;
if (st.opacity[k] === undefined) st.opacity[k] = 1;
if (!st.blend[k]) st.blend[k] = def.blend[k] || 'normal';
}
}
return st;
}
function saveState() {
try { localStorage.setItem(LS_KEY, JSON.stringify(STATE)); } catch (e) { /* privé */ }
}
// --- hash de vue ----------------------------------------------------------
// #z/lat/lng&L=clé:opacité:visible,...&B=on:opacité:sombre
function parseHash() {
const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
if (!m) return null;
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
layers: null, base: null };
const seg = {};
for (const part of m[4].split('&')) {
const i = part.indexOf('=');
if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
}
if (seg.L) {
// clé:opacité:visible[:fusion] — le 4e champ est optionnel (liens anciens)
const order = [], on = {}, opacity = {}, blend = {};
for (const item of seg.L.split(',')) {
const f = item.split(':');
if (f.length < 3) continue;
order.push(f[0]);
on[f[0]] = f[2] === '1';
const o = Number(f[1]);
opacity[f[0]] = Number.isFinite(o) ? Math.min(100, Math.max(0, o)) / 100 : 1;
const b = validBlend(f[3]);
if (b) blend[f[0]] = b;
}
if (order.length) out.layers = { order, on, opacity, blend };
}
if (seg.S) out.stackBlend = validBlend(seg.S) || 'normal';
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) {
h += '&L=' + STATE.order.map(k =>
k + ':' + Math.round(STATE.opacity[k] * 100) + ':' + (STATE.on[k] ? '1' : '0')
+ ':' + (STATE.blend[k] || 'normal')).join(',');
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
+ ':' + (STATE.base.dark ? '1' : '0');
h += '&S=' + (STATE.stackBlend || 'normal');
}
return h;
}
let hashTimer = null;
function saveHashSoon() {
clearTimeout(hashTimer);
hashTimer = setTimeout(() => {
history.replaceState(null, '', location.pathname + location.search + viewHash(false));
}, 350);
}
// --- couches --------------------------------------------------------------
function tileUrl(key) {
return META.tile_url.replace('{layer}', key) + (META.stamp ? '?v=' + META.stamp : '');
}
function buildLayers() {
// Conteneur isolé : `isolation: isolate` cantonne les mix-blend-mode à la
// pile LiDAR. Sans lui, « Produit » assombrirait aussi le fond OSM à travers
// les zones sans donnée. La fusion de la pile ENTIÈRE sur le fond reste
// réglable à part (STATE.stackBlend).
const stack = map.createPane('lidarStack');
stack.style.zIndex = 400;
stack.style.isolation = 'isolate';
META.layers.forEach((info) => {
const pane = 'lidar-' + info.key;
map.createPane(pane, stack);
const layer = L.tileLayer(tileUrl(info.key), {
pane: pane,
// Emprise des dalles : Leaflet ne demande aucune tuile en dehors —
// pas de requêtes inutiles sur le reste du monde.
bounds: META.bounds
? L.latLngBounds([META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]])
: undefined,
tileSize: META.tile_size,
zoomOffset: META.zoom_offset,
minNativeZoom: META.min_zoom,
maxNativeZoom: META.max_native_zoom,
minZoom: 0,
maxZoom: 22,
keepBuffer: 2,
updateWhenZooming: false,
crossOrigin: false,
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
attribution: META.attribution
});
LAYERS.set(info.key, layer);
});
applyLayers();
}
function applyLayers() {
const stack = map.getPane('lidarStack');
if (stack) stack.style.mixBlendMode = STATE.stackBlend || 'normal';
let z = 400;
for (const key of STATE.order) {
const layer = LAYERS.get(key);
if (!layer) continue;
const pane = map.getPane('lidar-' + key);
z += 1;
pane.style.zIndex = String(z);
pane.style.mixBlendMode = STATE.blend[key] || 'normal';
if (STATE.on[key]) {
if (!map.hasLayer(layer)) layer.addTo(map);
layer.setOpacity(STATE.opacity[key]);
} else if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
}
if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
else if (map.hasLayer(base)) map.removeLayer(base);
base.setOpacity(STATE.base.opacity);
const container = base.getContainer && base.getContainer();
if (container) container.classList.toggle('base-dark', STATE.base.dark);
}
function moveLayer(key, delta) {
// STATE.order est bas → haut ; le panneau affiche haut en premier.
const ord = STATE.order;
const i = ord.indexOf(key);
const j = i + delta;
if (i < 0 || j < 0 || j >= ord.length) return;
ord.splice(j, 0, ord.splice(i, 1)[0]);
saveState(); renderPanel(key); applyLayers();
}
// Insère `src` avant ou après `target` dans l'ordre AFFICHÉ (haut → bas).
function reorderRelative(src, target, before) {
const top = STATE.order.slice().reverse();
const from = top.indexOf(src);
if (from < 0) return;
top.splice(from, 1);
let at = top.indexOf(target);
if (at < 0) return;
if (!before) at += 1;
top.splice(at, 0, src);
STATE.order = top.reverse();
saveState(); renderPanel(src); applyLayers();
}
function clearDropMarks() {
document.querySelectorAll('.layer-row.drop-before, .layer-row.drop-after')
.forEach(r => r.classList.remove('drop-before', 'drop-after'));
}
function renderPanel(movedKey) {
const host = el('layers');
host.innerHTML = '';
const labels = new Map(META.layers.map(l => [l.key, l]));
// Haut de pile en premier (STATE.order est bas → haut).
const topFirst = STATE.order.slice().reverse();
topFirst.forEach((key, idx) => {
const info = labels.get(key);
if (!info) return;
const on = !!STATE.on[key];
const row = document.createElement('div');
row.className = 'layer-row' + (on ? ' on' : '')
+ (key === movedKey ? ' just-moved' : '');
row.dataset.layer = key;
row.title = info.description || info.label;
row.innerHTML =
' ' +
'' +
(on ? SVG_EYE : SVG_EYE_OFF) + ' ' +
'' + info.label + ' ' +
'' +
'▲ ' +
'▼ ' +
' ' +
'' + Math.round(STATE.opacity[key] * 100) + '% ' +
(on ? '' +
'' +
blendOptions(STATE.blend[key] || 'normal') + ' ' +
' ' +
'
' : '');
row.draggable = false;
row.addEventListener('mousedown', (e) => {
row.draggable = !!(e.target.closest && e.target.closest('.grip'));
});
row.querySelector('.eye').addEventListener('click', () => {
STATE.on[key] = !STATE.on[key];
if (STATE.on[key] && STATE.opacity[key] === 0) STATE.opacity[key] = 1;
saveState(); renderPanel(); applyLayers();
});
row.querySelector('.up').addEventListener('click', () => moveLayer(key, 1));
row.querySelector('.down').addEventListener('click', () => moveLayer(key, -1));
const blend = row.querySelector('.blend');
if (blend) blend.addEventListener('change', () => {
STATE.blend[key] = blend.value;
saveState();
const pane = map.getPane('lidar-' + key);
if (pane) pane.style.mixBlendMode = blend.value;
});
const range = row.querySelector('input[type="range"]');
const pct = row.querySelector('.pct');
if (range) {
range.addEventListener('input', () => {
STATE.opacity[key] = Number(range.value) / 100;
pct.textContent = range.value + '%';
const layer = LAYERS.get(key);
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.opacity[key]);
});
range.addEventListener('change', saveState);
}
row.addEventListener('dragstart', (e) => {
dragKey = key;
row.classList.add('dragging');
document.body.classList.add('reordering');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', key); // exigé par Firefox
}
});
row.addEventListener('dragend', () => {
dragKey = null;
row.classList.remove('dragging');
row.draggable = false;
document.body.classList.remove('reordering');
clearDropMarks();
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragKey || dragKey === key) return;
// Moitié haute survolée → insertion au-dessus, sinon en dessous.
const box = row.getBoundingClientRect();
const before = (e.clientY - box.top) < box.height / 2;
clearDropMarks();
row.classList.add(before ? 'drop-before' : 'drop-after');
});
row.addEventListener('dragleave', () => {
row.classList.remove('drop-before', 'drop-after');
});
row.addEventListener('drop', (e) => {
e.preventDefault();
if (!dragKey || dragKey === key) { clearDropMarks(); return; }
const before = row.classList.contains('drop-before');
const src = dragKey;
clearDropMarks();
document.body.classList.remove('reordering');
reorderRelative(src, key, before);
});
host.appendChild(row);
});
// La couche qui vient d'être déplacée est ramenée dans le champ de vision
// (le panneau défile dès qu'il y a beaucoup de couches).
if (movedKey) {
const moved = host.querySelector('.layer-row.just-moved');
if (moved && moved.scrollIntoView) moved.scrollIntoView({ block: 'nearest' });
}
}
function renderBaseRow() {
const b = STATE.base;
const sel = el('stackBlend');
if (sel && !sel.options.length) {
sel.innerHTML = blendOptions(STATE.stackBlend || 'normal');
sel.addEventListener('change', () => {
STATE.stackBlend = sel.value;
saveState(); applyLayers();
});
} else if (sel) {
sel.value = STATE.stackBlend || 'normal';
}
const toggle = el('baseToggle');
toggle.classList.toggle('on', b.on);
toggle.innerHTML = b.on ? SVG_EYE : SVG_EYE_OFF;
el('baseOpacity').value = Math.round(b.opacity * 100);
el('basePct').textContent = Math.round(b.opacity * 100) + '%';
el('baseRow').classList.toggle('on', b.on);
}
// --- fiche dalle ----------------------------------------------------------
function showTileInfo(data) {
const card = el('tilecard');
if (!data || !data.found) {
el('tileTitle').textContent = 'Aucune dalle ici';
el('tileBody').innerHTML = 'Pas de rendu disponible à cet endroit.
';
card.hidden = false;
return;
}
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
const viz = (data.layers || []).map(k => {
const info = META.layers.find(l => l.key === k);
return info ? info.label : k;
}).join(', ');
el('tileBody').innerHTML =
'' + data.name + '
' +
'Résolution : ' + String(data.resolution).replace('.', ',') + ' m/px ' +
' · Classification : ' + (data.method || 'inconnue') + '
' +
(data.generated ? 'Généré le ' + data.generated + '
' : '') +
'Couches : ' + (viz || '—') + '
';
card.hidden = false;
}
// --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
function renderUseCard() {
const origin = location.origin;
const opts = META.layers.map(l =>
'' + l.label + ' ').join('');
el('useBody').innerHTML =
'' + opts + ' ' +
'Copier
' +
'URL XYZ (JOSM « TMS », QGIS « XYZ Tiles », uMap, MapLibre) :
' +
'
' +
'' +
'' +
'' +
'Zoom natif maxi ' + META.max_native_zoom + '. ' + META.attribution_text + '
';
const sel = el('useLayer');
const refresh = () => {
const key = sel.value;
el('useUrl').textContent = origin + '/tiles/' + key + '/{z}/{x}/{y}.png';
const a = el('useJson');
a.textContent = origin + '/tiles/' + key + '.json';
a.href = origin + '/tiles/' + key + '.json';
};
sel.addEventListener('change', refresh);
el('useCopy').addEventListener('click', () => {
const text = el('useUrl').textContent;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => toast('URL copiée'),
() => window.prompt('URL XYZ :', text));
} else { window.prompt('URL XYZ :', text); }
});
refresh();
el('usecard').hidden = false;
}
// --- démarrage ------------------------------------------------------------
function fitData() {
if (META.bounds) {
map.fitBounds([[META.bounds[1], META.bounds[0]], [META.bounds[3], META.bounds[2]]],
{ padding: [30, 30] });
} else {
map.setView([46.6, 2.5], 6);
}
}
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
if (!META.layers.length) {
toast('Aucune visualisation disponible dans le cache.', 10000);
}
STATE = loadState(META);
// L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
// suite : ce qui est à l'écran est ce qui sera rechargé.
saveState();
el('stat').textContent = META.layers.length + ' couche(s)';
const hash = parseHash();
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
else fitData();
buildLayers();
renderPanel();
renderBaseRow();
}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
let applyingHash = false;
window.addEventListener('hashchange', () => {
// Lien partagé collé dans l'onglet courant : la vue (et l'état des couches
// s'il est présent) est appliquée sans rechargement.
if (applyingHash || !META) return;
const h = parseHash();
if (!h) return;
applyingHash = true;
map.setView([h.lat, h.lng], h.zoom);
if (h.layers) {
STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
}
setTimeout(() => { applyingHash = false; }, 0);
});
map.on('moveend zoomend', saveHashSoon);
map.on('click', (e) => {
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null).then(showTileInfo).catch(() => {});
});
el('cardToggle').addEventListener('click', () =>
el('layercard').classList.toggle('collapsed'));
el('tileClose').addEventListener('click', () => { el('tilecard').hidden = true; });
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
el('btnUse').addEventListener('click', renderUseCard);
el('btnShare').addEventListener('click', () => {
const url = location.origin + location.pathname + viewHash(true);
history.replaceState(null, '', url);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(() => toast('Lien copié'),
() => window.prompt('Lien :', url));
} else { window.prompt('Lien :', url); }
});
el('btnDefault').addEventListener('click', () => {
const body = {
order: STATE.order,
on: STATE.order.filter(k => STATE.on[k]),
opacity: Object.fromEntries(STATE.order.map(k => [k, STATE.opacity[k]])),
blend: Object.fromEntries(STATE.order.map(k => [k, STATE.blend[k] || 'normal'])),
base: STATE.base,
stack_blend: STATE.stackBlend || 'normal',
};
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_order = STATE.order.slice(); toast('Configuration enregistrée comme défaut'); })
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
});
el('btnReset').addEventListener('click', () => {
// Oublie l'état local et reprend la configuration servie par le serveur.
try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
fetch('api/map/meta').then(r => r.json()).then(meta => {
META = meta;
STATE = defaultState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
toast('Configuration par défaut rétablie');
}).catch(() => toast('Serveur injoignable', 6000));
});
el('baseToggle').addEventListener('click', () => {
STATE.base.on = !STATE.base.on;
saveState(); renderBaseRow(); applyLayers();
});
el('baseName').addEventListener('click', () => {
STATE.base.dark = !STATE.base.dark;
saveState(); applyLayers();
});
el('baseOpacity').addEventListener('input', (e) => {
STATE.base.opacity = Number(e.target.value) / 100;
el('basePct').textContent = e.target.value + '%';
base.setOpacity(STATE.base.opacity);
});
el('baseOpacity').addEventListener('change', saveState);
el('zoomIn').addEventListener('click', () => map.zoomIn(1));
el('zoomOut').addEventListener('click', () => map.zoomOut(1));
el('zoomFit').addEventListener('click', fitData);
el('gps').addEventListener('click', () => {
if (!('geolocation' in navigator)) {
toast('Géolocalisation indisponible (HTTPS requis).', 6000);
return;
}
const btn = el('gps');
btn.classList.add('busy');
navigator.geolocation.getCurrentPosition((pos) => {
btn.classList.remove('busy');
const ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, Math.max(map.getZoom(), 16));
if (window._gpsMarker) window._gpsMarker.remove();
window._gpsMarker = L.circleMarker(ll, {
radius: 6, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9
}).addTo(map);
}, (err) => {
btn.classList.remove('busy');
const why = err && err.code === 1 ? 'Autorisation refusée'
: err && err.code === 3 ? 'Délai dépassé' : 'Position indisponible';
toast(why + ' (la géolocalisation exige HTTPS).', 6000);
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { el('tilecard').hidden = true; el('usecard').hidden = true; }
});
"""