AVIF q60 par défaut et sélecteur de mode de fusion par couche

This commit is contained in:
Antoine Jacquin
2026-09-19 21:26:13 +02:00
parent 5f018fe2b6
commit 3a374a3ab4
6 changed files with 151 additions and 15 deletions

View File

@ -212,6 +212,12 @@ DEFAULT_LAYERS = ('positive_openness', 'aspect')
# Opacité par défaut des couches allumées (0–1) ; les absentes restent à 1.
DEFAULT_OPACITY = {'aspect': 0.6}
# Mode de fusion (mix-blend-mode CSS) par couche : les couches d'ombrage en
# niveaux de gris fondent en « multiply » — elles assombrissent le relief sans
# délaver la rampe de couleurs en dessous, contrairement à la transparence.
# Les couches absentes restent en « normal » (simple transparence).
DEFAULT_BLEND = {'hillshade_multi': 'multiply', 'solar': 'multiply', 'svf': 'multiply'}
# Couche « principale » (mise en avant ; index_tiles.json + affichage en
# direct de la webapp).
DEFAULT_VIZ = DEFAULT_LAYERS[0]
@ -1123,6 +1129,7 @@ def build_index(output_dir, output_format='avif'):
'stats': {'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys),
'default_opacity': dict(DEFAULT_OPACITY),
'default_blend': dict(DEFAULT_BLEND),
'n_tiles': len(display_tiles)},
}, ensure_ascii=False), encoding='utf-8')
@ -1239,6 +1246,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
{'default_viz': default_viz,
'default_layers': default_layers_present(all_viz_keys),
'default_opacity': dict(DEFAULT_OPACITY),
'default_blend': dict(DEFAULT_BLEND),
'n_tiles': len(tiles)},
)
@ -1589,6 +1597,15 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
padding: 5px 6px; border-radius: 6px;
user-select: none;
}
/* Ligne allumée : un sélecteur de mode de fusion s'insère avant le slider
(les lignes éteintes gardent la grille large pour un nom lisible). */
.layer-row.on { grid-template-columns: 16px 24px minmax(0, 1fr) 64px 62px 24px; }
.layer-blend {
font-size: 10.5px; color: var(--muted); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--panel-border); border-radius: 4px;
padding: 2px 2px; min-width: 0; width: 100%; height: 22px;
}
.layer-blend:hover { color: var(--text); }
#osmRow { grid-template-columns: 24px minmax(0, 1fr) 80px 30px; }
.layer-row:hover { background: rgba(255, 255, 255, 0.05); }
.layer-row.dragging { opacity: 0.45; }
@ -1667,6 +1684,10 @@ input[type="range"]::-moz-range-thumb {
/* Fond transparent obligatoire : l'opacité des couches doit laisser voir
les couches du dessous et le fond de carte, pas un rectangle noir. */
background: transparent;
/* Les modes de fusion (mix-blend-mode) des <img> se cantonnent à la pile de
couches de la tuile : sans isolation, « multiply » assombrirait aussi le
fond de carte OSM sous les zones sans données. */
isolation: isolate;
/* Layout et peinture des tuiles hors écran skippés par le navigateur */
content-visibility: auto;
}
@ -1998,8 +2019,28 @@ L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo
// État des couches (persisté en localStorage)
// ============================================================
// order : pile de visualisations, index 0 = bas de pile.
// blend : mode de fusion CSS (mix-blend-mode) de chaque couche — les couches
// se fondent entre elles au sein de la tuile (isolation: isolate sur .ltile)
// au lieu de simplement se superposer en transparence.
const LS_KEY = 'lidarLayers_v1';
// Modes de fusion proposés : [valeur CSS, libellé]. « multiply » assombrit le
// relief (ombrage par-dessus une rampe), « overlay » / « soft-light » creusent
// le contraste local, « screen » éclaircit (openness négative sur fond sombre).
const BLEND_MODES = [
['normal', 'Normal'],
['multiply', 'Produit'],
['overlay', 'Superposé'],
['soft-light', 'Doux'],
['screen', 'Écran'],
];
const BLEND_KEYS = BLEND_MODES.map(m => m[0]);
const blendLabel = v => {
const m = BLEND_MODES.find(x => x[0] === v);
return m ? m[1] : 'Normal';
};
const validBlend = v => BLEND_KEYS.includes(v) ? v : null;
function defaultLayers() {
const order = Object.keys(VIZ_META);
// Couches activées par défaut : seules celles-ci sont allumées, toutes les
@ -2023,14 +2064,18 @@ function defaultLayers() {
// Opacités par défaut (STATS.default_opacity) ; absentes → 1.
const defOp = (STATS.default_opacity && typeof STATS.default_opacity === 'object')
? STATS.default_opacity : {};
// Modes de fusion par défaut (STATS.default_blend) ; absents → normal.
const defBl = (STATS.default_blend && typeof STATS.default_blend === 'object')
? STATS.default_blend : {};
const onSet = new Set(defOrder);
const on = {}, opacity = {};
const on = {}, opacity = {}, blend = {};
for (const k of order) {
on[k] = onSet.has(k);
const op = Number(defOp[k]);
opacity[k] = Number.isNaN(op) ? 1 : Math.min(1, Math.max(0, op));
blend[k] = validBlend(defBl[k]) || 'normal';
}
return { order, on, opacity, osm: { on: true, opacity: 0.85, dark: true } };
return { order, on, opacity, blend, osm: { on: true, opacity: 0.85, dark: true } };
}
function loadLayers() {
@ -2045,19 +2090,28 @@ function loadLayers() {
for (const k of Object.keys(VIZ_META)) {
if (!order.includes(k)) order.push(k);
}
const on = {}, opacity = {};
const on = {}, opacity = {}, blend = {};
const savedBlend = (saved.blend && typeof saved.blend === 'object') ? saved.blend : {};
for (const k of order) {
const op = saved.opacity ? saved.opacity[k] : undefined;
on[k] = saved.on && saved.on[k] === true;
opacity[k] = (op === undefined || op === null || Number.isNaN(Number(op)))
? 1 : Math.min(1, Math.max(0, Number(op)));
blend[k] = validBlend(savedBlend[k]) || def.blend[k] || 'normal';
}
st = { order, on, opacity, osm: Object.assign({}, def.osm, saved.osm || {}) };
st = { order, on, opacity, blend, osm: Object.assign({}, def.osm, saved.osm || {}) };
}
} catch (e) { st = def; }
// Lien partagé (vue + couches + transparences) : il prime sur l'état local.
// Le lien ne transporte pas le mode de fusion : repli sur les défauts.
if (sharedHash) {
if (sharedHash.layers) st = sharedHash.layers;
if (sharedHash.layers) {
st = sharedHash.layers;
if (!st.blend || typeof st.blend !== 'object') st.blend = {};
for (const k of st.order) {
if (!validBlend(st.blend[k])) st.blend[k] = def.blend[k] || 'normal';
}
}
if (sharedHash.osm) st = Object.assign({}, st, { osm: sharedHash.osm });
}
return st;
@ -2216,6 +2270,7 @@ function syncTileLayers() {
}
img.style.zIndex = String(i + 1);
img.style.opacity = String(state.layers.opacity[k]);
img.style.mixBlendMode = state.layers.blend[k] || 'normal';
});
}
updateImages();
@ -2357,11 +2412,21 @@ function renderLayerPanel() {
row.draggable = !!(e.target.closest && e.target.closest('.layer-grip'));
});
row.title = meta.label;
const curBlend = state.layers.blend[key] || 'normal';
// Sélecteur de mode de fusion seulement sur les couches allumées :
// les lignes éteintes gardent la grille large (nom lisible).
const blendSel =
'<select class="layer-blend" title="Mode de fusion avec les couches en dessous">' +
BLEND_MODES.map(m =>
'<option value="' + m[0] + '"' + (m[0] === curBlend ? ' selected' : '') + '>' +
m[1] + '</option>').join('') +
'</select>';
row.innerHTML =
'<span class="layer-grip" title="Glisser pour réordonner">⠿</span>' +
'<button class="eye' + (state.layers.on[key] ? ' on' : '') + '" title="Afficher / masquer">' +
(state.layers.on[key] ? SVG_EYE : SVG_EYE_OFF) + '</button>' +
'<span class="layer-name">' + escapeHtml(meta.label) + '</span>' +
(state.layers.on[key] ? blendSel : '') +
'<input type="range" min="0" max="100" step="1" value="' +
Math.round(state.layers.opacity[key] * 100) + '" title="Opacité">' +
'<span class="layer-pct">' + Math.round(state.layers.opacity[key] * 100) + '%</span>';
@ -2391,6 +2456,17 @@ function renderLayerPanel() {
});
range.addEventListener('change', saveLayers);
const sel = row.querySelector('.layer-blend');
if (sel) sel.addEventListener('change', () => {
state.layers.blend[key] = sel.value;
saveLayers();
// Applique directement sans reconstruire le DOM
for (const entry of markers.values()) {
const img = entry.imgs[key];
if (img) img.style.mixBlendMode = sel.value;
}
});
row.addEventListener('dragstart', () => { dragKey = key; row.classList.add('dragging'); });
row.addEventListener('dragend', () => { dragKey = null; row.classList.remove('dragging'); row.draggable = false; });
row.addEventListener('dragover', e => e.preventDefault());
@ -2488,7 +2564,9 @@ const BUILTIN_PRESETS = [
function presetDetail(p) {
const parts = (p.on || []).filter(k => VIZ_META[k])
.map(k => (VIZ_META[k].label || k) + ' ' +
Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%');
Math.round(((p.opacity && p.opacity[k] != null) ? Number(p.opacity[k]) : 1) * 100) + '%' +
((p.blend && validBlend(p.blend[k]) && p.blend[k] !== 'normal')
? ' (' + blendLabel(p.blend[k]) + ')' : ''));
if (p.osm) parts.push('Fond ' + (p.osm.on
? Math.round((p.osm.opacity != null ? Number(p.osm.opacity) : 0.85) * 100) + '%'
: 'masqué'));
@ -2504,7 +2582,10 @@ function applyPreset(p) {
for (const k of L.order) {
const on = want.includes(k);
L.on[k] = on;
if (on) L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1;
if (on) {
L.opacity[k] = (p.opacity && k in p.opacity) ? Number(p.opacity[k]) : 1;
if (p.blend && validBlend(p.blend[k])) L.blend[k] = p.blend[k];
}
}
// Niveaux de transparence du fond de carte (visibilité, opacité, sombre)
if (p.osm) {
@ -2569,6 +2650,7 @@ presetSaveEl.addEventListener('click', () => {
label: name.trim(),
on: enabled,
opacity: Object.fromEntries(enabled.map(k => [k, state.layers.opacity[k]])),
blend: Object.fromEntries(enabled.map(k => [k, state.layers.blend[k] || 'normal'])),
// Niveaux de transparence du fond de carte
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
};