Ajouter le mode Comparer à barre glissante et retirer le mode « les deux »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 19:44:16 +02:00
parent efabf06bc7
commit 857a9e55d6
4 changed files with 163 additions and 74 deletions

View File

@ -28,16 +28,22 @@ const SVG_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
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>';
// 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']];
// « précision » (densité de points sol), montrée seule ou comparée : une
// barre glissante sépare deux couches (relief, précision ou OSM seul) de
// part et d'autre. Touche P : mode suivant.
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['compare', 'Comparer']];
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;
};
const COMPARE_SIDES = [['relief', 'Relief'], ['precision', 'Précision'], ['osm', 'OSM seul']];
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 };
}
// 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;
@ -221,7 +227,7 @@ function defaultState(meta) {
return {
main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
mode: validMode(meta.default_mode) || 'relief',
precOpacity: clamp01(meta.default_precision_opacity, 0.6),
compare: validCompare(null),
base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
// null = suit le thème (choix explicite seulement si b.dark est déjà
@ -241,8 +247,9 @@ function loadState(meta) {
const sBase = Object.assign({}, s.base || {});
if (s.v !== 3) delete sBase.dark;
st = { main: validMain(meta, s.main) || def.main,
mode: validMode(s.mode) || def.mode,
precOpacity: clamp01(s.precOpacity, def.precOpacity),
// Ancien mode « les deux » (produit) mémorisé : relief.
mode: validMode(s.mode === 'both' ? 'relief' : s.mode) || def.mode,
compare: validCompare(s.compare),
base: Object.assign({}, def.base, sBase) };
}
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la
@ -250,7 +257,8 @@ function loadState(meta) {
const hash = parseHash();
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.view) st.mode = hash.view.mode;
if (hash.compare) st.compare = hash.compare;
if (hash.base) st.base = hash.base;
}
return st;
@ -263,12 +271,12 @@ function saveState() {
}
// --- hash de vue ----------------------------------------------------------
// #z/lat/lng&M=principale&P=mode:opacité&B=on:opacité:sombre
// #z/lat/lng&M=principale&P=mode&C=gauche:droite:position&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]),
main: null, view: null, base: null };
main: null, view: null, compare: null, base: null };
const seg = {};
for (const part of m[4].split('&')) {
const i = part.indexOf('=');
@ -277,8 +285,14 @@ function parseHash() {
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) };
// Ancien mode « les deux » (produit) : s'ouvre en relief. L'opacité qui
// suivait (P=mode:opacité) est ignorée.
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(':');
@ -295,7 +309,10 @@ function viewHash(full) {
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 + ':' + Math.round(STATE.precOpacity * 100);
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');
@ -335,13 +352,10 @@ const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
});
function buildLayers() {
// 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).
// Pile LiDAR commune (z 400). 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';
META.layers.forEach((info) => {
const paneName = 'lidar-' + info.key;
const pane = map.getPane(paneName) || map.createPane(paneName, stack);
@ -370,6 +384,9 @@ function buildLayers() {
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
attribution: META.attribution
});
// Le conteneur de tuiles n'existe qu'après l'ajout : la découpe de la
// comparaison lui est appliquée à ce moment-là.
layer.on('add', applyCompareClip);
LAYERS.set(info.key, layer);
});
applyLayers();
@ -380,28 +397,23 @@ function effectiveMode() {
return precisionKey() ? STATE.mode : 'relief';
}
// Couche affichée pour un côté de la comparaison (null = OSM seul).
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);
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(opacity);
} else if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
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);
@ -409,9 +421,56 @@ function applyLayers() {
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);
applyCompareClip();
updateRose();
}
// Découpe des couches de part et d'autre de la barre, en coordonnées de
// calque (les conteneurs de tuiles se déplacent avec la carte) : recalculée
// à chaque déplacement. Même couche des deux côtés : aucune découpe.
function applyCompareClip() {
if (!STATE) return;
const on = effectiveMode() === 'compare';
el('compareBar').hidden = !on;
const size = map.getSize();
const x = size.x * (on ? STATE.compare.pos : 50) / 100;
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;
}
if (on) {
const r = map.getContainer().getBoundingClientRect();
el('compareBar').style.left = (r.left + x) + 'px';
}
}
map.on('move', applyCompareClip);
map.on('resize zoomend', 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 r = map.getContainer().getBoundingClientRect();
STATE.compare.pos = Math.min(95, Math.max(5, 100 * (e.clientX - r.left) / r.width));
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;
@ -458,10 +517,14 @@ function renderPanel() {
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';
el('compareCtl').hidden = mode !== 'compare';
for (const [id, side] of [['compareLeft', 'left'], ['compareRight', 'right']]) {
const sel = el(id);
sel.innerHTML = COMPARE_SIDES.map(([k, lbl]) => '<option value="' + k + '"'
+ (k === STATE.compare[side] ? ' selected' : '') + '>' + lbl + '</option>').join('');
}
el('precLegend').hidden = mode === 'relief'
|| (mode === 'compare' && STATE.compare.left !== 'precision' && STATE.compare.right !== 'precision');
}
function renderBaseRow() {
@ -741,7 +804,11 @@ function drawRose() {
}
function updateRose() {
const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
// Visible dès que le relief est affiché : seul, ou d'un côté de la barre.
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();
@ -1426,7 +1493,7 @@ window.addEventListener('hashchange', () => {
if (!h) return;
applyingHash = true;
map.setView([h.lat, h.lng], h.zoom);
if (h.main || h.view || h.base) {
if (h.main || h.view || h.base || h.compare) {
STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
}
@ -1456,7 +1523,6 @@ el('btnDefault').addEventListener('click', () => {
const body = {
main: STATE.main,
mode: STATE.mode,
precision_opacity: STATE.precOpacity,
base: STATE.base,
};
fetch('api/map/defaults', {
@ -1465,7 +1531,7 @@ el('btnDefault').addEventListener('click', () => {
}).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
.then(() => {
META.default_main = STATE.main; META.default_mode = STATE.mode;
META.default_precision_opacity = STATE.precOpacity; META.default_base = STATE.base;
META.default_base = STATE.base;
toast('Affichage enregistré comme défaut');
})
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
@ -1477,13 +1543,12 @@ 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);
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', () => {
// Oublie l'état local et reprend la configuration servie par le serveur.
@ -1552,7 +1617,7 @@ document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && typing) e.target.blur();
return;
}
// P : relief → précision → les deux → relief
// P : relief → précision → comparer → 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);