Files
DroneMapUK/app.js

878 lines
36 KiB
JavaScript

'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 += `<p>Designator: <strong>${p.designator}</strong></p>`;
if (p.lowerLimit) extra += `<p>Lower: <strong>${p.lowerLimit} ${p.lowerUom || ''} ${p.lowerRef || ''}</strong></p>`;
if (p.upperLimit) extra += `<p>Upper: <strong>${p.upperLimit} ${p.upperUom || ''} ${p.upperRef || ''}</strong></p>`;
break;
}
case 'frz':
name = p.name || 'FRZ';
extra = `${p.icao ? `<p>ICAO: <strong>${p.icao}</strong></p>` : ''}
${p.radiusKm ? `<p>Radius: <strong>${p.radiusKm} km</strong> (approx.)</p>` : ''}`;
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 = `<p>Area: <strong>${Math.round(p.HA_AREA)} ha</strong></p>`;
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 = `
<h3>${name}</h3>
${extra}
<p style="font-size:.72rem;color:#555;margin-top:6px">Always verify with official CAA / NATS sources.</p>
<span class="tag ${tagClass}">${tagLabel}</span>
`;
document.getElementById('popup').classList.remove('hidden');
}
// ── Location tracking ─────────────────────────────────────────────────────────
function createLocationElement() {
const el = document.createElement('div');
el.className = 'location-dot';
el.innerHTML = '<div class="location-pulse"></div><div class="location-center"></div>';
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: '© <a href="https://bournemouthtechnology.co.uk" target="_blank" rel="noopener">Bournemouth Technology</a>',
}), '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 = `<span style="color:#f87171">${msg}</span>`;
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();
});
})();