Poser le cadre d'export PDF sur le terrain, le déplacer par une poignée et recadrer la vue dessus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
16
docs/MAPS.md
16
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,
|
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
|
é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
|
pointillés** qui montre la zone qui sera imprimée. Le cadre est **posé sur le
|
||||||
centré sur la vue : c'est la carte qu'on déplace en dessous, pas le cadre —
|
terrain** : il se place au centre de la vue à l'ouverture (ou garde sa position
|
||||||
il se recalcule (géométrie Lambert 93 exacte, `GET /api/export/frame`) à
|
précédente si elle est encore visible), la carte zoome pour le montrer en
|
||||||
chaque déplacement et à chaque changement de réglage. Les réglages sont
|
entier au-dessus des panneaux, puis on navigue librement sans qu'il bouge. On le
|
||||||
mémorisés dans `localStorage` du navigateur et rechargés à l'ouverture
|
déplace en faisant glisser sa **poignée ✥** (souris ou doigt) ; au relâchement,
|
||||||
suivante. **Exporter le PDF** télécharge la planche (`GET
|
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
|
/api/export/pdf`), nommée `relief_{x_km}_{y_km}_1-{échelle}.pdf` (centre
|
||||||
Lambert 93 en km, à trois décimales).
|
Lambert 93 en km, à trois décimales).
|
||||||
|
|
||||||
|
|||||||
@ -137,7 +137,10 @@ _MAP_HTML = """<!DOCTYPE html>
|
|||||||
<option value="5000">1:5 000</option><option value="10000">1:10 000</option></select></div>
|
<option value="5000">1:5 000</option><option value="10000">1:10 000</option></select></div>
|
||||||
<div class="use-row"><input id="printTitle" type="text" maxlength="120"
|
<div class="use-row"><input id="printTitle" type="text" maxlength="120"
|
||||||
placeholder="Titre (optionnel)" title="Titre imprimé dans le cartouche"></div>
|
placeholder="Titre (optionnel)" title="Titre imprimé dans le cartouche"></div>
|
||||||
<p class="print-hint">Déplacez la carte sous le cadre jaune : il montre la zone imprimée.</p>
|
<p class="print-hint">Faites glisser la poignée ✥ pour placer le cadre jaune sur la zone à imprimer.</p>
|
||||||
|
<div class="use-row"><button id="printHere" class="link-btn"
|
||||||
|
title="Placer le cadre au centre de la vue">⌖ Centrer ici</button>
|
||||||
|
<button id="printFit" class="link-btn" title="Zoomer sur le cadre">⤢ Voir le cadre</button></div>
|
||||||
<div class="card-foot"><button id="printGo">Exporter le PDF</button>
|
<div class="card-foot"><button id="printGo">Exporter le PDF</button>
|
||||||
<span id="printMsg" class="print-msg"></span></div>
|
<span id="printMsg" class="print-msg"></span></div>
|
||||||
</div>
|
</div>
|
||||||
@ -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; }
|
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-hint { margin: 4px 0; font-size: 12px; opacity: 0.75; }
|
||||||
.print-msg { font-size: 12px; margin-left: 8px; }
|
.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 { cursor: crosshair !important; }
|
||||||
body.drawing .leaflet-container { cursor: crosshair !important; }
|
body.drawing .leaflet-container { cursor: crosshair !important; }
|
||||||
/* Barre de génération : en HAUT à droite de l'écran, boutons accentués —
|
/* 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)));
|
.catch(msg => toast('Échec : ' + msg.message, 6000)));
|
||||||
|
|
||||||
// --- export PDF (planche d'impression terrain) ---
|
// --- export PDF (planche d'impression terrain) ---
|
||||||
// Cadre = zone imprimée, toujours centré sur la vue : on déplace la carte
|
// Cadre = zone imprimée, posé sur le terrain (centre PRINT.center) : la carte
|
||||||
// dessous. Géométrie calculée par le serveur (projection Lambert 93).
|
// 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_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';
|
map.createPane('printFrame').style.zIndex = '690';
|
||||||
|
|
||||||
function printSettings() {
|
function printSettings() {
|
||||||
@ -1300,7 +1307,7 @@ function printSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function printQuery(s, withTitle) {
|
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),
|
const q = new URLSearchParams({ lat: c.lat.toFixed(6), lon: c.lng.toFixed(6),
|
||||||
paper: s.paper, orient: s.orient, scale: s.scale });
|
paper: s.paper, orient: s.orient, scale: s.scale });
|
||||||
if (withTitle && s.title) q.set('title', s.title);
|
if (withTitle && s.title) q.set('title', s.title);
|
||||||
@ -1308,7 +1315,9 @@ function printQuery(s, withTitle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function printSave() {
|
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() {
|
function printLoad() {
|
||||||
@ -1318,30 +1327,98 @@ function printLoad() {
|
|||||||
['scale', 'printScale'], ['title', 'printTitle']]) {
|
['scale', 'printScale'], ['title', 'printTitle']]) {
|
||||||
if (s[k] != null) el(id).value = s[k];
|
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 */ }
|
} 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;
|
if (!PRINT.on) return;
|
||||||
const seq = ++PRINT.seq;
|
const seq = ++PRINT.seq;
|
||||||
fetch('api/export/frame?' + printQuery(printSettings(), false))
|
fetch('api/export/frame?' + printQuery(printSettings(), false))
|
||||||
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
|
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (seq !== PRINT.seq || !PRINT.on) return;
|
if (seq !== PRINT.seq || !PRINT.on || PRINT.drag) return;
|
||||||
if (PRINT.rect) PRINT.rect.setLatLngs(d.corners);
|
printDraw(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';
|
el('printInfo').textContent = d.width_m + ' × ' + d.height_m + ' m';
|
||||||
|
if (fit === true) printFit();
|
||||||
})
|
})
|
||||||
.catch(() => { if (seq === PRINT.seq) el('printInfo').textContent = ''; });
|
.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) {
|
function printSetOpen(on) {
|
||||||
PRINT.on = on;
|
PRINT.on = on;
|
||||||
el('printcard').hidden = !on;
|
el('printcard').hidden = !on;
|
||||||
el('btnPrint').classList.toggle('accent', on);
|
el('btnPrint').classList.toggle('accent', on);
|
||||||
if (on) printRefreshFrame();
|
if (on) {
|
||||||
else if (PRINT.rect) { PRINT.rect.remove(); PRINT.rect = null; }
|
// 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() {
|
function printExport() {
|
||||||
@ -1375,10 +1452,12 @@ function printExport() {
|
|||||||
el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
|
el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
|
||||||
el('printClose').addEventListener('click', () => printSetOpen(false));
|
el('printClose').addEventListener('click', () => printSetOpen(false));
|
||||||
el('printGo').addEventListener('click', printExport);
|
el('printGo').addEventListener('click', printExport);
|
||||||
for (const id of ['printPaper', 'printOrient', 'printScale', 'printTitle']) {
|
el('printHere').addEventListener('click', printCenterHere);
|
||||||
el(id).addEventListener('change', () => { printSave(); printRefreshFrame(); });
|
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();
|
printLoad();
|
||||||
|
|
||||||
// --- démarrage ------------------------------------------------------------
|
// --- démarrage ------------------------------------------------------------
|
||||||
|
|||||||
@ -277,7 +277,7 @@ def test_ui_main_layer_and_precision_modes():
|
|||||||
# Légende en 16 paliers exacts (échelle log de la densité)
|
# 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
|
assert "const PREC_LEVELS = 16;" in _MAP_JS and "repeat(16, 1fr)" in _MAP_CSS
|
||||||
# Pile et fusions supprimées
|
# 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 gone not in _MAP_JS, gone
|
||||||
assert "Haut de pile" not in _MAP_HTML
|
assert "Haut de pile" not in _MAP_HTML
|
||||||
# Lien partagé : principale, mode:opacité, fond
|
# 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 f'value="{scale}"' in _MAP_HTML
|
||||||
assert "api/export/frame?" in _MAP_JS and "api/export/pdf?" in _MAP_JS
|
assert "api/export/frame?" in _MAP_JS and "api/export/pdf?" in _MAP_JS
|
||||||
assert "createPane('printFrame')" 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
|
assert "#printcard" in _MAP_CSS
|
||||||
# bouton toujours visible : indépendant de /api/status.available
|
# bouton toujours visible : indépendant de /api/status.available
|
||||||
assert 'id="btnPrint"' in _MAP_HTML and 'id="btnPrint" hidden' not in _MAP_HTML
|
assert 'id="btnPrint"' in _MAP_HTML and 'id="btnPrint" hidden' not in _MAP_HTML
|
||||||
|
|||||||
Reference in New Issue
Block a user