878 lines
36 KiB
JavaScript
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();
|
|
});
|
|
})();
|