├── .nvmrc
├── .nowignore
├── public
├── logo.png
├── social.png
├── favicon.ico
├── apple-icon.png
├── logo-small.png
├── robots.txt
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon-96x96.png
├── ms-icon-70x70.png
├── apple-icon-57x57.png
├── apple-icon-60x60.png
├── apple-icon-72x72.png
├── apple-icon-76x76.png
├── ms-icon-144x144.png
├── ms-icon-150x150.png
├── ms-icon-310x310.png
├── android-icon-36x36.png
├── android-icon-48x48.png
├── android-icon-72x72.png
├── android-icon-96x96.png
├── apple-icon-114x114.png
├── apple-icon-120x120.png
├── apple-icon-144x144.png
├── apple-icon-152x152.png
├── apple-icon-180x180.png
├── android-icon-144x144.png
├── android-icon-192x192.png
├── apple-icon-precomposed.png
├── browserconfig.xml
├── sitemap.xml
└── manifest.json
├── .npmrc
├── .vscode
└── settings.json
├── tailwind.config.cjs
├── src
├── consts.js
├── pages
│ ├── AirportPage
│ │ ├── windDirectionAnimation.css
│ │ ├── WindDirectionSvg.svg
│ │ ├── Compass.js
│ │ ├── AirportRunwaysMap.js
│ │ ├── WindDirectionBackground.js
│ │ ├── AirportHeader.js
│ │ ├── AirportPage.js
│ │ ├── AirportRunways.js
│ │ └── AirportRunwayCard.js
│ ├── TermsPage.js
│ ├── SelectAirportPage
│ │ └── SelectAirportPage.js
│ ├── ContactsPage.js
│ └── CookiesPolicyPage.js
├── index.js
├── setupTests.js
├── components
│ ├── ScrollToTop.js
│ ├── DebugDot.js
│ ├── PageLayout.js
│ ├── ScrollManagement.js
│ ├── Loading
│ │ ├── Loading.css
│ │ └── Loading.js
│ ├── CookiesConsent
│ │ ├── useCookiesConsent.js
│ │ └── CookiesConsent.js
│ ├── Footer.js
│ ├── RunwaysBackground.js
│ ├── RunwaysMap
│ │ ├── RunwayIdent.js
│ │ ├── Runway.js
│ │ ├── RunwaysMap.js
│ │ └── RunwayBlock.js
│ ├── NavBar.js
│ └── AirportSelectInput.js
├── hooks
│ ├── useStickyState.js
│ ├── useAirportFetch.js
│ └── useCalculateActiveRunways.js
├── AirportContext.js
├── index.css
├── helpers.js
├── App.js
└── serviceWorker.js
├── postcss.config.cjs
├── server
├── Dockerfile
├── helpers
│ └── downloadData.cjs
├── main.cjs
└── api
│ └── runway.cjs
├── nginx.conf
├── .gitignore
├── Dockerfile
├── vite.config.js
├── package.json
├── index.html
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20.13.1
--------------------------------------------------------------------------------
/.nowignore:
--------------------------------------------------------------------------------
1 | data
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/logo.png
--------------------------------------------------------------------------------
/public/social.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/social.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon.png
--------------------------------------------------------------------------------
/public/logo-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/logo-small.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | audit=false
2 | legacy-peer-deps=true
3 | force=true
4 | registry=https://registry.npmjs.org/
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "git.ignoreLimitWarning": true,
3 | "prettier.printWidth": 80
4 | }
5 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/favicon-96x96.png
--------------------------------------------------------------------------------
/public/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/ms-icon-70x70.png
--------------------------------------------------------------------------------
/public/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-57x57.png
--------------------------------------------------------------------------------
/public/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-60x60.png
--------------------------------------------------------------------------------
/public/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-72x72.png
--------------------------------------------------------------------------------
/public/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-76x76.png
--------------------------------------------------------------------------------
/public/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/ms-icon-144x144.png
--------------------------------------------------------------------------------
/public/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/ms-icon-150x150.png
--------------------------------------------------------------------------------
/public/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/ms-icon-310x310.png
--------------------------------------------------------------------------------
/public/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/android-icon-36x36.png
--------------------------------------------------------------------------------
/public/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/android-icon-48x48.png
--------------------------------------------------------------------------------
/public/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/android-icon-72x72.png
--------------------------------------------------------------------------------
/public/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/android-icon-96x96.png
--------------------------------------------------------------------------------
/public/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-114x114.png
--------------------------------------------------------------------------------
/public/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-120x120.png
--------------------------------------------------------------------------------
/public/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-144x144.png
--------------------------------------------------------------------------------
/public/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-152x152.png
--------------------------------------------------------------------------------
/public/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-180x180.png
--------------------------------------------------------------------------------
/public/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/android-icon-144x144.png
--------------------------------------------------------------------------------
/public/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/android-icon-192x192.png
--------------------------------------------------------------------------------
/public/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epranka/runway-app/HEAD/public/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
3 | };
4 |
--------------------------------------------------------------------------------
/src/consts.js:
--------------------------------------------------------------------------------
1 | export const DEFAULT_METAR_PROVIDER = "aviationweather";
2 | export const METAR_PROVIDER_STORAGE_KEY = "metarProvider";
3 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | ...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}),
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/windDirectionAnimation.css:
--------------------------------------------------------------------------------
1 | @keyframes animateBackground {
2 | from {
3 | background-position-y: 0;
4 | }
5 | to {
6 | background-position-y: 160px;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "./App";
4 | import "./index.css";
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById("root")
11 | );
12 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #ffffff
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20.13.1-alpine AS node
2 |
3 | WORKDIR /app
4 |
5 | COPY package.json /app
6 | COPY package-lock.json /app
7 | COPY .npmrc /app
8 | RUN npm ci
9 |
10 | COPY server /app/server
11 |
12 | ENV SERVER_PORT=80
13 | ENV NODE_ENV=production
14 |
15 | EXPOSE 80
16 |
17 | CMD ["node", "server/main.cjs"]
--------------------------------------------------------------------------------
/src/pages/AirportPage/WindDirectionSvg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/components/ScrollToTop.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useLocation } from "react-router-dom";
3 |
4 | const ScrollToTop = () => {
5 | const { pathname } = useLocation();
6 |
7 | useEffect(() => {
8 | window.scrollTo(0, 0);
9 | }, [pathname]);
10 |
11 | return null;
12 | };
13 |
14 | export default ScrollToTop;
15 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 |
4 | location / {
5 | root /usr/share/nginx/html;
6 | index index.html index.htm;
7 | try_files $uri $uri/ /index.html;
8 | }
9 |
10 | error_page 500 502 503 504 /50x.html;
11 |
12 | location = /50x.html {
13 | root /usr/share/nginx/html;
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/server/helpers/downloadData.cjs:
--------------------------------------------------------------------------------
1 | const request = require('request');
2 |
3 | const downloadFile = (url) => {
4 | return new Promise((resolve, reject) => {
5 | request.get(url, (error, _response, body) => {
6 | if (error) return reject(error);
7 | return resolve(body);
8 | });
9 | });
10 | };
11 |
12 | module.exports = downloadFile;
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /dist
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | .now
--------------------------------------------------------------------------------
/src/components/DebugDot.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function DebugDot(props) {
4 | const { x, y, ident } = props;
5 |
6 | return (
7 |
8 | {ident}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://runway.airportdb.io/
4 |
5 |
6 | https://runway.airportdb.io/contacts
7 |
8 |
9 | https://runway.airportdb.io/policy/cookies
10 |
11 |
12 | https://runway.airportdb.io/policy/terms-of-usage
13 |
14 |
--------------------------------------------------------------------------------
/src/components/PageLayout.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import CookiesConsent from "./CookiesConsent/CookiesConsent";
3 | import Footer from "./Footer";
4 | import NavBar from "./NavBar";
5 | import ScrollToTop from "./ScrollToTop";
6 |
7 | export default function PageLayout(props) {
8 | return (
9 | <>
10 |
11 |
12 | {props.children}
13 |
14 |
15 | >
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/server/main.cjs:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 | const express = require("express");
3 | const cors = require("cors");
4 | const runwayAPI = require("./api/runway.cjs");
5 |
6 | const app = express();
7 | const port = process.env.SERVER_PORT;
8 |
9 | if (process.env.NODE_ENV === "development") {
10 | app.use(cors());
11 | }
12 |
13 | app.get("/api/v1/runway/:icao", runwayAPI);
14 |
15 | app.listen(port, () => {
16 | console.log("Runway app is listening on http://localhost:" + port);
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/ScrollManagement.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | const ScrollManagement = ({ children }) => {
4 | useEffect(() => {
5 | const url = new URL(window.location.href);
6 | if (url.hash) {
7 | const hash_id = url.hash.replace(/^#/, "");
8 | if (hash_id) {
9 | const element = document.getElementById(hash_id);
10 | element.scrollIntoView();
11 | }
12 | }
13 | }, []);
14 |
15 | return children;
16 | };
17 |
18 | export default ScrollManagement;
19 |
--------------------------------------------------------------------------------
/src/hooks/useStickyState.js:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from "react";
2 |
3 | const useStickyState = (defaultValue, key) => {
4 | const [value, setValue] = useState(() => {
5 | const stickyValue = window.localStorage.getItem(key);
6 | return stickyValue !== null
7 | ? JSON.parse(stickyValue)
8 | : defaultValue;
9 | });
10 |
11 | useEffect(() => {
12 | window.localStorage.setItem(key, JSON.stringify(value));
13 | }, [key, value]);
14 |
15 | return [value, setValue];
16 | }
17 |
18 | export default useStickyState;
19 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Prepare node
2 |
3 | FROM node:20.13.1-alpine AS node
4 |
5 | WORKDIR /app
6 |
7 | COPY package.json /app
8 | COPY package-lock.json /app
9 | COPY .npmrc /app
10 | RUN npm ci
11 |
12 | COPY . /app
13 |
14 | ARG VITE_APP_API_HOST
15 | ARG VITE_APP_GA_ID
16 |
17 | ENV VITE_APP_API_HOST=$VITE_APP_API_HOST
18 | ENV VITE_APP_GA_ID=$VITE_APP_GA_ID
19 |
20 | RUN npm run build
21 |
22 | FROM nginx:alpine
23 |
24 | COPY --from=node /app/dist /usr/share/nginx/html
25 | RUN rm /etc/nginx/conf.d/default.conf
26 | COPY --from=node /app/nginx.conf /etc/nginx/conf.d
27 |
28 | CMD ["nginx", "-g", "daemon off;"]
29 |
30 | EXPOSE 80
--------------------------------------------------------------------------------
/src/AirportContext.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useMemo, useState } from "react";
2 |
3 | const AirportContext = React.createContext([null, () => {}]);
4 |
5 | const AirportContextProvider = ({ children }) => {
6 | const [airport, setAirport] = useState(null);
7 |
8 | const contextValue = useMemo(() => {
9 | return [airport, setAirport];
10 | }, [airport]);
11 |
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default AirportContextProvider;
20 |
21 | export const useAirport = () => {
22 | return useContext(AirportContext);
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/Loading/Loading.css:
--------------------------------------------------------------------------------
1 | .svg-calLoader {
2 | width: 230px;
3 | height: 230px;
4 | transform-origin: 115px 115px;
5 | animation: 2s linear infinite loader-spin;
6 | }
7 |
8 | .cal-loader__plane {
9 | fill: black;
10 | }
11 | .cal-loader__path {
12 | stroke: #666666;
13 | animation: 2s ease-in-out infinite loader-path;
14 | }
15 |
16 | @keyframes loader-spin {
17 | to {
18 | transform: rotate(360deg);
19 | }
20 | }
21 | @keyframes loader-path {
22 | 0% {
23 | stroke-dasharray: 0, 580, 0, 0, 0, 0, 0, 0, 0;
24 | }
25 | 50% {
26 | stroke-dasharray: 0, 450, 10, 30, 10, 30, 10, 30, 10;
27 | }
28 | 100% {
29 | stroke-dasharray: 0, 580, 0, 0, 0, 0, 0, 0, 0;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/CookiesConsent/useCookiesConsent.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | const getPersistentCookiesConsent = () => {
4 | return window.localStorage.getItem('cookiesConsent') === 'true';
5 | }
6 |
7 | const setPersistentCookiesConsent = () => {
8 | window.localStorage.setItem('cookiesConsent', true);
9 | }
10 |
11 | const useCookiesConsent = () => {
12 | const [cookiesConsent, setCookiesConsent] = useState(getPersistentCookiesConsent());
13 |
14 | const handleSetCookiesConsent = () => {
15 | setPersistentCookiesConsent();
16 | setCookiesConsent(true);
17 | }
18 |
19 | return [cookiesConsent, handleSetCookiesConsent];
20 | }
21 |
22 | export default useCookiesConsent;
--------------------------------------------------------------------------------
/src/pages/AirportPage/Compass.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function Compass() {
4 | return (
5 |
6 |
13 |
14 |
20 |
21 | N
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700&display=swap");
2 |
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 | @layer base {
8 | .c-min-h-screen {
9 | min-height: calc(100vh - 40px);
10 | }
11 | }
12 |
13 | body {
14 | margin: 0;
15 | font-family: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI",
16 | "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
17 | "Helvetica Neue", sans-serif;
18 | -webkit-font-smoothing: antialiased;
19 | -moz-osx-font-smoothing: grayscale;
20 | }
21 |
22 | code {
23 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
24 | monospace;
25 | }
26 |
27 | input:focus::placeholder {
28 | color: transparent;
29 | }
30 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/AirportRunwaysMap.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import RunwaysMap from "../../components/RunwaysMap/RunwaysMap";
3 |
4 | export default function AirportRunwaysMap(props) {
5 | const { airport, activeRunwaysData } = props;
6 |
7 | return (
8 |
9 |
10 | {airport.icao} Runways Map
11 |
12 |
13 | *Runways dimensions and positions are not strictly accurate
14 |
15 |
16 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App",
3 | "icons": [
4 | {
5 | "src": "\/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | // vite.config.js
2 | import react from "@vitejs/plugin-react";
3 | import * as esbuild from "esbuild";
4 | import fs from "node:fs";
5 | import { defineConfig } from "vite";
6 |
7 | const rollupPlugin = (matchers) => ({
8 | name: "js-in-jsx",
9 | load(id) {
10 | if (matchers.some(matcher => matcher.test(id))) {
11 | const file = fs.readFileSync(id, { encoding: "utf-8" });
12 | return esbuild.transformSync(file, { loader: "jsx" });
13 | }
14 | }
15 | });
16 |
17 | export default defineConfig({
18 | plugins: [react()],
19 | build: {
20 | rollupOptions: {
21 | plugins: [
22 | rollupPlugin([/\/src\/.*\.js$/])
23 | ],
24 | },
25 | commonjsOptions: {
26 | transformMixedEsModules: true,
27 | },
28 | },
29 | optimizeDeps: {
30 | esbuildOptions: {
31 | loader: {
32 | ".js": "jsx",
33 | },
34 | },
35 | },
36 | esbuild: {
37 | jsx: 'automatic',
38 | loader: "jsx",
39 | include: /\/src\/.*\.js$/,
40 | exclude: [],
41 | },
42 | });
--------------------------------------------------------------------------------
/src/hooks/useAirportFetch.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { useCallback } from "react";
3 |
4 | const MAIN_HUMAN_ERROR_MESSAGE = "Sorry, something went wrong. Try again later or contact administrators";
5 |
6 | const useAirportFetch = ({ onLoading, onError, onLoaded }) => {
7 | return useCallback(
8 | async (metarProviderValue, icaoValue) => {
9 | onLoading && onLoading(true);
10 | onError(null);
11 | try {
12 | const response = await axios.get(
13 | import.meta.env.VITE_APP_API_HOST + "/api/v1/runway/" + icaoValue + "?metarProvider=" + metarProviderValue,
14 | );
15 | const data = response.data;
16 | if (!data) {
17 | throw new Error("No data received");
18 | }
19 | if (data.error) {
20 | onError(data.error);
21 | onLoading && onLoading(false);
22 | return;
23 | }
24 | onLoading && onLoading(false);
25 | onLoaded(data);
26 | } catch (err) {
27 | console.error(err);
28 | onError(MAIN_HUMAN_ERROR_MESSAGE);
29 | onLoading && onLoading(false);
30 | }
31 | },
32 | [onLoading, onError, onLoaded]
33 | );
34 | };
35 |
36 | export default useAirportFetch;
37 |
--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
1 | export const toRad = (degrees) => {
2 | var pi = Math.PI;
3 | return degrees * (pi / 180);
4 | };
5 |
6 | const EARTH_RADIUS = 6371; //km
7 | const KM_TO_FT = 3280.8399;
8 |
9 | export const latLngToFt = (lat, lng) => {
10 | const yKm = 2 * Math.PI * EARTH_RADIUS * (lat / 360);
11 | const xKm = 2 * Math.PI * EARTH_RADIUS * (lng / 360);
12 | const xFt = xKm * KM_TO_FT;
13 | const yFt = yKm * KM_TO_FT;
14 | return { x: xFt, y: -yFt };
15 | };
16 |
17 | export const fitRectIntoBounds = (rect, bounds) => {
18 | var rectRatio = rect.width / rect.height;
19 | var boundsRatio = bounds.width / bounds.height;
20 |
21 | var newDimensions = {};
22 |
23 | // Rect is more landscape than bounds - fit to width
24 | if (rectRatio > boundsRatio) {
25 | newDimensions.width = bounds.width;
26 | newDimensions.height = rect.height * (bounds.width / rect.width);
27 | }
28 | // Rect is more portrait than bounds - fit to height
29 | else {
30 | newDimensions.width = rect.width * (bounds.height / rect.height);
31 | newDimensions.height = bounds.height;
32 | }
33 |
34 | return newDimensions;
35 | };
36 |
37 | export const ucfirst = (str) => {
38 | return str.charAt(0).toUpperCase() + str.slice(1);
39 | };
40 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Helmet } from "react-helmet";
3 | import { Route, BrowserRouter as Router } from "react-router-dom";
4 | import AirportContextProvider from "./AirportContext";
5 | import ScrollManagement from "./components/ScrollManagement";
6 | import AirportPage from "./pages/AirportPage/AirportPage";
7 | import ContactsPage from "./pages/ContactsPage";
8 | import CookiesPolicyPage from "./pages/CookiesPolicyPage";
9 | import SelectAirportPage from "./pages/SelectAirportPage/SelectAirportPage";
10 | import TermsPage from "./pages/TermsPage";
11 |
12 | function App() {
13 | return (
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default App;
35 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/WindDirectionBackground.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./windDirectionAnimation.css";
3 | import windDirectionSvg from "./WindDirectionSvg.svg";
4 |
5 | function toRad(degrees) {
6 | var pi = Math.PI;
7 | return degrees * (pi / 180);
8 | }
9 |
10 | export default function WindDirectionBackground(props) {
11 | const { windDirection } = props;
12 |
13 | if (windDirection === 0) return null;
14 |
15 | const deg = windDirection;
16 |
17 | const width =
18 | window.innerHeight * Math.abs(Math.sin(toRad(deg))) +
19 | window.innerWidth * Math.abs(Math.cos(toRad(deg)));
20 | const height =
21 | window.innerWidth * Math.abs(Math.sin(toRad(deg))) +
22 | window.innerHeight * Math.abs(Math.cos(toRad(deg)));
23 |
24 | const deltaX = (window.innerWidth - width) / 2;
25 | const deltaY = (window.innerHeight - height) / 2;
26 |
27 | return (
28 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "runway-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "start:api": "node ./server/main.cjs",
8 | "start": "vite",
9 | "build": "vite build",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@octokit/rest": "^17.3.0",
14 | "@testing-library/jest-dom": "^4.2.4",
15 | "@testing-library/react": "^9.3.2",
16 | "@testing-library/user-event": "^7.1.2",
17 | "@vitejs/plugin-react": "^4.3.3",
18 | "aewx-metar-parser": "^1.0.0",
19 | "axios": "^0.21.1",
20 | "clsx": "^1.1.1",
21 | "cors": "^2.8.5",
22 | "dotenv": "^10.0.0",
23 | "express": "^4.17.1",
24 | "lucide-react": "^0.456.0",
25 | "react": "^16.13.1",
26 | "react-dom": "^16.13.1",
27 | "react-helmet": "^6.1.0",
28 | "react-hotjar": "^5.0.0",
29 | "react-router-dom": "^5.2.0",
30 | "request": "^2.88.2",
31 | "spacetime": "^6.16.2",
32 | "vite": "^5.4.10",
33 | "xml2js": "^0.4.23"
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | },
47 | "devDependencies": {
48 | "autoprefixer": "^10.4.20",
49 | "cssnano": "^7.0.6",
50 | "postcss": "^8.4.49",
51 | "tailwindcss": "^3.4.14"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/CookiesConsent/CookiesConsent.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect } from "react";
2 | import { Link } from "react-router-dom";
3 | import useCookiesConsent from "./useCookiesConsent";
4 |
5 | const CookiesConsent = () => {
6 | const [cookiesConsent, setCookiesConsent] = useCookiesConsent();
7 |
8 | const handleConsent = useCallback(() => {
9 | setCookiesConsent();
10 | }, [setCookiesConsent]);
11 |
12 | useEffect(() => {
13 | if (cookiesConsent && import.meta.env.PROD) {
14 | // Start using Google Analytics
15 | window.dataLayer = window.dataLayer || [];
16 | function gtag() {
17 | window.dataLayer.push(arguments);
18 | }
19 | gtag("js", new Date());
20 | gtag("config", import.meta.env.VITE_APP_GA_ID);
21 | }
22 | }, [cookiesConsent]);
23 |
24 | if (cookiesConsent) return null;
25 |
26 | return (
27 |
31 |
32 | This website use cookies to analyze traffic. Also cookies are used for
33 | authentication state. If you don't agree with that you should stop using
34 | this website.
35 |
36 |
37 | Learn more
38 |
39 |
40 | Ok, I got it
41 |
42 |
43 | );
44 | };
45 |
46 | export default CookiesConsent;
47 |
--------------------------------------------------------------------------------
/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 |
4 | export default function Footer() {
5 | return (
6 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/pages/TermsPage.js:
--------------------------------------------------------------------------------
1 | import { Helmet } from "react-helmet";
2 | import PageLayout from "../components/PageLayout";
3 | import React from 'react';
4 |
5 | const TermsPage = () => {
6 | return (
7 |
8 |
9 | Terms of usage | Which runway to choose?
10 |
11 |
12 |
13 | Terms of usage Which runway to choose?
14 |
15 |
16 |
17 | This is the Terms of usage for Which runway to choose?, accessible from{" "}
18 |
19 | https://runway.airportdb.io
20 |
21 |
22 |
23 |
24 | Responsibility
25 |
26 |
27 |
28 | This application only calculates runway suggestion by wind. You must
29 | always listen to ATC for active runways, check that runways data is
30 | really valid or/and read NOTAM's.{" "}
31 | Use data at your own risk . Application calculates
32 | runway suggestion with no warranty of any kind. By using the provided
33 | data and suggestions, you agree that website/application creators,
34 | maintainers, and anyone involved with the website/application hold{" "}
35 | no liability for anything that happens when you use
36 | the data and suggestions
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default TermsPage;
44 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/AirportHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import spacetime from "spacetime";
3 |
4 | export default function AirportHeader(props) {
5 | const { airport } = props;
6 |
7 | return (
8 |
9 |
10 | {airport.icao}
11 |
12 |
13 | {airport.name}
14 |
15 |
16 | Last updated:{" "}
17 |
18 | {spacetime(airport.time).format(
19 | "{year}-{iso-month}-{date-pad} {hour-24-pad}:{minute-pad}"
20 | )}{" "}
21 | UTC
22 |
23 |
24 |
25 | METAR
26 | {airport.metar}
27 |
28 |
29 |
30 | Wind direction
31 |
32 |
33 |
34 | {airport.wind_direction === 'VRB' ? (
35 | "Variable"
36 | ) : (
37 | {airport.wind_direction}°
38 | )}
39 | {" "}
40 | {airport.wind_speed === 0 ? (
41 | calm winds
42 | ) : (
43 |
44 | at {" "}
45 | {airport.wind_speed} kts
46 |
47 | )}
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/RunwaysBackground.js:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from "react";
2 | import Runway from "./RunwaysMap/Runway";
3 |
4 | const random = (min, max) => Math.floor(Math.random() * (max - min)) + min;
5 |
6 | export default function RunwaysBackground(props) {
7 | const runways = useMemo(() => {
8 | const he_heading1 = random(0, 360);
9 | const he_ident1 = String(Math.round(he_heading1 / 10).toFixed(0));
10 | const le_ident1 = String(
11 | Math.round(((he_heading1 + 180) % 360) / 10).toFixed(0)
12 | );
13 | const he_heading2 = (he_heading1 + random(45, 135)) % 360;
14 | const he_ident2 = String(Math.round(he_heading2 / 10).toFixed(0));
15 | const le_ident2 = String(
16 | Math.round(((he_heading2 + 180) % 360) / 10).toFixed(0)
17 | );
18 | return [
19 | {
20 | width: 300,
21 | length: 10000,
22 | he_heading: he_heading1,
23 | le_ident: le_ident1,
24 | he_ident: he_ident1,
25 | he_active: {
26 | headtailwindType: "headwind",
27 | },
28 | },
29 | !props.singleRunway
30 | ? {
31 | width: 300,
32 | length: 10000,
33 | he_heading: he_heading2,
34 | le_ident: le_ident2,
35 | he_ident: he_ident2,
36 | he_active: {
37 | headtailwindType: "headwind",
38 | status: "crosswind",
39 | },
40 | }
41 | : null,
42 | ].filter(Boolean);
43 | }, [props.singleRunway]);
44 |
45 | return (
46 |
47 | {runways.map((runway, key) => {
48 | return (
49 |
61 | );
62 | })}
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/Loading/Loading.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./Loading.css";
3 |
4 | export default function Loading() {
5 | return (
6 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/pages/SelectAirportPage/SelectAirportPage.js:
--------------------------------------------------------------------------------
1 | import { default as React, useCallback } from "react";
2 | import { Helmet } from "react-helmet";
3 | import { useHistory, useLocation } from "react-router-dom";
4 | import { useAirport } from "../../AirportContext";
5 | import AirportSelectInput from "../../components/AirportSelectInput";
6 | import PageLayout from "../../components/PageLayout";
7 | import RunwaysBackground from "../../components/RunwaysBackground";
8 | import Compass from "../AirportPage/Compass";
9 |
10 | export default function SelectAirportPage() {
11 | const [, setAirport] = useAirport();
12 | const history = useHistory();
13 | const location = useLocation();
14 |
15 | const handleAirportLoaded = useCallback(
16 | (data) => {
17 | setAirport(data);
18 | history.push("/airport/" + data.icao);
19 | },
20 | [history, setAirport]
21 | );
22 |
23 | return (
24 |
25 |
26 | Which runway to choose?
27 |
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 | Which runway to choose?
40 |
41 |
48 |
49 | ★ Check the descent path calculator:{" "}
50 |
56 | descent.now.sh
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/AirportPage.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect } from "react";
2 | import Loading from "../../components/Loading/Loading";
3 | import { Helmet } from "react-helmet";
4 | import { useHistory, useRouteMatch } from "react-router-dom";
5 | import { useAirport } from "../../AirportContext";
6 | import PageLayout from "../../components/PageLayout";
7 | import useAirportFetch from "../../hooks/useAirportFetch";
8 | import useCalculateActiveRunways from "../../hooks/useCalculateActiveRunways";
9 | import AirportHeader from "./AirportHeader";
10 | import AirportRunways from "./AirportRunways";
11 | import AirportRunwaysMap from "./AirportRunwaysMap";
12 | import Compass from "./Compass";
13 | import WindDirectionBackground from "./WindDirectionBackground";
14 | import useStickyState from "../../hooks/useStickyState";
15 | import {DEFAULT_METAR_PROVIDER, METAR_PROVIDER_STORAGE_KEY} from "../../consts";
16 |
17 | export default function AirportPage() {
18 | const [metarProvider] = useStickyState(DEFAULT_METAR_PROVIDER, METAR_PROVIDER_STORAGE_KEY);
19 | const [airport, setAirport] = useAirport();
20 | const { params } = useRouteMatch();
21 | const history = useHistory();
22 |
23 | const airportIsValid =
24 | airport && airport.icao.toLowerCase() === params?.icao.toLowerCase();
25 |
26 | const handleError = useCallback(
27 | (error) => {
28 | if (error !== null) {
29 | history.replace("/", { error, icao: params?.icao });
30 | }
31 | },
32 | [history, params]
33 | );
34 |
35 | const fetchAirport = useAirportFetch({
36 | onLoaded: setAirport,
37 | onError: handleError,
38 | });
39 |
40 | useEffect(() => {
41 | if (!airportIsValid) {
42 | fetchAirport(metarProvider, params?.icao);
43 | }
44 | // eslint-disable-next-line react-hooks/exhaustive-deps
45 | }, [metarProvider]);
46 |
47 | const activeRunwaysData = useCalculateActiveRunways(airport, airportIsValid);
48 |
49 | if (!airportIsValid) return ;
50 |
51 | return (
52 |
53 |
54 |
55 | {airport.name} | {airport.icao} | Which runway to choose?
56 |
57 |
58 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/RunwaysMap/RunwayIdent.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function RunwayIdent(props) {
4 | const {
5 | activeSide,
6 | isCrosswind,
7 | scaleX,
8 | scaleY,
9 | originY,
10 | svgWidth,
11 | svgHeight,
12 | he_heading,
13 | leIdentRectX,
14 | leIdentRectY,
15 | identRectWidth,
16 | identRectHeight,
17 | identRectStroke,
18 | leIdentRotation,
19 | identX,
20 | leIdentY,
21 | le_ident,
22 | heIdentRectX,
23 | heIdentRectY,
24 | heIdentY,
25 | heIdentRotation,
26 | he_ident,
27 | originAtCenter,
28 | } = props;
29 |
30 | return (
31 |
49 |
50 |
66 |
74 | {le_ident}
75 |
76 |
77 |
78 |
94 |
102 | {he_ident}
103 |
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/pages/ContactsPage.js:
--------------------------------------------------------------------------------
1 | import { Helmet } from "react-helmet";
2 | import PageLayout from "../components/PageLayout";
3 | import RunwaysBackground from "../components/RunwaysBackground";
4 | import React from 'react';
5 |
6 | const ContactsPage = () => {
7 | return (
8 |
9 |
10 | Contacts | Which runway to choose?
11 |
12 |
13 |
14 | Contacts
15 |
16 |
17 |
18 | Feel free to ask any questions or suggestions
19 |
20 |
21 |
22 |
28 |
29 |
35 |
36 |
47 |
48 |
58 |
59 |
69 |
70 |
80 |
81 |
82 |
83 |
84 | );
85 | };
86 |
87 | export default ContactsPage;
88 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/AirportRunways.js:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from "react";
2 | import { Link } from "react-router-dom";
3 | import AirportRunwayCard from "./AirportRunwayCard";
4 |
5 | export default function AirportRunways(props) {
6 | const { airport, activeRunwaysData } = props;
7 |
8 | const sortedRunways = useMemo(() => {
9 | const runways = [];
10 | for (const runway of airport.runways) {
11 | runways.push({
12 | heading: (runway.he_heading_degT + 180) % 360,
13 | length: runway.length_ft,
14 | width: runway.width_ft,
15 | ident: runway.le_ident,
16 | invertIdent: runway.he_ident,
17 | active: activeRunwaysData[runway.le_ident],
18 | ils: runway.le_ils,
19 | });
20 |
21 | runways.push({
22 | heading: runway.he_heading_degT,
23 | length: runway.length_ft,
24 | width: runway.width_ft,
25 | ident: runway.he_ident,
26 | invertIdent: runway.le_ident,
27 | active: activeRunwaysData[runway.he_ident],
28 | ils: runway.he_ils,
29 | });
30 | }
31 |
32 | const sortIndexes = {
33 | tailwind: 0,
34 | crosswind: 1,
35 | headwind: 2,
36 | };
37 |
38 | const sortedRunways = runways.sort((a, b) => {
39 | return (
40 | sortIndexes[b.active?.status] - sortIndexes[a.active?.status] ||
41 | (!!b.ils ? 1 : 0) - (!!a.ils ? 1 : 0) ||
42 | a.active?.crosswind - b.active?.crosswind
43 | );
44 | });
45 |
46 | return sortedRunways;
47 | }, [airport, activeRunwaysData]);
48 |
49 | return (
50 |
51 |
52 | Runways
53 |
54 |
55 | *You must always listen to ATC for the active runway. The following
56 | runway suggestions are just approximations by wind direction. By using
57 | the following data you agree with{" "}
58 |
63 | terms of usage
64 |
65 |
66 | {airport.wind_direction === 0 ? (
67 |
68 |
69 | Winds are variable
70 |
71 | . Listen to ATC for the active runway, or choose by yourself
72 |
73 | ) : null}
74 |
75 |
76 | {sortedRunways.map((runway) => {
77 | return (
78 |
88 | );
89 | })}
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/NavBar.js:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import React, { useCallback, useRef, useState } from "react";
3 | import { Link, useLocation } from "react-router-dom";
4 |
5 | const NavBar = () => {
6 | const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
7 | const navBarRef = useRef(null);
8 | const location = useLocation();
9 |
10 | const handleMenuToggle = useCallback(() => {
11 | setMobileMenuOpen((state) => !state);
12 | }, []);
13 |
14 | const handleItemClick = useCallback((e) => {
15 | setMobileMenuOpen(false);
16 | }, []);
17 |
18 | return (
19 |
29 |
30 |
31 | {location.pathname !== "/" ? (
32 |
37 |
42 |
Which runway to choose?
43 |
44 | ) : (
45 |
46 | )}
47 | {location.pathname !== "/" ? (
48 |
52 |
57 |
62 |
63 |
64 | ) : null}
65 |
74 | <>
75 | {location.pathname !== "/contacts" &&
76 | location.pathname !== "/" ? (
77 |
82 | Contacts
83 |
84 | ) : null}
85 | >
86 |
87 |
88 |
89 |
90 | );
91 | };
92 |
93 | export default NavBar;
94 |
--------------------------------------------------------------------------------
/src/hooks/useCalculateActiveRunways.js:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { toRad } from "../helpers";
3 |
4 | const round = (value) => Math.round(value * 100) / 100;
5 |
6 | const useCalculateActiveRunways = (airport, airportIsValid) => {
7 | const activeRunwaysIdent = useMemo(() => {
8 | if (!airportIsValid) {
9 | return null;
10 | }
11 |
12 | const windDirection = airport.wind_direction;
13 | const windSpeed = airport.wind_speed;
14 | // variable wind
15 | if (windDirection === 'VRB') return {};
16 | const result = {};
17 | for (const runway of airport.runways) {
18 | // Runways heading inverted
19 | const he_heading = runway.he_heading_degT - 180;
20 | const le_heading = runway.he_heading_degT;
21 | const he_headtailwind = round(
22 | windSpeed * Math.cos(toRad(windDirection - he_heading))
23 | );
24 | const he_crosswind = round(
25 | windSpeed * Math.sin(toRad(windDirection - he_heading))
26 | );
27 | const he_crosswind_side = he_crosswind > 0 ? "left" : "right";
28 | const he_status =
29 | he_headtailwind > 0
30 | ? "tailwind"
31 | : Math.abs(he_crosswind) > Math.abs(he_headtailwind)
32 | ? "crosswind"
33 | : "headwind";
34 | const le_headtailwind = round(
35 | windSpeed * Math.cos(toRad(windDirection - le_heading))
36 | );
37 | const le_crosswind = round(
38 | windSpeed * Math.sin(toRad(windDirection - le_heading))
39 | );
40 | const le_crosswind_side = le_crosswind < 0 ? "right" : "left";
41 | const le_status =
42 | le_headtailwind > 0
43 | ? "tailwind"
44 | : Math.abs(le_crosswind) > Math.abs(le_headtailwind)
45 | ? "crosswind"
46 | : "headwind";
47 |
48 | // DEBUG
49 | // console.log("------");
50 | // console.log(runway.le_ident);
51 | // console.log("Heading", le_heading);
52 | // console.log("Status", le_status);
53 | // console.log(
54 | // "Headtailwind",
55 | // le_headtailwind,
56 | // le_headtailwind > 0 ? "tailwind" : "headwind"
57 | // );
58 | // console.log("Crosswind", le_crosswind, le_crosswind_side);
59 | // console.log(runway.he_ident);
60 | // console.log("Heading", he_heading);
61 | // console.log("Status", he_status);
62 | // console.log(
63 | // "Headtailwind",
64 | // he_headtailwind,
65 | // he_headtailwind > 0 ? "tailwind" : "headwind"
66 | // );
67 | // console.log("Crosswind", he_crosswind, he_crosswind_side);
68 |
69 | result[runway.le_ident] = {
70 | status: le_status,
71 | crosswind: Math.abs(le_crosswind),
72 | crosswindSide: le_crosswind_side,
73 | headtailwind: Math.abs(le_headtailwind),
74 | headtailwindType: le_headtailwind > 0 ? "tailwind" : "headwind",
75 | };
76 | result[runway.he_ident] = {
77 | status: he_status,
78 | crosswind: Math.abs(he_crosswind),
79 | crosswindSide: he_crosswind_side,
80 | headtailwind: Math.abs(he_headtailwind),
81 | headtailwindType: he_headtailwind > 0 ? "tailwind" : "headwind",
82 | };
83 | }
84 | return result;
85 | }, [airport, airportIsValid]);
86 |
87 | return activeRunwaysIdent;
88 | };
89 |
90 | export default useCalculateActiveRunways;
91 |
--------------------------------------------------------------------------------
/src/components/RunwaysMap/Runway.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import RunwayBlock from "./RunwayBlock";
3 | import RunwayIdent from "./RunwayIdent";
4 |
5 | export default function Runway(props) {
6 | const {
7 | x,
8 | y,
9 | width,
10 | length,
11 | he_heading,
12 | le_ident,
13 | he_ident,
14 | le_active,
15 | he_active,
16 | originAtCenter,
17 | } = props;
18 |
19 | const scaleWidth = width / 5;
20 | const scaleLength = length / 10;
21 | const scaleX = x / 10;
22 | const scaleY = y / 10;
23 |
24 | const paddingWidth = 40;
25 | const svgWidth = scaleWidth + paddingWidth * 2;
26 |
27 | const centerX = scaleWidth / 2 + paddingWidth;
28 |
29 | const identPadding = 25;
30 | const identRectWidth = svgWidth / 1.1;
31 | const identRectStroke = 2;
32 | const identYOffset = 28;
33 | const identX = centerX;
34 | const identRectHeight = 52;
35 |
36 | const leIdentY = identYOffset;
37 | const leIdentRectX = identRectStroke * 1.5;
38 | const leIdentRectY = identRectStroke;
39 | const leIdentRotation = he_heading > 90 && he_heading < 270 ? 180 : 0;
40 |
41 | const rwyX = paddingWidth;
42 | const rwyY = leIdentRectY + identRectHeight + identRectStroke + identPadding;
43 |
44 | const rwyLinePadding = 40;
45 | const rwyLineTopX = centerX;
46 | const rwyLineTopY = rwyY + rwyLinePadding;
47 | const rwyLineBottomX = rwyLineTopX;
48 | const rwyLineBottomY = rwyY + scaleLength - rwyLinePadding;
49 |
50 | const heIdentRectX = identRectStroke * 1.5;
51 | const heIdentRectY = rwyY + scaleLength + identPadding + identRectStroke;
52 | const heIdentRotation = he_heading > 90 && he_heading < 270 ? 180 : 0;
53 |
54 | const heIdentY = heIdentRectY + identYOffset - identRectStroke;
55 |
56 | const svgHeight = heIdentRectY + identRectHeight + identRectStroke;
57 |
58 | const originY = rwyY + scaleLength;
59 |
60 | let activeSide = null;
61 | let isCrosswind = false;
62 | if (le_active?.headtailwindType === "headwind") {
63 | activeSide = "le";
64 | isCrosswind = le_active.status === "crosswind";
65 | } else if (he_active?.headtailwindType === "headwind") {
66 | activeSide = "he";
67 | isCrosswind = he_active.status === "crosswind";
68 | }
69 |
70 | return (
71 | <>
72 |
93 |
118 | >
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/AirportSelectInput.js:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { Loader2, TriangleAlert } from "lucide-react";
3 | import React, { useCallback, useEffect, useState } from "react";
4 | import { DEFAULT_METAR_PROVIDER, METAR_PROVIDER_STORAGE_KEY } from "../consts";
5 | import useAirportFetch from "../hooks/useAirportFetch";
6 | import useStickyState from "../hooks/useStickyState";
7 |
8 | export default function AirportSelectInput(props) {
9 | const [metarProviderValue, setMetarProviderValue] = useStickyState(DEFAULT_METAR_PROVIDER, METAR_PROVIDER_STORAGE_KEY);
10 | const [icaoValue, setIcaoValue] = useState(props.initialValue ?? "");
11 | const [error, setError] = useState(props.initialError ?? null);
12 | const [edited, setEdited] = useState(false);
13 | const [isLoading, setLoading] = useState(false);
14 | const debouncedIcaoValue = useDebounced(icaoValue, 500);
15 |
16 | const handleIcaoValueChange = useCallback((e) => {
17 | setError(null);
18 | setIcaoValue(e.target.value);
19 | setEdited(true);
20 | }, []);
21 |
22 | const handleMetarProviderValueChange = useCallback((e) => {
23 | setMetarProviderValue(e.target.value);
24 | }, []);
25 |
26 | const fetchData = useAirportFetch({
27 | onLoading: setLoading,
28 | onError: setError,
29 | onLoaded: props.onDataLoaded,
30 | });
31 |
32 | useEffect(() => {
33 | if (debouncedIcaoValue) {
34 | fetchData(metarProviderValue, debouncedIcaoValue);
35 | } else if (edited) {
36 | setLoading(false);
37 | setError(null);
38 | }
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | }, [metarProviderValue, debouncedIcaoValue]);
41 |
42 | return (
43 |
44 |
45 |
METAR provider
46 |
48 | Aviation Weather
49 | VATSIM
50 |
51 |
Enter ICAO
52 |
53 |
62 | {isLoading || error ? (
63 |
68 | {isLoading ? : error ? : null}
69 |
70 | ) : null}
71 | {error ? (
72 |
73 | {error}
74 |
75 | ) : null}
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | const useDebounced = (value, delay) => {
83 | // State and setters for debounced value
84 | const [debouncedValue, setDebouncedValue] = useState(value);
85 |
86 | useEffect(
87 | () => {
88 | // Update debounced value after delay
89 | const handler = setTimeout(() => {
90 | setDebouncedValue(value);
91 | }, delay);
92 | // Cancel the timeout if value changes (also on delay change or unmount)
93 | // This is how we prevent debounced value from updating if value is changed ...
94 | // .. within the delay period. Timeout gets cleared and restarted.
95 | return () => {
96 | clearTimeout(handler);
97 | };
98 | },
99 | [value, delay] // Only re-call effect if value or delay changes
100 | );
101 | return debouncedValue;
102 | };
103 |
--------------------------------------------------------------------------------
/server/api/runway.cjs:
--------------------------------------------------------------------------------
1 | const downloadFile = require("../helpers/downloadData.cjs");
2 | const metarParser = require('aewx-metar-parser');
3 |
4 | const createMetarUrl = (provider, icao) => {
5 | switch (provider.toLowerCase()) {
6 | case 'vatsim':
7 | return `https://metar.vatsim.net/${icao}`;
8 | case 'aviationweather':
9 | default:
10 | return `https://aviationweather.gov/api/data/metar?ids=${icao}`;
11 | }
12 | };
13 |
14 | const createAirportUrl = (icao) =>
15 | `https://airportdb.io/api/v1/airport/${icao}?apiToken=${process.env.AIRPORTDB_API_TOKEN}`;
16 |
17 | const isNumber = (value) => {
18 | return typeof value === "number" && !isNaN(value);
19 | };
20 |
21 | const isString = (value) => {
22 | return typeof value === "string" && value.length;
23 | };
24 |
25 | const runwayAPI = async (req, res) => {
26 | try {
27 | const { icao } = req.params;
28 | const metarProvider = req.query.metarProvider || 'aviationweather';
29 |
30 | const airportUrl = createAirportUrl(icao);
31 | const airportDataRaw = await downloadFile(airportUrl);
32 | const airportData = JSON.parse(airportDataRaw);
33 |
34 | if (!airportData.ident) {
35 | return res.json({
36 | code: 2,
37 | error: `Can't find airport ${icao.toUpperCase()} data. Try to search a nearest bigger airport`,
38 | });
39 | }
40 | if (!airportData.runways || !airportData.runways.length) {
41 | return res.json({
42 | code: 3,
43 | error: `Sorry. The requested airport has invalid runway data, so it can't be displayed. Try other nearest airport`,
44 | });
45 | }
46 |
47 | let station = {
48 | icao_code: airportData.icao_code,
49 | distance: 0,
50 | };
51 | if (
52 | airportData.station &&
53 | airportData.station.icao_code !== airportData.icao_code
54 | ) {
55 | station = airportData.station;
56 | }
57 |
58 | const runways = airportData.runways.map((runway) => {
59 | return {
60 | width_ft: parseFloat(runway.width_ft),
61 | length_ft: parseFloat(runway.length_ft),
62 | le_ident: runway.le_ident,
63 | he_ident: runway.he_ident,
64 | he_latitude_deg: parseFloat(runway.he_latitude_deg),
65 | he_longitude_deg: parseFloat(runway.he_longitude_deg),
66 | he_heading_degT: parseFloat(runway.he_heading_degT),
67 | le_ils: runway.le_ils,
68 | he_ils: runway.he_ils,
69 | };
70 | });
71 |
72 | const validRunways = runways.filter((runway) => {
73 | return (
74 | isNumber(runway.width_ft) &&
75 | runway.width_ft > 0 &&
76 | isNumber(runway.length_ft) &&
77 | runway.length_ft > 0 &&
78 | isString(runway.le_ident) &&
79 | isString(runway.he_ident) &&
80 | isNumber(runway.he_latitude_deg) &&
81 | isNumber(runway.he_longitude_deg) &&
82 | isNumber(runway.he_heading_degT)
83 | );
84 | });
85 |
86 | if (!validRunways.length) {
87 | return res.json({
88 | code: 4,
89 | error: `Sorry. The requested airport has invalid runway data, so it can't be displayed. Try other nearest airport`,
90 | });
91 | }
92 |
93 | const metarUrl = createMetarUrl(metarProvider, station.icao_code);
94 |
95 | const metar = await downloadFile(metarUrl);
96 |
97 | if (!metar.trim()) {
98 | return res.json({
99 | code: 1,
100 | error: `Can't find airport ${icao.toUpperCase()} metar data. Try to search a nearby international airport`,
101 | });
102 | }
103 |
104 | const metarData = metarParser(metar.trim());
105 |
106 | const rawMetar = metar;
107 | const wind_direction = metarData.wind.degrees_from === 0 && metarData.wind.degrees_to === 359 ? 'VRB' : metarData.wind.degrees;
108 | const wind_speed = metarData.wind.speed_kts;
109 | const time = metarData.observed;
110 |
111 | res.json({
112 | name: airportData.name,
113 | metar: rawMetar,
114 | runways: validRunways,
115 | wind_direction,
116 | wind_speed,
117 | icao: icao.toUpperCase(),
118 | station,
119 | time,
120 | });
121 | } catch (err) {
122 | console.error(err);
123 | return res.status(500).send("Internal server error");
124 | }
125 | };
126 |
127 | module.exports = runwayAPI;
128 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 | Which runway to choose? The best runway suggestion based on wind
13 |
14 |
15 |
16 |
17 |
21 |
25 |
26 |
27 |
28 |
29 |
33 |
37 |
41 |
42 |
43 |
44 |
45 |
49 |
53 |
57 |
58 |
59 |
60 |
61 |
62 |
67 |
72 |
77 |
82 |
87 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | You need to enable JavaScript to run this app.
103 |
104 |
105 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/src/pages/AirportPage/AirportRunwayCard.js:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import React from "react";
3 | import { ucfirst } from "../../helpers";
4 |
5 | const round = (value) => Math.round(value).toFixed(0);
6 |
7 | const colorMap = {
8 | headwind: "#1fa71f",
9 | crosswind: "#dea109",
10 | tailwind: "black",
11 | };
12 |
13 | const backgroundMap = {
14 | headwind: "#74da74",
15 | crosswind: "#f5ca5f",
16 | tailwind: "transparent",
17 | };
18 |
19 | const AirportRunwayCard = (props) => {
20 | const { active } = props;
21 | return (
22 |
23 |
24 |
25 |
29 | {props.ident}
30 |
31 |
39 | {ucfirst(active?.status ?? "Variable")}
40 |
41 |
42 |
43 | {active?.headtailwindType ? (
44 |
45 |
46 | {ucfirst(active.headtailwindType)}: {" "}
47 |
48 |
49 | {round(active.headtailwind)}
50 | {" "}
51 | kts
52 |
53 |
54 |
55 | Crosswind: {" "}
56 |
57 |
58 | {round(active.crosswind)}
59 | {" "}
60 | kts
61 |
62 |
63 |
64 |
65 | Crosswind from {active.crosswindSide}
66 |
67 |
68 |
69 | ) : null}
70 |
71 | Heading: {" "}
72 |
73 |
74 | {round(props.heading)}
75 | {" "}
76 | degT
77 |
78 |
79 |
80 | Length: {" "}
81 |
82 |
83 | {round(props.length)}
84 | {" "}
85 | ft
86 |
87 |
88 |
89 | Width: {" "}
90 |
91 |
92 | {round(props.width)}
93 | {" "}
94 | ft
95 |
96 |
97 |
98 | Inverted mark: {" "}
99 |
100 |
101 | {props.invertMark}
102 | {" "}
103 |
104 |
105 |
106 | ILS: {" "}
107 |
108 |
109 | {props.ils ? (
110 | {props.ils.freq + "/" + props.ils.course}°
111 | ) : (
112 | "No"
113 | )}
114 |
115 |
116 |
117 |
118 |
119 |
120 | );
121 | };
122 |
123 | export default AirportRunwayCard;
124 |
--------------------------------------------------------------------------------
/src/components/RunwaysMap/RunwaysMap.js:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import React, { useLayoutEffect, useState } from "react";
3 | import { fitRectIntoBounds, latLngToFt } from "../../helpers";
4 | import Runway from "./Runway";
5 |
6 | function RunwaysMap(props) {
7 | const { runwaysData, activeRunwaysData } = props;
8 |
9 | const [state, setState] = useState({
10 | scale: 1,
11 | x: 0,
12 | y: 0,
13 | cx: 0,
14 | cy: 0,
15 | height: undefined,
16 | });
17 |
18 | useLayoutEffect(() => {
19 | const $runwaysMap = document.getElementById("runwaysMap");
20 | const runwaysMapRect = $runwaysMap.getBoundingClientRect();
21 | const mapWidth = runwaysMapRect.width - 20;
22 | const mapHeight = window.innerHeight * 0.8;
23 | const $runwaysContainer = document.getElementById("runwaysContainer");
24 | const runwaysContainerRect = $runwaysContainer.getBoundingClientRect();
25 | const $runways = document.querySelectorAll(".runway, .runway-ident");
26 | const $firstRunway = $runways[0];
27 | const firstRunwayRect = $firstRunway.getBoundingClientRect();
28 |
29 | let theMostLeft = firstRunwayRect.left;
30 | let theMostRight = firstRunwayRect.right;
31 | let theMostTop = firstRunwayRect.top;
32 | let theMostBottom = firstRunwayRect.bottom;
33 | for (const $runway of $runways) {
34 | const rect = $runway.getBoundingClientRect();
35 | if (rect.left < theMostLeft) theMostLeft = rect.left;
36 | if (rect.top < theMostTop) theMostTop = rect.top;
37 | if (rect.right > theMostRight) theMostRight = rect.right;
38 | if (rect.bottom > theMostBottom) theMostBottom = rect.bottom;
39 | }
40 |
41 | // compensate scroll
42 | theMostTop += window.scrollY;
43 | theMostBottom += window.scrollY;
44 |
45 | const width = theMostRight - theMostLeft;
46 | const height = theMostBottom - theMostTop;
47 |
48 | const newDimensions = fitRectIntoBounds(
49 | { width, height },
50 | { width: mapWidth, height: mapHeight }
51 | );
52 | const scaleRatio = newDimensions.width / width;
53 |
54 | const offsetX = runwaysContainerRect.left - theMostLeft - width / 2;
55 | // const offsetCY = runwaysContainerRect.top - theMostTop - height / 2;
56 | const offsetY =
57 | runwaysContainerRect.top + window.scrollY - theMostTop - height / 2;
58 |
59 | setState({
60 | x: offsetX,
61 | y: offsetY + newDimensions.height / 2,
62 | cx: -offsetX,
63 | cy: -offsetY,
64 | height: newDimensions.height,
65 | scale: scaleRatio,
66 | });
67 | }, []);
68 |
69 | let runways = runwaysData.map((runway) => {
70 | const { x, y } = latLngToFt(
71 | runway.he_latitude_deg,
72 | runway.he_longitude_deg
73 | );
74 | return {
75 | ...runway,
76 | x,
77 | y,
78 | };
79 | });
80 |
81 | let xs = runways.map((runway) => runway.x);
82 | let ys = runways.map((runway) => runway.y);
83 |
84 | let xsd = [0];
85 | let lastX = xs[0];
86 | for (let i = 1; i < xs.length; i++) {
87 | const x = xs[i];
88 | let d = x - lastX;
89 | xsd.push(d);
90 | }
91 |
92 | let ysd = [0];
93 | let lastY = ys[0];
94 | for (let i = 1; i < ys.length; i++) {
95 | const y = ys[i];
96 | let d = y - lastY;
97 | ysd.push(d);
98 | }
99 |
100 | runways = runways.map((runway, index) => {
101 | return {
102 | ...runway,
103 | x: xsd[index],
104 | y: ysd[index],
105 | le_active: activeRunwaysData[runway.le_ident],
106 | he_active: activeRunwaysData[runway.he_ident],
107 | };
108 | });
109 |
110 | return (
111 |
119 |
125 |
134 | {runways.map((runway, index) => {
135 | return (
136 |
148 | );
149 | })}
150 |
151 |
152 |
153 | );
154 | }
155 |
156 | export default RunwaysMap;
157 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (import.meta.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(import.meta.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${import.meta.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/pages/CookiesPolicyPage.js:
--------------------------------------------------------------------------------
1 | import { Helmet } from "react-helmet";
2 | import PageLayout from "../components/PageLayout";
3 | import React from 'react';
4 |
5 | const CookiesPolicyPage = () => {
6 | return (
7 |
8 |
9 | Cookies policy | Which runway to choose?
10 |
11 |
12 |
13 | Cookie Policy for Which runway to choose?
14 |
15 |
16 |
17 | This is the Cookie Policy for Which runway to choose?, accessible from{" "}
18 |
19 | https://runway.airportdb.io
20 |
21 |
22 |
23 |
24 | What Are Cookies
25 |
26 |
27 |
28 | As is common practice with almost all professional websites this site
29 | uses cookies, which are tiny files that are downloaded to your
30 | computer, to improve your experience. This page describes what
31 | information they gather, how we use it and why we sometimes need to
32 | store these cookies. We will also share how you can prevent these
33 | cookies from being stored however this may downgrade or 'break'
34 | certain elements of the sites functionality.
35 |
36 |
37 |
38 | How We Use Cookies
39 |
40 |
41 |
42 | We use cookies for a variety of reasons detailed below. Unfortunately
43 | in most cases there are no industry standard options for disabling
44 | cookies without completely disabling the functionality and features
45 | they add to this site. It is recommended that you leave on all cookies
46 | if you are not sure whether you need them or not in case they are used
47 | to provide a service that you use.
48 |
49 |
50 |
51 | Disabling Cookies
52 |
53 |
54 |
55 | You can prevent the setting of cookies by adjusting the settings on
56 | your browser (see your browser Help for how to do this). Be aware that
57 | disabling cookies will affect the functionality of this and many other
58 | websites that you visit. Disabling cookies will usually result in also
59 | disabling certain functionality and features of the this site.
60 | Therefore it is recommended that you do not disable cookies. .
61 |
62 |
63 | The Cookies We Set
64 |
65 |
66 |
67 |
68 |
69 | Account related cookies
70 |
71 |
72 | If you create an account with us then we will use cookies for the
73 | management of the signup process and general administration. These
74 | cookies will usually be deleted when you log out however in some
75 | cases they may remain afterwards to remember your site preferences
76 | when logged out.
77 |
78 |
79 |
80 | Login related cookies
81 |
82 | We use cookies when you are logged in so that we can remember this
83 | fact. This prevents you from having to log in every single time
84 | you visit a new page. These cookies are typically removed or
85 | cleared when you log out to ensure that you can only access
86 | restricted features and areas when logged in.
87 |
88 |
89 |
90 |
91 |
92 | Third Party Cookies
93 |
94 |
95 |
96 | In some special cases we also use cookies provided by trusted third
97 | parties. The following section details which third party cookies you
98 | might encounter through this site.
99 |
100 |
101 |
102 |
103 |
104 | This site uses Google Analytics which is one of the most
105 | widespread and trusted analytics solution on the web for helping
106 | us to understand how you use the site and ways that we can improve
107 | your experience. These cookies may track things such as how long
108 | you spend on the site and the pages that you visit so we can
109 | continue to produce engaging content.
110 |
111 |
112 | For more information on Google Analytics cookies, see the official
113 | Google Analytics page.
114 |
115 |
116 |
117 |
118 |
119 | More Information
120 |
121 |
122 |
123 | Hopefully that has clarified things for you and as was previously
124 | mentioned if there is something that you aren't sure whether you need
125 | or not it's usually safer to leave cookies enabled in case it does
126 | interact with one of the features you use on our site.
127 |
128 |
129 |
130 | For more general information on cookies, please read{" "}
131 |
135 | "What Are Cookies"
136 |
137 | .
138 |
139 |
140 |
141 | However if you are still looking for more information then you can
142 | contact us through one of our preferred contact methods:
143 |
144 |
145 |
150 |
151 |
152 | );
153 | };
154 |
155 | export default CookiesPolicyPage;
156 |
--------------------------------------------------------------------------------
/src/components/RunwaysMap/RunwayBlock.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function RunwayBlock(props) {
4 | const {
5 | activeSide,
6 | isCrosswind,
7 | le_ident,
8 | he_ident,
9 | scaleX,
10 | scaleY,
11 | he_heading,
12 | originY,
13 | svgWidth,
14 | svgHeight,
15 | rwyX,
16 | rwyY,
17 | scaleWidth,
18 | scaleLength,
19 | rwyLineBottomX,
20 | rwyLineBottomY,
21 | rwyLineTopX,
22 | rwyLineTopY,
23 | originAtCenter,
24 | } = props;
25 |
26 | const mark1Width = (scaleWidth / 2) * 0.5;
27 | const mark2Width = scaleWidth / 2;
28 |
29 | return (
30 |
48 | {activeSide === "le" ? (
49 |
58 |
66 |
74 |
75 | ) : activeSide === "he" ? (
76 |
85 |
93 |
101 |
102 | ) : null}
103 |
111 |
119 |
120 |
121 |
122 |
130 |
138 |
146 |
147 |
148 |
156 |
164 |
165 |
166 |
167 |
177 |
191 | {/* Le markings */}
192 |
199 |
200 |
205 |
206 |
211 |
218 | {/* He markings */}
219 |
226 |
231 |
236 |
241 |
246 |
252 |
253 |
254 | );
255 | }
256 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Which runway to choose?
6 |
7 | Runway calculator based on wind
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | > Check which runway to choose here: runway.airportdb.io
26 |
27 |
28 |
29 |
30 | This project was created just after I released the Airport database API service. Check it out too: airportdb.io . I am flying a lot in flight simulators like X-Plane or MS Flight Simulator and often come with problem to fastly determine the best active runway for the airport (in case if ATC is not available) based on wind. There rule of thumb, that wind direction should be as much as possible closer to runway heading. But what if the airport has multiple runways and the wind direction doesn't match exactly any of these runways, and I want to choose the landing with the least cross-wind?. Of course, I can calculate it, but it takes some time and effort.
31 |
32 | So I decide to create this tool, that lets to enter the airport ICAO code and get all best runway suggestions based on wind data.
33 |
34 |
35 |
36 | Instruction
37 |
38 |
39 | On the main page of the application, enter the airport ICAO code from that you want to take off or land.
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | After entering the airport ICAO you will be redirected to the result page.
48 |
49 |
50 |
51 | In the first part of the result page, you will see the airport name, last METAR update date, METAR data itself, and wind direction.
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | A little below is the main information: runway suggestions. All runways are sorted by two properties. In the first place, you will get all runways that have headwind, in the second crosswind, and the last tailwinds. Every group of runways is also sorted by cross-wind in ascending order. So the first runway in the list should be the best option because it should be with a headwind (if exist) and with a minimal crosswind.
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | And the last part of the page shows the minimal airport runways map. Green arrows show the headwind runways and the yellow arrows - crosswind runways. Also in the background, you will see light blue arrows which represent wind direction.
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | Have ideas on how to improve it or found a bug? Share it on the [GitHub Issues](https://github.com/epranka/runway-app/issues).
77 |
78 | If you have any questions, feel free to ask. Feedback and questions are very appreciated.
79 |
80 | Follow on [Twitter](https://twitter.com/epranka), [GitHub](https://github.com/epranka), and let’s connect on [LinkedIn](https://linkedin.com/in/epranka)
81 |
--------------------------------------------------------------------------------