UI polish, bug fixes, and README

Changes since last commit:

NATS airspace
- Remove D and D_OTHER types from parser — UK small-arms ranges covered the
  entire country and made the layer unusable
- Replace title-attribute tooltips on type filters with clickable ⓘ icons
  (mobile-friendly); info text appears in a shared box below the grid
- Add Select All / Deselect All controls (moved to bottom of all layers, now
  applies to every layer checkbox not just NATS types)
- Fix per-type filter using expression syntax ['match', ...] — legacy filter
  syntax was unreliable in MapLibre GL JS 4.x

Map style switching
- Fix overlay layers being lost when switching Terrain / Satellite / Streets
  by waiting for the 'idle' event instead of 'style.load'; MapTiler styles
  fire style.load before the map is ready to accept addSource/addLayer calls
- Defensive cleanup at top of addAllLayers() removes all custom layers and
  sources before re-adding, preventing "source already exists" crashes

Location tracking
- Move locate button from floating bottom-right into the left panel
- Auto-request location on page load
- Dynamic button label: "Getting location…" → "Stop Tracking" → "Track location"
- Suppress alert on permission-denied (error code 1); show inline message for
  other errors

Panel UX
- Move planning disclaimer out of panel body into a ⚠ icon in the header;
  click to expand, includes hyperlinked NOTAM link
- Add NOTAM link to the data-links section at the bottom
- Danger / Restricted and MoD / Military rows now have ⓘ icons explaining
  that these layers have no built-in data and require a GeoJSON upload;
  removes the old "Override: load GeoJSON below" note and "(override)" labels
  from the dropdown
- Load Data section: stacked full-width layout (dropdown above button)
- Lighten grey text (#555#888, #666#999) across section labels,
  layer notes, info icons, hints, data links, and popup close button

Map bounds and attribution
- Restrict panning to UK + Channel Islands with maxBounds
- Replace default attribution control with custom one appending
  "© Bournemouth Technology" with link to bournemouthtechnology.co.uk

README
- Add full project description, feature list, tech stack table, setup
  instructions, and data source reference table
This commit is contained in:
Jon
2026-05-25 18:28:25 +01:00
parent db9d58043d
commit f741a17202
4 changed files with 297 additions and 86 deletions

89
app.js
View File

@@ -532,11 +532,17 @@ function createLocationElement() {
return el;
}
function setLocateLabel(text) {
const el = document.getElementById('locate-label');
if (el) el.textContent = text;
}
function onLocationUpdate(pos) {
const { longitude: lon, latitude: lat, accuracy } = pos.coords;
const locateBtn = document.getElementById('locate-btn');
locateBtn.classList.remove('locating');
locateBtn.classList.add('tracking');
const btn = document.getElementById('locate-btn');
btn.classList.remove('locating');
btn.classList.add('tracking');
setLocateLabel('Stop Tracking');
if (locationMarker) {
locationMarker.setLngLat([lon, lat]);
@@ -562,12 +568,14 @@ function onLocationUpdate(pos) {
function onLocationError(err) {
stopTracking();
alert(`Location error: ${err.message}`);
// Permission denied (code 1) — user chose not to share, no need to alert
if (err.code !== 1) setLocateLabel('Location error — click to retry');
}
function startTracking() {
if (!navigator.geolocation) { alert('Geolocation not supported by this browser.'); return; }
if (!navigator.geolocation) { setLocateLabel('Geolocation not supported'); return; }
document.getElementById('locate-btn').classList.add('locating');
setLocateLabel('Getting location…');
isFirstFix = true;
locationWatcher = navigator.geolocation.watchPosition(
onLocationUpdate, onLocationError,
@@ -580,6 +588,7 @@ function stopTracking() {
if (locationMarker) { locationMarker.remove(); locationMarker = null; }
if (map && map.getSource('location-accuracy')) map.getSource('location-accuracy').setData(emptyFC());
document.getElementById('locate-btn').classList.remove('locating', 'tracking');
setLocateLabel('Track location');
}
// ── Auto-load NATS XML from local path ───────────────────────────────────────
@@ -645,15 +654,26 @@ function initMap(key) {
zoom: 5.5,
minZoom: 4,
maxPitch: 85,
attributionControl: true,
attributionControl: false,
// Constrain panning to the UK + Channel Islands + a small buffer
maxBounds: [[-11, 47.5], [4, 62.5]],
});
map.addControl(new maplibregl.AttributionControl({
customAttribution: '© <a href="https://bournemouthtechnology.co.uk" target="_blank" rel="noopener">Bournemouth Technology</a>',
}), 'bottom-right');
map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'bottom-right');
map.on('load', () => {
addAllLayers();
// style.load fires when the initial style is ready — add layers here.
// Style *switches* use idle (see switchStyle) because setStyle fires style.load
// prematurely on some MapTiler styles before the map is ready to accept sources.
map.once('style.load', addAllLayers);
// load fires once after the first full render.
map.once('load', () => {
document.getElementById('key-modal').classList.add('hidden');
loadNATSLocal();
startTracking();
});
map.on('moveend', () => {
@@ -682,8 +702,13 @@ function initMap(key) {
function switchStyle(styleKey) {
if (styleKey === currentStyle) return;
currentStyle = styleKey;
// Cancel any previous pending idle handler (rapid clicks), then wait for
// the map to be truly idle after the style swap before re-adding layers.
// Using 'idle' rather than 'style.load' because MapTiler styles fire
// style.load before they're fully ready to accept addSource/addLayer calls.
map.off('idle', addAllLayers);
map.once('idle', addAllLayers);
map.setStyle(STYLES[styleKey](apiKey));
map.once('style.load', () => addAllLayers());
}
// ── API key modal ─────────────────────────────────────────────────────────────
@@ -698,6 +723,10 @@ function wireControls() {
document.getElementById('panel-body').classList.toggle('collapsed');
});
document.getElementById('warn-btn').addEventListener('click', () => {
document.getElementById('warn-popup').classList.toggle('hidden');
});
document.querySelectorAll('[data-layer]').forEach(cb => {
cb.addEventListener('change', () => {
const id = cb.dataset.layer;
@@ -714,6 +743,48 @@ function wireControls() {
});
});
document.getElementById('layers-select-all').addEventListener('click', () => {
document.querySelectorAll('[data-layer]').forEach(cb => {
cb.checked = true;
const id = cb.dataset.layer;
layerVisibility[id] = true;
if (map) setLayerVisibility(id, true);
if (id === 'sssi') scheduleSSSIRefresh();
});
});
document.getElementById('layers-deselect-all').addEventListener('click', () => {
document.querySelectorAll('[data-layer]').forEach(cb => {
cb.checked = false;
const id = cb.dataset.layer;
layerVisibility[id] = false;
if (map) setLayerVisibility(id, false);
});
});
// Info buttons — click to show/hide description; each button specifies its box via data-infobox
let activeInfoBtn = null;
document.querySelectorAll('.info-btn').forEach(btn => {
btn.addEventListener('click', () => {
const box = document.getElementById(btn.dataset.infobox || 'nats-type-info');
if (activeInfoBtn === btn) {
btn.classList.remove('active');
box.classList.add('hidden');
activeInfoBtn = null;
} else {
if (activeInfoBtn) {
const prevBox = document.getElementById(activeInfoBtn.dataset.infobox || 'nats-type-info');
prevBox.classList.add('hidden');
activeInfoBtn.classList.remove('active');
}
btn.classList.add('active');
box.textContent = btn.dataset.info;
box.classList.remove('hidden');
activeInfoBtn = btn;
}
});
});
document.querySelectorAll('.style-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.style-btn').forEach(b => b.classList.remove('active'));