diff --git a/internal/web/README.md b/internal/web/README.md index bd32135..9ed43cd 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -28,7 +28,7 @@ JSON under `/api/v1` remains for external consumers and map polling. Admin mutat ### JS budget / map exception First-party openfsd modules stay small and vanilla (no jQuery). The **dashboard route** may load **Leaflet** (vendor) + `dashboard.js` as a documented exception to the 30–50 KB compressed first-party budget. Failure mode: map is absent; connection summary HTML still works. -The **airport editor** (`/airport-editor`) is a second complexity-gate exception for map geometry authoring (Leaflet + first-party modules). Essential data path without JS: paste `apt_text` / `air_text` + CSRF form echo-download. Map region is inert when JS is off. Download handlers never write APT/AIR to disk or DB. +The **airport editor** (`/airport-editor`) is a second complexity-gate exception for map geometry authoring (Leaflet + first-party modules). Open/edit/download are JS-primary (toolbar FileReader + Blob download; Raw tab for text). Map region is inert when JS is off. Optional `POST /airport-editor/download-*` echo handlers remain for tests/tools and never write APT/AIR to disk or DB. --- diff --git a/internal/web/pages_airport_editor.go b/internal/web/pages_airport_editor.go index 654b075..f4fdfa9 100644 --- a/internal/web/pages_airport_editor.go +++ b/internal/web/pages_airport_editor.go @@ -21,8 +21,8 @@ const airportEditorFlashMsgMaxRunes = 240 var airportEditorSafeFilenameRE = regexp.MustCompile(`^[A-Za-z0-9._\-]{1,64}$`) // handleFrontendAirportEditor GET /airport-editor — server-rendered shell. -// No-JS path: dual textareas + CSRF echo-download forms. Map region is inert -// without JS (message only). No server-side parse or persistence. +// Map authoring is JS-primary (Leaflet + modules). Without JS the map region +// shows a placeholder only. No server-side parse or persistence of APT/AIR. func (s *Server) handleFrontendAirportEditor(c *gin.Context) { page := s.newAirportEditorPage(c) s.applyAirportEditorFlash(c, &page) diff --git a/internal/web/pages_airport_editor_test.go b/internal/web/pages_airport_editor_test.go index c1fff4a..9bd7d4e 100644 --- a/internal/web/pages_airport_editor_test.go +++ b/internal/web/pages_airport_editor_test.go @@ -66,12 +66,6 @@ func TestAirportEditorAdminShell(t *testing.T) { for _, want := range []string{ `id="apted-root"`, `data-js="airport-editor"`, - `name="apt_text"`, - `name="air_text"`, - `name="filename"`, - `name="csrf_token"`, - `action="/airport-editor/download-apt"`, - `action="/airport-editor/download-air"`, `id="apted-map"`, `requires JavaScript`, `href="/airport-editor"`, @@ -110,6 +104,18 @@ func TestAirportEditorAdminShell(t *testing.T) { t.Fatalf("expected body to contain %q, body=%s", want, clip(body, 800)) } } + // No-JS text backup forms were removed (map is JS-primary; reclaim screen space). + for _, ban := range []string{ + `name="apt_text"`, + `name="air_text"`, + `apted-fallback`, + `action="/airport-editor/download-apt"`, + `action="/airport-editor/download-air"`, + } { + if strings.Contains(body, ban) { + t.Fatalf("shell should not contain removed fallback UI %q", ban) + } + } } func TestAirportEditorNavOnlyForAdmin(t *testing.T) { diff --git a/internal/web/static/css/openfsd/airport-editor.css b/internal/web/static/css/openfsd/airport-editor.css index 54b6c16..5bc5ed5 100644 --- a/internal/web/static/css/openfsd/airport-editor.css +++ b/internal/web/static/css/openfsd/airport-editor.css @@ -1,8 +1,9 @@ /* * Airport editor — dense utilitarian 16:9 console (map + rail). * - * Aligned with sweatbox tokens. Progressive enhancement: map/rail JS - * enhances layout; forms and text fallback work without this stylesheet. + * Aligned with sweatbox tokens. Map authoring is JS-primary (Leaflet); + * this stylesheet sizes the enhanced shell so the map host has a definite + * height (required for correct OSM tile coverage). */ /* --- tokens (mirrors sweatbox density; --apted-* namespace) --- */ @@ -25,6 +26,8 @@ --apted-fs: 0.8125rem; --apted-fs-sm: 0.75rem; --apted-header-approx: 3.25rem; + /* Remaining chrome below site header: titlebar + toolbar + gaps + padding */ + --apted-chrome-approx: 7.5rem; box-sizing: border-box; font-family: var(--apted-font); @@ -41,6 +44,18 @@ gap: var(--apted-gap); } +/* + * JS-enhanced shell: pin to the viewport so map+rail share one definite height. + * Leaflet tiles glitch (half-map / wrong pan) when the container is taller than + * the size measured at init — height:100% of an auto parent is the usual cause. + */ +.apted.is-enhanced { + height: calc(100dvh - var(--apted-header-approx)); + max-height: calc(100dvh - var(--apted-header-approx)); + min-height: 0; + overflow: hidden; +} + .apted *, .apted *::before, .apted *::after { @@ -337,14 +352,41 @@ flex: 1 1 auto; display: grid; grid-template-columns: 1fr; + grid-template-rows: minmax(16rem, auto) auto; min-height: 0; gap: var(--apted-gap); } +.apted.is-enhanced .apted-body { + flex: 1 1 0; + min-height: 0; + /* Single row fills leftover height so map height:100% is definite */ + grid-template-rows: minmax(0, 1fr); +} + @media (min-width: 900px) { .apted-body { grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem); - max-height: calc(100dvh - var(--apted-header-approx) - 7.5rem); + grid-template-rows: minmax(0, 1fr); + } + + .apted:not(.is-enhanced) .apted-body { + max-height: calc(100dvh - var(--apted-header-approx) - var(--apted-chrome-approx)); + } +} + +/* Narrow enhanced: stack map over rail; keep map a fixed band so Leaflet size is stable */ +@media (max-width: 899.98px) { + .apted.is-enhanced { + height: auto; + max-height: none; + overflow: visible; + min-height: calc(100dvh - var(--apted-header-approx)); + } + + .apted.is-enhanced .apted-body { + flex: 1 1 auto; + grid-template-rows: minmax(16rem, 45dvh) minmax(12rem, auto); } } @@ -360,19 +402,22 @@ padding: 1rem; position: relative; z-index: 0; + /* Clip tile overflow if Leaflet size lags a frame behind CSS */ + overflow: hidden; } .apted.is-enhanced .apted-map { display: block; padding: 0; - min-height: 20rem; + min-height: 0; + height: 100%; + width: 100%; } -@media (min-width: 900px) { - .apted.is-enhanced .apted-map { - min-height: 0; - height: 100%; - } +/* Leaflet host must fill the grid cell — never rely on content-sized height */ +.apted.is-enhanced .apted-map.leaflet-container { + height: 100%; + width: 100%; } .apted-map-placeholder { @@ -663,39 +708,7 @@ background: #f8f9fa; } -/* --- no-JS text fallback --- */ -.apted-fallback { - background: var(--apted-panel); - border: 1px solid var(--apted-border); - border-radius: 2px; - padding: 0.6rem 0.75rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.apted.is-enhanced .apted-fallback { - /* Keep available for PE text path / download forms; collapse visually slightly */ - opacity: 0.95; -} - -.apted-fallback h2 { - margin: 0; - font-size: var(--apted-fs-sm); - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.apted-form { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin: 0; - padding-top: 0.35rem; - border-top: 1px dashed var(--apted-border); -} - +/* Field blocks (inspector / raw panels) */ .apted-field { display: flex; flex-direction: column; @@ -722,7 +735,7 @@ } .apted-field textarea { - min-height: 8rem; + min-height: 5rem; resize: vertical; line-height: 1.4; } diff --git a/internal/web/static/js/openfsd/airport-editor/main.js b/internal/web/static/js/openfsd/airport-editor/main.js index 7c9ee8a..1dbbc9f 100644 --- a/internal/web/static/js/openfsd/airport-editor/main.js +++ b/internal/web/static/js/openfsd/airport-editor/main.js @@ -4,8 +4,8 @@ * Modes, draw, vertex/aircraft drag, Blob download, dirty hash, * beforeunload, replace confirms, Raw Apply, shortcuts 1–6 / Del / Esc. * - * Progressive enhancement: requires Leaflet (global L) + this module. - * Without JS: textareas + echo-download forms still work. + * Requires Leaflet (global L) + this module. Map authoring is JS-primary + * (complexity-gate exception); no no-JS text fallback on this page. */ import { @@ -50,7 +50,7 @@ import { } from './map-layers.js'; import { mountToolbar, readFileAsText, modeFromDigitKey } from './ui-toolbar.js'; import { mountRail, applyTitlebarChips } from './ui-rail.js'; -import { downloadApt, downloadAir, sanitizeFilename } from './download.js'; +import { downloadApt, downloadAir } from './download.js'; import { createDrawSession, beginDraw, @@ -70,7 +70,7 @@ function main() { if (!root || root.getAttribute('data-js') !== 'airport-editor') return; if (typeof L === 'undefined') { - // Leaflet failed to load; leave no-JS fallback visible. + // Leaflet failed to load; leave placeholder message visible. return; } @@ -90,6 +90,18 @@ function main() { /** @type {string} */ let activeBase = mapCtl.activeBase; // 'osm' | 'esri' | 'blank' + /** + * Leaflet measures container size at init; flex/grid can settle a frame later. + * Re-measure whenever the host box changes so OSM tiles fill the full map. + */ + function invalidateMapSize() { + try { + mapCtl.map.invalidateSize({ animate: false }); + } catch { + /* map may be torn down in tests */ + } + } + // Double-click finish flag (map fires click then dblclick). let suppressNextClick = false; @@ -297,7 +309,6 @@ function main() { }); doc.lastAptDownloadHash = null; doc.serverValidation = null; - syncFallbackTextareas(); validateDocument(doc); doc.selection = null; rail.setTab('surfaces'); @@ -328,7 +339,6 @@ function main() { }); doc.lastAirDownloadHash = null; doc.serverValidation = null; - syncFallbackTextareas(); validateDocument(doc); doc.selection = null; rail.setTab('aircraft'); @@ -344,6 +354,7 @@ function main() { } }, onFit() { + invalidateMapSize(); const ok = overlays.fitBounds(); if (!ok) showStatus('Nothing to fit — open or draw geometry first.', false); }, @@ -370,7 +381,6 @@ function main() { showStatus('Download failed (browser blocked Blob?).', true); return; } - syncFallbackTextareas(); refresh(); showStatus(`Downloading ${res.filename}…`, false); }, @@ -384,7 +394,6 @@ function main() { showStatus('Download failed (browser blocked Blob?).', true); return; } - syncFallbackTextareas(); refresh(); showStatus(`Downloading ${res.filename}…`, false); }, @@ -399,7 +408,6 @@ function main() { toolbar.setMode(MODE_SELECT); rail.setTab('airport'); mapCtl.map.setView([30, 0], 2); - syncFallbackTextareas(); refresh(); showStatus('New empty document.', false); }, @@ -436,12 +444,20 @@ function main() { window.addEventListener('keydown', onKeyDown); window.addEventListener('beforeunload', onBeforeUnload); + // Size after layout paints (double rAF + short delay covers flex settle). requestAnimationFrame(() => { - mapCtl.map.invalidateSize(); - }); - window.addEventListener('resize', () => { - mapCtl.map.invalidateSize(); + requestAnimationFrame(() => { + invalidateMapSize(); + }); }); + window.setTimeout(invalidateMapSize, 100); + window.addEventListener('resize', invalidateMapSize); + if (typeof ResizeObserver === 'function') { + const ro = new ResizeObserver(() => { + invalidateMapSize(); + }); + ro.observe(mapEl); + } /** * @param {KeyboardEvent} ev @@ -600,7 +616,6 @@ function main() { function afterAptMutation(opts = {}) { validateDocument(doc); markServerValidationStale(doc); - syncFallbackTextareas(); refresh(opts); } @@ -610,7 +625,6 @@ function main() { function afterAirMutation(opts = {}) { validateDocument(doc); markServerValidationStale(doc); - syncFallbackTextareas(); refresh(opts); } @@ -725,29 +739,11 @@ function main() { // Mode indicator on root for CSS/cursor root.setAttribute('data-mode', doc.mode || MODE_SELECT); if (opts.fit) { + invalidateMapSize(); overlays.fitBounds(); } } - function syncFallbackTextareas() { - const aptTa = document.getElementById('apt_text'); - const airTa = document.getElementById('air_text'); - if (aptTa instanceof HTMLTextAreaElement && doc.airport) { - aptTa.value = formatAPT(doc.airport); - } - if (airTa instanceof HTMLTextAreaElement) { - airTa.value = doc.aircraft?.length ? formatAIR(doc.aircraft) : ''; - } - const aptFn = document.getElementById('apt_filename'); - const airFn = document.getElementById('air_filename'); - if (aptFn instanceof HTMLInputElement && doc.aptFilename) { - aptFn.value = sanitizeFilename(doc.aptFilename, 'airport.apt'); - } - if (airFn instanceof HTMLInputElement && doc.airFilename) { - airFn.value = sanitizeFilename(doc.airFilename, 'scenario.air'); - } - } - /** * @param {string} msg * @param {boolean} isError diff --git a/internal/web/static/js/openfsd/airport-editor/map-layers.js b/internal/web/static/js/openfsd/airport-editor/map-layers.js index 0dbe301..370a87d 100644 --- a/internal/web/static/js/openfsd/airport-editor/map-layers.js +++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js @@ -320,6 +320,19 @@ export function createMap(L, mapEl, opts = {}) { if (prev && map.hasLayer(prev)) map.removeLayer(prev); if (!map.hasLayer(next)) next.addTo(map); activeBase = key; + // Basemap swap after layout changes can leave a half-covered tile pane. + if (typeof map.invalidateSize === 'function') { + map.invalidateSize({ animate: false }); + } + } + + // Force a remeasure after the first paint of this host (flex/grid settle). + if (typeof map.whenReady === 'function') { + map.whenReady(() => { + if (typeof map.invalidateSize === 'function') { + map.invalidateSize({ animate: false }); + } + }); } return { map, baseLayers, get activeBase() { return activeBase; }, setBase }; diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html index 87a1b13..2fdcc96 100644 --- a/internal/web/templates/airport_editor.html +++ b/internal/web/templates/airport_editor.html @@ -5,9 +5,8 @@ JS budget exception (airport map editor) — complexity-gate exception: This route loads Leaflet (vendor) + first-party airport-editor modules. Map geometry authoring is JS-primary (documented exception to the general - PE bar). Essential data path without JS: paste apt_text / air_text + form - CSRF echo-download. No SPA router / global server store / hydration. - Files are never written to server disk or DB. + PE bar): open/edit/download requires JavaScript. No SPA router / global + server store / hydration. Files are never written to server disk or DB. e2e blank tiles: set data-test-tiles="blank" on #apted-root (no OSM/Esri). That is the only blank-tile hook for this page (query ?e2e=1 is not used). @@ -31,7 +30,7 @@

- Integrated .apt + .air · map requires JavaScript · text download works without JS + Integrated .apt + .air · requires JavaScript · downloads via toolbar (Blob)

@@ -88,8 +87,8 @@

- Map editing requires JavaScript. Use the text fallback below to - paste and download .apt / .air files. + Airport editor requires JavaScript to open, edit, and download + .apt / .air files on the map.

@@ -207,47 +206,6 @@ - - {{/* No-JS fallback: always present in HTML (not JS-injected) */}} -
-

Text fallback (works without JavaScript)

-

- Map editing requires JavaScript. Paste or type file text below, then download. - Echo-download only — the server does not persist these files. -

- -
- -
- - -
-
- - -
- -
- -
- -
- - -
-
- - -
- -
-