Borner le comblement du MNT à l'enveloppe des points, ajouter la couche précision et un affichage relief/précision, encoder les sous-tuiles une seule fois en q75
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@ -67,19 +67,34 @@ _MAP_HTML = """<!DOCTYPE html>
|
||||
<input type="range" id="baseOpacity" min="0" max="100" step="1" title="Opacité du fond">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-edge" id="edgeTop">Haut de pile — devant</div>
|
||||
<div id="layers"></div>
|
||||
<div class="stack-edge" id="edgeBottom">Bas de pile — derrière</div>
|
||||
<div class="stack-row">
|
||||
<span>Fusion de la pile sur le fond</span>
|
||||
<select id="stackBlend" class="blend" title="Mode de fusion de l'ensemble des couches LiDAR sur le fond de carte"></select>
|
||||
<div class="view-row">
|
||||
<span class="view-label">Affichage</span>
|
||||
<span class="layer-name" id="mainName"></span>
|
||||
<select id="mainSel" class="view-sel" title="Couche d'affichage principal" hidden></select>
|
||||
</div>
|
||||
<div id="precBlock" hidden>
|
||||
<div class="mode-seg" id="modeSeg" role="group" aria-label="Relief, précision ou les deux (touche P)">
|
||||
<button type="button" data-mode="relief" title="Relief seul (touche P : mode suivant)">Relief</button>
|
||||
<button type="button" data-mode="precision" title="Précision géométrique seule : densité de points sol">Précision</button>
|
||||
<button type="button" data-mode="both" title="Précision en produit sur le relief : les zones peu fiables s'assombrissent">Les deux</button>
|
||||
</div>
|
||||
<div class="prec-ctl" id="precCtl" hidden>
|
||||
<span>Opacité de la précision</span>
|
||||
<input type="range" id="precOpacity" min="0" max="100" step="1" title="Opacité de la précision sur le relief">
|
||||
<span class="pct" id="precPct"></span>
|
||||
</div>
|
||||
<div class="prec-legend" id="precLegend" hidden>
|
||||
<div class="prec-bar" id="precBar"></div>
|
||||
<div class="prec-ticks"><span>≤ 0,35</span><span>4</span><span>≥ 45 pts/m²</span></div>
|
||||
<div class="prec-cap">Points sol par m² : sombre = relief interpolé, noir = aucun point</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<button id="btnShare" class="link-btn" title="Copier un lien vers cette vue">⎘ Partager la vue</button>
|
||||
<button id="btnUse" class="link-btn" title="Utiliser ces rendus comme fond d'imagerie (JOSM, QGIS, iD…)">⤓ Utiliser dans JOSM / QGIS</button>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<button id="btnDefault" class="link-btn" title="Figer l'état courant (ordre, visibilité, opacités, fusions) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
|
||||
<button id="btnDefault" class="link-btn" title="Figer l'affichage courant (couche principale, mode, opacité de la précision, fond) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
|
||||
<button id="btnReset" class="link-btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -220,49 +235,36 @@ button:hover { background: rgba(255, 255, 255, 0.12); border-color: var(--accent
|
||||
.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. */
|
||||
/* Ligne du fond de carte : œil, nom, opacité. */
|
||||
.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); }
|
||||
/* Affichage : une couche principale, la précision seule ou superposée. */
|
||||
.view-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center;
|
||||
gap: 8px; padding: 8px 4px 4px; border-top: 1px solid var(--border); margin-top: 4px; }
|
||||
.view-label { font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
|
||||
.view-row .layer-name { color: var(--text); }
|
||||
.view-sel { background: rgba(255, 255, 255, 0.06); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 2px 4px; font: inherit; font-size: 12px; min-width: 0; width: 100%; height: 24px; }
|
||||
.view-sel option { background-color: #10141b; color: var(--text); }
|
||||
.mode-seg { display: grid; grid-template-columns: repeat(3, 1fr); margin: 6px 4px 2px;
|
||||
border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.mode-seg button { border: none; border-radius: 0; background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--muted); padding: 6px 4px; font-size: 12px; }
|
||||
.mode-seg button + button { border-left: 1px solid var(--border); }
|
||||
.mode-seg button:hover { color: var(--text); background: rgba(255, 255, 255, 0.1); }
|
||||
.mode-seg button[aria-pressed="true"] { background: var(--accent); color: #0b0e13; font-weight: 600; }
|
||||
.mode-seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.prec-ctl { display: grid; grid-template-columns: auto minmax(0, 1fr) 34px; gap: 8px;
|
||||
align-items: center; padding: 6px 4px 0; font-size: 11.5px; color: var(--muted); }
|
||||
.prec-legend { padding: 8px 4px 2px; }
|
||||
.prec-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px;
|
||||
border: 1px solid var(--border); border-radius: 3px; overflow: hidden; }
|
||||
.prec-ticks { display: flex; justify-content: space-between; font-size: 10.5px; color: var(--muted);
|
||||
font-variant-numeric: tabular-nums; margin-top: 2px; }
|
||||
.prec-cap { font-size: 10.5px; color: var(--muted); margin-top: 2px; }
|
||||
.grip { color: var(--muted); cursor: grab; text-align: center; font-size: 12px; }
|
||||
.grip::before { content: '⠿'; }
|
||||
#baseRow .grip::before { content: ''; }
|
||||
@ -371,35 +373,29 @@ _MAP_JS = r"""'use strict';
|
||||
d'image, aucune rotation CSS, aucune mosaïque maison. */
|
||||
|
||||
const UI_VERSION = "__UI_VERSION__";
|
||||
const LS_KEY = 'lidarMapLayers_v1';
|
||||
const LS_KEY = 'lidarMapView_v2';
|
||||
const el = (id) => document.getElementById(id);
|
||||
const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const SVG_EYE_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||||
|
||||
// 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 => '<option value="' + m[0] + '"' + (m[0] === current ? ' selected' : '') +
|
||||
'>' + m[1] + '</option>').join('');
|
||||
// Affichage : une couche principale (relief orienté) et la couche
|
||||
// « précision » (densité de points sol), montrée seule ou superposée en
|
||||
// « produit » (mix-blend-mode multiply) sur la principale : les zones peu
|
||||
// fiables s'assombrissent sans masquer le relief. Touche P : mode suivant.
|
||||
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['both', 'Les deux']];
|
||||
const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
|
||||
const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
|
||||
const clamp01 = (v, d) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : d;
|
||||
};
|
||||
// Paliers de la précision : niveau k à partir de 0,25 × 2^(k/2) pts/m²
|
||||
// (visualizations.density_levels), gris = colormap « gray » sur 0..15.
|
||||
const PREC_LEVELS = 16;
|
||||
|
||||
let META = null;
|
||||
let STATE = null;
|
||||
const LAYERS = new Map(); // clé → L.TileLayer
|
||||
let dragKey = null;
|
||||
|
||||
const map = L.map('map', {
|
||||
zoomControl: false,
|
||||
@ -506,33 +502,28 @@ function toast(msg, ms) {
|
||||
}
|
||||
|
||||
// --- état persisté --------------------------------------------------------
|
||||
function precisionKey() {
|
||||
const p = META && META.precision_layer;
|
||||
return p && META.layers.some(l => l.key === p) ? p : null;
|
||||
}
|
||||
|
||||
function mainKeys(meta) {
|
||||
return meta.layers.map(l => l.key).filter(k => k !== meta.precision_layer);
|
||||
}
|
||||
|
||||
function validMain(meta, k) {
|
||||
return mainKeys(meta).includes(k) ? k : null;
|
||||
}
|
||||
|
||||
function defaultState(meta) {
|
||||
// `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,
|
||||
main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
|
||||
mode: validMode(meta.default_mode) || 'relief',
|
||||
precOpacity: clamp01(meta.default_precision_opacity, 0.6),
|
||||
base: { on: b.on !== undefined ? !!b.on : true,
|
||||
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
|
||||
dark: b.dark !== undefined ? !!b.dark : true },
|
||||
stackBlend: validBlend(meta.default_stack_blend) || 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
@ -543,33 +534,19 @@ function loadState(meta) {
|
||||
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' };
|
||||
st = { main: validMain(meta, s.main) || def.main,
|
||||
mode: validMode(s.mode) || def.mode,
|
||||
precOpacity: clamp01(s.precOpacity, def.precOpacity),
|
||||
base: Object.assign({}, def.base, s.base || {}) };
|
||||
}
|
||||
} catch (e) { st = def; }
|
||||
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la
|
||||
// pile de couches sont ignorés, la vue s'ouvre alors sur l'état courant).
|
||||
const hash = parseHash();
|
||||
if (hash && 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';
|
||||
}
|
||||
if (hash) {
|
||||
if (hash.main && validMain(meta, hash.main)) st.main = hash.main;
|
||||
if (hash.view) { st.mode = hash.view.mode; st.precOpacity = hash.view.opacity; }
|
||||
if (hash.base) st.base = hash.base;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
@ -579,33 +556,23 @@ function saveState() {
|
||||
}
|
||||
|
||||
// --- hash de vue ----------------------------------------------------------
|
||||
// #z/lat/lng&L=clé:opacité:visible,...&B=on:opacité:sombre
|
||||
// #z/lat/lng&M=principale&P=mode:opacité&B=on:opacité:sombre
|
||||
function parseHash() {
|
||||
const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
|
||||
if (!m) return null;
|
||||
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
|
||||
layers: null, base: null };
|
||||
main: null, view: null, base: null };
|
||||
const seg = {};
|
||||
for (const part of m[4].split('&')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) seg[part.slice(0, i)] = part.slice(i + 1);
|
||||
}
|
||||
if (seg.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.M) out.main = seg.M;
|
||||
if (seg.P) {
|
||||
const f = seg.P.split(':');
|
||||
const mode = validMode(f[0]);
|
||||
if (mode) out.view = { mode, opacity: clamp01(Number(f[1]) / 100, 0.6) };
|
||||
}
|
||||
if (seg.S) out.stackBlend = validBlend(seg.S) || 'normal';
|
||||
if (seg.B) {
|
||||
const f = seg.B.split(':');
|
||||
if (f.length === 3) {
|
||||
@ -620,12 +587,10 @@ 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(',');
|
||||
if (STATE.main) h += '&M=' + STATE.main;
|
||||
h += '&P=' + STATE.mode + ':' + Math.round(STATE.precOpacity * 100);
|
||||
h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
|
||||
+ ':' + (STATE.base.dark ? '1' : '0');
|
||||
h += '&S=' + (STATE.stackBlend || 'normal');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
@ -662,11 +627,10 @@ const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
|
||||
});
|
||||
|
||||
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). Les panneaux sont réutilisés si déjà
|
||||
// créés (rafraîchissement après un run de génération).
|
||||
// Conteneur isolé : `isolation: isolate` cantonne le « produit » de la
|
||||
// précision à la couche principale. Sans lui, il assombrirait aussi le fond
|
||||
// OSM. Les panneaux sont réutilisés si déjà créés (rafraîchissement après
|
||||
// un run de génération).
|
||||
const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
|
||||
stack.style.zIndex = 400;
|
||||
stack.style.isolation = 'isolate';
|
||||
@ -703,20 +667,30 @@ function buildLayers() {
|
||||
applyLayers();
|
||||
}
|
||||
|
||||
// Mode réellement appliqué : sans couche précision servie, relief seul.
|
||||
function effectiveMode() {
|
||||
return precisionKey() ? STATE.mode : 'relief';
|
||||
}
|
||||
|
||||
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 prec = precisionKey();
|
||||
const mode = effectiveMode();
|
||||
for (const [key, layer] of LAYERS) {
|
||||
const pane = map.getPane('lidar-' + key);
|
||||
z += 1;
|
||||
pane.style.zIndex = String(z);
|
||||
pane.style.mixBlendMode = STATE.blend[key] || 'normal';
|
||||
if (STATE.on[key]) {
|
||||
let show, opacity = 1, blend = 'normal';
|
||||
if (key === prec) {
|
||||
show = mode !== 'relief';
|
||||
if (mode === 'both') { opacity = STATE.precOpacity; blend = 'multiply'; }
|
||||
} else {
|
||||
show = key === STATE.main && mode !== 'precision';
|
||||
}
|
||||
if (pane) {
|
||||
pane.style.zIndex = key === prec ? '402' : '401';
|
||||
pane.style.mixBlendMode = blend;
|
||||
}
|
||||
if (show) {
|
||||
if (!map.hasLayer(layer)) layer.addTo(map);
|
||||
layer.setOpacity(STATE.opacity[key]);
|
||||
layer.setOpacity(opacity);
|
||||
} else if (map.hasLayer(layer)) {
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
@ -729,156 +703,60 @@ function applyLayers() {
|
||||
updateRose();
|
||||
}
|
||||
|
||||
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();
|
||||
function setMode(mode, announce) {
|
||||
if (!validMode(mode)) return;
|
||||
STATE.mode = mode;
|
||||
saveState(); renderPanel(); applyLayers();
|
||||
if (announce) toast('Affichage : ' + VIEW_MODES.find(m => m[0] === mode)[1], 1200);
|
||||
}
|
||||
|
||||
// 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 =
|
||||
'<span class="grip" title="Glisser pour réordonner"></span>' +
|
||||
'<button class="eye' + (on ? ' on' : '') + '" title="Afficher / masquer">' +
|
||||
(on ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
|
||||
'<span class="layer-name">' + info.label + '</span>' +
|
||||
'<span class="layer-move">' +
|
||||
'<button class="up" title="Monter dans la pile"' +
|
||||
(idx === 0 ? ' disabled' : '') + '>▲</button>' +
|
||||
'<button class="down" title="Descendre dans la pile"' +
|
||||
(idx === topFirst.length - 1 ? ' disabled' : '') + '>▼</button>' +
|
||||
'</span>' +
|
||||
'<span class="pct">' + Math.round(STATE.opacity[key] * 100) + '%</span>' +
|
||||
(on ? '<div class="layer-ctl">' +
|
||||
'<select class="blend" title="Mode de fusion avec les couches du dessous">' +
|
||||
blendOptions(STATE.blend[key] || 'normal') + '</select>' +
|
||||
'<input type="range" min="0" max="100" step="1" value="' +
|
||||
Math.round(STATE.opacity[key] * 100) + '" title="Opacité">' +
|
||||
'</div>' : '');
|
||||
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 renderPrecLegend() {
|
||||
const bar = el('precBar');
|
||||
if (bar.childElementCount) return;
|
||||
for (let k = 0; k < PREC_LEVELS; k++) {
|
||||
const g = Math.round(255 * k / (PREC_LEVELS - 1));
|
||||
const lo = 0.25 * Math.pow(2, k / 2);
|
||||
const hi = 0.25 * Math.pow(2, (k + 1) / 2);
|
||||
const cell = document.createElement('i');
|
||||
cell.style.background = 'rgb(' + g + ',' + g + ',' + g + ')';
|
||||
cell.title = k === 0 ? '≤ ' + hi.toFixed(2).replace('.', ',') + ' pt/m²'
|
||||
: k === PREC_LEVELS - 1 ? '≥ ' + Math.round(lo) + ' pts/m²'
|
||||
: lo.toFixed(lo < 10 ? 1 : 0).replace('.', ',') + '–'
|
||||
+ hi.toFixed(hi < 10 ? 1 : 0).replace('.', ',') + ' pts/m²';
|
||||
bar.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const labels = new Map(META.layers.map(l => [l.key, l]));
|
||||
const mains = mainKeys(META);
|
||||
const info = labels.get(STATE.main);
|
||||
const name = el('mainName'), sel = el('mainSel');
|
||||
name.textContent = info ? info.label : 'Aucune couche';
|
||||
name.title = info ? (info.description || info.label) : '';
|
||||
// Plusieurs couches principales possibles : menu ; sinon simple libellé.
|
||||
const many = mains.length > 1;
|
||||
name.hidden = many;
|
||||
sel.hidden = !many;
|
||||
if (many) {
|
||||
sel.innerHTML = mains.map(k => '<option value="' + k + '"' + (k === STATE.main ? ' selected' : '')
|
||||
+ '>' + labels.get(k).label + '</option>').join('');
|
||||
}
|
||||
const prec = precisionKey();
|
||||
el('precBlock').hidden = !prec;
|
||||
if (!prec) return;
|
||||
renderPrecLegend();
|
||||
const mode = effectiveMode();
|
||||
el('modeSeg').querySelectorAll('button').forEach(b =>
|
||||
b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
|
||||
el('precCtl').hidden = mode !== 'both';
|
||||
el('precOpacity').value = Math.round(STATE.precOpacity * 100);
|
||||
el('precPct').textContent = Math.round(STATE.precOpacity * 100) + '%';
|
||||
el('precLegend').hidden = mode === 'relief';
|
||||
}
|
||||
|
||||
function renderBaseRow() {
|
||||
const b = STATE.base;
|
||||
const 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;
|
||||
@ -1062,7 +940,8 @@ function drawRose() {
|
||||
}
|
||||
|
||||
function updateRose() {
|
||||
const on = !!(STATE && STATE.on && STATE.on.relief_oriente && LAYERS.get('relief_oriente'));
|
||||
const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
|
||||
&& LAYERS.get('relief_oriente'));
|
||||
el('rose').hidden = !on;
|
||||
if (on) drawRose();
|
||||
}
|
||||
@ -1429,7 +1308,7 @@ window.addEventListener('hashchange', () => {
|
||||
if (!h) return;
|
||||
applyingHash = true;
|
||||
map.setView([h.lat, h.lng], h.zoom);
|
||||
if (h.layers) {
|
||||
if (h.main || h.view || h.base) {
|
||||
STATE = loadState(META);
|
||||
saveState(); renderPanel(); renderBaseRow(); applyLayers();
|
||||
}
|
||||
@ -1463,21 +1342,37 @@ el('btnShare').addEventListener('click', () => {
|
||||
});
|
||||
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'])),
|
||||
main: STATE.main,
|
||||
mode: STATE.mode,
|
||||
precision_opacity: STATE.precOpacity,
|
||||
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'); })
|
||||
.then(() => {
|
||||
META.default_main = STATE.main; META.default_mode = STATE.mode;
|
||||
META.default_precision_opacity = STATE.precOpacity; META.default_base = STATE.base;
|
||||
toast('Affichage enregistré comme défaut');
|
||||
})
|
||||
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
|
||||
});
|
||||
|
||||
el('modeSeg').querySelectorAll('button').forEach(b =>
|
||||
b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||
el('mainSel').addEventListener('change', (e) => {
|
||||
STATE.main = e.target.value;
|
||||
saveState(); renderPanel(); applyLayers();
|
||||
});
|
||||
el('precOpacity').addEventListener('input', (e) => {
|
||||
STATE.precOpacity = Number(e.target.value) / 100;
|
||||
el('precPct').textContent = e.target.value + '%';
|
||||
const layer = LAYERS.get(precisionKey());
|
||||
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.precOpacity);
|
||||
});
|
||||
el('precOpacity').addEventListener('change', saveState);
|
||||
|
||||
el('btnReset').addEventListener('click', () => {
|
||||
// Oublie l'état local et reprend la configuration servie par le serveur.
|
||||
try { localStorage.removeItem(LS_KEY); } catch (e) { /* privé */ }
|
||||
@ -1529,6 +1424,14 @@ el('gps').addEventListener('click', () => {
|
||||
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// P : relief → précision → les deux → relief (hors saisie de texte)
|
||||
const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
|
||||
if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
|
||||
&& META && STATE && precisionKey()) {
|
||||
const i = VIEW_KEYS.indexOf(STATE.mode);
|
||||
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
el('tilecard').hidden = true; el('usecard').hidden = true;
|
||||
if (genDrawing) genSetDrawing(false);
|
||||
|
||||
Reference in New Issue
Block a user