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) 🚁

_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

_Recording of the skies-adsb app running in a browser demonstrating the use of the onscreen controls_

_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

# 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/).

## 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

_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

_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.

_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)

_Examples of custom map layers: Miami International (KMIA), LaGuardia (KLGA), and Mexico City International (MMMX) airports_

_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_
# 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_
# 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:

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.

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:

Clear out the value there so it looks like this:

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_',
})