fix(web): airport editor map size + drop no-JS text fallback

Pin the enhanced map host to a definite viewport height and invalidate
Leaflet size on layout changes so OSM tiles fill the pane. Remove the
bottom apt/air text backup forms to reclaim console space.
This commit is contained in:
Reese Norris
2026-07-23 21:17:32 -04:00
parent f58157b113
commit 287100acf8
7 changed files with 119 additions and 133 deletions

View File

@@ -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 3050 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.
---

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -4,8 +4,8 @@
* Modes, draw, vertex/aircraft drag, Blob download, dirty hash,
* beforeunload, replace confirms, Raw Apply, shortcuts 16 / 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

View File

@@ -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 };

View File

@@ -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 @@
<span class="apted-hint" data-js="chip-counts"></span>
</div>
<p class="apted-hint apted-title-hint">
Integrated .apt + .air · map requires JavaScript · text download works without JS
Integrated .apt + .air · requires JavaScript · downloads via toolbar (Blob)
</p>
</header>
@@ -88,8 +87,8 @@
<div id="apted-map" class="apted-map" role="application"
aria-label="Airport map editor (requires JavaScript)">
<p class="apted-map-placeholder">
Map editing requires JavaScript. Use the text fallback below to
paste and download <code>.apt</code> / <code>.air</code> files.
Airport editor requires JavaScript to open, edit, and download
<code>.apt</code> / <code>.air</code> files on the map.
</p>
</div>
@@ -207,47 +206,6 @@
</div>
</aside>
</div>
{{/* No-JS fallback: always present in HTML (not JS-injected) */}}
<section class="apted-fallback" aria-labelledby="apted-fallback-heading">
<h2 id="apted-fallback-heading">Text fallback (works without JavaScript)</h2>
<p class="apted-hint">
Map editing requires JavaScript. Paste or type file text below, then download.
Echo-download only — the server does not persist these files.
</p>
<form method="post" action="/airport-editor/download-apt" class="apted-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="apted-field">
<label for="apt_text">Airport .apt</label>
<textarea id="apt_text" name="apt_text" rows="12" spellcheck="false"
placeholder="icao=KBTV&#10;…"></textarea>
</div>
<div class="apted-field">
<label for="apt_filename">Download filename</label>
<input type="text" id="apt_filename" name="filename" value="airport.apt"
autocomplete="off" spellcheck="false" maxlength="64"
pattern="[A-Za-z0-9._\-]{1,64}">
</div>
<button type="submit" class="btn btn-sm btn-primary">Download .apt</button>
</form>
<form method="post" action="/airport-editor/download-air" class="apted-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="apted-field">
<label for="air_text">Scenario .air</label>
<textarea id="air_text" name="air_text" rows="8" spellcheck="false"
placeholder="CALLSIGN:TYPE:…"></textarea>
</div>
<div class="apted-field">
<label for="air_filename">Download filename</label>
<input type="text" id="air_filename" name="filename" value="scenario.air"
autocomplete="off" spellcheck="false" maxlength="64"
pattern="[A-Za-z0-9._\-]{1,64}">
</div>
<button type="submit" class="btn btn-sm btn-primary">Download .air</button>
</form>
</section>
</main>
<script src="/static/js/leaflet.js"></script>