[
  {
    "path": ".github/FUNDING.yml",
    "content": "buy_me_a_coffee: machineinteractive\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\ndist/\n.env*\n__pycache__\n.venv\nflask/config.json\npublic/map-data\nmaps/data/10m_cultural\nmaps/data/10m_physical\nmaps/data/110m_cultural\nmaps/data/110m_physical\nmaps/data/Class_Airspace\nmaps/data/10m_cultural.zip\nmaps/data/10m_physical.zip\nmaps/data/110m_cultural.zip\nmaps/data/110m_physical.zip\nmaps/data/Class_Airspace.zip\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"type\": \"pwa-chrome\",\n      \"request\": \"launch\",\n      \"name\": \"Launch Chrome against localhost\",\n      \"url\": \"http://localhost:8080\",\n      \"webRoot\": \"${workspaceFolder}\"\n    }\n  ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"cSpell.words\": [\n    \"ADSB\",\n    \"dpkg\",\n    \"FLIGHTAWARE\",\n    \"geolocation\",\n    \"Geospatial\",\n    \"Guardia\",\n    \"Imager\",\n    \"KLGA\",\n    \"KMIA\",\n    \"MMMX\",\n    \"nmap\",\n    \"osmtogeojson\",\n    \"planespotters\",\n    \"QGIS\",\n    \"raspberrypi\",\n    \"raycaster\",\n    \"readsb\",\n    \"Shapefile\",\n    \"Shapefiles\",\n    \"SKYBOX\",\n    \"VITE\",\n    \"WEBROOT\",\n    \"websockify\"\n  ]\n}"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [2.4.3] - 2025-03-11\n\n### Added\n\n- Added additional parameters to build-map-layers script for creating rectangular map bounding areas\n\n### Changed\n\n- Adjust range for auto-orbit camera vertical and horizontal speed settings from -0.2 to 0.2\n\n## [2.4.2] - 2025-03-10\n\n### Changed\n\n- Fixed typo in maps/build-map-layers.py\n\n## [2.4.1] - 2025-03-10\n\n### Changed\n\n- Fixed bug in UTILS.setOrigin and UTILS.getXY calculations for lat/lon offsets\n\n## [2.4.0] - 2025-03-06\n\n### Added\n\n- Added aerodrome and runway elevation visualization with ground projections\n- Added `SKIES_ADSB_DEFAULT_ORIGIN_ELEVATION_METERS_OPTIONAL` environment variable\n- Updated orbit and auto-orbit cameras to handle origin elevation\n- Enhanced runway visibility with contrasting white material\n\n### Changed\n\n- Adjust height of origin labels\n- Adjust starting default orbit camera settings\n- Modified maps.js origins creation to include elevation data\n- Modified build-map-layers script to include spatial join of aerodrome and runway data for elevation information\n\n## [2.3.2] - 2025-03-05\n\n### Changed\n\n- Fixed typos in INSTALL.md\n\n## [2.3.1] - 2025-03-05\n\n### Changed\n\n- Update project documentation\n\n## [2.3.0] - 2025-03-02\n\n### Added\n\n- Added basic flight information to aircraft dialog using planespotters.net aircraft registration data\n\n### Changed\n\n- Hide FlightAware Flight info section in flight info dialog on empty JSON response\n- Adjusted aircraft flight info dialog header text\n- Fixed typo in update_flask_app.sh script\n- Updated Flask app to return empty json response for flightInfo on failure to find FlightAware AeroAPI key\n- Update docs with explanation of changes\n\n## [2.2.1] - 2025-03-01\n\n### Changed\n\n- Changed build-map-layer.sh default environment variables prefix from `VITE_` to `SKIES_ADSB_`\n\n## [2.2.0] - 2025-03-01\n\n### Added\n\n- Added \"Auto-Orbit\" camera mode\n- Added \"Auto-Orbit\" camera controls to dat-gui settings\n- Added new gif recording of v2.2.0\n\n### Changed\n\n- Changed default environment variables prefix from `VITE_` to `SKIES_ADSB_`\n- Adjusted urban layer Y position\n- Updated automation + build scripts to use `SKIES_ADSB_` environment variables\n- Updated docs\n- Misc clean up\n\n## [2.1.8] - 2025-02-24\n\n### Changed\n\n- Updated troika-three-text library to 0.52.3\n- Fixed bug in Aircraft.hasExpired() causing incorrect aircraft expiration\n\n## [2.1.7] - 2025-02-20\n\n### Changed\n\n- Fixed error in UTILS.getXY due to typo\n\n## [2.1.6] - 2025-02-20\n\n### Changed\n\n- Misc documentation clean up\n\n## [2.1.5] - 2025-02-20\n\n### Changed\n\n- Update project README.md with link to SDR Enthusiasts compatible Docker container\n\n## [2.1.4] - 2025-02-19\n\n### Changed\n\n- Update project README.md with Docker container notice.\n\n## [2.1.3] - 2025-02-19\n\n### Changed\n\n- Misc documentation clean up\n\n## [2.1.2] - 2025-02-19\n\n### Changed\n\n- Update DEVELOPMENT.md Flask app update instructions\n\n## [2.1.1] - 2025-02-19\n\n### Changed\n\n- Fixed script initialization issue in install-skies-adsb.sh\n\n## [2.1.0] - 2025-02-19\n\n### Added\n\n- Added readsb RTL-SDR driver option in installation process\n\n### Changed\n\n- Fixed aircraft TTL bug caused by improper type check\n- Simplified Raspberry Pi installation process\n  - Removed need for manual script editing\n  - Added command line options\n  - Renamed `install.sh` to `install-skies-adsb.sh`\n- Updated documentation\n  - Updated INSTALL.md and RPI-INSTALL-GUIDE.md\n  - Renamed LOCALHOST-SETUP-GUIDE.md to LOCALHOST-HEADLESS-SETUP-GUIDE.md\n\n## [2.0.9] - 2025-02-16\n\n## Changed\n\n- Fixed INSTALL.md table of contents.\n\n## [2.0.8] - 2025-02-16\n\n## Changed\n\n- Misc documentation clean up\n\n## [2.0.7] - 2025-02-16\n\n## Changed\n\n- Updated use_existing_adsb.sh script\n  - added --host option when launching Vite in order to automatically setup Network IP for development server\n  - removed --open option in order to prevent failure if run in a headless setup\n- Updated utils.js to use window.location.hostname instead of hardcoded localhost string for Localhost setups\n- Updated RPI and Localhost installation guides and consolidated redundant sections into the docs/INSTALL.md guide\n- Misc documentation clean up\n\n## [2.0.6] - 2025-02-15\n\n## Changed\n\n- Update docs/INSTALL.md repo url\n\n## [2.0.5] - 2025-02-15\n\n## Changed\n\n- Updated project README.md\n\n## [2.0.4] - 2025-02-15\n\n## Added\n\n- Added documentation for enabling remote access to Raspberry Pi dump1090-mutability\n- Added documentation for customizing default visualization settings\n- Added documentation for create map layers for larger coverage areas\n- Added --skip-aerodromes option to build-map-layers.py script\n\n## Changed\n\n- Refactored many default settings to be user configurable via src/utils.js file\n- Updated instructions on how to work with existing ADS-B receivers\n- Updated project README.md\n- Update Vite to 5.4.14\n- Update @mapbox/sphericalmercator to 2.0.1\n\n## [2.0.3] - 2025-02-11\n\n### Changed\n\n- Updated project README.md\n- Updated DEVELOPMENT.md\n\n## [2.0.2] - 2025-02-11\n\n### Changed\n\n- Fixed typo in BUILD-MAPS.md\n\n## [2.0.1] - 2025-02-11\n\n### Added\n\n- Build-map-layers.sh bash automation script\n\n### Changed\n\n- Misc documentation typo fixes\n- Updated map layer building instructions to use build-map-layers.sh script\n\n## [2.0.0] - 2025-02-02\n\n### Added\n\n- Generate custom GeoJSON map layers from Natural Earth, FAA, and OpenStreetMap data\n- Aircraft trails visualization\n- Enhanced map renderer with multi-layer vector support:\n  - Aerodromes\n  - Airspaces\n  - States / Provinces\n  - Counties\n  - Urban areas\n  - Roads\n  - Rivers\n  - Lakes\n- New aircraft follow camera controls\n- Added project sponsor button via Buy Me a Coffee\n\n### Changed\n\n- Major codebase refactoring and simplification\n- Simplified setup and build process\n- Updated documentation to reflect migration to Raspberry Pi OS 64-bit\n- Update project screenshots and recordings\n- Updated the project README\n- Updated METAR api call to use new aviationweather.gov JSON endpoint\n\n### Removed\n\n- Removed outdated CLOUDFLARE-TUNNEL.md documentation\n\n## [1.3.2] - 2024-12-24\n\n### Changed\n\n- Misc refactoring of main.js\n\n## [1.3.1] - 2024-12-24\n\n### Changed\n\n- Misc refactoring of aircraft.js\n\n## [1.3.0] - 2024-12-23\n\n### Changed\n\n- Refactored aircraft follow camera logic and controls\n\n## [1.2.5] - 2024-03-19\n\n### Removed\n\n- Removed TODO\n\n## [1.2.4] - 2024-03-12\n\n### Changed\n\n- Updated Raspberry Pi Install Guide\n\n## [1.2.3] - 2024-03-12\n\n### Changed\n\n- Updated localhost Install Guide\n\n## [1.2.2] - 2024-03-10\n\n### Changed\n\n- Updated Raspberry Pi Install Guide with additional Vite environment variable usage instructions\n\n## [1.2.1] - 2024-03-10\n\n### Changed\n\n- Updated Raspberry Pi Install Guide with Vite environment variable usage instructions\n\n## [1.2.0] - 2024-03-10\n\n### Changed\n\n- Migrated project build system to use Vite instead of webpack\n- Updated project documentation to reflect usage of Vite\n\n## [1.1.1] - 2023-11-22\n\n### Changed\n\n- Misc updates to project README\n\n## [1.1.0] - 2023-11-18\n\n### Changed\n\n- Migrated flask app to use FlightAware AeroAPI v4\n\n## [1.0.2] - 2022-05-22\n\n### Changed\n\n- Disable user-select CSS on aircraft dialog to prevent loss of focus on main rendering widow\n\n## [1.0.1] - 2022-05-15\n\n### Changed\n\n- Disable user-select CSS on HUD buttons to prevent loss of focus on main rendering widow\n\n## [1.0.0] - 2022-05-14\n\n- First stable release\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Don E. Llopis\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# skies-adsb\n\n### ✈️ [Current Version: 2.4.3](CHANGELOG.md) 🚁\n\n![Screenshot](docs/screenshot.png)\n\n_Image of the skies-adsb app running in a browser showing air traffic around KMIA in Miami, FL_\n\n# Introduction\n\nskies-adsb transforms your browser into a real-time 3D air traffic display. Using ADS-B data from an RTL-SDR receiver, you can explore local air traffic, surrounding airspace, and geography with customizable 3D maps.\n\nBuilt with:\n\n- JavaScript\n- HTML5\n- CSS\n- Python 3\n- WebGL (Three.js)\n\nRuns on all major modern browsers (Chrome, Firefox, Safari).\n\n## Features\n\n- Real-time aircraft tracking and rendering using unfiltered [ADS-B](https://mode-s.org/decode/content/ads-b/1-basics.html) data\n- Deployable on a [Raspberry Pi](https://www.raspberrypi.org/) on your local network\n- Compatible with existing ADS-B installations on separate hosts\n- Enhanced flight data via [FlightAware AeroAPI v4](https://flightaware.com/commercial/aeroapi/)\n- Aircraft photos and additional information via [Planespotters.net](https://www.planespotters.net/)\n- Custom map layers powered by [Natural Earth Data](https://www.naturalearthdata.com/), [FAA Aeronautical Data Delivery Service](https://adds-faa.opendata.arcgis.com/), and [OpenStreetMap](https://www.openstreetmap.org/)\n\n- Touch-friendly mobile web interface\n- Install as PWA on mobile or desktop\n\n![Gif Recording](docs/skies-adsb-v2.2.0-recording.gif)\n\n_Recording of the skies-adsb app running in a browser demonstrating the use of the onscreen controls_\n\n![Custom Map Layers](docs/custom-map-layers.png)\n\n_Examples of custom map layers: Miami International (KMIA), LaGuardia (KLGA), and Mexico City International (MMMX) airports_\n\n# Build and Installation\n\nskies-adsb requires a build process prior to deployment and cannot be run directly from source code.\n\nFor complete build and installation instructions, see [INSTALL.md](docs/INSTALL.md).\n\n### NOTE: Version 2.x Release\n\nThere were breaking changes from **1.x** to **2.x.** You will need to reinstall the app if you were running the 1.x version.\n\nPlease see the [CHANGELOG.md](CHANGELOG.md) for details.\n\n# Contributing\n\n## Development\n\nFor development setup and guidelines, see [DEVELOPMENT.md](docs/DEVELOPMENT.md).\n\n## Issues\n\nUse the issue tracker to:\n\n- Report bugs\n- Request features (Please no requests for Docker containers--see below)\n- Suggest improvements\n\nPlease include relevant details and steps to reproduce when submitting issues.\n\n### Docker\n\nThank you for your interest in Docker. While I appreciate the interest in Docker containers, my development efforts are currently focused on core functionality. For a Docker container compatible with the [SDR Enthusiasts project](https://github.com/sdr-enthusiasts), check out:\n\nhttps://github.com/kx1t/docker-skies-adsb\n\nsee:\n\nhttps://github.com/machineinteractive/skies-adsb/issues/6\n\n## Community Screenshots\n\nPlease share screenshots of your skies-adsb installation in action! To submit a screenshot please open an issue, attach a screenshot, and label it:\n\n```\nscreenshot\n```\n\n# Support This Project\n\n<a href=\"https://www.buymeacoffee.com/machineinteractive\" target=\"_blank\">![Buy me a coffee](docs/bmc-button.png)</a>\n\n# Thanks\n\nI would like to give special thanks to the following people who gave me valuable feedback and helped me debug the app:\n\nAndre Thais CFI\n\n[Frank E. Hernandez](https://github.com/CodeMinion)\n\nI would also like to thank the authors and creators of the datasets, hardware, libraries, tools, and tutorials used to create this project. None of this would have been possible without their creations.\n\n# Attribution\n\n## Natural Earth Data\n\nHigh-quality public domain map datasets are provided by [Natural Earth](https://www.naturalearthdata.com/).\n\n![Natural Earth Logo](docs/NEV-Logo-Black.png)\n\n## OpenStreetMap Data\n\nAdditional map data provided by [OpenStreetMap](https://www.openstreetmap.org/copyright) via the Overpass API.\n\n## Fallback Aircraft Photo\n\nPan Am Boeing 747-121 N732PA image by Aldo Bidini  \nSource: [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Pan_Am_Boeing_747-121_N732PA_Bidini.jpg)\n\n# References\n\n## Raspberry Pi\n\n[Raspberry Pi Documentation](https://www.raspberrypi.com/documentation/)\n\n## RTL-SDR + ADS-B\n\n[The 1090 Megahertz Riddle (second edition) A Guide to Decoding Mode S and ADS-B Signals](https://mode-s.org/1090mhz/)\n\n[RTL-SDR Quick Start Guide](https://www.rtl-sdr.com/rtl-sdr-quick-start-guide/)\n\n[Gqrx is an open source software defined radio receiver ](https://www.gqrx.dk/)\n\n[FlightAware PiAware](https://www.flightaware.com/adsb/piaware/)\n\n[FlightAware AeroAPI](https://www.flightaware.com/commercial/aeroapi/)\n\n## GIS\n\n[PyGIS - Open Source Spatial Programming & Remote Sensing](https://pygis.io/)\n\nhttps://geopandas.org/\n\n## Datasets\n\n[Natural Earth Data](https://www.naturalearthdata.com/)\n\n[FAA Aeronautical Data Delivery Service](https://adds-faa.opendata.arcgis.com/)\n\n[OpenStreetMap](https://www.openstreetmap.org/)\n"
  },
  {
    "path": "deploy_web_app.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# this script deploys the dist folder to the RPI server\n#\n\nWEBROOT=\"/var/www/html/skies-adsb\"\n\nsource src/.env\n\nif [ -z \"$SKIES_ADSB_RPI_USERNAME\" ] || [ -z \"$SKIES_ADSB_RPI_HOST\" ]; then\n  echo \"Error: Required environment variables are not set\"\n  echo \"Please set SKIES_ADSB_RPI_USERNAME and SKIES_ADSB_RPI_HOST\"\n  exit 1\nfi\n\nRPI_TARGET=$SKIES_ADSB_RPI_USERNAME@$SKIES_ADSB_RPI_HOST\n\necho \"Deploy to: $RPI_TARGET\"\necho \"Creating dist.tar...\"\ntar cf dist.tar -C dist .\n\necho \"Copying dist.tar to $RPI_TARGET:~\"\nscp dist.tar $RPI_TARGET:~\n\necho \"Deploying dist.tar to $RPI_TARGET:$WEBROOT\"\nssh $RPI_TARGET \"\n  echo '    Removing old webroot...' &&\n  sudo rm -rf $WEBROOT || true &&\n  echo '    Creating new webroot...' &&\n  sudo mkdir -p $WEBROOT &&\n  echo '    Changing to webroot...' &&\n  cd $WEBROOT &&\n  echo '    Extracting new files...' &&\n  sudo tar xf ~/dist.tar . &&\n  echo '    Cleaning up temporary files...' &&\n  cd &&\n  rm dist.tar &&\n  echo '    Restarting web server...' &&\n  sudo service lighttpd restart\n  \"\n\necho \"Cleaning up local files...\"\nrm dist.tar\n"
  },
  {
    "path": "docs/BUILD-MAPS.md",
    "content": "# Introduction\n\nThis document describes how to build custom GeoJSON map layers for skies-adsb.\n\nskies-adsb centers map layers on a point of origin you specify, such as:\n\n- Your ADS-B installation location\n- A nearby aerodrome\n- Any point of interest\n\n![Custom Map Layers](custom-map-layers.png)\n\n_Examples: Custom map layers for Miami International (KMIA), LaGuardia (KLGA), and Mexico City International (MMMX) airports_\n\nThe project uses data from:\n\n- Natural Earth datasets (boundaries, roads, points of interest)\n- FAA airspace data (Class B, C, D controlled airspace)\n- OpenStreetMap via Overpass API (aerodrome boundaries, origins, runways)\n\nA script at `maps/build-map-layers.sh` automates building these GeoJSON layers.\n\nCustom map layers are also supported. Please review the automated process before consulting the appendix for custom layer instructions.\n\n## Table of Contents\n\n- [Introduction](#introduction)\n- [Table of Contents](#table-of-contents)\n- [Dependencies](#dependencies)\n- [Step 1 - Prerequisites](#step-1---prerequisites)\n- [Step 2 - Build Map Layers for Your Location](#step-2---build-map-layers-for-your-location)\n- [Appendix](#appendix)\n  - [Large Coverage Areas](#large-coverage-areas)\n  - [Creating Lower Resolution Maps](#creating-lower-resolution-maps)\n  - [Map Layer Output Files](#map-layer-output-files)\n    - [Natural Earth Layers](#natural-earth-layers)\n    - [FAA Airspace Boundaries](#faa-airspace-boundaries)\n    - [OpenStreetMap Data](#openstreetmap-data)\n  - [Creating Custom Map Layers With QGIS](#creating-custom-map-layers-with-qgis)\n    - [Importing custom GeoJSON layers](#importing-custom-geojson-layers)\n    - [How to define custom origins](#how-to-define-custom-origins)\n    - [Dataset Update Frequency](#dataset-update-frequency)\n\n## Dependencies\n\n| Dependency             | Description                                     |\n| ---------------------- | ----------------------------------------------- |\n| Python 3               | Scripting language for GeoJSON layer creation   |\n| GeoPandas              | Geospatial data processing library              |\n| osmtogeojson           | Converts Overpass API data to GeoJSON           |\n| Natural Earth datasets | map data (see INSTALL.md)                       |\n| FAA airspace data      | airspace data (see INSTALL.md)                  |\n| QGIS (optional)        | GUI tool for viewing and editing GeoJSON layers |\n| VSCode (optional)      | Recommended IDE for Python development          |\n\n## Step 1 - Prerequisites\n\nThis guide assumes that you have set up your local environment as described here:\n\n[INSTALL.md](INSTALL.md)\n\nPlease follow the steps in the install guide above before continuing.\n\n## Step 2 - Build Map Layers for Your Location\n\nMap layers are built using the `build-map-layers.sh` script. By default, it generates map layers covering a square area of ±2 degrees latitude/longitude around your default origin.\n\nExample for your default origin:\n\n```shell\ncd /path/to/skies-adsb\ncd maps\nchmod +x build-map-layers.sh\n./build-map-layers.sh\n```\n\nAfter building the layers, you can run the skies-adsb simulation.\n\n## Appendix\n\n### Large Coverage Areas\n\nYou can expand map coverage beyond the default ±2 degrees using these parameters:\n\n- `--origin-distance <value>`: Expands coverage uniformly in all directions\n- `--origin-left <value>` and `--origin-top <value>`: Creates rectangular coverage areas\n\nExamples below show using these parameters.\n\n```shell\ncd /path/to/skies-adsb\ncd maps\nchmod +x build-map-layers.sh\n./build-map-layers.sh --origin-distance 5\n```\n\n```shell\ncd /path/to/skies-adsb\ncd maps\nchmod +x build-map-layers.sh\n./build-map-layers.sh --origin-left 3 --origin-top 5\n```\n\n**Important:**\n\nFor coverage areas larger than 2 degrees, you will likely encounter Overpass API rate limits or timeout errors. To avoid these errors, skip building aerodromes (and runways and origins) using:\n\n```shell\n./build-map-layers.sh --origin-distance 5 --skip-aerodromes\n```\n\nAn alternative approach is to build 2-degree tiles and combine them into unified layers using QGIS. This advanced technique allows coverage of larger areas while avoiding API limits. For detailed instructions on working with map tiles in QGIS, please consult the [QGIS Documentation](https://www.qgis.org/resources/hub/).\n\n### Creating Lower Resolution Maps\n\nBy default maps are created at 1:10m scale. You can also create maps at 1:110m scale. Example:\n\n```shell\n./build-map-layers.sh ---build-110m-maps\n```\n\n### Map Layer Output Files\n\nThe map layers will be placed into:\n\n```shell\n/path/to/skies-adsb/public/map-data\n```\n\nSee below for a description of the generated files.\n\n#### Natural Earth Layers\n\n| File                     | Description                               |\n| ------------------------ | ----------------------------------------- |\n| airports.geojson         | Airports at 1:10m scale                   |\n| counties.geojson         | Counties at 1:10m scale                   |\n| lakes.geojson            | Lakes at 1:10m or 1:110m scale            |\n| rivers.geojson           | Rivers at 1:10m or 1:110m scale           |\n| roads.geojson            | Roads at 1:10m scale                      |\n| states_provinces.geojson | States/provinces at 1:10m or 1:110m scale |\n| urban_areas.geojson      | Urban areas at 1:10m scale                |\n\n#### FAA Airspace Boundaries\n\n| File                     | Description                 |\n| ------------------------ | --------------------------- |\n| airspace_class_b.geojson | Class B airspace boundaries |\n| airspace_class_c.geojson | Class C airspace boundaries |\n| airspace_class_d.geojson | Class D airspace boundaries |\n\n#### OpenStreetMap Data\n\n| File              | Description                              |\n| ----------------- | ---------------------------------------- |\n| aerodrome.geojson | Aerodrome geometry data                  |\n| origins.json      | Aerodrome origins as lat/lon coordinates |\n| runway.geojson    | Runway data                              |\n\n### Creating Custom Map Layers With QGIS\n\nTo create custom GeoJSON map layers using QGIS:\n\n1. Install QGIS from https://www.qgis.org\n2. Install the QuickOSM plugin within QGIS\n3. Load your base layers and data sources\n4. Use QGIS tools to:\n\n- Select features\n- Filter data\n- Edit geometries\n- Combine layers\n\n5. Export your work as GeoJSON files\n\nRecommended tutorials:\n\n- Working with Vector Data\n- Creating and Editing GeoJSON\n- Using the QuickOSM Plugin\n\nFor detailed instructions, refer to the official QGIS documentation and tutorials at https://www.qgis.org/en/docs/\n\n#### Importing custom GeoJSON layers\n\nTo import your GeoJSON files into skies-adsb make sure to follow the layer filename conventions in the tables above.\n\nNOTE: The skies-adsb map will render all GeoJSON Polygons or MultiPolygons as LineStrings. Solid polygonal rendering is not currently supported and probably never will be as skies-adsb is going for a mostly vector graphics aesthetic.\n\n#### How to define custom origins\n\nThe `public/map-data/origins.json` file specifies the main reference points used by skies-adsb. This file is automatically created when you run `build-map-layers.sh`. If needed, you can create or modify it manually.\n\nSteps to manually create the origins.json file:\n\n```\ncd /path/to/skies-adsb\ncd public/map-data\ntouch origins.json\n```\n\n**NOTE: each time you run the build-map-layers.sh script you will lose any custom changes to the origins.json file.**\n\nOrigins are defined using JSON objects with this format:\n\n```json\n    {\n      \"center\": {\n        \"lat\": <YOUR LATITUDE GOES HERE>,\n        \"lon\": <YOUR LONGITUDE GOES HERE>\n      },\n      \"tags\": {\n        \"ref\": \"<YOUR LABEL GOES HERE>\",\n        \"ele\": \"<OPTIONAL ELEVATION IN METERS MSL - DEFAULTS TO 0 meters>\"\n      }\n    }\n```\n\n#### Create root JSON Object\n\nIn the origins.json file create a JSON Object with an array property called \"elements\":\n\n```json\n{\n  \"elements\": []\n}\n```\n\nThen place each `Origin` object into the element array. For example:\n\n```json\n{\n  \"elements\": [\n    {\n      \"center\": {\n        \"lat\": 25.7955406,\n        \"lon\": -80.2918816\n      },\n      \"tags\": {\n        \"ref\": \"KMIA\",\n        \"ele\": 3\n      }\n    },\n    {\n      \"center\": {\n        \"lat\": 26.0723139,\n        \"lon\": -80.1497953\n      },\n      \"tags\": {\n        \"ref\": \"KFLL\",\n        \"ele\": 2\n      }\n    }\n  ]\n}\n```\n\nNOTE: An origin can be any location on Earth - it does not have to be an aerodrome. You can define origins for:\n\n- Cities\n- Points of interest\n- Geographic features\n- Or any other location you want to track aircraft relative to\n\n#### Dataset Update Frequency\n\n- **Natural Earth datasets**: Change infrequently, update as needed\n- **FAA VFR sectional charts**: Updated every 56 days\n\nAlways rebuild map layers after updating any datasets to ensure your visualizations reflect the latest data.\n"
  },
  {
    "path": "docs/DEVELOPMENT.md",
    "content": "# Introduction\n\nThis document describes how to setup a development environment to hack on skies-adsb.\n\n# Table of Contents\n\n- [Introduction](#introduction)\n- [Table of Contents](#table-of-contents)\n- [Prerequisites](#prerequisites)\n- [Application Architecture](#application-architecture)\n  - [The application consists of three main components:](#the-application-consists-of-three-main-components)\n    - [Web Frontend (skies-adsb/src)](#web-frontend-skies-adbsrc)\n    - [Backend Service (skies-adsb/flask)](#backend-service-skies-adbflask)\n    - [Map Generator (skies-adsb/maps)](#map-generator-skies-adbmaps)\n- [Tech Stack](#tech-stack)\n  - [Languages](#languages)\n  - [Frameworks](#frameworks)\n  - [Development Tools](#development-tools)\n  - [Key Libraries](#key-libraries)\n  - [Assets](#assets)\n- [Contributing to skies-adsb](#contributing-to-skies-adsb)\n- [Development Environment Setup](#development-environment-setup)\n  - [Available npm scripts](#available-npm-scripts)\n- [HOWTO](#howto)\n  - [Updating the Web App](#updating-the-web-app)\n  - [Updating the Flask app and RPI System services](#updating-the-flask-app-and-rpi-system-services)\n- [Notes](#notes)\n\n# Prerequisites\n\nThis guide assumes that you have set up your local environment as described here:\n\n[INSTALL.md](INSTALL.md)\n\nPlease follow the steps in the install guide above before continuing.\n\n# Application Architecture\n\nThe primary goal of this project was to create 1980s-style 3D vector graphic visualization of ADS-B data. Think Alien, Escape From New York, Max Headroom, and WarGames.\n\nskies-adsb focuses on simplicity and avoids replicating features of existing plane tracking web apps. Its core principles are:\n\n- minimize complexity\n- minimize dependencies\n- utilize free and open-source software (FOSS) data, libraries, and tools\n- provide equal support for desktop and mobile\n- run on any WebGL-capable browser\n\n## The application consists of three main components:\n\n### Web Frontend (skies-adsb/src)\n\n- Three.js-based 3D visualization of aircraft ADS-B data\n- Interactive user controls and UI elements\n\n### Backend Service (skies-adsb/flask)\n\n- Flight status and summary data proxy\n- METAR weather proxy\n\n### Map Generator (skies-adsb/maps)\n\n- Generates GeoJSON map layers from Natural Earth, FAA, and OpenStreetMap data\n\n# Tech Stack\n\n## Languages\n\n- JavaScript\n- HTML5\n- CSS\n- Python 3\n\n## Frameworks\n\n- [three.js](https://threejs.org/) - 3D graphics library\n- [Flask](https://flask.palletsprojects.com/) - Python web framework\n\n## Development Tools\n\n- [VScode](https://code.visualstudio.com/) - Code editor\n- [Vite](https://vite.dev/) - Build tool\n- [npm](https://www.npmjs.com/) - Package manager\n- [nvm](https://github.com/nvm-sh/nvm) - Node version manager\n\n## Key Libraries\n\n- [GeoPandas](https://geopandas.org/) - Geospatial data handling\n- [GSAP](https://greensock.com/gsap/) - Animation\n- [sphericalmercator](https://github.com/mapbox/sphericalmercator) - Map projections\n- [dat.gui](https://github.com/dataarts/dat.gui) - UI controls\n- [stats.js](https://github.com/mrdoob/stats.js/) - Performance monitoring\n- [Troika Text](https://protectwise.github.io/troika/troika-three-text/) - Three.js text rendering\n\n## Assets\n\n- Fonts\n  - [IBM Plex Mono](https://fonts.google.com/specimen/IBM+Plex+Mono)\n  - [Orbitron](https://fonts.google.com/specimen/Orbitron)\n- [Material Icons](https://fonts.google.com/icons)\n\n# Contributing to skies-adsb\n\nIf you wish to contribute to skies-adsb please fork the project and submit changes via pull-requests.\n\n# Development Environment Setup\n\n1. Install and configure VSCode\n\n- Recommended for JavaScript development and Python virtual environments\n- Excellent integration with project tooling\n- Download from: https://code.visualstudio.com/\n\n2. Follow the Localhost+Headless Setup Guide here: [Localhost+Headless Setup Guide](LOCALHOST-HEADLESS-SETUP-GUIDE.md)\n\n3. Once setup is complete, start the development server:\n\n```shell\ncd /path/to/skies-adsb\n./use_existing_adsb.sh\n```\n\nThis will launch the application in development mode with live reload enabled.\n\n## Available npm scripts\n\nStart the Vite development server:\n\n```shell\nnpm run dev\n```\n\nBuild the skies-adsb web app for distribution:\n\n```shell\nnpm run build\n```\n\nStart the Flask app development server\n\n```shell\nnpm run dev-flask\n```\n\n# HOWTO\n\n## Updating the Web App\n\nIf you make local changes to the skies-adsb web app and want to deploy them to your Raspberry Pi, follow these steps:\n\n1. Build your maps:\n\n```shell\ncd /path/to/skies-adsb\ncd maps\nchmod +x build-map-layers.sh\n./build-map-layers.sh\n```\n\n2. Build the web app:\n\n```shell\ncd /path/to/skies-adsb\nnpm run build\n```\n\n3. Deploy the web app:\n\n```shell\ncd /path/to/skies-adsb\n./deploy_web_app.sh\n```\n\n## Updating the Flask app and RPI System services\n\nTo update the Flask app or services on your RPI:\n\n```shell\ncd /path/to/skies-adsb/raspberrypi\n./update_flask_app.sh\n```\n\n# Notes\n\nFor information about working with the Flask app please see the Flask app README:\n\n[Flask App README](/flask/README.md)\n"
  },
  {
    "path": "docs/INSTALL.md",
    "content": "# Introduction\n\nThis guide provides step-by-step instructions for installing skies-adsb. The instructions outlined here apply to both:\n\n- New 64-bit Raspberry Pi installations\n- Localhost or headless installations on existing systems\n\nFollow each step carefully to set up the core dependencies and configuration needed to deploy and run the application.\n\n# Table of Contents\n\n- [Introduction](#introduction)\n- [Prerequisites](#prerequisites)\n  - [Important Notes](#important-notes)\n  - [Required Software](#required-software)\n  - [Development Environment](#development-environment)\n- [Step 1 - Clone the skies-adsb repository](#step-1---clone-the-skies-adsb-repository)\n- [Step 2 - Setup Python environment](#step-2---setup-python-environment)\n- [Step 3 - Install Node.js and npm](#step-3---install-nodejs-and-npm)\n- [Step 4 - Initialize the Node.js Dependencies](#step-4---initialize-the-nodejs-dependencies)\n- [Step 5 - Create src/.env File](#step-5---create-srcenv-file)\n- [Step 6 - Setup Flask Server](#step-6---setup-flask-server)\n- [Step 7 - Set Your Geolocation Coordinates](#step-7---set-your-geolocation-coordinates)\n- [Step 8 - Download Natural Earth Datasets](#step-8---download-natural-earth-datasets)\n- [Step 9 - Download FAA Airspace Shapefile](#step-9---download-faa-airspace-shapefile)\n- [Step 10 - Extract the Datasets](#step-10---extract-the-datasets)\n- [Step 11 - Build your map layers](#step-11---build-your-map-layers)\n- [Step 12 - Configure Visualization Settings](#step-12---configure-visualization-settings)\n- [Step 13 - Configure Auto Orbit Environment Variables](#step-13---configure-auto-orbit-environment-variables)\n- [Step 14 - Configure Default SkyBox, Aircraft Trails, and Which Map Layers Are Visible by Default](#step-14---configure-default-skybox-aircraft-trails-and-which-map-layers-are-visible-by-default)\n- [Next Steps](#next-steps)\n\n# Prerequisites\n\n## Important Notes\n\n- Unix command line experience is required to build and deploy skies-adsb\n- Follow all installation steps in sequence unless explicitly noted as optional\n- Installation process has been streamlined but requires careful attention to detail\n\n## Required Software\n\n- Git\n- Python 3.x or higher\n- QGIS (for map working with map layers)\n- VSCode recommended for Python/JavaScript development\n- Modern web browser with WebGL support (Chrome/Firefox recommended)\n\n## Development Environment\n\nRecommended workstation requirements:\n\n- Operating System: Linux (Ubuntu/Fedora) or macOS\n- Storage: 5GB free disk space\n- Memory: 8GB RAM minimum\n- CPU: Quad-core processor\n\nNote: Development and testing was done on Ubuntu and Fedora workstations\n\n# Step 1 - Clone the skies-adsb repository\n\nOn your workstation clone the skies-adsb GitHub repository:\n\n```shell\ncd /path/to/your/git/projects\ngit clone https://github.com/machineinteractive/skies-adsb.git\n```\n\n# Step 2 - Setup Python environment\n\nThis step setups up a Python Virtual Environment with all the dependencies needed to run the Python scripts included with the app.\n\n```shell\ncd /path/to/skies-adsb\npython3 -m venv .venv\nsource .venv/bin/activate\npip3 install flask flask-cors geopandas osmtogeojson requests websockify\ndeactivate\n```\n\n# Step 3 - Install Node.js and npm\n\nThe skies-adsb web app requires Node.js and npm. If you already have these installed, you can skip to **Step 4**.\n\nFor a clean Node.js installation, use nvm (Node Version Manager) - the recommended way to install and manage Node.js:\n\n1. Install nvm by following the official instructions at:\n\nhttps://github.com/nvm-sh/nvm\n\n2. Once nvm is installed, install the latest Node.js version:\n\n```shell\nnvm install node\n```\n\n3. Logout and login again before continuing to **Step 4**\n\n# Step 4 - Initialize the Node.js Dependencies\n\nInstall required node modules by running:\n\n```shell\ncd /path/to/skies-adsb\nnpm install\n```\n\nThis will install all dependencies specified in package.json.\n\n# Step 5 - Create src/.env File\n\nThe src/.env file is used to store numerous environment variables which are necessary for building and running skies-adsb.\n\n```shell\ncd /path/to/skies-adsb\ncp docs/dot-env-template src/.env\n```\n\n# Step 6 - Setup Flask Server\n\nThe Flask server acts as a proxy for aviation-related APIs to fetch realtime aircraft and weather information.\n\nCreate the Flask server configuration file:\n\n```shell\ncd /path/to/skies-adsb\ncp docs/flask-config-template.json flask/config.json\n```\n\nThis creates the minimum necessary config.json for the Flask server.\n\nFor additional functionality like FlightAware AeroAPI integration to get flight status information, see the instructions in the [Flask README](/flask/README.md).\n\n**Note:** Use of the FlightAware AeroAPI is optional (paid service):\n\n- It is required for flight status information\n- It is not needed for basic ADS-B data visualization\n\n![Reference Polar Grid](screenshot-flightaware-flightinfo.png)\n\n_Full FlightAware AeroAPI Output_\n\nIf you do not have a FlightAware AeroAPI key, the app will use aircraft registration information from planespotters.net photo API by default.\n\n![Reference Polar Grid](screenshot-planespotters-aircraft-registration.png)\n\n_Basic planespotters.net Aircraft Registration Output_\n\n# Step 7 - Set Your Geolocation Coordinates\n\nThe skies-adsb app uses geolocation coordinates as a reference point for:\n\n- Map layer rendering\n- Aircraft position tracking relative to your ADS-B receiver\n- Distance and bearing calculations\n\nThe app does not automatically detect location. You must set these coordinates manually.\n\nTo get your coordinates:\n\n1. Visit [OpenStreetMap](https://www.openstreetmap.org/)\n2. Search for your location\n3. Right-click on your exact position\n4. Select \"Show address\"\n5. Note the latitude and longitude values\n\nTip: To find the elevation of your location:\n\n1. On OpenStreetMap, select \"Query Features\" (right-click menu)\n2. Click a nearby aerodrome or point of interest\n3. Look for the \"ele\" (elevation) field in meters\n\nAdd these coordinates to your **/path/to/skies-adsb/src/.env** file:\n\n```shell\nSKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=<DEFAULT ORIGIN LATITUDE>\nSKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=<DEFAULT ORIGIN LONGITUDE>\nSKIES_ADSB_DEFAULT_ORIGIN_ELEVATION_METERS_OPTIONAL=<OPTIONAL DEFAULT ORIGIN ELEVATION IN METERS MSL>\n```\n\nExample using Miami International Airport (KMIA):\n\n```shell\nSKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=25.7955406\nSKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=-80.2918816\nSKIES_ADSB_DEFAULT_ORIGIN_ELEVATION_METERS_OPTIONAL=3\n```\n\nNOTE: **SKIES_ADSB_DEFAULT_ORIGIN_ELEVATION_METERS_OPTIONAL** is optional. If it is not set the default elevation will be set to 0 meters. If your default origin is an aerodrome it is strongly suggested you set the elevation otherwise you will see floating aircraft above the aerodrome.\n\n# Step 8 - Download Natural Earth Datasets\n\nskies-adsb uses Natural Earth datasets and FAA Airspace Shapefiles for building map layers. Due to GitHub file size limitations, you must download and install these data files separately by following the steps below.\n\n## 1:10m Scale Datasets\n\nFrom https://www.naturalearthdata.com/downloads/10m-cultural-vectors/\n\n- Click \"Download all 10m cultural themes\"\n\nFrom https://www.naturalearthdata.com/downloads/10m-physical-vectors/\n\n- Click \"Download all 10m physical themes\"\n\n## 1:110m Scale Datasets\n\nFrom https://www.naturalearthdata.com/downloads/110m-cultural-vectors/\n\n- Click \"Download all 110m cultural themes\"\n\nFrom https://www.naturalearthdata.com/downloads/110m-physical-vectors/\n\n- Click \"Download all 110m physical themes\"\n\nCopy the files:\n\n- **10m_cultural.zip**\n- **10m_physical.zip**\n- **110m_cultural.zip**\n- **110m_physical.zip**\n\nto the directory:\n\n```shell\n/path/to/skies-adsb/maps/data\n```\n\n# Step 9 - Download FAA Airspace Shapefile\n\nDownload the FAA Airspace Shapefile:\n\n1. Go to [FAA Airspace Data](https://adds-faa.opendata.arcgis.com/datasets/faa::class-airspace)\n2. Click \"Download\"\n3. Choose \"Shapefile\" format\n\nSave the downloaded **Class_Airspace.zip** file.\n\nCopy the **Class_Airspace.zip** file to:\n\n```shell\n/path/to/skies-adsb/maps/data\n```\n\n# Step 10 - Extract the Datasets\n\nThe install-datasets.sh script will extract the Natural Earth and FAA Airspace datasets to their required locations for use by the build-map-layers.py script.\n\n```shell\ncd /path/to/skies-adsb/maps/data\n./install-datasets.sh\n```\n\n# Step 11 - Build your map layers\n\nThis step is necessary to build map layers specific to your ADS-B installation location. Without map layers, you'll only see a skybox and aircraft. If you prefer not to use map layers, the simulation includes a reference polar grid that can be toggled on/off via the settings GUI.\n\n```shell\ncd /path/to/skies-adsb\ncd maps\nchmod +x build_map_layers.sh\n./build-map-layers.sh\n```\n\nfor more information see this document:\n\n[Build Map Layers Guide](BUILD-MAPS.md)\n\n![Custom Map Layers](custom-map-layers.png)\n\n_Examples of custom map layers: Miami International (KMIA), LaGuardia (KLGA), and Mexico City International (MMMX) airports_\n\n![Reference Polar Grid](screenshot-grid.png)\n\n_Reference Polar Grid_\n\n## Test your map layers\n\nAt this point you can see what your map layers look like by running the following command:\n\n```shell\ncd /path/to/skies-adsb\nnpx vite --host\n```\n\nThis will launch the Vite development HTTP server.\n\n# Step 12 - Configure Visualization Settings\n\nThe following table lists the default visualization settings in **src/utils.js**. These settings control various aspects of the 3D visualization including camera behavior, skybox dimensions, and aircraft tracking parameters.\n\n<!-- prettier-ignore -->\n| Constant                        | Default Value | Description                                            |\n| ------------------------------- | ------------- | ------------------------------------------------------ |\n| DEFAULT_SCALE                   | 1.0 / 250.0   | Default scale for geometry                             |\n| CAMERA_FOV                      | 75            | Camera field of view in degrees                        |\n| CAMERA_NEAR                     | 0.1           | Camera Near clipping plane distance                    |\n| CAMERA_FAR                      | 10000.0       | Camera Far clipping plane distance                     |\n| SKYBOX_RADIUS                   | 3000.0        | Radius of the skybox (must be ≤ half of CAMERA_FAR)    |\n| FOLLOW_CAM_DISTANCE             | 24.0          | Default follow camera distance from aircraft           |\n| POLAR_GRID_RADIUS               | 3000.0        | Radius of the polar grid (should match SKYBOX_RADIUS)  |\n| POLAR_GRID_RADIALS              | 16            | Number of radial lines in the polar grid               |\n| POLAR_GRID_CIRCLES              | 5             | Number of concentric circles in the polar grid         |\n| POLAR_DIVISIONS                 | 64            | Number of divisions in the polar grid                  |\n| POLAR_GRID_COLOR_1              | \"#81efff\"     | Primary color for polar grid                           |\n| POLAR_GRID_COLOR_2              | \"#81efff\"     | Secondary color for polar grid                         |\n| AIRCRAFT_TTL                    | 15.0          | Aircraft time-to-live in seconds                       |\n| AIRCRAFT_TRAIL_UPDATE_FREQUENCY | 100            | Trail update frequency based on telemetry update count |\n| AIRCRAFT_MAX_TRAIL_POINTS       | 2500          | Maximum number of points in aircraft trail             |\n\nThese values can be modified in the **src/util.js** file to adjust the visualization behavior to your preferences. Note that some values are interdependent (e.g., SKYBOX_RADIUS must be less than or equal to half of CAMERA_FAR).\n\n# Step 13 - Configure Auto Orbit Environment Variables\n\nThe following variables in src/.env control the default automatic camera orbit behavior on app launch:\n\n<!-- prettier-ignore -->\n| Variable Name | Explanation | Value | Default |\n| ------------- | ----------- | ------| ------- |\n| SKIES_ADSB_DEFAULT_CAMERA_MODE                  | Initial camera mode at startup                 | string(ORBIT, or AUTO_ORBIT) | ORBIT   |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_MIN_RADIUS       | Minimum orbit radius in world units            | Number >= 0                  | 25      |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_MAX_RADIUS       | Maximum orbit radius in world units            | Number >= MIN_RADIUS         | 250     |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_RADIUS_SPEED     | Speed of radius changes                        | Number between 0 and 0.5         | 0.009   |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_VERTICAL_SPEED   | Speed of vertical movement                     | Number between -0.2 and 0.2     | 0.009   |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_HORIZONTAL_SPEED | Speed of horizontal rotation                   | Number between -0.2 and 0.2     | 0.009   |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_MIN_PHI          | Minimum camera phi angle (degrees from zenith) | Number >= 0                  | 0       |\n| SKIES_ADSB_SETTINGS_AUTO_ORBIT_MAX_PHI          | Maximum camera phi angle (degrees from zenith) | Number >= MIN_ALTITUDE       | 90      |\n\n![Auto Orbit Camera Controls](screenshot-auto-orbit-camera-controls.png)\n\n_Auto Orbit Camera Controls_\n\n# Step 14 - Configure Default SkyBox, Aircraft Trails, and Which Map Layers Are Visible by Default\n\n<!-- prettier-ignore -->\n| Variable Name | Explanation | Value | Default |\n|---------------|-------------|-------|---------|\n| SKIES_ADSB_SETTINGS_DEFAULT_SKYBOX        | Set Default Skybox Theme                                       | string (DAWN_DUSK, DAY, or NIGHT) | DAWN_DUSK |\n| SKIES_ADSB_SETTINGS_SHOW_ALL_TRAILS       | Controls visibility of aircraft trails for all tracked flights | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_AERODROMES       | Controls visibility of aerodrome and runways locations         | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_ORIGINS          | Controls display of origin name labels                         | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_B | Controls visibility of Class B airspace boundaries             | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_C | Controls visibility of Class C airspace boundaries             | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_D | Controls visibility of Class D airspace boundaries             | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_URBAN_AREAS      | Controls display of urban area boundaries                      | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_ROADS            | Controls visibility of major roads and highways                | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_LAKES            | Controls visibility of lakes and large water bodies            | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_RIVERS           | Controls visibility of rivers and waterways                    | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_STATES_PROVINCES | Controls display of state/province boundaries                  | boolean                           | true      |\n| SKIES_ADSB_SETTINGS_SHOW_COUNTIES         | Controls visibility of county boundaries                       | boolean                           | true      |\n\n![Map Layers Controls](screenshot-map-layers-controls.png)\n\n_Map Layers Controls_\n\n# Next Steps\n\nAfter completing the base installation, follow one of these guides to finalize your setup:\n\n- [Raspberry Pi Installation Guide](RPI-INSTALL-GUIDE.md) - Configure skies-adsb on a 64-bit Raspberry Pi\n- [Localhost+Headless Setup Guide](LOCALHOST-HEADLESS-SETUP-GUIDE.md) - Run skies-adsb locally or headless on your system\n\nChoose the guide that matches your deployment scenario.\n"
  },
  {
    "path": "docs/LOCALHOST-HEADLESS-SETUP-GUIDE.md",
    "content": "# Localhost & Headless Setup Guide\n\nThis guide describes how to set up skies-adsb to connect to an existing ADS-B receiver, either on your local machine or a headless system. The setup:\n\n- Runs locally as a web app and Flask application\n- Creates a local websocket proxy to forward ADS-B data\n- Compatible with ADS-B receivers using SBS format\n- Doesn't modify your existing ADS-B receiver installation\n\nThis has been tested on a Linux workstation and a headless Raspberry Pi Zero 2 W.\n\n**Note:** skies-adsb was developed under Linux. This document assumes your workstation is running Linux or macOS.\n\n## Table of Contents\n\n- [Step 1 - Prerequisites](#step-1---prerequisites)\n- [Step 2 - Setup src/.env file variables](#step-2---setup-srcenv-file-variables)\n  - [Required Environment Variables](#required-environment-variables)\n  - [Example .env file](#example-env-file)\n  - [Check ADS-B SBS Port 30003 Connection](#check-ads-b-sbs-port-30003-connection)\n  - [Enable Flight Status](#enable-flight-status)\n- [Step 3 - Start skies-adsb](#step-3---start-skies-adsb)\n\n# Step 1 - Prerequisites\n\nThis guide assumes that you have set up your local environment as described here:\n\n[INSTALL.md](INSTALL.md)\n\nPlease follow the steps in the install guide above before continuing.\n\n# Step 2 - Setup src/.env file variables\n\n## Required Environment Variables\n\n<!-- prettier-ignore -->\n| Variable Name | Explanation | Value | Default |\n| ------------- | ----------- | ------| ------- |\n| SKIES_ADSB_USE_EXISTING_ADSB | Specifies the IP address and port of your ADS-B receiver | `<ADS-B RECEIVER IP ADDRESS>:<SBS PORT>` | None    |\n\n**NOTE: typically SBS port is on 30003**\n\n```shell\ncd /path/to/skies-adsb/src\n```\n\nadd the following variables to the **.env** file:\n\n```shells\nSKIES_ADSB_USE_EXISTING_ADSB=<ADS-B RECEIVER IP ADDRESS>:<SBS PORT>\n```\n\n## Example .env file\n\n### NOTE: When SKIES_ADSB_USE_EXISTING_ADSB is defined, skies-adsb defaults to using localhost for both websocket and flask connections.\n\nExample **.env** file with default origin centered on **KMIA** and ADS-B receiver at **192.168.1.123:30003**:\n\n```shell\nSKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=25.7955406\nSKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=-80.2918816\n\nSKIES_ADSB_USE_EXISTING_ADSB=192.168.1.123:30003\n```\n\nExample **.env** file with default origin centered on **KMIA** and ADS-B receiver at **localhost:30003**:\n\n```shell\nSKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=25.7955406\nSKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=-80.2918816\n\nSKIES_ADSB_USE_EXISTING_ADSB=localhost:30003\n```\n\n## Check ADS-B SBS Port 30003 Connection\n\nBefore proceeding, verify that your ADS-B receiver allows connections on port 30003:\n\n```shell\nnmap -p 30003 <YOUR-ADSB-IP-ADDRESS>\n```\n\nExample:\n\n```shell\nnmap -p 30003 192.168.1.123\n```\n\nYou should see something like:\n\n```shell\n\nPORT      STATE SERVICE\n30003/tcp open  amicon-fpsu-ra\n\n```\n\n**Note:** Some ADS-B receivers only allow connections from localhost by default. You may need to configure your receiver to accept external connections.\n\n## Enable Flight Status\n\nIf you wish to enable flight status with FlightAware AeroAPI then please follow the **OPTIONAL** section in the Flask Server setup instructions here:\n\n[flask/README.md](/flask/README.md)\n\n**note: skip the last part called \"Run the Flask Server\".\\***\n\n# Step 3 - Start skies-adsb\n\n```shell\ncd /path/to/skies-adsb\n./use_existing_adsb.sh\n```\n\n**NOTE: To exit press CTRL+C.**\n\nThe script will:\n\n<!-- prettier-ignore -->\n| Action | Description |\n| -------| ----------- |\n| Start web app          | In development mode on localhost:5173 and `<LOCALHOST-NETWORK-IP>:5173`                                |\n| Start Flask app        | In development mode on localhost:5000 and `<LOCALHOST-NETWORK-IP>:5000`                                |\n| Create websocket proxy | Sets up on localhost:30006 and `<LOCALHOST-NETWORK-IP>:30006` to forward ADS-B data from your receiver |\n\nFor example, if your localhost IP address is 192.168.1.123 you should see an output similar to below:\n\n```shell\n  VITE v5.4.14  ready in 1888 ms\n  ➜  Local:   http://localhost:5173/\n  ➜  Network: http://192.168.1.123:5173/\n  ➜  press h + enter to show help\n```\n\nOnce running, you should see live aircraft traffic in your local area.\n"
  },
  {
    "path": "docs/RPI-INSTALL-GUIDE.md",
    "content": "# Introduction\n\nThis document describes how to setup and deploy the skies-adsb app to a Raspberry Pi Zero 2 W (or newer 64-bit Raspberry Pi model) connected to a RTL-SDR receiver on your home network.\n\n**Note:** if you have an existing RPI ADS-B installation this guide will make changes to the RPI setup. If you do not wish to alter your RPI setup then please use the [Localhost+Headless Setup Guide](LOCALHOST-HEADLESS-SETUP-GUIDE.md) instead.\n\n**Note:** skies-adsb was developed under Linux. This document assumes your workstation is running Linux or macOS.\n\n## Table of Contents\n\n- [Introduction](#introduction)\n- [Terms Used](#terms-used)\n- [What You Will Need & Shopping List](#what-you-will-need--shopping-list)\n- [Hardware and reference materials used to build this project](#hardware-and-reference-materials-used-to-build-this-project)\n  - [Recommended Hardware](#recommended-hardware)\n  - [Outdoor Setup](#outdoor-setup)\n  - [Indoor Setup](#indoor-setup)\n  - [Other hardware used](#other-hardware-used)\n  - [Learning about RTL-SDR and ADS-B](#learning-about-rtl-sdr-and-ads-b)\n- [Step 1 - Prerequisites](#step-1---prerequisites)\n- [Step 2 - Raspberry Pi (RPI) Setup](#step-2---raspberry-pi-rpi-setup)\n- [Step 3 - Setup src/.env file variables](#step-3---setup-srcenv-file-variables)\n- [Step 4 - Choose and Configure ADS-B Driver](#step-4---choose-and-configure-ads-b-driver)\n- [Step 5 - Deploy and run the RPI skies-adsb install-skies-adsb.sh Script](#step-5---deploy-and-run-the-rpi-skies-adsb-install-skies-adsbsh-script)\n- [Step 6 - Connect your RTL-SDR receiver](#step-6---connect-your-rtl-sdr-receiver)\n- [Step 7 - Build and Deploy the skies-adsb web app to the Raspberry Pi](#step-7---build-and-deploy-the-skies-adsb-web-app-to-the-raspberry-pi)\n- [Step 8 - Test the skies-adsb Installation](#step-8---test-the-skies-adsb-installation)\n\n## Terms Used\n\n<!-- prettier-ignore -->\n| Term | Meaning |\n|------|---------|\n| RPI | Raspberry Pi |\n| Default RPI Username | pi |\n| Default RPI Hostname | raspberrypi.local |\n| Default RPI IP Address | 192.168.1.123 |\n\n## What You Will Need & Shopping List\n\nThe minimum hardware needed to build this project is:\n\n- 1 Raspberry Pi Zero 2 W or newer 64-bit Raspberry Pi model\n- 1 32gb microSD card\n- 1 RTL-SDR Receiver that works with [readsb](https://github.com/wiedehopf/readsb) or [dump1090-mutability](https://github.com/adsb-related-code/dump1090-mutability)\n- 1 ADS-B 1090MHz Antenna (see recommendations below)\n- a Linux or Mac workstation for Raspberry Pi setup\n\n**NOTE:** _If you wish to keep the costs as low as possible (and get the best reception), then I suggest using a Raspberry Pi Zero 2 W kit combined with the ADSBexchange.com Blue R820T2 RTL2832U kit._\n\n## Hardware and reference materials used to build this project\n\n### Recommended Hardware\n\n<!-- prettier-ignore -->\n| Amount | Item |\n|--------|------|\n| 1 | [CanaKit Raspberry Pi Zero 2 W - Pi Zero 2 W Starter MAX Kit](https://www.canakit.com/raspberry-pi-zero-2-w.html) |\n| 1 | [ADSBexchange.com Blue R820T2 RTL2832U, 0.5 PPM TCXO ADS-B SDR w/Amp and 1090 Mhz Filter, Antenna & Software on Industrial MicroSD](https://store.adsbexchange.com/) |\n\n### Outdoor Setup\n\n<!-- prettier-ignore -->\n| Amount | Item |\n|--------|------|\n| 1 | [5.5dBi 1090/978 N-Type Female Antenna - 26-inch](https://a.co/d/flkLEo5) |\n| 1 | [10ft SMA Male to N Male Pure Cable](https://a.co/d/d6f23F3) |\n| 1 | [IP54 Waterproof Box with Large Capacity Outdoor Weatherproof Box](https://a.co/d/9MidpWv) |\n| 1 | 1/2\" x 10' PVC Pipe (cut as needed to form stand for antenna)\n\n## Indoor Setup\n\n<!-- prettier-ignore -->\n| Amount | Item |\n|--------|------|\n| 1 | [AirNav RadarBox ADS-B 1090 MHz XBoost Antenna with SMA Connector](https://www.radarbox.com/store) |\n| 1 | [Proxicast 6 ft Ultra Flexible SMA Male - SMA Male Low Loss Coax Jumper Cable for 3G/4G/LTE/Ham/ADS-B/GPS/RF Radios & Antennas (Not for TV or WiFi) - 50 Ohm](https://amazon.com/gp/product/B07R2CWDPJ/) |\n\n### Other hardware used\n\n<!-- prettier-ignore -->\n| Amount | Item |\n|--------|------|\n| 1 | [CanaKit Raspberry Pi 3 - Complete Starter Kit - 32 GB Edition](https://www.canakit.com/raspberry-pi-3-starter-kit.html)\n| 1 | [RTL-SDR Blog V4 R828D RTL2832U 1PPM TCXO SMA Software Defined Radio with Dipole Antenna Kit](https://www.rtl-sdr.com/buy-rtl-sdr-dvb-t-dongles/) |\n| 1 | [Software Defined Radio Receiver USB Stick - RTL2832 w/R820T](https://www.adafruit.com/product/1497) |\n| 1 | [Nooelec NESDR Smart v4 Bundle - Premium RTL-SDR w/Aluminum Enclosure, 0.5PPM TCXO, SMA Input & 3 Antennas. RTL2832U & R820T2-Based Software Defined Radio](https://www.nooelec.com/store/nesdr-smart.html) |\n\n### Learning about RTL-SDR and ADS-B\n\n<!-- prettier-ignore -->\n| Amount | Item |\n| ------ | ---- |\n| 1 | [The Hobbyist's Guide to the RTL-SDR: Really Cheap Software Defined Radio](https://amazon.com/gp/product/B00KCDF1QI/) |\n| 1 | [RTL-SDR for Everyone: Second Edition 2016 Guide including Raspberry Pi 2](https://amazon.com/gp/product/B01C9KZKAI/) |\n| 1 | [Airband Radio on the RTL-SDR: Tips and tricks for capturing voice and data on a revolutionary device](https://a.co/d/3EMAZcR) |\n\n## Step 1 - Prerequisites\n\nThis guide assumes that you have set up your local environment as described here:\n\n[INSTALL.md](INSTALL.md)\n\nPlease follow the steps in the install guide above before continuing.\n\n## Step 2 - Raspberry Pi (RPI) Setup\n\nFollow the RPI OS installation instructions here:\n\nhttps://www.raspberrypi.com/documentation/computers/getting-started.html#installing-the-operating-system\n\n**NOTE: THE SETUP INSTRUCTIONS WILL ASSUME YOU ARE USING THE RASPBERRY PI IMAGER**\n\nI strongly suggest using the [RPI Imager](https://www.raspberrypi.com/software/) to do the initial installation as this will save you time with the initial setup.\n\nYou must use the 64-bit version of Raspberry Pi OS. I recommend using Raspberry Pi OS Lite as no GUI is needed.\n\nFor this project I am using:\n\n- Raspberry Pi Zero 2 W\n- Raspberry Pi 3\n\nBoth are running Raspberry Pi OS Lite (64-bit)\n\nNOTE: For purposes of the setup tutorial I'm assuming the default RPI username and hostname are used:\n\n```\nusername: pi\nhostname: raspberrypi.local\nip: 192.168.1.123\n```\n\nFrom the Raspberry Pi Imager:\n\n1. Choose your destination device\n2. Select the 64-bit Raspberry Pi OS Lite as your OS image\n3. Select your Storage\n4. Press Next\n5. You will be presented with a dialog \"Use OS customization\"\n6. Press \"Edit Settings\"\n7. Set the hostname to something like: raspberrypi.local\n8. Set your username and password. You must use the username: pi\n9. Configure your wireless LAN\n10. Set the locale settings. Make sure you set your Time zone and Keyboard layout.\n11. Press Save\n\nOnce you have written your image then boot and log into your RPI.\n\nBoot your RPI. Verify that you can ssh into your RPI:\n\n```\nssh pi@raspberrypi.local\n\n```\n\nOnce you login determine which IP address has been assigned to your RPI using the hostname command as follows:\n\n```\nhostname -I\n```\n\nand write down this IP address. It will be needed for setting up the skies-adsb web app.\n\nYou can also check for your IP address from a Linux or Mac workstation with the following command:\n\n```\n# assuming network at 192.168.1.0 - change as needed to match yours\nnmap -sn 192.168.1.0/24\n```\n\nThis command will print out a list of devices with their hostname and IP address that are present on your local network. Replace the subnet with your subnet as needed.\n\n## Step 3 - Setup src/.env file variables\n\nThe **src/.env** file contains environment variables used to build and deploy the skies-adsb web app. These variables control various aspects of the application's behavior and deployment settings.\n\nStart by creating a src/.env file with the required variables needed to get the app running. The optional variables can be added later once basic functionality is working.\n\nPlease refer to the tables below for descriptions of what each variable does.\n\nCreate a src/.env file with these minimum required variables:\n\n```shell\nSKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=<YOUR LATITUDE>\nSKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=<YOUR LONGITUDE>\n\nSKIES_ADSB_RPI_USERNAME=<Default RPI username>\nSKIES_ADSB_RPI_HOST=<Default RPI IP address>\n```\n\n### Example src/.env file:\n\n```shell\nSKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=25.7919\nSKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=-80.2871\n\nSKIES_ADSB_RPI_USERNAME=pi\nSKIES_ADSB_RPI_HOST=192.168.1.123\n\n# set default skybox to night\nSKIES_ADSB_SETTINGS_DEFAULT_SKYBOX=NIGHT\n# by default do not show all aircraft trails at the same time\nSKIES_ADSB_SETTINGS_SHOW_ALL_TRAILS=false\n# by default do not show roads\nSKIES_ADSB_SETTINGS_SHOW_ROADS=false\n```\n\n## Available environment variables:\n\n### Required\n\n<!-- prettier-ignore -->\n| Variable Name | Explanation | Value | Default | Platform | Example |\n|---------------|-------------|-------|---------|-----------|---------|\n| SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE | Default latitude for default origin location (from Step 1) | number | none | All | 25.7919 |\n| SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE | Default longitude for default origin location (from Step 1) | number | none | All | -80.2871 |\n| SKIES_ADSB_RPI_USERNAME | Default RPI username with sudo privileges | string | pi | RPI | pi |\n| SKIES_ADSB_RPI_HOST | Default RPI IP address | string | none | RPI | 192.168.1.123 |\n\n### Optional\n\nPlease see the [INSTALL.md](INSTALL.md) guide for all the available optional environment variables.\n\n## Step 4 - Choose and Configure ADS-B Driver\n\nYou have three driver options for the RTL-SDR:\n\n1. dump1090-mutability (default) - Included in Raspberry Pi OS\n2. readsb - More modern (actively developed) driver - Not included with Raspberry Pi OS but compatible and easily installed\n3. existing - Use an existing receiver\n\n### dump1090-mutability (default)\n\nThis driver comes preinstalled with Raspberry Pi OS (Raspbian). It works out of the box with minimal configuration needed. You can customize settings after installation if desired - see the configuration section below.\n\nThe dump1090-mutability driver provides basic ADS-B decoding capabilities and is a good choice for getting started quickly.\n\n### readsb\n\nThis is an actively developed and modern RTL-SDR driver that works with Debian-based systems like Raspberry Pi OS. The default configuration works well out of the box, though it can be customized if needed (see documentation links below).\n\n**Important Note for RTL-SDR Blog V4 SDR Users:**\nIf you're using a RTL-SDR Blog V4 SDR, you'll need to install additional drivers first:\n\n1. See installation instructions at:\n\n- https://github.com/wiedehopf/adsb-scripts/wiki/Automatic-installation-for-readsb#installation\n- https://www.rtl-sdr.com/V4/\n\nNote: In outdoor setups, readsb has shown better reliability with fewer dropped positions compared to dump1090-mutability.\n\n### existing - Using An Existing ADS-B receiver\n\nThe skies-adsb app works with any receiver that outputs [SBS BaseStation formatted data](http://woodair.net/sbs/article/barebones42_socket_data.htm) on a TCP socket. To use an existing receiver, you'll need its IP address and SBS output port number.\n\nFor example, if your receiver outputs SBS data on IP 192.168.1.100 port 30003, you would use:\n\n```shell\n./install-skies-adsb.sh -e 192.168.1.100:30003\n```\n\nThis will configure skies-adsb to receive data from your existing ADS-B receiver instead of setting up a new RTL-SDR receiver.\n\n## Step 5 - Deploy and run the RPI skies-adsb install-skies-adsb.sh Script\n\nWith the .env file created in _step 3_ you are ready to set up the RPI to host the skies-adsb app.\n\n### Deploy files to RPI\n\nCopy the setup files over to the RPI as follows:\n\n```shell\ncd /path/to/skies-adsb/raspberrypi\nchmod +x deploy.sh\n./deploy.sh\n```\n\n### Run install-skies-adsb.sh script\n\nSSH into the RPI and run the **install-skies-adsb.sh** script:\n\nThis script will setup the RPI to run skies-adsb.\n\n**NOTE: by default the install script will update and upgrade your Raspberry Pi before installing dependencies.**\n\n<!-- prettier-ignore -->\n| Command | Description | Optional Argument |\n| ------- | ----------- | ---------------- |\n| -s | Skip Raspberry Pi update + upgade | none |\n| -d | Install RTL-SDR dump1090-mutability driver - Basic ADS-B decoder included with Raspberry Pi OS | none |\n| -r | Install RTL-SDR readsb driver - Modern, actively developed ADS-B decoder with enhanced features | none |\n| -e | Use existing ADS-B receiver | `<ADS-B RECEIVER IP ADDRESS>:<SBS PORT>` |\n\nExample Install with dump1090-mutability driver:\n\n```shell\n./install-skies-adsb.sh -d\n```\n\nExample Install with readsb driver:\n\n```shell\n./install-skies-adsb.sh -r\n```\n\nExample Use existing ADS-B receiver:\n\n```shell\n./install-skies-adsb.sh -e <ADS-B RECEIVER IP ADDRESS>:<SBS PORT>\n\nexample:\n\n./install-skies-adsb.sh -e 192.168.1.123:30003\n```\n\nExample multiple arguments skip upgrade and install readsb driver:\n\n```shell\n./install-skies-adsb.sh -s -r\n```\n\n### OPTIONAL: Configure dump1090-mutability\n\nIf you selected the option:\n\n```shell\n./install-skies-adsb.sh -d\n```\n\nAt some point in the installation process the dump1090-mutability config dialog will pop up:\n\n![Screenshot](configure_dump1090-mutability.png)\n\nbe sure to select \"Yes\" for \"Start dump1090 automatically\". If you make a mistake you can always re-run the dump1090-mutability setup as follows from the RPI:\n\n```shell\nsudo dpkg-reconfigure dump1090-mutability\n```\n\n### Post install-skies-adsb.sh Verification\n\nWhen the **install-skies-adsb.sh script** finishes it will reboot the RPI. When the RPI boots up again ssh into the RPI and verify that the flask and websocket proxy are listening on ports 5000 and 30006 respectively.\n\n```shell\nssh pi@raspberrypi.local\nss -tlp\n```\n\nyou should see an output similar to the one below:\n\n```shell\nState Recv-Q Send-Q Local Address:Port Peer Address:Port Process\nLISTEN 0 128 0.0.0.0:5000 0.0.0.0:_ users:((\"flask\",pid=499,fd=5),(\"flask\",pid=499,fd=3))\nLISTEN 0 1024 0.0.0.0:http 0.0.0.0:_\nLISTEN 0 100 0.0.0.0:30006 0.0.0.0:_ users:((\"websockify\",pid=572,fd=3))\nLISTEN 0 128 0.0.0.0:ssh 0.0.0.0:_\nLISTEN 0 1024 [::]:http [::]:_\nLISTEN 0 128 [::]:ssh [::]:_\n```\n\nYou can also verify the skies-adsb and skies-flask services are running as follows:\n\n```shell\nssh pi@raspberrypi.local\nsudo systemctl status skies-adsb-websockify\nsudo systemctl status skies-adsb-flask\n```\n\nfor more detailed service logs you can issue the following commands:\n\n```shell\nssh pi@raspberrypi.local\nsudo journalctl -u skies-adsb-websockify\nsudo journalctl -u skies-adsb-flask\n```\n\nNow lets setup your RTL-SDR receiver.\n\n## Step 6 - Connect your RTL-SDR receiver\n\nIf you did not install a RTL-SDR driver in **Step 5** you can skip this step and proceed to **Step 7**\n\nBy using a R820T2 based RTL-SDR receiver everything should work out of the box thanks to the [readsb](https://github.com/wiedehopf/readsb) or [dump1090-mutability](https://github.com/adsbxchange/dump1090-mutability) package installed on the RPI in Step 5.\n\nNow lets verify that the receiver works.\n\nShutdown the RPI:\n\n```shell\nssh pi@raspberrypi.local\nsudo shutdown -h now\n```\n\nonce the RPI is shutdown:\n\n1. disconnected the power\n2. plug in your RTL-SDR device to any of the available USB ports on the RPI.\n3. reconnect the power\n\nOnce the RPI boots up you can verify that the RLT-SDR receiver ADS-B data is being decoded using netcat:\n\n```shell\nssh pi@raspberrypi.local\nsudo apt install -y netcat-openbsd\nnc localhost 30003\n```\n\nyou should see a stream of raw ADS-B data. Press CTRL-C to stop.\n\nalternatively you can just verify that the ports 30001 to 30005 are listening for connections:\n\n```shell\nssh pi@raspberrypi.local\nss -tlp\n```\n\nyou should see something like:\n\n```shell\nLISTEN    0          1024                 0.0.0.0:80                 0.0.0.0:*\nLISTEN    0          511                127.0.0.1:30104              0.0.0.0:*\nLISTEN    0          100                  0.0.0.0:30006              0.0.0.0:*        users:((\"websockify\",pid=460,fd=3))\nLISTEN    0          128                  0.0.0.0:22                 0.0.0.0:*\nLISTEN    0          128                  0.0.0.0:5000               0.0.0.0:*        users:((\"flask\",pid=464,fd=3))\nLISTEN    0          511                127.0.0.1:30001              0.0.0.0:*\nLISTEN    0          511                127.0.0.1:30003              0.0.0.0:*\nLISTEN    0          511                127.0.0.1:30002              0.0.0.0:*\nLISTEN    0          511                127.0.0.1:30005              0.0.0.0:*\nLISTEN    0          511                127.0.0.1:30004              0.0.0.0:*\nLISTEN    0          1024                    [::]:80                    [::]:*\nLISTEN    0          128                     [::]:22                    [::]:*\n```\n\nNow lets setup the workstation build environment so we can build and deploy the skies-adsb web app.\n\n### Step 6b - OPTIONAL: Configure dump1090-mutability Remote Access\n\n**NOTE: This step is only if you installed the dump1090-mutability package.**\n\nBy default, **dump1090-mutability** only accepts connections from **localhost**. To allow connections to **port 30003** from other machines on your network, follow these steps to reconfigure **dump1090-mutability**:\n\n```shell\nssh pi@raspberrypi.local\nsudo dpkg-reconfigure dump1090-mutability\n```\n\n**WARNING:** Only modify these settings if you understand the security implications of allowing remote connections.\n\n![Screenshot](configure_dump1090-mutability.png)\n\nbe sure to select \"Yes\" for \"Start dump1090 automatically\".\n\nContinue the configuration with the default settings (unless you know what you are doing) and you will reach the following screen:\n\n![Screenshot](configure_dump1090-mutability-bind-1.png)\n\nClear out the value there so it looks like this:\n\n![Screenshot](configure_dump1090-mutability-bind-2.png)\n\npress **OK** and continue with the **dump1090-mutability** configuration.\n\nOnce the configuration is finished you can verify that **dump1090-mutability** can be accessed remotely as follows:\n\n```shell\nssh pi@raspberrypi.local\nss -tlp\n```\n\nYou should see something like:\n\n```shell\nState     Recv-Q     Send-Q         Local Address:Port          Peer Address:Port    Process\nLISTEN    0          1024                 0.0.0.0:80                 0.0.0.0:*\nLISTEN    0          511                  0.0.0.0:30005              0.0.0.0:*\nLISTEN    0          511                  0.0.0.0:30004              0.0.0.0:*\nLISTEN    0          100                  0.0.0.0:30006              0.0.0.0:*        users:((\"websockify\",pid=460,fd=3))\nLISTEN    0          511                  0.0.0.0:30001              0.0.0.0:*\nLISTEN    0          511                  0.0.0.0:30003              0.0.0.0:*\nLISTEN    0          511                  0.0.0.0:30002              0.0.0.0:*\nLISTEN    0          128                  0.0.0.0:22                 0.0.0.0:*\nLISTEN    0          128                  0.0.0.0:5000               0.0.0.0:*        users:((\"flask\",pid=464,fd=3))\nLISTEN    0          511                  0.0.0.0:30104              0.0.0.0:*\nLISTEN    0          1024                    [::]:80                    [::]:*\nLISTEN    0          511                     [::]:30005                 [::]:*\nLISTEN    0          511                     [::]:30004                 [::]:*\nLISTEN    0          511                     [::]:30001                 [::]:*\nLISTEN    0          511                     [::]:30003                 [::]:*\nLISTEN    0          511                     [::]:30002                 [::]:*\nLISTEN    0          128                     [::]:22                    [::]:*\nLISTEN    0          511                     [::]:30104                 [::]:*\n```\n\n## Step 7 - Build and Deploy the skies-adsb web app to the Raspberry Pi\n\nBuild the skies-adsb web app as follows:\n\n```shell\ncd /path/to/skies-adsb\nnpm run build\n```\n\nwhen the \"npm run build\" script is finished you can deploy the web app to the RPI as follows:\n\n```shell\ncd /path/to/skies-adsb\nchmod +x deploy_web_app.sh\n./deploy_web_app.sh\n```\n\n### Customizing the skies-adsb WEBROOT\n\nThe default skies-adsb web root directory is `/var/www/html/skies-adsb`. You can customize this by modifying the `WEBROOT` variable in `deploy_web_app.sh`:\n\n```shell\nWEBROOT=\"/var/www/html/skies-adsb\"\n```\n\nChange this path as needed for your environment.\n\n## Step 8 - Test the skies-adsb Installation\n\nAt this point from your workstation you should be able to open a web browser and navigate to:\n\n```shell\nhttp://raspberrypi.local/skies-adsb\n```\n\n**NOTE:** _The app works on all of the recent versions of the major browsers: Chrome (Desktop+Mobile), Firefox (Desktop), and Safari (Desktop+Mobile)._\n\nand you should see either:\n\n- A wireframe map showing your local geography and points of interest, or\n- A wireframe reference grid at the center of the display\n\nYou may or may not see any air traffic depending on your geographic location.\n\nIf you see no air traffic, check that:\n\n1. Your RTL-SDR receiver is properly connected and receiving signals\n2. You have correctly set your latitude/longitude coordinates as described in Step 1\n3. There are aircraft flying within range of your receiver (typically 50-150 miles depending on conditions)\n\nYou can verify signal reception by checking the raw ADS-B data feed:\n\n```shell\nssh pi@raspberrypi.local\nnc localhost 30003\n```\n\nIf you see data flowing, the receiver is working. If not, try:\n\n- Moving the antenna to a better location\n- Using a longer antenna cable to place it higher\n- Checking RTL-SDR connections\n\nAt this point feel free to take your setup outside, enjoy the outdoors, and do some plane spotting.\n\nI hope you enjoy using the app.\n"
  },
  {
    "path": "docs/dot-env-template",
    "content": "#\n# Required Settings for all skies-adsb setups\n#\n\n#SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE=\n#SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE=\n#SKIES_ADSB_DEFAULT_ORIGIN_ELEVATION_METERS_OPTIONAL=\n\n#\n# Required for Raspberry Pi Setup\n#\n\n#SKIES_ADSB_RPI_USERNAME=pi\n#SKIES_ADSB_RPI_HOST=\n\n#\n# Required for Localhost Setup\n#\n\n#SKIES_ADSB_USE_EXISTING_ADSB=\n\n#\n# Optional Default Camera Mode\n#\n\n#SKIES_ADSB_DEFAULT_CAMERA_MODE=\n\n#\n# Optional Auto Orbit Camera Settings\n#\n\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_MIN_RADIUS=\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_MAX_RADIUS=\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_RADIUS_SPEED=\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_VERTICAL_SPEED=\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_HORIZONTAL_SPEED=\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_MIN_PHI=\n#SKIES_ADSB_SETTINGS_AUTO_ORBIT_MAX_PHI=\n\n#\n# Optional Default Settings\n#\n\n#SKIES_ADSB_SETTINGS_DEFAULT_SKYBOX=\n#SKIES_ADSB_SETTINGS_SHOW_ALL_TRAILS=\n#SKIES_ADSB_SETTINGS_SHOW_AERODROMES=\n#SKIES_ADSB_SETTINGS_SHOW_ORIGINS=\n#SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_B=\n#SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_C=\n#SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_D=\n#SKIES_ADSB_SETTINGS_SHOW_URBAN_AREAS=\n#SKIES_ADSB_SETTINGS_SHOW_ROADS=\n#SKIES_ADSB_SETTINGS_SHOW_LAKES=\n#SKIES_ADSB_SETTINGS_SHOW_RIVERS=\n#SKIES_ADSB_SETTINGS_SHOW_STATES_PROVINCES=\n#SKIES_ADSB_SETTINGS_SHOW_COUNTIES=\n"
  },
  {
    "path": "docs/flask-config-template.json",
    "content": "{\n  \"FLIGHTAWARE_API_KEY\": \"\"\n}\n"
  },
  {
    "path": "flask/README.md",
    "content": "# skies-adsb Flask app\n\nThis document describes how to setup the skies-adsb Flask app server which acts as a local proxy to the FlightAware AeroAPI v4 and the FAA METAR api.\n\nThe FlightAware AeroAPI integration is optional. When enabled, it allows fetching flight status information for aircraft with a known callsign.\n\n## Table of Contents\n\n- [Dependencies](#dependencies)\n- [Step 1 - Create a Flask config.json file](#step-1---create-a-flask-configjson-file)\n- [Step 2 - OPTIONAL: Add the FlightAware AeroAPI v4 Key to the config.json File](#step-2---optional-add-the-flightaware-aeroapi-v4-key-to-the-configjson-file)\n- [Notes](#notes)\n  - [Run the Flask Server In Development Mode](#run-the-flask-server-in-development-mode)\n  - [Verify Flask Server is working](#verify-flask-server-is-working)\n\n## Dependencies\n\n| Dependency             | Description                                                |\n| ---------------------- | ---------------------------------------------------------- |\n| Python 3               | Scripting language for GeoJSON layer creation              |\n| flask                  | Web framework for Python                                   |\n| flask-cors             | Flask extension for handling Cross Origin Resource Sharing |\n| Requests               | HTTP library for Python                                    |\n| GeoPandas              | Geospatial data processing library                         |\n| osmtogeojson           | Converts Overpass API data to GeoJSON                      |\n| Natural Earth datasets | Pre-included map data (see update instructions below)      |\n| FAA airspace data      | Pre-included airspace data (see update instructions below) |\n| QGIS (optional)        | GUI tool for viewing and editing GeoJSON layers            |\n| VSCode (optional)      | Recommended IDE for Python development                     |\n\n# Step 1 - Create a Flask config.json file\n\n```shell\ncd /path/to/skies-adsb\ncp docs/flask-config-template.json flask/config.json\n```\n\n# Step 2 - OPTIONAL: Add the FlightAware AeroAPI v4 Key to the config.json File\n\n### If you don't have a FlightAware AeroAPI subscription, you can skip this step. Flight status information will be unavailable.\n\n### CAUTION: AeroAPI is a paid service. Visit the documentation link below for API key creation and billing setup.\n\n```json\n{\n  \"FLIGHTAWARE_API_KEY\": \"<YOUR API KEY>\"\n}\n```\n\n_note: only AeroAPI v4+ is supported_\n\nFor instructions on how to create an AeroAPI v4 key go here:\n\nhttps://flightaware.com/aeroapi/portal/documentation\n\nsee section on **\"Authentication\"**.\n\n# Notes\n\n## Run the Flask Server In Development Mode\n\nStart the Flask app in development mode:\n\n```bash\nnpm run dev-flask\n```\n\n## Verify Flask Server is working\n\nYou can test that the app is working correctly by making a test request:\n\n```bash\ncurl http://localhost:5000/hello\n```\n\nIf everything is working as expected you will see:\n\n```json\n{ \"text\": \"Hello, World!\" }\n```\n"
  },
  {
    "path": "flask/app.py",
    "content": "from flask import Flask\nfrom flask import jsonify\nfrom flask_cors import CORS\nimport json\nimport pprint\nimport requests\n\n\nKEY_IDENT = 'ident'\nKEY_FLIGHTS = 'flights'\nKEY_ORIGIN = 'origin'\nKEY_DESTINATION = 'destination'\nKEY_CODE = 'code'\nKEY_NAME = 'name'\nKEY_CITY = 'city'\n\napp = Flask(__name__)\napp.config.from_file(\"config.json\", load=json.load)\nCORS(app)\n\npp = pprint.PrettyPrinter(indent=2)\n\ndef create_flight_data(flight, aircraftTypeResult, airlineInfoResult):\n  return {\n    'ident': flight[KEY_IDENT],\n    'origin': flight[KEY_ORIGIN][KEY_CODE],    \n    'originName': flight[KEY_ORIGIN][KEY_NAME],\n    'originCity': flight[KEY_ORIGIN][KEY_CITY],\n    'destination': flight[KEY_DESTINATION][KEY_CODE],\n    'destinationName': flight[KEY_DESTINATION][KEY_NAME],\n    'destinationCity': flight[KEY_DESTINATION][KEY_CITY],\n    'manufacturer': aircraftTypeResult['manufacturer'],\n    'type': aircraftTypeResult['type'],\n    'description': aircraftTypeResult['description'],\n    'airline': airlineInfoResult['name'],\n    'airlineCallsign': airlineInfoResult['callsign']\n  }\n\n@app.route('/flightinfo/<callsign>')\ndef flightinfo(callsign):\n\n  AIRLINE_CODE = callsign[0:3] if len(callsign) > 2 else \"\"\n\n  EMPTY_LOCATION = {\n    KEY_CODE: '',\n    KEY_NAME: '',\n    KEY_CITY: '',\n  }\n\n  EMPTY_FLIGHT = {\n        KEY_IDENT: callsign,\n        KEY_ORIGIN: EMPTY_LOCATION,\n        KEY_DESTINATION:EMPTY_LOCATION,\n  }\n\n  EMPTY_AIRCRAFT_TYPE = {\n    'manufacturer': '',\n    'type': '',\n    'description': ''\n  }\n  \n  EMPTY_AIRLINE = {\n    'name': '',\n    'callsign': '',\n  }\n\n  if \"FLIGHTAWARE_API_KEY\" not in app.config or not app.config[\"FLIGHTAWARE_API_KEY\"]:\n    print(\"WARNING: FlightAware API key not found or empty in configuration\")    \n    return jsonify()\n  \n  AERO_API_BASE_URL = 'https://aeroapi.flightaware.com/aeroapi/'\n\n  FLIGHTAWARE_HEADERS = {\n    'x-apikey': app.config[\"FLIGHTAWARE_API_KEY\"]\n    }\n\n  print(\"##############################\")\n\n  print(f\"Fetching Flight Info for: {callsign}\")\n\n  flight = EMPTY_FLIGHT\n  \n  FlightInfoExUrl = f\"{AERO_API_BASE_URL}/flights/{callsign}\"\n  r = requests.get(FlightInfoExUrl, headers=FLIGHTAWARE_HEADERS)\n  flightsJson = r.json()\n\n  if KEY_FLIGHTS in flightsJson and len(flightsJson[KEY_FLIGHTS]) > 0   :\n    flight = flightsJson[KEY_FLIGHTS][0]\n    if KEY_ORIGIN not in flight or flight[KEY_ORIGIN] == None:\n      flight[KEY_ORIGIN] = EMPTY_LOCATION\n    if KEY_DESTINATION not in flight or flight[KEY_DESTINATION] == None:\n      flight[KEY_DESTINATION] = EMPTY_LOCATION\n\n  print(f\"FLIGHT:\\n{flight}\")\n\n  print(\"==============================\")\n\n  aircraftType = flight['aircraft_type'] if 'aircraft_type' in flight else None\n\n  aircraftTypeResult = EMPTY_AIRCRAFT_TYPE\n\n  if aircraftType != None:\n    AircraftTypeUrl = f'{AERO_API_BASE_URL}/aircraft/types/{aircraftType}'\n    print(AircraftTypeUrl)\n    r = requests.get(AircraftTypeUrl, headers=FLIGHTAWARE_HEADERS)\n    aircraftTypeJson = r.json()\n    \n    print(aircraftTypeJson)\n    if 'status' not in aircraftTypeJson:\n        aircraftTypeResult = aircraftTypeJson\n    \n  print(aircraftTypeResult)\n\n  print(\"------------------------------\")\n\n  airlineInfoResult = EMPTY_AIRLINE\n\n  if (len(AIRLINE_CODE) == 3):\n    AirlineInfoUrl = f'{AERO_API_BASE_URL}/operators/{AIRLINE_CODE}'\n    r = requests.get(AirlineInfoUrl, headers=FLIGHTAWARE_HEADERS)\n    airlineInfoJson = r.json()\n    print(airlineInfoJson)\n    if 'status' not in airlineInfoJson:    \n      airlineInfoResult = airlineInfoJson\n\n  print(airlineInfoResult)\n\n  print(\"******************************\")\n  \n  data = create_flight_data(flight, aircraftTypeResult, airlineInfoResult)\n  print(data)\n  return jsonify(data)\n\n@app.route('/metar/<station>')\ndef metar(station):\n  METAR_URL = f\"https://aviationweather.gov/api/data/metar?ids={station}&format=geojson\"  \n  r = requests.get(METAR_URL)\n  return r.json()\n\n\n@app.route('/hello')\ndef hello():\n  return jsonify({\"text\": \"Hello, World!\"})\n"
  },
  {
    "path": "maps/build-map-layers.py",
    "content": "import argparse\nimport geopandas as gpd\nimport glob\nimport json\nimport os\nimport requests\nimport warnings\nfrom shapely.geometry import box, Polygon, MultiPolygon, LineString, MultiLineString\nfrom osmtogeojson import osmtogeojson\n\nOUTPUT_DIR = \"../public/map-data\"\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\n#\n# Parse cli arguments\n#\n\n# Default distance from the origin used to build a bounding box to clip the map layers\n# Adjust as needed for your area of interest\nDEFAULT_ORIGIN_DISTANCE = 2.0\n\nparser = argparse.ArgumentParser(description=\"Build map layers for an lat/lon origin and bounding box\")\nparser.add_argument(\"--origin-lat\", type=float, default=None, help=\"Latitude of origin\")\nparser.add_argument(\"--origin-lon\", type=float, default=None, help=\"Longitude of origin\")\nparser.add_argument(\"--origin-distance\", type=float, default=DEFAULT_ORIGIN_DISTANCE, help=\"Distance from origin (in degrees) used to build bounding box\")\nparser.add_argument(\"--origin-left\", type=float, default=None, help=\"Distance from origin and to the left (in degrees) used to build bounding box\")\nparser.add_argument(\"--origin-top\", type=float, default=None, help=\"Distance from origin and to the top (in degrees) used to build bounding box\")\nparser.add_argument(\"--show-geopandas-warnings\", type=bool, default=False, help=\"Show Geopandas warnings\")\nparser.add_argument(\"--build-110m-maps\", type=bool, default=False, help=\"Build 110m maps instead of 10m maps\")\nparser.add_argument(\"--skip-aerodromes\", type=bool, default=False, help=\"Skip building aerodrome layers and origins\")\n\nargs = parser.parse_args()\n\n#\n# setup default origin latitude and longitude\n#\n\norigin_lat = os.environ.get(\"SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE\") or args.origin_lat\norigin_lon = os.environ.get(\"SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE\") or args.origin_lon\n\nif origin_lat is not None and origin_lon is not None:\n    ORIGIN_LAT = float(origin_lat)\n    ORIGIN_LON = float(origin_lon)\nelse:\n    print(\"Error: Default origin latitude and longitude not found.\")\n    parser.print_help()\n    print()\n    exit(1)\n\n\n\n#\n# By default suppress geopandas warnings\n#\n# Pass commandline argument --show-geopandas-warnings=true to show warnings\n#\nif not args.show_geopandas_warnings:\n    warnings.filterwarnings(\"ignore\")\n    \nORIGIN_LEFT = None\nORIGIN_TOP = None\n\nif args.origin_left is not None and args.origin_top is not None:\n    ORIGIN_LEFT = args.origin_left\n    ORIGIN_TOP = args.origin_top\nelse:\n    ORIGIN_LEFT = args.origin_distance\n    ORIGIN_TOP = args.origin_distance\n    \nORIGIN_LEFT = abs(ORIGIN_LEFT)\nORIGIN_TOP = abs(ORIGIN_TOP)\n\n# setup Bounding box for clipping\nWEST = ORIGIN_LON - ORIGIN_LEFT\nEAST = ORIGIN_LON + ORIGIN_LEFT\nNORTH = ORIGIN_LAT + ORIGIN_TOP\nSOUTH = ORIGIN_LAT - ORIGIN_TOP\n\nprint(\"############################################\")\nprint(f\"\\nDefault Origin lat: {ORIGIN_LAT} lon: {ORIGIN_LON}\")\nprint(f\"Origin Left: {ORIGIN_LEFT} Top: {ORIGIN_TOP}\")\nprint(f\"Bounding box: ({WEST}, {NORTH}) to ({EAST}, {SOUTH})\\n\")\nprint(\"============================================\")\n#\n# Convert any instances of Polygon and MultiPolygon to LineString or MultiLineString as needed\n#\ndef convert_polygons_to_lines(geometry):\n    if isinstance(geometry, (Polygon, MultiPolygon)):\n        if isinstance(geometry, Polygon):\n            return LineString(list(geometry.exterior.coords))\n        else:\n            lines = []\n            for poly in geometry.geoms:\n                lines.append(LineString(list(poly.exterior.coords)))\n            return MultiLineString(lines)\n    return geometry\n\n#\n# Clip a shapefile to a bounding box\n#\ndef clip_shapefile_to_bounding_box(shape_file, bounding_box):\n    try:\n        gdf = gpd.read_file(shape_file)        \n        bounds = box(*bounding_box)\n        clipped_gdf = gdf.clip(bounds)\n        clipped_gdf.geometry = clipped_gdf.geometry.apply(convert_polygons_to_lines)\n        return clipped_gdf\n    except FileNotFoundError:\n        print(f\"Error: shape file not found at {shape_file}\")\n        return None\n    except Exception as e:\n        print(f\"An unexpected error occurred: {e}\")\n        return None\n\n#\n# Clean output directory first\n#\ndef clean_output_directory():\n    print(f\"Cleaning output directory: {OUTPUT_DIR}\")\n    try:\n        # Find and delete all .geojson and .json files\n        for pattern in ['*.geojson', '*.json']:\n            files = glob.glob(os.path.join(OUTPUT_DIR, pattern))\n            for file in files:\n                os.remove(file)\n                print(f\"Deleted: {file}\")\n    except Exception as e:\n        print(f\"Error cleaning directory {OUTPUT_DIR}: {e}\")\n\nprint(\"############################################\")\nclean_output_directory()\nprint(\"============================================\")\n\n#\n# Generate Natural Earth Layers\n#\n\nMAP_LAYERS_10M = [\n    (\"data/10m_cultural/10m_cultural/ne_10m_admin_1_states_provinces.shp\", \"states_provinces\"),\n    (\"data/10m_cultural/10m_cultural/ne_10m_airports.shp\", \"airports\"),    \n    (\"data/10m_cultural/10m_cultural/ne_10m_urban_areas.shp\", \"urban_areas\"),\n    (\"data/10m_cultural/10m_cultural/ne_10m_admin_2_counties.shp\", \"counties\"),\n    (\"data/10m_cultural/10m_cultural/ne_10m_roads.shp\", \"roads\"),\n    (\"data/10m_physical/ne_10m_lakes.shp\", \"lakes\"),\n    (\"data/10m_physical/ne_10m_rivers_lake_centerlines.shp\", \"rivers\"),\n]\n\nMAP_LAYERS_110M = [\n    (\"data/110m_cultural/ne_110m_admin_1_states_provinces.shp\", \"states_provinces\"),            \n    (\"data/110m_physical/ne_110m_lakes.shp\", \"lakes\"),\n    (\"data/110m_physical/ne_110m_rivers_lake_centerlines.shp\", \"rivers\"),\n]\n\nif args.build_110m_maps:\n    MAP_LAYERS = MAP_LAYERS_110M\nelse:\n    MAP_LAYERS = MAP_LAYERS_10M\n\nprint(\"############################################\")\nprint(\"Generating Natural Earth Layers...\")\n\nif (args.build_110m_maps):\n    print(\"\\n\\tBuilding 110m maps\")\nelse:\n    print(\"\\n\\tBuilding 10m maps\")\n\nprint(f\"\\n\\tClipping maps to bounding box ({WEST}, {NORTH}) to ({EAST}, {SOUTH})...\")\n\nfor map_data, output_name, in MAP_LAYERS:\n    print(f\"\\tClipping {map_data} to bounding box...\")\n    clipped_map = clip_shapefile_to_bounding_box(map_data, (WEST, NORTH, EAST, SOUTH))\n    clipped_map.to_file(f\"{OUTPUT_DIR}/{output_name}.geojson\", driver=\"GeoJSON\")\n\nprint(\"============================================\")\n\n\n#\n# Generate FAA Airspace Layers\n#\n\nprint(\"############################################\")\nprint(\"Generating FAA Airspace Layers...\\n\")\n\nAIRSPACE = [\n    (\"B\", \"airspace_class_b\"),\n    (\"C\", \"airspace_class_c\"),\n    (\"D\", \"airspace_class_d\"),\n]\n\nclipped_airspace = clip_shapefile_to_bounding_box(\"data/Class_Airspace/Class_Airspace.shp\", (WEST, NORTH, EAST, SOUTH))\nclipped_airspace = clipped_airspace.to_crs(\"EPSG:4326\")\nfor class_name, output_name in AIRSPACE:\n    print(f\"\\tClipping Class {class_name} Airspace to bounding box...\")\n    airspace = clipped_airspace[clipped_airspace[\"CLASS\"] == class_name]\n    airspace.to_file(f\"{OUTPUT_DIR}/{output_name}.geojson\", driver=\"GeoJSON\")\n\nprint(\"============================================\")\n\n#\n# Generate Aerodrome and Runway Geometry Layers\n#\n\nprint(\"############################################\")\nprint(\"Generating OSM Aerodrome and Runway Geometry Layers...\\n\")\n\ndef generate_aerodrome_runway_geometry(osm_value, output_file_name):\n    try:\n        OVERPASS_URL = \"https://overpass-api.de/api/interpreter\"\n        bounds = f\"\"\"{SOUTH},{WEST},{NORTH},{EAST}\"\"\"\n        query = f\"\"\"\n            [out:json][timeout:25];\n            (    \n            way[\"aeroway\"=\"{osm_value}\"]({bounds});\n            relation[\"aeroway\"=\"{osm_value}\"]({bounds});\n            );\n            out body;\n            >;\n            out skel qt;\n        \"\"\"\n        print(query)\n        result = requests.get(OVERPASS_URL, params={\"data\": query})\n        osm_json = osmtogeojson.process_osm_json(result.json())\n        osm_json['name'] = osm_value\n        with(open(f\"{OUTPUT_DIR}/{output_file_name}\", 'w')) as f:\n            json.dump(osm_json, f, indent=4)\n    except Exception as e:\n        print(f\"An unexpected error occurred: {e}\")\n\n\nOVERPASS_QUERIES = [\n    (\"aerodrome\", \"aerodrome.geojson\"),\n    (\"runway\", \"tmp_runway.geojson\"),\n]\n\nfor osm_value, output_file_name in OVERPASS_QUERIES:\n    if not args.skip_aerodromes:\n        print(f\"\\tRunning Overpass query for {osm_value}...\")\n        generate_aerodrome_runway_geometry(osm_value, output_file_name)\n    else:\n        print(f\"\\tSkipping Overpass query for {osm_value}...\")\n        with open(output_file_name, 'w') as f:\n            json.dump({}, f)\n\n\nprint(f\"\\tMerge Aerodromes and Runways...\")\n\ntmp_runway_geojson = f\"{OUTPUT_DIR}/{OVERPASS_QUERIES[1][1]}\"\n\ngdf_aerodromes = gpd.read_file(f\"{OUTPUT_DIR}/{OVERPASS_QUERIES[0][1]}\")\ngdf_runways = gpd.read_file(tmp_runway_geojson)\nmerged_gdf = gpd.sjoin(gdf_runways, gdf_aerodromes, how='inner', predicate='within')\nmerged_gdf.to_file(f\"{OUTPUT_DIR}/runway.geojson\", driver=\"GeoJSON\")\n\ntry:    \n    os.remove(tmp_runway_geojson)\n    print(f\"Deleted: {tmp_runway_geojson}\")\nexcept Exception as e:\n    print(f\"Error cleaning directory {OUTPUT_DIR}: {e}\")\n\n\nprint(\"============================================\")\n\n#\n# Generate Aerodrome Origins as LAT/LON\n#\n\nprint(\"############################################\")\nprint(\"Fetching OSM Aerodrome Origins as LAT/LON...\")\n\nAERODROME_ORIGINS_FILENAME = f\"{OUTPUT_DIR}/origins.json\"\n\ndef get_aerodrome_origins_as_lat_lon():\n    try:\n        OVERPASS_URL = \"https://overpass-api.de/api/interpreter\"\n        bounds = f\"\"\"{SOUTH},{WEST},{NORTH},{EAST}\"\"\"\n        query = f\"\"\"\n            [out:json][timeout:25];\n            (    \n            way[\"aeroway\"=\"aerodrome\"]({bounds});\n            relation[\"aeroway\"=\"aerodrome\"]({bounds});\n            );\n            out center tags;\n        \"\"\"\n        print(query)\n        result = requests.get(OVERPASS_URL, params={\"data\": query})        \n        with(open(AERODROME_ORIGINS_FILENAME, 'w')) as f:\n            json.dump(result.json(), f, indent=4)\n    except Exception as e:\n        print(f\"An unexpected error occurred: {e}\")\n\nif not args.skip_aerodromes:\n    get_aerodrome_origins_as_lat_lon()\nelse:\n    print(f\"\\tSkipping Overpass query for OSM Aerodrome Origins...\")\n    with open(AERODROME_ORIGINS_FILENAME, 'w') as f:\n        json.dump({}, f)\n\n\nprint(\"============================================\")\n"
  },
  {
    "path": "maps/build-map-layers.sh",
    "content": "#!/usr/bin/env bash\n\nsource ../.venv/bin/activate\n\nENV_FILE=../src/.env\n\nsource $ENV_FILE\n\nif [ -z \"$SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE\" ] || [ -z \"$SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE\" ]; then\n  echo \"Error: Required environment variables are not set\"\n  echo \"Please set SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE and SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE\"\n  exit 1\nfi\n\nexport $(grep '^SKIES_ADSB_DEFAULT_ORIGIN' $ENV_FILE | xargs)\n\n# forward all command line arguments\npython3 build-map-layers.py \"$@\"\n"
  },
  {
    "path": "maps/data/install-datasets.sh",
    "content": "#!/usr/bin/env bash\n\nrm -rf 10m_cultural\nrm -rf 10m_physical\nrm -rf 110m_cultural\nrm -rf 110m_physical\nrm -rf Class_Airspace\n\nunzip -d 10m_cultural 10m_cultural.zip\nunzip -d 10m_physical 10m_physical.zip\nunzip -d 110m_cultural 110m_cultural.zip\nunzip -d 110m_physical 110m_physical.zip\nunzip -d Class_Airspace Class_Airspace.zip\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"skies-adsb\",\n  \"version\": \"2.4.3\",\n  \"description\": \"skies-adsb is a real-time 3D browser based web app for tracking aircraft using ADS-B data obtained from a RTL-SDR receiver.\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build --emptyOutDir --base=/skies-adsb/\",\n    \"dev-flask\": \"source .venv/bin/activate && cd flask && export FLASK_ENV=development && flask run -h 0.0.0.0\"\n  },\n  \"keywords\": [],\n  \"author\": \"\",\n  \"license\": \"MIT License\",\n  \"devDependencies\": {\n    \"vite\": \"^5.4.14\"\n  },\n  \"dependencies\": {\n    \"@mapbox/sphericalmercator\": \"^2.0.1\",\n    \"dat.gui\": \"^0.7.9\",\n    \"stats.js\": \"^0.17.0\",\n    \"three\": \"^0.133.1\",\n    \"troika-three-text\": \"^0.52.3\"\n  }\n}\n"
  },
  {
    "path": "raspberrypi/deploy.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# this script is used to deploy the skies-adsb flask app and system services to the Raspberry Pi server\n#\n\nsource ../src/.env\n\nif [ -z \"$SKIES_ADSB_RPI_USERNAME\" ] || [ -z \"$SKIES_ADSB_RPI_HOST\" ]; then\n  echo \"Error: Required environment variables are not set\"\n  echo \"Please set SKIES_ADSB_RPI_USERNAME and SKIES_ADSB_RPI_HOST\"\n  exit 1\nfi\n\nif [ -z \"$SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE\" ] || [ -z \"$SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE\" ]; then\n  echo \"Error: Required environment variables are not set\"\n  echo \"Please set SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE and SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE\"\n  exit 1\nfi\n\nRPI_TARGET=$SKIES_ADSB_RPI_USERNAME@$SKIES_ADSB_RPI_HOST\n\n#\n# create tar files for flask app\n#\n\n# create tar file for flask app, excluding unnecessary files\ntar -czvf skies-adsb-app.tar.gz \\\n  --exclude='__pycache__' \\\n  --exclude='README.md' \\\n  --exclude='*.zip' \\\n  --exclude='*.log' \\\n  ../flask ../src/.env skies-*.service skies-*.sh\n\n# copy files to Raspberry Pi\necho \"Copying skies-adsb files to Raspberry Pi...\"\nscp install-skies-adsb.sh skies-adsb-app.tar.gz \"$RPI_TARGET:~\"\n\n# Cleanup\nrm skies-adsb-app.tar.gz\n"
  },
  {
    "path": "raspberrypi/install-skies-adsb.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# this is the skies-adsb install script for the Raspberry Pi\n#\n\nif ! grep -q \"Raspberry Pi\" /proc/cpuinfo; then\n  echo \"This script must be run on a Raspberry Pi\"\n  exit 1\nfi\n\nwhile getopts \":srde:\" opt; do\n  case $opt in\n  s)\n    SKIP_RPI_UPGRADE=1\n    ;;\n  r)\n    ADSB_DRIVER=\"readsb\"\n    ;;\n  d)\n    ADSB_DRIVER=\"dump1090\"\n    ;;\n  e)\n    ADSB_DRIVER=\"existing\"\n    ADSB_HOST_PORT=\"$OPTARG\"\n    # validate IP:port format\n    if ! echo \"$ADSB_HOST_PORT\" | grep -qE '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+:[0-9]+$'; then\n      echo \"Error: -e requires valid IP:port format (e.g. 192.168.1.123:30003)\"\n      exit 1\n    fi\n    ;;\n  \\?)\n    echo \"Invalid option: -$OPTARG\"\n    exit 1\n    ;;\n  :)\n    echo \"Option -$OPTARG requires an argument\"\n    exit 1\n    ;;\n  esac\ndone\n\nif [ -z \"$ADSB_DRIVER\" ]; then\n  echo \"Error: ADSB driver not specified. Use -r for readsb or -d for dump1090 or -e for using existing ADS-B receiver\"\n  exit 1\nfi\n\nif [ -z \"$ADSB_HOST_PORT\" ]; then\n  ADSB_HOST_PORT=\"0.0.0.0:30003\"\nfi\n\nupgrade_rpi() {\n  echo \"###############################################\"\n  echo \"Updating and upgrading Raspberry Pi system...\"\n  echo \"-----------------------------------------------\"\n\n  echo \"Running apt update...\"\n  sudo apt update\n\n  echo \"Running apt upgrade...\"\n  sudo apt -y upgrade\n\n  echo \"Cleaning up packages...\"\n  sudo apt -y autoremove\n\n  echo \"System update complete!\"\n  echo \"**********************************************\"\n}\n\ninstall_readsb() {\n  echo \"###############################################\"\n  echo \"Installing readsb...\"\n  echo \"-----------------------------------------------\"\n\n  sudo bash -c \"$(wget -O - https://github.com/wiedehopf/adsb-scripts/raw/master/readsb-install.sh)\"\n\n  source ~/skies-adsb/src/.env\n\n  sudo readsb-set-location $SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE $SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE\n\n  echo\n  echo \"readsb installation complete!\"\n  echo \"**********************************************\"\n}\n\ninstall_dump1090() {\n  echo \"###############################################\"\n  echo \"Installing dump1090-mutability...\"\n  echo \"-----------------------------------------------\"\n\n  # Install dump1090 package (includes lighttpd dependency)\n  sudo apt -y install dump1090-mutability\n\n  # Add dump1090 user to plugdev group for USB device access\n  echo \"Adding dump1090 user to plugdev group...\"\n  sudo adduser dump1090 plugdev\n\n  echo \"dump1090 installation complete!\"\n  echo \"**********************************************\"\n}\n\nsetup_python_environment() {\n  echo \"###############################################\"\n  echo \"Setup Python environment...\"\n  echo \"-----------------------------------------------\"\n\n  if ! dpkg -l | grep -q \"python3-websockify\"; then\n    echo \"Installing python3-websockify...\"\n    sudo apt -y install python3-websockify\n  else\n    echo \"Skipping: python3-websockify is already installed\"\n  fi\n\n  echo \"Setting up Python virtual environment...\"\n  cd ~/skies-adsb\n  python -m venv .venv\n  source .venv/bin/activate\n\n  echo \"Installing Flask and dependencies...\"\n  pip install flask flask-cors requests\n\n  deactivate\n  echo \"-----------------------------------------------\"\n  echo \"Setup Python environment complete!\"\n  echo \"**********************************************\"\n}\n\nsetup_app_start() {\n  echo \"###############################################\"\n  echo \"Setting up skies-adsb...\"\n\n  # Setup initial directory structure\n  cd\n  rm -rf skies-adsb\n  mkdir -p skies-adsb\n\n  echo \"Extracting skies-adsb app...\"\n  tar zxvf skies-adsb-app.tar.gz -C skies-adsb\n  rm skies-adsb-app.tar.gz\n\n  # Setup Python environment\n  setup_python_environment\n}\n\nsetup_app_finish() {\n  cd\n\n  # Clean up existing services\n  echo \"Stopping and removing any running skies-adsb services...\"\n  for service in websockify flask; do\n    sudo systemctl stop skies-adsb-${service}\n    sudo rm -f /etc/systemd/system/skies-adsb-${service}.service\n  done\n\n  echo \"Replacing ADSB_HOST_PORT in websockify service script...\"\n  sed -i \"s/#ADSB_HOST_PORT#/${ADSB_HOST_PORT}/g\" skies-adsb/skies-adsb-websockify.sh\n\n  # Setup new system services\n  echo \"Setting up skies-adsb system services...\"\n  sudo cp skies-adsb/*.service /etc/systemd/system/\n  sudo systemctl daemon-reload\n\n  # Enable and check services\n  for service in websockify flask; do\n    sudo systemctl enable skies-adsb-${service}\n    sudo systemctl status skies-adsb-${service}\n  done\n\n  echo \"**********************************************\"\n  echo \"Cleaning up and rebooting Raspberry Pi to complete setup...\"\n  rm install-skies-adsb.sh\n  sudo reboot\n}\n\n#\n# Main Installation Steps\n# ----------------------\n# Order is important.\n#\n\necho \"Starting skies-adsb installation...\"\necho \"====================================\"\n\nif [ -z \"$SKIP_RPI_UPGRADE\" ]; then\n  upgrade_rpi\n  echo\nfi\n\nsetup_app_start\necho\n\ncase \"$ADSB_DRIVER\" in\n\"readsb\")\n  install_readsb\n  ;;\n\"dump1090\")\n  install_dump1090\n  ;;\n\"existing\")\n  echo \"###############################################\"\n  echo \"Using existing ADS-B receiver...\"\n  echo \"**********************************************\"\n  ;;\nesac\necho\n\nsetup_app_finish\necho\n\necho \"Installation complete!\"\n"
  },
  {
    "path": "raspberrypi/skies-adsb-flask.service",
    "content": "[Unit]\nDescription=skies-adsb Flask Service \nAfter=network.target\n\n[Service]\nExecStart=/usr/bin/bash skies-adsb-flask.sh\nWorkingDirectory=/home/pi/skies-adsb\nStandardOutput=inherit\nStandardError=inherit\nRestart=always\nUser=pi\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "raspberrypi/skies-adsb-flask.sh",
    "content": "#!/usr/bin/env bash\n\n# Set Flask application path\nexport FLASK_APP=~/skies-adsb/flask/app\n\n# Activate virtual environment\nsource ~/skies-adsb/.venv/bin/activate\n\n# Change to Flask application directory\ncd ~/skies-adsb/flask\n\n# Start Flask server, listening on all interfaces\nflask run -h 0.0.0.0\n"
  },
  {
    "path": "raspberrypi/skies-adsb-websockify.service",
    "content": "[Unit]\nDescription=skies-adsb Websockify Service\nAfter=network.target\n\n[Service]\nExecStart=/usr/bin/bash skies-adsb-websockify.sh\nWorkingDirectory=/home/pi/skies-adsb\nStandardOutput=inherit\nStandardError=inherit\nRestart=always\nUser=pi\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "raspberrypi/skies-adsb-websockify.sh",
    "content": "#!/usr/bin/env bash\n#\n# Websocket proxy for ADS-B SBS data\n#\n\nLISTEN_HOST_PORT=\"0.0.0.0:30006\"\nADSB_HOST_PORT=\"#ADSB_HOST_PORT#\"\n\n# Check if websockify is installed\nif ! command -v websockify >/dev/null 2>&1; then\n  echo \"Error: websockify is not installed\"\n  exit 1\nfi\n\n# Start websockify\necho \"Starting websocket proxy on ${LISTEN_HOST_PORT}\"\nwebsockify \"${LISTEN_HOST_PORT}\" \"${ADSB_HOST_PORT}\"\n"
  },
  {
    "path": "raspberrypi/update_flask_app.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# this script is used to deploy the skies-adsb flask app and system services to the Raspberry Pi server\n#\n\nsource ../src/.env\n\nif [ -z \"$SKIES_ADSB_RPI_USERNAME\" ] || [ -z \"$SKIES_ADSB_RPI_HOST\" ]; then\n  echo \"Error: Required environment variables are not set\"\n  echo \"Please set SKIES_ADSB_RPI_USERNAME and SKIES_ADSB_RPI_HOST\"\n  exit 1\nfi\n\nRPI_TARGET=$SKIES_ADSB_RPI_USERNAME@$SKIES_ADSB_RPI_HOST\n\n#\n# create tar files for flask app\n#\n\n# Create a tarball of the Flask app, excluding unnecessary files\ntar --exclude='__pycache__' \\\n  --exclude='README.md' \\\n  --exclude='*.zip' \\\n  --exclude='*.log' \\\n  -czvf skies-adsb-flask-app.tar.gz ../flask\n\necho \"Copying skies-adsb files to Raspberry Pi...\"\nscp skies-adsb-flask-app.tar.gz \"$RPI_TARGET\":~\n\n# Execute remote commands on Raspberry Pi\nssh \"$RPI_TARGET\" 'bash -s' <<'EOF'\n  # Stop the Flask service\n  sudo systemctl stop skies-adsb-flask\n\n  # Move and extract files\n  mv skies-adsb-flask-app.tar.gz skies-adsb\n  echo \"Setting up skies-adsb flask app...\"\n  cd ~/skies-adsb  \n  tar zxvf skies-adsb-flask-app.tar.gz\n  rm skies-adsb-flask-app.tar.gz\n  cd\n\n  # Restart the Flask service\n  sudo systemctl start skies-adsb-flask\n  sudo systemctl status skies-adsb-flask\nEOF\n\necho \"Cleaning up local files...\"\nrm skies-adsb-flask-app.tar.gz\n"
  },
  {
    "path": "src/ADSB.js",
    "content": "import * as AIRCRAFT from './aircraft.js'\nimport * as UTILS from './utils.js'\n\n\n//\n// dump1090 ADB-S protocol \n//\n\nexport const MSG_TYPE = 0\nexport const TRANSMISSION_TYPE = 1\nexport const AIRCRAFT_ID = 3\nexport const HEX_IDENT = 4\nexport const FLIGHT_ID = 5\nexport const CALLSIGN = 10\nexport const ALTITUDE = 11\nexport const GROUND_SPEED = 12\nexport const TRACK = 13\nexport const LATITUDE = 14\nexport const LONGITUDE = 15\nexport const SQUAWK = 17\nexport const IS_ON_GROUND = 21\n\n\n//\n// websocket - handles ADS-B messages coming from RTL-SDR/RPI\n//\nlet websocket = null\nlet scene = null\nlet clock = null\n\nconst handleADSBMessage = (event) => {\n  const reader = new FileReader()\n  reader.onload = () => {\n    const result = reader.result\n\n    // parse SBS data here...\n\n    let data = result.split(\",\")\n    let hexIdent = data[HEX_IDENT]\n\n    if (!/^[0-9A-F]{6}$/i.test(hexIdent)) {\n      //console.warn(`[ADSB] Invalid Hex Ident - msg_type: ${data[MSG_TYPE]} hex: ${hexIdent}`)\n      return\n    }\n\n    if (!(hexIdent in AIRCRAFT.aircraft)) {\n      const aircraft = new AIRCRAFT.Aircraft(scene, hexIdent)\n      AIRCRAFT.aircraft[hexIdent] = aircraft\n    }\n\n    AIRCRAFT.aircraft[hexIdent].update(data, clock.getElapsedTime())\n  }\n  reader.readAsText(event.data)\n}\n\nexport function start(threeJsScene, threeJsClock) {\n  console.log(\"[ADSB] start: OPEN WebSocket connection...\")\n  scene = threeJsScene\n  clock = threeJsClock\n  websocket = new WebSocket(UTILS.DATA_HOSTS[\"adsb\"])\n  websocket.addEventListener('error', (event) => {\n    console.error('[ADSB] Error Message from server ', event.data)\n  })\n  websocket.addEventListener('message', handleADSBMessage)\n}\n\nexport function stop() {\n  console.log(\"[ADSB] stop: CLOSE WebSocket connection...\")\n  websocket?.removeEventListener('message', handleADSBMessage)\n  websocket?.close(1000)\n}\n\n\n\n"
  },
  {
    "path": "src/HUD.js",
    "content": "import * as UTILS from \"./utils.js\"\n\n//\n// aircraft info HTML HUD\n//\n\n/*\nhttps://www.google.com/search?q=flight status AAL961\nhttps://www.google.com/search?q=about 737 MAX 8 Boeing\nhttps://www.google.com/search?q=about American American Airlines Inc.\nhttps://www.google.com/search?q=aerodrome MROC\n*/\n\nconst HUD_DEFAULT_PHOTO = \"./static/Pan_Am_747.jpg\"\nconst NOT_AVAILABLE = \"n/a\"\n\nclass _HUD {\n  constructor() {\n    this.aircraft = null\n    this.hud = this._getHud()\n    this.isHUDDialogShown = false\n    this.isRightDialogShown = false\n    this.isFollowCamActive = false\n    this._reset()\n  }\n\n  _getHud() {\n    const dialog = document.getElementById(\"hud-dialog\")\n    const flightAwareDiv = document.querySelector(\"#section_flightAware\")\n    const planespottersDiv = document.querySelector(\"#section_planespotters\")\n    const telemetryDiv = document.querySelector(\"#section_telemetry\")\n\n    return {\n      leftButtonContainer: document.getElementById(\"hud-left\"),\n      rightButtonContainer: document.getElementById(\"hud-right\"),\n\n      homeButton: document.getElementById(\"home\"),\n      autoOrbitButton: document.getElementById(\"360\"),\n      settingsButton: document.getElementById(\"settings\"),\n      fullscreenButton: document.getElementById(\"full-screen\"),\n      cameraButton: document.getElementById(\"camera\"),\n      infoButton: document.getElementById(\"info\"),\n      closeButton: document.getElementById(\"close\"),\n\n      dialog: dialog,\n\n      callsign: document.querySelector(\"#callsign\"),\n\n      flightAwareDiv: flightAwareDiv,\n      airline: flightAwareDiv.querySelector(\"#airline\"),\n      aircraftType: flightAwareDiv.querySelector(\"#aircraftType\"),\n      origin_long: flightAwareDiv.querySelector(\"#origin_long\"),\n      destination_long: flightAwareDiv.querySelector(\"#destination_long\"),\n\n      planespottersDiv: planespottersDiv,\n      photo: dialog.querySelector(\"#photo\"),\n      aircraftRegistration: planespottersDiv.querySelector(\"#aircraftRegistration\"),\n      photographer: planespottersDiv.querySelector(\"#photographer\"),\n\n      telemetryDiv: telemetryDiv,\n      telemetry_heading: telemetryDiv.querySelector(\"#telemetry_heading\"),\n      telemetry_ground_speed: telemetryDiv.querySelector(\"#telemetry_ground_speed\"),\n      telemetry_altitude: telemetryDiv.querySelector(\"#telemetry_altitude\"),\n    }\n  }\n\n  _showPhoto() {\n    if (!this.hud || this.aircraftPhotoShown) return\n    const aircraft = this.aircraft\n    this.hud.photo.src =\n      aircraft.photo?.[\"thumbnail_large\"][\"src\"] ?? HUD_DEFAULT_PHOTO\n    this.hud.photographer.text = `PHOTOGRAPHER: ${aircraft.photo?.[\"photographer\"] ?? NOT_AVAILABLE\n      }`\n    this.hud.photographer.href = `${aircraft.photo?.[\"link\"] ?? \"#\"}`\n\n    const link = aircraft.photo?.[\"link\"]?.split(\"?\")[0]\n    if (link !== undefined) {\n      const segments = link.split(\"/\")\n      const registrationInfo = segments[segments.length - 1]?.replace(/-/g, ' ').toUpperCase()\n      if (registrationInfo !== undefined) {\n        this.hud.aircraftRegistration.text = `REG: ${registrationInfo}`\n        this.hud.aircraftRegistration.href = `https://www.google.com/search?q=about ${registrationInfo}`\n      }\n    }\n\n    this.aircraftPhotoShown = true\n  }\n\n  _reset() {\n    this.aircraft = null\n    this.needsFetchAircraftInfo = false\n    this.aircraftInfoShown = false\n    this.needsFetchAircraftPhoto = false\n    this.aircraftPhotoShown = false\n    this._clearPhoto()\n    this._clearAircraftInfo()\n  }\n\n  _clearPhoto() {\n    this.hud.photo.src = HUD_DEFAULT_PHOTO\n    this.hud.photographer.text = `PHOTOGRAPHER: ${NOT_AVAILABLE}`\n  }\n\n  _clearAircraftInfo() {\n    if (!this.hud) return\n\n    this.hud.flightAwareDiv.style.display = \"none\"\n\n    // flight aware info\n    this.hud.airline.text = NOT_AVAILABLE\n    this.hud.airline.href = \"\"\n    this.hud.aircraftType.text = NOT_AVAILABLE\n    this.hud.aircraftType.href = \"\"\n    this.hud.origin_long.text = `ORG: ${NOT_AVAILABLE}`\n    this.hud.origin_long.href = \"\"\n    this.hud.destination_long.text = `DST: ${NOT_AVAILABLE}`\n    this.hud.destination_long.href = \"\"\n\n    // planespotters info\n    this.hud.aircraftRegistration.text = `REG: ${NOT_AVAILABLE}`\n    this.hud.aircraftRegistration.href = \"\"\n\n    // ads-b telemetry\n    this.hud.callsign.text = `CALLSIGN: ${NOT_AVAILABLE}`\n    this.hud.callsign.href = \"\"\n    this.hud.telemetry_heading.text = `H: ${NOT_AVAILABLE}`\n    this.hud.telemetry_ground_speed.text = `GSPD: ${NOT_AVAILABLE}`\n    this.hud.telemetry_altitude.text = `ALT: ${NOT_AVAILABLE}`\n  }\n\n  _showAircraftInfo() {\n    if (!this.hud || this.aircraftInfoShown) return\n    const aircraft = this.aircraft\n    console.table(aircraft?.flightInfo)\n\n    if (Object.keys(aircraft?.flightInfo ?? {}).length === 0) {\n      this.hud.flightAwareDiv.style.display = \"none\"\n      return\n    }\n\n    this.hud.flightAwareDiv.style.display = \"block\"\n\n    this.hud.airline.text = `${aircraft?.flightInfo?.[\"airlineCallsign\"] ?? NOT_AVAILABLE} | ${aircraft?.flightInfo?.[\"airline\"] ?? NOT_AVAILABLE}`\n    this.hud.airline.href = `https://www.google.com/search?q=about ${aircraft?.flightInfo?.[\"airlineCallsign\"]} ${aircraft?.flightInfo?.[\"airline\"]}`\n    this.hud.aircraftType.text = `TYPE: ${aircraft?.flightInfo?.[\"type\"] ?? NOT_AVAILABLE} | ${aircraft?.flightInfo?.[\"manufacturer\"] ?? NOT_AVAILABLE}`\n    this.hud.aircraftType.href = `https://www.google.com/search?q=about ${aircraft?.flightInfo?.[\"type\"]} ${aircraft?.flightInfo?.[\"manufacturer\"]}`\n    this.hud.origin_long.text = `ORG: ${aircraft?.flightInfo?.[\"origin\"] ?? NOT_AVAILABLE}, ${aircraft?.flightInfo?.[\"originName\"] ?? NOT_AVAILABLE}`\n    this.hud.origin_long.href = `https://www.google.com/search?q=aerodrome ${aircraft?.flightInfo?.[\"origin\"]}`\n    this.hud.destination_long.text = `DST: ${aircraft?.flightInfo?.[\"destination\"] ?? NOT_AVAILABLE}, ${aircraft?.flightInfo?.[\"destinationName\"] ?? NOT_AVAILABLE}`\n    this.hud.destination_long.href = `https://www.google.com/search?q=aerodrome ${aircraft?.flightInfo?.[\"destination\"]}`\n\n    this.aircraftInfoShown = true\n  }\n\n  _updateTelemetry() {\n    if (!this.hud || !this.aircraft) return\n\n    const aircraft = this.aircraft\n\n    this.hud.callsign.text = `CALLSIGN: ${aircraft?.callsign ?? NOT_AVAILABLE}`\n    this.hud.callsign.href = `https://www.google.com/search?q=flight status ${aircraft?.callsign ?? NOT_AVAILABLE}`\n\n    const heading = aircraft?.hdg ? aircraft.hdg + \"°\" : NOT_AVAILABLE\n    const groundSpeed = aircraft?.spd ? aircraft.spd + \" kt\" : NOT_AVAILABLE\n    const altitude = aircraft?.alt ? aircraft.alt + \"'\" : NOT_AVAILABLE\n    this.hud.telemetry_heading.innerText = `H: ${heading}`\n    this.hud.telemetry_ground_speed.innerText = `GSPD: ${groundSpeed}`\n    this.hud.telemetry_altitude.innerText = `ALT: ${altitude}`\n  }\n\n  isClientXYInHUDContainer(clientX, clientY) {\n    const leftHUDRect = this.hud.leftButtonContainer.getBoundingClientRect()\n    const rightHUDRect = this.hud.rightButtonContainer.getBoundingClientRect()\n    const inLeftHUD =\n      clientX >= leftHUDRect.left &&\n      clientX <= leftHUDRect.right &&\n      clientY <= leftHUDRect.bottom &&\n      clientY >= leftHUDRect.top\n    const inRightHUD =\n      clientX >= rightHUDRect.left &&\n      clientX <= rightHUDRect.right &&\n      clientY <= rightHUDRect.bottom &&\n      clientY >= rightHUDRect.top\n    return inLeftHUD || inRightHUD\n  }\n\n  isVisible() {\n    return this.aircraft !== null\n  }\n\n  _isFollowCamActive() {\n    return this.hud.cameraButton.classList.contains(\"active\")\n  }\n\n  hide() {\n    this._reset()\n    this.toggleRightActions()\n  }\n\n  show(aircraft) {\n    if (!this.isVisible()) this.toggleRightActions()\n    this._reset()\n    this.aircraft = aircraft\n    this.needsFetchAircraftInfo = true\n    this.needsFetchAircraftPhoto = true\n    console.log(`[HUD] show aircraft: ${aircraft.hex} | ${aircraft?.callsign}`)\n  }\n\n  update() {\n    if (!this.isVisible()) return\n\n    this._updateTelemetry()\n\n    if (this.needsFetchAircraftInfo) {\n      this._fetchAircraftInfo()\n    }\n\n    if (this.needsFetchAircraftPhoto) {\n      this._fetchAircraftPhoto()\n    }\n  }\n\n  enableHUD() {\n    const param = {\n      opacity: 1,\n      display: \"flex\",\n      duration: 0.25,\n    }\n\n    console.log(\"HUD: enableHUD\")\n    //console.table(param)\n\n    gsap.to(\"#hud-left\", param)\n    gsap.to(\"#hud-dialog-container\", param)\n  }\n\n\n  toggleRightActions() {\n    this.isRightDialogShown = !this.isRightDialogShown\n\n    console.log(\"[HUD] toggleRightActions - isRightDialogShown: \", this.isRightDialogShown)\n\n    if (this.isHUDDialogShown) this.toggleAircraftInfoDialogButton()\n    if (this.isFollowCamActive) this.toggleFollowButton()\n\n    const param = this.isRightDialogShown\n      ? {\n        opacity: 1,\n        display: \"flex\",\n        duration: 0.25,\n      }\n      : {\n        opacity: 0,\n        display: \"none\",\n        duration: 0.25,\n      }\n\n    // console.table(param)\n\n    gsap.to(\"#hud-right\", param)\n  }\n\n  toggleAutoOrbitButton() {\n    let autoOrbitButton = this.hud.autoOrbitButton\n    if (autoOrbitButton.classList.contains(\"active\")) {\n      autoOrbitButton.classList.remove(\"active\")\n    } else {\n      autoOrbitButton.classList.add(\"active\")\n    }\n  }\n\n  toggleSettingsButton() {\n    const settingsButton = this.hud.settingsButton\n    if (settingsButton.classList.contains(\"active\")) {\n      settingsButton.classList.remove(\"active\")\n    } else {\n      settingsButton.classList.add(\"active\")\n    }\n  }\n\n  toggleFollowButton() {\n    const followButton = this.hud.cameraButton\n    this.isFollowCamActive = !this.isFollowCamActive\n\n    if (this.isFollowCamActive) {\n      followButton.classList.add(\"active\")\n    } else {\n      followButton.classList.remove(\"active\")\n    }\n  }\n\n  toggleAircraftInfoDialogButton() {\n    this.isHUDDialogShown = !this.isHUDDialogShown\n    let info = this.hud.infoButton\n    if (info.classList.contains(\"active\")) {\n      info.classList.remove(\"active\")\n    } else {\n      info.classList.add(\"active\")\n    }\n\n    const param = this.isHUDDialogShown\n      ? {\n        y: \"0%\",\n        duration: 0.25,\n        autoAlpha: 1,\n        display: \"flex\",\n      }\n      : {\n        y: \"100%\",\n        duration: 0.25,\n        autoAlpha: 0,\n        display: \"none\",\n      }\n\n    gsap.to(\"#hud-dialog\", param)\n  }\n\n  _fetchAircraftPhoto() {\n    const aircraft = this.aircraft\n\n    if (!aircraft?.hex) {\n      return\n    }\n\n    console.log(\"=============================================\")\n    console.log(\"FETCH PHOTO:\", aircraft.hex)\n\n    if (aircraft.photoFuture) {\n      if (aircraft.photo) {\n        HUD._showPhoto()\n      }\n      this.needsFetchAircraftPhoto = false\n      return\n    }\n\n    const photoUrl = `${UTILS.DATA_HOSTS[\"photos\"]}/${aircraft.hex}`\n    console.log(`fetchPhoto -> ${photoUrl}`)\n    aircraft.photoFuture = fetch(photoUrl)\n      .then((response) => response.json())\n      .then((data) => {\n        console.table(data)\n        aircraft.photoData = data\n        if (Array.isArray(data[\"photos\"]) && data[\"photos\"].length > 0) {\n          const photo = data[\"photos\"][0]\n          if (\"thumbnail\" in photo) {\n            aircraft.photo = photo\n            console.table(aircraft.photo)\n            HUD._showPhoto()\n          }\n        }\n        if (!aircraft?.photo) {\n          HUD._clearPhoto()\n        }\n      })\n\n    this.needsFetchAircraftPhoto = false\n  }\n\n  _fetchAircraftInfo() {\n    const aircraft = this.aircraft\n\n    if (!aircraft?.callsign) {\n      return\n    }\n\n    console.log(\"[HUD] Fetch Aircraft Flight Info: \", aircraft.callsign)\n\n    if (aircraft.flightInfoFuture && aircraft.flightInfo) {\n      console.log(\"\\tFlight already fetched:\", aircraft.callsign)\n      HUD._showAircraftInfo()\n      this.needsFetchAircraftInfo = false\n      return\n    }\n\n    const url = `${UTILS.DATA_HOSTS[\"flight_info\"]}/${aircraft.callsign}`\n    aircraft.flightInfoFuture = fetch(url)\n      .then((response) => response.json())\n      .then((data) => {\n        aircraft.flightInfo = data\n        this.hud.flightAwareDiv.style.display = \"block\"\n        HUD._showAircraftInfo()\n      })\n\n    this.needsFetchAircraftInfo = false\n  }\n}\n\n// HUD\nexport const HUD = new _HUD()\n"
  },
  {
    "path": "src/aircraft.js",
    "content": "import * as THREE from 'three'\nimport { Text } from 'troika-three-text'\nimport * as UTILS from './utils.js'\nimport * as ADSB from './ADSB.js'\n\nexport const aircraft = {}\n\nconst airCraftGeometry = new THREE.BufferGeometry()\nairCraftGeometry.setFromPoints([\n  // top\n  new THREE.Vector3(0, 0, -3), // a\n  new THREE.Vector3(-1.5, 1, 1), // b\n  new THREE.Vector3(1.5, 1, 1), // c\n\n  // back\n  new THREE.Vector3(0, -1, 1), // d\n  new THREE.Vector3(1.5, 1, 1), // b\n  new THREE.Vector3(-1.5, 1, 1), // c\n\n  // left\n  new THREE.Vector3(0, -1, 1), // d\n  new THREE.Vector3(-1.5, 1, 1), // c\n  new THREE.Vector3(0, 0, -3), // a\n\n  // right\n  new THREE.Vector3(0, -1, 1), // d\n  new THREE.Vector3(0, 0, -3), // a\n  new THREE.Vector3(1.5, 1, 1), // c\n])\nairCraftGeometry.computeVertexNormals()\n\nexport const airCraftSelectedColor = new THREE.Color(0xff0000)\nexport const airCraftColor = new THREE.Color(0x00ff00)\n\nconst aircraftMaterial = new THREE.MeshLambertMaterial({ color: airCraftColor })\nconst aircraftHeightLineMaterial = new THREE.LineBasicMaterial({ color: 0x0000ff })\nconst aircraftTrailMaterial = new THREE.LineBasicMaterial({ color: 0xffff00 })\n\nconst blackColor = new THREE.Color(0x444444)\nconst whiteColor = new THREE.Color(0xffffff)\n\nconst redNavigationLightMaterial = new THREE.PointsMaterial({ size: 0.5, color: 0xff0000 })\nconst greenNavigationLightMaterial = new THREE.PointsMaterial({ size: 0.5, color: 0x00ff00 })\n\n\nexport class Aircraft {\n  constructor(scene, hexIdent) {\n    this.hex = hexIdent\n    this.squawk = null\n    this.flight = null\n    this.alt = null\n    this.spd = null\n    this.hdg = null\n    this.pos = {\n      x: null,\n      y: null,\n      z: null,\n      lngLat: [null, null]\n    }\n    this.rssi = 0.0\n    this.msgs = 0\n    this.is_on_ground = false\n    this.timestamp = 0\n\n    this.photoFuture = null\n    this.photo == null\n\n    this.flightInfoFuture = null\n    this.flightInfo = null\n\n    // aircraft group\n    this.group = new THREE.Group()\n\n    // aircraft mesh\n    this.mesh = new THREE.Mesh(airCraftGeometry, aircraftMaterial.clone())\n    this.mesh.name = \"aircraft_mesh\"\n    this.mesh.visible = false\n\n    // aircraft height line\n    this.heightLineGeometry = new THREE.BufferGeometry().setFromPoints([\n      new THREE.Vector3(0, 0, 0),\n      new THREE.Vector3(0, 0, 0)\n    ])\n    this.heightLineGeometry.attributes.position.usage = THREE.DynamicDrawUsage\n    this.heightLinePos = this.heightLineGeometry.attributes.position\n    this.heightLineMesh = new THREE.Line(this.heightLineGeometry, aircraftHeightLineMaterial)\n    this.heightLineMesh.name = \"height_line\"\n    this.mesh.add(this.heightLineMesh)\n\n    // aircraft messages text\n    this.text = new Text()\n    this.text.text = \"\"\n    this.text.fontSize = 1\n    this.text.anchorX = -1.5\n    this.text.anchorY = 1\n    this.text.color = 0xED225D\n    this.text.font = \"./static/Orbitron-VariableFont_wght.ttf\"\n    this.text.name = \"aircraft_text\"\n    this.group.add(this.text)\n\n    // follow camera\n    this.followCam = new THREE.Object3D()\n    this.followCam.name = \"follow_cam\"\n    this.followCam.position.set(0, 6, UTILS.FOLLOW_CAM_DISTANCE)\n    this.followCam.userData = {\n      touchStartX: 0,\n      touchStartY: 0,\n      rotationVelocity: 0,\n      sphericalCoords: new THREE.Spherical(UTILS.FOLLOW_CAM_DISTANCE, Math.PI / 2, 0),\n    }\n    this.mesh.add(this.followCam)\n\n    // lights\n    this.redNavigationLight = new THREE.Points(\n      new THREE.BufferGeometry().setFromPoints(\n        [new THREE.Vector3(-1.75, 1, 1.05)]\n      ),\n      redNavigationLightMaterial\n    )\n    this.redNavigationLight.name = \"red_nav_light\"\n    this.mesh.add(this.redNavigationLight)\n\n    this.greenNavigationLight = new THREE.Points(\n      new THREE.BufferGeometry().setFromPoints(\n        [new THREE.Vector3(1.75, 1, 1.05)]\n      ),\n      greenNavigationLightMaterial\n    )\n    this.greenNavigationLight.name = \"green_nav_light\"\n    this.mesh.add(this.greenNavigationLight)\n\n    this.strobeLight = new THREE.Points(\n      new THREE.BufferGeometry().setFromPoints(\n        [new THREE.Vector3(0, -1.25, 1)]\n      ),\n      new THREE.PointsMaterial({ size: 0.5, color: blackColor })\n    )\n    this.strobeLight.name = \"strobe_light\"\n    this.mesh.add(this.strobeLight)\n\n    this.strobeLightTop = new THREE.Points(\n      new THREE.BufferGeometry().setFromPoints(\n        [new THREE.Vector3(0, 1.25, 1)]\n      ),\n      new THREE.PointsMaterial({ size: 0.5, color: blackColor })\n    )\n    this.strobeLightTop.name = \"strobe_light_top\"\n    this.mesh.add(this.strobeLightTop)\n\n    this.group.add(this.mesh)\n\n    scene.add(this.group)\n\n    //\n    // setup aircraft trails\n    //\n    // note: aircraft trails are placed into the scene as a separate objects from the aircraft group\n    //\n\n    // set up trail\n    this.curTrailLength = 0\n    this.lastTrailUpdate = 0\n    this.maxTrailPoints = UTILS.AIRCRAFT_MAX_TRAIL_POINTS\n    this.trailGeometry = new THREE.BufferGeometry()\n    this.trailPositions = new Float32Array(this.maxTrailPoints * 3)\n    this.trailGeometry.setAttribute(\n      'position',\n      new THREE.BufferAttribute(this.trailPositions, 3).setUsage(THREE.DynamicDrawUsage)\n    )\n    this.trailLine = new THREE.Line(this.trailGeometry, aircraftTrailMaterial)\n    this.trailLine.name = \"trail_line\"\n    this.trailLine.userData.hexIdex = hexIdent\n    this.trailLine.frustumCulled = false\n    scene.add(this.trailLine)\n\n    // this line is used to join the aircraft to the trail so there are never any gaps\n    // this can happen when the sample rate is reduced and the aircraft moves a large distance\n    this.trailHeadGeometry = new THREE.BufferGeometry()\n    this.trailHeadPositions = new Float32Array(6)\n    this.trailHeadGeometry.setAttribute(\n      'position',\n      new THREE.BufferAttribute(this.trailHeadPositions, 3).setUsage(THREE.DynamicDrawUsage)\n    )\n    this.trailHeadLine = new THREE.Line(this.trailHeadGeometry, aircraftTrailMaterial)\n    this.trailHeadLine.name = \"trail_head_line\"\n    this.trailHeadLine.userData.hexIdex = hexIdent\n    this.trailHeadLine.frustumCulled = false\n    scene.add(this.trailHeadLine)\n\n    if (UTILS.settings.show_all_trails) {\n      this.showTrail()\n    } else {\n      this.hideTrail()\n    }\n\n    //console.log(`[aircraft] - add: hexIdent: ${hexIdent} | ${this.hex} | ${this.callsign} | ${this.timestamp}`)\n  }\n\n  resetFollowCameraTarget() {\n    this.followCam.userData.touchStartX = 0\n    this.followCam.userData.touchStartY = 0\n    this.followCam.userData.rotationVelocity = 0\n    this.followCam.sphericalCoords = new THREE.Spherical(UTILS.FOLLOW_CAM_DISTANCE, Math.PI / 2, 0)\n  }\n\n  remove(scene) {\n    //console.log(`[aircraft] - remove: ${this.hex} | ${this.callsign} | ${this.timestamp}`)\n    this.group.remove(this.text)\n    this.text.dispose()\n    scene.remove(this.group)\n    scene.remove(this.trailLine)\n    scene.remove(this.trailHeadLine)\n    delete aircraft[this.hex]\n  }\n\n  update(data, elapsedTime) {\n    if (data[ADSB.CALLSIGN] !== \"\") {\n      this.callsign = data[ADSB.CALLSIGN]\n    }\n\n    if (data[ADSB.ALTITUDE] !== \"\") {\n      this.alt = Number(data[ADSB.ALTITUDE])\n      // note: the aircraft y-position is kept in feet for display purposes.      \n      this.pos.y = this.alt\n    }\n\n    if (data[ADSB.LATITUDE] !== \"\") {\n      this.pos.lngLat[1] = Number(data[ADSB.LATITUDE])\n    }\n\n    if (data[ADSB.LONGITUDE] !== \"\") {\n      this.pos.lngLat[0] = Number(data[ADSB.LONGITUDE])\n    }\n\n    if (data[ADSB.SQUAWK] !== \"\") {\n      this.squawk = data[ADSB.SQUAWK]\n    }\n\n    if (data[ADSB.IS_ON_GROUND] !== \"\") {\n      this.isOnGround = data[ADSB.IS_ON_GROUND]\n    }\n\n    if (data[ADSB.TRACK] !== \"\") {\n      this.hdg = data[ADSB.TRACK]\n      this.mesh.rotation.y = THREE.MathUtils.degToRad(-this.hdg)\n    }\n    if (data[ADSB.GROUND_SPEED] !== \"\") {\n      this.spd = data[ADSB.GROUND_SPEED]\n    }\n\n    if (this.hasValidTelemetry()) {\n\n      if (!this.mesh.visible) {\n        this.mesh.visible = true\n        this.mesh.needsUpdate = true\n      }\n\n      [this.pos.x, this.pos.z] = UTILS.getXY(this.pos.lngLat).map(val => val * UTILS.DEFAULT_SCALE)\n\n      // position is in world coordinates\n      const xPos = this.pos.x\n      const yPos = this.pos.y * UTILS.DEFAULT_SCALE\n      const zPos = this.pos.z\n\n      this.heightLinePos.setY(1, -yPos)\n      this.heightLinePos.needsUpdate = true\n\n      const heading = (this?.hdg) ? this.hdg + '°' : '-'\n      const groundSpeed = (this?.hdg) ? this.spd + ' kt' : '-'\n      const altitude = (this?.alt) ? this.alt + \"'\" : '-'\n\n      this.text.text = `${this.callsign || '-'}\\n${this.hex}\\n${heading}\\n${groundSpeed}\\n${altitude}\\n`\n\n      const prevYpos = this.group.position.y\n      this.group.position.set(xPos, yPos, zPos)\n\n      // update trail iff diff between previous points is less than 1000 units\n      // this is completely arbitrary and can be adjusted\n      // i have noticed that there are sometimes ADS-B errors that cause the aircraft to jump\n      // by a large amount in a single frame\n      const diff = this.group.position.y - prevYpos\n      if (diff < UTILS.AIRCRAFT_TRAIL_UPDATE_Y_POS_THRESHOLD) {\n        if (this.lastTrailUpdate % UTILS.AIRCRAFT_TRAIL_UPDATE_FREQUENCY == 0) {\n          this.updateTrail(this.group.position)\n        }\n        this.lastTrailUpdate += 1\n        this.updateTrailHead(this.group.position)\n      } else {\n        //console.log(`[aircraft] - skip trail update! - bad alt - hex: ${this.hex} callsign: ${this.callsign} prevY: ${prevYpos} diff: ${diff}`)\n      }\n    }\n\n    // after each update reset timestamp\n    this.timestamp = elapsedTime\n  }\n\n  hideTrail() {\n    //console.log(\"[aircraft] - hide trail: \", this.hex, this.callsign)\n    this.trailLine.visible = false\n    this.trailHeadLine.visible = false\n    this.trailLine.needsUpdate = true\n    this.trailHeadLine.needsUpdate = true\n  }\n\n  showTrail() {\n    //console.log(\"[aircraft] - show trail: \", this.hex, this.callsign)\n    this.trailLine.visible = true\n    this.trailHeadLine.visible = true\n    this.trailLine.needsUpdate = true\n    this.trailHeadLine.needsUpdate = true\n  }\n\n  updateTrailHead(newPoint) {\n    this.trailHeadPositions[0] = this.trailPositions[0]\n    this.trailHeadPositions[1] = this.trailPositions[1]\n    this.trailHeadPositions[2] = this.trailPositions[2]\n    this.trailHeadPositions[3] = newPoint.x\n    this.trailHeadPositions[4] = newPoint.y\n    this.trailHeadPositions[5] = newPoint.z\n    this.trailHeadGeometry.setDrawRange(0, 2)\n    this.trailHeadGeometry.attributes.position.needsUpdate = true\n  }\n\n  updateTrail(newPoint) {\n    // shift existing points back\n    for (let i = this.trailPositions.length - 3; i >= 3; i -= 3) {\n      this.trailPositions[i] = this.trailPositions[i - 3]\n      this.trailPositions[i + 1] = this.trailPositions[i - 2]\n      this.trailPositions[i + 2] = this.trailPositions[i - 1]\n    }\n\n    // add new point at start\n    this.trailPositions[0] = newPoint.x\n    this.trailPositions[1] = newPoint.y\n    this.trailPositions[2] = newPoint.z\n\n    this.curTrailLength = Math.min(this.curTrailLength + 1, this.maxTrailPoints)\n    this.trailGeometry.setDrawRange(0, this.curTrailLength)\n    this.trailGeometry.attributes.position.needsUpdate = true\n  }\n\n  draw(scene, elapsedTime, cameraPosition) {\n\n    if (!this.mesh.visible) {\n      return\n    }\n\n    this.updateText(cameraPosition)\n\n    // animate strobe light\n    const alpha = Math.sin(elapsedTime * 6.0) * 0.5 + 0.5\n    this.strobeLight.material.color.copy(blackColor).lerp(whiteColor, alpha)\n    this.strobeLight.material.needsUpdate = true\n    this.strobeLightTop.material.color.copy(blackColor).lerp(whiteColor, alpha)\n    this.strobeLightTop.material.needsUpdate = true\n  }\n\n  hasExpired(elapsedTime) {\n    return (elapsedTime - this.timestamp) > UTILS.AIRCRAFT_TTL\n  }\n\n  getAircraftTypeKey() {\n    if (!this?.flightInfo) return\n    const aircraftType = this?.flightInfo?.['type']\n    const aircraftManufacturer = this?.flightInfo?.['manufacturer']\n    if (aircraftType && aircraftManufacturer) {\n      return `${aircraftManufacturer}#${aircraftType}`\n    } else {\n      return undefined\n    }\n  }\n\n  updateText(position) {\n    this.text.lookAt(position)\n  }\n\n  hasValidTelemetry() {\n    return Number.isFinite(this.pos?.y) && Number.isFinite(this.pos.lngLat?.[0]) && Number.isFinite(this.pos.lngLat?.[1])\n  }\n\n  _log() {\n    console.log(\"================\")\n    console.log(`hex: ${this.hex} | sqwk: ${this.squawk} | cs: ${this.callsign} | alt: ${this.alt} | spd: ${this.spd} | hdg: ${this.hdg} | lng: ${this.pos.lngLat[0]} | lat: ${this.pos.lngLat[1]}`)\n    console.log(this.pos)\n    console.log(\"################\")\n  }\n}\n"
  },
  {
    "path": "src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, user-scalable=no\">\n  <link rel=\"manifest\" href=\"manifest.json\">\n  <link rel=\"apple-touch-icon\" href=\"static/icon192.png\">\n  <link rel=\"icon\" type=\"image/png\" href=\"static/icon64.png\">\n  <link href=\"https://fonts.googleapis.com/icon?family=Material+Icons+Outlined\" rel=\"stylesheet\">\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n  <link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap\" rel=\"stylesheet\">\n  <script src=\"https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js\"></script>\n\n  <title>skies-adsb</title>\n</head>\n\n<body>\n  <canvas id=\"webgl\" class=\"webgl\"></canvas>\n\n  <div id=\"hud-left\" class=\"action-container\" style=\"display: none;\">\n    <button id=\"home\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">home</span>\n    </button>\n    <button id=\"360\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">360</span>\n    </button>\n    <button id=\"settings\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">settings</span>\n    </button>\n    <button id=\"full-screen\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">fullscreen</span>\n    </button>\n  </div>\n\n  <div id=\"hud-right\" class=\"action-container\" style=\"display: none;\">\n    <button id=\"camera\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">visibility</span>\n    </button>\n    <button id=\"info\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">info</span>\n    </button>\n    <button id=\"close\" class=\"icon\">\n      <span class=\"material-icons-outlined md-light md-36\">close</span>\n    </button>\n  </div>\n\n  <div id=\"hud-dialog-container\" style=\"display: none;\">\n    <div id=\"hud-dialog\" class=\"media\">\n      <div class=\"image\">\n        <img id=\"photo\">\n      </div>\n      <div class=\"content\">\n        <div>\n          <a id=\"callsign\" target=\"_blank\"></a>\n        </div>\n        <div id=\"section_flightAware\">\n          <div>\n            <a id=\"aircraftType\" target=\"_blank\"></a>\n          </div>\n          <div>\n            <a id=\"airline\" target=\"_blank\"></a>\n          </div>\n          <div>\n            <a id=\"origin_long\" target=\"_blank\"></a>\n          </div>\n          <div>\n            <a id=\"destination_long\" target=\"_blank\"></a>\n          </div>\n        </div>\n        <div id=\"section_planespotters\">\n          <div>\n            <a id=\"aircraftRegistration\" target=\"_blank\"></a>\n          </div>\n          <div>\n            <a id=\"photographer\" target=\"_blank\"></a>\n          </div>\n        </div>\n        <div id=\"section_telemetry\" class=\"telemetry\">\n          <div id=\"telemetry_heading\"></div>|\n          <div id=\"telemetry_ground_speed\"></div>|\n          <div id=\"telemetry_altitude\"></div>\n        </div>\n      </div>\n    </div>\n  </div>\n\n</body>\n\n<script type=\"module\" src=\"main.js\"></script>\n\n</html>"
  },
  {
    "path": "src/main.js",
    "content": "import './style.css'\nimport * as THREE from 'three'\nimport { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'\nimport Stats from 'stats.js'\nimport * as UTILS from './utils.js'\nimport * as MAPS from './maps.js'\nimport { HUD } from './HUD.js'\nimport * as AIRCRAFT from './aircraft.js'\nimport * as ADSB from './ADSB.js'\nimport * as dat from 'dat.gui'\nimport * as SKYBOX from './skybox.js'\n\n\n//\n// globals\n//\n\nlet animationFrameRequestId = -1\n\nconst raycasterPointer = new THREE.Vector2()\nconst raycaster = new THREE.Raycaster()\n\n// scene\nconst scene = new THREE.Scene()\n\n// renderer\nconst canvas = document.querySelector('canvas.webgl')\nconst renderer = new THREE.WebGLRenderer({\n  canvas: canvas\n})\nrenderer.setSize(window.innerWidth, window.innerHeight)\n\n\n//\n// dat.gui\n//\n\n// stats panel\nconst stats = new Stats()\nstats.showPanel(0)\ndocument.body.appendChild(stats.dom)\nstats.dom.style.display = \"none\"\n\nconst gui = new dat.GUI({\n  hidable: true,\n})\ngui.hide()\nlet showDatGui = false\n\nconst SETTINGS_SHOW_STATS = 'show stats'\nconst SETTINGS_SKYBOX = 'skybox'\nconst SETTINGS_SHOW_GRID = 'show polar grid'\nconst SETTINGS_SHOW_ALL_TRAILS = 'show all trails'\nconst SETTINGS_ORIGIN = 'origin'\n\nconst defaultSkybox = import.meta.env.SKIES_ADSB_SETTINGS_DEFAULT_SKYBOX?.toLowerCase() ?? SKYBOX.DAWN_DUSK\n\nconst DAT_GUI_SETTINGS = {\n  [SETTINGS_SHOW_STATS]: false,\n  [SETTINGS_SKYBOX]: defaultSkybox,\n  [SETTINGS_SHOW_GRID]: false,\n  [SETTINGS_SHOW_ALL_TRAILS]: UTILS.settings.show_all_trails,\n}\n\nMAPS.LAYER_NAMES.forEach(layer => {\n  DAT_GUI_SETTINGS[layer] = false\n})\n\nDAT_GUI_SETTINGS[SETTINGS_ORIGIN] = []\n\nlet datGuiOriginController = null\n\nconsole.table(\"[DAT GUI Settings]: \")\nconsole.table(DAT_GUI_SETTINGS)\n\ngui.add(DAT_GUI_SETTINGS, SETTINGS_SHOW_STATS).onChange(showStats => {\n  if (showStats) {\n    stats.dom.style.display = \"\"\n  } else {\n    stats.dom.style.display = \"none\"\n  }\n})\n\ngui.add(DAT_GUI_SETTINGS, SETTINGS_SHOW_GRID).onChange(isVisible => {\n  console.log(`[DAT GUI] - show grid: ${isVisible}`)\n  polarGridHelper.visible = isVisible\n})\n\ngui.add(DAT_GUI_SETTINGS, SETTINGS_SKYBOX, [\n  SKYBOX.DAWN_DUSK,\n  SKYBOX.DAY,\n  SKYBOX.NIGHT\n]).onChange(timeOfDay => {\n  console.log(`[DAT GUI] - skybox: ${timeOfDay}`)\n  skybox.setTexture(timeOfDay)\n})\n\n\nconst autoOrbitSettingsFolder = gui.addFolder('Auto Orbit Settings')\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'min_radius',\n  UTILS.CAMERA_AUTO_ORBIT_SETTINGS_MIN_RADIUS,\n  UTILS.CAMERA_AUTO_ORBIT_SETTINGS_MAX_RADIUS\n).onChange(value => {\n  UTILS.settings.auto_orbit.min_radius = value\n  if (UTILS.settings.auto_orbit.min_radius > UTILS.settings.auto_orbit.max_radius) {\n    UTILS.settings.auto_orbit.max_radius = UTILS.settings.auto_orbit.min_radius\n    gui.updateDisplay()\n  }\n  resetAutoOrbitCamera()\n})\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'max_radius',\n  UTILS.CAMERA_AUTO_ORBIT_SETTINGS_MIN_RADIUS,\n  UTILS.CAMERA_AUTO_ORBIT_SETTINGS_MAX_RADIUS\n).onChange(value => {\n  UTILS.settings.auto_orbit.max_radius = value\n  if (UTILS.settings.auto_orbit.max_radius < UTILS.settings.auto_orbit.min_radius) {\n    UTILS.settings.auto_orbit.min_radius = UTILS.settings.auto_orbit.max_radius\n    gui.updateDisplay()\n  }\n  resetAutoOrbitCamera()\n})\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'radius_speed',\n  UTILS.CAMERA_AUTO_ORBIT_MIN_RADIUS_SPEED,\n  UTILS.CAMERA_AUTO_ORBIT_MAX_RADIUS_SPEED\n).onChange(value => {\n  UTILS.settings.auto_orbit.radius_speed = value\n  resetAutoOrbitCamera()\n})\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'vertical_speed',\n  UTILS.CAMERA_AUTO_ORBIT_MIN_VERTICAL_SPEED,\n  UTILS.CAMERA_AUTO_ORBIT_MAX_VERTICAL_SPEED\n).onChange(value => {\n  UTILS.settings.auto_orbit.vertical_speed = value\n  resetAutoOrbitCamera()\n})\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'horizontal_speed',\n  UTILS.CAMERA_AUTO_ORBIT_MIN_HORIZONTAL_SPEED,\n  UTILS.CAMERA_AUTO_ORBIT_MAX_HORIZONTAL_SPEED\n).onChange(value => {\n  UTILS.settings.auto_orbit.horizontal_speed = value\n  resetAutoOrbitCamera()\n})\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'min_phi',\n  UTILS.CAMERA_AUTO_ORBIT_MIN_PHI,\n  UTILS.CAMERA_AUTO_ORBIT_MAX_PHI\n).onChange(value => {\n  UTILS.settings.auto_orbit.min_phi = value\n  if (UTILS.settings.auto_orbit.min_phi > UTILS.settings.auto_orbit.max_phi) {\n    UTILS.settings.auto_orbit.max_phi = UTILS.settings.auto_orbit.min_phi\n    gui.updateDisplay()\n  }\n  resetAutoOrbitCamera()\n})\nautoOrbitSettingsFolder.add(UTILS.settings.auto_orbit, 'max_phi',\n  UTILS.CAMERA_AUTO_ORBIT_MIN_PHI,\n  UTILS.CAMERA_AUTO_ORBIT_MAX_PHI\n).onChange(value => {\n  UTILS.settings.auto_orbit.max_phi = value\n  if (UTILS.settings.auto_orbit.max_phi < UTILS.settings.auto_orbit.min_phi) {\n    UTILS.settings.auto_orbit.min_phi = UTILS.settings.auto_orbit.max_phi\n    gui.updateDisplay()\n  }\n  resetAutoOrbitCamera()\n})\n\n\ngui.add(DAT_GUI_SETTINGS, SETTINGS_SHOW_ALL_TRAILS)\n  .onChange(async showAllTrails => {\n    console.log(\"[DAT GUI] - showAllTrails toggle: \", showAllTrails)\n    await toggleAircraftTrails(showAllTrails)\n  })\n\nasync function toggleAircraftTrails(showAllTrails) {\n  Object.values(AIRCRAFT.aircraft).forEach(aircraft => {\n    UTILS.settings.show_all_trails = showAllTrails\n    if (showAllTrails) {\n      aircraft.showTrail()\n    } else {\n      aircraft.hideTrail()\n      // keep the selected aircraft trail visible\n      UTILS.INTERSECTED?.aircraft?.showTrail()\n    }\n  })\n}\n\n\n// Clock\nlet clock = new THREE.Clock()\n\n//\n// cameras\n//\n\nconst CAMERA_INITIAL_ASPECT = UTILS.sizes.width / UTILS.sizes.height\n\nconst orbitCamera = new THREE.PerspectiveCamera(\n  UTILS.CAMERA_FOV, CAMERA_INITIAL_ASPECT, UTILS.CAMERA_NEAR, UTILS.CAMERA_FAR)\n\n// TODO need to adjust camera Y positions and target based on origin elevation here... or somewhere...\n\norbitCamera.position.z = UTILS.FOLLOW_CAM_DISTANCE\n\nconst followCamera = orbitCamera.clone()\n\nconst autoOrbitCamera = orbitCamera.clone()\n\nconst autoOrbitCameraObject = new THREE.Object3D()\nscene.add(autoOrbitCameraObject)\n\n\nconst cameras = {\n  [UTILS.CAMERA_MODE_ORBIT]: {\n    cam: orbitCamera,\n    mode: UTILS.CAMERA_MODE_ORBIT,\n  },\n  [UTILS.CAMERA_MODE_FOLLOW]: {\n    cam: followCamera,\n    mode: UTILS.CAMERA_MODE_FOLLOW,\n  },\n  [UTILS.CAMERA_MODE_AUTO_ORBIT]: {\n    cam: autoOrbitCamera,\n    mode: UTILS.CAMERA_MODE_AUTO_ORBIT,\n  }\n}\n\nlet camera = cameras[UTILS.CAMERA_MODE_ORBIT]\n\n// controls\nconst controls = new OrbitControls(orbitCamera, renderer.domElement)\n\n//\n// track if mouse click causes camera changes via OrbitControls\n// used to help toggle display of HUD and prevent the HUD\n// from toggling while user pans the camera around using a mouse\n// see:\n// https://www.html5rocks.com/en/mobile/touchandmouse/\n//\ncontrols.addEventListener('change', (event) => {\n  light.position.copy(camera.cam.position)\n  light.target.position.copy(controls.target)\n})\n\n// axes helper\nconst axesHelper = new THREE.AxesHelper()\nscene.add(axesHelper)\n\n// scene lighting\nconst ambientLight = new THREE.AmbientLight(0x4c4c4c)\nscene.add(ambientLight)\n\nconst light = new THREE.DirectionalLight(0xffffff, 1)\nscene.add(light)\nscene.add(light.target)\n\n// skybox\nconst skybox = new SKYBOX.Skybox(scene, defaultSkybox)\n\n// polar grid\nconst polarGridHelper = new THREE.PolarGridHelper(\n  UTILS.POLAR_GRID_RADIUS,\n  UTILS.POLAR_GRID_RADIALS,\n  UTILS.POLAR_GRID_CIRCLES,\n  UTILS.POLAR_DIVISIONS,\n  UTILS.POLAR_GRID_COLOR_1,\n  UTILS.POLAR_GRID_COLOR_2\n)\npolarGridHelper.visible = false\nscene.add(polarGridHelper)\n\n\n//\n// draw\n//\n\nconst cameraWorldPos = new THREE.Vector3()\n\nfunction draw(elapsedTime, deltaTime) {\n\n  camera.cam.getWorldPosition(cameraWorldPos)\n\n  HUD.update()\n\n  raycaster.setFromCamera(raycasterPointer, camera.cam)\n\n  //\n  // aircraft\n  //\n\n  Object.entries(AIRCRAFT.aircraft).forEach(([key, aircraft]) => {\n\n    const aircraftHasExpired = aircraft.draw(scene, elapsedTime, cameraWorldPos)\n\n    if (raycasterPointer?.x && raycasterPointer?.y) {\n\n      const groupIntersect = raycaster.intersectObject(aircraft.group, true)\n\n      if (groupIntersect.length > 0) {\n\n        // console.log(\"=============================================\")\n        // console.log(\"Found Raycaster Intersection\")\n        // console.log(\"---------------------------------------------\")\n        // console.log(\"\\t\", aircraft)\n        // console.log(\"\\t\", groupIntersect)\n        // console.log(`\\thasValidTelemetry: ${aircraft.hasValidTelemetry()}`)\n\n        raycasterPointer.set(null, null)\n\n        if (aircraft.hasValidTelemetry() && key !== UTILS.INTERSECTED.key) {\n\n          if (UTILS.INTERSECTED?.key) {\n            UTILS.INTERSECTED?.aircraft.resetFollowCameraTarget()\n            if (!UTILS.settings.show_all_trails) {\n              UTILS.INTERSECTED?.aircraft.hideTrail()\n            }\n            UTILS.INTERSECTED.mesh.material.color = AIRCRAFT.airCraftColor\n          }\n\n          UTILS.INTERSECTED.key = key\n          UTILS.INTERSECTED.mesh = aircraft.mesh\n          UTILS.INTERSECTED.mesh.material.color = AIRCRAFT.airCraftSelectedColor\n          UTILS.INTERSECTED.aircraft = aircraft\n\n          aircraft.showTrail()\n\n          console.log(`[main] AIRCRAFT INTERSECTED - key: ${key} | callsign: ${aircraft?.callsign}`)\n\n          HUD.show(aircraft)\n\n          // console.log(UTILS.INTERSECTED)\n        }\n\n        // console.log(\"=============================================\")\n      }\n    }\n\n    if (aircraft.hasExpired(elapsedTime)) {\n      removeAircraft(aircraft)\n    }\n  })\n\n  // Make sure the map origin labels are always facing the user's camera\n  MAPS.LAYER_GROUPS[MAPS.LAYER_ORIGINS]?.children?.forEach((child) => {\n    child.lookAt(camera.cam.position)\n  })\n\n  if (raycasterPointer?.x && raycasterPointer?.y) {\n    raycasterPointer.set(null, null)\n  }\n}\n\nfunction removeAircraft(aircraft) {\n\n  //console.log(\"removeAircraft: \", aircraft.hex)\n\n  if (aircraft.hex === UTILS.INTERSECTED.key) {\n    deselectAirCraftAndHideHUD()\n  }\n  aircraft.remove(scene)\n}\n\nfunction deselectAirCraftAndHideHUD() {\n  if (UTILS.INTERSECTED?.key) {\n    UTILS.INTERSECTED.mesh.material.color = AIRCRAFT.airCraftColor\n    UTILS.INTERSECTED.key = null\n    UTILS.INTERSECTED.mesh = null\n    const aircraft = UTILS.INTERSECTED.aircraft\n\n    if (!UTILS.settings.show_all_trails) {\n      aircraft.hideTrail()\n    }\n\n    UTILS.INTERSECTED.aircraft = null\n    if (camera.mode === UTILS.CAMERA_MODE_FOLLOW) {\n      const target = aircraft.group.getWorldPosition(new THREE.Vector3())\n      aircraft.resetFollowCameraTarget()\n      resetOrbitCamera(target)\n    }\n\n    isFollowCamAttached = false\n    HUD.hide()\n  }\n}\n\n//\n// window resize event listeners\n//\n\nwindow.addEventListener('resize', () => {\n  UTILS.sizes.width = window.innerWidth\n  UTILS.sizes.height = window.innerHeight\n\n  console.log(`[main] window resize - w: ${UTILS.sizes.width} h: ${UTILS.sizes.height}`)\n\n  orbitCamera.aspect = UTILS.sizes.width / UTILS.sizes.height\n  orbitCamera.updateProjectionMatrix()\n\n  followCamera.aspect = UTILS.sizes.width / UTILS.sizes.height\n  followCamera.updateProjectionMatrix()\n\n  autoOrbitCamera.aspect = UTILS.sizes.width / UTILS.sizes.height\n  autoOrbitCamera.updateProjectionMatrix()\n\n  renderer.setSize(UTILS.sizes.width, UTILS.sizes.height)\n  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))\n})\n\n\n\n//\n// Raycaster Pointer and Follow Camera Touch Controls\n//\n\nlet isFollowCamAttached = false\n\nconst raycasterPointerStart = new THREE.Vector2()\nconst raycasterPointerEnd = new THREE.Vector2()\n\nfunction onPointerDown(event) {\n\n  if (event.isPrimary === false) return\n\n  raycasterPointerStart.set(event.clientX, event.clientY)\n\n  if (isFollowCamAttached) {\n    const aircraft = UTILS.INTERSECTED?.aircraft\n    const aircraftFollowCam = aircraft.followCam\n\n    aircraftFollowCam.userData.touchStartX =\n      event.pointerType === \"touch\" ? event.pageX : event.clientX\n    aircraftFollowCam.userData.touchStartY =\n      event.pointerType === \"touch\" ? event.pageY : event.clientY\n  }\n\n  document.addEventListener('pointermove', onPointerMove)\n  document.addEventListener('pointerup', onPointerUp)\n}\n\ndocument.addEventListener('pointerdown', onPointerDown)\n\n//\n// Follow Camera Touch Controls\n//\n\nfunction onPointerMove(event) {\n  if (event.isPrimary === false || !isFollowCamAttached) return\n\n  const aircraft = UTILS.INTERSECTED?.aircraft\n  const aircraftFollowCam = aircraft.followCam\n\n  let touchX = event.pointerType === \"touch\" ? event.pageX : event.clientX\n  let touchY = event.pointerType === \"touch\" ? event.pageY : event.clientY\n\n  // Calculate normalized deltas\n  const deltaX =\n    (touchX - aircraftFollowCam.userData.touchStartX) / window.innerWidth\n  const deltaY =\n    (touchY - aircraftFollowCam.userData.touchStartY) / window.innerHeight\n\n  // Calculate new velocity with smoothing\n  let targetVelocity = deltaX * Math.PI\n\n  // Apply direction change resistance\n  if (\n    Math.sign(targetVelocity) !==\n    Math.sign(aircraftFollowCam.userData.rotationVelocity)\n  ) {\n    targetVelocity *= UTILS.FOLLOW_CAM_DIRECTION_CHANGE_RESISTANCE\n  }\n\n  // Smooth velocity transitions\n  aircraftFollowCam.userData.rotationVelocity =\n    aircraftFollowCam.userData.rotationVelocity * (1 - UTILS.FOLLOW_CAM_VELOCITY_SMOOTHING) +\n    targetVelocity * UTILS.FOLLOW_CAM_VELOCITY_SMOOTHING\n\n  // Apply minimum threshold\n  if (Math.abs(aircraftFollowCam.userData.rotationVelocity) < UTILS.FOLLOW_CAM_VELOCITY_THRESHOLD) {\n    aircraftFollowCam.userData.rotationVelocity = 0\n  }\n\n  // Update spherical coordinates with dampening\n  aircraftFollowCam.userData.sphericalCoords.theta +=\n    aircraftFollowCam.userData.rotationVelocity * UTILS.FOLLOW_CAM_DAMPING_FACTOR\n  aircraftFollowCam.userData.sphericalCoords.phi = THREE.MathUtils.clamp(\n    aircraftFollowCam.userData.sphericalCoords.phi +\n    deltaY * Math.PI * UTILS.FOLLOW_CAM_DAMPING_FACTOR,\n    UTILS.FOLLOW_CAM_MIN_POLAR_ANGLE,\n    UTILS.FOLLOW_CAM_MAX_POLAR_ANGLE\n  )\n\n  // Update position\n  const position = new THREE.Vector3()\n  position.setFromSpherical(aircraftFollowCam.userData.sphericalCoords)\n\n  const targetPos = aircraft.group.getWorldPosition(new THREE.Vector3())\n  aircraftFollowCam.position.copy(position)\n  followCamera.position.copy(aircraftFollowCam.getWorldPosition(new THREE.Vector3()))\n  followCamera.lookAt(targetPos)\n\n  aircraftFollowCam.userData.touchStartX = touchX\n  aircraftFollowCam.userData.touchStartY = touchY\n}\n\nfunction onPointerUp(event) {\n  if (event.isPrimary === false) return\n\n  raycasterPointerEnd.set(event.clientX, event.clientY)\n\n  const isClick = raycasterPointerStart.distanceToSquared(raycasterPointerEnd) === 0\n  const notInHUD = !HUD.isClientXYInHUDContainer(event.clientX, event.clientY)\n\n  if (isClick && notInHUD) {\n    raycasterPointer.set(\n      (raycasterPointerEnd.x / window.innerWidth) * 2 - 1,\n      -(raycasterPointerEnd.y / window.innerHeight) * 2 + 1\n    )\n  }\n\n  document.removeEventListener('pointermove', onPointerMove)\n  document.removeEventListener('pointerup', onPointerUp)\n}\n\n\n//\n// HUD\n//\n\n//\n// homeButton - reset camera to home position and reset orbit controls\n//\nHUD.hud.homeButton.addEventListener('click', (e) => {\n  resetCameraToHome()\n  e.stopPropagation()\n})\n\n//\n// autoOrbitButton - toggle between auto orbit camera and orbit control camera\n//\nHUD.hud.autoOrbitButton.addEventListener('click', (e) => {\n  HUD.toggleAutoOrbitButton()\n\n  if (camera.mode === UTILS.CAMERA_MODE_FOLLOW) {\n    HUD.toggleFollowButton()\n  }\n\n  if (camera.mode === UTILS.CAMERA_MODE_AUTO_ORBIT) {\n    camera = cameras[UTILS.CAMERA_MODE_ORBIT]\n    resetCameraToHome()\n  } else {\n    camera = cameras[UTILS.CAMERA_MODE_AUTO_ORBIT]\n  }\n\n  e.stopPropagation()\n})\n\n\n//\n// settingsButton - show dat.gui settings dialog\n//\nHUD.hud.settingsButton.addEventListener('click', (e) => {\n  HUD.toggleSettingsButton()\n  showDatGui = !showDatGui\n  if (showDatGui) {\n    gui.show()\n  } else {\n    gui.hide()\n  }\n  e.stopPropagation()\n})\n\n//\n// closeButton - deselect aircraft and hide right side HUD\n//\nHUD.hud.closeButton.addEventListener('click', (e) => {\n  if (!HUD.isVisible()) return\n  deselectAirCraftAndHideHUD()\n  e.stopPropagation()\n})\n\n//\n// infoButton - toggle selected aircraft info dialog\n//\nHUD.hud.infoButton.addEventListener('click', (e) => {\n  if (!HUD.isVisible()) return\n  HUD.toggleAircraftInfoDialogButton()\n  e.stopPropagation()\n})\n\n//\n// cameraButton - toggle between orbit control camera and follow camera\n//\nHUD.hud.cameraButton.addEventListener('click', (e) => {\n\n  if (!HUD.isVisible()) return\n  if (camera.mode !== UTILS.CAMERA_MODE_FOLLOW) {\n\n    if (camera.mode === UTILS.CAMERA_MODE_AUTO_ORBIT) {\n      HUD.toggleAutoOrbitButton()\n    }\n\n    // console.log(\"INTERSECTED AIRCRAFT: \")\n    // console.log(UTILS.INTERSECTED?.aircraft)    \n    followCamera.position.copy(camera.cam.position)\n    followCamera.lookAt(camera.cam.lookAt)\n    camera = cameras[UTILS.CAMERA_MODE_FOLLOW]\n    controls.enabled = false\n  } else {\n    deselectAirCraftAndHideHUD()\n  }\n  console.log(`[HUD] toggle camera - mode: ${camera.mode}`)\n  HUD.toggleFollowButton()\n  e.stopPropagation()\n})\n\n//\n// fullscreen toggle on double click event listener\n// see:\n// https://developers.google.com/web/fundamentals/native-hardware/fullscreen\n//\nHUD.hud.fullscreenButton.addEventListener('click', (e) => {\n  const doc = window.document\n  const docEl = doc.documentElement\n\n  const requestFullScreen = docEl.requestFullscreen || docEl.mozRequestFullScreen || docEl.webkitRequestFullScreen || docEl.msRequestFullscreen\n  const cancelFullScreen = doc.exitFullscreen || doc.mozCancelFullScreen || doc.webkitExitFullscreen || doc.msExitFullscreen\n\n  if (!doc.fullscreenElement && !doc.mozFullScreenElement && !doc.webkitFullscreenElement && !doc.msFullscreenElement) {\n    requestFullScreen.call(docEl)\n  }\n  else {\n    cancelFullScreen.call(doc)\n  }\n  e.stopPropagation()\n})\n\nfunction resetCameraToHome() {\n\n  switch (camera.mode) {\n    case UTILS.CAMERA_MODE_FOLLOW:\n      HUD.toggleFollowButton()\n      break\n    case UTILS.CAMERA_MODE_AUTO_ORBIT:\n      HUD.toggleAutoOrbitButton()\n      break\n  }\n\n  camera = cameras[UTILS.CAMERA_MODE_ORBIT]\n\n  controls.reset()\n\n  const key = datGuiOriginController?.getValue()\n  const elevation = MAPS.ORIGINS[key]?.elevation ?? 0.0\n\n  camera.cam.position.set(0,\n    elevation + UTILS.CAMERA_ORBIT_START_ELEVATION_ADJUST,\n    UTILS.CAMERA_ORBIT_START_DISTANCE\n  )\n  controls.target.set(0, elevation, 0)\n  controls.update()\n  controls.enabled = true\n}\n\nfunction resetOrbitCamera(target) {\n  console.log(\"[main] - resetOrbitCamera\")\n  orbitCamera.position.copy(camera.cam.position)\n  controls.target.set(target.x, target.y, target.z)\n  controls.update()\n  controls.enabled = true\n  camera = cameras[UTILS.CAMERA_MODE_ORBIT]\n}\n\nfunction updateCamera(elapsedTime, deltaTime) {\n\n  if (camera.mode === UTILS.CAMERA_MODE_FOLLOW && UTILS.INTERSECTED?.aircraft) {\n    const aircraft = UTILS.INTERSECTED?.aircraft\n    const followCamPos = aircraft.followCam.getWorldPosition(new THREE.Vector3())\n    const followCamTargetPos = aircraft.group.getWorldPosition(new THREE.Vector3())\n    camera.cam.position.lerp(followCamPos, 0.05)\n    camera.cam.lookAt(followCamTargetPos)\n\n    if (camera.cam.position.distanceToSquared(followCamPos) < 1.0) {\n      isFollowCamAttached = true\n    }\n    light.position.copy(camera.cam.position)\n    light.target.position.copy(followCamTargetPos)\n\n    //\n    // Apply momentum damping\n    //\n    if (isFollowCamAttached) {\n      const aircraftFollowCam = aircraft.followCam\n      aircraftFollowCam.userData.rotationVelocity *= UTILS.FOLLOW_CAM_DAMPING_FACTOR\n      aircraftFollowCam.userData.sphericalCoords.theta +=\n        aircraftFollowCam.userData.rotationVelocity\n    }\n\n  } else {\n    controls.update()\n  }\n\n  updateAutoOrbitCamera(elapsedTime, deltaTime)\n}\n\n\nfunction updateAutoOrbitCamera(elapsedTime, deltaTime) {\n\n  const MIN_RADIUS = UTILS.settings.auto_orbit.min_radius\n  const MAX_RADIUS = UTILS.settings.auto_orbit.max_radius\n  const RADIUS_SPEED = UTILS.settings.auto_orbit.radius_speed\n  const VERTICAL_SPEED = UTILS.settings.auto_orbit.vertical_speed\n  const HORIZONTAL_SPEED = UTILS.settings.auto_orbit.horizontal_speed\n\n  const MIN_ALTITUDE = THREE.MathUtils.degToRad(UTILS.settings.auto_orbit.min_phi)\n  const MAX_ALTITUDE = THREE.MathUtils.degToRad(UTILS.settings.auto_orbit.max_phi)\n\n  const radius = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * (0.5 + 0.5 * Math.sin(elapsedTime * RADIUS_SPEED))\n  const verticalAngle = MIN_ALTITUDE + (MAX_ALTITUDE - MIN_ALTITUDE) * (0.5 + 0.5 * Math.sin(elapsedTime * VERTICAL_SPEED))\n\n  const horizontalAngle = Math.sin(elapsedTime * HORIZONTAL_SPEED) * Math.PI * 2\n\n  autoOrbitCameraObject.position.setFromSphericalCoords(\n    radius,\n    verticalAngle,\n    horizontalAngle\n  )\n\n  const key = datGuiOriginController?.getValue()\n  const elevation = MAPS.ORIGINS[key]?.elevation ?? 0.0\n\n  const worldPosition = new THREE.Vector3()\n  autoOrbitCameraObject.getWorldPosition(worldPosition)\n  autoOrbitCamera.position.copy(worldPosition)\n  autoOrbitCamera.position.y += elevation\n  autoOrbitCamera.lookAt(0, elevation, 0)\n}\n\nfunction resetAutoOrbitCamera() {\n  updateAutoOrbitCamera(0, 0)\n}\n\n\n//\n// handle page visibility\n// https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API\n//\nfunction handleVisibilityChange() {\n  if (document.visibilityState === \"hidden\") {\n    console.log(\"[main] handleVisibilityChange: PAUSE SIMULATION\")\n    ADSB.stop()\n    window.cancelAnimationFrame(animationFrameRequestId)\n  } else {\n    console.log(\"[main] handleVisibilityChange: RESUME SIMULATION\")\n    ADSB.start(scene, clock)\n    animationFrameRequestId = window.requestAnimationFrame(animate)\n  }\n}\ndocument.addEventListener('visibilitychange', handleVisibilityChange, false)\n\n//\n// animate\n//\n\nconst animate = function () {\n  stats.begin()\n\n  const elapsedTime = clock.getElapsedTime()\n  const deltaTime = clock.getDelta()\n\n  animationFrameRequestId = requestAnimationFrame(animate)\n\n  updateCamera(elapsedTime, deltaTime)\n\n  draw(elapsedTime, deltaTime)\n\n  renderer.render(scene, camera.cam)\n\n  stats.end()\n}\n\n//\n// stop ADSB, rebuild maps, update origin, update camera, update UI, start ADSB\n//\nasync function updateOriginAndRebuildMapLayers(key) {\n\n  ADSB.stop()\n\n  console.log(\"[main] - updateOriginAndReloadMapLayers: \")\n  console.table(MAPS.ORIGINS[key])\n\n  // clear map layers\n  Object.entries(MAPS.LAYER_GROUPS).forEach(([key, layer]) => {\n    console.log(`\\tremoving layer: ${key}`)\n    scene.remove(layer)\n  })\n\n  // clear aircraft\n  Object.values(AIRCRAFT.aircraft).forEach(aircraft => {\n    removeAircraft(aircraft)\n  })\n\n  // rebuild map layers with new origin\n  const lonLat = [\n    MAPS.ORIGINS[key].lon,\n    MAPS.ORIGINS[key].lat\n  ]\n\n  await UTILS.setOrigin(lonLat)\n\n  await MAPS.buildMapLayers(scene)\n\n  resetCameraToHome()\n\n  ADSB.start(scene, clock)\n\n  HUD.enableHUD()\n\n  // select default camera mode on map load\n  let cameraMode = import.meta.env.SKIES_ADSB_DEFAULT_CAMERA_MODE?.toLowerCase()\n  const defaultCameraModes = [UTILS.CAMERA_MODE_ORBIT, UTILS.CAMERA_MODE_AUTO_ORBIT]\n  if (!defaultCameraModes.includes(cameraMode)) {\n    console.warn(`[main] - invalid default camera mode: ${cameraMode} | using orbit camera`)\n    cameraMode = UTILS.CAMERA_MODE_ORBIT\n  }\n  camera = cameras[cameraMode]\n  if (cameraMode === UTILS.CAMERA_MODE_AUTO_ORBIT) {\n    HUD.toggleAutoOrbitButton()\n  }\n}\n\n//\n// Initialize Simulation - start rendering, \n//\n\nlet curOriginKey = null\n\nasync function initSimulation() {\n  console.log(\"[main] - initSimulation\")\n\n  animate()\n\n  // Initialize Map data\n  const result = await MAPS.init()\n  if (!result) {\n    console.error(\"\\tERROR: Failed to initialize map data!\")\n    return\n  }\n\n  // build controller for changing origins\n  datGuiOriginController = gui.add(\n    DAT_GUI_SETTINGS,\n    SETTINGS_ORIGIN,\n    Object.keys(MAPS.ORIGINS)\n  ).onChange(key => {\n    updateOriginAndRebuildMapLayers(key)\n  })\n\n  // build map layers toggle\n  console.log(\"[main] - buildMapLayersToggle gui\")\n\n  const layersFolder = gui.addFolder('Map Layers')\n\n  Object.keys(MAPS.LAYER_GROUPS).forEach(key => {\n    DAT_GUI_SETTINGS[key] = MAPS.isLayerVisible(key)\n    layersFolder.add(DAT_GUI_SETTINGS, key).onChange(isVisible => {\n      const layer = MAPS.LAYER_GROUPS[key]\n      console.log(`[DAT GUI] toggle layer - visibility: ${key} | isVisible: ${isVisible}`)\n      layer.visible = isVisible\n      layer.needsUpdate = true\n    })\n  })\n\n  // select default origin as the simulation starting view point\n  datGuiOriginController.setValue(MAPS.DEFAULT_ORIGIN)\n}\n\ninitSimulation()\n"
  },
  {
    "path": "src/manifest.json",
    "content": "{\n  \"name\": \"skies-adsb\",\n  \"short_name\": \"skies-adsb\",\n  \"description\": \"skies-adsb is a real-time 3D browser based web app for tracking aircraft using ADS-B data obtained from a RTL-SDR receiver.\",\n  \"dir\": \"auto\",\n  \"lang\": \"en-us\",\n  \"display\": \"standalone\",\n  \"orientation\": \"any\",\n  \"id\": \"/skies-adsb\",\n  \"start_url\": \"/skies-adsb\",\n  \"background_color\": \"#000\",\n  \"theme_color\": \"#000\",\n\n  \"icons\": [\n    {\n      \"src\": \"static/icon512.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\"\n    },\n    {\n      \"src\": \"static/icon192.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\"\n    }\n  ]\n}\n"
  },
  {
    "path": "src/maps.js",
    "content": "import * as THREE from 'three'\nimport { Text } from 'troika-three-text'\nimport * as UTILS from './utils.js'\n\nconst METERS_TO_FEET = 3.28084\n\nconst AERODROME_LABEL_HEIGHT = 4.0\nconst AERODROME_LABEL_FONT_SIZE = 2\n\nconst TEXT_COLOR = new THREE.Color(0xed225d)\nconst TEXT_FONT = \"./static/Orbitron-VariableFont_wght.ttf\"\n\nexport const LAYER_AERODROMES = \"aerodrome\"\nexport const LAYER_ORIGINS = \"origins\"\nexport const LAYER_RUNWAYS = \"runway\"\nexport const LAYER_AIRSPACE_CLASS_B = \"airspace_class_b\"\nexport const LAYER_AIRSPACE_CLASS_C = \"airspace_class_c\"\nexport const LAYER_AIRSPACE_CLASS_D = \"airspace_class_d\"\nexport const LAYER_URBAN_AREAS = \"urban_areas\"\nexport const LAYER_ROADS = \"roads\"\nexport const LAYER_LAKES = \"lakes\"\nexport const LAYER_RIVERS = \"rivers\"\nexport const LAYER_STATES_PROVINCES = \"states_provinces\"\nexport const LAYER_COUNTIES = \"counties\"\n\nexport const LAYER_NAMES = [\n  LAYER_AERODROMES,\n  LAYER_ORIGINS,\n  LAYER_RUNWAYS,\n  LAYER_AIRSPACE_CLASS_B,\n  LAYER_AIRSPACE_CLASS_C,\n  LAYER_AIRSPACE_CLASS_D,\n  LAYER_URBAN_AREAS,\n  LAYER_ROADS,\n  LAYER_LAKES,\n  LAYER_RIVERS,\n  LAYER_STATES_PROVINCES,\n  LAYER_COUNTIES,\n]\n\nconst MAP_DATA_DIR = \"map-data\"\n\nconst LAYERS_GEOJSON = {}\n\n// setup geojson data layers\nLAYER_NAMES.forEach((layerName) => {\n  // skip aerodrome label layer as it is not geojson data layer\n  if (layerName === LAYER_ORIGINS) {\n    return\n  }\n  LAYERS_GEOJSON[layerName] = `${MAP_DATA_DIR}/${layerName}.geojson`\n})\n\nexport const LAYER_GROUPS = {}\n\nexport const ORIGINS = {}\nexport const DEFAULT_ORIGIN = \"Default Origin\"\n\n\nexport function isLayerVisible(layerName) {\n  switch (layerName) {\n    case LAYER_AERODROMES:\n      return UTILS.settings.show_aerodromes\n    case LAYER_ORIGINS:\n      return UTILS.settings.show_aerodromes\n    case LAYER_RUNWAYS:\n      return UTILS.settings.show_aerodromes\n    case LAYER_AIRSPACE_CLASS_B:\n      return UTILS.settings.show_airspace_class_b\n    case LAYER_AIRSPACE_CLASS_C:\n      return UTILS.settings.show_airspace_class_c\n    case LAYER_AIRSPACE_CLASS_D:\n      return UTILS.settings.show_airspace_class_d\n    case LAYER_URBAN_AREAS:\n      return UTILS.settings.show_urban_areas\n    case LAYER_ROADS:\n      return UTILS.settings.show_roads\n    case LAYER_LAKES:\n      return UTILS.settings.show_lakes\n    case LAYER_RIVERS:\n      return UTILS.settings.show_rivers\n    case LAYER_STATES_PROVINCES:\n      return UTILS.settings.show_states_provinces\n    case LAYER_COUNTIES:\n      return UTILS.settings.show_counties\n    default:\n      return false\n  }\n}\n\n\nlet ORIGINS_DATA = null\n\nexport async function init() {\n  console.log(\"MAPS: init...\")\n\n  ORIGINS_DATA = await getOriginsData()\n  const hasValidOrigins = await buildOrigins(ORIGINS_DATA)\n\n  LAYER_NAMES.forEach((layerName) => {\n    LAYER_GROUPS[layerName] = null\n  })\n\n  console.log(\"MAPS: init done...\")\n  return hasValidOrigins\n}\n\nexport async function buildMapLayers(scene) {\n  try {\n    console.log(\"MAPS: building map layers...\")\n\n    // build map labels layer separately as we need to get the origin data from another json file\n    console.log(\"\\tbuilding layer: origin labels\")\n    const originLabelsLayerGroup = await buildOriginLabelsLayer(scene, ORIGINS_DATA)\n    scene.add(originLabelsLayerGroup)\n    LAYER_GROUPS[LAYER_ORIGINS] = originLabelsLayerGroup\n    originLabelsLayerGroup.visible = isLayerVisible(LAYER_ORIGINS)\n    originLabelsLayerGroup.needsUpdate = true\n\n    // build map layers\n    Object.entries(LAYERS_GEOJSON).forEach(async ([layerName, fileName]) => {\n      const group = await buildMapLayer(scene, layerName, fileName)\n      scene.add(group)\n      LAYER_GROUPS[layerName] = group\n      group.visible = isLayerVisible(layerName)\n      group.needsUpdate = true\n    })\n\n    console.log(\"MAPS: map layers built...\")\n  } catch (e) {\n    console.error(e)\n  }\n}\n\nconst mapDefaultLineMaterial = new THREE.LineBasicMaterial({\n  color: 0x81efff\n})\n\nconst airspaceBLineMaterial = new THREE.LineBasicMaterial({\n  color: 0x0000ff,\n})\nconst airspaceCLineMaterial = new THREE.LineBasicMaterial({\n  color: 0xff00ff,\n})\nconst airspaceDLineMaterial = new THREE.LineBasicMaterial({\n  color: 0x0000cc,\n})\nconst roadsLineMaterial = new THREE.LineBasicMaterial({\n  color: 0xf39900,\n})\nconst urbanAreasLineMaterial = new THREE.LineBasicMaterial({\n  color: 0x708090,\n})\n\nconst runwayLineMaterial = new THREE.LineBasicMaterial({\n  color: 0xfffffff\n})\n\n\nasync function buildMapLayer(scene, layerName, fileName) {\n\n  console.log(`\\tbuilding layer: ${fileName}`)\n\n  const parentGroup = new THREE.Group()\n  parentGroup.userData.name = layerName\n\n  const geoJson = await fetchData(fileName)\n\n  if (geoJson?.features === undefined || geoJson.features.length === 0) {\n    console.warn(\"\\t\\tNo features found in geojson file: \", fileName)\n    return parentGroup\n  }\n\n  let material\n  switch (geoJson.name) {\n    case LAYER_AIRSPACE_CLASS_B:\n      material = airspaceBLineMaterial\n      break\n    case LAYER_AIRSPACE_CLASS_C:\n      material = airspaceCLineMaterial\n      break\n    case LAYER_AIRSPACE_CLASS_D:\n      material = airspaceDLineMaterial\n      break\n    case LAYER_URBAN_AREAS:\n      material = urbanAreasLineMaterial\n      break\n    case LAYER_ROADS:\n      material = roadsLineMaterial\n      break\n    case LAYER_RUNWAYS:\n      material = runwayLineMaterial\n      break\n    default:\n      material = mapDefaultLineMaterial\n      break\n  }\n\n  geoJson.features.forEach((feature) => {\n    const childGroup = parseGeoJsonFeature(feature, material)\n\n    childGroup.userData.type = geoJson.name\n\n    switch (geoJson.name) {\n      case LAYER_AERODROMES:\n      case LAYER_RUNWAYS:\n        childGroup.userData.id = feature.properties.icao ?? feature.properties.iata ?? feature.properties.faa ?? feature.properties.ref\n        break\n\n      case LAYER_AIRSPACE_CLASS_B:\n      case LAYER_AIRSPACE_CLASS_C:\n      case LAYER_AIRSPACE_CLASS_D:\n        childGroup.userData.id = feature.properties.ICAO_ID ?? feature.properties.IDENT\n        childGroup.userData.upper = feature.properties.UPPER_VAL\n        childGroup.userData.lower = feature.properties.LOWER_VAL\n        break\n    }\n\n    switch (geoJson.name) {\n\n      case LAYER_AERODROMES:\n      case LAYER_RUNWAYS:\n        const elevation = (feature.properties.ele ?? feature.properties.ele_right ?? 0.0) * METERS_TO_FEET * UTILS.DEFAULT_SCALE\n        childGroup.position.set(0, elevation, 0)\n        childGroup.userData.elevation = elevation\n        break\n\n      case LAYER_URBAN_AREAS:\n        childGroup.position.set(0, -0.15, 0)\n        break\n      case LAYER_AIRSPACE_CLASS_B:\n        childGroup.position.set(0, 0, 0)\n        break\n      case LAYER_AIRSPACE_CLASS_C:\n        childGroup.position.set(0, -1, 0)\n        break\n      case LAYER_AIRSPACE_CLASS_D:\n        childGroup.position.set(0, -2, 0)\n        break\n    }\n\n    switch (geoJson.name) {\n      case LAYER_AERODROMES:\n        const childElevation = childGroup.userData.elevation ?? 0.0\n        // skip if elevation is 0\n        if (childElevation > 0.0) {\n          const steps = Math.floor(childElevation / 1.5)\n          for (let i = 0; i < steps; i++) {\n            const interpolatedElevation = (childElevation / steps) * i\n            const clone = childGroup.clone()\n            const cloneMaterial = clone.children[0].material.clone()\n            cloneMaterial.color.multiplyScalar((i / steps / 1.5))\n            clone.children[0].material = cloneMaterial\n            clone.position.y = interpolatedElevation\n            parentGroup.add(clone)\n          }\n        }\n        break\n    }\n\n    parentGroup.add(childGroup)\n  })\n\n  return parentGroup\n}\n\nfunction parseGeoJsonFeature(feature, lineMaterial) {\n  const group = new THREE.Group()\n\n  if (feature.geometry.type === \"MultiLineString\") {\n    feature.geometry.coordinates.forEach((coordinates) => {\n      const points = coordinates.map((coord) => {\n        let [x, y] = UTILS.getXY(coord).map(val => val * UTILS.DEFAULT_SCALE)\n        return new THREE.Vector3(x, 0, y)\n      })\n\n      const bufferGeometry = new THREE.BufferGeometry().setFromPoints(points)\n      const line = new THREE.Line(bufferGeometry, lineMaterial)\n\n      group.add(line)\n    })\n  }\n  if (feature.geometry.type === \"MultiPolygon\") {\n    feature.geometry.coordinates.forEach((coordinates) => {\n      const points = coordinates[0].map((coord) => {\n        let [x, y] = UTILS.getXY(coord).map(val => val * UTILS.DEFAULT_SCALE)\n        return new THREE.Vector3(x, 0, y)\n      })\n\n      const bufferGeometry = new THREE.BufferGeometry().setFromPoints(points)\n      const line = new THREE.Line(bufferGeometry, lineMaterial)\n\n      group.add(line)\n    })\n  }\n\n  if (feature.geometry.type === \"LineString\") {\n    const coordinates = feature.geometry.coordinates\n    const points = coordinates.map((coord) => {\n      let [x, y] = UTILS.getXY(coord).map(val => val * UTILS.DEFAULT_SCALE)\n      return new THREE.Vector3(x, 0, y)\n    })\n\n    const bufferGeometry = new THREE.BufferGeometry().setFromPoints(points)\n    const line = new THREE.Line(bufferGeometry, lineMaterial)\n\n    group.add(line)\n  }\n  if (feature.geometry.type === \"Polygon\") {\n    const coordinates = feature.geometry.coordinates[0]\n    const points = coordinates.map((coord) => {\n      let [x, y] = UTILS.getXY(coord).map(val => val * UTILS.DEFAULT_SCALE)\n      return new THREE.Vector3(x, 0, y)\n    })\n\n    const bufferGeometry = new THREE.BufferGeometry().setFromPoints(points)\n    const line = new THREE.Line(bufferGeometry, lineMaterial)\n\n    group.add(line)\n  }\n\n  if (feature.geometry.type === \"GeometryCollection\") {\n    feature.geometry.geometries.forEach((childGeometry) => {\n      let coordinates = null\n      switch (childGeometry.type) {\n        case 'Polygon':\n          coordinates = childGeometry.coordinates[0]\n          break\n        case 'LineString':\n          coordinates = childGeometry.coordinates\n          break\n        default:\n          console.warn(\"Unknown GeometryCollection Geometry type: \", childGeometry.type)\n          break\n      }\n\n      if (coordinates) {\n        const points = coordinates.map((coord) => {\n          let [x, y] = UTILS.getXY(coord).map(val => val * UTILS.DEFAULT_SCALE)\n          return new THREE.Vector3(x, 0, y)\n        })\n\n        const bufferGeometry = new THREE.BufferGeometry().setFromPoints(points)\n        const line = new THREE.Line(bufferGeometry, lineMaterial)\n\n        group.add(line)\n      }\n    })\n  }\n\n  return group\n}\n\n\nasync function getOriginsData() {\n  console.log(\"MAPS: get origins data...\")\n  const ORIGINS_JSON = `${MAP_DATA_DIR}/origins.json`\n\n  try {\n    const json = await fetchData(ORIGINS_JSON)\n    const data = []\n    json.elements?.forEach((element) => {\n      const id = element.tags?.icao\n        ?? element.tags?.iata\n        ?? element.tags?.faa\n        ?? element.tags['faa:lid']\n        ?? element.tags?.ref\n\n\n      if (!id) {\n        console.warn(\"\\tNo ref attribute or ICAO/IATA/FAA compatible id found for use as origin id:\\n\\t\", element)\n        return\n      }\n\n      const center = element.center\n\n      if (!element.tags?.ele) {\n        console.warn(`\\tNo ele attribute found for origin id: ${id}. Defaulting to 0 meters elevation.\\n\\t`)\n      }\n\n      // OSM elevation is in meters so convert elevation from meters to feet\n      let elevation = parseFloat(element.tags?.ele ?? 0.0)\n      if (isNaN(elevation)) {\n        console.warn(`\\tInvalid ele attribute found for origin id: ${id}. Defaulting to 0 meters elevation.\\n\\t`)\n        elevation = 0.0\n      }\n      elevation *= METERS_TO_FEET\n\n      data.push({\n        id: id,\n        center: center,\n        elevation: elevation\n      })\n\n    })\n    return data\n  } catch (e) {\n    console.error(`ERROR: Unable to fetch origins data: ${e}`)\n    return []\n  }\n}\n\nfunction buildOriginObject(name, lat, lon, elevation) {\n  return {\n    name: name,\n    lat: lat,\n    lon: lon,\n    elevation: elevation * UTILS.DEFAULT_SCALE\n  }\n}\n\nasync function buildOrigins(originsData) {\n\n  console.log(\"MAPS: build origins...\")\n\n  // set default origin\n  console.log(\"\\tBuilding default origin...\")\n  const defaultLat = parseFloat(import.meta.env.SKIES_ADSB_DEFAULT_ORIGIN_LATITUDE)\n  const defaultLon = parseFloat(import.meta.env.SKIES_ADSB_DEFAULT_ORIGIN_LONGITUDE)\n  let defaultElevation = parseFloat(import.meta.env.SKIES_ADSB_DEFAULT_ORIGIN_ELEVATION_METERS_OPTIONAL)\n\n  if (isNaN(defaultLat) || isNaN(parseFloat(defaultLon))) {\n    console.error(\"ERROR: Invalid Default Origin Latitude and/or Longitude in .env file.\")\n    return false\n  }\n\n  if (isNaN(defaultElevation)) {\n    console.warn(\"WARNING: Invalid Default Origin Elevation in .env file. Defaulting to 0 meters.\")\n    defaultElevation = 0.0\n  }\n\n  defaultElevation *= METERS_TO_FEET\n\n  ORIGINS[DEFAULT_ORIGIN] = buildOriginObject(DEFAULT_ORIGIN, defaultLat, defaultLon, defaultElevation)\n\n  // build other origins  \n  console.log(\"\\tBuilding additional origins...\")\n\n  if (originsData.length === 0) {\n    console.warn(\"\\tWARNING: No origins found. Unable to build additional origins.\")\n  }\n\n  originsData.forEach((origin) => {\n    ORIGINS[origin.id] = buildOriginObject(\n      origin.id,\n      origin.center.lat,\n      origin.center.lon,\n      origin.elevation\n    )\n  })\n\n  return true\n}\n\nasync function buildOriginLabelsLayer(scene, originsData) {\n  const parentGroup = new THREE.Group()\n  parentGroup.userData.type = LAYER_ORIGINS\n  originsData.forEach((origin) => {\n    const group = new THREE.Group()\n    group.userData.id = origin.id\n    group.userData.center = origin.center\n    group.userData.type = LAYER_ORIGINS\n    const [x, y] = UTILS.getXY([origin.center.lon, origin.center.lat])\n    const z = origin.elevation * UTILS.DEFAULT_SCALE\n    group.position.set(x * UTILS.DEFAULT_SCALE, z, y * UTILS.DEFAULT_SCALE)\n\n    const label = new Text()\n    label.text = origin.id\n    label.fontSize = AERODROME_LABEL_FONT_SIZE\n    label.anchorX = 'center'\n    label.color = new THREE.Color(TEXT_COLOR)\n    label.font = TEXT_FONT\n    label.position.x = 0.0\n    label.position.y = AERODROME_LABEL_HEIGHT\n    label.position.z = 0.0\n\n    group.add(label)\n    parentGroup.add(group)\n  })\n\n  return parentGroup\n}\n\nasync function fetchData(src) {\n  try {\n    const response = await fetch(src)\n    return await response.json()\n  } catch (e) {\n    console.error(`Error while fetching data source\\n\\n\\t${src}\\n\\n\\t${e}`)\n    return {}\n  }\n}\n\n"
  },
  {
    "path": "src/skybox.js",
    "content": "import * as THREE from 'three'\nimport * as UTILS from './utils.js'\n\n//\n// skybox\n// source:\n// https://threejs.org/manual/?q=canvas#en/canvas-textures\n// https://www.w3schools.com/graphics/canvas_gradients.asp\n// https://discourse.threejs.org/t/how-to-define-a-scene-background-with-gradients/3647/6\n//\n\nexport const DAWN_DUSK = 'dawn_dusk'\nexport const DAY = 'day'\nexport const NIGHT = 'night'\n\n\nconst gradients = {\n  [DAWN_DUSK]:\n    [\n      0, '#2d5277',\n      0.45, '#4f809f',\n      0.46, '#4f809f',\n      0.46, '#82a7b3',\n      0.47, '#82a7b3',\n      0.47, '#b3b4a8',\n      0.48, '#b3b4a8',\n      0.48, '#e6aa6c',\n      0.49, '#e6aa6c',\n      0.49, '#e0682c',\n      0.5, '#e0682c',\n      0.5, '#5f3627',\n      0.5, '#181413',\n      1, '#181413'\n    ],\n\n  [DAY]:\n    [\n      0, '#1f71a4',\n      0.1, '#1f71a4',\n      0.2, '#438dbc',\n      0.48, '#438dbc',\n      0.485, '#69a5ce',\n      0.495, '#69a5ce',\n      0.496, '#8abadb',\n      0.5, '#8abadb',\n      0.5, '#000',\n      1, '#000',\n    ],\n\n  [NIGHT]:\n    [\n      0, '#000',\n      0.3, '#2b2233',\n      0.41, '#2b2233',\n      0.41, '#28293b',\n      0.43, '#28293b',\n      0.43, '#2f3749',\n      0.45, '#2f3749',\n      0.45, '#3b4558',\n      0.46, '#3b4558',\n      0.46, '#4a5468',\n      0.47, '#83879d',\n      0.48, '#83879d',\n      0.48, '#211e27',\n      0.5, '#211e27',\n      0.5, '#000',\n      0.1, '#000',\n    ]\n}\n\n\n\nexport class Skybox {\n  constructor(scene, defaultSkybox = DAWN_DUSK) {\n    this.mesh = null\n    this.textures = {}\n\n    Object.entries(gradients).forEach(([key, colorStop]) => {\n      const ctx = document.createElement('canvas').getContext('2d')\n      const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height)\n      for (let i = 0; i < colorStop.length - 2; i += 2) {\n        gradient.addColorStop(colorStop[i], colorStop[i + 1])\n      }\n      ctx.fillStyle = gradient\n      ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)\n      const texture = new THREE.CanvasTexture(ctx.canvas)\n      this.textures[key] = texture\n    })\n\n    const geometry = new THREE.IcosahedronGeometry(UTILS.SKYBOX_RADIUS, 2)\n    this.mesh = new THREE.Mesh(\n      geometry,\n      new THREE.MeshBasicMaterial(\n        {\n          map: this.textures[defaultSkybox.toLowerCase()],\n          side: THREE.BackSide,\n          depthWrite: false,\n          fog: false\n        }\n      )\n    )\n    scene.add(this.mesh)\n  }\n\n  setTexture(textureName) {\n    this.mesh.material.map = this.textures[textureName]\n  }\n}\n"
  },
  {
    "path": "src/style.css",
    "content": "body {\n  margin: 0px;\n  padding: 0px;\n}\n\nhtml,\nbody {\n  overflow: hidden;\n  color: #444;\n  height: 100%;\n  font-family: \"IBM Plex Mono\", monospace;\n}\n\nbutton {\n  user-select: none;\n}\n\n#webgl:-webkit-full-screen {\n  width: 100%;\n  height: 100%;\n}\n\n.material-icons-outlined.md-36 {\n  font-size: 36px;\n}\n\n.material-icons-outlined.md-dark {\n  color: rgba(0, 0, 0, 0.54);\n}\n\n.material-icons-outlined.md-dark.md-inactive {\n  color: rgba(0, 0, 0, 0.26);\n}\n\n/* Rules for using icons as white on a dark background. */\n.material-icons-outlined.md-light {\n  color: rgba(255, 255, 255, 1);\n}\n\n.material-icons-outlined.md-light.md-inactive {\n  color: rgba(255, 255, 255, 0.3);\n}\n\n.icon {\n  width: 64px;\n  height: 64px;\n  border-radius: 100%;\n  background-color: rgba(255, 255, 0, 0.26);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 0;\n  color: #f0f;\n  transition: background-color 0.5s ease;\n}\n\n@media (hover: hover) {\n  button:hover {\n    background-color: rgba(255, 255, 0, 0.5);\n  }\n}\n\n.icon.active {\n  background-color: rgba(255, 255, 0, 0.75);\n}\n\n.action-container {\n  display: flex;\n  flex-direction: column;\n  gap: 1em;\n  z-index: 1;\n}\n\n#hud-left {\n  position: absolute;\n  left: 16px;\n  top: 16px;\n  opacity: 0;\n  display: none;\n  z-index: 2;\n}\n\n#hud-right {\n  position: absolute;\n  right: 16px;\n  top: 16px;\n  opacity: 0;\n  display: none;\n  z-index: 2;\n}\n\n#hud-dialog-container {\n  display: flex;\n  flex-direction: row;\n  justify-content: center;\n  opacity: 0;\n}\n\n#hud-dialog {\n  position: absolute;\n  width: 100%;\n  z-index: 0;\n  bottom: 0;\n  background-color: #f8f8f8;\n  transform: translateY(100%);\n  border-radius: 8px 8px 0px 0px;\n  opacity: 0.6;\n  user-select: none;\n}\n\n#photo {\n  object-fit: cover;\n}\n\n.media {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 1em;\n}\n\n.media > * {\n  flex: 1 1;\n  width: 50vw;\n}\n\nimg {\n  width: 100%;\n  flex: 1 240px;\n  border-radius: 8px 8px 0px 0px;\n}\n\n.image {\n  display: flex;\n  flex-direction: column;\n  justify-content: start;\n}\n\n.content {\n  padding: 8px 8px 8px 0px;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n}\n\n.telemetry {\n  display: flex;\n  flex-direction: row;\n  justify-content: stretch;\n  align-items: center;\n  margin-top: 1em;\n  margin-bottom: 1em;\n  gap: 0.25em;\n}\n\n@media only screen and (min-width: 1080px) {\n  #hud-dialog {\n    width: 75vw;\n  }\n}\n\n@media only screen and (min-width: 1200px) {\n  #hud-dialog {\n    width: 55vw;\n  }\n}\n\n@media only screen and (min-width: 600px) {\n  img {\n    border-radius: 8px 0px 0px 0px;\n  }\n}\n\n@media only screen and (max-width: 800px) {\n  .media > * {\n    flex: 1 1;\n    width: 100vw;\n  }\n}\n\n@media only screen and (max-width: 600px) {\n  .content {\n    padding: 0px 8px 8px 8px;\n  }\n\n  .icon {\n    width: 48px;\n    height: 48px;\n    border-radius: 100%;\n    background-color: rgba(255, 255, 0, 0.26);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    border: 0;\n    color: #f0f;\n    transition: background-color 0.5s ease;\n  }\n\n  .material-icons-outlined.md-36 {\n    font-size: 24px;\n  }\n\n  img {\n    width: 100vw;\n    height: 140px;\n  }\n}\n\n@media only screen and (max-height: 600px) and (orientation: landscape) {\n  body {\n    font-size: 16px;\n  }\n\n  .icon {\n    width: 48px;\n    height: 48px;\n    border-radius: 100%;\n    background-color: rgba(255, 255, 0, 0.26);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    border: 0;\n    color: #f0f;\n    transition: background-color 0.5s ease;\n  }\n\n  .material-icons-outlined.md-36 {\n    font-size: 24px;\n  }\n\n  img {\n    width: 100%;\n    height: 100%;\n  }\n}\n"
  },
  {
    "path": "src/utils.js",
    "content": "import { SphericalMercator } from '@mapbox/sphericalmercator'\n\nconst ADSB_LOCALHOST = window.location.hostname\n\nconst ADSB_HOST = import.meta.env.SKIES_ADSB_USE_EXISTING_ADSB ?\n  `${ADSB_LOCALHOST}:30006` :\n  `${import.meta.env.SKIES_ADSB_RPI_HOST}:30006`\n\nconst FLASK_HOST = import.meta.env.SKIES_ADSB_USE_EXISTING_ADSB ?\n  `${ADSB_LOCALHOST}:5000` :\n  `${import.meta.env.SKIES_ADSB_RPI_HOST}:5000`\n\nexport const DATA_HOSTS = {\n  \"adsb\": `ws://${ADSB_HOST}`,\n  \"flight_info\": `http://${FLASK_HOST}/flightinfo`,\n  \"metar\": `http://${FLASK_HOST}/metar`,\n  \"photos\": \"https://api.planespotters.net/pub/photos/hex\"\n}\n\nconsole.log(\"DATA_HOSTS:\")\nconsole.table(DATA_HOSTS)\n// Object.entries(DATA_HOSTS).forEach(([key, value]) => {\n//   console.log(`\\t${key}: ${value}`)\n// })\n\n//\n// ADS-B sends back speed, velocity changes, and altitude in knots and feet.\n//\n// For display purposes all of the distance, heading, and bearing calculations\n// are calculated in meters using the ADS-B lat/long data.\n//\n// For right now the scale of 1 unit over 250 unit seems to look good. \n//\n// TODO improve documentation about how DEFAULT_SCALE works\n//\nexport const DEFAULT_SCALE = 1.0 / 250.0\n\n//\n// Camera Default Settings\n//\n// NOTE:\n// CAMERA_FAR should always be at least double the SKYBOX_RADIUS\n//\nexport const CAMERA_FOV = 75\nexport const CAMERA_NEAR = 0.1\nexport const CAMERA_FAR = 10000.0\n\nexport const CAMERA_ORBIT_START_ELEVATION_ADJUST = 25.0\nexport const CAMERA_ORBIT_START_DISTANCE = 64.0\n\nexport const CAMERA_AUTO_ORBIT_DEFAULT_MIN_RADIUS = 25\nexport const CAMERA_AUTO_ORBIT_DEFAULT_MAX_RADIUS = 250\nexport const CAMERA_AUTO_ORBIT_DEFAULT_RADIUS_SPEED = 0.009\nexport const CAMERA_AUTO_ORBIT_DEFAULT_VERTICAL_SPEED = 0.009\nexport const CAMERA_AUTO_ORBIT_DEFAULT_HORIZONTAL_SPEED = 0.009\nexport const CAMERA_AUTO_ORBIT_DEFAULT_MIN_PHI = 0\nexport const CAMERA_AUTO_ORBIT_DEFAULT_MAX_PHI = 90\n\nexport const CAMERA_AUTO_ORBIT_SETTINGS_MIN_RADIUS = 10.0\nexport const CAMERA_AUTO_ORBIT_SETTINGS_MAX_RADIUS = 1000.0\nexport const CAMERA_AUTO_ORBIT_MIN_RADIUS_SPEED = 0.0\nexport const CAMERA_AUTO_ORBIT_MAX_RADIUS_SPEED = 0.5\nexport const CAMERA_AUTO_ORBIT_MIN_VERTICAL_SPEED = -0.2\nexport const CAMERA_AUTO_ORBIT_MAX_VERTICAL_SPEED = 0.2\nexport const CAMERA_AUTO_ORBIT_MIN_HORIZONTAL_SPEED = -0.2\nexport const CAMERA_AUTO_ORBIT_MAX_HORIZONTAL_SPEED = 0.2\nexport const CAMERA_AUTO_ORBIT_MIN_PHI = 0\nexport const CAMERA_AUTO_ORBIT_MAX_PHI = 90\n\nexport const CAMERA_MODE_ORBIT = \"orbit\"\nexport const CAMERA_MODE_FOLLOW = \"follow\"\nexport const CAMERA_MODE_AUTO_ORBIT = \"auto_orbit\"\n\n\n//\n// Skybox Radius\n//\n// NOTE:\n// SKYBOX_RADIUS should be less than or equal to half of the camera far plane\n//\nexport const SKYBOX_RADIUS = 3000.0\n\n//\n// Follow Camera Default Settings\n//\n// NOTE:\n// min polar angle: 45 degrees\n// max polar angle: 135 degrees\n//\nexport const FOLLOW_CAM_DISTANCE = 24.0\nexport const FOLLOW_CAM_DAMPING_FACTOR = 0.95\nexport const FOLLOW_CAM_VELOCITY_THRESHOLD = 0.001\nexport const FOLLOW_CAM_DIRECTION_CHANGE_RESISTANCE = 0.7\nexport const FOLLOW_CAM_VELOCITY_SMOOTHING = 0.3\nexport const FOLLOW_CAM_MIN_POLAR_ANGLE = Math.PI / 4\nexport const FOLLOW_CAM_MAX_POLAR_ANGLE = (3 * Math.PI) / 4\n\n\n//\n// Polar Grid Default Settings\n//\n// NOTE: \n// Polar Grid Radius should ideally match the SKYBOX_RADIUS\n//\nexport const POLAR_GRID_RADIUS = SKYBOX_RADIUS\nexport const POLAR_GRID_RADIALS = 16\nexport const POLAR_GRID_CIRCLES = 5\nexport const POLAR_DIVISIONS = 64\nexport const POLAR_GRID_COLOR_1 = \"#81efff\"\nexport const POLAR_GRID_COLOR_2 = \"#81efff\"\n\n//\n// Aircraft Default Settings\n//\n\n//\n// Aircraft time-to-live in seconds\n//\n// NOTE: \n// Adjust this value as needed. Use increments of +/- 5 seconds. \n// If you find that aircraft are disappearing too quickly try increasing this value. \n// If you find that aircraft are not disappearing quickly enough try decreasing this value. \n// The best value is dependent on how much traffic you have in your area.\n//\nexport const AIRCRAFT_TTL = 15.0\n// trail update frequency is based on number of valid telemetry updates that have occurred\nexport const AIRCRAFT_TRAIL_UPDATE_FREQUENCY = 100\nexport const AIRCRAFT_MAX_TRAIL_POINTS = 2500\n// guards against sudden jumps tail in altitude due to bad ADS-B data\nexport const AIRCRAFT_TRAIL_UPDATE_Y_POS_THRESHOLD = 1000.0 * DEFAULT_SCALE\n\n\nexport const sizes = {\n  width: window.innerWidth,\n  height: window.innerHeight\n}\n\nexport const INTERSECTED = {\n  key: null,\n  mesh: null,\n  aircraft: null,\n}\n\nexport function parseViteEnvBooleanSetting(value) {\n  if (value === undefined) return undefined\n  const setting = value.toLowerCase()\n  if (setting === \"true\") return true\n  if (setting === \"false\") return false\n  return undefined\n}\n\nexport function parseViteEnvNumberSetting(value, min, max) {\n  if (value === undefined) return undefined\n  const setting = parseFloat(value)\n  if (isNaN(setting)) return undefined\n\n  if (min !== undefined && setting < min) return min\n  if (max !== undefined && setting > max) return max\n\n  return setting\n}\n\n\nexport const settings = {\n\n  // auto orbit camera\n  auto_orbit: {\n    min_radius: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_MIN_RADIUS,\n      CAMERA_AUTO_ORBIT_SETTINGS_MIN_RADIUS, CAMERA_AUTO_ORBIT_SETTINGS_MAX_RADIUS\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_MIN_RADIUS,\n    max_radius: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_MAX_RADIUS,\n      CAMERA_AUTO_ORBIT_SETTINGS_MIN_RADIUS, CAMERA_AUTO_ORBIT_SETTINGS_MAX_RADIUS\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_MAX_RADIUS,\n    radius_speed: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_RADIUS_SPEED,\n      CAMERA_AUTO_ORBIT_MIN_RADIUS_SPEED, CAMERA_AUTO_ORBIT_MAX_RADIUS_SPEED\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_RADIUS_SPEED,\n    vertical_speed: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_VERTICAL_SPEED,\n      CAMERA_AUTO_ORBIT_MIN_VERTICAL_SPEED, CAMERA_AUTO_ORBIT_MAX_VERTICAL_SPEED\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_VERTICAL_SPEED,\n    horizontal_speed: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_HORIZONTAL_SPEED,\n      CAMERA_AUTO_ORBIT_MIN_HORIZONTAL_SPEED, CAMERA_AUTO_ORBIT_MAX_HORIZONTAL_SPEED\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_HORIZONTAL_SPEED,\n    min_phi: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_MIN_PHI,\n      CAMERA_AUTO_ORBIT_MIN_PHI, CAMERA_AUTO_ORBIT_MAX_PHI\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_MIN_PHI,\n    max_phi: parseViteEnvNumberSetting(import.meta.env.SKIES_ADSB_SETTINGS_AUTO_ORBIT_MAX_PHI,\n      CAMERA_AUTO_ORBIT_MIN_PHI, CAMERA_AUTO_ORBIT_MAX_PHI\n    ) ?? CAMERA_AUTO_ORBIT_DEFAULT_MAX_PHI,\n  },\n\n  // trails\n  show_all_trails: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_ALL_TRAILS) ?? true,\n\n  // map layers\n  show_aerodromes: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_AERODROMES) ?? true,\n  show_origin_labels: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_ORIGINS) ?? true,\n  show_runways: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_AERODROMES) ?? true,\n  show_airspace_class_b: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_B) ?? true,\n  show_airspace_class_c: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_C) ?? true,\n  show_airspace_class_d: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_AIRSPACE_CLASS_D) ?? true,\n  show_urban_areas: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_URBAN_AREAS) ?? true,\n  show_roads: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_ROADS) ?? true,\n  show_lakes: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_LAKES) ?? true,\n  show_rivers: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_RIVERS) ?? true,\n  show_states_provinces: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_STATES_PROVINCES) ?? true,\n  show_counties: parseViteEnvBooleanSetting(import.meta.env.SKIES_ADSB_SETTINGS_SHOW_COUNTIES) ?? true,\n}\n\nconsole.log(\"UTIL SETTINGS: \")\nconsole.table(settings)\n\nexport function isLandscape() {\n  return sizes.width > sizes.height && sizes.height < 576\n}\n\n\n//\n// haversine/spherical distance and bearing calculations\n// source: https://www.movable-type.co.uk/scripts/latlong.html\n//\n\nexport function calcHaversineDistance(from, to) {\n  const R = 6371e3 // metres\n  const φ1 = from.lat * Math.PI / 180 // φ, λ in radians\n  const φ2 = to.lat * Math.PI / 180\n  const Δφ = (to.lat - from.lat) * Math.PI / 180\n  const Δλ = (to.lng - from.lng) * Math.PI / 180\n  const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +\n    Math.cos(φ1) * Math.cos(φ2) *\n    Math.sin(Δλ / 2) * Math.sin(Δλ / 2)\n  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))\n  const d = R * c // in metres\n\n  return d\n}\n\nexport function calcSphericalDistance(from, to) {\n  const φ1 = from.lat * Math.PI / 180\n  const φ2 = to.lat * Math.PI / 180\n  const Δλ = (to.lng - from.lng) * Math.PI / 180\n  const R = 6371e3\n  const d = Math.acos(Math.sin(φ1) * Math.sin(φ2) + Math.cos(φ1) * Math.cos(φ2) * Math.cos(Δλ)) * R\n\n  return d\n}\n\nexport function calcBearing(from, to) {\n  const φ1 = from.lat\n  const λ1 = from.lng\n  const φ2 = to.lat\n  const λ2 = to.lng\n\n  const y = Math.sin(λ2 - λ1) * Math.cos(φ2)\n  const x = Math.cos(φ1) * Math.sin(φ2) -\n    Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ2 - λ1)\n\n  const θ = Math.atan2(y, x)\n  const bearing = (θ * 180 / Math.PI + 360) % 360 // in degrees\n\n  return bearing\n}\n\nconst sphericalMercator = new SphericalMercator()\n\nlet originX = undefined\nlet originY = undefined\n\nexport async function setOrigin(lonLat) {\n  let [mx, my] = sphericalMercator.forward(lonLat)\n  originX = mx\n  originY = my\n  console.log(\"[UTIL] setOrigin:\", lonLat, mx, my, originX, originY)\n}\n\n//\n// convert lon/lat into Web Mercator XY coordinates centered around the UTILS.origin\n//\nexport function getXY(lonLat) {\n  let [xx, yy] = sphericalMercator.forward(lonLat)\n\n  xx -= originX\n  yy -= originY\n\n  //console.log(lonLat)\n\n  return [xx, -yy]\n}\n"
  },
  {
    "path": "use_existing_adsb.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# This script is used to run the application locally in development mode\n# and it will establish a connection to an existing ADS-B receiver\n# via websockify.\n#\n\nsource src/.env\n\nif [ -z $SKIES_ADSB_USE_EXISTING_ADSB ]; then\n  echo \"SKIES_ADSB_USE_EXISTING_ADSB not found. Please set SKIES_ADSB_USE_EXISTING_ADSB in .env file.\"\n  exit 1\nfi\n\n#\n# kill previous Flask server and websockify instances\n#\npkill -f \"flask run\" || true\npkill -f websockify || true\n\n#\n# activate Python virtual environment so we can run Flask + websockify\n#\nsource .venv/bin/activate\n\n#\n# start Flask server and websockify\n#\nexport FLASK_ENV=development && cd flask && flask run -h 0.0.0.0 &\n\nsleep 1\n\nwebsockify 30006 $SKIES_ADSB_USE_EXISTING_ADSB &\n\nsleep 1\n\n#\n# start the application local HTTP development server\n#\nnpx vite --host\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig } from \"vite\"\n\nexport default defineConfig({\n    root: 'src',\n    build: {\n        outDir: '../dist',\n    },\n    publicDir: '../public',\n    envPrefix: 'SKIES_ADSB_',\n})"
  }
]