Génération
@@ -160,6 +182,7 @@ _MAP_HTML = """
+
@@ -322,6 +345,11 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
/* --- génération de tuiles ------------------------------------------------ */
#gencard { right: 12px; top: 56px; width: 340px; max-width: calc(100vw - 24px); }
+#printcard { right: 60px; bottom: 12px; width: 340px; max-width: calc(100vw - 84px); }
+#printTitle { flex: 1; min-width: 0; background: rgba(255,255,255,0.06); color: var(--text);
+ 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; }
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 —
@@ -363,6 +391,7 @@ button.primary:hover { filter: brightness(1.12); }
#layercard { width: calc(100vw - 24px); }
#tilecard { width: calc(100vw - 24px); }
#gencard { width: calc(100vw - 24px); }
+ #printcard { width: calc(100vw - 84px); }
}
"""
@@ -1258,6 +1287,100 @@ el('genQueueClear').addEventListener('click', () =>
.then(d => toast((d.retirées || 0) + ' demande(s) retirée(s)'))
.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).
+const PRINT_LS = 'lidar-print';
+const PRINT = { on: false, rect: null, seq: 0 };
+map.createPane('printFrame').style.zIndex = '690';
+
+function printSettings() {
+ return { paper: el('printPaper').value, orient: el('printOrient').value,
+ scale: el('printScale').value, title: el('printTitle').value.trim() };
+}
+
+function printQuery(s, withTitle) {
+ const c = 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);
+ return q.toString();
+}
+
+function printSave() {
+ try { localStorage.setItem(PRINT_LS, JSON.stringify(printSettings())); } catch (e) { /* privé */ }
+}
+
+function printLoad() {
+ try {
+ const s = JSON.parse(localStorage.getItem(PRINT_LS) || '{}');
+ for (const [k, id] of [['paper', 'printPaper'], ['orient', 'printOrient'],
+ ['scale', 'printScale'], ['title', 'printTitle']]) {
+ if (s[k] != null) el(id).value = s[k];
+ }
+ } catch (e) { /* privé ou illisible : réglages par défaut */ }
+}
+
+function printRefreshFrame() {
+ 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);
+ el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m';
+ })
+ .catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; });
+}
+
+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; }
+}
+
+function printExport() {
+ const btn = el('printGo');
+ btn.disabled = true;
+ el('printMsg').textContent = 'Génération du PDF…';
+ fetch('api/export/pdf?' + printQuery(printSettings(), true))
+ .then(r => {
+ if (!r.ok) {
+ return r.json().catch(() => ({})).then(d => {
+ throw new Error((d && d.detail) || ('HTTP ' + r.status));
+ });
+ }
+ const m = /filename="([^"]+)"/.exec(r.headers.get('Content-Disposition') || '');
+ return r.blob().then(b => ({ b: b, name: m ? m[1] : 'relief.pdf' }));
+ })
+ .then(({ b, name }) => {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(b);
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 10000);
+ el('printMsg').textContent = 'Téléchargé : ' + name;
+ })
+ .catch(e => { el('printMsg').textContent = 'Échec : ' + e.message; })
+ .finally(() => { btn.disabled = false; });
+}
+
+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(); });
+}
+map.on('moveend', printRefreshFrame);
+printLoad();
+
// --- démarrage ------------------------------------------------------------
function fitData() {
if (META.bounds) {
diff --git a/lidar_pipeline/tests/test_mapserve.py b/lidar_pipeline/tests/test_mapserve.py
index dddb22d..0296404 100644
--- a/lidar_pipeline/tests/test_mapserve.py
+++ b/lidar_pipeline/tests/test_mapserve.py
@@ -1321,3 +1321,18 @@ def test_export_pdf_never_delegated(tmp_path, monkeypatch):
monkeypatch.setattr(mapserve, "GENERATION_URL", "http://injoignable.invalid")
lat, lon = _center()
assert mapserve.export_pdf_file(lat, lon, "A4", "paysage", 2000, None).body.startswith(b"%PDF")
+
+
+def test_map_ui_has_pdf_export():
+ from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
+ for id_ in ("btnPrint", "printcard", "printPaper", "printOrient", "printScale",
+ "printTitle", "printGo", "printMsg", "printClose", "printInfo"):
+ assert f'id="{id_}"' in _MAP_HTML, id_
+ for scale in ("1000", "2000", "5000", "10000"):
+ 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
+ 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