mirror of
https://github.com/renorris/openfsd
synced 2026-08-12 04:15:40 +08:00
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 …"></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>
|
||||
|
||||
Reference in New Issue
Block a user