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:
@ -1205,7 +1205,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
</div>
|
||||
<div class="layer-sub">
|
||||
<span>Visualisations · glisser ⠿ pour réordonner</span>
|
||||
<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,9 +1857,10 @@ function defaultLayers() {
|
||||
|
||||
function loadLayers() {
|
||||
const def = defaultLayers();
|
||||
let st = def;
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return def;
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw);
|
||||
const order = (Array.isArray(saved.order) ? saved.order : [])
|
||||
.filter(k => VIZ_META[k]);
|
||||
@ -1868,10 +1874,22 @@ function loadLayers() {
|
||||
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; }
|
||||
st = { order, on, opacity, osm: Object.assign({}, def.osm, saved.osm || {}) };
|
||||
}
|
||||
} 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() };
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user