Working drone map solution with up to date data
This commit is contained in:
1027
airspace-uk.geojson
Normal file
1027
airspace-uk.geojson
Normal file
File diff suppressed because one or more lines are too long
805
app.js
Normal file
805
app.js
Normal file
@@ -0,0 +1,805 @@
|
||||
'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}`,
|
||||
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 onLocationUpdate(pos) {
|
||||
const { longitude: lon, latitude: lat, accuracy } = pos.coords;
|
||||
const locateBtn = document.getElementById('locate-btn');
|
||||
locateBtn.classList.remove('locating');
|
||||
locateBtn.classList.add('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();
|
||||
alert(`Location error: ${err.message}`);
|
||||
}
|
||||
|
||||
function startTracking() {
|
||||
if (!navigator.geolocation) { alert('Geolocation not supported by this browser.'); return; }
|
||||
document.getElementById('locate-btn').classList.add('locating');
|
||||
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');
|
||||
}
|
||||
|
||||
// ── 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: true,
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'bottom-right');
|
||||
|
||||
map.on('load', () => {
|
||||
addAllLayers();
|
||||
document.getElementById('key-modal').classList.add('hidden');
|
||||
loadNATSLocal();
|
||||
});
|
||||
|
||||
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;
|
||||
map.setStyle(STYLES[styleKey](apiKey));
|
||||
map.once('style.load', () => addAllLayers());
|
||||
}
|
||||
|
||||
// ── 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.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.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();
|
||||
});
|
||||
})();
|
||||
178
index.html
Normal file
178
index.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>DroneMapUK — UK Airspace</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css">
|
||||
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<!-- API key setup modal -->
|
||||
<div id="key-modal">
|
||||
<div class="modal-box">
|
||||
<h2>MapTiler API Key</h2>
|
||||
<p>Get a free key at <a href="https://maptiler.com" target="_blank" rel="noopener">maptiler.com</a> then paste it below.</p>
|
||||
<input id="key-input" type="text" placeholder="Your MapTiler API key" autocomplete="off" spellcheck="false">
|
||||
<button id="key-submit">Load Map</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar panel -->
|
||||
<div id="panel">
|
||||
<div id="panel-header">
|
||||
<span class="panel-title">DroneMapUK</span>
|
||||
<button id="panel-toggle" title="Toggle panel">☰</button>
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
|
||||
<!-- ── Airspace restrictions ── -->
|
||||
<div class="section-label">AIRSPACE</div>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="nats" checked>
|
||||
<span class="dot dot-nats"></span>
|
||||
<span>NATS AIP Airspace</span>
|
||||
</label>
|
||||
<p class="layer-note" id="nats-status">Loading…</p>
|
||||
<div id="nats-progress" class="progress-bar hidden">
|
||||
<div id="nats-progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<div class="nats-type-grid">
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="R" checked><span class="tc-r">R</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="P" checked><span class="tc-r">P</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="D" checked><span class="tc-d">D</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="D_OTHER" checked><span class="tc-d">D_OTH</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="CTR" checked><span class="tc-ctr">CTR</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="CTA" checked><span class="tc-cta">CTA</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="TMA" checked><span class="tc-cta">TMA</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="RAS" checked><span class="tc-ras">RAS</span></label>
|
||||
<label class="type-check"><input type="checkbox" data-nats-type="OTHER:TMZ" checked><span class="tc-tmz">TMZ</span></label>
|
||||
</div>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="frz" checked>
|
||||
<span class="dot dot-frz"></span>
|
||||
<span>Flight Restriction Zones</span>
|
||||
</label>
|
||||
<p class="layer-note">5 km radius around licensed aerodromes (approx.)</p>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="danger" checked>
|
||||
<span class="dot dot-danger"></span>
|
||||
<span>Danger / Restricted Areas</span>
|
||||
</label>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="military" checked>
|
||||
<span class="dot dot-military"></span>
|
||||
<span>MoD / Military Areas</span>
|
||||
</label>
|
||||
<p class="layer-note">Override: load GeoJSON below</p>
|
||||
|
||||
<!-- ── Protected nature ── -->
|
||||
<div class="section-label" style="margin-top:12px">PROTECTED AREAS</div>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="national-parks" checked>
|
||||
<span class="dot dot-parks"></span>
|
||||
<span>National Parks (GB)</span>
|
||||
</label>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="royal-parks" checked>
|
||||
<span class="dot dot-royal"></span>
|
||||
<span>Royal Parks (London)</span>
|
||||
</label>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="sssi" checked>
|
||||
<span class="dot dot-sssi"></span>
|
||||
<span>SSSIs</span>
|
||||
</label>
|
||||
<p class="layer-note" id="sssi-status">England: auto-loaded at zoom ≥ 8 · Scotland/Wales: load file</p>
|
||||
|
||||
<!-- ── Managed land ── -->
|
||||
<div class="section-label" style="margin-top:12px">MANAGED LAND</div>
|
||||
|
||||
<label class="layer-row">
|
||||
<input type="checkbox" data-layer="forestry">
|
||||
<span class="dot dot-forestry"></span>
|
||||
<span>Forestry managed land</span>
|
||||
</label>
|
||||
<p class="layer-note">Forestry England / FLS / NRW — load GeoJSON below</p>
|
||||
|
||||
<!-- ── Map style ── -->
|
||||
<div class="section-label" style="margin-top:12px">MAP STYLE</div>
|
||||
<div class="style-row">
|
||||
<button class="style-btn active" data-style="outdoor">Terrain</button>
|
||||
<button class="style-btn" data-style="satellite">Satellite</button>
|
||||
<button class="style-btn" data-style="streets">Streets</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<span>3D Terrain</span>
|
||||
<label class="switch" title="Toggle 3D terrain">
|
||||
<input type="checkbox" id="toggle-3d">
|
||||
<span class="switch-track"><span class="switch-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<span>Hillshade <span class="hint">(best on Satellite)</span></span>
|
||||
<label class="switch" title="Toggle hillshade terrain overlay">
|
||||
<input type="checkbox" id="toggle-hillshade">
|
||||
<span class="switch-track"><span class="switch-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ── Load data ── -->
|
||||
<div class="section-label" style="margin-top:12px">LOAD DATA</div>
|
||||
|
||||
<p class="layer-note" style="margin-top:6px">Other GeoJSON layers:</p>
|
||||
<div class="load-row">
|
||||
<select id="layer-select" class="layer-select">
|
||||
<option value="danger">Danger / Restricted (override)</option>
|
||||
<option value="military">Military Areas (override)</option>
|
||||
<option value="forestry">Forestry managed land</option>
|
||||
<option value="sssi">SSSIs (Scotland / Wales)</option>
|
||||
</select>
|
||||
<button class="load-btn" id="geojson-btn">Load GeoJSON…</button>
|
||||
</div>
|
||||
<input type="file" id="geojson-file" accept=".geojson,.json" style="display:none">
|
||||
|
||||
<div class="data-links">
|
||||
<a href="https://nats-uk.ead-it.com/cms-nats/opencms/en/uas-restriction-zones/" target="_blank" rel="noopener">NATS UAS Zones</a> ·
|
||||
<a href="https://data-forestry.opendata.arcgis.com/" target="_blank" rel="noopener">Forestry England</a> ·
|
||||
<a href="https://www.arcgis.com/apps/webappviewer/index.html?id=e4b9f5f437474e0481a3cec097e4c2ec" target="_blank" rel="noopener">FLS Scotland</a> ·
|
||||
<a href="https://datamap.gov.wales/" target="_blank" rel="noopener">NRW Wales</a> ·
|
||||
<a href="https://naturalengland-defra.opendata.arcgis.com/" target="_blank" rel="noopener">Natural England</a>
|
||||
</div>
|
||||
|
||||
<div class="disclaimer">
|
||||
⚠ Planning aid only. Always check NOTAMs and official CAA sources before any flight.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locate button -->
|
||||
<button id="locate-btn" title="Track my location">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
||||
<circle cx="12" cy="12" r="9" stroke-dasharray="3 3" opacity=".4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Click info popup -->
|
||||
<div id="popup" class="hidden">
|
||||
<button id="popup-close">✕</button>
|
||||
<div id="popup-body"></div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
214
nats-worker.js
Normal file
214
nats-worker.js
Normal file
@@ -0,0 +1,214 @@
|
||||
'use strict';
|
||||
|
||||
// ── NATS AIXM 5.1 XML parser Web Worker ──────────────────────────────────────
|
||||
// Runs off the main thread to avoid freezing the UI on the 72 MB file.
|
||||
// Accepts: { buffer: ArrayBuffer }
|
||||
// Returns: { type:'progress', percent } or { type:'done', geojson } or { type:'error', message }
|
||||
|
||||
self.onmessage = function (e) {
|
||||
try {
|
||||
const text = new TextDecoder('utf-8').decode(e.data.buffer);
|
||||
const geojson = parseAIXM(text);
|
||||
self.postMessage({ type: 'done', geojson });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'error', message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ── Airspace types relevant to drone operations ───────────────────────────────
|
||||
// FIR excluded: it is one giant polygon covering all UK airspace — useless on a drone map.
|
||||
const RELEVANT_TYPES = new Set([
|
||||
'R', // Restricted
|
||||
'P', // Prohibited
|
||||
'CTR', // Control zone (immediately around aerodromes)
|
||||
'CTA', // Control area
|
||||
'TMA', // Terminal manoeuvring area
|
||||
'RAS', // Radar Advisory Service area
|
||||
'OTHER:TMZ', // Transponder Mandatory Zone
|
||||
// D / D_OTHER (Danger) excluded — UK data contains enormous small-arms ranges
|
||||
// that cover huge swathes of the country and swamp the map. Danger areas are
|
||||
// covered separately via the user-loaded GeoJSON layer.
|
||||
]);
|
||||
|
||||
// ── Main parse function ───────────────────────────────────────────────────────
|
||||
function parseAIXM(text) {
|
||||
const features = [];
|
||||
|
||||
// Slice the document into hasMember chunks — avoids running complex regex
|
||||
// over the full 72 MB string all at once.
|
||||
const members = text.split('</message:hasMember>');
|
||||
const total = members.length;
|
||||
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
const member = members[i];
|
||||
|
||||
// Progress every 250 chunks
|
||||
if (i % 250 === 0) {
|
||||
self.postMessage({ type: 'progress', percent: Math.round((i / total) * 100) });
|
||||
}
|
||||
|
||||
// Fast bail-out: only process members that contain an Airspace element
|
||||
if (!member.includes('<aixm:Airspace ')) continue;
|
||||
|
||||
// Extract the Airspace element from within this member
|
||||
const start = member.indexOf('<aixm:Airspace ');
|
||||
if (start === -1) continue;
|
||||
|
||||
// Only process BASELINE timeslices
|
||||
if (!member.includes('<aixm:interpretation>BASELINE</aixm:interpretation>')) continue;
|
||||
|
||||
const block = member.substring(start);
|
||||
|
||||
// ── Properties ──────────────────────────────────────────────────────────
|
||||
// type is within AirspaceTimeSlice; take the first match
|
||||
const typeM = block.match(/<aixm:type>([^<]+)<\/aixm:type>/);
|
||||
if (!typeM) continue;
|
||||
const type = typeM[1].trim();
|
||||
if (!RELEVANT_TYPES.has(type)) continue;
|
||||
|
||||
const name = first(block, /<aixm:name>([^<]+)<\/aixm:name>/);
|
||||
const designator = first(block, /<aixm:designator>([^<]+)<\/aixm:designator>/);
|
||||
const localType = first(block, /<aixm:localType>([^<]+)<\/aixm:localType>/);
|
||||
const upperLimit = first(block, /<aixm:upperLimit[^>]*>([^<]+)<\/aixm:upperLimit>/);
|
||||
const upperUom = first(block, /<aixm:upperLimit uom="([^"]+)"/);
|
||||
const upperRef = first(block, /<aixm:upperLimitReference>([^<]+)<\/aixm:upperLimitReference>/);
|
||||
const lowerLimit = first(block, /<aixm:lowerLimit[^>]*>([^<]+)<\/aixm:lowerLimit>/);
|
||||
const lowerUom = first(block, /<aixm:lowerLimit uom="([^"]+)"/);
|
||||
const lowerRef = first(block, /<aixm:lowerLimitReference>([^<]+)<\/aixm:lowerLimitReference>/);
|
||||
|
||||
// ── Geometry ─────────────────────────────────────────────────────────────
|
||||
const geometry = parseGeometry(block);
|
||||
if (!geometry) continue;
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { name, designator, localType, type, upperLimit, upperUom, upperRef, lowerLimit, lowerUom, lowerRef },
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
|
||||
self.postMessage({ type: 'progress', percent: 100 });
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
// ── Geometry parser ───────────────────────────────────────────────────────────
|
||||
function parseGeometry(block) {
|
||||
// Find the horizontal projection Surface
|
||||
const sStart = block.indexOf('<aixm:Surface ');
|
||||
if (sStart === -1) return null;
|
||||
const sEnd = block.indexOf('</aixm:Surface>', sStart);
|
||||
if (sEnd === -1) return null;
|
||||
const surface = block.substring(sStart, sEnd + 15);
|
||||
|
||||
const ring = [];
|
||||
|
||||
// Walk through each curveMember
|
||||
const cmRe = /<gml:curveMember[^>]*>([\s\S]*?)<\/gml:curveMember>/g;
|
||||
let cm;
|
||||
while ((cm = cmRe.exec(surface)) !== null) {
|
||||
const seg = cm[1];
|
||||
appendSegment(seg, ring);
|
||||
}
|
||||
|
||||
if (ring.length < 3) return null;
|
||||
|
||||
// Close the ring
|
||||
const first0 = ring[0], last = ring[ring.length - 1];
|
||||
if (first0[0] !== last[0] || first0[1] !== last[1]) ring.push([...first0]);
|
||||
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
function appendSegment(seg, ring) {
|
||||
if (seg.includes('<gml:CircleByCenterPoint')) {
|
||||
// ── Full circle ──────────────────────────────────────────────────────────
|
||||
const posM = seg.match(/<gml:pos[^>]*>([\d.\-]+)\s+([\d.\-]+)<\/gml:pos>/);
|
||||
const radM = seg.match(/<gml:radius[^>]*>([\d.\-]+)<\/gml:radius>/);
|
||||
const uomM = seg.match(/<gml:radius uom="([^"]+)"/);
|
||||
if (!posM || !radM) return;
|
||||
const lat = parseFloat(posM[1]), lon = parseFloat(posM[2]);
|
||||
const km = toKm(parseFloat(radM[1]), uomM ? uomM[1] : '[nmi_i]');
|
||||
ring.push(...circleCoords(lon, lat, km, 64));
|
||||
|
||||
} else if (seg.includes('<gml:ArcByCenterPoint')) {
|
||||
// ── Arc segment ──────────────────────────────────────────────────────────
|
||||
// GML angles: counterclockwise from east (math convention).
|
||||
// Bearing = (90 - mathAngle + 360) % 360
|
||||
const posM = seg.match(/<gml:pos[^>]*>([\d.\-]+)\s+([\d.\-]+)<\/gml:pos>/);
|
||||
const radM = seg.match(/<gml:radius[^>]*>([\d.\-]+)<\/gml:radius>/);
|
||||
const uomM = seg.match(/<gml:radius uom="([^"]+)"/);
|
||||
const startM = seg.match(/<gml:startAngle[^>]*>([\d.\-]+)<\/gml:startAngle>/);
|
||||
const endM = seg.match(/<gml:endAngle[^>]*>([\d.\-]+)<\/gml:endAngle>/);
|
||||
if (!posM || !radM || !startM || !endM) return;
|
||||
const lat = parseFloat(posM[1]), lon = parseFloat(posM[2]);
|
||||
const km = toKm(parseFloat(radM[1]), uomM ? uomM[1] : '[nmi_i]');
|
||||
const sa = parseFloat(startM[1]), ea = parseFloat(endM[1]);
|
||||
ring.push(...arcCoords(lon, lat, km, sa, ea, 32));
|
||||
|
||||
} else {
|
||||
// ── GeodesicString / LineStringSegment ───────────────────────────────────
|
||||
// gml:posList: flat sequence "lat1 lon1 lat2 lon2 ..."
|
||||
const plM = seg.match(/<gml:posList[^>]*>([\s\S]*?)<\/gml:posList>/);
|
||||
if (plM) {
|
||||
const vals = plM[1].trim().split(/\s+/);
|
||||
for (let i = 0; i + 1 < vals.length; i += 2) {
|
||||
ring.push([parseFloat(vals[i + 1]), parseFloat(vals[i])]);
|
||||
}
|
||||
} else {
|
||||
// Individual gml:pos elements inside pointProperty/Point
|
||||
const posRe = /<gml:pos[^>]*>([\d.\-]+)\s+([\d.\-]+)<\/gml:pos>/g;
|
||||
let pt;
|
||||
while ((pt = posRe.exec(seg)) !== null) {
|
||||
ring.push([parseFloat(pt[2]), parseFloat(pt[1])]); // flip to [lon, lat]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geometry utilities ────────────────────────────────────────────────────────
|
||||
function toKm(value, uom) {
|
||||
if (uom === '[nmi_i]') return value * 1.852;
|
||||
if (uom === 'M') return value / 1000;
|
||||
if (uom === 'KM') return value;
|
||||
return value * 1.852; // assume nmi
|
||||
}
|
||||
|
||||
// Great-circle destination point given start (lat/lon in degrees), bearing (deg CW from N), distance (km)
|
||||
function destPoint(lat, lon, bearingDeg, distKm) {
|
||||
const R = 6371;
|
||||
const d = distKm / R;
|
||||
const b = bearingDeg * Math.PI / 180;
|
||||
const φ1 = lat * Math.PI / 180;
|
||||
const λ1 = lon * Math.PI / 180;
|
||||
const φ2 = Math.asin(Math.sin(φ1) * Math.cos(d) + Math.cos(φ1) * Math.sin(d) * Math.cos(b));
|
||||
const λ2 = λ1 + Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(φ1), Math.cos(d) - Math.sin(φ1) * Math.sin(φ2));
|
||||
return [λ2 * 180 / Math.PI, φ2 * 180 / Math.PI]; // [lon, lat] for GeoJSON
|
||||
}
|
||||
|
||||
function circleCoords(lon, lat, radiusKm, steps) {
|
||||
const pts = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
pts.push(destPoint(lat, lon, (i / steps) * 360, radiusKm));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function arcCoords(lon, lat, radiusKm, startMathDeg, endMathDeg, steps) {
|
||||
// GML CCW-from-east → sweep counterclockwise; always positive
|
||||
let sweep = endMathDeg - startMathDeg;
|
||||
if (sweep <= 0) sweep += 360;
|
||||
|
||||
const pts = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const mathAngle = startMathDeg + (sweep * i / steps);
|
||||
const bearing = ((90 - mathAngle) % 360 + 360) % 360; // CW from N
|
||||
pts.push(destPoint(lat, lon, bearing, radiusKm));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// ── Regex helper ──────────────────────────────────────────────────────────────
|
||||
function first(text, re) {
|
||||
const m = text.match(re);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
260
style.css
Normal file
260
style.css
Normal file
@@ -0,0 +1,260 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #111; color: #e8e8e8; overflow: hidden; height: 100dvh; }
|
||||
|
||||
#map { position: absolute; inset: 0; }
|
||||
|
||||
/* ── Modal ── */
|
||||
#key-modal {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,.85);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#key-modal.hidden { display: none; }
|
||||
|
||||
.modal-box {
|
||||
background: #1e2027; border: 1px solid #333; border-radius: 10px;
|
||||
padding: 28px 32px; width: min(420px, 92vw);
|
||||
}
|
||||
.modal-box h2 { font-size: 1.15rem; margin-bottom: 8px; color: #fff; }
|
||||
.modal-box p { font-size: .85rem; color: #aaa; margin-bottom: 16px; line-height: 1.5; }
|
||||
.modal-box a { color: #5aacff; text-decoration: none; }
|
||||
.modal-box a:hover { text-decoration: underline; }
|
||||
.modal-box input {
|
||||
width: 100%; padding: 10px 12px; border-radius: 6px;
|
||||
border: 1px solid #444; background: #111; color: #fff;
|
||||
font-size: .9rem; margin-bottom: 12px; outline: none;
|
||||
}
|
||||
.modal-box input:focus { border-color: #5aacff; }
|
||||
.modal-box button {
|
||||
width: 100%; padding: 10px; border-radius: 6px; border: none;
|
||||
background: #2563eb; color: #fff; font-size: .9rem; font-weight: 600;
|
||||
cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.modal-box button:hover { background: #1d4ed8; }
|
||||
|
||||
/* ── Panel ── */
|
||||
#panel {
|
||||
position: absolute; top: 12px; left: 12px; z-index: 100;
|
||||
width: 264px; background: #1e2027cc; backdrop-filter: blur(10px);
|
||||
border: 1px solid #333; border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
#panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid #2a2d38;
|
||||
}
|
||||
.panel-title { font-weight: 700; font-size: .95rem; letter-spacing: .5px; }
|
||||
#panel-toggle {
|
||||
background: none; border: none; color: #aaa; font-size: 1.1rem;
|
||||
cursor: pointer; line-height: 1; padding: 2px 4px; border-radius: 4px;
|
||||
}
|
||||
#panel-toggle:hover { background: #ffffff15; color: #fff; }
|
||||
|
||||
#panel-body {
|
||||
padding: 12px 14px 14px;
|
||||
max-height: calc(100dvh - 90px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
#panel-body.collapsed { display: none; }
|
||||
|
||||
.section-label {
|
||||
font-size: .62rem; font-weight: 700; letter-spacing: 1.3px;
|
||||
color: #555; margin: 8px 0 5px; text-transform: uppercase;
|
||||
border-top: 1px solid #2a2d38; padding-top: 8px;
|
||||
}
|
||||
.section-label:first-child { border-top: none; padding-top: 0; margin-top: 0; }
|
||||
|
||||
.layer-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
cursor: pointer; padding: 3px 2px; font-size: .83rem; color: #ddd;
|
||||
user-select: none;
|
||||
}
|
||||
.layer-row input[type=checkbox] { width: 15px; height: 15px; accent-color: #2563eb; cursor: pointer; flex-shrink: 0; }
|
||||
.layer-row span:last-child { flex: 1; }
|
||||
|
||||
/* ── Dots ── */
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-nats { background: linear-gradient(135deg, #ef4444 50%, #60a5fa 50%); }
|
||||
.dot-frz { background: #ef4444; }
|
||||
.dot-danger { background: #f97316; }
|
||||
.dot-military { background: #64748b; }
|
||||
.dot-parks { background: #22c55e; }
|
||||
.dot-royal { background: #a855f7; }
|
||||
.dot-sssi { background: #06b6d4; }
|
||||
.dot-forestry { background: #4d7c0f; }
|
||||
|
||||
.layer-note {
|
||||
font-size: .7rem; color: #555; margin: 1px 0 4px 23px; line-height: 1.4;
|
||||
}
|
||||
.layer-note a { color: #5aacff; text-decoration: none; }
|
||||
.layer-note a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Style row ── */
|
||||
.style-row { display: flex; gap: 6px; margin-top: 4px; }
|
||||
.style-btn {
|
||||
flex: 1; padding: 6px 4px; border-radius: 6px; border: 1px solid #333;
|
||||
background: #2a2d38; color: #aaa; font-size: .75rem; cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.style-btn:hover { background: #34384a; color: #fff; }
|
||||
.style-btn.active { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||
|
||||
/* ── Toggle rows (3D, Hillshade) ── */
|
||||
.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 7px; padding: 2px 1px; font-size: .82rem; color: #ccc;
|
||||
}
|
||||
.hint { font-size: .68rem; color: #555; }
|
||||
|
||||
.switch { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch-track {
|
||||
position: absolute; inset: 0; border-radius: 20px;
|
||||
background: #2a2d38; border: 1px solid #444;
|
||||
transition: background .2s, border-color .2s; cursor: pointer;
|
||||
}
|
||||
.switch input:checked ~ .switch-track { background: #2563eb; border-color: #2563eb; }
|
||||
.switch-thumb {
|
||||
position: absolute; top: 2px; left: 2px;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: #888; transition: transform .2s, background .2s;
|
||||
}
|
||||
.switch input:checked ~ .switch-track .switch-thumb { transform: translateX(16px); background: #fff; }
|
||||
|
||||
/* ── Load data ── */
|
||||
.load-row { display: flex; gap: 6px; margin-top: 4px; align-items: center; }
|
||||
.layer-select {
|
||||
flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid #333;
|
||||
background: #2a2d38; color: #ccc; font-size: .75rem; cursor: pointer;
|
||||
appearance: none; outline: none;
|
||||
}
|
||||
.layer-select:focus { border-color: #555; }
|
||||
|
||||
.load-btn {
|
||||
flex-shrink: 0; padding: 6px 10px; border-radius: 6px;
|
||||
border: 1px solid #333; background: #2a2d38;
|
||||
color: #ccc; font-size: .75rem; cursor: pointer; white-space: nowrap;
|
||||
transition: background .15s;
|
||||
}
|
||||
.load-btn:hover { background: #34384a; color: #fff; }
|
||||
|
||||
/* ── Progress bar ── */
|
||||
.progress-bar {
|
||||
height: 4px; border-radius: 2px; background: #2a2d38;
|
||||
overflow: hidden; margin-bottom: 4px;
|
||||
}
|
||||
.progress-bar.hidden { display: none; }
|
||||
.progress-fill {
|
||||
height: 100%; border-radius: 2px;
|
||||
background: linear-gradient(90deg, #f59e0b, #ef4444);
|
||||
width: 0%; transition: width .2s ease;
|
||||
}
|
||||
|
||||
/* ── NATS type filter grid ── */
|
||||
.nats-type-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2px 4px; margin: 3px 0 5px 22px;
|
||||
}
|
||||
.type-check {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
font-size: .7rem; cursor: pointer; user-select: none; color: #999;
|
||||
}
|
||||
.type-check input[type=checkbox] { width: 11px; height: 11px; cursor: pointer; flex-shrink: 0; }
|
||||
.tc-r { color: #ef4444; font-weight: 600; }
|
||||
.tc-d { color: #f97316; font-weight: 600; }
|
||||
.tc-ctr { color: #facc15; font-weight: 600; }
|
||||
.tc-cta { color: #60a5fa; font-weight: 600; }
|
||||
.tc-ras { color: #818cf8; font-weight: 600; }
|
||||
.tc-tmz { color: #c084fc; font-weight: 600; }
|
||||
|
||||
.data-links {
|
||||
margin-top: 7px; font-size: .68rem; color: #555; line-height: 1.9;
|
||||
}
|
||||
.data-links a { color: #5aacff; text-decoration: none; }
|
||||
.data-links a:hover { text-decoration: underline; }
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 12px; padding: 9px 10px; border-radius: 6px;
|
||||
background: #2a2206; border: 1px solid #5a4200;
|
||||
font-size: .7rem; color: #c9a84c; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Locate button ── */
|
||||
#locate-btn {
|
||||
position: absolute; bottom: 30px; right: 12px; z-index: 100;
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
border: 1px solid #333; background: #1e2027cc; backdrop-filter: blur(10px);
|
||||
color: #ccc; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: all .15s;
|
||||
}
|
||||
#locate-btn:hover { background: #2a2d38cc; color: #fff; }
|
||||
#locate-btn.locating { color: #2563eb; animation: btn-pulse 1s infinite; }
|
||||
#locate-btn.tracking { color: #2563eb; border-color: #2563eb44; background: #1a2f5acc; }
|
||||
#locate-btn.tracking:hover { background: #1d3560cc; }
|
||||
#locate-btn svg { width: 20px; height: 20px; }
|
||||
|
||||
@keyframes btn-pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
|
||||
/* ── Location dot marker ── */
|
||||
.location-dot { position: relative; width: 22px; height: 22px; }
|
||||
.location-pulse {
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
background: rgba(37,99,235,.35);
|
||||
animation: loc-pulse 2.2s ease-out infinite;
|
||||
}
|
||||
.location-center {
|
||||
position: absolute; top: 4px; left: 4px;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: #2563eb; border: 2.5px solid #fff;
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,.55);
|
||||
}
|
||||
@keyframes loc-pulse {
|
||||
0% { transform: scale(.5); opacity: 1; }
|
||||
100% { transform: scale(2.8); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Popup ── */
|
||||
#popup {
|
||||
position: absolute; bottom: 84px; right: 12px; z-index: 100;
|
||||
width: min(280px, calc(100vw - 24px));
|
||||
background: #1e2027ee; backdrop-filter: blur(10px);
|
||||
border: 1px solid #333; border-radius: 10px; padding: 14px 16px;
|
||||
}
|
||||
#popup.hidden { display: none; }
|
||||
#popup-close {
|
||||
position: absolute; top: 10px; right: 12px;
|
||||
background: none; border: none; color: #666; font-size: .85rem;
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
#popup-close:hover { color: #fff; }
|
||||
#popup-body h3 { font-size: .9rem; margin-bottom: 6px; color: #fff; padding-right: 20px; }
|
||||
#popup-body p { font-size: .8rem; color: #aaa; line-height: 1.5; margin-bottom: 4px; }
|
||||
#popup-body .tag {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||
font-size: .7rem; font-weight: 600; margin-top: 6px;
|
||||
}
|
||||
.tag-nats-r { background: #7f1d1d; color: #fca5a5; }
|
||||
.tag-nats-d { background: #7c2d12; color: #fdba74; }
|
||||
.tag-nats-ctr { background: #1e3a5f; color: #93c5fd; }
|
||||
.tag-nats-cta { background: #1e3a5f; color: #93c5fd; }
|
||||
.tag-nats-ras { background: #2e1065; color: #c4b5fd; }
|
||||
.tag-nats-tmz { background: #3b0764; color: #e9d5ff; }
|
||||
.tag-nats-fir { background: #1c1917; color: #d6d3d1; }
|
||||
.tag-frz { background: #7f1d1d; color: #fca5a5; }
|
||||
.tag-danger { background: #7c2d12; color: #fdba74; }
|
||||
.tag-military { background: #1e293b; color: #94a3b8; }
|
||||
.tag-parks { background: #14532d; color: #86efac; }
|
||||
.tag-royal { background: #4a1d96; color: #d8b4fe; }
|
||||
.tag-sssi { background: #164e63; color: #67e8f9; }
|
||||
.tag-forestry { background: #1a2e05; color: #86efac; }
|
||||
|
||||
/* ── MapLibre overrides ── */
|
||||
.maplibregl-ctrl-attrib { background: #000000aa !important; color: #666 !important; font-size: .65rem !important; }
|
||||
.maplibregl-ctrl-attrib a { color: #888 !important; }
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 480px) {
|
||||
#panel { width: calc(100vw - 24px); top: 8px; left: 8px; right: 8px; }
|
||||
#locate-btn { bottom: 20px; right: 8px; }
|
||||
#popup { right: 8px; bottom: 74px; width: calc(100vw - 16px); }
|
||||
}
|
||||
Reference in New Issue
Block a user