diff --git a/docs/MAPS.md b/docs/MAPS.md index 3bddb14..170537e 100644 --- a/docs/MAPS.md +++ b/docs/MAPS.md @@ -171,12 +171,16 @@ principale, mode validé, opacités bornées à 0–1. Un fichier de l'ancienne Le bouton **⎙** ouvre une carte de réglages (format A4/A3, paysage/portrait, échelle 1:1 000 à 1:10 000, titre optionnel) et affiche un **cadre jaune en -pointillés** qui montre la zone qui sera imprimée. Le cadre reste toujours -centré sur la vue : c'est la carte qu'on déplace en dessous, pas le cadre — -il se recalcule (géométrie Lambert 93 exacte, `GET /api/export/frame`) à -chaque déplacement et à chaque changement de réglage. Les réglages sont -mémorisés dans `localStorage` du navigateur et rechargés à l'ouverture -suivante. **Exporter le PDF** télécharge la planche (`GET +pointillés** qui montre la zone qui sera imprimée. Le cadre est **posé sur le +terrain** : il se place au centre de la vue à l'ouverture (ou garde sa position +précédente si elle est encore visible), la carte zoome pour le montrer en +entier au-dessus des panneaux, puis on navigue librement sans qu'il bouge. On le +déplace en faisant glisser sa **poignée ✥** (souris ou doigt) ; au relâchement, +la géométrie Lambert 93 exacte est recalculée (`GET /api/export/frame`). +« ⌖ Centrer ici » le ramène au centre de la vue, « ⤢ Voir le cadre » zoome +dessus ; changer de format, d'orientation ou d'échelle recadre la vue. Les +réglages et la position du cadre sont mémorisés dans `localStorage` du +navigateur. **Exporter le PDF** télécharge la planche (`GET /api/export/pdf`), nommée `relief_{x_km}_{y_km}_1-{échelle}.pdf` (centre Lambert 93 en km, à trois décimales). diff --git a/lidar_pipeline/mapui.py b/lidar_pipeline/mapui.py index f7022df..01aa188 100644 --- a/lidar_pipeline/mapui.py +++ b/lidar_pipeline/mapui.py @@ -137,7 +137,10 @@ _MAP_HTML = """
- + +
+
@@ -350,6 +353,9 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; } .print-hint { margin: 4px 0; font-size: 12px; opacity: 0.75; } .print-msg { font-size: 12px; margin-left: 8px; } +.print-handle { display: flex; align-items: center; justify-content: center; cursor: move; + font-size: 20px; color: #1b1b1b; background: #ffd166; border: 2px solid #1b1b1b; + border-radius: 50%; box-shadow: 0 1px 6px rgba(0,0,0,0.5); } body.drawing { cursor: crosshair !important; } body.drawing .leaflet-container { cursor: crosshair !important; } /* Barre de génération : en HAUT à droite de l'écran, boutons accentués — @@ -1288,10 +1294,11 @@ el('genQueueClear').addEventListener('click', () => .catch(msg => toast('Échec : ' + msg.message, 6000))); // --- export PDF (planche d'impression terrain) --- -// Cadre = zone imprimée, toujours centré sur la vue : on déplace la carte -// dessous. Géométrie calculée par le serveur (projection Lambert 93). +// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte +// se déplace librement autour, on le fait glisser à la souris ou au doigt. +// Géométrie exacte calculée par le serveur (projection Lambert 93). const PRINT_LS = 'lidar-print'; -const PRINT = { on: false, rect: null, seq: 0 }; +const PRINT = { on: false, rect: null, handle: null, seq: 0, center: null, corners: null, drag: null }; map.createPane('printFrame').style.zIndex = '690'; function printSettings() { @@ -1300,7 +1307,7 @@ function printSettings() { } function printQuery(s, withTitle) { - const c = map.getCenter(); + const c = PRINT.center || map.getCenter(); const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6), paper: s.paper, orient: s.orient, scale: s.scale }); if (withTitle && s.title) q.set('title', s.title); @@ -1308,7 +1315,9 @@ function printQuery(s, withTitle) { } function printSave() { - try { localStorage.setItem(PRINT_LS, JSON.stringify(printSettings())); } catch (e) { /* privé */ } + const s = printSettings(); + if (PRINT.center) s.center = [PRINT.center.lat, PRINT.center.lng]; + try { localStorage.setItem(PRINT_LS, JSON.stringify(s)); } catch (e) { /* privé */ } } function printLoad() { @@ -1318,30 +1327,98 @@ function printLoad() { ['scale', 'printScale'], ['title', 'printTitle']]) { if (s[k] != null) el(id).value = s[k]; } + if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center); } catch (e) { /* privé ou illisible : réglages par défaut */ } } -function printRefreshFrame() { +// Recadre sur le cadre en laissant libre la bande basse couverte par les +// cartes (couches, export…) : sinon le bas du cadre passe dessous. +function printFit() { + if (!PRINT.corners) return; + const box = map.getContainer().getBoundingClientRect(); + let bottom = 0; + for (const c of document.querySelectorAll('.card')) { + if (c.hidden || !c.offsetParent) continue; + const r = c.getBoundingClientRect(); + if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top); + } + map.fitBounds(L.latLngBounds(PRINT.corners), + { paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] }); +} + +function printDraw(corners) { + PRINT.corners = corners; + if (PRINT.rect) PRINT.rect.setLatLngs(corners); + else PRINT.rect = L.polygon(corners, { pane: 'printFrame', color: '#ffd166', weight: 2, + dashArray: '8 5', fill: false, interactive: false }).addTo(map); + if (PRINT.handle) { PRINT.handle.setLatLng(PRINT.center); return; } + PRINT.handle = L.marker(PRINT.center, { + pane: 'printFrame', draggable: true, autoPan: true, keyboard: false, + title: 'Faire glisser pour déplacer la zone imprimée', + icon: L.divIcon({ className: 'print-handle', html: '✥', iconSize: [34, 34] }), + }).addTo(map); + PRINT.handle.on('dragstart', printDragStart); + PRINT.handle.on('drag', printDragMove); + PRINT.handle.on('dragend', printDragEnd); +} + +// fit : recadrer la vue sur le cadre une fois la géométrie reçue. +function printRefreshFrame(fit) { if (!PRINT.on) return; const seq = ++PRINT.seq; fetch('api/export/frame?' + printQuery(printSettings(), false)) .then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))) .then(d => { - if (seq !== PRINT.seq || !PRINT.on) return; - if (PRINT.rect) PRINT.rect.setLatLngs(d.corners); - else PRINT.rect = L.polygon(d.corners, { pane: 'printFrame', color: '#ffd166', weight: 2, - dashArray: '8 5', fill: false, interactive: false }).addTo(map); + if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return; + printDraw(d.corners); el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m'; + if (fit === true) printFit(); }) .catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; }); } +// Glisser la poignée : le cadre suit en direct (translation en pixels de calque, +// stables pendant le défilement automatique), +// géométrie exacte redemandée au serveur au relâchement. +function printDragStart() { + PRINT.drag = { start: map.latLngToLayerPoint(PRINT.handle.getLatLng()), + corners: PRINT.corners.map(c => map.latLngToLayerPoint(c)) }; +} + +function printDragMove() { + const d = PRINT.drag; + if (!d) return; + const delta = map.latLngToLayerPoint(PRINT.handle.getLatLng()).subtract(d.start); + PRINT.rect.setLatLngs(d.corners.map(p => map.layerPointToLatLng(p.add(delta)))); +} + +function printDragEnd() { + PRINT.drag = null; + PRINT.center = PRINT.handle.getLatLng(); + PRINT.corners = PRINT.rect.getLatLngs()[0]; + printSave(); + printRefreshFrame(); +} + +function printCenterHere() { + PRINT.center = map.getCenter(); + printSave(); + printRefreshFrame(); +} + function printSetOpen(on) { PRINT.on = on; el('printcard').hidden = !on; el('btnPrint').classList.toggle('accent', on); - if (on) printRefreshFrame(); - else if (PRINT.rect) { PRINT.rect.remove(); PRINT.rect = null; } + if (on) { + // cadre gardé s'il est encore dans la vue, sinon posé au centre + if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter(); + printRefreshFrame(true); + } else { + if (PRINT.rect) PRINT.rect.remove(); + if (PRINT.handle) PRINT.handle.remove(); + PRINT.rect = PRINT.handle = PRINT.corners = null; + } } function printExport() { @@ -1375,10 +1452,12 @@ function printExport() { el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on)); el('printClose').addEventListener('click', () => printSetOpen(false)); el('printGo').addEventListener('click', printExport); -for (const id of ['printPaper', 'printOrient', 'printScale', 'printTitle']) { - el(id).addEventListener('change', () => { printSave(); printRefreshFrame(); }); +el('printHere').addEventListener('click', printCenterHere); +el('printFit').addEventListener('click', printFit); +for (const id of ['printPaper', 'printOrient', 'printScale']) { + el(id).addEventListener('change', () => { printSave(); printRefreshFrame(true); }); } -map.on('moveend', printRefreshFrame); +el('printTitle').addEventListener('change', printSave); printLoad(); // --- démarrage ------------------------------------------------------------ diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py index 0296404..78db31c 100644 --- a/lidar_pipeline/tests/test_mapserve.py +++ b/lidar_pipeline/tests/test_mapserve.py @@ -277,7 +277,7 @@ def test_ui_main_layer_and_precision_modes(): # Légende en 16 paliers exacts (échelle log de la densité) assert "const PREC_LEVELS = 16;" in _MAP_JS and "repeat(16, 1fr)" in _MAP_CSS # Pile et fusions supprimées - for gone in ("function moveLayer(", "reorderRelative", "stackBlend", "dragstart", "blendOptions"): + for gone in ("function moveLayer(", "reorderRelative", "stackBlend", "addEventListener('dragstart'", "blendOptions"): assert gone not in _MAP_JS, gone assert "Haut de pile" not in _MAP_HTML # Lien partagé : principale, mode:opacité, fond @@ -1332,7 +1332,12 @@ def test_map_ui_has_pdf_export(): assert f'value="{scale}"' in _MAP_HTML assert "api/export/frame?" in _MAP_JS and "api/export/pdf?" in _MAP_JS assert "createPane('printFrame')" in _MAP_JS - assert "map.on('moveend', printRefreshFrame)" in _MAP_JS + # cadre posé sur le terrain : il ne suit plus la vue, on le fait glisser + assert "map.on('moveend', printRefreshFrame)" not in _MAP_JS + assert "draggable: true" in _MAP_JS and "on('dragend', printDragEnd)" in _MAP_JS + assert "PRINT.center" in _MAP_JS + for id_ in ("printHere", "printFit"): + assert f'id="{id_}"' in _MAP_HTML, id_ assert "#printcard" in _MAP_CSS # bouton toujours visible : indépendant de /api/status.available assert 'id="btnPrint"' in _MAP_HTML and 'id="btnPrint" hidden' not in _MAP_HTML