diff --git a/.gitignore b/.gitignore index 77fc58921..bdde36e86 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ debian/.debhelper debian/dump1090-fa* debian/debhelper-build-stamp debian/files + +# Runtime generated JSON data files +public_html/data/ diff --git a/RANGE_OUTLINE.md b/RANGE_OUTLINE.md new file mode 100644 index 000000000..59624d58d --- /dev/null +++ b/RANGE_OUTLINE.md @@ -0,0 +1,555 @@ +# Range Outline Feature Documentation + +## Overview + +The Range Outline feature tracks and visualizes the maximum detection range of the ADS-B receiver at each bearing (0-359 degrees). It displays an altitude-colored outline on the map showing the actual coverage area based on received aircraft positions, providing a real-time view of detection capabilities that adapts to environmental conditions, antenna characteristics, and terrain. Each segment of the outline is colored according to the altitude of the aircraft at maximum range for that bearing, creating a gradient visualization that shows both coverage and altitude information. + +## How It Works + +### Backend (C) + +#### Data Tracking (`track.c`) + +The backend continuously tracks aircraft positions and calculates the maximum detection range at each degree bearing from the receiver location: + +1. **Position Updates** (`updatePosition()` at line 626): + - Each time an aircraft position is decoded, `update_range_outline()` is called + - Distance and bearing from receiver to aircraft are calculated using great circle formulas + +2. **Range Outline Updates** (`update_range_outline()` starting at line 273): + - Accepts aircraft object to access altitude information + - Calculates the great circle distance from receiver to aircraft + - Calculates the bearing from receiver to aircraft (0-359 degrees) + - Rounds bearing to nearest integer degree + - Retrieves altitude (prefers barometric, falls back to geometric) + - Updates maximum range for that bearing if: + - This is the first position at this bearing, OR + - This distance exceeds the previous maximum for this bearing + - Records timestamp and altitude of the update + +3. **Data Structure** (`dump1090.h` lines 409-414): + ```c + double range_outline_max[RANGE_OUTLINE_DEGREES]; // Maximum range at each bearing (meters) + uint64_t range_outline_updated[RANGE_OUTLINE_DEGREES]; // Timestamp when each bearing was last updated + int range_outline_altitude[RANGE_OUTLINE_DEGREES]; // Altitude (feet) of aircraft at maximum range + char *range_outline_persistence_file; // File to persist range outline data + uint64_t range_outline_retention_ms; // Current retention period in milliseconds + ``` + +4. **Data Expiration** (`expireRangeOutline()` at line 1472 in `track.c`): + - Called every second by `trackPeriodicUpdate()` + - Iterates through all 360 bearings + - Resets bearings to 0 if timestamp exceeds retention period + - Allows the outline to adapt to changing conditions over time + +#### Data Persistence (`dump1090.c`) + +Range outline data persists across application restarts: + +1. **Save Function** (`saveRangeOutline()` starting at line 105): + - Writes binary file with three arrays + - Called every 60 seconds by `backgroundTasks()` + - Also called at shutdown + - File format: `double[360] ranges | uint64_t[360] timestamps | int[360] altitudes` + +2. **Load Function** (`loadRangeOutline()` starting at line 125): + - Reads binary file at startup + - Restores previous range, timestamp, and altitude data + - Logs success/failure messages + +3. **Storage Location**: + - Default: `/tmp/range_outline.dat` + - When `--write-json ` is used: `/range_outline.dat` + +#### JSON Generation (`net_io.c`) + +The backend generates JSON output for the web interface: + +1. **Function** (`generateRangeOutlineJson()` starting at line 1732): + - Generates JSON with current timestamp, range array, timestamp array, and altitude array + - Applies retention filter: outputs 0/null for bearings outside retention window + - Called by `backgroundTasks()` at the JSON update interval + +2. **JSON Format**: + ```json + { + "now": 1760394506.0, + "range_outline": [104761, 211010, ...360 values in meters...], + "range_outline_timestamps": [1760390035.1, 1760389259.8, ...360 values in seconds...], + "range_outline_altitudes": [35000, 28000, null, ...360 values in feet or null...] + } + ``` + +3. **Output File**: `data/range_outline.json` (written at same interval as `aircraft.json`) + +### Frontend (JavaScript) + +#### Initialization (`public_html/script.js`) + +1. **Global Variables** (lines 10-13): + ```javascript + var RangeOutlineFeature = null; // OpenLayers feature containing the polygon + var RangeOutlineLayer = null; // OpenLayers vector layer for display + var ShowRangeOutline = false; // Toggle state + var RangeOutlineData = null; // Cached JSON data from server + ``` + +2. **Startup** (`initRangeOutline()`): + - Called from `end_load_history()` after map initialization + - Reads `ShowRangeOutline` preference from localStorage + - If enabled, fetches data and updates checkbox visual state + - Ensures UI state matches saved preference after browser refresh + +3. **UI Setup** (`initialize_map()`): + - Adds checkbox to settings panel (HTML in `public_html/index.html`) + - Registers click handler for `toggleRangeOutline()` + - Synchronizes checkbox appearance with boolean state + +#### Data Fetching + +1. **Periodic Updates** (`fetchData()`): + - If `ShowRangeOutline` is enabled, calls `fetchRangeOutline()` each refresh interval + - Runs alongside aircraft data updates + +2. **AJAX Request** (`fetchRangeOutline()`): + - Fetches `data/range_outline.json` from server + - 5-second timeout, no caching + - On success: stores data and calls `updateRangeOutline()` + - On failure: silently ignores (outline is optional feature) + +#### Altitude-Colored Outline Rendering + +1. **Coordinate Conversion** (`updateRangeOutline()`): + - Validates data structure (must have 360 ranges, timestamps, and altitudes) + - Iterates through all 360 bearings + - For each bearing: + - Checks if range > 0 and timestamp > 0 (backend sends 0 for expired data) + - Uses effective range (actual if valid, 1 meter if invalid to create small circle at center) + - Calculates lat/lon using `destinationPoint()` Haversine formula + - Converts to map projection coordinates + - Stores altitude information for coloring + +2. **Haversine Calculation** (`destinationPoint()`): + - Takes lat/lon origin, bearing, and distance in meters + - Returns destination lat/lon point + - Earth radius: 6,371,000 meters + - Uses standard spherical trigonometry formulas + +3. **Multi-Segment Rendering**: + - Creates 360 individual LineString segments (one for each degree transition) + - Each segment connects bearing N to bearing N+1 (with wraparound from 359 to 0) + - Always draws complete circle: segments without data stay at 1 meter radius + - Color calculation for each segment: + - If both endpoints have valid altitude: uses midpoint altitude + - If only one endpoint has valid altitude: uses that altitude + - If no altitude data: uses default blue color + - Uses existing `PlaneObject.prototype.getAltitudeColor()` function for altitude-to-color mapping + - Uses existing `PlaneObject.prototype.hslRepr()` function for HSL color conversion + - Layer properties: + - Width: 2 pixels + - No fill + - zIndex: 99 (below site circles, above base layers) + - Creates smooth gradient effect as colors transition between adjacent segments + +#### User Controls + +1. **Toggle Function** (`toggleRangeOutline()`): + - Inverts `ShowRangeOutline` boolean + - Saves state to localStorage for persistence + - When enabling: + - Fetches fresh data from server + - Data fetch callback updates display + - When disabling: + - Hides the layer (`setVisible(false)`) + - Clears all line segment features from the source + +2. **State Persistence**: + - Uses browser localStorage with key `ShowRangeOutline` + - Values: `'true'` or `'false'` (string) + - Restored on page load by `initRangeOutline()` + +## User Configuration + +### Backend Configuration + +#### Command-Line Options + +The feature adds one new command-line option and uses existing options: + +- **Range Outline Retention Period**: Use `--range-outline-retention ` to control data retention + - Default: 24 hours + - Specified in hours (accepts decimal values) + - Example: `--range-outline-retention 48` for 48 hours + - Example: `--range-outline-retention 0.5` for 30 minutes + - Controls how long range data is kept before expiring + +- **Data Directory**: Use `--write-json ` to specify where JSON and persistence files are written + - Default JSON location: Uses the directory specified by `--write-json` + - Persistence file: Automatically placed in same directory as `range_outline.dat` + - If `--write-json` not specified, persistence uses `/tmp/range_outline.dat` + +- **JSON Update Interval**: Use `--write-json-every ` to control update frequency + - Default: 1.0 seconds + - Minimum: 0.1 seconds + - Affects how often `range_outline.json` is regenerated + +#### Retention Period Details + +The data retention period controls how long range data is kept before expiring. + +**Default Value**: 24 hours (defined in `dump1090.h` line 278 as `RANGE_OUTLINE_DEFAULT_RETENTION_HOURS`) + +**Runtime Configuration**: Use the `--range-outline-retention ` command-line option to override the default at startup. + +**Retention Period Behavior**: +- Data older than the retention period is automatically expired and reset to 0 +- Expiration happens every second via `expireRangeOutline()` in `track.c` +- Allows outline to adapt to changing conditions (weather, seasonal foliage, antenna adjustments) +- Stored internally as `Modes.range_outline_retention_ms` (converted to milliseconds) + +### Frontend Configuration + +#### User Controls + +The web interface provides a simple on/off toggle: + +1. **Location**: Settings panel → "Range Outline" checkbox (below "Site Position and Range Rings") + +2. **Behavior**: + - Click to enable: Fetches data and displays polygon on map + - Click to disable: Hides polygon, stops fetching updates + - State persists across browser sessions via localStorage + +#### Visual Customization + +The outline is automatically colored based on altitude using the same color scale as aircraft markers. Each line segment is colored according to the altitude of the aircraft at maximum range for that bearing. + +**Altitude Color Scale**: +- The colors are determined by `PlaneObject.prototype.getAltitudeColor()` in `planeObject.js` +- Higher altitudes appear in different hues than lower altitudes +- Colors smoothly gradient between adjacent bearings + +**Line Width**: To change the line thickness, edit `public_html/script.js` in the `updateRangeOutline()` function around line 2995: +```javascript +lineFeature.setStyle(new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: color, + width: 2 // Change this value (in pixels) + }) +})); +``` + +**Default Color for No Altitude Data**: Segments without altitude information use `'rgba(0, 128, 255, 0.8)'` (semi-transparent blue). This can be changed in the same location around line 2992. + +## Technical Flow + +### Startup Sequence + +1. **Backend Initialization** (`dump1090.c` `main()`): + ``` + modesInitConfig() + ↓ Sets default persistence file: /tmp/range_outline.dat + ↓ Sets default retention: 24 hours * 3600 * 1000 ms + Parse --write-json argument + ↓ Updates persistence file path: /range_outline.dat + loadRangeOutline() + ↓ Reads persisted data from file + ↓ Restores range_outline_max[] and range_outline_updated[] + writeJsonToFile("range_outline.json", generateRangeOutlineJson) + ↓ Writes initial JSON (may be empty or contain loaded data) + ``` + +2. **Frontend Initialization** (`script.js`): + ``` + initialize() + ↓ Sets up UI, hides range_outline_column initially + initialize_map() + ↓ Shows range_outline_column if SitePosition is configured + ↓ Registers checkbox click handler + end_load_history() + ↓ initRangeOutline() + ↓ Reads localStorage['ShowRangeOutline'] + ↓ If 'true': sets ShowRangeOutline = true + ↓ Calls fetchRangeOutline() + ↓ Updates checkbox appearance + fetchData() starts periodic refresh loop + ``` + +### Runtime Data Flow + +1. **Aircraft Position Received**: + ``` + demodulate2400() / demodulate2400AC() [demod_2400.c] + ↓ Decodes Mode S message + detectModeS() [mode_s.c] + ↓ Validates message + useModesMessage() [mode_s.c] + ↓ Processes valid message + decodeModesMessage() [mode_s.c] + ↓ Extracts position data + trackUpdateFromMessage() [track.c] + ↓ Updates aircraft state + updatePosition() [track.c] + ↓ Validates new position + ↓ update_range_outline(aircraft) + ↓ greatcircle(receiver, aircraft) → distance in meters + ↓ get_bearing(receiver, aircraft) → bearing 0-359° + ↓ bearing_idx = round(bearing) % 360 + ↓ Get altitude (prefer barometric, fallback to geometric) + ↓ if (distance > max[bearing_idx] || no data yet) + ↓ range_outline_max[bearing_idx] = distance + ↓ range_outline_updated[bearing_idx] = now (ms) + ↓ range_outline_altitude[bearing_idx] = altitude (feet) + ``` + +2. **Periodic Updates (Every 1 Second)**: + ``` + trackPeriodicUpdate() [track.c] + ↓ expireRangeOutline() + ↓ for each bearing (0-359): + ↓ if (now - updated[bearing]) > retention_ms + ↓ range_outline_max[bearing] = 0 + ↓ range_outline_updated[bearing] = 0 + ``` + +3. **JSON Generation (Every 1 Second by Default)**: + ``` + backgroundTasks() [dump1090.c] + ↓ if (now >= next_json) + ↓ writeJsonToFile("range_outline.json", generateRangeOutlineJson) + ↓ generateRangeOutlineJson() [net_io.c] + ↓ for each bearing (0-359): + ↓ if updated[bearing] != 0 && (now - updated[bearing]) <= retention_ms + ↓ output: range_outline[bearing] = max[bearing] + ↓ output: range_outline_timestamps[bearing] = updated[bearing] + ↓ output: range_outline_altitudes[bearing] = altitude[bearing] or null + ↓ else + ↓ output: range_outline[bearing] = 0 + ↓ output: range_outline_timestamps[bearing] = 0 + ↓ output: range_outline_altitudes[bearing] = null + ``` + +4. **Persistence (Every 60 Seconds + Shutdown)**: + ``` + backgroundTasks() [dump1090.c] + ↓ if (now >= next_range_outline_save) + ↓ saveRangeOutline() + ↓ fopen(persistence_file, "wb") + ↓ fwrite(range_outline_max[360]) + ↓ fwrite(range_outline_updated[360]) + ↓ fwrite(range_outline_altitude[360]) + ↓ fclose() + ``` + +5. **Frontend Display (Every Refresh Interval)**: + ``` + fetchData() [script.js] + ↓ if (ShowRangeOutline) + ↓ fetchRangeOutline() + ↓ $.ajax("data/range_outline.json") + ↓ on success: + ↓ RangeOutlineData = data + ↓ updateRangeOutline() + ↓ Build points array for all 360 bearings: + ↓ range = data.range_outline[bearing] + ↓ timestamp = data.range_outline_timestamps[bearing] + ↓ altitude = data.range_outline_altitudes[bearing] + ↓ isValid = (range > 0 && timestamp > 0) + ↓ effectiveRange = isValid ? range : 1 + ↓ point = destinationPoint(receiver, bearing, effectiveRange) + ↓ points[bearing] = {coord, altitude, isValid} + ↓ Clear existing features from layer + ↓ for bearing = 0 to 359: + ↓ nextBearing = (bearing + 1) % 360 + ↓ Create LineString from points[bearing] to points[nextBearing] + ↓ Calculate color based on altitude(s) + ↓ Apply style with calculated color + ↓ Add feature to layer + ↓ RangeOutlineLayer.setVisible(true) + ``` + +## File Modifications + +### New Files +- `RANGE_OUTLINE.md` - This documentation file + +### Modified Files + +1. **`dump1090.h`** - Data structure definitions + - Lines 276-277: Constants for degrees and default retention + - Lines 409-414: State variables in `struct _Modes` (includes altitude array) + +2. **`dump1090.c`** - Persistence and initialization + - Lines 105-149: `saveRangeOutline()` and `loadRangeOutline()` functions + - Default configuration in `modesInitConfig()` + - Periodic save logic in `backgroundTasks()` (every 60 seconds) + - JSON generation call in `backgroundTasks()` + - Path update when `--write-json` parsed + - Load persisted data at startup + - Write initial JSON at startup + - Save data at shutdown + +3. **`track.c`** - Position tracking and expiration + - Lines 273-300: `update_range_outline()` function (updated to accept aircraft object and store altitude) + - Line 635: Call to `update_range_outline()` in `updatePosition()` + - Lines 1472-1480: `expireRangeOutline()` function + - Line 1497: Call to `expireRangeOutline()` in `trackPeriodicUpdate()` + +4. **`net_io.c`** - JSON generation + - Lines 1732-1789: `generateRangeOutlineJson()` function (includes altitude array output) + +5. **`net_io.h`** - Function declaration + - Declaration of `generateRangeOutlineJson()` + +6. **`public_html/index.html`** - UI element + - Range outline checkbox in settings panel + +7. **`public_html/script.js`** - Frontend logic + - Lines 10-13: Global variables + - Line 221-224: Fetch call in `fetchData()` + - Line 759: Initialize on startup in `end_load_history()` + - Lines 1115-1126: Checkbox setup in `initialize_map()` + - Line 1148: Show column if site position configured + - Lines 2878-3056: Range outline functions (fetch, update with multi-segment rendering, destinationPoint, toggle, init) + +### Generated Files + +1. **`data/range_outline.json`** - Runtime JSON output + - Generated by backend every JSON update interval + - Read by frontend for display + +2. **`data/range_outline.dat` (or `/tmp/range_outline.dat`)** - Persistence file + - Binary format: ranges (double[360]) + timestamps (uint64[360]) + altitudes (int[360]) + - Updated every 60 seconds and at shutdown + - Loaded at startup + - **Note**: If upgrading from a version without altitude support, delete this file to start fresh + +## Design Decisions + +### Why 360 Degrees? +- Provides sufficient angular resolution for most use cases +- Balance between detail and memory/performance +- 1-degree precision is fine for typical ADS-B reception ranges (50-400+ km) + +### Why 24-Hour Default Retention? +- Long enough to capture daily patterns and build complete outline +- Short enough to adapt to changing conditions (weather, foliage, antenna modifications) +- Prevents indefinite growth from one-off long-distance reception events + +### Why Binary Persistence Format? +- Compact: 3 arrays × 360 elements = ~11 KB +- Fast to read/write +- Simple implementation without dependencies + +### Why No Fill Color? +- User preference: outline-only provides clear boundary without obscuring map +- Reduces visual clutter +- Better visibility of aircraft icons inside coverage area + +### Why Altitude-Based Coloring? +- Provides additional insight into coverage characteristics +- High-altitude aircraft typically have longer ranges +- Color gradients show both coverage and altitude profile at a glance +- Uses existing altitude color scale for consistency with aircraft display + +### Why Multi-Segment Rendering Instead of Single Polygon? +- Allows individual coloring of each degree transition +- Creates smooth gradient effect between different altitudes +- More flexible for future enhancements +- Always shows complete circle (small radius for bearings without data yet) + +### Why Layer Visibility Instead of Add/Remove? +- More efficient: layer and feature persist in memory +- Faster toggle response +- Maintains geometry when disabled, instant re-show when enabled +- Standard OpenLayers pattern + +### Why Dual Retention Filtering? +- Backend applies actual expiration: zeros out in-memory data older than retention period +- JSON generation applies read-time filter: outputs 0 for expired bearings +- Frontend skips rendering points with 0 range values +- This layered approach ensures data consistency and clean visualization + +## Troubleshooting + +### Outline Not Appearing + +1. **Check receiver position is configured**: + - Settings panel → "Site Position and Range Rings" must be enabled + - Position must be set via `--lat` and `--lon` command-line options + +2. **Check data exists**: + - Look for `data/range_outline.json` in your JSON directory + - File should contain non-zero values in `range_outline` array + - If all zeros, no aircraft have been received yet or data has expired + +3. **Check browser console for errors**: + - Press F12 to open developer tools + - Look for failed AJAX requests or JavaScript errors + +### Outline Shows Then Disappears + +- **Cause**: Data has expired (exceeds retention period with no new aircraft at those bearings) +- **Solution**: Wait for aircraft to be received again, or increase retention period using `--range-outline-retention ` and restart dump1090 + +### Checkbox State Wrong After Refresh + +- Check browser localStorage is enabled +- Verify `initRangeOutline()` is being called on page load + +### Outline Doesn't Update + +1. **Check ShowRangeOutline is enabled** (checkbox checked) +2. **Check fetchData() is running** (aircraft list updating?) +3. **Check backend is generating JSON** (`ls -l data/range_outline.json` shows recent timestamp) +4. **Check backend is receiving aircraft** (dump1090 console shows messages?) + +## Feature Highlights + +### Altitude-Based Gradient Visualization +The range outline displays a color gradient based on the altitude of aircraft at maximum range for each bearing: + +- **Purple/Magenta**: High altitude aircraft (typically 35,000+ feet) +- **Blue/Cyan**: Medium altitude aircraft (typically 15,000-30,000 feet) +- **Green/Yellow**: Lower altitude aircraft (below 15,000 feet) +- **Smooth Gradients**: Colors smoothly transition between adjacent bearings + +This provides immediate visual feedback about your coverage profile: +- Areas with high-altitude colors suggest good line-of-sight coverage +- Lower altitude colors may indicate terrain limitations or closer aircraft +- Uniform coloring suggests consistent altitude coverage +- Varied coloring shows diverse altitude profiles across different directions + +### Dynamic Coverage Display +- Starts as a small circle at receiver location +- Gradually expands outward as aircraft are tracked at each bearing +- Segments with no data yet remain at center (1 meter radius) +- Segments extend to maximum observed range when aircraft are detected + +## Future Enhancements + +Potential improvements not currently implemented: + +1. **Multiple Outline Layers**: + - Show 24-hour, 7-day, and 30-day outlines simultaneously + - Different rendering styles for each time period + +2. **Export/Import**: + - Download range outline data as GeoJSON + - Import previously saved outlines + +3. **Statistics Display**: + - Total coverage area calculation + - Coverage percentage by direction + - Identify weak coverage areas + - Altitude distribution analysis + +4. **Altitude Band Filtering**: + - Toggle between different altitude bands + - Compare low-altitude vs high-altitude coverage + - Better understanding of ground-level vs. high-altitude capabilities + +5. **Historical Comparison**: + - Overlay previous period's outline to see changes + - Detect antenna degradation or improvements + - Track seasonal variations diff --git a/README.md b/README.md index 9e82dd898..c220e79f2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,19 @@ $ dpkg-buildpackage -b --no-sign --build-profiles=custom # build ``` +## Range Outline Feature + +dump1090-fa includes a range outline visualization that shows your receiver's maximum detection range at each bearing (0-359 degrees). The outline is colored based on the altitude of aircraft at maximum range, creating a gradient visualization of your coverage profile. + +To configure the data retention period (default 24 hours): +``` +--range-outline-retention +``` + +For example, to keep range data for 48 hours: `--range-outline-retention 48` + +See [RANGE_OUTLINE.md](RANGE_OUTLINE.md) for complete documentation. + ## Building manually You can probably just run "make" after installing the required dependencies. diff --git a/dump1090.c b/dump1090.c index 6ac21ae8b..965cedbb4 100644 --- a/dump1090.c +++ b/dump1090.c @@ -101,6 +101,52 @@ void receiverPositionChanged(float lat, float lon, float alt) writeJsonToFile("receiver.json", generateReceiverJson); // location changed } +// Save range outline data to file for persistence across restarts +static void saveRangeOutline(void) +{ + if (!Modes.range_outline_persistence_file) + return; + + FILE *f = fopen(Modes.range_outline_persistence_file, "wb"); + if (!f) { + log_with_timestamp("Failed to save range outline data to %s: %s", + Modes.range_outline_persistence_file, strerror(errno)); + return; + } + + // Write a simple binary format: three arrays + fwrite(Modes.range_outline_max, sizeof(Modes.range_outline_max), 1, f); + fwrite(Modes.range_outline_updated, sizeof(Modes.range_outline_updated), 1, f); + fwrite(Modes.range_outline_altitude, sizeof(Modes.range_outline_altitude), 1, f); + fclose(f); +} + +// Load range outline data from file +static void loadRangeOutline(void) +{ + if (!Modes.range_outline_persistence_file) + return; + + FILE *f = fopen(Modes.range_outline_persistence_file, "rb"); + if (!f) { + // File doesn't exist yet, that's OK + return; + } + + // Read the three arrays + if (fread(Modes.range_outline_max, sizeof(Modes.range_outline_max), 1, f) != 1 || + fread(Modes.range_outline_updated, sizeof(Modes.range_outline_updated), 1, f) != 1 || + fread(Modes.range_outline_altitude, sizeof(Modes.range_outline_altitude), 1, f) != 1) { + log_with_timestamp("Failed to load range outline data, file may be corrupted"); + memset(Modes.range_outline_max, 0, sizeof(Modes.range_outline_max)); + memset(Modes.range_outline_updated, 0, sizeof(Modes.range_outline_updated)); + memset(Modes.range_outline_altitude, 0, sizeof(Modes.range_outline_altitude)); + } else { + log_with_timestamp("Loaded range outline data from %s", Modes.range_outline_persistence_file); + } + + fclose(f); +} // // =============================== Initialization =========================== @@ -145,6 +191,12 @@ static void modesInitConfig(void) { Modes.adaptive_range_scan_delay = 300; Modes.adaptive_range_rescan_delay = 3600; + // Range outline persistence - default to /tmp, will be updated if --write-json is used + Modes.range_outline_persistence_file = strdup("/tmp/range_outline.dat"); + + // Range outline retention - default to 24 hours (converted to milliseconds) + Modes.range_outline_retention_ms = (uint64_t)RANGE_OUTLINE_DEFAULT_RETENTION_HOURS * 3600 * 1000; + sdrInitConfig(); } // @@ -415,6 +467,8 @@ static void showHelp(void) "--json-stats-every Write json stats output every t seconds (default 60)\n" "--json-location-accuracy Accuracy of receiver location in json metadata\n" " (0=no location, 1=approximate, 2=exact)\n" +"--range-outline-retention Set range outline data retention period in hours\n" +" (default: 24)\n" "\n" " Interactive mode\n" "\n" @@ -463,6 +517,7 @@ static void backgroundTasks(void) { static uint64_t next_stats_update; static uint64_t next_json_stats_update; static uint64_t next_json, next_history; + static uint64_t next_range_outline_save; uint64_t now = mstime(); @@ -547,6 +602,7 @@ static void backgroundTasks(void) { if (Modes.json_dir && now >= next_json) { writeJsonToFile("aircraft.json", generateAircraftJson); + writeJsonToFile("range_outline.json", generateRangeOutlineJson); next_json = now + Modes.json_interval; } @@ -570,6 +626,16 @@ static void backgroundTasks(void) { next_history = now + HISTORY_INTERVAL; } + + // Periodically save range outline data (every 1 minute) + if (now >= next_range_outline_save) { + if (next_range_outline_save == 0) { + next_range_outline_save = now + 60000; // 1 minute + } else { + saveRangeOutline(); + next_range_outline_save += 60000; + } + } } // @@ -760,6 +826,11 @@ int main(int argc, char **argv) { // Ignored } else if (!strcmp(argv[j], "--write-json") && more) { Modes.json_dir = strdup(argv[++j]); + // Update range outline persistence file to json directory + free(Modes.range_outline_persistence_file); + char pathbuf[PATH_MAX]; + snprintf(pathbuf, PATH_MAX, "%s/range_outline.dat", Modes.json_dir); + Modes.range_outline_persistence_file = strdup(pathbuf); } else if (!strcmp(argv[j], "--write-json-every") && more) { Modes.json_interval = (uint64_t)(1000 * atof(argv[++j])); if (Modes.json_interval < 100) // 0.1s @@ -806,6 +877,8 @@ int main(int argc, char **argv) { Modes.adaptive_range_scan_delay = atoi(argv[++j]); } else if (!strcmp(argv[j], "--adaptive-range-rescan-delay") && more) { Modes.adaptive_range_rescan_delay = atoi(argv[++j]); + } else if (!strcmp(argv[j], "--range-outline-retention") && more) { + Modes.range_outline_retention_ms = (uint64_t)(atof(argv[++j]) * 3600 * 1000); // convert hours to milliseconds } else if (sdrHandleOption(argc, argv, &j)) { /* handled */ } else { @@ -863,10 +936,14 @@ int main(int argc, char **argv) { adaptive_init(); + // Load persisted range outline data + loadRangeOutline(); + // write initial json files so they're not missing writeJsonToFile("receiver.json", generateReceiverJson); writeJsonToFile("stats.json", generateStatsJson); writeJsonToFile("aircraft.json", generateAircraftJson); + writeJsonToFile("range_outline.json", generateRangeOutlineJson); interactiveInit(); @@ -946,6 +1023,9 @@ int main(int argc, char **argv) { display_stats(&Modes.stats_alltime); } + // Save range outline data for persistence + saveRangeOutline(); + sdrClose(); fifo_destroy(); diff --git a/dump1090.h b/dump1090.h index 46fe55f28..888b6ad62 100644 --- a/dump1090.h +++ b/dump1090.h @@ -273,6 +273,10 @@ typedef enum { #define HISTORY_SIZE 120 #define HISTORY_INTERVAL 30000 +// Range outline configuration +#define RANGE_OUTLINE_DEGREES 360 +#define RANGE_OUTLINE_DEFAULT_RETENTION_HOURS 24 // Default retention: 24 hours + #define MODES_NOTUSED(V) ((void) V) #define MAX_AMPLITUDE 65535.0 @@ -402,6 +406,13 @@ struct _Modes { // Internal state int bUserFlags; // Flags relating to the user details double maxRange; // Absolute maximum decoding range, in *metres* + // Range outline tracking + double range_outline_max[RANGE_OUTLINE_DEGREES]; // Maximum range seen at each bearing (0-359 degrees) + uint64_t range_outline_updated[RANGE_OUTLINE_DEGREES]; // Timestamp when each bearing was last updated + int range_outline_altitude[RANGE_OUTLINE_DEGREES]; // Altitude (feet) of aircraft at maximum range for each bearing + char *range_outline_persistence_file; // File to persist range outline data + uint64_t range_outline_retention_ms; // Current retention period in milliseconds (configurable at runtime) + // State tracking struct aircraft *aircrafts; diff --git a/net_io.c b/net_io.c index 8abd51086..a99eb9c15 100644 --- a/net_io.c +++ b/net_io.c @@ -1729,6 +1729,70 @@ static const char *hazard_enum_string(hazard_t hazard) } } +char *generateRangeOutlineJson(const char *url_path, int *len) { + uint64_t now = mstime(); + int buflen = 32768; + char *buf = (char *) malloc(buflen), *p = buf, *end = buf+buflen; + + MODES_NOTUSED(url_path); + + p = safe_snprintf(p, end, + "{ \"now\" : %.1f,\n" + " \"range_outline\" : [", + now / 1000.0); + + // Output array of ranges for each degree (0-359) + for (int i = 0; i < RANGE_OUTLINE_DEGREES; i++) { + if (i > 0) + p = safe_snprintf(p, end, ","); + + // Output range in meters (or 0 if no data) + if (Modes.range_outline_updated[i] != 0 && + (now - Modes.range_outline_updated[i]) <= Modes.range_outline_retention_ms) { + p = safe_snprintf(p, end, "%.0f", Modes.range_outline_max[i]); + } else { + p = safe_snprintf(p, end, "0"); + } + } + + p = safe_snprintf(p, end, "],\n \"range_outline_timestamps\" : ["); + + // Output array of timestamps for each degree (0-359) + for (int i = 0; i < RANGE_OUTLINE_DEGREES; i++) { + if (i > 0) + p = safe_snprintf(p, end, ","); + + // Output timestamp in seconds (or 0 if no data) + if (Modes.range_outline_updated[i] != 0 && + (now - Modes.range_outline_updated[i]) <= Modes.range_outline_retention_ms) { + p = safe_snprintf(p, end, "%.1f", Modes.range_outline_updated[i] / 1000.0); + } else { + p = safe_snprintf(p, end, "0"); + } + } + + p = safe_snprintf(p, end, "],\n \"range_outline_altitudes\" : ["); + + // Output array of altitudes for each degree (0-359) + for (int i = 0; i < RANGE_OUTLINE_DEGREES; i++) { + if (i > 0) + p = safe_snprintf(p, end, ","); + + // Output altitude in feet (or null if no data or invalid altitude) + if (Modes.range_outline_updated[i] != 0 && + (now - Modes.range_outline_updated[i]) <= Modes.range_outline_retention_ms && + Modes.range_outline_altitude[i] != INVALID_ALTITUDE) { + p = safe_snprintf(p, end, "%d", Modes.range_outline_altitude[i]); + } else { + p = safe_snprintf(p, end, "null"); + } + } + + p = safe_snprintf(p, end, "]\n}\n"); + *len = p-buf; + return buf; +} + char *generateAircraftJson(const char *url_path, int *len) { uint64_t now = mstime(); struct aircraft *a; diff --git a/net_io.h b/net_io.h index bf24f1c1e..dced807c0 100644 --- a/net_io.h +++ b/net_io.h @@ -95,6 +95,7 @@ char *generateAircraftJson(const char *url_path, int *len); char *generateStatsJson(const char *url_path, int *len); char *generateReceiverJson(const char *url_path, int *len); char *generateHistoryJson(const char *url_path, int *len); +char *generateRangeOutlineJson(const char *url_path, int *len); void writeJsonToFile(const char *file, char * (*generator) (const char *,int*)); #endif diff --git a/public_html/index.html b/public_html/index.html index ffc37386b..be821a575 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -131,6 +131,10 @@
Site Position and Range Rings
+
+
+
Range Outline
+
Selected Aircraft Trail
diff --git a/public_html/script.js b/public_html/script.js index 7217613ca..0f36daa32 100644 --- a/public_html/script.js +++ b/public_html/script.js @@ -7,6 +7,10 @@ var StaticFeatures = new ol.Collection(); var SiteCircleFeatures = new ol.Collection(); var PlaneIconFeatures = new ol.Collection(); var PlaneTrailFeatures = new ol.Collection(); +var RangeOutlineFeature = null; +var RangeOutlineLayer = null; +var ShowRangeOutline = false; +var RangeOutlineData = null; var Planes = {}; var PlanesOrdered = []; var PlaneFilter = {}; @@ -214,6 +218,11 @@ function fetchData() { $("#update_error").css('display','block'); }); } + + // Fetch range outline data along with aircraft data + if (ShowRangeOutline) { + fetchRangeOutline(); + } // Fetch UAT if enabled if (UAT_Enabled) { if (FetchPending_UAT !== null && FetchPending_UAT.state() == 'pending') { @@ -747,6 +756,9 @@ function end_load_history() { window.setInterval(fetchData, RefreshInterval); window.setInterval(reaper, 60000); + // Initialize range outline + initRangeOutline(); + // And kick off one refresh immediately. fetchData(); @@ -1100,6 +1112,19 @@ function initialize_map() { toggleLayer('#sitepos_checkbox', 'site_pos'); toggleLayer('#actrail_checkbox', 'ac_trail'); toggleLayer('#acpositions_checkbox', 'ac_positions'); + + // Set up range outline checkbox + if (ShowRangeOutline) { + $('#range_outline_checkbox').addClass('settingsCheckboxChecked'); + } + $('#range_outline_checkbox').on('click', function() { + toggleRangeOutline(); + if (ShowRangeOutline) { + $('#range_outline_checkbox').addClass('settingsCheckboxChecked'); + } else { + $('#range_outline_checkbox').removeClass('settingsCheckboxChecked'); + } + }); }); // Add home marker if requested @@ -1120,6 +1145,7 @@ function initialize_map() { StaticFeatures.push(feature); $('#range_ring_column').show(); + $('#range_outline_column').show(); setRangeRings(); @@ -2844,3 +2870,186 @@ function toggleTISBAircraft(switchFilter) { } localStorage.setItem('sourceTISBFilter', sourceTISBFilter); } + +// ============================================================================ +// Range Outline Functions +// ============================================================================ + +// Fetch range outline data from the server +function fetchRangeOutline() { + $.ajax({ + url: 'data/range_outline.json', + timeout: 5000, + cache: false, + dataType: 'json' + }).done(function(data) { + RangeOutlineData = data; + if (ShowRangeOutline && SitePosition) { + updateRangeOutline(); + } + }).fail(function(jqXHR, textStatus, errorThrown) { + // Silently fail - range outline is optional + }); +} + +// Convert range outline data to map polygon and update display +function updateRangeOutline() { + if (!RangeOutlineData || !SitePosition) { + return; + } + + if (!ShowRangeOutline) { + // Clear all features when hiding + if (RangeOutlineLayer) { + RangeOutlineLayer.getSource().clear(); + } + return; + } + + var ranges = RangeOutlineData.range_outline; + var timestamps = RangeOutlineData.range_outline_timestamps; + var altitudes = RangeOutlineData.range_outline_altitudes; + if (!ranges || ranges.length !== 360 || !timestamps || timestamps.length !== 360 || !altitudes || altitudes.length !== 360) { + return; + } + + // Build coordinates for each bearing + var points = []; + var hasData = false; + for (var bearing = 0; bearing < 360; bearing++) { + var range = ranges[bearing]; + var timestamp = timestamps[bearing]; + + // Backend sends 0 for ranges outside retention window + var isValid = range > 0 && timestamp > 0; + + if (isValid) { + hasData = true; + } + + // Convert bearing and range to lat/lon + var effectiveRange = isValid ? range : 1; + var point = destinationPoint(SitePosition[1], SitePosition[0], bearing, effectiveRange); + points.push({ + coord: ol.proj.fromLonLat([point.lon, point.lat]), + altitude: altitudes[bearing], + isValid: isValid + }); + } + + if (!hasData) { + if (RangeOutlineLayer) { + RangeOutlineLayer.getSource().clear(); + } + return; + } + + // Create or get the layer + if (!RangeOutlineLayer) { + var source = new ol.source.Vector(); + RangeOutlineLayer = new ol.layer.Vector({ + source: source, + zIndex: 99 // Below site circles but above base layers + }); + OLMap.addLayer(RangeOutlineLayer); + } + + // Clear existing features + RangeOutlineLayer.getSource().clear(); + + // Create 360 individual line segments with altitude-based gradient coloring + // Always draw all segments to create a complete circle + for (var bearing = 0; bearing < 360; bearing++) { + var nextBearing = (bearing + 1) % 360; // Wrap around at 359 + + var p1 = points[bearing]; + var p2 = points[nextBearing]; + + // Create a line segment from bearing to bearing+1 + var lineCoords = [p1.coord, p2.coord]; + var lineGeom = new ol.geom.LineString(lineCoords); + var lineFeature = new ol.Feature(lineGeom); + + // Calculate color based on altitude + // If we have valid altitudes, interpolate the color + // Otherwise use a default color + var color; + if (p1.isValid && p2.isValid && p1.altitude !== null && p2.altitude !== null) { + // Both points valid with altitude - use midpoint altitude for the segment color + var midAltitude = (p1.altitude + p2.altitude) / 2; + var colorArr = PlaneObject.prototype.getAltitudeColor(midAltitude); + color = PlaneObject.prototype.hslRepr(colorArr); + } else if (p1.isValid && p1.altitude !== null) { + // Only p1 has valid data and altitude + var colorArr = PlaneObject.prototype.getAltitudeColor(p1.altitude); + color = PlaneObject.prototype.hslRepr(colorArr); + } else if (p2.isValid && p2.altitude !== null) { + // Only p2 has valid data and altitude + var colorArr = PlaneObject.prototype.getAltitudeColor(p2.altitude); + color = PlaneObject.prototype.hslRepr(colorArr); + } else { + // No altitude data, use default color + color = 'rgba(0, 128, 255, 0.8)'; + } + + lineFeature.setStyle(new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: color, + width: 2 + }) + })); + + RangeOutlineLayer.getSource().addFeature(lineFeature); + } + + // Ensure layer is visible when ShowRangeOutline is true + RangeOutlineLayer.setVisible(true); +} + +// Calculate destination point given start point, bearing and distance +// Uses Haversine formula +function destinationPoint(lat, lon, bearing, distance) { + var R = 6371000; // Earth radius in meters + var bearingRad = bearing * Math.PI / 180; + var latRad = lat * Math.PI / 180; + var lonRad = lon * Math.PI / 180; + + var latRad2 = Math.asin(Math.sin(latRad) * Math.cos(distance / R) + + Math.cos(latRad) * Math.sin(distance / R) * Math.cos(bearingRad)); + + var lonRad2 = lonRad + Math.atan2(Math.sin(bearingRad) * Math.sin(distance / R) * Math.cos(latRad), + Math.cos(distance / R) - Math.sin(latRad) * Math.sin(latRad2)); + + return { + lat: latRad2 * 180 / Math.PI, + lon: lonRad2 * 180 / Math.PI + }; +} + +// Toggle range outline visibility +function toggleRangeOutline() { + ShowRangeOutline = !ShowRangeOutline; + localStorage.setItem('ShowRangeOutline', ShowRangeOutline); + + if (ShowRangeOutline) { + // Fetch data which will call updateRangeOutline when done + fetchRangeOutline(); + } else { + // Hide and clear the layer + if (RangeOutlineLayer) { + RangeOutlineLayer.setVisible(false); + RangeOutlineLayer.getSource().clear(); + } + } +} + +// Initialize range outline from localStorage +function initRangeOutline() { + var saved = localStorage.getItem('ShowRangeOutline'); + if (saved === 'true') { + ShowRangeOutline = true; + fetchRangeOutline(); + // Update checkbox to match loaded state + $('#range_outline_checkbox').addClass('settingsCheckboxChecked'); + } +} diff --git a/track.c b/track.c index ac9590d3d..ef380f195 100644 --- a/track.c +++ b/track.c @@ -270,6 +270,35 @@ static void update_range_histogram(double lat, double lon) } } +static void update_range_outline(struct aircraft *a, double lat, double lon) +{ + if (Modes.bUserFlags & MODES_USER_LATLON_VALID) { + double range = greatcircle(Modes.fUserLat, Modes.fUserLon, lat, lon); + double bearing = get_bearing(Modes.fUserLat, Modes.fUserLon, lat, lon); + + // Round bearing to nearest integer degree (0-359) + int bearing_idx = ((int)round(bearing)) % 360; + + uint64_t now = messageNow(); + + // Get altitude - prefer barometric, fall back to geometric if available + int altitude = INVALID_ALTITUDE; + if (trackDataValid(&a->altitude_baro_valid)) { + altitude = a->altitude_baro; + } else if (trackDataValid(&a->altitude_geom_valid)) { + altitude = a->altitude_geom; + } + + // Update if this is a new maximum for this bearing, or if we don't have data yet + if (range > Modes.range_outline_max[bearing_idx] || + Modes.range_outline_updated[bearing_idx] == 0) { + Modes.range_outline_max[bearing_idx] = range; + Modes.range_outline_updated[bearing_idx] = now; + Modes.range_outline_altitude[bearing_idx] = altitude; + } + } +} + // return true if it's OK for the aircraft to have travelled from its last known position // to a new position at (lat,lon,surface) at a time of now. static int speed_check(struct aircraft *a, double lat, double lon, int surface) @@ -603,6 +632,7 @@ static void updatePosition(struct aircraft *a, struct modesMessage *mm) a->pos_rc = new_rc; update_range_histogram(new_lat, new_lon); + update_range_outline(a, new_lat, new_lon); } } @@ -1444,6 +1474,21 @@ static void trackRemoveStaleAircraft(uint64_t now) } +// +// Expire old range outline data +// +static void expireRangeOutline(uint64_t now) +{ + for (int i = 0; i < RANGE_OUTLINE_DEGREES; i++) { + if (Modes.range_outline_updated[i] != 0 && + (now - Modes.range_outline_updated[i]) > Modes.range_outline_retention_ms) { + // Expire this bearing - reset to zero + Modes.range_outline_max[i] = 0; + Modes.range_outline_updated[i] = 0; + } + } +} + // // Entry point for periodic updates // @@ -1458,5 +1503,6 @@ void trackPeriodicUpdate() next_update = now + 1000; trackRemoveStaleAircraft(now); trackMatchAC(now); + expireRangeOutline(now); } }