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