'use strict'; // ── Configuration ──────────────────────────────────────────────────────────── const MAPTILER_KEY_STORAGE = 'droneMapUK_maptilerKey'; const STYLES = { outdoor: key => `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`, satellite: key => `https://api.maptiler.com/maps/satellite/style.json?key=${key}`, hybrid: key => `https://api.maptiler.com/maps/hybrid/style.json?key=${key}`, streets: key => `https://api.maptiler.com/maps/streets-v2/style.json?key=${key}`, }; // ── UK licensed aerodromes (FRZ = 5 km radius from reference point, approx.) ─ const AERODROMES = [ { name: 'Heathrow', icao: 'EGLL', lat: 51.4775, lon: -0.4614 }, { name: 'Gatwick', icao: 'EGKK', lat: 51.1481, lon: -0.1903 }, { name: 'Stansted', icao: 'EGSS', lat: 51.8850, lon: 0.2350 }, { name: 'Luton', icao: 'EGGW', lat: 51.8747, lon: -0.3683 }, { name: 'London City', icao: 'EGLC', lat: 51.5048, lon: 0.0505 }, { name: 'Manchester', icao: 'EGCC', lat: 53.3537, lon: -2.2750 }, { name: 'Birmingham', icao: 'EGBB', lat: 52.4538, lon: -1.7480 }, { name: 'Edinburgh', icao: 'EGPH', lat: 55.9500, lon: -3.3625 }, { name: 'Glasgow', icao: 'EGPF', lat: 55.8719, lon: -4.4331 }, { name: 'Bristol', icao: 'EGGD', lat: 51.3827, lon: -2.7190 }, { name: 'Leeds Bradford', icao: 'EGNM', lat: 53.8659, lon: -1.6606 }, { name: 'Newcastle', icao: 'EGNT', lat: 55.0375, lon: -1.6917 }, { name: 'Liverpool', icao: 'EGGP', lat: 53.3336, lon: -2.8497 }, { name: 'East Midlands', icao: 'EGNX', lat: 52.8311, lon: -1.3281 }, { name: 'Southampton', icao: 'EGHI', lat: 50.9503, lon: -1.3568 }, { name: 'Aberdeen', icao: 'EGPD', lat: 57.2019, lon: -2.1978 }, { name: 'Cardiff', icao: 'EGFF', lat: 51.3967, lon: -3.3433 }, { name: 'Belfast International', icao: 'EGAA', lat: 54.6575, lon: -6.2158 }, { name: 'Belfast City', icao: 'EGAC', lat: 54.6181, lon: -5.8725 }, { name: 'Exeter', icao: 'EGTE', lat: 50.7344, lon: -3.4139 }, { name: 'Norwich', icao: 'EGSH', lat: 52.6758, lon: 1.2828 }, { name: 'Bournemouth', icao: 'EGHH', lat: 50.7800, lon: -1.8425 }, { name: 'Durham Tees Valley', icao: 'EGNV', lat: 54.5092, lon: -1.4294 }, { name: 'Humberside', icao: 'EGNJ', lat: 53.5744, lon: -0.3508 }, { name: 'Doncaster Sheffield', icao: 'EGCN', lat: 53.4805, lon: -1.0106 }, { name: 'Inverness', icao: 'EGPE', lat: 57.5425, lon: -4.0475 }, { name: 'Prestwick', icao: 'EGPK', lat: 55.5094, lon: -4.5867 }, { name: 'Dundee', icao: 'EGPN', lat: 56.4525, lon: -3.0258 }, { name: 'Cambridge', icao: 'EGSC', lat: 52.2050, lon: 0.1750 }, { name: 'Oxford', icao: 'EGTK', lat: 51.8369, lon: -1.3200 }, { name: 'Farnborough', icao: 'EGLF', lat: 51.2778, lon: -0.7764 }, { name: 'Biggin Hill', icao: 'EGKB', lat: 51.3308, lon: 0.0325 }, { name: 'Southend', icao: 'EGMC', lat: 51.5714, lon: 0.6956 }, { name: 'Lydd', icao: 'EGMD', lat: 50.9561, lon: 0.9392 }, { name: 'Shoreham', icao: 'EGKA', lat: 50.8356, lon: -0.2972 }, { name: 'Gloucester', icao: 'EGBJ', lat: 51.8942, lon: -2.1672 }, { name: 'Newquay', icao: 'EGHQ', lat: 50.4406, lon: -4.9954 }, { name: 'Ronaldsway (IoM)', icao: 'EGNS', lat: 54.0833, lon: -4.6239 }, { name: 'Jersey', icao: 'EGJJ', lat: 49.2078, lon: -2.1950 }, { name: 'Guernsey', icao: 'EGJB', lat: 49.4350, lon: -2.6019 }, ]; // ── Royal Parks (London) ───────────────────────────────────────────────────── const ROYAL_PARKS = [ { name: 'Hyde Park', lat: 51.5073, lon: -0.1657, radiusKm: 0.72 }, { name: 'Kensington Gardens', lat: 51.5057, lon: -0.1882, radiusKm: 0.62 }, { name: "St James's Park", lat: 51.5024, lon: -0.1347, radiusKm: 0.42 }, { name: 'Green Park', lat: 51.5027, lon: -0.1432, radiusKm: 0.32 }, { name: "Regent's Park", lat: 51.5313, lon: -0.1564, radiusKm: 0.85 }, { name: 'Richmond Park', lat: 51.4422, lon: -0.2781, radiusKm: 1.80 }, { name: 'Bushy Park', lat: 51.4153, lon: -0.3382, radiusKm: 1.20 }, { name: 'Greenwich Park', lat: 51.4769, lon: 0.0003, radiusKm: 0.55 }, ]; // ── Geometry helpers ───────────────────────────────────────────────────────── function makeCircle(lon, lat, radiusKm, steps = 64) { const R = 6371; const d = radiusKm / R; const latR = lat * Math.PI / 180; const lonR = lon * Math.PI / 180; const coords = []; for (let i = 0; i <= steps; i++) { const bearing = (i / steps) * 2 * Math.PI; const pLat = Math.asin( Math.sin(latR) * Math.cos(d) + Math.cos(latR) * Math.sin(d) * Math.cos(bearing) ); const pLon = lonR + Math.atan2( Math.sin(bearing) * Math.sin(d) * Math.cos(latR), Math.cos(d) - Math.sin(latR) * Math.sin(pLat) ); coords.push([pLon * 180 / Math.PI, pLat * 180 / Math.PI]); } return coords; } const emptyFC = () => ({ type: 'FeatureCollection', features: [] }); function buildFRZGeoJSON() { return { type: 'FeatureCollection', features: AERODROMES.map(a => ({ type: 'Feature', properties: { name: `${a.name} (${a.icao})`, icao: a.icao, type: 'FRZ', radiusKm: 5 }, geometry: { type: 'Polygon', coordinates: [makeCircle(a.lon, a.lat, 5)] }, })), }; } function buildRoyalParksGeoJSON() { return { type: 'FeatureCollection', features: ROYAL_PARKS.map(p => ({ type: 'Feature', properties: { name: p.name, type: 'Royal Park' }, geometry: { type: 'Polygon', coordinates: [makeCircle(p.lon, p.lat, p.radiusKm)] }, })), }; } // ── National Parks — OS Open Geography Portal ───────────────────────────────── async function fetchNationalParks() { const url = 'https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/' + 'National_Parks_December_2023_GB_BFE/FeatureServer/0/query' + '?where=1%3D1&outFields=NPARK23NM&outSR=4326&f=geojson&geometryPrecision=5&resultRecordCount=20'; const res = await fetch(url); if (!res.ok) throw new Error(res.status); return res.json(); } // ── SSSIs — Natural England ArcGIS (England, viewport-lazy) ────────────────── // Loads up to 200 features intersecting the current map extent; debounced on moveend. const SSSI_MIN_ZOOM = 8; let sssiTimer = null; let sssiFileData = null; // user-loaded Scotland/Wales data merged with England API data async function loadSSSIViewport() { if (!map || !map.getSource('sssi') || !layerVisibility['sssi']) return; if (map.getZoom() < SSSI_MIN_ZOOM) { setSSSIStatus('Zoom in to zoom ≥ 8 to see England SSSIs'); map.getSource('sssi').setData(sssiFileData || emptyFC()); return; } setSSSIStatus('Loading…'); const b = map.getBounds(); const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`; const url = 'https://services.arcgis.com/JJzESW51TqeY9uat/arcgis/rest/services/SSSI_England/FeatureServer/0/query' + `?geometry=${bbox}&geometryType=esriGeometryEnvelope&inSR=4326&spatialRel=esriSpatialRelIntersects` + `&outFields=SSSI_NAME,STATUS,HA_AREA&outSR=4326&f=geojson&resultRecordCount=200&geometryPrecision=4`; try { const res = await fetch(url); if (!res.ok) throw new Error(res.status); const data = await res.json(); if (!map.getSource('sssi')) return; // Merge England API data with any user-loaded file data const merged = { type: 'FeatureCollection', features: [ ...data.features, ...(sssiFileData ? sssiFileData.features : []), ], }; map.getSource('sssi').setData(merged); const count = data.features.length; setSSSIStatus(`${count} England SSSIs visible${count === 200 ? ' (limit)' : ''}${sssiFileData ? ' + file data' : ''}`); } catch (err) { console.warn('SSSI England load failed:', err); setSSSIStatus('England load failed — load file manually'); if (sssiFileData && map.getSource('sssi')) map.getSource('sssi').setData(sssiFileData); } } function scheduleSSSIRefresh() { clearTimeout(sssiTimer); sssiTimer = setTimeout(loadSSSIViewport, 450); } function setSSSIStatus(msg) { const el = document.getElementById('sssi-status'); if (el) el.textContent = msg; } function setNATSStatus(msg) { const el = document.getElementById('nats-status'); if (el) el.textContent = msg; } // ── NATS data path (served from same local directory) ───────────────────────── const NATS_XML_PATH = 'EG_AIP_DS_20260514_XML/EG_AIP_DS_FULL_20260514.xml'; // ── App state ───────────────────────────────────────────────────────────────── let map = null; let apiKey = null; let currentStyle = 'outdoor'; let layerVisibility = { nats: true, frz: true, danger: true, military: true, 'national-parks': true, 'royal-parks': true, sssi: true, forestry: false, }; // User-loaded GeoJSON persisted across style switches const fileData = { danger: null, military: null, forestry: null, }; let natsGeoJSON = null; // persists across style switches let natsLoaded = false; // prevents re-fetching on style switches // Per-type visibility for NATS layer (all on by default) const natsTypeFilter = { R: true, P: true, CTR: true, CTA: true, TMA: true, RAS: true, 'OTHER:TMZ': true, }; let is3DEnabled = false; let hillshadeEnabled = false; // Location tracking let locationWatcher = null; let locationMarker = null; let isFirstFix = true; // ── Hillshade ───────────────────────────────────────────────────────────────── function addHillshade() { if (map.getLayer('hillshade')) return; // Insert below the first airspace fill layer so restrictions stay on top const beforeId = map.getLayer('frz-fill') ? 'frz-fill' : undefined; map.addLayer({ id: 'hillshade', type: 'hillshade', source: 'terrain-dem', paint: { 'hillshade-exaggeration': 0.5, 'hillshade-shadow-color': '#4a3728', 'hillshade-highlight-color': '#ffffff', 'hillshade-accent-color': '#3d2b1f', 'hillshade-illumination-direction': 335, 'hillshade-illumination-anchor': 'viewport', }, }, beforeId); } function removeHillshade() { if (map.getLayer('hillshade')) map.removeLayer('hillshade'); } // ── 3D terrain ──────────────────────────────────────────────────────────────── function addTerrainSource() { if (!map.getSource('terrain-dem')) { map.addSource('terrain-dem', { type: 'raster-dem', url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${apiKey}`, tileSize: 256, }); } } function enable3D() { addTerrainSource(); map.setTerrain({ source: 'terrain-dem', exaggeration: 1.4 }); if (!map.getLayer('sky')) { try { map.addLayer({ id: 'sky', type: 'sky', paint: { 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15, }, }); } catch { /* style may already include sky */ } } map.easeTo({ pitch: 50, duration: 600 }); } function disable3D() { map.setTerrain(null); try { if (map.getLayer('sky')) map.removeLayer('sky'); } catch { /* ignore */ } map.easeTo({ pitch: 0, duration: 400 }); } // ── NATS type filter ────────────────────────────────────────────────────────── function applyNATSFilter() { if (!map) return; const enabled = Object.keys(natsTypeFilter).filter(k => natsTypeFilter[k]); const f = enabled.length === 0 ? ['==', 1, 0] : ['match', ['get', 'type'], enabled, true, false]; ['nats-fill', 'nats-line', 'nats-label'].forEach(id => { if (map.getLayer(id)) map.setFilter(id, f); }); } // ── Layer definitions ───────────────────────────────────────────────────────── function addAllLayers() { // Defensive cleanup — prevents "source already exists" crash on rapid style switches // or if this fires twice (MapLibre can fire both 'load' and 'style.load' in some versions). [ 'nats-label','nats-line','nats-fill', 'frz-label','frz-line','frz-fill', 'danger-line','danger-fill', 'military-line','military-fill', 'national-parks-line','national-parks-fill', 'royal-parks-line','royal-parks-fill', 'sssi-line','sssi-fill', 'forestry-line','forestry-fill', 'location-accuracy-line','location-accuracy-fill', 'hillshade', ].forEach(id => { try { if (map.getLayer(id)) map.removeLayer(id); } catch (_) {} }); [ 'nats','frz','danger','military', 'national-parks','royal-parks','sssi','forestry','location-accuracy', ].forEach(id => { try { if (map.getSource(id)) map.removeSource(id); } catch (_) {} }); // 1 — Terrain DEM source (shared by hillshade and 3D) addTerrainSource(); // 2 — Hillshade sits below everything else if (hillshadeEnabled) addHillshade(); // 3 — NATS AIP airspace — filter applied dynamically via applyNATSFilter() map.addSource('nats', { type: 'geojson', data: natsGeoJSON || emptyFC() }); map.addLayer({ id: 'nats-fill', type: 'fill', source: 'nats', paint: { 'fill-color': [ 'match', ['get', 'type'], ['R', 'P'], '#ef4444', ['CTR'], '#facc15', ['CTA', 'TMA'], '#60a5fa', ['RAS'], '#818cf8', ['OTHER:TMZ'], '#c084fc', '#94a3b8', ], 'fill-opacity': 0.15, }, }); map.addLayer({ id: 'nats-line', type: 'line', source: 'nats', paint: { 'line-color': [ 'match', ['get', 'type'], ['R', 'P'], '#ef4444', ['CTR'], '#facc15', ['CTA', 'TMA'], '#60a5fa', ['RAS'], '#818cf8', ['OTHER:TMZ'], '#c084fc', '#94a3b8', ], 'line-width': 1.2, 'line-opacity': 0.75, }, }); map.addLayer({ id: 'nats-label', type: 'symbol', source: 'nats', minzoom: 8, layout: { 'text-field': ['coalesce', ['get', 'designator'], ['get', 'name']], 'text-size': 9, 'text-anchor': 'center', 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], }, paint: { 'text-color': '#fff', 'text-halo-color': '#000', 'text-halo-width': 1.2 }, }); applyNATSFilter(); // apply per-type checkbox state // 4 — Hardcoded FRZ circles map.addSource('frz', { type: 'geojson', data: buildFRZGeoJSON() }); map.addLayer({ id: 'frz-fill', type: 'fill', source: 'frz', paint: { 'fill-color': '#ef4444', 'fill-opacity': 0.15 } }); map.addLayer({ id: 'frz-line', type: 'line', source: 'frz', paint: { 'line-color': '#ef4444', 'line-width': 1.5, 'line-opacity': 0.8 } }); map.addLayer({ id: 'frz-label', type: 'symbol', source: 'frz', minzoom: 9, layout: { 'text-field': ['get', 'icao'], 'text-size': 11, 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-anchor': 'center', }, paint: { 'text-color': '#ef4444', 'text-halo-color': '#000', 'text-halo-width': 1.5 }, }); map.addSource('danger', { type: 'geojson', data: fileData.danger || emptyFC() }); map.addLayer({ id: 'danger-fill', type: 'fill', source: 'danger', paint: { 'fill-color': '#f97316', 'fill-opacity': 0.18 } }); map.addLayer({ id: 'danger-line', type: 'line', source: 'danger', paint: { 'line-color': '#f97316', 'line-width': 1.5, 'line-opacity': 0.9 } }); map.addSource('military', { type: 'geojson', data: fileData.military || emptyFC() }); map.addLayer({ id: 'military-fill', type: 'fill', source: 'military', paint: { 'fill-color': '#64748b', 'fill-opacity': 0.2 } }); map.addLayer({ id: 'military-line', type: 'line', source: 'military', paint: { 'line-color': '#64748b', 'line-width': 1.5, 'line-opacity': 0.8 } }); // 5 — Protected areas map.addSource('national-parks', { type: 'geojson', data: emptyFC() }); map.addLayer({ id: 'national-parks-fill', type: 'fill', source: 'national-parks', paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.12 } }); map.addLayer({ id: 'national-parks-line', type: 'line', source: 'national-parks', paint: { 'line-color': '#22c55e', 'line-width': 1.2, 'line-opacity': 0.7 } }); map.addSource('royal-parks', { type: 'geojson', data: buildRoyalParksGeoJSON() }); map.addLayer({ id: 'royal-parks-fill', type: 'fill', source: 'royal-parks', paint: { 'fill-color': '#a855f7', 'fill-opacity': 0.18 } }); map.addLayer({ id: 'royal-parks-line', type: 'line', source: 'royal-parks', paint: { 'line-color': '#a855f7', 'line-width': 1.2, 'line-opacity': 0.8 } }); map.addSource('sssi', { type: 'geojson', data: emptyFC() }); map.addLayer({ id: 'sssi-fill', type: 'fill', source: 'sssi', paint: { 'fill-color': '#06b6d4', 'fill-opacity': 0.14 } }); map.addLayer({ id: 'sssi-line', type: 'line', source: 'sssi', paint: { 'line-color': '#06b6d4', 'line-width': 1.2, 'line-opacity': 0.75 } }); // 6 — Managed land map.addSource('forestry', { type: 'geojson', data: fileData.forestry || emptyFC() }); map.addLayer({ id: 'forestry-fill', type: 'fill', source: 'forestry', paint: { 'fill-color': '#4d7c0f', 'fill-opacity': 0.2 } }); map.addLayer({ id: 'forestry-line', type: 'line', source: 'forestry', paint: { 'line-color': '#4d7c0f', 'line-width': 1.2, 'line-opacity': 0.8 } }); // 7 — Location accuracy circle map.addSource('location-accuracy', { type: 'geojson', data: emptyFC() }); map.addLayer({ id: 'location-accuracy-fill', type: 'fill', source: 'location-accuracy', paint: { 'fill-color': '#2563eb', 'fill-opacity': 0.08 } }); map.addLayer({ id: 'location-accuracy-line', type: 'line', source: 'location-accuracy', paint: { 'line-color': '#2563eb', 'line-width': 1, 'line-dasharray': [3, 3], 'line-opacity': 0.5 } }); // Re-apply 3D if enabled before the style switch if (is3DEnabled) enable3D(); // Restore layer visibility from checkbox state Object.entries(layerVisibility).forEach(([id, vis]) => setLayerVisibility(id, vis)); // Click handlers ['nats-fill','frz-fill','danger-fill','military-fill', 'national-parks-fill','royal-parks-fill','sssi-fill','forestry-fill', ].forEach(layer => { map.on('click', layer, onFeatureClick); map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; }); }); // Async data fetches fetchNationalParks() .then(data => { if (map.getSource('national-parks')) map.getSource('national-parks').setData(data); }) .catch(err => console.warn('National Parks load failed:', err)); if (layerVisibility['sssi']) scheduleSSSIRefresh(); } // ── Visibility helper ───────────────────────────────────────────────────────── function setLayerVisibility(layerId, visible) { const vis = visible ? 'visible' : 'none'; const hasLabel = layerId === 'frz' || layerId === 'nats'; const suffixes = hasLabel ? ['fill', 'line', 'label'] : ['fill', 'line']; suffixes.forEach(s => { const id = `${layerId}-${s}`; if (map && map.getLayer(id)) map.setLayoutProperty(id, 'visibility', vis); }); } // ── Click popup ─────────────────────────────────────────────────────────────── function onFeatureClick(e) { const f = e.features[0]; const p = f.properties; let name, extra = '', tagClass, tagLabel; switch (f.source) { case 'nats': { name = p.name || p.designator || 'Airspace'; const typeLabel = { R: 'Restricted Area', P: 'Prohibited Area', D: 'Danger Area', D_OTHER: 'Danger Area', CTR: 'Control Zone (CTR)', CTA: 'Control Area (CTA)', TMA: 'Terminal Area (TMA)', RAS: 'Restricted Airspace', 'OTHER:TMZ': 'Transponder Mandatory Zone', FIR: 'Flight Information Region', }[p.type] || p.type; const tagMap = { R: 'tag-nats-r', P: 'tag-nats-r', D: 'tag-nats-d', D_OTHER: 'tag-nats-d', CTR: 'tag-nats-ctr', CTA: 'tag-nats-cta', TMA: 'tag-nats-cta', RAS: 'tag-nats-ras', 'OTHER:TMZ': 'tag-nats-tmz', FIR: 'tag-nats-fir', }; tagClass = tagMap[p.type] || 'tag-nats-fir'; tagLabel = typeLabel; if (p.designator) extra += `
Designator: ${p.designator}
`; if (p.lowerLimit) extra += `Lower: ${p.lowerLimit} ${p.lowerUom || ''} ${p.lowerRef || ''}
`; if (p.upperLimit) extra += `Upper: ${p.upperLimit} ${p.upperUom || ''} ${p.upperRef || ''}
`; break; } case 'frz': name = p.name || 'FRZ'; extra = `${p.icao ? `ICAO: ${p.icao}
` : ''} ${p.radiusKm ? `Radius: ${p.radiusKm} km (approx.)
` : ''}`; tagClass = 'tag-frz'; tagLabel = 'Flight Restriction Zone'; break; case 'danger': name = p.name || p.Name || p.NAME || 'Restricted Area'; tagClass = 'tag-danger'; tagLabel = 'Danger / Restricted Area'; break; case 'military': name = p.name || p.Name || p.NAME || 'Military Area'; tagClass = 'tag-military'; tagLabel = 'MoD / Military'; break; case 'national-parks': name = p.NPARK23NM || p.name || 'National Park'; tagClass = 'tag-parks'; tagLabel = 'National Park'; break; case 'royal-parks': name = p.name || 'Royal Park'; tagClass = 'tag-royal'; tagLabel = 'Royal Park'; break; case 'sssi': name = p.SSSI_NAME || p.sssiName || p.name || 'SSSI'; if (p.HA_AREA) extra = `Area: ${Math.round(p.HA_AREA)} ha
`; tagClass = 'tag-sssi'; tagLabel = 'Site of Special Scientific Interest'; break; case 'forestry': name = p.name || p.Name || p.NAME || 'Forestry managed land'; tagClass = 'tag-forestry'; tagLabel = 'Managed Forest'; break; default: name = p.name || 'Feature'; tagClass = 'tag-danger'; tagLabel = 'Restricted Area'; } document.getElementById('popup-body').innerHTML = `Always verify with official CAA / NATS sources.
${tagLabel} `; document.getElementById('popup').classList.remove('hidden'); } // ── Location tracking ───────────────────────────────────────────────────────── function createLocationElement() { const el = document.createElement('div'); el.className = 'location-dot'; el.innerHTML = ''; 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 btn = document.getElementById('locate-btn'); btn.classList.remove('locating'); btn.classList.add('tracking'); setLocateLabel('Stop Tracking'); if (locationMarker) { locationMarker.setLngLat([lon, lat]); } else { locationMarker = new maplibregl.Marker({ element: createLocationElement(), anchor: 'center' }) .setLngLat([lon, lat]).addTo(map); } if (map.getSource('location-accuracy')) { map.getSource('location-accuracy').setData({ type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [makeCircle(lon, lat, Math.max(accuracy / 1000, 0.01))] }, }], }); } if (isFirstFix) { isFirstFix = false; map.flyTo({ center: [lon, lat], zoom: 13, duration: 1400 }); } } function onLocationError(err) { stopTracking(); // 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) { setLocateLabel('Geolocation not supported'); return; } document.getElementById('locate-btn').classList.add('locating'); setLocateLabel('Getting location…'); isFirstFix = true; locationWatcher = navigator.geolocation.watchPosition( onLocationUpdate, onLocationError, { enableHighAccuracy: true, timeout: 12000, maximumAge: 5000 } ); } function stopTracking() { if (locationWatcher !== null) { navigator.geolocation.clearWatch(locationWatcher); locationWatcher = null; } 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 ─────────────────────────────────────── function loadNATSLocal() { if (natsLoaded) return; // already parsed; source data restored by addAllLayers const progressBar = document.getElementById('nats-progress'); const progressFill = document.getElementById('nats-progress-fill'); setNATSStatus('Fetching NATS data…'); progressBar.classList.remove('hidden'); progressFill.style.width = '0%'; fetch(NATS_XML_PATH) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); setNATSStatus('Downloading… (72 MB)'); return res.arrayBuffer(); }) .then(buffer => { setNATSStatus('Parsing airspace…'); const worker = new Worker('nats-worker.js'); worker.onmessage = msg => { const { type, percent, geojson, message } = msg.data; if (type === 'progress') { progressFill.style.width = `${percent}%`; setNATSStatus(`Parsing… ${percent}%`); } else if (type === 'done') { progressBar.classList.add('hidden'); natsLoaded = true; natsGeoJSON = geojson; if (map && map.getSource('nats')) map.getSource('nats').setData(natsGeoJSON); setNATSStatus(`${geojson.features.length.toLocaleString()} features loaded`); worker.terminate(); } else if (type === 'error') { progressBar.classList.add('hidden'); setNATSStatus(`Parse error: ${message}`); worker.terminate(); } }; worker.onerror = err => { progressBar.classList.add('hidden'); setNATSStatus(`Worker error: ${err.message}`); }; worker.postMessage({ buffer }, [buffer]); }) .catch(err => { progressBar.classList.add('hidden'); setNATSStatus(`Load failed: ${err.message}`); }); } // ── Map initialisation ──────────────────────────────────────────────────────── function initMap(key) { apiKey = key; map = new maplibregl.Map({ container: 'map', style: STYLES[currentStyle](apiKey), center: [-2.5, 54.0], zoom: 5.5, minZoom: 4, maxPitch: 85, 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'); // 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', () => { if (layerVisibility['sssi']) scheduleSSSIRefresh(); }); map.on('error', e => { if (e.error?.status === 401 || e.error?.status === 403) { localStorage.removeItem(MAPTILER_KEY_STORAGE); showKeyModal('Invalid API key — please try again.'); } }); map.on('click', e => { const clickable = [ 'nats-fill', 'frz-fill', 'danger-fill', 'military-fill', 'national-parks-fill', 'royal-parks-fill', 'sssi-fill', 'forestry-fill', ]; if (!map.queryRenderedFeatures(e.point, { layers: clickable }).length) { document.getElementById('popup').classList.add('hidden'); } }); } // ── Style switch ────────────────────────────────────────────────────────────── 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)); } // ── API key modal ───────────────────────────────────────────────────────────── function showKeyModal(msg) { if (msg) document.querySelector('.modal-box p').innerHTML = `${msg}`; document.getElementById('key-modal').classList.remove('hidden'); } // ── Controls wiring ─────────────────────────────────────────────────────────── function wireControls() { document.getElementById('panel-toggle').addEventListener('click', () => { 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; layerVisibility[id] = cb.checked; if (map) setLayerVisibility(id, cb.checked); if (id === 'sssi' && cb.checked) scheduleSSSIRefresh(); }); }); document.querySelectorAll('[data-nats-type]').forEach(cb => { cb.addEventListener('change', () => { natsTypeFilter[cb.dataset.natsType] = cb.checked; applyNATSFilter(); }); }); 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')); btn.classList.add('active'); switchStyle(btn.dataset.style); }); }); document.getElementById('toggle-3d').addEventListener('change', e => { if (!map) return; is3DEnabled = e.target.checked; if (is3DEnabled) enable3D(); else disable3D(); }); document.getElementById('toggle-hillshade').addEventListener('change', e => { if (!map) return; hillshadeEnabled = e.target.checked; if (hillshadeEnabled) addHillshade(); else removeHillshade(); }); // ── GeoJSON file loader — targets the layer selected in the dropdown ──────── const fileInput = document.getElementById('geojson-file'); document.getElementById('geojson-btn').addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; const targetId = document.getElementById('layer-select').value; const reader = new FileReader(); reader.onload = ev => { try { const data = JSON.parse(ev.target.result); if (targetId === 'sssi') { // Merge into the SSSI layer alongside England API data sssiFileData = data; if (map && map.getSource('sssi')) scheduleSSSIRefresh(); } else { // Replace the appropriate source data fileData[targetId] = data; if (map && map.getSource(targetId)) { map.getSource(targetId).setData(data); // Auto-enable the layer checkbox if it was off const cb = document.querySelector(`[data-layer="${targetId}"]`); if (cb && !cb.checked) { cb.checked = true; layerVisibility[targetId] = true; setLayerVisibility(targetId, true); } } } } catch { alert('Could not parse GeoJSON — check the file format.'); } }; reader.readAsText(file); fileInput.value = ''; }); document.getElementById('locate-btn').addEventListener('click', () => { if (locationWatcher !== null) stopTracking(); else startTracking(); }); document.getElementById('popup-close').addEventListener('click', () => { document.getElementById('popup').classList.add('hidden'); }); } // ── Bootstrap ───────────────────────────────────────────────────────────────── (function bootstrap() { wireControls(); const stored = localStorage.getItem(MAPTILER_KEY_STORAGE); if (stored) { initMap(stored); } else { document.getElementById('key-modal').classList.remove('hidden'); } document.getElementById('key-submit').addEventListener('click', () => { const key = document.getElementById('key-input').value.trim(); if (!key) return; localStorage.setItem(MAPTILER_KEY_STORAGE, key); initMap(key); }); document.getElementById('key-input').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('key-submit').click(); }); })();