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:
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user