diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad59f3..14d1f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,57 @@ Backfill note: v1–v11 entries were reconstructed from the `sw.js` version history and git log on 2026-06-12; v4–v10 shipped between 2026-05-08 and 2026-05-13 without individually recorded dates. +## v79 — 2026-09-20 + +**Celular: painel vira folha inferior (bottom sheet).** Em telas ≤ 860 px o +painel lateral deslizante (uma sidebar — impraticável no celular) foi +substituído por uma folha ancorada na borda de baixo, com três posições: +**mínima** (só a alça + o botão Calcular), **meia** (52% da tela, padrão ao +abrir) e **cheia** (deixa uma faixa de 56 px no topo para a pílula de +status; os botões flutuantes do mapa se escondem). Arrasta-se pela alça ou +pela cabeça da folha (encaixa na posição mais próxima ao soltar) e um toque +na alça alterna mínima → meia → cheia. O botão **Calcular** (com a +estimativa de tempo) é movido para a cabeça da folha no celular, então fica +sempre à mão sem abrir o painel — e volta ao lugar de origem no desktop. Um +toque no mapa (marcar origem/destino/referência) recolhe a folha para a +mínima, como antes fechava a gaveta. O hambúrguer ☰ e o pano de fundo somem +no celular (a folha está sempre presente); a pilha de botões da direita fica +localizar (8) / camadas (56) e o popover de camadas abre em 104. A pílula de +status/progresso sai da borda inferior (agora da folha) para o topo, +centralizada, estreita o bastante para não cobrir 🔍 nem localizar. + +**Celular: alvos de toque.** Botões e linhas de exemplo com ≥ 40 px de +altura, campos numéricos/selects com 16 px (abaixo disso o Safari iOS dá +zoom na página ao focar), cabeçalhos dos grupos com 40 px, checkboxes de 20 +px, botão dos popups de marcador com 40 px; `overscroll-behavior: none` +para não disparar pull-to-refresh arrastando mapa/folha; em retrato o +subtítulo da marca sai para a linha caber. O hash CSP do script inline da +gaveta/folha foi recalculado (o corpo mudou). + +## v78 — 2026-09-20 + +**Referências editáveis.** Clicar num marcador de referência abre um popup +com o rótulo (`ref N · r, c`) e o botão "Remover esta referência" — a +referência sai do conjunto, as sobreviventes são renumeradas e a matriz de +acessibilidade em cache é invalidada (como em qualquer mudança do conjunto). +O clique no marcador não atravessa para o mapa, então inspecionar uma +referência nunca deixa cair outra embaixo dela. + +**Desfazer / refazer do conjunto de referências.** Barra ↶ ↷ estilo zoom +estacionada logo abaixo do botão 🔍 (Ctrl+Z / Ctrl+Shift+Z ou Ctrl+Y fora +de campos de texto). Cada operação do usuário sobre o conjunto vira um passo +— clique no mapa, remoção pelo popup, "Distribuir aleatórias" e censo (o +lote inteiro é um único passo), "Limpar referências", carga de GeoJSON e +importação de bundle. A snapshot leva junto o índice QMC, o proxy de +população do censo (`refPopM`) e o campo "N referências" (a carga de GeoJSON +o eleva; desfazer o devolve), e a restauração reconstrói os marcadores +diretamente — um limite reduzido depois da snapshot não corta o que o +desfazer traz de volta. Operações sem efeito (limpar um conjunto vazio) não +entram no histórico; ele zera ao carregar outro DEM (as snapshots indexam +células da grade antiga). O re-snap à rede feito na hora do cálculo segue +fora do histórico de propósito (não é ação do usuário e se reaplica ao que +for restaurado). + ## v77 — 2026-08-28 **Cores dos segmentos viram dado (3C.c).** Em vez de cores arbitrárias por diff --git a/app.js b/app.js index 885131b..1f1d5fd 100644 --- a/app.js +++ b/app.js @@ -413,6 +413,13 @@ const STRINGS = { "census.fetch_failed": { pt: "Falha ao buscar setores censitários: {0}", en: "Failed to fetch census sectors: {0}" }, "census.lib_missing": { pt: "Biblioteca FlatGeobuf indisponível (offline?). Reconecte e recarregue.", en: "FlatGeobuf library unavailable (offline?). Reconnect and reload." }, "ref.clear": { pt: "Limpar referências", en: "Clear refs" }, + // Per-marker popup (click a reference on the map) + undo/redo of the set. + "ref.delete_this": { pt: "Remover esta referência", en: "Remove this reference" }, + "ref.deleted": { pt: "Referência {0} removida.", en: "Reference {0} removed." }, + "ref.undo.title": { pt: "Desfazer (referências) — Ctrl+Z", en: "Undo (reference points) — Ctrl+Z" }, + "ref.redo.title": { pt: "Refazer (referências) — Ctrl+Shift+Z", en: "Redo (reference points) — Ctrl+Shift+Z" }, + "ref.undo.done": { pt: "Referências: desfeito.", en: "References: undone." }, + "ref.redo.done": { pt: "Referências: refeito.", en: "References: redone." }, "ref.none": { pt: "nenhuma referência marcada", en: "no references placed" }, "ref.count": { pt: "{0} referência(s) marcada(s)", en: "{0} reference(s) placed" }, "ref.show_markers": { pt: "Mostrar marcadores de referência", en: "Show reference markers" }, @@ -494,6 +501,7 @@ const STRINGS = { // Accessible names / tooltips (icon buttons, file pickers, layer rows) + a few // strings that used to be hardcoded (calibration, gate reasons, budget hint). "drawer.toggle": { pt: "Mostrar/ocultar painel", en: "Show/hide panel" }, + "sheet.handle": { pt: "Expandir/recolher painel", en: "Expand/collapse panel" }, "lang.toggle.aria": { pt: "Mudar idioma", en: "Switch language" }, "modal.close": { pt: "Fechar", en: "Close" }, "aria.dem_file": { pt: "Carregar DEM", en: "Load DEM" }, @@ -1498,7 +1506,11 @@ document.addEventListener("DOMContentLoaded", () => { const n = parseInt(document.getElementById("n-refs")?.value, 10) || 10; placeRandomRefPoints(n); }); - document.getElementById("ref-clear")?.addEventListener("click", clearRefPoints); + document.getElementById("ref-clear")?.addEventListener("click", () => { + refHistoryPush(); + clearRefPoints(); + refHistorySettle(); // clearing an empty set is not an undo step + }); document.getElementById("ref-file")?.addEventListener("change", async (ev) => { const f = ev.target.files[0]; if (f) await loadRefPointsFromFile(f); @@ -1980,6 +1992,32 @@ let geoSearchActiveIdx = -1; // item destacado via ↑/↓ L.DomEvent.disableClickPropagation(geoSearchBtn); L.DomEvent.disableScrollPropagation(geoSearchBtn); } + // Undo/redo of the reference set: a zoom-style bar parked right under 🔍 + // (same hide-outside-the-Leaflet-container rule). Buttons are disabled + // while their stack is empty (updateRefHistoryButtons). + const refHistoryBar = document.getElementById("ref-history-bar"); + if (refHistoryBar && leafletTopLeft) { + leafletTopLeft.appendChild(refHistoryBar); + L.DomEvent.disableClickPropagation(refHistoryBar); + L.DomEvent.disableScrollPropagation(refHistoryBar); + document.getElementById("ref-undo-btn")?.addEventListener("click", refUndo); + document.getElementById("ref-redo-btn")?.addEventListener("click", refRedo); + // (Both start `disabled` in the markup; `state` is declared further down, + // so the first updateRefHistoryButtons() runs on the first push.) + } + // Ctrl/⌘+Z undoes, Ctrl/⌘+Shift+Z or Ctrl/⌘+Y redoes — only outside text + // inputs (their native undo must keep working) and only when there is a + // step to take, so the browser default is otherwise untouched. + document.addEventListener("keydown", (e) => { + if (!(e.ctrlKey || e.metaKey) || e.altKey) return; + const tag = e.target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || e.target?.isContentEditable) return; + const key = e.key.toLowerCase(); + const redo = (key === "z" && e.shiftKey) || key === "y"; + const undo = key === "z" && !e.shiftKey; + if (redo && state.refHistory.future.length) { e.preventDefault(); refRedo(); } + else if (undo && state.refHistory.past.length) { e.preventDefault(); refUndo(); } + }); } async function photonGeocode(query) { @@ -2337,6 +2375,9 @@ const state = { // Multi-reference density: list of [r, c] pixel coords plus their map markers. refPoints: [], refMarkers: [], + // Undo/redo stacks of ref-set snapshots (see refHistoryPush). Reset on + // DEM load — snapshots index cells of the grid they were taken on. + refHistory: { past: [], future: [] }, // Total in-extent population the CURRENT refs proxy (Σ setor pop × in-bbox // area fraction, from the census sampler). Each census ref is an equal-share // proxy of refPopM/K. Nulled on ANY ref-set change (manual click, clear, @@ -3339,6 +3380,7 @@ async function loadDemFromArrayBuffer(buf, label, gen) { state.qmcIndex = 0; state.refPopM = null; kpiInvalidate(); + refHistoryReset(); // snapshots index the OLD grid's cells state.src = null; state.dst = null; state.lastResult = null; @@ -6330,7 +6372,9 @@ map.on("click", (e) => { // "— density —" displays and leak a stale seed into the compute message. const densityOn = !!document.getElementById("want-density")?.checked; if (densityOn) { + refHistoryPush(); addRefPoint([r, c]); + refHistorySettle(); return; } if (!state.src) { @@ -6420,17 +6464,79 @@ function addRefPoint(rc) { state.refPopM = null; kpiInvalidate(); state.refPoints.push([r, c]); + state.refMarkers.push(makeRefMarker(state.refPoints.length, [r, c])); + enforceRefCap(); + syncRefDisplay(); +} + +// Build the numbered marker for ref #idx (1-based) at cell rc. Clicking it +// opens a popup with the ref's label and a "remove this reference" button — +// the popup content is a function so the label reflects the CURRENT number +// (deletes/trims renumber the survivors). Respects the visibility toggle: a +// marker created while refs are hidden stays off the map until the toggle +// re-adds it (applyLayerControls). +function makeRefMarker(idx, rc) { + const [r, c] = rc; const { originX, originY, dx, dy } = state.dem; const latlng = L.latLng(originY - (r + 0.5) * dy, originX + (c + 0.5) * dx); - const idx = state.refPoints.length; const m = L.marker(latlng, { icon: makeRefIcon(idx) }) .bindTooltip(`ref ${idx} · r=${r}, c=${c}`); - // Respect the visibility toggle: a marker added while refs are hidden stays - // off the map until the toggle re-adds it (applyLayerControls). + m.bindPopup(() => { + const i = state.refMarkers.indexOf(m); + const cur = state.refPoints[i] || rc; + const wrap = document.createElement("div"); + wrap.style.cssText = "text-align:center;font-size:12px;color:#000;"; + const label = document.createElement("div"); + label.style.cssText = "margin-bottom:6px;font-family:ui-monospace,monospace;"; + label.textContent = `ref ${i + 1} · r=${cur[0]}, c=${cur[1]}`; + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "🗑 " + t("ref.delete_this"); + // Same explicit style as the drawn-shape delete popup: Leaflet's popup is + // white, so the app's .secondary class would be invisible here. + btn.style.cssText = + "margin:0;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer;" + + "background:#d6493e;color:#fff;border:none;border-radius:4px;white-space:nowrap;"; + btn.addEventListener("click", () => { m.closePopup(); deleteRefPoint(m); }); + wrap.appendChild(label); + wrap.appendChild(btn); + return wrap; + }, { closeButton: false, offset: [0, -ICON_REF_SIZE / 2] }); + // Marker clicks must never fall through to the map handler (which would + // drop a NEW reference under the one being inspected). + m.on("click", (e) => L.DomEvent.stopPropagation(e)); if (document.getElementById("refs-visible")?.checked ?? true) m.addTo(map); - state.refMarkers.push(m); - enforceRefCap(); + return m; +} + +// Re-stamp every surviving ref's numbered icon + default tooltip so what's +// drawn matches the current order (after a FIFO trim, a delete, or a KPI +// recolor being undone). +function renumberRefMarkers() { + for (let i = 0; i < state.refMarkers.length; i++) { + const marker = state.refMarkers[i]; + const rc = state.refPoints[i]; + if (!marker || !rc) continue; + marker.setIcon(makeRefIcon(i + 1)); + marker.unbindTooltip().bindTooltip(`ref ${i + 1} · r=${rc[0]}, c=${rc[1]}`); + } +} + +// Remove ONE reference by its marker (from the marker's popup). One undo +// step; the survivors are renumbered so the labels stay contiguous. +function deleteRefPoint(marker) { + const i = state.refMarkers.indexOf(marker); + if (i < 0) return; + refHistoryPush(); + state.refPoints.splice(i, 1); + state.refMarkers.splice(i, 1); + marker.remove(); + state.refPopM = null; // the set no longer matches the census sample + kpiInvalidate(); + renumberRefMarkers(); syncRefDisplay(); + status.textContent = t("ref.deleted", i + 1); + scheduleStatusClear(status.textContent); } function enforceRefCap() { @@ -6446,12 +6552,7 @@ function enforceRefCap() { } // After a FIFO trim the surviving refs need their numbered icons // refreshed so what's drawn matches the (now reset) order. - for (let i = 0; i < state.refMarkers.length; i++) { - const marker = state.refMarkers[i]; - const [r, c] = state.refPoints[i]; - marker.setIcon(makeRefIcon(i + 1)); - marker.unbindTooltip().bindTooltip(`ref ${i + 1} · r=${r}, c=${c}`); - } + renumberRefMarkers(); } function clearRefPoints() { @@ -6464,6 +6565,105 @@ function clearRefPoints() { syncRefDisplay(); } +// ------- Reference-set undo / redo ------- +// Snapshot-based. Every USER-LEVEL operation on the ref set — map click, +// marker delete, Place random / census, Clear refs, GeoJSON load, bundle +// import — calls refHistoryPush() once BEFORE it mutates, so a batch of N +// random points is ONE undo step. Sync callers follow with +// refHistorySettle() to drop the entry when nothing actually changed (e.g. a +// clear on an empty set). The N-references cap travels with the snapshot +// (loadRefPointsFromFile raises it), and restore rebuilds the markers +// DIRECTLY — not through addRefPoint — so a cap lowered since the snapshot +// cannot FIFO-trim what undo brings back. Compute-time network re-snapping +// (runCompute) mutates refPoints in place without a snapshot on purpose: it +// is not a user action and re-applies to whatever undo restores. +const REF_HISTORY_MAX = 50; + +function refSnapshot() { + return { + pts: state.refPoints.map(([r, c]) => [r, c]), + qmc: state.qmcIndex, + popM: state.refPopM, + cap: document.getElementById("n-refs")?.value ?? null, + }; +} + +function refSnapshotEquals(a, b) { + if (!a || !b || a.pts.length !== b.pts.length) return false; + if (a.qmc !== b.qmc || a.popM !== b.popM || a.cap !== b.cap) return false; + for (let i = 0; i < a.pts.length; i++) { + if (a.pts[i][0] !== b.pts[i][0] || a.pts[i][1] !== b.pts[i][1]) return false; + } + return true; +} + +function refHistoryPush() { + const h = state.refHistory; + h.past.push(refSnapshot()); + if (h.past.length > REF_HISTORY_MAX) h.past.shift(); + h.future.length = 0; + updateRefHistoryButtons(); +} + +// Drop the top snapshot if the operation it preceded turned out to be a no-op. +function refHistorySettle() { + const h = state.refHistory; + if (h.past.length && refSnapshotEquals(h.past[h.past.length - 1], refSnapshot())) { + h.past.pop(); + updateRefHistoryButtons(); + } +} + +function refHistoryReset() { + state.refHistory.past.length = 0; + state.refHistory.future.length = 0; + updateRefHistoryButtons(); +} + +function refRestore(snap) { + for (const m of state.refMarkers) m.remove(); + state.refMarkers = []; + state.refPoints = snap.pts.map(([r, c]) => [r, c]); + state.qmcIndex = snap.qmc; + kpiInvalidate(); // rows were keyed to the set being replaced + state.refPopM = snap.popM; + const capInput = document.getElementById("n-refs"); + if (capInput && snap.cap != null) capInput.value = snap.cap; + if (state.dem) { + for (let i = 0; i < state.refPoints.length; i++) { + state.refMarkers.push(makeRefMarker(i + 1, state.refPoints[i])); + } + } + syncRefDisplay(); +} + +function refUndo() { + const h = state.refHistory; + if (!h.past.length) return; + h.future.push(refSnapshot()); + refRestore(h.past.pop()); + updateRefHistoryButtons(); + status.textContent = t("ref.undo.done"); + scheduleStatusClear(status.textContent); +} + +function refRedo() { + const h = state.refHistory; + if (!h.future.length) return; + h.past.push(refSnapshot()); + refRestore(h.future.pop()); + updateRefHistoryButtons(); + status.textContent = t("ref.redo.done"); + scheduleStatusClear(status.textContent); +} + +function updateRefHistoryButtons() { + const u = document.getElementById("ref-undo-btn"); + const r = document.getElementById("ref-redo-btn"); + if (u) u.disabled = !state.refHistory.past.length; + if (r) r.disabled = !state.refHistory.future.length; +} + // Drop the cached accessibility matrix + KPI block and restore the default // ref-marker icons/tooltips. Called on ANY change that would desynchronise // the cache from what's on screen: ref add/clear/trim, DEM load (via @@ -6475,13 +6675,7 @@ function kpiInvalidate() { const group = document.getElementById("kpi-group"); if (group) group.style.display = "none"; // Undo the accessibility recoloring (markers may have been trimmed since). - for (let i = 0; i < state.refMarkers.length; i++) { - const marker = state.refMarkers[i]; - const rc = state.refPoints[i]; - if (!marker || !rc) continue; - marker.setIcon(makeRefIcon(i + 1)); - marker.unbindTooltip().bindTooltip(`ref ${i + 1} · r=${rc[0]}, c=${rc[1]}`); - } + renumberRefMarkers(); } // ---- Quasi-Monte-Carlo point sets for reference placement ---------------- @@ -6545,6 +6739,7 @@ function placeRandomRefPoints(n) { // "census" is population-weighted and runs an async cloud query (FlatGeobuf // over the DEM bbox); fork to it and leave the QMC/random paths synchronous. if (sampling === "census") { placeCensusRefPoints(want); return; } + refHistoryPush(); // the whole batch is one undo step (census pushes its own) const nextUV = sampling === "sobol" ? () => sobolPoint2D(++state.qmcIndex) : sampling === "halton" ? () => haltonPoint2D(++state.qmcIndex) : @@ -6574,6 +6769,7 @@ function placeRandomRefPoints(n) { placed.push([r, c]); } for (const rc of placed) addRefPoint(rc); + refHistorySettle(); } // ---- Census (IBGE 2022) population-weighted reference sampling ------------ @@ -6750,6 +6946,7 @@ async function placeCensusRefPoints(want) { else skipped++; } if (!valid.length) return fail("census.no_points"); // refs left untouched + refHistoryPush(); // one undo step for the whole replacement clearRefPoints(); // census REPLACES the current set (like file load) for (const rc of valid) addRefPoint(rc); const placed = state.refPoints.length; @@ -6830,6 +7027,8 @@ async function loadRefPointsFromFile(file) { else skipped++; } if (!valid.length) return fail("ref.load.no_points", file.name); + // Snapshot BEFORE the cap is raised so undo restores the previous cap too. + refHistoryPush(); // Raise the N-references cap so the whole file survives the FIFO trim in // addRefPoint (input + worker top out at 2000; warn if we clip). const capInput = document.getElementById("n-refs"); @@ -6842,6 +7041,7 @@ async function loadRefPointsFromFile(file) { clearRefPoints(); for (const rc of valid) addRefPoint(rc); const placed = state.refPoints.length; + refHistorySettle(); status.textContent = skipped ? t("ref.loaded.skipped", placed, file.name, skipped) : t("ref.loaded", placed, file.name); @@ -12388,6 +12588,7 @@ function applyMetadataToUI(md, bin = {}) { // the markers and respects enforceRefCap, so the cap field set above // governs how many actually survive. if (Array.isArray(p.refPoints) && state.dem && demMatch !== false) { + refHistoryPush(); // the import replaces the set as ONE undoable step // Clear whatever is on the map from a previous bundle / run. if (state.refMarkers) for (const m of state.refMarkers) m.remove(); state.refMarkers = []; @@ -12396,6 +12597,7 @@ function applyMetadataToUI(md, bin = {}) { if (Array.isArray(rc) && rc.length >= 2) addRefPoint([rc[0] | 0, rc[1] | 0]); } syncRefDisplay(); + refHistorySettle(); } // ---- Network mask restore --------------------------------------------- diff --git a/index.html b/index.html index fe9497a..8a3c742 100644 --- a/index.html +++ b/index.html @@ -63,7 +63,7 @@ default-src 'self'; script-src 'self' https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com 'wasm-unsafe-eval' 'sha256-Zummr8wLfJ3XxdgpKBzuhAKVQKIcBLTZ/vDz4TDv2lg=' - 'sha256-759bp9RLFyEKbmJ2/BusIfD6K8bDDx0xlabSImiad4M=' + 'sha256-3Ncpey8Q1Duv9sf5yFIhb5GwCoZh8vQzW1RBGG4MnrI=' 'sha256-xRKHkuCIqaZQ8tc4K6yAEK0CmbWBBJSpZvmHKcRdVIU=' 'sha256-xChjLER4dCiBiSng0gVHR06gT1nCUkmOsZ/ajs/SZcg='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; @@ -217,6 +217,11 @@ } #sidebar-resizer:hover, #sidebar-resizer.dragging { background: var(--accent-2); opacity: 0.45; } #map { height: 100vh; height: 100dvh; } + /* Bottom-sheet scaffolding is inert on desktop: the head is hidden and the + body wrapper is display:contents so fitPanelColumns() still measures + .panel-header / .panel-cols as direct children of the scrolling #panel. */ + .sheet-head { display: none; } + .panel-body { display: contents; } /* ---- Mobile drawer ---------------------------------------------------- Below 860 px (small tablets and phones) the panel becomes a fixed @@ -367,6 +372,20 @@ .geo-search-status { margin: 6px 2px 2px; color: var(--muted); } .geo-search-attrib { margin: 6px 2px 0; font-size: 10px; color: var(--muted); } .geo-search-attrib a { color: inherit; } + /* Desfazer/refazer das referências: barra estilo zoom (+/−) estacionada + logo abaixo do 🔍 pela mesma regra — só ganha display dentro do + container do Leaflet. Botões desabilitados enquanto a pilha está vazia. */ + .ref-history-bar { display: none; } + .leaflet-control-container .ref-history-bar { display: block; } + .leaflet-control-container .ref-history-bar button { + width: 30px; height: 30px; + display: flex; align-items: center; justify-content: center; + background: #fff; color: #333; + font-size: 16px; line-height: 1; cursor: pointer; padding: 0; border: none; + } + .leaflet-control-container .ref-history-bar button + button { border-top: 1px solid #ccc; } + .leaflet-control-container .ref-history-bar button:hover:not([disabled]) { background: #f4f4f4; } + .leaflet-control-container .ref-history-bar button[disabled] { color: #bbb; cursor: default; } @media (max-width: 860px) { /* Single-column grid on mobile. CRITICAL: position:fixed removes @@ -374,77 +393,113 @@ remaining child (#map) would auto-place into column 1 (0 px wide) and render as a black void. One column → map fills the only cell. */ #app { grid-template-columns: 1fr; grid-template-rows: 1fr; } - /* Drawer mode: force a single column and hide the resizer. */ .panel-cols { column-count: 1; } - #sidebar-resizer { display: none; } + /* The sidebar affordances (resizer, hamburger, backdrop) are desktop- + only: on phones the panel is a BOTTOM SHEET that is always present. */ + #sidebar-resizer, .drawer-toggle, .drawer-backdrop { display: none !important; } + /* No pull-to-refresh / rubber-banding under a map + sheet UI. */ + html, body { overscroll-behavior: none; } + + /* ---- Bottom sheet ------------------------------------------------- + #panel docks to the bottom edge, full width, and the inline sheet + controller sets its HEIGHT to one of three snap points: peek (just + the handle + Compute), half, full. Dragging the head or tapping the + handle moves between them; the body scrolls internally. */ #panel { position: fixed; - top: 0; left: 0; - height: 100vh; height: 100dvh; - width: min(86vw, 360px); + top: auto; left: 0; right: 0; bottom: 0; + width: auto; + height: 52vh; height: 52dvh; /* controller overrides per snap point */ + max-height: 100vh; max-height: 100dvh; z-index: 900; - transform: translateX(-100%); - transition: transform 0.22s ease-out; - box-shadow: 4px 0 18px rgba(0, 0, 0, 0.5); - /* Extra top padding so content clears the hamburger button. */ - padding-top: 56px; - padding-bottom: 72px; /* last form row clears the fixed bottom × / locate */ + transform: none; + display: flex; flex-direction: column; + overflow: hidden; + border-right: none; border-top: 1px solid var(--border); + border-radius: 14px 14px 0 0; + box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.5); + padding: 0 12px calc(8px + env(safe-area-inset-bottom, 0px)); + transition: height 0.22s ease-out; + } + #panel.sheet-dragging { transition: none; } + .sheet-head { + display: block; flex: none; + margin: 0 -12px; padding: 0 12px 6px; + touch-action: none; /* the drag is ours — no scroll/zoom gesture here */ + user-select: none; -webkit-user-select: none; } - #panel.open { transform: translateX(0); } - /* On phones the top-left corner is awkward to reach with the - right thumb; move both floating buttons to the bottom row. */ - /* Mobile mirrors desktop: locate / hamburger / layers form a single - top-right stack (8 / 56 / 104) so they all stay ABOVE the layer-control - modal (which opens at top:152) — tapping them, including to close the - modal, always works. Sized for an iPhone SE: the 40px buttons sit in the - narrow strip the slide-in drawer leaves on the right. */ - .drawer-toggle { - display: inline-flex; - top: 56px; - right: 8px; - bottom: auto; - left: auto; + .sheet-handle { + width: 44px; height: 5px; border-radius: 3px; + background: var(--muted); opacity: 0.7; + margin: 0 auto 8px; + /* Generous invisible hit area around the 5 px pill (::before). */ + position: relative; padding: 0; cursor: grab; } - .locate-button { - top: 8px; - right: 8px; - bottom: auto; + .sheet-handle::before { content: ""; position: absolute; inset: -12px -60px; } + #sheet-actions #run { margin: 0; width: 100%; min-height: 44px; font-size: 15px; } + #sheet-actions #time-estimate { margin-top: 3px; } + .panel-body { + display: block; flex: 1 1 auto; min-height: 0; + overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding-top: 4px; } - /* Once the drawer is open, swap the icon to an × via the data-attr. - The button stays where it is so the user can close from the same - spot they opened it from. */ - .drawer-toggle.open { background: var(--accent); color: var(--bg); border-color: var(--accent); } - - /* Touch-friendly bump on mobile, but only as much as readability - actually needs — the user has to scroll less when these stay - modest. Buttons end up ~32 px tall, comfortable for thumbs. */ - button { padding: 8px; font-size: 13px; } - .ex-link { padding: 6px 10px; font-size: 12px; } - input[type="number"], select { padding: 6px 9px; font-size: 13px; } - /* Keep the small inline buttons (engine tag, help) compact even on - mobile — they're not primary actions. */ - .engine-tag, .help-button { padding: 0; } - .help-button { width: 24px !important; height: 24px; font-size: 13px; } - .engine-tag { height: 24px; min-width: 30px; font-size: 11px; } - - h1 { font-size: 13px; } + /* In peek only the head shows — keep the body from painting a sliver. */ + #panel.sheet-peek .panel-body { visibility: hidden; } + + /* Top-right stack without the hamburger: locate (8) / layers (56). */ + .locate-button { top: 8px; right: 8px; bottom: auto; } + .layer-ctrl-button { top: 56px; right: 8px; bottom: auto; } + #layer-order-modal { top: 104px; } + #layer-order-modal > .modal { max-height: calc(100vh - 116px); max-height: calc(100dvh - 116px); } + /* Compute feedback pill: TOP centre (the sheet owns the bottom edge), + narrow enough to clear the 🔍 (left) and locate (right) buttons. */ + /* `body #status-float` out-specifies the base rule further down (which + sets bottom:14px) — same-specificity would lose on source order. */ + body #status-float { + top: 8px; bottom: auto; + max-width: calc(100vw - 120px); + } + /* Sheet at full height: the map is covered, so its floating controls + (locate / layers / 🔍 + ↶↷ column) step aside; the 56 px strip the + sheet leaves at the top keeps the status pill visible. */ + body.sheet-full .locate-button, body.sheet-full .layer-ctrl-button, + body.sheet-full .leaflet-top.leaflet-left { display: none; } + /* Hide Leaflet's default +/− zoom control on phones — pinch-zoom is the + natural gesture; 🔍 and ↶↷ keep the top-left column. */ + .leaflet-control-zoom { display: none; } + + /* ---- Touch targets ------------------------------------------------ + ≥ 40 px controls, 16 px inputs (below that iOS Safari auto-zooms + the page on focus), roomier collapsible headers. */ + button { padding: 9px 10px; font-size: 14px; min-height: 40px; } + .ex-link { padding: 9px 10px; font-size: 13px; min-height: 40px; } + input[type="number"], input[type="text"], input[type="search"], select { + padding: 8px 10px; font-size: 16px; min-height: 40px; + } + input[type="file"] { font-size: 14px; } + input[type="checkbox"], input[type="radio"] { width: 20px; height: 20px; } + details.group > summary { min-height: 40px; padding: 4px 0; font-size: 12px; } + details.group-parent > summary { min-height: 40px; padding: 8px 2px; font-size: 13px; } + label { font-size: 12px; } + .hint, .meta { font-size: 11px; } + /* Keep the small inline buttons (lang tag, help) compact — not primary + actions, and they share the brand row. */ + .engine-tag, .help-button { padding: 0; min-height: 0; } + .help-button { width: 28px !important; height: 28px; font-size: 14px; } + .engine-tag { height: 28px; min-width: 34px; font-size: 12px; } + /* Leaflet marker popups (reference delete etc.) get a thumb-sized button. */ + .leaflet-popup-content button { min-height: 40px; font-size: 14px; } + + h1 { font-size: 14px; } .group { padding: 8px 10px; } } - /* On really small screens (phones in portrait), let the modal use the - full viewport — the side-by-side help layout already collapses below - 720 px in the existing rule. */ + /* Phones in portrait: help modal uses the whole viewport (its two-column + layout already collapses below 720 px). */ @media (max-width: 480px) { .modal { padding: 16px; max-height: 92vh; } - #panel { width: 92vw; } - } - - /* Hide Leaflet's default +/− zoom control on mobile — the hamburger - lives in the same top-left slot, and pinch-zoom is the natural - gesture on touch devices anyway. */ - @media (max-width: 860px) { - .leaflet-control-zoom { display: none; } - /* Third in the top-right stack (mirrors desktop), clear of its own modal. */ - .layer-ctrl-button { top: 104px; right: 8px; bottom: auto; } + .panel-header .subtitle { display: none; } /* brand row fits on one line */ } h1 { font-size: 14px; @@ -946,6 +1001,14 @@