Compare commits

...

2 Commits

Author SHA1 Message Date
2bdbb583a8 Begin Refactor + Webapp 2025-07-13 22:53:20 +02:00
2c99b701ce Types AF;RF 2025-07-13 22:51:43 +02:00
51 changed files with 3208 additions and 45 deletions

20
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Parser Node",
"skipFiles": ["<node_internals>/**"],
"args": [
"--experimental-strip-types",
"${workspaceFolder}\\browser\\src\\parser\\node.ts"
],
"cwd": "${workspaceFolder}\\browser\\",
"outFiles": ["${workspaceFolder}/**/*.js"]
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"cSpell.words": ["intc"]
}

View File

@ -27,7 +27,8 @@ LGAV BIBE1K SID (Cycle 2507, ID 10653)
(`NavID`, `NavLat`, `NavLon`, `NavDist`, `NavBear`).
- Arc radius shall be `NavDist`.
- Arc center shall be navaid identified by `NavID`, `NavLat`, `NavLon`.
- `Course` shall be the boundary radial on which the arc begins (preceding fix lies hereupon).
- `Course` shall be the boundary radial on which the arc begins, measured from the navaid
identified by (`NavID`, `NavLat`, `NavLon`, `NavDist`, `NavBear`).
- Arc and turn shall be flown in direction specified by `TurnDir`.
### Units
@ -512,7 +513,6 @@ LFRN GODA5R SID (cycle 2507, ID 10485)
### Instructions
- From preceding fix, fly a smooth arc to the fix identified by (`WptID`, `WptLat`, `WptLon`).
- Arc radius shall be `NavDist`.
- Arc center shall be navaid identified by `CenterID`, `CenterLat`, `CenterLon`.
- Arc and turn shall be flown in direction specified by `TurnDir`.
- `Distance` shall be the track miles along the curved path

24
browser/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

69
browser/README.md Normal file
View File

@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
browser/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
browser/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MD-11 NavData Browser</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

38
browser/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "browser",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"parser": "node --experimental-strip-types src/parser/node.ts"
},
"dependencies": {
"geojson": "^0.5.0",
"geolib": "^3.3.4",
"leaflet": "^1.9.4",
"magvar": "^2.0.0",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"react-leaflet": "5.0.0-rc.2"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/leaflet": "^1.9.20",
"@types/object-hash": "^3.0.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"object-hash": "^3.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}

1931
browser/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

45
browser/src/App.tsx Normal file
View File

@ -0,0 +1,45 @@
import { MapContainer, GeoJSON, TileLayer } from "react-leaflet";
import Parser from "./parser/parser";
import { useEffect, useState } from "react";
import hash from "object-hash";
const parser = await Parser.instance();
function App() {
const [procedure, setProcedure] = useState<string>();
console.log(procedure);
useEffect(() => {
(async () => {
setProcedure(await parser.parse(10394));
})();
}, []);
return (
<div style={{ display: "flex", height: "100vh", width: "100vw" }}>
<MapContainer
center={[51.505, -0.09]}
zoom={13}
style={{ height: "100%", width: "100%" }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<GeoJSON
key={hash(procedure ?? "")}
data={procedure}
style={{
color: "#00ffff",
stroke: true,
weight: 2,
opacity: 1,
}}
/>
</MapContainer>
<div></div>
</div>
);
}
export default App;

8
browser/src/index.css Normal file
View File

@ -0,0 +1,8 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}

12
browser/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "leaflet/dist/leaflet.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@ -0,0 +1,16 @@
import Parser from "./parser.ts";
// mutate fetch to be local
// @ts-ignore
fetch = async (path: string) => {
// @ts-ignore
const fs = await import("fs");
return {
json: () => JSON.parse(fs.readFileSync(`public/${path}`)),
};
};
const parser = await Parser.instance();
console.log(JSON.stringify(await parser.parse(10394)));

View File

@ -0,0 +1,148 @@
import "./utils/extensions.ts";
import { handleTurnAtFix } from "./utils/handleTurnAtFix.ts";
import * as geolib from "geolib";
import geojson from "geojson";
class Parser {
private static _instance: Parser;
private waypoints: Waypoint[];
private runways: Runway[];
private terminals: Terminal[];
public static AC_SPEED = 250;
private constructor(
waypoints: Waypoint[],
runways: Runway[],
terminals: Terminal[]
) {
this.waypoints = waypoints;
this.runways = runways;
this.terminals = terminals;
}
public static instance = async () => {
if (!Parser._instance) {
const waypoints = await (await fetch("navdata/Waypoints.json")).json();
const runways = await (await fetch("navdata/Runways.json")).json();
const terminals = await (await fetch("navdata/Terminals.json")).json();
Parser._instance = new Parser(waypoints, runways, terminals);
}
return Parser._instance;
};
public parse = async (terminalID: number) => {
// Private functions
/**
* @param line Line segments
*/
const updateLastCourse = (line: LineSegment[]) => {
lastCourse = geolib.getGreatCircleBearing(
{
latitude: line.at(-2)![1],
longitude: line.at(-2)![0],
},
{
latitude: line.at(-1)![1],
longitude: line.at(-1)![0],
}
);
};
// Get Procedure main
const terminal = this.terminals.find(({ ID }) => ID === terminalID);
if (!terminal) throw new Error("Procedure does not exists");
// Get runway this procedure is for
const runway = this.runways.find(({ ID }) => ID === terminal.RwyID);
if (!runway) throw new Error("Procedure links to non existent Runway");
// Load procedure
const procedure = (await (
await fetch(`navdata/TermID_${terminalID}.json`)
).json()) as TerminalEntry[];
// Output variables
const navFixes: NavFix[] = [];
const lineSegments: { line: LineSegment[] }[] = [];
// Initials
navFixes.push({
latitude: runway.Latitude,
longitude: runway.Longitude,
altitude: runway.Elevation,
speed: 0,
name: runway.Ident,
});
let lastCourse = runway.TrueHeading;
// Main
for (let index = 0; index < procedure.length; index++) {
const leg = procedure[index];
const previousFix = navFixes.at(-1)!;
const waypoint = this.waypoints.filter(({ ID }) => ID === leg.WptID)[0];
switch (leg.TrackCode) {
case "AF":
case "CA":
case "CD":
break;
case "CF": {
const _leg = leg as CFTerminalEntry;
const targetFix: NavFix = {
latitude: _leg.WptLat,
longitude: _leg.WptLon,
name: waypoint?.Ident ?? undefined,
"marker-color": _leg.IsFlyOver ? "#ff0000" : undefined,
isFlyOver: _leg.IsFlyOver,
altitude: previousFix.altitude,
};
navFixes.push(targetFix);
const line = handleTurnAtFix(
_leg.Course.toTrue(previousFix),
_leg.Course.toTrue(previousFix),
lastCourse,
previousFix,
targetFix,
_leg.TurnDir
);
lineSegments.push({ line });
updateLastCourse(lineSegments.at(-1)!.line);
break;
}
case "CI":
case "CR":
case "DF":
case "FA":
case "FC":
case "FD":
case "FM":
case "HA":
case "HF":
case "HM":
case "IF":
case "PI":
case "RF":
case "TF":
case "VA":
case "VD":
case "VI":
case "VM":
case "VR":
default:
console.error("Unknown TrackCode", leg.TrackCode);
break;
}
}
return geojson.parse([...navFixes, ...lineSegments], {
Point: ["latitude", "longitude"],
LineString: "line",
});
};
}
export default Parser;

View File

@ -0,0 +1 @@
export const TerminatorsAF = () => {};

View File

@ -0,0 +1,89 @@
/**
* @param p1 Point 1
* @param brng1 Bearing from Point 1
* @param p2 Point 2
* @param brng2 bearing from Point 2
* @returns Intersection point
*/
export const computeIntersection = (
p1: NavFix,
brng1: number,
p2: NavFix,
brng2: number
): NavFix | null => {
if (isNaN(brng1)) throw new TypeError(`invalid brng1 ${brng1}`);
if (isNaN(brng2)) throw new TypeError(`invalid brng2 ${brng2}`);
const π = Math.PI;
// see www.edwilliams.org/avform.htm#Intersection
const φ1 = p1.latitude.toRadians(),
λ1 = p1.longitude.toRadians();
const φ2 = p2.latitude.toRadians(),
λ2 = p2.longitude.toRadians();
const θ13 = Number(brng1).toRadians(),
θ23 = Number(brng2).toRadians();
const Δφ = φ2 - φ1,
Δλ = λ2 - λ1;
// angular distance p1-p2
const δ12 =
2 *
Math.asin(
Math.sqrt(
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
)
);
if (Math.abs(δ12) < Number.EPSILON) return p1; // coincident points
// initial/final bearings between points
const cosθa =
(Math.sin(φ2) - Math.sin(φ1) * Math.cos(δ12)) /
(Math.sin(δ12) * Math.cos(φ1));
const cosθb =
(Math.sin(φ1) - Math.sin(φ2) * Math.cos(δ12)) /
(Math.sin(δ12) * Math.cos(φ2));
const θa = Math.acos(Math.min(Math.max(cosθa, -1), 1)); // protect against rounding errors
const θb = Math.acos(Math.min(Math.max(cosθb, -1), 1)); // protect against rounding errors
const θ12 = Math.sin(λ2 - λ1) > 0 ? θa : 2 * π - θa;
const θ21 = Math.sin(λ2 - λ1) > 0 ? 2 * π - θb : θb;
const α1 = θ13 - θ12; // angle 2-1-3
const α2 = θ21 - θ23; // angle 1-2-3
if (Math.sin(α1) == 0 && Math.sin(α2) == 0) return null; // infinite intersections
if (Math.sin(α1) * Math.sin(α2) < 0) return null; // ambiguous intersection (antipodal/360°)
const cosα3 =
-Math.cos(α1) * Math.cos(α2) + Math.sin(α1) * Math.sin(α2) * Math.cos(δ12);
const δ13 = Math.atan2(
Math.sin(δ12) * Math.sin(α1) * Math.sin(α2),
Math.cos(α2) + Math.cos(α1) * cosα3
);
const φ3 = Math.asin(
Math.min(
Math.max(
Math.sin(φ1) * Math.cos(δ13) +
Math.cos(φ1) * Math.sin(δ13) * Math.cos(θ13),
-1
),
1
)
);
const Δλ13 = Math.atan2(
Math.sin(θ13) * Math.sin(δ13) * Math.cos(φ1),
Math.cos(δ13) - Math.sin(φ1) * Math.sin(φ3)
);
const λ3 = λ1 + Δλ13;
const lat = φ3.toDegrees();
const lon = λ3.toDegrees();
return { ...p1, latitude: lat, longitude: lon, name: "INTC" };
};

View File

@ -0,0 +1,27 @@
import { magvar } from "magvar";
Number.prototype.toRadians = function () {
return ((this as number) * Math.PI) / 180;
};
Number.prototype.toDegrees = function () {
return ((this as number) * 180) / Math.PI;
};
Number.prototype.reciprocalCourse = function () {
let inv = (this as number) + 180;
inv = inv >= 360 ? inv - 360 : inv;
return inv;
};
Number.prototype.normaliseDegrees = function () {
return (this as number) >= 360
? (this as number) - 360
: (this as number) < 0
? (this as number) + 360
: (this as number);
};
Number.prototype.toTrue = function (fix) {
const _magvar = magvar(fix.latitude, fix.longitude); //Magvar is returned + for East
return ((this as number) + _magvar).normaliseDegrees();
};
Number.prototype.toMetre = function () {
return (this as number) * 1852.0;
};

View File

@ -0,0 +1,54 @@
import * as geolib from "geolib";
import Parser from "../parser.ts";
/**
* @param crsIntoEndpoint Course into arc endpoint
* @param crsFromOrigin Course from arc origin point
* @param start Arc origin point
* @param turnDir Turn direction
* @returns Line segments
*/
export const generatePerformanceArc = (
crsIntoEndpoint: number,
crsFromOrigin: number,
start: NavFix,
turnDir?: TurnDirection
) => {
const line: LineSegment[] = [[start.longitude, start.latitude]];
// Check if there even is an arc
if (crsIntoEndpoint !== crsFromOrigin) {
// Turn Dir
if (!turnDir || turnDir === "E") {
let prov = crsFromOrigin - crsIntoEndpoint;
prov = prov > 180 ? prov - 360 : prov <= -180 ? prov + 360 : prov;
turnDir = prov > 0 ? "L" : "R";
}
// Generate arc
while (crsFromOrigin !== crsIntoEndpoint) {
if (turnDir === "R") {
const delta = (crsIntoEndpoint - crsFromOrigin).normaliseDegrees();
crsFromOrigin += delta < 1 ? delta : 1;
crsFromOrigin = crsFromOrigin.normaliseDegrees();
} else {
const delta = (crsFromOrigin - crsIntoEndpoint).normaliseDegrees();
crsFromOrigin -= delta < 1 ? delta : 1;
crsFromOrigin = crsFromOrigin.normaliseDegrees();
}
const arcFix = geolib.computeDestinationPoint(
{
latitude: line.at(-1)![1],
longitude: line.at(-1)![0],
},
(Parser.AC_SPEED / 3600).toMetre(),
crsFromOrigin
);
line.push([arcFix.longitude, arcFix.latitude]);
}
}
return line;
};

View File

@ -0,0 +1,105 @@
import * as geolib from "geolib";
import { computeIntersection } from "./computeIntersection.ts";
/**
* @param crsIntoEndpoint Course into arc endpoint
* @param crsFromOrigin Course from arc origin point
* @param start Arc origin point
* @param end Leg endpoint
* @param turnDir Turn direction
* @returns Line segments forming arc from `start`to `end`
*/
export const generateTangentArc = (
crsIntoEndpoint: number,
crsFromOrigin: number,
start: NavFix,
end: NavFix,
turnDir?: TurnDirection
) => {
const line: LineSegment[] = [[start.longitude, start.latitude]];
// Check if there even is an arc
if (crsFromOrigin !== crsIntoEndpoint) {
// Course to the end of the arc
let crsFromStartToEnd;
if (!turnDir || turnDir === "E") {
let prov = crsFromOrigin - crsIntoEndpoint;
prov = prov > 180 ? prov - 360 : prov <= -180 ? prov + 360 : prov;
turnDir = prov > 0 ? "L" : "R";
}
if (turnDir === "R") {
const delta = (360 - crsFromOrigin + crsIntoEndpoint).normaliseDegrees();
crsFromStartToEnd = (crsFromOrigin + delta / 2).normaliseDegrees();
} else {
const delta = (crsFromOrigin + 360 - crsIntoEndpoint).normaliseDegrees();
crsFromStartToEnd = (crsFromOrigin - delta / 2).normaliseDegrees();
}
// Arc end
const intcArcOnCrsIntoEndpoint = computeIntersection(
start,
crsFromStartToEnd,
end,
crsIntoEndpoint.reciprocalCourse()
);
if (!intcArcOnCrsIntoEndpoint) return null;
let crsOrthogonalOnOrigin;
let crsOrthogonalOnEndpoint;
if (turnDir === "R") {
crsOrthogonalOnOrigin = (crsFromOrigin + 90).normaliseDegrees();
crsOrthogonalOnEndpoint = (crsIntoEndpoint + 90).normaliseDegrees();
} else {
crsOrthogonalOnOrigin = (crsFromOrigin - 90).normaliseDegrees();
crsOrthogonalOnEndpoint = (crsIntoEndpoint - 90).normaliseDegrees();
}
// Generate arc
const arcCenter = computeIntersection(
start,
crsOrthogonalOnOrigin,
intcArcOnCrsIntoEndpoint,
crsOrthogonalOnEndpoint
);
if (!arcCenter) return null;
const arcRad = geolib.getDistance(arcCenter, start);
crsOrthogonalOnOrigin = crsOrthogonalOnOrigin.reciprocalCourse();
crsOrthogonalOnEndpoint = crsOrthogonalOnEndpoint.reciprocalCourse();
// Start turn immediately
if (turnDir === "R") {
crsOrthogonalOnOrigin +=
crsOrthogonalOnOrigin < 1 ? crsOrthogonalOnOrigin : 1;
} else {
crsOrthogonalOnOrigin -=
crsOrthogonalOnOrigin < 1 ? crsOrthogonalOnOrigin : 1;
}
while (crsOrthogonalOnOrigin !== crsOrthogonalOnEndpoint) {
if (turnDir === "R") {
const delta = (
crsOrthogonalOnEndpoint - crsOrthogonalOnOrigin
).normaliseDegrees();
crsOrthogonalOnOrigin += delta < 1 ? delta : 1;
crsOrthogonalOnOrigin = crsOrthogonalOnOrigin.normaliseDegrees();
} else {
const delta = (
crsOrthogonalOnOrigin - crsOrthogonalOnEndpoint
).normaliseDegrees();
crsOrthogonalOnOrigin -= delta < 1 ? delta : 1;
crsOrthogonalOnOrigin = crsOrthogonalOnOrigin.normaliseDegrees();
}
if (crsOrthogonalOnOrigin === crsOrthogonalOnEndpoint) break;
const arcFix = geolib.computeDestinationPoint(
arcCenter,
arcRad,
crsOrthogonalOnOrigin
);
line.push([arcFix.longitude, arcFix.latitude]);
}
}
return line;
};

View File

@ -0,0 +1,68 @@
import * as geolib from "geolib";
import { generateTangentArc } from "./generateTangentArc.ts";
import { generatePerformanceArc } from "./generatePerformanceArc.ts";
/**
* @param crsIntoEndpoint Course into endpoint
* @param crsIntoIntercept Course into intercept point (only fo intercept type legs)
* @param crsFromOrigin Course from arc origin point
* @param start Waypoint where leg starts
* @param end Waypoint where leg ends
* @param turnDir Turn direction
* @returns Line segments from `start` to `end`
*/
export const handleTurnAtFix = (
crsIntoEndpoint: number,
crsIntoIntercept: number,
crsFromOrigin: number,
start: NavFix,
end: NavFix,
turnDir?: TurnDirection
) => {
const line: LineSegment[] = [];
// Overfly turn
if (start.isFlyOver) {
const arc1 = generateTangentArc(
crsIntoEndpoint,
crsFromOrigin,
start,
end,
turnDir
);
const arc2 = generatePerformanceArc(
crsIntoIntercept,
crsFromOrigin,
start,
turnDir
);
// Decide on arc
let arc;
if (arc1) {
const endCrs = geolib.getGreatCircleBearing(
{
latitude: arc1.at(-1)![1],
longitude: arc1.at(-1)![0],
},
end
);
if (endCrs <= crsIntoEndpoint + 1 && endCrs >= crsIntoEndpoint - 1)
arc = arc1;
else arc = arc2;
} else {
arc = arc2;
}
line.push(...arc);
line.push([end.longitude, end.latitude]);
}
// FIXME: Procedural turn
else {
// Direct line for now
line.push([start.longitude, start.latitude], [end.longitude, end.latitude]);
}
return line;
};

29
browser/src/types/extensions.d.ts vendored Normal file
View File

@ -0,0 +1,29 @@
export declare global {
interface Number {
/**
* @returns Value converted from degrees to radians
*/
toRadians: () => number;
/**
* @returns Value converted from radians to degrees
*/
toDegrees: () => number;
/**
* @returns Reciprocal course of value
*/
reciprocalCourse: () => number;
/**
* @returns Normalises to [0,360)
*/
normaliseDegrees: () => number;
/**
* @param fix Fix for magnetic declination
* @returns Value referencing true north
*/
toTrue: (fix: { latitude: number; longitude: number }) => number;
/**
* @returns Value converted from nmi to metres
*/
toMetre: () => number;
}
}

3
browser/src/types/geojson.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module "geojson" {
export const parse: (data: any, format: any) => any;
}

3
browser/src/types/magvar.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module "magvar" {
export const magvar: (latitude: number, longitude: number) => number;
}

18
browser/src/types/terminators/AF.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
export declare global {
type AFTerminalEntry = Required<
Pick<
TerminalEntry,
| "TurnDir"
| "WptID"
| "WptLat"
| "WptLon"
| "NavID"
| "NavLat"
| "NavLon"
| "NavBear"
| "NavDist"
| "Course"
>
> &
TerminalEntry;
}

18
browser/src/types/terminators/CF.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
export declare global {
type CFTerminalEntry = Required<
Pick<
TerminalEntry,
| "WptID"
| "WptLat"
| "WptLon"
| "NavID"
| "NavLat"
| "NavLon"
| "NavBear"
| "NavDist"
| "Course"
| "Distance"
>
> &
TerminalEntry;
}

91
browser/src/types/types.d.ts vendored Normal file
View File

@ -0,0 +1,91 @@
export declare global {
type Waypoint = {
ID: number;
Ident: string;
};
type Runway = {
ID: number;
Latitude: number;
Longitude: number;
Elevation: number;
Ident: string;
TrueHeading: number;
};
type TrackCode =
| "AF"
| "CA"
| "CD"
| "CF"
| "CI"
| "CR"
| "DF"
| "FA"
| "FC"
| "FD"
| "FM"
| "HA"
| "HF"
| "HM"
| "IF"
| "PI"
| "RF"
| "TF"
| "VA"
| "VD"
| "VI"
| "VM"
| "VR";
type TurnDirection = "E" | "L" | "R";
type TerminalEntry = {
ID: number;
TerminalID: number;
Type: string;
Transition: string;
TrackCode: TrackCode;
WptID?: number;
WptLat?: number;
WptLon?: number;
TurnDir?: TurnDirection;
NavID?: number;
NavLat?: number;
NavLon?: number;
NavBear?: number;
NavDist?: number;
Course?: number;
Distance?: number;
Alt?: string;
Vnav: unknown;
CenterID?: number;
CenterLat?: number;
CenterLon?: number;
IsFlyOver: boolean;
SpeedLimit?: string;
IsFAF: boolean;
IsMAP: boolean;
};
type Terminal = {
ID: number;
FullName: string;
ICAO: string;
RwyID: number;
};
type NavFix = {
latitude: number;
longitude: number;
altitude?: number;
speed?: number;
name?: string;
isFlyOver?: boolean;
"marker-color"?: string;
altitudeConstraint?: string;
speedConstraint?: string;
};
type LineSegment = [number, number];
}

1
browser/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

28
browser/tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"typeRoots": ["node_modules/@types", "src/types/**"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
browser/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"typeRoots": ["node_modules/@types", "src/types/**"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
browser/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@ -1,6 +1,7 @@
import fs from "fs";
import geo from "geojson";
import geolib from "geolib";
import magvar from "magvar";
/* Constants */
const π = Math.PI;
@ -12,11 +13,13 @@ const AC_VS = 3000;
//const PROCEDURE_ID = 10394;
//const PROCEDURE_ID = 10395;
/* LFRK */
const MAGVAR = 0;
//const MAGVAR = 0;
//const PROCEDURE_ID = 10475;
//const PROCEDURE_ID = 10480;
//const PROCEDURE_ID = 10482;
const PROCEDURE_ID = 10485;
//const PROCEDURE_ID = 10485;
const MAGVAR = -5;
const PROCEDURE_ID = 10653;
/* Prototypes */
Number.prototype.toRadians = function () {
@ -33,11 +36,18 @@ Number.prototype.flipCourse = function () {
Number.prototype.normaliseDegrees = function () {
return this >= 360 ? this - 360 : this < 0 ? this + 360 : this;
};
Number.prototype.toTrue = function () {
Number.prototype.toTrue = function (fix) {
// IRL Magvar
//return (this - magvar.magvar(fix.lat, fix.lon)).normaliseDegrees();
// NG Magvar
return (this - MAGVAR).normaliseDegrees();
};
/* Functions */
/**
*
* @param {[number, number]} line line segment
*/
const updateLastCourse = (line) => {
lastCourse = geolib.getGreatCircleBearing(
{
@ -50,7 +60,20 @@ const updateLastCourse = (line) => {
}
);
};
/**
*
* @param {number} nmi Nautical Miles
* @returns Metres
*/
const nmiToMetre = (nmi) => nmi * 1852.0;
/**
*
* @param {{lat: number, lon: number}} p1 Point 1
* @param {number} brng1 Bearing from Point 1
* @param {{lat: number, lon: number}} p2 Point 2
* @param {number} brng2 bearing from Point 2
* @returns {{lat: number, lon: number}} Intersection point
*/
const computeIntersection = (p1, brng1, p2, brng2) => {
if (isNaN(brng1)) throw new TypeError(`invalid brng1 ${brng1}`);
if (isNaN(brng2)) throw new TypeError(`invalid brng2 ${brng2}`);
@ -126,6 +149,15 @@ const computeIntersection = (p1, brng1, p2, brng2) => {
return { lat, lon, name: "INTC", alt: p1.alt };
};
/**
*
* @param {number} inbdCrs Course into arc endpoint
* @param {*} outbCrs Course into arc origin point
* @param {{lat: number, lon: number}} arcStart Arc origin point
* @param {{lat: number, lon: number}} legEnd Arc endpoint
* @param {"R"|"L"|"E"|null} turnDir Turn direction
* @returns {[[number, number]]} Line segments
*/
const generateTangentialArc = (inbdCrs, outbCrs, arcStart, legEnd, turnDir) => {
const line = [];
@ -219,6 +251,14 @@ const generateTangentialArc = (inbdCrs, outbCrs, arcStart, legEnd, turnDir) => {
return line;
};
/**
*
* @param {number} inbdCrs Course into arc endpoint
* @param {number} outbCrs Course into arc origin point
* @param {{lat: number, lon: number}} arcStart Arc origin point
* @param {"R"|"L"|"E"|null} turnDir Turn direction
* @returns {[[number, number]]} Line segments
*/
const generatePerformanceArc = (inbdCrs, outbCrs, arcStart, turnDir) => {
const line = [[arcStart.lon, arcStart.lat]];
@ -260,16 +300,147 @@ const generatePerformanceArc = (inbdCrs, outbCrs, arcStart, turnDir) => {
return line.slice(1);
};
const getCourseAndFixForInterceptions = (leg) => {
/**
*
* @param {number} inbdCrs Course into arc endpoint
* @param {number} outbCrs Course into arc origin point
* @param {{lat: number, lon: number}} arcStart Arc origin point
* @param {{lat: number, lon: number}} center Arc center point
* @param {"R"|"L"|"E"|null} turnDir
* @returns {[[number, number]]} Line segments
*/
const generateRFArc = (inbdCrs, outbCrs, arcStart, center, turnDir) => {
const line = [];
if (inbdCrs !== outbCrs) {
// Turn Dir
if (!turnDir || turnDir === "E") {
let prov = outbCrs - inbdCrs;
prov = prov > 180 ? prov - 360 : prov <= -180 ? prov + 360 : prov;
turnDir = prov > 0 ? "L" : "R";
}
let startPerpCrs;
let endPerpCrs;
if (turnDir === "R") {
startPerpCrs = (outbCrs + 90).normaliseDegrees();
endPerpCrs = (inbdCrs + 90).normaliseDegrees();
} else {
startPerpCrs = (outbCrs - 90).normaliseDegrees();
endPerpCrs = (inbdCrs - 90).normaliseDegrees();
}
const arcRad = geolib.getDistance(
{
latitude: center.lat,
longitude: center.lon,
},
{
latitude: arcStart.lat,
longitude: arcStart.lon,
}
);
startPerpCrs = startPerpCrs.flipCourse();
endPerpCrs = endPerpCrs.flipCourse();
// Start turn immediately
if (turnDir === "R") {
startPerpCrs += startPerpCrs < 1 ? startPerpCrs : 1;
} else {
startPerpCrs -= startPerpCrs < 1 ? startPerpCrs : 1;
}
while (startPerpCrs !== endPerpCrs) {
if (turnDir === "R") {
const delta = (endPerpCrs - startPerpCrs).normaliseDegrees();
startPerpCrs += delta < 1 ? delta : 1;
startPerpCrs = startPerpCrs.normaliseDegrees();
} else {
const delta = (startPerpCrs - endPerpCrs).normaliseDegrees();
startPerpCrs -= delta < 1 ? delta : 1;
startPerpCrs = startPerpCrs.normaliseDegrees();
}
if (startPerpCrs === endPerpCrs) break;
const arcFix = geolib.computeDestinationPoint(
center,
arcRad,
startPerpCrs
);
line.push([arcFix.longitude, arcFix.latitude]);
}
}
return line;
};
/**
*
* @param {number} inbdCrs Course into arc endpoint
* @param {number} outbCrs Course into arc origin point
* @param {{lat: number, lon: number}} arcStart Arc origin point
* @param {{lat: number, lon: number}} center Arc center point
* @param {number} radius Arc radius in nmi
* @param {"R"|"L"|"E"|null} turnDir
* @returns {[[number, number]]} Line segments
*/
const generateAFArc = (inbdCrs, outbCrs, arcStart, center, radius, turnDir) => {
const line = [[arcStart.lon, arcStart.lat]];
if (inbdCrs !== outbCrs) {
// Turn Dir
if (!turnDir || turnDir === "E") {
let prov = outbCrs - inbdCrs;
prov = prov > 180 ? prov - 360 : prov <= -180 ? prov + 360 : prov;
turnDir = prov > 0 ? "L" : "R";
}
while (outbCrs !== inbdCrs) {
if (turnDir === "R") {
const delta = (inbdCrs - outbCrs).normaliseDegrees();
outbCrs += delta < 1 ? delta : 1;
outbCrs = outbCrs.normaliseDegrees();
} else {
const delta = (outbCrs - inbdCrs).normaliseDegrees();
outbCrs -= delta < 1 ? delta : 1;
outbCrs = outbCrs.normaliseDegrees();
}
if (outbCrs === inbdCrs) break;
const arcFix = geolib.computeDestinationPoint(
center,
nmiToMetre(radius),
outbCrs
);
line.push([arcFix.longitude, arcFix.latitude]);
}
}
return line;
};
const getCourseAndFixForInterceptions = (leg, origin) => {
switch (leg.TrackCode) {
case "CF": {
const fix = { lat: leg.WptLat, lon: leg.WptLon };
return [leg.Course.flipCourse().toTrue(fix), fix];
}
case "FM": {
const fix = { lat: leg.WptLat, lon: leg.WptLon };
return [leg.Course.toTrue(fix), fix];
}
case "TF": {
return [
leg.Course.flipCourse().toTrue(),
geolib.getGreatCircleBearing(
{ latitude: origin.lat, longitude: origin.lon },
{ latitude: leg.WptLat, longitude: leg.WptLon }
),
{ lat: leg.WptLat, lon: leg.WptLon },
];
}
case "FM": {
return [leg.Course.toTrue(), { lat: leg.WptLat, lon: leg.WptLon }];
case "AF": {
const fix = { lat: leg.WptLat, lon: leg.WptLon };
return [leg.Course.flipCourse().toTrue(fix), fix];
}
}
};
@ -322,21 +493,25 @@ const parseAltitude = (alt) => {
};
/* Data */
const waypoints = JSON.parse(fs.readFileSync("Waypoints.json"));
const terminal = JSON.parse(fs.readFileSync("Terminals.json")).filter(
({ ID }) => ID === PROCEDURE_ID
)[0];
const runway = JSON.parse(fs.readFileSync("Runways.json")).filter(
({ ID }) => ID === terminal.RwyID
)[0];
const procedure = JSON.parse(fs.readFileSync(`TermID_${PROCEDURE_ID}.json`));
const waypoints = JSON.parse(
fs.readFileSync("browser/public/navdata/Waypoints.json")
);
const terminal = JSON.parse(
fs.readFileSync("browser/public/navdata/Terminals.json")
).filter(({ ID }) => ID === PROCEDURE_ID)[0];
const runway = JSON.parse(
fs.readFileSync("browser/public/navdata/Runways.json")
).filter(({ ID }) => ID === terminal.RwyID)[0];
const procedure = JSON.parse(
fs.readFileSync(`browser/public/navdata/TermID_${PROCEDURE_ID}.json`)
);
/* Output */
const points = [
{ lat: runway.Latitude, lon: runway.Longitude, alt: runway.Elevation },
];
const lines = [];
let lastCourse = 0;
let lastCourse = runway.TrueHeading;
/* Main */
for (let index = 0; index < procedure.length; index++) {
@ -344,7 +519,41 @@ for (let index = 0; index < procedure.length; index++) {
const waypoint = waypoints.filter(({ ID }) => ID === leg.WptID)[0];
switch (leg.TrackCode) {
case "AF":
case "AF": {
// Push in ending waypoint
points.push({
lat: leg.WptLat,
lon: leg.WptLon,
name: waypoint?.Ident ?? undefined,
"marker-color": leg.IsFlyOver !== 0 ? "#ff0000" : undefined,
isFlyOver: leg.IsFlyOver !== 0,
alt: points.at(-1).alt,
});
const arcEndCrs = geolib.getGreatCircleBearing(
{
latitude: leg.NavLat,
longitude: leg.NavLon,
},
{
latitude: leg.WptLat,
longitude: leg.WptLon,
}
);
const line = generateAFArc(
arcEndCrs,
leg.Course.toTrue({ lat: leg.NavLat, lon: leg.NavLon }),
points.at(-2),
{ lat: leg.NavLat, lon: leg.NavLon },
leg.NavDist,
leg.TurnDir
);
lines.push({ line });
updateLastCourse(line);
break;
}
case "CA":
case "CD":
break;
@ -360,8 +569,8 @@ for (let index = 0; index < procedure.length; index++) {
});
const line = handleTurnAtFix(
leg.Course.toTrue(),
leg.Course.toTrue(),
leg.Course.toTrue(points.at(-1)),
leg.Course.toTrue(points.at(-1)),
points.at(-2),
points.at(-1),
leg.TurnDir,
@ -377,20 +586,21 @@ for (let index = 0; index < procedure.length; index++) {
case "CI": {
// Course into the destination fix and said fix
const [inbdCrs, fix] = getCourseAndFixForInterceptions(
procedure[index + 1]
procedure[index + 1],
points.at(-1)
);
// Compute INTC
const intc = computeIntersection(
points.at(-1),
leg.Course.toTrue(),
leg.Course.toTrue(fix),
fix,
inbdCrs
);
const line = handleTurnAtFix(
inbdCrs,
leg.Course.toTrue(),
leg.Course.toTrue(fix),
points.at(-1),
intc,
leg.TurnDir,
@ -401,7 +611,7 @@ for (let index = 0; index < procedure.length; index++) {
const intc2 = computeIntersection(
{ lat: line.at(-1)[1], lon: line.at(-1)[0] },
leg.Course.toTrue(),
leg.Course.toTrue(fix),
fix,
inbdCrs
);
@ -420,20 +630,20 @@ for (let index = 0; index < procedure.length; index++) {
}
case "CR": {
// Course into the destination fix
const inbdCrs = leg.Course.toTrue();
const inbdCrs = leg.Course.toTrue(points.at(-1));
// Compute INTC
const intc = computeIntersection(
points.at(-1),
inbdCrs,
{ lat: leg.NavLat, lon: leg.NavLon },
leg.NavBear.toTrue()
leg.NavBear.toTrue({ lat: leg.NavLat, lon: leg.NavLon })
);
points.push(intc);
const line = handleTurnAtFix(
inbdCrs,
leg.Course.toTrue(),
leg.Course.toTrue(points.at(-2)),
points.at(-2),
points.at(-1),
leg.TurnDir,
@ -457,12 +667,12 @@ for (let index = 0; index < procedure.length; index++) {
longitude: points.at(-1).lon,
},
nmiToMetre(10),
leg.Course.toTrue()
leg.Course.toTrue(points.at(-1))
);
const line = handleTurnAtFix(
leg.Course.toTrue(),
leg.Course.toTrue(),
leg.Course.toTrue(points.at(-1)),
leg.Course.toTrue(points.at(-1)),
points.at(-1),
{ lat: end.latitude, lon: end.longitude },
leg.TurnDir,
@ -479,8 +689,35 @@ for (let index = 0; index < procedure.length; index++) {
case "HM":
case "IF":
case "PI":
case "RF":
break;
case "RF": {
// Push in ending waypoint
points.push({
lat: leg.WptLat,
lon: leg.WptLon,
name: waypoint?.Ident ?? undefined,
"marker-color": leg.IsFlyOver !== 0 ? "#ff0000" : undefined,
isFlyOver: leg.IsFlyOver !== 0,
alt: points.at(-1).alt,
});
const [inbdCrs] = getCourseAndFixForInterceptions(
procedure[index + 1],
points.at(-1)
);
const line = generateRFArc(
inbdCrs,
lastCourse,
points.at(-1),
{ lat: leg.CenterLat, lon: leg.CenterLon },
leg.TurnDir
);
lines.push({ line });
updateLastCourse(line);
break;
}
case "TF": {
// Push in ending waypoint
points.push({
@ -523,7 +760,7 @@ for (let index = 0; index < procedure.length; index++) {
((parseAltitude(leg.Alt) - points.at(-1).alt) / AC_VS) *
(AC_SPEED / 60)
),
leg.Course.toTrue()
leg.Course.toTrue(points.at(-1))
);
points.push({
lat: end.latitude,
@ -535,8 +772,8 @@ for (let index = 0; index < procedure.length; index++) {
});
const line = handleTurnAtFix(
leg.Course.toTrue(),
leg.Course.toTrue(),
leg.Course.toTrue(points.at(-2)),
leg.Course.toTrue(points.at(-2)),
points.at(-2),
{ lat: end.latitude, lon: end.longitude },
leg.TurnDir,
@ -558,7 +795,7 @@ for (let index = 0; index < procedure.length; index++) {
},
//NOTE: Does not account for slant
nmiToMetre(leg.Distance),
leg.Course.toTrue()
leg.Course.toTrue(points.at(-1))
);
points.push({
lat: end.latitude,
@ -570,8 +807,8 @@ for (let index = 0; index < procedure.length; index++) {
});
const line = handleTurnAtFix(
leg.Course.toTrue(),
leg.Course.toTrue(),
leg.Course.toTrue(points.at(-2)),
leg.Course.toTrue(points.at(-2)),
points.at(-2),
{ lat: end.latitude, lon: end.longitude },
leg.TurnDir,
@ -588,20 +825,21 @@ for (let index = 0; index < procedure.length; index++) {
// Course into the destination fix and said fix
const [inbdCrs, fix] = getCourseAndFixForInterceptions(
procedure[index + 1]
procedure[index + 1],
points.at(-1)
);
// Compute INTC
const intc = computeIntersection(
points.at(-1),
leg.Course.toTrue(),
leg.Course.toTrue(fix),
fix,
inbdCrs
);
const line = handleTurnAtFix(
inbdCrs,
leg.Course.toTrue(),
leg.Course.toTrue(fix),
points.at(-1),
intc,
leg.TurnDir,
@ -612,7 +850,7 @@ for (let index = 0; index < procedure.length; index++) {
const intc2 = computeIntersection(
{ lat: line.at(-1)[1], lon: line.at(-1)[0] },
leg.Course.toTrue(),
leg.Course.toTrue(fix),
fix,
inbdCrs
);
@ -631,12 +869,12 @@ for (let index = 0; index < procedure.length; index++) {
longitude: points.at(-1).lon,
},
nmiToMetre(10),
leg.Course.toTrue()
leg.Course.toTrue(points.at(-1))
);
const line = handleTurnAtFix(
leg.Course.toTrue(),
leg.Course.toTrue(),
leg.Course.toTrue(points.at(-1)),
leg.Course.toTrue(points.at(-1)),
points.at(-1),
{ lat: end.latitude, lon: end.longitude },
leg.TurnDir,