Files
DroneMapUK/nats-worker.js

215 lines
9.7 KiB
JavaScript

'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() : '';
}