@squawk/airspace 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @squawk/airspace
2
+
3
+ Pure logic library for querying US airspace geometry. Given a position and altitude,
4
+ returns all applicable airspace designations. Contains no bundled data - accepts a
5
+ GeoJSON dataset at initialization. For zero-config use, pair with `@squawk/airspace-data`.
6
+
7
+ ## Coverage
8
+
9
+ - Class B, C, D, and E controlled airspace (E2 through E7 subtypes)
10
+ - Special Use Airspace: MOAs, restricted, prohibited, warning, alert, and national security areas
11
+
12
+ ## Usage
13
+
14
+ ```typescript
15
+ import { usBundledAirspace } from '@squawk/airspace-data';
16
+ import { createAirspaceResolver } from '@squawk/airspace';
17
+
18
+ const resolve = createAirspaceResolver({ data: usBundledAirspace });
19
+
20
+ // Query a position and altitude
21
+ const features = resolve({ lat: 33.9425, lon: -118.4081, altitudeFt: 3000 });
22
+
23
+ for (const f of features) {
24
+ console.log(f.type, f.name, f.identifier);
25
+ }
26
+ ```
27
+
28
+ Consumers who have their own GeoJSON airspace data can use this package standalone:
29
+
30
+ ```typescript
31
+ import { createAirspaceResolver } from '@squawk/airspace';
32
+
33
+ const resolve = createAirspaceResolver({ data: myGeoJson });
34
+ ```
35
+
36
+ ## How it works
37
+
38
+ `createAirspaceResolver` parses the GeoJSON FeatureCollection at initialization and
39
+ returns a resolver function. Each call to the resolver performs two checks per
40
+ feature:
41
+
42
+ 1. **Lateral** - a ray casting point-in-polygon test against the feature boundary
43
+ 2. **Vertical** - altitude comparison against floor and ceiling bounds
44
+
45
+ All matching features are returned as `AirspaceFeature` objects (from `@squawk/types`).
46
+
47
+ ## AGL altitude handling
48
+
49
+ Some airspace features have floor or ceiling bounds referenced to AGL (above ground
50
+ level) rather than MSL. Converting AGL to MSL requires terrain elevation data that
51
+ this library does not include.
52
+
53
+ The resolver handles AGL bounds conservatively: when it cannot determine the MSL
54
+ equivalent, it **includes** the feature rather than silently excluding it. This means
55
+ the resolver may return features whose AGL bounds do not actually contain the
56
+ queried altitude.
57
+
58
+ Consumers can inspect the `reference` field on the returned `AltitudeBound` objects
59
+ and apply their own terrain lookup if needed:
60
+
61
+ ```typescript
62
+ for (const f of features) {
63
+ if (f.floor.reference === 'AGL') {
64
+ // This feature's floor is AGL - apply terrain data if available
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## API
70
+
71
+ ### `createAirspaceResolver(options)`
72
+
73
+ Creates a resolver function from a GeoJSON dataset.
74
+
75
+ **Parameters:**
76
+
77
+ - `options.data` - a GeoJSON `FeatureCollection` with airspace features
78
+
79
+ **Returns:** `AirspaceResolver` - a function with signature
80
+ `(query: AirspaceQuery) => AirspaceFeature[]`
81
+
82
+ ### `AirspaceQuery`
83
+
84
+ | Property | Type | Description |
85
+ | ------------ | -------------------------- | ------------------------------------------------------------------ |
86
+ | `lat` | number | Latitude in decimal degrees (WGS84) |
87
+ | `lon` | number | Longitude in decimal degrees (WGS84) |
88
+ | `altitudeFt` | number | Altitude in feet MSL |
89
+ | `types` | ReadonlySet\<AirspaceType> | Optional. When provided, only features of these types are returned |
90
+
91
+ ```typescript
92
+ // Only query tower-controlled airspace (exclude Class E and SUA)
93
+ const controlled = resolve({
94
+ lat: 33.9425,
95
+ lon: -118.4081,
96
+ altitudeFt: 3000,
97
+ types: new Set(['CLASS_B', 'CLASS_C', 'CLASS_D']),
98
+ });
99
+ ```
@@ -0,0 +1,3 @@
1
+ export { createAirspaceResolver } from './resolver.js';
2
+ export type { AirspaceResolver, AirspaceResolverOptions, AirspaceQuery } from './resolver.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createAirspaceResolver } from './resolver.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Tests whether a point is inside a polygon using the ray casting algorithm.
3
+ * The polygon is represented as an array of [lon, lat] coordinate pairs
4
+ * forming a closed ring (first and last points are identical).
5
+ *
6
+ * Returns true if the point is inside or on the boundary of the polygon.
7
+ */
8
+ export declare function pointInPolygon(
9
+ /** Longitude of the test point in decimal degrees. */
10
+ x: number,
11
+ /** Latitude of the test point in decimal degrees. */
12
+ y: number,
13
+ /** Polygon exterior ring as [lon, lat] coordinate pairs. */
14
+ ring: number[][]): boolean;
15
+ //# sourceMappingURL=point-in-polygon.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"point-in-polygon.d.ts","sourceRoot":"","sources":["../src/point-in-polygon.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,cAAc;AAC5B,sDAAsD;AACtD,CAAC,EAAE,MAAM;AACT,qDAAqD;AACrD,CAAC,EAAE,MAAM;AACT,4DAA4D;AAC5D,IAAI,EAAE,MAAM,EAAE,EAAE,GACf,OAAO,CAgBT"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Tests whether a point is inside a polygon using the ray casting algorithm.
3
+ * The polygon is represented as an array of [lon, lat] coordinate pairs
4
+ * forming a closed ring (first and last points are identical).
5
+ *
6
+ * Returns true if the point is inside or on the boundary of the polygon.
7
+ */
8
+ export function pointInPolygon(
9
+ /** Longitude of the test point in decimal degrees. */
10
+ x,
11
+ /** Latitude of the test point in decimal degrees. */
12
+ y,
13
+ /** Polygon exterior ring as [lon, lat] coordinate pairs. */
14
+ ring) {
15
+ let inside = false;
16
+ const len = ring.length;
17
+ for (let i = 0, j = len - 1; i < len; j = i++) {
18
+ const xi = ring[i][0];
19
+ const yi = ring[i][1];
20
+ const xj = ring[j][0];
21
+ const yj = ring[j][1];
22
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
23
+ inside = !inside;
24
+ }
25
+ }
26
+ return inside;
27
+ }
@@ -0,0 +1,62 @@
1
+ import type { FeatureCollection } from 'geojson';
2
+ import type { AirspaceFeature, AirspaceType } from '@squawk/types';
3
+ /**
4
+ * A query describing a geographic position and altitude to resolve
5
+ * against loaded airspace data.
6
+ */
7
+ export interface AirspaceQuery {
8
+ /** Latitude in decimal degrees (WGS84). */
9
+ lat: number;
10
+ /** Longitude in decimal degrees (WGS84). */
11
+ lon: number;
12
+ /** Altitude in feet MSL to compare against airspace vertical bounds. */
13
+ altitudeFt: number;
14
+ /**
15
+ * Optional set of airspace types to include in the results. When provided,
16
+ * only features whose type is in this set are considered. Features of other
17
+ * types are skipped before any geometry or altitude checks, improving query
18
+ * performance when only specific airspace classes are needed.
19
+ *
20
+ * When omitted, all airspace types are included.
21
+ */
22
+ types?: ReadonlySet<AirspaceType>;
23
+ }
24
+ /**
25
+ * Options for creating an airspace resolver.
26
+ */
27
+ export interface AirspaceResolverOptions {
28
+ /** GeoJSON FeatureCollection containing airspace features. */
29
+ data: FeatureCollection;
30
+ }
31
+ /**
32
+ * A function that accepts a position and altitude and returns all
33
+ * airspace features that contain that point laterally and vertically.
34
+ */
35
+ export type AirspaceResolver = (query: AirspaceQuery) => AirspaceFeature[];
36
+ /**
37
+ * Creates a stateless airspace resolver function. The resolver accepts a
38
+ * GeoJSON FeatureCollection at initialization (typically from
39
+ * `@squawk/airspace-data`) and returns a function that, given a position
40
+ * and altitude, returns all matching airspace features.
41
+ *
42
+ * The resolver performs two checks for each feature:
43
+ * 1. **Lateral** - point-in-polygon test against the feature boundary
44
+ * 2. **Vertical** - altitude comparison against floor/ceiling bounds
45
+ *
46
+ * AGL-referenced altitude bounds are handled conservatively: when the
47
+ * resolver cannot determine the MSL equivalent (because it has no terrain
48
+ * data), it includes the feature rather than silently excluding it. This
49
+ * means the resolver may return features whose AGL bounds do not actually
50
+ * contain the queried altitude. Consumers can inspect the returned
51
+ * AltitudeBound references and apply their own terrain lookup if needed.
52
+ *
53
+ * ```typescript
54
+ * import { usBundledAirspace } from '@squawk/airspace-data';
55
+ * import { createAirspaceResolver } from '@squawk/airspace';
56
+ *
57
+ * const resolve = createAirspaceResolver({ data: usBundledAirspace });
58
+ * const features = resolve({ lat: 33.9425, lon: -118.4081, altitudeFt: 3000 });
59
+ * ```
60
+ */
61
+ export declare function createAirspaceResolver(options: AirspaceResolverOptions): AirspaceResolver;
62
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../src/resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAC;AACnE,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAiB,MAAM,eAAe,CAAC;AAIlF;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,WAAW,CAAC,YAAY,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,8DAA8D;IAC9D,IAAI,EAAE,iBAAiB,CAAC;CACzB;AAED;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,eAAe,EAAE,CAAC;AA+F3E;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,uBAAuB,GAAG,gBAAgB,CAqCzF"}
@@ -0,0 +1,117 @@
1
+ import { pointInPolygon } from './point-in-polygon.js';
2
+ import { altitudeMatches } from './vertical-filter.js';
3
+ /**
4
+ * Computes an axis-aligned bounding box from a polygon exterior ring.
5
+ */
6
+ function computeBoundingBox(ring) {
7
+ let minLon = Infinity;
8
+ let maxLon = -Infinity;
9
+ let minLat = Infinity;
10
+ let maxLat = -Infinity;
11
+ for (const coord of ring) {
12
+ const lon = coord[0];
13
+ const lat = coord[1];
14
+ if (lon < minLon) {
15
+ minLon = lon;
16
+ }
17
+ if (lon > maxLon) {
18
+ maxLon = lon;
19
+ }
20
+ if (lat < minLat) {
21
+ minLat = lat;
22
+ }
23
+ if (lat > maxLat) {
24
+ maxLat = lat;
25
+ }
26
+ }
27
+ return { minLon, maxLon, minLat, maxLat };
28
+ }
29
+ /**
30
+ * Parses a GeoJSON Feature into an IndexedFeature, extracting the
31
+ * AirspaceFeature properties and polygon ring. Returns null if the
32
+ * feature cannot be parsed (missing geometry, invalid type, etc.).
33
+ */
34
+ function parseFeature(geoFeature) {
35
+ const geom = geoFeature.geometry;
36
+ if (!geom || geom.type !== 'Polygon') {
37
+ return null;
38
+ }
39
+ const polygon = geom;
40
+ const ring = polygon.coordinates[0];
41
+ if (!ring || ring.length < 4) {
42
+ return null;
43
+ }
44
+ const props = geoFeature.properties;
45
+ if (!props) {
46
+ return null;
47
+ }
48
+ const feature = {
49
+ type: props.type,
50
+ name: props.name ?? '',
51
+ identifier: props.identifier ?? '',
52
+ floor: props.floor,
53
+ ceiling: props.ceiling,
54
+ boundary: polygon,
55
+ state: props.state ?? null,
56
+ controllingFacility: props.controllingFacility ?? null,
57
+ scheduleDescription: props.scheduleDescription ?? null,
58
+ };
59
+ return { feature, ring, boundingBox: computeBoundingBox(ring) };
60
+ }
61
+ /**
62
+ * Creates a stateless airspace resolver function. The resolver accepts a
63
+ * GeoJSON FeatureCollection at initialization (typically from
64
+ * `@squawk/airspace-data`) and returns a function that, given a position
65
+ * and altitude, returns all matching airspace features.
66
+ *
67
+ * The resolver performs two checks for each feature:
68
+ * 1. **Lateral** - point-in-polygon test against the feature boundary
69
+ * 2. **Vertical** - altitude comparison against floor/ceiling bounds
70
+ *
71
+ * AGL-referenced altitude bounds are handled conservatively: when the
72
+ * resolver cannot determine the MSL equivalent (because it has no terrain
73
+ * data), it includes the feature rather than silently excluding it. This
74
+ * means the resolver may return features whose AGL bounds do not actually
75
+ * contain the queried altitude. Consumers can inspect the returned
76
+ * AltitudeBound references and apply their own terrain lookup if needed.
77
+ *
78
+ * ```typescript
79
+ * import { usBundledAirspace } from '@squawk/airspace-data';
80
+ * import { createAirspaceResolver } from '@squawk/airspace';
81
+ *
82
+ * const resolve = createAirspaceResolver({ data: usBundledAirspace });
83
+ * const features = resolve({ lat: 33.9425, lon: -118.4081, altitudeFt: 3000 });
84
+ * ```
85
+ */
86
+ export function createAirspaceResolver(options) {
87
+ const indexed = [];
88
+ for (const geoFeature of options.data.features) {
89
+ const parsed = parseFeature(geoFeature);
90
+ if (parsed) {
91
+ indexed.push(parsed);
92
+ }
93
+ }
94
+ return (query) => {
95
+ const results = [];
96
+ const { lon, lat, altitudeFt, types } = query;
97
+ for (const { feature, ring, boundingBox } of indexed) {
98
+ if (types && !types.has(feature.type)) {
99
+ continue;
100
+ }
101
+ if (lon < boundingBox.minLon ||
102
+ lon > boundingBox.maxLon ||
103
+ lat < boundingBox.minLat ||
104
+ lat > boundingBox.maxLat) {
105
+ continue;
106
+ }
107
+ if (!pointInPolygon(lon, lat, ring)) {
108
+ continue;
109
+ }
110
+ if (!altitudeMatches(altitudeFt, feature.floor, feature.ceiling)) {
111
+ continue;
112
+ }
113
+ results.push(feature);
114
+ }
115
+ return results;
116
+ };
117
+ }
@@ -0,0 +1,23 @@
1
+ import type { AltitudeBound } from '@squawk/types';
2
+ /**
3
+ * Tests whether an altitude in feet MSL falls within the vertical bounds
4
+ * defined by a floor and ceiling AltitudeBound pair.
5
+ *
6
+ * Altitude reference handling:
7
+ * - `SFC` - treated as 0 ft MSL (surface level)
8
+ * - `MSL` - compared directly against the queried altitude
9
+ * - `AGL` - the vertical check is skipped for that bound (returns true),
10
+ * because converting AGL to MSL requires terrain elevation data that this
11
+ * library does not have. This is a conservative approach: the feature is
12
+ * included rather than silently excluded when the bound cannot be resolved.
13
+ * Consumers can inspect the returned AltitudeBound references and apply
14
+ * their own terrain lookup if needed.
15
+ */
16
+ export declare function altitudeMatches(
17
+ /** Queried altitude in feet MSL. */
18
+ altitudeFt: number,
19
+ /** Lower vertical bound of the airspace feature. */
20
+ floor: AltitudeBound,
21
+ /** Upper vertical bound of the airspace feature. */
22
+ ceiling: AltitudeBound): boolean;
23
+ //# sourceMappingURL=vertical-filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vertical-filter.d.ts","sourceRoot":"","sources":["../src/vertical-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe;AAC7B,oCAAoC;AACpC,UAAU,EAAE,MAAM;AAClB,oDAAoD;AACpD,KAAK,EAAE,aAAa;AACpB,oDAAoD;AACpD,OAAO,EAAE,aAAa,GACrB,OAAO,CAaT"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tests whether an altitude in feet MSL falls within the vertical bounds
3
+ * defined by a floor and ceiling AltitudeBound pair.
4
+ *
5
+ * Altitude reference handling:
6
+ * - `SFC` - treated as 0 ft MSL (surface level)
7
+ * - `MSL` - compared directly against the queried altitude
8
+ * - `AGL` - the vertical check is skipped for that bound (returns true),
9
+ * because converting AGL to MSL requires terrain elevation data that this
10
+ * library does not have. This is a conservative approach: the feature is
11
+ * included rather than silently excluded when the bound cannot be resolved.
12
+ * Consumers can inspect the returned AltitudeBound references and apply
13
+ * their own terrain lookup if needed.
14
+ */
15
+ export function altitudeMatches(
16
+ /** Queried altitude in feet MSL. */
17
+ altitudeFt,
18
+ /** Lower vertical bound of the airspace feature. */
19
+ floor,
20
+ /** Upper vertical bound of the airspace feature. */
21
+ ceiling) {
22
+ const floorFt = resolveAltitude(floor);
23
+ const ceilingFt = resolveAltitude(ceiling);
24
+ // If either bound is AGL (resolved as null), skip that side of the check.
25
+ if (floorFt !== null && altitudeFt < floorFt) {
26
+ return false;
27
+ }
28
+ if (ceilingFt !== null && altitudeFt > ceilingFt) {
29
+ return false;
30
+ }
31
+ return true;
32
+ }
33
+ /**
34
+ * Resolves an AltitudeBound to a feet MSL value for comparison, or null
35
+ * if the bound uses AGL reference and cannot be resolved without terrain data.
36
+ */
37
+ function resolveAltitude(bound) {
38
+ switch (bound.reference) {
39
+ case 'SFC':
40
+ return 0;
41
+ case 'MSL':
42
+ return bound.valueFt;
43
+ case 'AGL':
44
+ return null;
45
+ default:
46
+ return null;
47
+ }
48
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@squawk/airspace",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Pure logic library for querying US airspace geometry by position and altitude",
6
+ "author": "Neil Cochran",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/neilcochran/squawk",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/neilcochran/squawk.git",
12
+ "directory": "packages/airspace"
13
+ },
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "typedocMain": "src/index.ts",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "import": "./dist/index.js",
23
+ "types": "./dist/index.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "!dist/**/*.spec.*"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "test": "node --test 'dist/**/*.spec.js'",
33
+ "lint": "tsc --noEmit && eslint src"
34
+ },
35
+ "dependencies": {
36
+ "@squawk/types": "*"
37
+ },
38
+ "devDependencies": {
39
+ "@squawk/airspace-data": "*",
40
+ "@types/node": "^25.5.2"
41
+ },
42
+ "keywords": [
43
+ "aviation",
44
+ "typescript",
45
+ "airspace",
46
+ "geojson",
47
+ "class-b",
48
+ "class-c",
49
+ "sua",
50
+ "faa",
51
+ "nasr"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }