Regrouper les réglages de la carte dans un panneau unique à onglets, volet en bas sur téléphone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 18:00:59 +02:00
parent b975ed272a
commit b24ebcd83a
4 changed files with 362 additions and 188 deletions

View File

@ -65,9 +65,18 @@ function applyTheme(pref) {
const btn = el('btnTheme');
if (btn) { btn.textContent = THEME_ICON[p]; btn.title = THEME_TITLE[p] + ' (cliquer pour changer)'; }
if (typeof applyLayers === 'function' && STATE) applyLayers(); // fond OSM assombri
redrawRose();
return p;
}
// Rose de la légende : ses libellés cardinaux prennent la couleur du texte,
// elle est donc redessinée à chaque changement de thème (si déjà dessinée).
function redrawRose() {
if (!drawRose.done) return;
drawRose.done = false;
drawRose();
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
@ -580,15 +589,17 @@ function labToRgb(L, a, b) {
});
}
const ROSE_SIZE = 96; // taille affichée (px CSS), cf. .legend canvas
function drawRose() {
const cv = el('roseCanvas');
if (!cv || drawRose.done) return;
drawRose.done = true;
const dpr = window.devicePixelRatio || 1, size = 132;
const dpr = window.devicePixelRatio || 1, size = ROSE_SIZE, k = size / 132;
cv.width = size * dpr; cv.height = size * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
const c = size / 2, rOut = 50, rIn = 22, L = 64;
const c = size / 2, rOut = 50 * k, rIn = 22 * k, L = 64;
const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
const img = ctx.createImageData(size * dpr, size * dpr);
for (let py = 0; py < size * dpr; py++) {
@ -604,14 +615,15 @@ function drawRose() {
}
ctx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '600 11px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const text = cssVar('--text') || '#1d232b';
const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
for (const [t, deg] of labels) {
const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
const a = deg * Math.PI / 180, rr = (t.length > 1 ? 36 : 58) * k;
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
// intercardinaux sur l'anneau coloré : sombres ; cardinaux hors anneau : couleur du texte
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : text;
ctx.font = t.length > 1 ? '600 8px system-ui, sans-serif' : '700 10px system-ui, sans-serif';
ctx.fillText(t, x, y);
}
}
@ -619,7 +631,7 @@ function drawRose() {
function updateRose() {
const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
&& LAYERS.get('relief_oriente'));
el('rose').hidden = !on;
el('legendBlock').hidden = !on;
if (on) drawRose();
}
@ -654,7 +666,6 @@ function renderUseCard() {
} else { window.prompt('URL XYZ :', text); }
});
refresh();
el('usecard').hidden = false;
}
// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
@ -693,7 +704,8 @@ function genApi(method, url, body) {
function genOpenCard(msg) {
if (msg !== undefined) el('genMsg').textContent = msg;
el('gencard').hidden = false;
// Onglet déjà visible : pas de openTab, qui changerait la hauteur du volet.
if (panelVisibleTab() !== 'gen') openTab('gen');
}
function genFillViz() {
@ -862,6 +874,7 @@ function genAfterRun() {
renderPanel();
renderBaseRow();
applyLayers();
renderUseCard();
}).catch(() => {});
fetch('api/map/background', { method: 'POST' }).catch(() => {});
}
@ -915,10 +928,6 @@ function genPollSoon() {
el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
el('btnComplete').addEventListener('click', genComplete);
el('genClose').addEventListener('click', () => {
el('gencard').hidden = true;
if (genDrawing) genSetDrawing(false);
});
el('genClear').addEventListener('click', () => {
genSel.clear();
genCorners.clear();
@ -971,19 +980,15 @@ function printLoad() {
if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center);
}
// Recadre sur le cadre en laissant libre la bande basse couverte par les
// cartes (couches, export…) : sinon le bas du cadre passe dessous.
// Recadre sur le cadre en laissant libre la place du panneau (colonne à
// gauche sur ordinateur, volet en bas sur téléphone) : sinon il 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);
}
const r = el('panel').getBoundingClientRect();
const panelWidth = isPhone() ? 0 : r.right;
const sheetHeight = isPhone() ? r.height : 0;
map.fitBounds(L.latLngBounds(PRINT.corners),
{ paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] });
{ paddingTopLeft: [panelWidth + 30, 60], paddingBottomRight: [70, sheetHeight + 20] });
}
function printDraw(corners) {
@ -1046,10 +1051,11 @@ function printCenterHere() {
printRefreshFrame();
}
// Cadre affiché tant que l'onglet Export PDF est visible (événement panel:tab,
// émis à chaque rendu du panneau : on ne fait rien si l'état est inchangé).
function printSetOpen(on) {
if (PRINT.on === on) return;
PRINT.on = on;
el('printcard').hidden = !on;
el('btnPrint').classList.toggle('accent', on);
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();
@ -1089,8 +1095,6 @@ function printExport() {
.finally(() => { btn.disabled = false; });
}
el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
el('printClose').addEventListener('click', () => printSetOpen(false));
el('printGo').addEventListener('click', printExport);
el('printHere').addEventListener('click', printCenterHere);
el('printFit').addEventListener('click', printFit);
@ -1100,6 +1104,115 @@ for (const id of ['printPaper', 'printOrient', 'printScale']) {
el('printTitle').addEventListener('change', printSave);
printLoad();
document.addEventListener('panel:tab', (e) => {
printSetOpen(e.detail.visible === 'print');
if (e.detail.visible !== 'gen' && genDrawing) genSetDrawing(false);
});
// --- panneau à onglets --------------------------------------------------------
const PANEL_LS = 'lidar-panel';
const PANEL = Object.assign({ tab: 'view', collapsed: false, sheet: 'half' }, lsGet(PANEL_LS, {}));
const phoneQuery = window.matchMedia('(max-width: 720px)');
const isPhone = () => phoneQuery.matches;
// Onglets proposés : Génération seulement si le générateur répond et que ce
// navigateur a le droit de lancer (même règle que les anciens boutons).
function panelTabs() {
return ['view', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
}
// Onglet réellement visible, ou null (panneau replié, volet fermé).
function panelVisibleTab() {
if (isPhone() ? PANEL.sheet === 'closed' : PANEL.collapsed) return null;
return PANEL.tab;
}
function panelSave() { lsSet(PANEL_LS, { tab: PANEL.tab, collapsed: PANEL.collapsed, sheet: PANEL.sheet }); }
function panelRender() {
const tabs = panelTabs();
if (!tabs.includes(PANEL.tab)) PANEL.tab = 'view';
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b => {
b.hidden = !tabs.includes(b.dataset.tab);
b.setAttribute('aria-selected', String(b.dataset.tab === PANEL.tab));
});
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
const panel = el('panel');
panel.classList.toggle('collapsed', PANEL.collapsed);
panel.dataset.sheet = PANEL.sheet;
document.body.classList.toggle('panel-collapsed', PANEL.collapsed);
// Hauteur couverte par le volet, que l'échelle, l'attribution et les messages
// dégagent ; volet plein : la carte est cachée, rien à dégager (sinon ils
// remonteraient en haut de l'écran, sur les outils).
const sheetPx = isPhone() && PANEL.sheet !== 'full' ? panel.getBoundingClientRect().height : 0;
document.body.style.setProperty('--sheet-h', sheetPx + 'px');
document.dispatchEvent(new CustomEvent('panel:tab', { detail: { tab: PANEL.tab, visible: panelVisibleTab() } }));
}
function openTab(tab) {
if (!panelTabs().includes(tab)) return;
const before = PANEL.sheet;
// Onglet actif retouché sur téléphone : le volet change de hauteur.
if (isPhone() && tab === PANEL.tab && PANEL.sheet !== 'closed') {
PANEL.sheet = PANEL.sheet === 'half' ? 'full' : 'half';
} else if (isPhone() && PANEL.sheet === 'closed') {
PANEL.sheet = 'half';
}
const resized = isPhone() && PANEL.sheet !== before;
PANEL.tab = tab;
PANEL.collapsed = false;
panelSave(); panelRender();
if (resized) setTimeout(panelRender, 220); // hauteur finale après la transition CSS
}
function setCollapsed(on) { PANEL.collapsed = !!on; panelSave(); panelRender(); map.invalidateSize(); }
function setSheet(state) {
if (!['closed', 'half', 'full'].includes(state)) return;
PANEL.sheet = state; panelSave();
panelRender();
setTimeout(panelRender, 220); // hauteur finale après la transition CSS
}
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b =>
b.addEventListener('click', () => openTab(b.dataset.tab)));
el('panelCollapse').addEventListener('click', () => setCollapsed(!PANEL.collapsed));
phoneQuery.addEventListener('change', panelRender);
window.addEventListener('resize', panelRender); // hauteur du volet en vh (rotation)
// Poignée du volet : glisser vers le haut / le bas, relâcher sur la hauteur
// la plus proche ; un simple toucher fait monter d'un cran.
(function sheetDrag() {
const grip = el('sheetGrip');
let start = null;
grip.addEventListener('pointerdown', (e) => {
start = { y: e.clientY, h: el('panel').getBoundingClientRect().height };
grip.setPointerCapture(e.pointerId);
el('panel').style.transition = 'none';
});
grip.addEventListener('pointermove', (e) => {
if (!start) return;
const h = Math.max(72, Math.min(window.innerHeight - 16, start.h + start.y - e.clientY));
el('panel').style.height = h + 'px';
});
const end = (e) => {
if (!start) return;
const moved = Math.abs(e.clientY - start.y) > 6;
const h = el('panel').getBoundingClientRect().height;
el('panel').style.height = ''; el('panel').style.transition = '';
start = null;
if (!moved) { setSheet(PANEL.sheet === 'closed' ? 'half' : PANEL.sheet === 'half' ? 'full' : 'half'); return; }
const stops = { closed: 72, half: window.innerHeight * 0.45, full: window.innerHeight - 16 };
const best = Object.entries(stops).sort((a, b) => Math.abs(a[1] - h) - Math.abs(b[1] - h))[0][0];
setSheet(best);
};
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
})();
// Fiche de dalle fermée par Échap (provisoire : la Task 4 la remplace).
function closeTileViews() { if (el('tilecard').hidden) return false; el('tilecard').hidden = true; return true; }
// --- démarrage ------------------------------------------------------------
function fitData() {
if (META.bounds) {
@ -1119,13 +1232,14 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
// L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
// suite : ce qui est à l'écran est ce qui sera rechargé.
saveState();
el('stat').textContent = META.layers.length + ' couche(s)';
const hash = parseHash();
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
else fitData();
buildLayers();
renderPanel();
renderBaseRow();
panelRender();
renderUseCard();
genFillViz();
// Droits de génération : boutons affichés si le serveur peut générer
// (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
@ -1135,8 +1249,7 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
GEN.available = !!d.available;
GEN.allowed = d.regen_allowed !== false;
const show = GEN.available && GEN.allowed;
el('btnZone').hidden = !show;
el('btnComplete').hidden = !show;
panelRender();
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
@ -1166,14 +1279,10 @@ map.on('click', (e) => {
.then(d => showTileInfo(d, e.latlng));
});
el('cardToggle').addEventListener('click', () =>
el('layercard').classList.toggle('collapsed'));
el('tileClose').addEventListener('click', () => {
el('tilecard').hidden = true;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
el('btnUse').addEventListener('click', renderUseCard);
el('btnShare').addEventListener('click', () => {
const url = location.origin + location.pathname + viewHash(true);
history.replaceState(null, '', url);
@ -1245,7 +1354,11 @@ el('btnTheme').addEventListener('click', () => {
lsSet(THEME_LS, THEME);
});
if (darkQuery && darkQuery.addEventListener) {
darkQuery.addEventListener('change', () => { if (THEME === 'auto' && STATE) applyLayers(); });
darkQuery.addEventListener('change', () => {
if (THEME !== 'auto') return;
if (STATE) applyLayers();
redrawRose();
});
}
el('zoomIn').addEventListener('click', () => map.zoomIn(1));
el('zoomOut').addEventListener('click', () => map.zoomOut(1));
@ -1273,16 +1386,26 @@ el('gps').addEventListener('click', () => {
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
});
document.addEventListener('keydown', (e) => {
// P : relief → précision → les deux → relief (hors saisie de texte)
const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
&& META && STATE && precisionKey()) {
if (typing || e.ctrlKey || e.metaKey || e.altKey) {
if (e.key === 'Escape' && typing) e.target.blur();
return;
}
// P : relief → précision → les deux → relief
if ((e.key === 'p' || e.key === 'P') && META && STATE && precisionKey()) {
const i = VIEW_KEYS.indexOf(STATE.mode);
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return;
}
// 1–4 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-4]$/.test(e.key)) {
const tab = ['view', 'print', 'gen', 'share'][Number(e.key) - 1];
if (panelTabs().includes(tab)) openTab(tab);
return;
}
if (e.key === 'Escape') {
el('tilecard').hidden = true; el('usecard').hidden = true;
if (genDrawing) genSetDrawing(false);
if (genDrawing) { genSetDrawing(false); return; }
if (closeTileViews()) return; // bulle / fiche de dalle (Task 4)
if (isPhone()) setSheet('closed'); else setCollapsed(true);
}
});