diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py
index 6fc73d7..d9c701d 100644
--- a/lidar_pipeline/index.py
+++ b/lidar_pipeline/index.py
@@ -1205,7 +1205,10 @@ _HTML_TEMPLATE = """
Visualisations · glisser ⠿ pour réordonner
-
+
+
+
+
@@ -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:// 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();
diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py
index 8e2ef53..ee92b7c 100644
--- a/lidar_pipeline/tests/test_index.py
+++ b/lidar_pipeline/tests/test_index.py
@@ -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