Partage de vue : lien avec couches, transparences et fond OSM

Bouton « Partager » dans l'onglet Couches : copie un lien URL encodant
la vue (zoom/lat/lng), la pile de couches avec visibilité et opacité,
et le fond OSM (affiché, opacité, mode sombre). Le destinataire reçoit
l'état exact, le lien prime sur son localStorage local. Hash étendu :
#zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark — le hash
simple #zoom,lat,lng reste rétro-compatible.
This commit is contained in:
Antoine Jacquin
2026-09-12 23:43:36 +02:00
parent bc94ae3df0
commit 24897367e0
2 changed files with 135 additions and 27 deletions

View File

@ -1205,7 +1205,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
</div>
<div class="layer-sub">
<span>Visualisations · glisser ⠿ pour réordonner</span>
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
<span class="layer-sub-actions">
<button id="btnShare" class="icon-btn" title="Copier un lien partageant la vue, les couches et leurs transparences">⎘ Partager</button>
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
</span>
</div>
<div id="vizLayers"></div>
</div>
@ -1504,6 +1507,8 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
letter-spacing: 0.05em; padding: 8px 6px 4px;
border-top: 1px solid var(--panel-border); margin-top: 6px;
}
.layer-sub-actions { display: inline-flex; align-items: center; gap: 4px; }
.layer-sub-actions .icon-btn { font-size: 10.5px; padding: 1px 6px; }
/* Sliders d'opacité : piste fine dans une zone cliquable haute de 18 px —
le pouce reste saisissable au drag (l'élément lui-même fait toute la
@ -1852,27 +1857,40 @@ function defaultLayers() {
function loadLayers() {
const def = defaultLayers();
let st = def;
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return def;
const saved = JSON.parse(raw);
const order = (Array.isArray(saved.order) ? saved.order : [])
.filter(k => VIZ_META[k]);
for (const k of Object.keys(VIZ_META)) {
if (!order.includes(k)) order.push(k);
if (raw) {
const saved = JSON.parse(raw);
const order = (Array.isArray(saved.order) ? saved.order : [])
.filter(k => VIZ_META[k]);
for (const k of Object.keys(VIZ_META)) {
if (!order.includes(k)) order.push(k);
}
const on = {}, opacity = {};
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)));
}
st = { order, on, opacity, osm: Object.assign({}, def.osm, saved.osm || {}) };
}
const on = {}, opacity = {};
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)));
}
const osm = Object.assign({}, def.osm, saved.osm || {});
return { order, on, opacity, osm };
} catch (e) { return def; }
} catch (e) { st = def; }
// Lien partagé (vue + couches + transparences) : il prime sur l'état local.
if (sharedHash) {
if (sharedHash.layers) st = sharedHash.layers;
if (sharedHash.osm) st = Object.assign({}, st, { osm: sharedHash.osm });
}
return st;
}
// Lien partagé analysé une seule fois au chargement :
// #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark (format détaillé
// dans parseSharedHash). Les couches partagent l'ordre de pile (bas → haut)
// et l'opacité en pourcentage, indépendamment du localStorage local.
const sharedHash = parseSharedHash();
const state = { layers: loadLayers() };
function saveLayers() {
@ -2207,20 +2225,105 @@ document.getElementById('btnMenu').addEventListener('click', () =>
document.body.classList.toggle('menu-open'));
// ============================================================
// Vue : zoom / pan / persistance (#zoom,lat,lng dans l'URL)
// Vue : zoom / pan / persistance et partage.
// Hash simple : #zoom,lat,lng. Hash partagé (bouton « Partager ») :
// #zoom,lat,lng&L=clé:opacité:visible,...&O=on:opacité:dark — L liste
// TOUTES les couches de bas en haut (opacité en %, visible 0/1), O
// décrit le fond OSM (affiché : opacité en % : mode sombre).
// ============================================================
function readViewHash() {
const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)$/.exec(location.hash);
if (m && !isNaN(parseFloat(m[1])) && !isNaN(parseFloat(m[2])) && !isNaN(parseFloat(m[3]))) {
return { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]) };
function parseSharedHash() {
const m = /^#([\d.]+),(-?[\d.]+),(-?[\d.]+)(.*)$/.exec(location.hash || '');
if (!m || isNaN(parseFloat(m[1])) || isNaN(parseFloat(m[2])) || isNaN(parseFloat(m[3]))) return null;
const out = { zoom: parseFloat(m[1]), lat: parseFloat(m[2]), lng: parseFloat(m[3]),
layers: null, osm: null };
const seg = {};
for (const p of m[4].split('&')) {
const i = p.indexOf('=');
if (i > 0) seg[p.slice(0, i)] = p.slice(i + 1);
}
return null;
if (seg.L) {
const found = {}, order = [];
for (const part of seg.L.split(',')) {
const f = part.split(':');
if (f.length < 3 || !VIZ_META[f[0]]) continue;
const op = Number(f[1]);
if (isNaN(op)) continue;
found[f[0]] = { on: f[2] === '1', op: Math.min(100, Math.max(0, op)) };
order.push(f[0]);
}
if (order.length) {
for (const k of Object.keys(VIZ_META)) if (!order.includes(k)) order.push(k);
const on = {}, opacity = {};
for (const k of order) {
const e = found[k];
on[k] = e ? e.on : false;
opacity[k] = e ? e.op / 100 : 1;
}
out.layers = { order, on, opacity };
}
}
if (seg.O) {
const f = seg.O.split(':');
if (f.length === 3) {
const op = Number(f[1]);
if (!isNaN(op)) {
out.osm = { on: f[0] === '1', opacity: Math.min(100, Math.max(0, op)) / 100, dark: f[2] === '1' };
}
}
}
return out;
}
function readViewHash() { return parseSharedHash(); }
function saveViewHash() {
const c = map.getCenter();
history.replaceState(null, '', '#' +
map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6));
}
// Construit le lien partageable : vue courante + état complet des couches
// (pile, visibilité, opacités) + fond OSM.
function buildShareHash() {
const c = map.getCenter();
const L = state.layers.order.map(k =>
k + ':' + Math.round(state.layers.opacity[k] * 100) + ':' + (state.layers.on[k] ? '1' : '0')
).join(',');
const o = state.layers.osm;
return '#' + map.getZoom().toFixed(2) + ',' + c.lat.toFixed(6) + ',' + c.lng.toFixed(6) +
'&L=' + L +
'&O=' + (o.on ? '1' : '0') + ':' + Math.round(o.opacity * 100) + ':' + (o.dark ? '1' : '0');
}
// Copie du lien : l'API presse-papiers n'existe qu'en contexte sécurisé
// (https/localhost) — sur http://<raspberry-pi> on repasse par
// execCommand, puis par un prompt de repli.
const shareBtn = document.getElementById('btnShare');
function copyShareLink() {
const url = location.href;
const feedback = () => {
if (!shareBtn) return;
shareBtn.textContent = 'Lien copié ✓';
shareBtn.disabled = true;
setTimeout(() => { shareBtn.textContent = '⎘ Partager'; shareBtn.disabled = false; }, 2000);
};
const legacy = () => {
const inp = document.createElement('input');
inp.value = url;
inp.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(inp);
inp.select();
inp.setSelectionRange(0, url.length);
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
inp.remove();
if (ok) feedback();
else window.prompt('Copier le lien de la vue :', url);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(feedback, legacy);
} else legacy();
}
if (shareBtn) shareBtn.addEventListener('click', () => {
history.replaceState(null, '', location.pathname + location.search + buildShareHash());
copyShareLink();
});
function fitView() {
// Si une vue a été sauvegardée dans l'URL (F5 / lien partagé), on la
@ -3178,10 +3281,10 @@ function pollGenStatus() {
}, 2000);
}
// Après un run : ramener les tuiles produites par la machine de traitement
// (rsync via /api/sync — mode webapp sur Raspberry Pi), reconstruire vignettes
// et carte, puis recharger. Sans backend distant, /api/sync se réduit à un
// rebuild local : même flux partout. Rechargement de secours après 30 min.
// Après un run : reconstruire vignettes et carte depuis le cache à la demande
// (/api/sync — sur la webapp légère, les tuiles proviennent de la machine de
// traitement), puis recharger. /api/sync se réduit à un rebuild local : même
// flux partout. Rechargement de secours après 30 min.
function syncThenReload() {
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
const t0 = Date.now();

View File

@ -273,6 +273,11 @@ def test_build_index_generates_html(tmp_path):
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/sync" in app_js
# Partage de la vue : bouton « Partager » + lien contenant vue, couches
# et transparences (hash #zoom,lat,lng&L=...&O=...) — le destinataire
# reçoit l'état exact, indépendamment de son localStorage local.
assert 'id="btnShare"' in content
assert "buildShareHash" in app_js and "parseSharedHash" in app_js
assert 'assets/app.js' in content
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
assert 'assets/vendor/leaflet/leaflet.js' in content