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():
"""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
cantonné à la pile LiDAR par un conteneur isolé (sinon il assombrirait
aussi le fond de carte). Bascule rapide au clavier (touche P).
Plus de pile réordonnable ni de fusion par couche. Bascule rapide au
clavier (touche P).
"""
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 'id="precOpacity"' in _MAP_HTML and 'id="precLegend"' in _MAP_HTML
assert 'id="precLegend"' in _MAP_HTML
assert 'id="mainSel"' in _MAP_HTML
assert "blend = 'multiply'" in _MAP_JS
assert "createPane('lidarStack')" in _MAP_JS and "isolation = 'isolate'" in _MAP_JS
assert "createPane('lidarStack')" in _MAP_JS
assert "e.key === 'p'" in _MAP_JS
# 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
@ -509,6 +507,25 @@ def test_meta_serves_reading_text(tmp_path, monkeypatch):
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():
"""L'interface part des défauts du serveur et sait les (re)définir."""
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 "meta.default_main" in _MAP_JS and "meta.default_mode" 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
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);
font: var(--fs-md)/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
#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; }
/* 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
@ -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; }
.base-row .base-ctl { grid-column: 2 / -1; display: grid; grid-template-columns: minmax(0, 1fr) 40px;
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;
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-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-bar { display: grid; grid-template-columns: repeat(16, 1fr); height: 10px;
border: 1px solid var(--border); border-radius: 3px; overflow: hidden; }

View File

@ -10,6 +10,7 @@
</head>
<body>
<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">
<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>
</div>
<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="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 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 class="row" id="compareCtl" hidden>
<select id="compareLeft" class="field" title="Couche à gauche de la barre"></select>
<span class="hint">⇆</span>
<select id="compareRight" class="field" title="Couche à droite de la barre"></select>
</div>
<div class="prec-legend" id="precLegend" hidden>
<div class="prec-bar" id="precBar"></div>
@ -106,7 +107,7 @@
<h4 class="sec-title">Fond d'imagerie XYZ (JOSM, QGIS, iD…)</h4>
<div id="useBody"></div>
<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>
</section>
<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>';
// 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);