'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(''); 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('BASELINE')) continue; const block = member.substring(start); // ── Properties ────────────────────────────────────────────────────────── // type is within AirspaceTimeSlice; take the first match const typeM = block.match(/([^<]+)<\/aixm:type>/); if (!typeM) continue; const type = typeM[1].trim(); if (!RELEVANT_TYPES.has(type)) continue; const name = first(block, /([^<]+)<\/aixm:name>/); const designator = first(block, /([^<]+)<\/aixm:designator>/); const localType = first(block, /([^<]+)<\/aixm:localType>/); const upperLimit = first(block, /]*>([^<]+)<\/aixm:upperLimit>/); const upperUom = first(block, /([^<]+)<\/aixm:upperLimitReference>/); const lowerLimit = first(block, /]*>([^<]+)<\/aixm:lowerLimit>/); const lowerUom = first(block, /([^<]+)<\/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('', sStart); if (sEnd === -1) return null; const surface = block.substring(sStart, sEnd + 15); const ring = []; // Walk through each curveMember const cmRe = /]*>([\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(']*>([\d.\-]+)\s+([\d.\-]+)<\/gml:pos>/); const radM = seg.match(/]*>([\d.\-]+)<\/gml:radius>/); const uomM = seg.match(/]*>([\d.\-]+)\s+([\d.\-]+)<\/gml:pos>/); const radM = seg.match(/]*>([\d.\-]+)<\/gml:radius>/); const uomM = seg.match(/]*>([\d.\-]+)<\/gml:startAngle>/); const endM = seg.match(/]*>([\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(/]*>([\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 = /]*>([\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() : ''; }