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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user