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

@ -306,19 +306,17 @@ def test_healthz_and_assets(tmp_path, monkeypatch):
def test_ui_main_layer_and_precision_modes(): def test_ui_main_layer_and_precision_modes():
"""Une couche d'affichage principal et la précision : relief seul, """Une couche d'affichage principal et la précision : relief seul,
précision seule, ou précision en « produit » sur le relief. précision seule, ou comparaison de part et d'autre d'une barre glissante.
Plus de pile réordonnable ni de fusion par couche. Le produit est Plus de pile réordonnable ni de fusion par couche. Bascule rapide au
cantonné à la pile LiDAR par un conteneur isolé (sinon il assombrirait clavier (touche P).
aussi le fond de carte). Bascule rapide au clavier (touche P).
""" """
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
for mode in ('data-mode="relief"', 'data-mode="precision"', 'data-mode="both"'): for mode in ('data-mode="relief"', 'data-mode="precision"', 'data-mode="compare"'):
assert mode in _MAP_HTML assert mode in _MAP_HTML
assert 'id="precOpacity"' in _MAP_HTML and 'id="precLegend"' in _MAP_HTML assert 'id="precLegend"' in _MAP_HTML
assert 'id="mainSel"' in _MAP_HTML assert 'id="mainSel"' in _MAP_HTML
assert "blend = 'multiply'" in _MAP_JS assert "createPane('lidarStack')" in _MAP_JS
assert "createPane('lidarStack')" in _MAP_JS and "isolation = 'isolate'" in _MAP_JS
assert "e.key === 'p'" in _MAP_JS assert "e.key === 'p'" in _MAP_JS
# Légende en 16 paliers exacts (échelle log de la densité) # Légende en 16 paliers exacts (échelle log de la densité)
assert "const PREC_LEVELS = 16;" in _MAP_JS and "repeat(16, 1fr)" in _MAP_CSS assert "const PREC_LEVELS = 16;" in _MAP_JS and "repeat(16, 1fr)" in _MAP_CSS
@ -509,6 +507,25 @@ def test_meta_serves_reading_text(tmp_path, monkeypatch):
assert layers["densite_sol"]["reading"] assert layers["densite_sol"]["reading"]
def test_ui_compare_mode_replaces_both():
"""Mode « Comparer » : une barre glissante sépare deux couches (relief,
précision ou OSM seul) ; l'ancien mode « les deux » (produit) disparaît
et un ancien lien P=both s'ouvre en relief."""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
assert 'data-mode="compare"' in _MAP_HTML and 'data-mode="both"' not in _MAP_HTML
assert 'id="precOpacity"' not in _MAP_HTML and "precOpacity" not in _MAP_JS
assert "mixBlendMode" not in _MAP_JS and "multiply" not in _MAP_JS
for id_ in ("compareBar", "compareLeft", "compareRight", "compareCtl"):
assert f'id="{id_}"' in _MAP_HTML, id_
assert "function applyCompareClip(" in _MAP_JS and "clipPath" in _MAP_JS
assert "map.on('move', applyCompareClip)" in _MAP_JS
assert "'&C=' +" in _MAP_JS
# Ancien lien P=both : mode relief
parse = _MAP_JS[_MAP_JS.index("function parseHash("):_MAP_JS.index("function viewHash(")]
assert "'both'" in parse and "'relief'" in parse
assert "#compareBar" in _MAP_CSS and "touch-action: none" in _MAP_CSS
def test_ui_applies_server_defaults(): def test_ui_applies_server_defaults():
"""L'interface part des défauts du serveur et sait les (re)définir.""" """L'interface part des défauts du serveur et sait les (re)définir."""
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
@ -516,7 +533,8 @@ def test_ui_applies_server_defaults():
assert "api/map/defaults" in _MAP_JS assert "api/map/defaults" in _MAP_JS
assert "meta.default_main" in _MAP_JS and "meta.default_mode" in _MAP_JS assert "meta.default_main" in _MAP_JS and "meta.default_mode" in _MAP_JS
assert "meta.default_base" in _MAP_JS assert "meta.default_base" in _MAP_JS
assert "meta.default_precision_opacity" in _MAP_JS assert "default_precision_opacity" not in _MAP_JS
assert "precision_opacity" not in _MAP_JS
# Réinitialiser oublie l'état local avant de reprendre celui du serveur # Réinitialiser oublie l'état local avant de reprendre celui du serveur
assert "lsDel(LS_KEY)" in _MAP_JS assert "lsDel(LS_KEY)" in _MAP_JS

View File

@ -57,6 +57,13 @@
html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text); html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
font: var(--fs-md)/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } font: var(--fs-md)/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
#map { position: absolute; inset: 0; background: var(--bg); } #map { position: absolute; inset: 0; background: var(--bg); }
/* Mode Comparer : barre glissante qui sépare les deux couches. */
#compareBar { position: fixed; top: 0; bottom: 0; width: 4px; margin-left: -2px; z-index: var(--z-map-ui);
background: var(--surface); box-shadow: var(--shadow-sm); cursor: ew-resize; touch-action: none; }
#compareBar span { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 36px; height: 36px; border-radius: var(--r-full); display: flex; align-items: center;
justify-content: center; background: var(--surface); color: var(--text); border: 1px solid var(--border);
box-shadow: var(--shadow-sm); font-size: var(--fs-lg); }
.leaflet-container { background: var(--bg); font: inherit; } .leaflet-container { background: var(--bg); font: inherit; }
/* Fond OSM assombri pour laisser ressortir les rendus LiDAR. Filtre posé sur /* Fond OSM assombri pour laisser ressortir les rendus LiDAR. Filtre posé sur
le conteneur de la couche, pas sur chaque tuile : même rendu (opérations le conteneur de la couche, pas sur chaque tuile : même rendu (opérations
@ -129,13 +136,11 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
gap: var(--sp-2); padding: var(--sp-1) 0; } gap: var(--sp-2); padding: var(--sp-1) 0; }
.base-row .base-ctl { grid-column: 2 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 40px; .base-row .base-ctl { grid-column: 2 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 40px;
gap: var(--sp-2); align-items: center; } gap: var(--sp-2); align-items: center; }
/* Affichage : une couche principale, la précision seule ou superposée. */ /* Affichage : une couche principale, la précision seule ou en comparaison. */
.view-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; .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; } gap: 8px; padding: 8px 4px 4px; border-top: 1px solid var(--border); margin-top: 4px; }
.view-label { font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); } .view-label { font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.view-row .layer-name { color: var(--text); } .view-row .layer-name { color: var(--text); }
.prec-ctl { display: grid; grid-template-columns: auto minmax(0, 1fr) 34px; gap: 8px;
align-items: center; padding: 6px 4px 0; font-size: var(--fs-xs); color: var(--muted); }
.prec-legend { padding: 8px 4px 2px; } .prec-legend { padding: 8px 4px 2px; }
.prec-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px; .prec-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px;
border: 1px solid var(--border); border-radius: 3px; overflow: hidden; } border: 1px solid var(--border); border-radius: 3px; overflow: hidden; }

View File

@ -10,6 +10,7 @@
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div id="compareBar" hidden title="Glisser pour déplacer la séparation"><span>⇆</span></div>
<aside id="panel" data-sheet="half" aria-label="Réglages de la carte"> <aside id="panel" data-sheet="half" aria-label="Réglages de la carte">
<div id="sheetGrip" class="sheet-grip" title="Glisser pour agrandir ou réduire"><span></span></div> <div id="sheetGrip" class="sheet-grip" title="Glisser pour agrandir ou réduire"><span></span></div>
@ -38,15 +39,15 @@
<select id="mainSel" class="field" title="Couche d'affichage principal" hidden></select> <select id="mainSel" class="field" title="Couche d'affichage principal" hidden></select>
</div> </div>
<div id="precBlock" hidden> <div id="precBlock" hidden>
<div class="seg" id="modeSeg" role="group" aria-label="Relief, précision ou les deux (touche P)"> <div class="seg" id="modeSeg" role="group" aria-label="Relief, précision ou comparaison (touche P)">
<button type="button" data-mode="relief" title="Relief seul (touche P : mode suivant)">Relief</button> <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="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> <button type="button" data-mode="compare" title="Comparer deux couches de part et d'autre d'une barre glissante (touche P : mode suivant)">Comparer</button>
</div> </div>
<div class="prec-ctl" id="precCtl" hidden> <div class="row" id="compareCtl" hidden>
<span>Opacité de la précision</span> <select id="compareLeft" class="field" title="Couche à gauche de la barre"></select>
<input type="range" id="precOpacity" min="0" max="100" step="1" title="Opacité de la précision sur le relief"> <span class="hint">⇆</span>
<span class="pct" id="precPct"></span> <select id="compareRight" class="field" title="Couche à droite de la barre"></select>
</div> </div>
<div class="prec-legend" id="precLegend" hidden> <div class="prec-legend" id="precLegend" hidden>
<div class="prec-bar" id="precBar"></div> <div class="prec-bar" id="precBar"></div>
@ -106,7 +107,7 @@
<h4 class="sec-title">Fond d'imagerie XYZ (JOSM, QGIS, iD…)</h4> <h4 class="sec-title">Fond d'imagerie XYZ (JOSM, QGIS, iD…)</h4>
<div id="useBody"></div> <div id="useBody"></div>
<h4 class="sec-title">Configuration par défaut</h4> <h4 class="sec-title">Configuration par défaut</h4>
<div class="row"><button id="btnDefault" class="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> <div class="row"><button id="btnDefault" class="btn" title="Figer l'affichage courant (couche principale, mode, fond) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
<button id="btnReset" class="btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button></div> <button id="btnReset" class="btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button></div>
</section> </section>
<section id="tileView" hidden> <section id="tileView" hidden>

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>'; 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 // Affichage : une couche principale (relief orienté) et la couche
// « précision » (densité de points sol), montrée seule ou superposée en // « précision » (densité de points sol), montrée seule ou comparée : une
// « produit » (mix-blend-mode multiply) sur la principale : les zones peu // barre glissante sépare deux couches (relief, précision ou OSM seul) de
// fiables s'assombrissent sans masquer le relief. Touche P : mode suivant. // part et d'autre. Touche P : mode suivant.
const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['both', 'Les deux']]; const VIEW_MODES = [['relief', 'Relief'], ['precision', 'Précision'], ['compare', 'Comparer']];
const VIEW_KEYS = VIEW_MODES.map(m => m[0]); const VIEW_KEYS = VIEW_MODES.map(m => m[0]);
const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null); const validMode = (v) => (VIEW_KEYS.includes(v) ? v : null);
const clamp01 = (v, d) => { const COMPARE_SIDES = [['relief', 'Relief'], ['precision', 'Précision'], ['osm', 'OSM seul']];
const n = Number(v); const validSide = (v) => (COMPARE_SIDES.some(s => s[0] === v) ? v : null);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : d; 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² // 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. // (visualizations.density_levels), gris = colormap « gray » sur 0..15.
const PREC_LEVELS = 16; const PREC_LEVELS = 16;
@ -221,7 +227,7 @@ function defaultState(meta) {
return { return {
main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null, main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
mode: validMode(meta.default_mode) || 'relief', 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, base: { on: b.on !== undefined ? !!b.on : true,
opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85, opacity: b.opacity !== undefined ? Number(b.opacity) : 0.85,
// null = suit le thème (choix explicite seulement si b.dark est déjà // 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 || {}); const sBase = Object.assign({}, s.base || {});
if (s.v !== 3) delete sBase.dark; if (s.v !== 3) delete sBase.dark;
st = { main: validMain(meta, s.main) || def.main, st = { main: validMain(meta, s.main) || def.main,
mode: validMode(s.mode) || def.mode, // Ancien mode « les deux » (produit) mémorisé : relief.
precOpacity: clamp01(s.precOpacity, def.precOpacity), mode: validMode(s.mode === 'both' ? 'relief' : s.mode) || def.mode,
compare: validCompare(s.compare),
base: Object.assign({}, def.base, sBase) }; base: Object.assign({}, def.base, sBase) };
} }
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la // Lien partagé : prime sur l'état local (les anciens liens &L=… de la
@ -250,7 +257,8 @@ function loadState(meta) {
const hash = parseHash(); const hash = parseHash();
if (hash) { if (hash) {
if (hash.main && validMain(meta, hash.main)) st.main = hash.main; 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; if (hash.base) st.base = hash.base;
} }
return st; return st;
@ -263,12 +271,12 @@ function saveState() {
} }
// --- hash de vue ---------------------------------------------------------- // --- 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() { function parseHash() {
const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || ''); const m = /^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)(.*)$/.exec(location.hash || '');
if (!m) return null; if (!m) return null;
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]), 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 = {}; const seg = {};
for (const part of m[4].split('&')) { for (const part of m[4].split('&')) {
const i = part.indexOf('='); const i = part.indexOf('=');
@ -277,8 +285,14 @@ function parseHash() {
if (seg.M) out.main = seg.M; if (seg.M) out.main = seg.M;
if (seg.P) { if (seg.P) {
const f = seg.P.split(':'); const f = seg.P.split(':');
const mode = validMode(f[0]); // Ancien mode « les deux » (produit) : s'ouvre en relief. L'opacité qui
if (mode) out.view = { mode, opacity: clamp01(Number(f[1]) / 100, 0.6) }; // 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) { if (seg.B) {
const f = seg.B.split(':'); 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); let h = '#' + map.getZoom().toFixed(1) + '/' + c.lat.toFixed(6) + '/' + c.lng.toFixed(6);
if (full) { if (full) {
if (STATE.main) h += '&M=' + STATE.main; 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; 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) h += '&B=' + (STATE.base.on ? '1' : '0') + ':' + Math.round(STATE.base.opacity * 100)
+ ':' + (dark ? '1' : '0'); + ':' + (dark ? '1' : '0');
@ -335,13 +352,10 @@ const EvenLevelTileLayer = ZoomAnimTileLayer.extend({
}); });
function buildLayers() { function buildLayers() {
// Conteneur isolé : `isolation: isolate` cantonne le « produit » de la // Pile LiDAR commune (z 400). Les panneaux sont réutilisés si déjà créés
// précision à la couche principale. Sans lui, il assombrirait aussi le fond // (rafraîchissement après un run de génération).
// 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'); const stack = map.getPane('lidarStack') || map.createPane('lidarStack');
stack.style.zIndex = 400; stack.style.zIndex = 400;
stack.style.isolation = 'isolate';
META.layers.forEach((info) => { META.layers.forEach((info) => {
const paneName = 'lidar-' + info.key; const paneName = 'lidar-' + info.key;
const pane = map.getPane(paneName) || map.createPane(paneName, stack); const pane = map.getPane(paneName) || map.createPane(paneName, stack);
@ -370,6 +384,9 @@ function buildLayers() {
errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', errorTileUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
attribution: META.attribution 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); LAYERS.set(info.key, layer);
}); });
applyLayers(); applyLayers();
@ -380,28 +397,23 @@ function effectiveMode() {
return precisionKey() ? STATE.mode : 'relief'; 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() { function applyLayers() {
const prec = precisionKey(); const prec = precisionKey();
const mode = effectiveMode(); 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) { for (const [key, layer] of LAYERS) {
const pane = map.getPane('lidar-' + key); const pane = map.getPane('lidar-' + key);
let show, opacity = 1, blend = 'normal'; if (pane) pane.style.zIndex = key === prec ? '402' : '401';
if (key === prec) { if (wanted.has(key)) { if (!map.hasLayer(layer)) layer.addTo(map); layer.setOpacity(1); }
show = mode !== 'relief'; else if (map.hasLayer(layer)) map.removeLayer(layer);
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 (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); } if (STATE.base.on) { if (!map.hasLayer(base)) base.addTo(map); }
else if (map.hasLayer(base)) map.removeLayer(base); else if (map.hasLayer(base)) map.removeLayer(base);
@ -409,9 +421,56 @@ function applyLayers() {
const container = base.getContainer && base.getContainer(); const container = base.getContainer && base.getContainer();
const dark = STATE.base.dark === null || STATE.base.dark === undefined ? themeIsDark() : STATE.base.dark; const dark = STATE.base.dark === null || STATE.base.dark === undefined ? themeIsDark() : STATE.base.dark;
if (container) container.classList.toggle('base-dark', dark); if (container) container.classList.toggle('base-dark', dark);
applyCompareClip();
updateRose(); 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) { function setMode(mode, announce) {
if (!validMode(mode)) return; if (!validMode(mode)) return;
STATE.mode = mode; STATE.mode = mode;
@ -458,10 +517,14 @@ function renderPanel() {
const mode = effectiveMode(); const mode = effectiveMode();
el('modeSeg').querySelectorAll('button').forEach(b => el('modeSeg').querySelectorAll('button').forEach(b =>
b.setAttribute('aria-pressed', String(b.dataset.mode === mode))); b.setAttribute('aria-pressed', String(b.dataset.mode === mode)));
el('precCtl').hidden = mode !== 'both'; el('compareCtl').hidden = mode !== 'compare';
el('precOpacity').value = Math.round(STATE.precOpacity * 100); for (const [id, side] of [['compareLeft', 'left'], ['compareRight', 'right']]) {
el('precPct').textContent = Math.round(STATE.precOpacity * 100) + '%'; const sel = el(id);
el('precLegend').hidden = mode === 'relief'; 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() { function renderBaseRow() {
@ -741,7 +804,11 @@ function drawRose() {
} }
function updateRose() { 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')); && LAYERS.get('relief_oriente'));
el('legendBlock').hidden = !on; el('legendBlock').hidden = !on;
if (on) drawRose(); if (on) drawRose();
@ -1426,7 +1493,7 @@ window.addEventListener('hashchange', () => {
if (!h) return; if (!h) return;
applyingHash = true; applyingHash = true;
map.setView([h.lat, h.lng], h.zoom); 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); STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers(); saveState(); renderPanel(); renderBaseRow(); applyLayers();
} }
@ -1456,7 +1523,6 @@ el('btnDefault').addEventListener('click', () => {
const body = { const body = {
main: STATE.main, main: STATE.main,
mode: STATE.mode, mode: STATE.mode,
precision_opacity: STATE.precOpacity,
base: STATE.base, base: STATE.base,
}; };
fetch('api/map/defaults', { fetch('api/map/defaults', {
@ -1465,7 +1531,7 @@ el('btnDefault').addEventListener('click', () => {
}).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status)) }).then(r => r.ok ? r.json() : Promise.reject('HTTP ' + r.status))
.then(() => { .then(() => {
META.default_main = STATE.main; META.default_mode = STATE.mode; 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'); toast('Affichage enregistré comme défaut');
}) })
.catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000)); .catch(msg => toast('Échec de l\'enregistrement : ' + msg, 6000));
@ -1477,13 +1543,12 @@ el('mainSel').addEventListener('change', (e) => {
STATE.main = e.target.value; STATE.main = e.target.value;
saveState(); renderPanel(); applyLayers(); saveState(); renderPanel(); applyLayers();
}); });
el('precOpacity').addEventListener('input', (e) => { for (const [id, side] of [['compareLeft', 'left'], ['compareRight', 'right']]) {
STATE.precOpacity = Number(e.target.value) / 100; el(id).addEventListener('change', (e) => {
el('precPct').textContent = e.target.value + '%'; STATE.compare[side] = validSide(e.target.value) || STATE.compare[side];
const layer = LAYERS.get(precisionKey()); saveState(); renderPanel(); applyLayers();
if (layer && map.hasLayer(layer)) layer.setOpacity(STATE.precOpacity); });
}); }
el('precOpacity').addEventListener('change', saveState);
el('btnReset').addEventListener('click', () => { el('btnReset').addEventListener('click', () => {
// Oublie l'état local et reprend la configuration servie par le serveur. // 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(); if (e.key === 'Escape' && typing) e.target.blur();
return; return;
} }
// P : relief → précision → les deux → relief // P : relief → précision → comparer → relief
if ((e.key === 'p' || e.key === 'P') && META && STATE && precisionKey()) { if ((e.key === 'p' || e.key === 'P') && META && STATE && precisionKey()) {
const i = VIEW_KEYS.indexOf(STATE.mode); const i = VIEW_KEYS.indexOf(STATE.mode);
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true); setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);