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