215 lines
9.7 KiB
JavaScript
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() : '';
|
|
}
|