diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py
index bae5ccb..eae93c7 100644
--- a/lidar_pipeline/tests/test_mapserve.py
+++ b/lidar_pipeline/tests/test_mapserve.py
@@ -329,6 +329,17 @@ def test_ui_main_layer_and_precision_modes():
assert "'&P=' + STATE.mode" in _MAP_JS
+def test_ui_relief_intensity():
+ """Curseur d'intensité (contraste) du relief : mémorisé, partagé dans le
+ lien (&I=, écrit seulement si ≠ 1), masqué en mode précision."""
+ from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
+ assert 'id="reliefIntensity"' in _MAP_HTML
+ assert 'min="0.5"' in _MAP_HTML and 'max="2"' in _MAP_HTML and 'step="0.1"' in _MAP_HTML
+ assert "function clampIntensity(" in _MAP_JS
+ assert "'contrast(' +" in _MAP_JS
+ assert "'&I=' +" in _MAP_JS and "seg.I" in _MAP_JS
+
+
def test_map_ui_tile_load_indicator_and_selection_pane():
"""Chargement : compteur de tuiles restantes + contour de dalle visible.
diff --git a/lidar_pipeline/web/map.html b/lidar_pipeline/web/map.html
index e2c60cf..b767438 100644
--- a/lidar_pipeline/web/map.html
+++ b/lidar_pipeline/web/map.html
@@ -45,6 +45,12 @@
+
+
+
+ 1×
+
⇆
diff --git a/lidar_pipeline/web/map.js b/lidar_pipeline/web/map.js
index ff26895..bdcc6fe 100644
--- a/lidar_pipeline/web/map.js
+++ b/lidar_pipeline/web/map.js
@@ -217,12 +217,22 @@ function validMain(meta, k) {
return mainKeys(meta).includes(k) ? k : null;
}
+// Intensité du relief : contraste appliqué au conteneur de la couche
+// principale (réglage de confort personnel, jamais figé comme défaut serveur
+// ni appliqué au PDF exporté).
+function clampIntensity(v) {
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.round(Math.min(2, Math.max(0.5, n)) * 10) / 10 : 1;
+}
+const fmtIntensity = (v) => String(v).replace('.', ',') + '×';
+
function defaultState(meta) {
const b = meta.default_base || {};
return {
main: validMain(meta, meta.default_main) || mainKeys(meta)[0] || null,
mode: validMode(meta.default_mode) || 'relief',
compare: validCompare(null),
+ intensity: 1,
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à
@@ -245,6 +255,7 @@ function loadState(meta) {
// Ancien mode « les deux » (produit) mémorisé : relief.
mode: validMode(s.mode === 'both' ? 'relief' : s.mode) || def.mode,
compare: validCompare(s.compare),
+ intensity: clampIntensity(s.intensity),
base: Object.assign({}, def.base, sBase) };
}
// Lien partagé : prime sur l'état local (les anciens liens &L=… de la
@@ -255,6 +266,7 @@ function loadState(meta) {
if (hash.view) st.mode = hash.view.mode;
if (hash.compare) st.compare = hash.compare;
if (hash.base) st.base = hash.base;
+ if (hash.intensity != null) st.intensity = hash.intensity;
}
return st;
}
@@ -271,13 +283,14 @@ 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, compare: null, base: null };
+ main: null, view: null, compare: null, base: null, intensity: 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.M) out.main = seg.M;
+ if (seg.I) out.intensity = clampIntensity(seg.I);
if (seg.P) {
const f = seg.P.split(':');
// Ancien mode « les deux » (produit) : s'ouvre en relief. L'opacité qui
@@ -311,6 +324,7 @@ function viewHash(full) {
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');
+ if (STATE.intensity !== 1) h += '&I=' + STATE.intensity;
}
return h;
}
@@ -416,6 +430,13 @@ 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);
+ // Intensité (contraste) : posée sur le conteneur de la couche relief
+ // seule — la découpe de comparaison (clipPath) reste indépendante, les
+ // deux propriétés CSS coexistent sur le même conteneur.
+ for (const [key, layer] of LAYERS) {
+ const c = layer.getContainer && layer.getContainer();
+ if (c) c.style.filter = key === STATE.main && STATE.intensity !== 1 ? 'contrast(' + STATE.intensity + ')' : '';
+ }
applyCompareClip();
updateRose();
}
@@ -528,6 +549,9 @@ function renderPanel() {
}
const prec = precisionKey();
el('precBlock').hidden = !prec;
+ el('reliefIntensity').value = STATE.intensity;
+ el('reliefIntensityVal').textContent = fmtIntensity(STATE.intensity);
+ el('intensityRow').hidden = effectiveMode() === 'precision';
if (!prec) return;
renderPrecLegend();
const mode = effectiveMode();
@@ -1456,7 +1480,7 @@ window.addEventListener('hashchange', () => {
if (!h) return;
applyingHash = true;
map.setView([h.lat, h.lng], h.zoom);
- if (h.main || h.view || h.base || h.compare) {
+ if (h.main || h.view || h.base || h.compare || h.intensity != null) {
STATE = loadState(META);
saveState(); renderPanel(); renderBaseRow(); applyLayers();
}
@@ -1511,6 +1535,12 @@ el('btnDefault').addEventListener('click', () => {
el('modeSeg').querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => setMode(b.dataset.mode)));
+el('reliefIntensity').addEventListener('input', (e) => {
+ STATE.intensity = clampIntensity(e.target.value);
+ el('reliefIntensityVal').textContent = fmtIntensity(STATE.intensity);
+ applyLayers();
+});
+el('reliefIntensity').addEventListener('change', saveState);
el('mainSel').addEventListener('change', (e) => {
STATE.main = e.target.value;
saveState(); renderPanel(); applyLayers();