diff --git a/README.md b/README.md index e69de29..70b0f3f 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,86 @@ +# DroneMapUK + +A browser-based UK drone airspace planning tool built with MapLibre GL JS. Visualises the airspace restrictions, protected areas, and managed land relevant to UK drone operations in a single interactive map. + +> **Planning aid only.** Always verify with official CAA sources and check NOTAMs at [aurora.nats.co.uk](https://www.aurora.nats.co.uk/) before any flight. + +--- + +## Features + +- **NATS AIP Airspace** — parses the official NATS AIXM 5.1 XML dataset in a Web Worker (off the main thread) to render Restricted, Prohibited, CTR, CTA, TMA, RAS, and TMZ zones with per-type toggles and colour coding +- **Flight Restriction Zones (FRZ)** — 5 km radius circles around ~40 UK licensed aerodromes, hardcoded from CAA reference points +- **Danger / Restricted Areas** — load your own GeoJSON (e.g. converted from NATS ENR 5.1 KMZ) +- **MoD / Military Areas** — load your own GeoJSON +- **National Parks (GB)** — fetched automatically from the OS Open Geography Portal ArcGIS REST API +- **Royal Parks (London)** — 8 London parks with approximate boundaries +- **SSSIs** — England auto-loaded viewport-lazy from Natural England ArcGIS API (zoom ≥ 8); Scotland/Wales via file upload +- **Forestry managed land** — load GeoJSON from Forestry England, FLS Scotland, or NRW Wales +- **GPS location tracking** — live position with accuracy circle, auto-centres on first fix +- **Map styles** — Terrain, Satellite, Streets (MapTiler) +- **3D terrain** — MapTiler terrain-rgb-v2 DEM with pitch and sky layer +- **Hillshade overlay** — best on Satellite style + +--- + +## How it works + +The app is three plain files — `index.html`, `style.css`, and `app.js` — with no build step, no bundler, and no framework. + +| Concern | Detail | +|---|---| +| Map rendering | [MapLibre GL JS 4.x](https://maplibre.org/) via CDN | +| Base tiles | [MapTiler](https://maptiler.com) (requires a free API key) | +| NATS parsing | `nats-worker.js` Web Worker — receives the 72 MB AIXM XML as an `ArrayBuffer`, parses GML geometry (GeodesicString, CircleByCenterPoint, ArcByCenterPoint), and posts GeoJSON back to the main thread | +| API key storage | `localStorage` key `droneMapUK_maptilerKey` | +| SSSI loading | Viewport-lazy fetch from Natural England ArcGIS FeatureServer, debounced 450 ms on map move | +| National Parks | Single fetch from OS Open Geography Portal on map load | +| Style switching | Uses MapLibre `idle` event (not `style.load`) to re-add sources and layers after `setStyle()` | + +--- + +## Setup + +1. Get a free API key at [maptiler.com](https://maptiler.com) +2. Serve the directory from a local web server — the NATS XML fetch requires HTTP, not `file://` + +```bash +# Python +python3 -m http.server 8080 + +# Node +npx serve . +``` + +3. Open `http://localhost:8080` in a browser and enter your MapTiler key when prompted + +--- + +## NATS airspace data + +Download the official NATS AIP dataset from [nats-uk.ead-it.com](https://nats-uk.ead-it.com/cms-nats/opencms/en/uas-restriction-zones/) and place the extracted XML file at the path referenced in `app.js`: + +``` +EG_AIP_DS_20260514_XML/EG_AIP_DS_FULL_20260514.xml +``` + +Update `NATS_XML_PATH` in `app.js` if your filename differs. + +--- + +## GeoJSON data sources + +| Layer | Source | +|---|---| +| Danger / Restricted | [NATS UAS Zones](https://nats-uk.ead-it.com/cms-nats/opencms/en/uas-restriction-zones/) | +| Forestry England | [data-forestry.opendata.arcgis.com](https://data-forestry.opendata.arcgis.com/) | +| FLS Scotland | [ArcGIS Viewer](https://www.arcgis.com/apps/webappviewer/index.html?id=e4b9f5f437474e0481a3cec097e4c2ec) | +| NRW Wales | [datamap.gov.wales](https://datamap.gov.wales/) | +| SSSIs (Scotland/Wales) | [Natural England / Defra Open Data](https://naturalengland-defra.opendata.arcgis.com/) | + +--- + +## Attribution + +Map tiles © [MapTiler](https://maptiler.com) · Data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright) +A [Bournemouth Technology](https://bournemouthtechnology.co.uk) product diff --git a/app.js b/app.js index 841c73f..92ddd43 100644 --- a/app.js +++ b/app.js @@ -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: '© Bournemouth Technology', + }), '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')); diff --git a/index.html b/index.html index 7f7e9f7..faee895 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,15 @@
5 km radius around licensed aerodromes (approx.)
- +Override: load GeoJSON below
+Forestry England / FLS / NRW — load GeoJSON below
+Other GeoJSON layers:
-