From 8dd0cd24d65887fe4c91424626843c912b6dfe82 Mon Sep 17 00:00:00 2001 From: Stephan Besser Date: Tue, 24 May 2022 16:47:52 +0200 Subject: [PATCH] Fixes openaip/openaip#11 --- src/airspace-factory.js | 76 +++++++++++++++ src/tokenizer.js | 6 ++ src/tokens/ah-token.js | 4 +- src/tokens/al-token.js | 4 +- src/tokens/dy-token.js | 49 ++++++++++ src/tokens/skipped-token.js | 4 +- src/tokens/vw-token.js | 45 +++++++++ src/tokens/vx-token.js | 4 +- tests/fixtures/airway.txt | 7 ++ tests/fixtures/multiple-airspaces.txt | 9 ++ tests/parser.test.js | 133 ++++++++++++++++++++++++++ 11 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 src/tokens/dy-token.js create mode 100644 src/tokens/vw-token.js create mode 100644 tests/fixtures/airway.txt diff --git a/src/airspace-factory.js b/src/airspace-factory.js index e642d8b..6cc55ec 100644 --- a/src/airspace-factory.js +++ b/src/airspace-factory.js @@ -7,9 +7,11 @@ const AlToken = require('./tokens/al-token'); const DpToken = require('./tokens/dp-token'); const VdToken = require('./tokens/vd-token'); const VxToken = require('./tokens/vx-token'); +const VwToken = require('./tokens/vw-token'); const DcToken = require('./tokens/dc-token'); const DbToken = require('./tokens/db-token'); const DaToken = require('./tokens/da-token'); +const DyToken = require('./tokens/dy-token'); const EofToken = require('./tokens/eof-token'); const BaseLineToken = require('./tokens/base-line-token'); const checkTypes = require('check-types'); @@ -18,6 +20,8 @@ const { lineArc: createArc, bearing: calcBearing, distance: calcDistance, + buffer: createBuffer, + lineString: createLineString, } = require('@turf/turf'); const Airspace = require('./airspace'); const ParserError = require('./parser-error'); @@ -47,6 +51,14 @@ class AirspaceFactory { this.currentLineNumber = null; // set to true if airspace contains tokens other than "skipped, blanks or comment" this.hasBuildTokens = false; + this.isAirway = false; + // metadata required to build an airspace from a width and airways segment coordinates + this._airway = { + // width in nautical miles as read from VW token + width: null, + // airway segment coordinates read from DY tokens + segments: [], + }; } /** @@ -72,6 +84,14 @@ class AirspaceFactory { } this.airspace.consumedTokens.push(token); } + // if airspace is build from airway definitions, an additional step is required that creates the actual + // airspace's polygon geometry + if (this.isAirway) { + this.airspace.coordinates = this.buildCoordinatesFromAirway({ + width: this._airway.width, + segments: this._airway.segments, + }); + } const airspace = this.airspace; this.tokens = null; @@ -80,6 +100,19 @@ class AirspaceFactory { return this.hasBuildTokens ? airspace : null; } + /** + * @param {{width: number, segments: Array}} options + * @returns {Array} + */ + buildCoordinatesFromAirway({ width, segments }) { + const airwayPathFeature = createLineString(segments); + const bufferKm = width * 1.852; + const airwayPolygonFeature = createBuffer(airwayPathFeature, bufferKm, { units: 'kilometers' }); + const [coordinates] = airwayPolygonFeature.geometry.coordinates; + + return coordinates; + } + /** * @param {typedefs.openaip.OpenairParser.Token} token */ @@ -106,12 +139,18 @@ class AirspaceFactory { case DpToken.type: this.handleDpToken(token); break; + case DyToken.type: + this.handleDyToken(token); + break; case VdToken.type: this.handleVdToken(token); break; case VxToken.type: this.handleVxToken(token); break; + case VwToken.type: + this.handleVwToken(token); + break; case DcToken.type: this.handleDcToken(token); break; @@ -273,6 +312,43 @@ class AirspaceFactory { checkTypes.assert.instance(token, VxToken); } + /** + * Sets airway width in nautical miles. + * + * @param {typedefs.openaip.OpenairParser.Token} token + * @return {void} + * @private + */ + handleVwToken(token) { + checkTypes.assert.instance(token, VwToken); + + const { metadata } = token.getTokenized(); + const { width } = metadata; + + // IMPORTANT indicate that we are building an airspace from airway definition + this.isAirway = true; + this._airway.width = width; + } + + /** + * Sets airway segment. + * + * @param {typedefs.openaip.OpenairParser.Token} token + * @return {void} + * @private + */ + handleDyToken(token) { + checkTypes.assert.instance(token, DyToken); + + const { metadata } = token.getTokenized(); + const { coordinate } = metadata; + + checkTypes.assert.nonEmptyObject(coordinate); + + // IMPORTANT subsequently push airway segment coordinates + this._airway.segments.push(this.toArrayLike(coordinate)); + } + /** * Creates a circle geometry from the last VToken coordinate and a DcToken radius. * diff --git a/src/tokenizer.js b/src/tokenizer.js index f1fb03a..0cfac5c 100644 --- a/src/tokenizer.js +++ b/src/tokenizer.js @@ -8,9 +8,11 @@ const AlToken = require('./tokens/al-token'); const DpToken = require('./tokens/dp-token'); const VdToken = require('./tokens/vd-token'); const VxToken = require('./tokens/vx-token'); +const VwToken = require('./tokens/vw-token'); const DcToken = require('./tokens/dc-token'); const DbToken = require('./tokens/db-token'); const DaToken = require('./tokens/da-token'); +const DyToken = require('./tokens/dy-token'); const EofToken = require('./tokens/eof-token'); const LineByLine = require('n-readlines'); const fs = require('fs'); @@ -48,9 +50,11 @@ const TOKEN_TYPES = { DP_TOKEN: DpToken.type, VD_TOKEN: VdToken.type, VX_TOKEN: VxToken.type, + VW_TOKEN: VwToken.type, DC_TOKEN: DcToken.type, DB_TOKEN: DbToken.type, DA_TOKEN: DaToken.type, + DY_TOKEN: DyToken.type, EOF_TOKEN: EofToken.type, SKIPPED_TOKEN: SkippedToken.type, }; @@ -107,9 +111,11 @@ class Tokenizer { new DpToken({ tokenTypes: TOKEN_TYPES }), new VdToken({ tokenTypes: TOKEN_TYPES }), new VxToken({ tokenTypes: TOKEN_TYPES }), + new VwToken({ tokenTypes: TOKEN_TYPES }), new DcToken({ tokenTypes: TOKEN_TYPES }), new DbToken({ tokenTypes: TOKEN_TYPES }), new DaToken({ tokenTypes: TOKEN_TYPES }), + new DyToken({ tokenTypes: TOKEN_TYPES }), ]; /** @type {typedefs.openaip.OpenairParser.Token[]} */ this.tokens = []; diff --git a/src/tokens/ah-token.js b/src/tokens/ah-token.js index d23341a..aae9b0a 100644 --- a/src/tokens/ah-token.js +++ b/src/tokens/ah-token.js @@ -47,9 +47,9 @@ class AhToken extends BaseAltitudeToken { } getAllowedNextTokens() { - const { COMMENT_TOKEN, AL_TOKEN, DP_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN } = this.tokenTypes; + const { COMMENT_TOKEN, AL_TOKEN, DP_TOKEN, VW_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN } = this.tokenTypes; - return [COMMENT_TOKEN, AL_TOKEN, DP_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN]; + return [COMMENT_TOKEN, AL_TOKEN, DP_TOKEN, VW_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN]; } } diff --git a/src/tokens/al-token.js b/src/tokens/al-token.js index 68ccd8a..2aae586 100644 --- a/src/tokens/al-token.js +++ b/src/tokens/al-token.js @@ -47,9 +47,9 @@ class AlToken extends BaseAltitudeToken { } getAllowedNextTokens() { - const { COMMENT_TOKEN, AH_TOKEN, DP_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN } = this.tokenTypes; + const { COMMENT_TOKEN, AH_TOKEN, DP_TOKEN, VW_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN } = this.tokenTypes; - return [COMMENT_TOKEN, AH_TOKEN, DP_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN]; + return [COMMENT_TOKEN, AH_TOKEN, DP_TOKEN, VW_TOKEN, VX_TOKEN, SKIPPED_TOKEN, VD_TOKEN]; } } diff --git a/src/tokens/dy-token.js b/src/tokens/dy-token.js new file mode 100644 index 0000000..9443570 --- /dev/null +++ b/src/tokens/dy-token.js @@ -0,0 +1,49 @@ +const BaseLineToken = require('./base-line-token'); +const checkTypes = require('check-types'); +const Coordinates = require('coordinate-parser'); +const ParserError = require('../parser-error'); + +/** + * Tokenizes "DY" airway segment coordinate definition. + */ +class DyToken extends BaseLineToken { + static type = 'DY'; + + canHandle(line) { + checkTypes.assert.string(line); + + // is DP line e.g. "DY 54:25:00 N 010:40:00 E" + return /^DY\s+.*$/.test(line); + } + + tokenize(line, lineNumber) { + const token = new DyToken({ tokenTypes: this.tokenTypes }); + + checkTypes.assert.string(line); + checkTypes.assert.integer(lineNumber); + + // remove inline comments + line = line.replace(/\s?\*.*/, ''); + // extract coordinate pair + const linePartCoordinate = line.replace(/^DY\s+/, ''); + + let coordinate; + try { + coordinate = new Coordinates(linePartCoordinate); + } catch (e) { + throw new ParserError({ lineNumber, errorMessage: `Unknown coordinate definition '${line}'` }); + } + + token.tokenized = { line, lineNumber, metadata: { coordinate } }; + + return token; + } + + getAllowedNextTokens() { + const { COMMENT_TOKEN, DY_TOKEN, BLANK_TOKEN, EOF_TOKEN, SKIPPED_TOKEN } = this.tokenTypes; + + return [COMMENT_TOKEN, DY_TOKEN, BLANK_TOKEN, EOF_TOKEN, SKIPPED_TOKEN]; + } +} + +module.exports = DyToken; diff --git a/src/tokens/skipped-token.js b/src/tokens/skipped-token.js index 8b0e46f..35e0028 100644 --- a/src/tokens/skipped-token.js +++ b/src/tokens/skipped-token.js @@ -15,11 +15,11 @@ class SkippedToken extends CommentToken { checkTypes.assert.string(line); // line contains a skipped token - return /^(AT|TO|TC|SP|SB|DY).*$/.test(line); + return /^(AT|TO|TC|SP|SB).*$/.test(line); } tokenize(line, lineNumber) { - const token = new CommentToken({ tokenTypes: this.tokenTypes }); + const token = new SkippedToken({ tokenTypes: this.tokenTypes }); checkTypes.assert.string(line); checkTypes.assert.integer(lineNumber); diff --git a/src/tokens/vw-token.js b/src/tokens/vw-token.js new file mode 100644 index 0000000..256d868 --- /dev/null +++ b/src/tokens/vw-token.js @@ -0,0 +1,45 @@ +const BaseLineToken = require('./base-line-token'); +const checkTypes = require('check-types'); +const ParserError = require('../parser-error'); + +/** + * Tokenizes "V W=" airway width in nautical miles. + */ +class VwToken extends BaseLineToken { + static type = 'VW'; + + canHandle(line) { + checkTypes.assert.string(line); + + // is W line e.g. "V W=2.5" + return /^V\s+W=.*$/.test(line); + } + + tokenize(line, lineNumber) { + const token = new VwToken({ tokenTypes: this.tokenTypes }); + + checkTypes.assert.string(line); + checkTypes.assert.integer(lineNumber); + + // remove inline comments + line = line.replace(/\s?\*.*/, ''); + const linePartWidth = line.replace(/^V\s+[W]=/, ''); + + const isWidth = /^\d+(\.\d+)?$/.test(linePartWidth); + if (!isWidth) { + throw new ParserError({ lineNumber, errorMessage: `Unknown airway width definition '${line}'` }); + } + + token.tokenized = { line, lineNumber, metadata: { width: parseFloat(linePartWidth) } }; + + return token; + } + + getAllowedNextTokens() { + const { COMMENT_TOKEN, DY_TOKEN, BLANK_TOKEN, EOF_TOKEN, SKIPPED_TOKEN } = this.tokenTypes; + + return [COMMENT_TOKEN, DY_TOKEN, BLANK_TOKEN, EOF_TOKEN, SKIPPED_TOKEN]; + } +} + +module.exports = VwToken; diff --git a/src/tokens/vx-token.js b/src/tokens/vx-token.js index 0bc66c6..3215ab4 100644 --- a/src/tokens/vx-token.js +++ b/src/tokens/vx-token.js @@ -4,7 +4,7 @@ const Coordinates = require('coordinate-parser'); const ParserError = require('../parser-error'); /** - * Tokenizes "V" airspace circle center coordinate definition. + * Tokenizes "V X=" airspace circle center coordinate definition. */ class VxToken extends BaseLineToken { static type = 'VX'; @@ -24,7 +24,7 @@ class VxToken extends BaseLineToken { // remove inline comments line = line.replace(/\s?\*.*/, ''); - const linePartCoordinate = line.replace(/^V\s+X=/, ''); + const linePartCoordinate = line.replace(/^V\s+[X]=/, ''); let coordinate; try { diff --git a/tests/fixtures/airway.txt b/tests/fixtures/airway.txt new file mode 100644 index 0000000..569e771 --- /dev/null +++ b/tests/fixtures/airway.txt @@ -0,0 +1,7 @@ +AC P +AN Axe 1 +AL FL090 +AH FL140 +V W=2.5 +DY 44:17:00 N 004:59:00 E +DY 44:19:30 N 005:05:00 E diff --git a/tests/fixtures/multiple-airspaces.txt b/tests/fixtures/multiple-airspaces.txt index 72dcfe7..750b972 100644 --- a/tests/fixtures/multiple-airspaces.txt +++ b/tests/fixtures/multiple-airspaces.txt @@ -83,6 +83,15 @@ DP 34:50:30 N 138:46:49 E DP 34:52:36 N 138:46:49 E DP 35:08:28 N 138:41:31 E +* Airway definition +AC P +AN Axe 1 +AL FL090 +AH FL140 +V W=2.5 +DY 44:17:00 N 004:59:00 E +DY 44:19:30 N 005:05:00 E + * * AIP SUP 140/20 * Temporary Training Area (Shizuhama) diff --git a/tests/parser.test.js b/tests/parser.test.js index 9183c96..e4ea49d 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -1135,6 +1135,65 @@ describe('test parse complete airspace definition blocks', () => { ], }, }, + { + type: 'Feature', + properties: { + name: 'Axe 1', + class: 'P', + upperCeiling: { + value: 140, + unit: 'FL', + referenceDatum: 'STD', + }, + lowerCeiling: { + value: 90, + unit: 'FL', + referenceDatum: 'STD', + }, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [5.112570608206531, 44.28899972331213], + [5.121822393404271, 44.293771664549226], + [5.129597395072883, 44.299743303712745], + [5.135596760563085, 44.30668548087831], + [5.139589529267002, 44.314331705394636], + [5.141421570080589, 44.322388352324204], + [5.141021609463724, 44.330545913471425], + [5.138404102591787, 44.33849087564102], + [5.133668817205852, 44.345917772116714], + [5.126997124925046, 44.35254094375764], + [5.118645123378475, 44.35810555439286], + [5.108933839313692, 44.36239743147385], + [5.098236882223024, 44.36525134658038], + [5.086966024313204, 44.36655740992672], + [5.075555270463559, 44.366265326261406], + [5.064444046536528, 44.36438634356669], + [5.054060172490205, 44.36099281723478], + [4.954019651367301, 44.31930059287128], + [4.94477750519178, 44.314515115166344], + [4.937019369431093, 44.30853095388841], + [4.931043315374702, 44.30157840419458], + [4.927078585259396, 44.29392494133622], + [4.925276846065786, 44.28586489716996], + [4.925706467250688, 44.27770811669027], + [4.928350026033288, 44.26976803542963], + [4.933105115844873, 44.26234963822004], + [4.9397884057428545, 44.25573776142085], + [4.9481427759983045, 44.2501861846778], + [4.957847242023225, 44.24590792563693], + [4.96852927885222, 44.24306710338908], + [4.979779074278334, 44.24177267580793], + [4.991165172472977, 44.24207428473905], + [5.002250922871211, 44.24396036381114], + [5.012611122136857, 44.24735857923959], + [5.112570608206531, 44.28899972331213], + ], + ], + }, + }, { type: 'Feature', properties: { @@ -1172,6 +1231,80 @@ describe('test parse complete airspace definition blocks', () => { // remove feature id for comparison geojson.features.map((value) => delete value.id); + expect(geojson).toEqual(expectedJson); + }); + test('parse custom airway definition', async () => { + const openairParser = new Parser(); + await openairParser.parse('./tests/fixtures/airway.txt'); + const geojson = openairParser.toGeojson(); + + const expectedJson = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'Axe 1', + class: 'P', + upperCeiling: { + value: 140, + unit: 'FL', + referenceDatum: 'STD', + }, + lowerCeiling: { + value: 90, + unit: 'FL', + referenceDatum: 'STD', + }, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [5.112570608206531, 44.28899972331213], + [5.121822393404271, 44.293771664549226], + [5.129597395072883, 44.299743303712745], + [5.135596760563085, 44.30668548087831], + [5.139589529267002, 44.314331705394636], + [5.141421570080589, 44.322388352324204], + [5.141021609463724, 44.330545913471425], + [5.138404102591787, 44.33849087564102], + [5.133668817205852, 44.345917772116714], + [5.126997124925046, 44.35254094375764], + [5.118645123378475, 44.35810555439286], + [5.108933839313692, 44.36239743147385], + [5.098236882223024, 44.36525134658038], + [5.086966024313204, 44.36655740992672], + [5.075555270463559, 44.366265326261406], + [5.064444046536528, 44.36438634356669], + [5.054060172490205, 44.36099281723478], + [4.954019651367301, 44.31930059287128], + [4.94477750519178, 44.314515115166344], + [4.937019369431093, 44.30853095388841], + [4.931043315374702, 44.30157840419458], + [4.927078585259396, 44.29392494133622], + [4.925276846065786, 44.28586489716996], + [4.925706467250688, 44.27770811669027], + [4.928350026033288, 44.26976803542963], + [4.933105115844873, 44.26234963822004], + [4.9397884057428545, 44.25573776142085], + [4.9481427759983045, 44.2501861846778], + [4.957847242023225, 44.24590792563693], + [4.96852927885222, 44.24306710338908], + [4.979779074278334, 44.24177267580793], + [4.991165172472977, 44.24207428473905], + [5.002250922871211, 44.24396036381114], + [5.012611122136857, 44.24735857923959], + [5.112570608206531, 44.28899972331213], + ], + ], + }, + }, + ], + }; + // remove feature id for comparison + geojson.features.map((value) => delete value.id); + expect(geojson).toEqual(expectedJson); }); });