Airplane Scatter (also known as Aircraft Scatter) is a radio propagation phenomenon in which a passing aircraft reflects or scatters FM radio signals between a transmitter and a receiver that would otherwise not be in direct line of sight of each other. The effect is real and well documented in the DX community.
When a large commercial aircraft — typically at cruise altitude between 8,000 m and 12,000 m — passes near the geometric midpoint of the great-circle path between a transmitting antenna and a receiving antenna, it can act as a mirror. The signal is scattered off the fuselage, wings, and engines and redirected toward the receiver. The scatter event is short-lived (typically 10–60 seconds) but can produce signals strong enough to decode RDS data or hear audio from a distant station.
Key factors that determine whether a scatter event will be detectable:
Without prediction tools, a DX operator has no way of knowing in advance that a scatter opportunity is about to occur. The window is narrow — typically under one minute — and the aircraft moves continuously. By the time a DX signal is noticed and identified, the aircraft may have already moved away from the optimal reflection geometry.
airplanescatter_server.js) which relies on the Node.js Plugin API introduced in FM-DX-Webserver v1.4.0. Version 2.2 adds optional integration of the fmscan.org userlist1.csv file which provides transmitter beam directions and supplementary RDS metadata (PI code, PS name, programme name, polarisation), enhancing both the scoring accuracy and the information displayed in the TX detail panel. It also fixes two bugs present in v2.1a: incorrect compass filtering for the E and NE sectors, and a WebSocket connection counter discrepancy caused by the plugin registering its own connections.
Imagine you are trying to shine a laser pointer at a mirror to hit a distant target. If the mirror (the aircraft) is positioned at exactly the right angle between the laser source (the transmitter) and the target (your receiver), the beam bounces directly to you. The plugin computes this geometry for every aircraft currently in the sky, for every known FM transmitter in a 750 km radius, and tells you which combinations are close to the ideal reflection angle — before they occur.
For a scatter event to occur, three points must be approximately collinear in a specific angular relationship: the transmitter, the aircraft, and the receiver. The aircraft should be positioned roughly on the great-circle path between transmitter and receiver, at a point where the angle of incidence (TX→AC) approximately equals the angle of reflection (AC→RX).
The plugin computes the great-circle intersection point — the point on the TX–RX path closest to the aircraft's current track — and evaluates the reflection quality based on cross-track distance, reflection angle, aircraft altitude, transmitter ERP, and aircraft fuselage alignment. From v2.2 onward, the transmitter's beam direction (if known from the userlist) is also factored in as a hard gate. The result is a single scatter score from 0–100%.
The scatter score is a weighted composite of physical factors. The score is designed so that aircraft flying in the Sweet Spot — just barely above both the TX and RX horizon envelopes with the flattest possible elevation angle — receive the highest scores. This directly reflects the physical reality of airplane scatter: the most efficient scatter occurs when the signal grazes the aircraft at the lowest possible elevation angle from both ends simultaneously.
| Factor | Max points | Description |
|---|---|---|
| Sweet Spot (elevation angles) | 40 | Gaussian reward for flat elevation angles from both TX and RX simultaneously. TX ideal: ~0° (grazing horizon), RX ideal: ~0° (just above horizon). Score drops rapidly as elevation angle increases. |
| Horizon margin | 20 | Extra reward for flying just barely above both horizon envelopes (inside the purple zone but close to its floor). Exponential decay with σ ≈ 4000 m above the envelope. |
| Cross-track distance | 20 | How close the aircraft is to the great-circle path. Gaussian decay with σ = 25 km. |
| Reflection geometry | 10 | How close the TX→AC→RX angle is to the 40° optimum. Gaussian decay with σ = 25°. |
| Fuselage alignment | 5 | Whether the aircraft's longitudinal axis is perpendicular to the bisector of the TX–AC–RX angle. |
| Transmitter ERP | 3 | Logarithmic scale: a 500 kW transmitter scores higher than a 100 kW transmitter. |
| Frequency | 2 | Lower FM frequencies (87.5 MHz) scatter slightly better than higher ones (108 MHz). |
The raw score is then multiplied by an aircraft size multiplier based on the ADS-B category code, and additionally by the beam multiplier from the TX antenna beam direction (new in v2.2):
| Category | Description | Multiplier |
|---|---|---|
| A5 | Super Heavy (A380, B747-8) | 1.30× |
| A4 | Heavy jet (B777, A330, B744) | 1.15× |
| A3 | Large jet (B737, A320) – default | 1.00× |
| A6 | High-performance aircraft | 0.90× |
| A2 | Small aircraft | 0.85× |
| A1 | Light aircraft | 0.70× |
| B1–B4 | Rotorcraft | 0.60–0.80× |
The ETA (Estimated Time of Arrival at the crossing point) is displayed as a countdown timer in the format −M:SS (approaching) or +M:SS (receding). When the aircraft is within 5 seconds of the optimal crossing point, the display switches to NOW ✓ in green.
The TX database originates from maps.fmdx.org/api/ and contains all known FM transmitters worldwide. This database is loaded and managed entirely on the server side by airplanescatter_server.js. Only the pre-filtered result (typically under 200 KB) is sent to the browser, instead of the full 50 MB raw file.
Each transmitter entry provides:
Starting with v2.2, supplementary data (beam direction, RDS PI code, PS name, programme name, language) can optionally be loaded from the userlist1.csv file obtained from fmscan.org (see §5.18 and §7.2).
Live aircraft positions are fetched every 15 seconds from ADS-B REST APIs. The plugin supports three public API endpoints with automatic failover (see §5.10). Each aircraft record provides:
Aircraft on the ground (alt_baro = "ground") or with a speed below 50 knots or an altitude below 1000 ft are silently excluded from all scatter calculations.
When a transmitter is selected from the list or map, the plugin renders an interactive terrain elevation profile along the great-circle path from the receiver to the transmitter. The profile shows:
The profile is fully interactive: mouse-wheel to zoom horizontally, click-drag to pan, a vertical slider to scale the altitude axis, and a hover tooltip that displays terrain height, elevation angles from both ends, and aircraft elevation angles at the cursor position.
The Sweet Spot is displayed as a semi-transparent green band in the elevation profile. This is the single most important visual indicator for understanding whether a scatter event is likely to be strong and audible.
For airplane scatter to work optimally, the aircraft must simultaneously satisfy two angular constraints — one seen from the receiver side and one seen from the transmitter side:
The Sweet Spot is the altitude band along the TX–RX path where both conditions are satisfied at the same time. An aircraft flying through this green corridor is in the ideal geometric position to produce a strong, detectable scatter signal.
| Element | Meaning |
|---|---|
| Candidate list (left panel) | All currently active scatter candidates sorted by |ETA|. Each row shows callsign, target TX city with country flag, scatter score (colour-coded), and ETA countdown. |
| TX detail panel | Opens when a candidate or TX dot is clicked. Shows all frequencies at that transmitter site with clickable tune buttons, bearing and distance from RX, terrain height, and list of crossing aircraft. In v2.2, also shows beam direction, RDS PI code, PS name, programme name and language if the userlist1.csv is available. Includes Play/Stop icons for live audio streaming. |
| Clickable Azimuth | Clicking the Azimuth degree in the TX detail panel automatically turns your PST Rotator antenna to that station (requires Admin/Tune permissions). |
| Tune button | Clicking a frequency in the TX detail panel sends a tune command (T{freq_kHz}) over the main FM-DX-Webserver WebSocket, instantly changing the receiver frequency. |
| Audio Stream Play/Stop | Clicking the play icon next to a frequency will fetch and start the live audio webstream for that station directly inside your browser. |
| Aircraft icon | Plane symbol rotated to match heading. Colour follows score scale. Turns bright green when ETA is within ±5 seconds (NOW ✓). |
| ETA label | Countdown rendered next to each aircraft icon. Updates every second. Blue = approaching, green = NOW ✓, red = past. |
| TX dots on map | Coloured circles sized by ERP. The connecting dashed line from TX to RX is drawn for each active scatter candidate. Both dot and line turn green when the associated aircraft is at NOW ✓. |
| Receiver dot | Blue circle at receiver QTH. Tooltip shows coordinates, terrain elevation, and antenna AGL height. |
| Compass filter | Nine-button directional filter. Supports Multi-Select (click multiple directions simultaneously). Click ✕ to clear. E/NE sector boundary bug fixed in v2.2. |
| Rotor Sync Lock | Click the 🔓/🔒 icon next to the compass. When locked, the compass filter automatically updates to track your physical PST Rotator azimuth. |
| Frequency filter | Type a frequency to restrict the map and candidate list to TX stations on that exact frequency. Also tunes the receiver. ✕ clears the filter. |
| Frequency Sync Lock | When locked, the map automatically filters to whatever frequency your FM-DX-Webserver receiver is currently tuned to. |
| Elevation profile | Shown below the map when a TX is selected. Interactive terrain cross-section with LOS ceilings, purple scatter zone, green Sweet Spot corridor, and aircraft dots. Mouse wheel = horizontal zoom, drag = pan, right slider = vertical zoom. |
| Profile hover tooltip | Moving the mouse over the elevation profile canvas shows a floating tooltip with: distance from RX and TX at cursor, terrain height at cursor, elevation angle from RX to terrain (red), elevation angle from TX to terrain (yellow), and if an aircraft is within ±30 km of the cursor, its individual elevation angles from both RX and TX. |
| Status bar | Aircraft count (total tracked), active candidate count (passing filter), TX database size, last update time or error message. |
| ⚙ Settings button | Opens the settings panel (see §7). |
| ↺ Reload button | Clears the TX database cache and forces a fresh download and a new ADS-B fetch. |
The core scoring function calcScatter(ac, rxLat, rxLon, rxElevM, tx) is called for every (aircraft, transmitter) pair that passes the spatial pre-filter. It performs three steps: compute the ETA to the crossing point, evaluate the score at a series of forecast positions, and return the best score found.
const crossPt = gcIntersectionPoint(txLat,txLon,rxLat,rxLon,ac.lat,ac.lon,ac.track); const speedKmS = (ac.speed * 1.852) / 3600; const distToCross = haversineKm(ac.lat,ac.lon,crossPt.lat,crossPt.lon); let etaSec = distToCross / speedKmS; // Negate if aircraft is flying away from crossing point const brgToCross = bearingDeg(ac.lat,ac.lon,crossPt.lat,crossPt.lon); if (Math.abs(normalizeAngle180(ac.track - brgToCross)) > 90) etaSec = -etaSec;
The aircraft position is dead-reckoned to 5 forecast time steps: 0, 60, 120, 180, 300 seconds. For each step the inner evaluation function _evalScatterAt() is called and the best score is retained. An early-exit stops the loop as soon as a score of 95 or higher is found.
Before any score is computed, two hard gates are applied in sequence. First, the beam gate (new in v2.2): if userlist data is available for this TX, the bearing to the aircraft and the bearing to the RX are both checked against the antenna beam arc. If either lies outside the beam, the candidate is rejected immediately without computing the terrain envelope. Second, the terrain-aware horizon check using the cached TX envelope:
// ── Beam hard gate (v2.2) ───────────────────────────────────────── const beamMultAc = getTxBeamScore(tx, acLat, acLon); if (beamMultAc === 0.0) return null; // aircraft outside beam → hard reject const beamMultRx = getTxBeamScore(tx, rxLat, rxLon); if (beamMultRx === 0.0) return null; // RX outside beam → hard reject // Combined beam multiplier applied to final score const beamMult = beamMultAc * beamMultRx; // ── Terrain-aware horizon check (envelope cache) ────────────────── const env = getTxEnvelope(tx, rxLat, rxLon, rxElevM); if (altM < hrxAtAc || altM < htxAtAc) return null;
let baseScore = sweetSpotScore * 40 + marginScore * 20 + distScore * 20 + reflScore * 10 + fuseScore * 5 + erpScoreVal * 3 + (freqFactor - 0.97) / 0.06 * 2; // beamMult: product of aircraft-side and RX-side beam scores (v2.2) const finalScore = baseScore * sizeMult * beamMult; return { score: Math.max(0, Math.min(100, Math.round(finalScore))), crossTrackKm };
const nTxRx = normalize(cross(vTx, vRx)); const nAc = normalize(cross(vAcPos, vAcAhead)); const inter = normalize(cross(nTxRx, nAc)); // Two antipodal solutions – pick closest to aircraft const best = haversineKm(acLat,acLon,p1.lat,p1.lon) < haversineKm(acLat,acLon,p2.lat,p2.lon) ? p1 : p2; // Safety: if outside TX–RX segment, fall back to midpoint if (haversineKm(best,txLat,txLon) > d_txrx || haversineKm(best,rxLat,rxLon) > d_txrx) return midpointGreatCircle(txLat,txLon,rxLat,rxLon);
const d13 = haversineKm(aLat,aLon,pLat,pLon); const angleRad = toRad(normalizeAngle180(t13 - t12)); crossTrackKm = Math.abs(d13 * Math.sin(angleRad)); alongTrackKm = d13 * Math.cos(angleRad);
const optimalTrack = ((bTx + normalizeAngle180(bRx - bTx) / 2 + 360) % 360 + 90) % 360; let trackDiff = Math.abs(normalizeAngle180(acTrackDeg - optimalTrack)); if (trackDiff > 90) trackDiff = 180 - trackDiff; return 0.3 + 0.7 * Math.max(0, Math.cos(toRad(trackDiff)));
const angleDeg = toDeg(Math.acos( Math.max(-1, Math.min(1, dot(i, o))))); const diff = angleDeg - 40; reflScore = Math.exp(-(diff * diff) / (2 * 25 * 25));
Airplane scatter can only occur when neither the transmitter nor the receiver can "see" the other directly, but both can "see" the aircraft. The radio horizon formula uses the standard 4/3-earth approximation:
function radioHorizonKm(h1m, h2m) { return 4.12 * (Math.sqrt(Math.max(0, h1m)) + Math.sqrt(Math.max(0, h2m))); }
The dynamic ellipse gate grows slightly with altitude:
const dynamicEllipseFactor = 1.02 + Math.min(1.0, altM / 12000) * 0.06; if (d_tx + d_rx > dynamicEllipseFactor * d_txrx) return null;
Each (aircraft, transmitter) pair that reaches the score threshold is stored in the two-level dictionary _persistentCrossings[icao24][txKey]. This persistence layer ensures that a candidate first detected at −3 minutes remains visible throughout its approach, peak, and recession.
Between 15-second ADS-B updates the plugin uses dead reckoning to estimate each aircraft's current position:
function deadReckonRad(lat, lon, trackDeg, distKm) { const d = distKm / 6371; const f = toRad(lat), l = toRad(lon), t = toRad(trackDeg); const lat2 = Math.asin(Math.sin(f)*Math.cos(d) + Math.cos(f)*Math.sin(d)*Math.cos(t)); const lon2 = l + Math.atan2( Math.sin(t)*Math.sin(d)*Math.cos(f), Math.cos(d)-Math.sin(f)*Math.sin(lat2)); return { lat: toDeg(lat2), lon: toDeg(lon2) }; }
Altitude is also extrapolated using the reported vertical speed:
const fAltFt = ac.alt_ft + ((ac.vspeed || 0) / 60) * dtSec; if (fAltFt < 1000) continue;
Terrain elevation data is fetched from two APIs:
| API | Usage | Resolution |
|---|---|---|
opentopodata.org/v1/srtm90m | Batch profile points (up to 100 per request), TX site elevations | SRTM 90 m |
open-elevation.com/api/v1/lookup | Fallback for individual points if batch API fails | SRTM 30 m |
The profile is built from 100 equidistant great-circle sample points. Two running envelopes are computed — one from RX and one from TX — representing the minimum flight altitude to be simultaneously visible from both ends.
The purple scatter zone floor at each profile point is max(hrx_arr[i], htx_arr[i]) — the aircraft must be above this to be visible from both ends.
The Sweet Spot corridor is computed by the function computeSweetSpotCorridor(elevs, d_txrx, rxAltM, txAltM, hrx_arr, htx_arr). It adds two angular ceiling constraints on top of the existing purple zone floor.
const RX_ANGLE_DEG = 5.0; // aircraft must be ≤ 5° elevation from RX const TX_ANGLE_DEG = 1.0; // aircraft must be ≤ 1° elevation from TX // Ceiling imposed by the RX elevation angle limit const rxAngleCeil = rxAltM + x * 1000 * tanRx; // Ceiling imposed by the TX elevation angle limit const txAngleCeil = txAltM + d_tx * 1000 * tanTx; // Sweet spot ceiling = the lower of the two limits const sweetCeil = Math.min(rxAngleCeil, txAngleCeil);
The function initProfileCanvasHover() attaches a mousemove listener to the profile canvas. When the cursor is inside the plot area, it creates a floating tooltip showing detailed information for the path point directly below the cursor.
| Field | Description |
|---|---|
| Distance (RX) | Cursor distance from the receiver along the path (km or miles) |
| Distance (TX) | Remaining distance to the transmitter |
| Terrain | Interpolated terrain elevation at cursor position |
| El. angle (RX) | Elevation angle in degrees from the RX antenna to the terrain point at cursor. Displayed in red. |
| El. angle (TX) | Same from the TX antenna side. Displayed in yellow. |
| Per-aircraft El. (RX) / (TX) | If a crossing aircraft is within ±30 km of the cursor, its own elevation angles from RX and TX are shown. |
The plugin builds a 10° × 10° spatial grid index over the loaded TX database. For each aircraft, only transmitters in the 9 grid cells surrounding the aircraft position are checked.
Each TX envelope is computed once per RX position and stored in a Map keyed by tx.lat_tx.lon_tx.freq. All subsequent evaluations for the same TX reuse the cached result. The cache is invalidated when the RX elevation is refreshed, when a new TX dataset is loaded, after elevation enrichment completes, or when the user applies new settings.
Yield interval: 8 ms trigger, 0 ms sleep (pure event-loop yield via setTimeout(r, 0)). The zero-delay return is sufficient because the envelope cache eliminates most of the computation that previously caused long synchronous stretches.
The plugin uses automatic round-robin failover across three public ADS-B aggregators:
api.adsb.oneapi.adsb.lolapi.adsb.fiLarge radius requests are broken into hexagonal overlapping tile queries because the APIs have a strict maximum radius of 250 Nautical Miles (≈463 km). An explicit 500 ms delay between tile points prevents API rate-limiting.
The full 50 MB fmdx.org dataset is managed entirely on the server with a four-tier priority cascade:
The plugin integrates with the FM-DX-Webserver ecosystem by listening to two standard WebSockets:
/data_plugins: Broadcasts GPS updates and PST Rotator azimuth. When the rotor turns, the compass lock (if activated) follows automatically./text: Broadcasts RDS data and frequency changes. When the Frequency Sync Lock is active, the map filters automatically to the current receiver frequency.Country flags are generated dynamically. The plugin loads a JavaScript array from tef.noobish.eu/logos/scripts/js/countryList.js mapping ITU codes to ISO 2-letter country codes, then generates HTML <img> tags pointing to flagcdn.com. The lookup is cached in localStorage for 24 hours.
A3+ hides Cessnas and helicopters, reducing clutter.On startup, the plugin fetches its own source file from GitHub and compares the pluginVersion constant. If a newer version is found, a red notification dot appears on the plugin icon and a link is injected into the settings panel.
The TX detail panel includes Play/Stop icons next to each frequency. Clicking Play calls window._asHandleStreamClick() which queries api.fmlist.org/152/fmdxGetStreamById.php (via the local proxy) for stream URLs, selects the highest bitrate, and plays it via a hidden HTML5 <audio> element.
The airplanescatter_server.js backend attaches to the FM-DX-Webserver HTTP server and exposes two endpoints:
/api/airplanescatter/fmdx – returns the server-filtered TX station list/api/airplanescatter/proxy?url=... – generic HTTPS proxy for ADS-B, elevation, and stream APIsKey security mechanisms: Caller Guard, Domain Whitelist (only proxies to allowed domains), 10 MB response size cap, and sealResponse() to prevent "headers already sent" conflicts.
Version 2.2 introduces optional integration of the userlist1.csv file generated by the fmscan.org FMSCAN Userlist service. This file contains supplementary per-transmitter data that is not available in the standard fmdx.org database, most importantly the main transmitter beam direction and additional RDS metadata.
The fmscan.org service (login required at fmscan.org) allows registered users to generate a personalised CSV file based on their receiver location. The file contains all FM transmitters that are theoretically receivable from the configured QTH, with calculated distance, azimuth, and signal level (dB) for each. By selecting FM+ (Tropo) as the mode and CSV format with semicolon as the separator, and then downloading userlist1.csv, the user gets a file with the following column layout:
| Col # | Field name | Description |
|---|---|---|
| 1 | khz | Frequency in kHz (e.g. 98000 = 98.000 MHz) |
| 2 | itu | ITU country code (e.g. D, F, I, SUI) |
| 3 | lang | Language code |
| 4 | program | Programme name / station name |
| 5 | mod | Modulation (p = FM stereo, m = mono, u = unknown) |
| 6 | city | Transmitter site city or location name |
| 7 | lat | Transmitter latitude (decimal degrees) |
| 8 | lon | Transmitter longitude (decimal degrees) |
| 9 | erp_to_rx | ERP towards receiver direction (kW) |
| 10 | erp_max | Maximum ERP (kW) |
| 11 | beam | Main transmitter beam direction (degrees or range, e.g. "270" or "270-40") |
| 12 | dist | Distance from receiver (km) |
| 13 | azimuth | Azimuth from receiver to transmitter (degrees) |
| 14 | db | Estimated signal level (dBµV/m) |
| 15 | ps | RDS PS (Programme Service) name |
| 16 | pi | RDS PI (Programme Identification) code |
| 17 | pol | Polarisation (H = horizontal, V = vertical, M = mixed) |
| 18 | id | Unique transmitter ID (used as primary key) |
userlist1.csv from https://fmscan.org/perseus.php?userlistmode=fm…/fm-dx-webserver-main/plugins/AirplaneScatter/userlist1.csvThe file is loaded once at startup by the asynchronous function loadUserlist() and stored in the in-memory dictionary _userlistDb, keyed by the unique transmitter ID (last column). If the file is not present, the plugin continues to work normally using only the fmdx.org data — all userlist features simply remain inactive.
Once loaded, the userlist data enriches the TX station objects in three ways:
enrichTxBeamData()): For each TX station loaded from fmdx.org, the function searches for a matching entry in the userlist (matched by frequency + coordinates within 0.01°). If a beam value is found, it is copied to the TX object's beam property. This enables the beam scoring gate in getTxBeamScore().getTxBeamScore()): The beam string is parsed and the bearing to the aircraft and the bearing to the RX are tested against the antenna arc. Non-directional transmitters (no beam data) are treated as omnidirectional and pass with a multiplier of 1.0.The function getUserlistEntry(tx) first tries to find a match by the numeric transmitter ID. If no ID is stored on the TX object (fmdx.org entries do not carry fmscan IDs), it falls back to a frequency + coordinate search:
function getUserlistEntry(tx) { if (!tx) return null; // 1. Try direct ID lookup if (tx.id && _userlistDb[String(tx.id)]) return _userlistDb[String(tx.id)]; // 2. Fallback: frequency + coordinate match const freqKhz = Math.round(tx.freq * 1000); return Object.values(_userlistDb).find(e => parseInt(e.khz, 10) === freqKhz && Math.abs(parseFloat(e.lat) - parseFloat(tx.lat)) < 0.01 && Math.abs(parseFloat(e.lon) - parseFloat(tx.lon)) < 0.01 ) || null; }
In v2.2 the TX beam direction is a fully functional hard gate and score multiplier, powered by the userlist1.csv data. The function getTxBeamScore(tx, targetLat, targetLon) returns a value between 0.0 (hard reject) and 1.0 (full score) for a given target direction from the transmitter.
The userlist beam field (beam) can contain two formats:
| Format | Example | Meaning |
|---|---|---|
| Single heading | 270 | Directional antenna aimed at 270° (West). Score falls off from centre with ±30° half-beamwidth; hard reject beyond 40° off-axis. |
| Multiple headings | 90 270 | Bi-directional or multi-directional antenna. Score computed against the nearest heading. |
| Range (arc) | 270-40 | Sector antenna covering an arc from 270° to 40° (crossing 0°/North). Score is 1.0 inside the arc; hard reject beyond 3° outside the arc. |
function getTxBeamScore(tx, targetLat, targetLon) { // Look up beam string: first from this tx, then from siblings let beamStr = (tx.beam || '').trim(); if (!beamStr) { const sibs = txSiblings(tx); for (const sib of sibs) { const sibBeam = (sib.beam || '').trim(); if (sibBeam) { beamStr = sibBeam; break; } } } if (!beamStr) return 1.0; // omnidirectional – no data const brg = bearingDeg(tx.lat, tx.lon, targetLat, targetLon); const isRange = /^\d+\s*-\s*\d+$/.test(beamStr); if (isRange) { // Range beam: e.g. "270-40" means arc from 270° to 40° // Returns 1.0 inside arc, linear fade over 3° outside, 0.0 beyond const inRange = /* ... wrapping check ... */; if (inRange) return 1.0; if (outsideDeg >= 3) return 0.0; return 1.0 - (outsideDeg / 3); } else { // Single / multi heading: ±30° half-beamwidth, hard reject at 40° const outsideDeg = Math.max(0, minDist - 30); if (outsideDeg >= 10) return 0.0; return 1.0 - (outsideDeg / 10); } }
The function is called twice per (aircraft, TX) pair inside _evalScatterAt():
The two individual beam scores are then multiplied together and applied to the final score:
const finalScore = baseScore * sizeMult * beamMult; // beamMult = beamMultAc × beamMultRx (both must be > 0)
Transmitters for which no beam data is available (either not in the userlist, or beam field is empty) are treated as omnidirectional — the gate always passes and the multiplier is 1.0. This means that the userlist1.csv only ever reduces scores or hard-rejects candidates; it never increases them above the base value.
The fmdx.org database lists each frequency at a transmitter site as a separate entry. When beam data is looked up for a given TX entry and the entry itself has no beam field, the function also searches all co-located entries at the same geographic position (same latitude/longitude within 0.001°) — the "siblings" — and uses the first beam value found. This is important because a transmitter site may have beam data attached only to one of its frequency entries in the userlist.
In versions 2.1 and 2.1a the compass filter incorrectly classified bearings in the E and NE sectors. The sector boundary angles for E and NE were swapped relative to the standard compass rose definition. This caused stations that were in fact in the NE direction to be shown when the E button was pressed, and vice versa.
The corrected sector definitions in v2.2 are:
| Direction | Bearing range (corrected) |
|---|---|
| N | 337.5° – 360° and 0° – 22.5° (wrapping) |
| NE | 22.5° – 67.5° |
| E | 67.5° – 112.5° |
| SE | 112.5° – 157.5° |
| S | 157.5° – 202.5° |
| SW | 202.5° – 247.5° |
| W | 247.5° – 292.5° |
| NW | 292.5° – 337.5° |
This fix also corrects the rotor auto-sync behaviour: when the rotor lock is active and the antenna points between 22.5° and 112.5°, the correct NE and/or E sectors are now highlighted.
In previous versions the plugin opened its own WebSocket connections to the FM-DX-Webserver data feeds (/data_plugins and /text) which were incorrectly counted by the server as client connections. This inflated the displayed connection count in the FM-DX-Webserver admin interface and could trigger connection limits.
In v2.2 the plugin's internal WebSocket connections are properly marked so that the FM-DX-Webserver excludes them from the public connection counter. No configuration change is required; the fix is applied automatically.
The plugin contains several hardcoded parameters at the top of airplanescatter.js that determine the physical and visual bounds of the simulation.
// Forecast steps in seconds – 5 steps, 5-minute look-ahead const FORECAST_STEPS_SEC = [0, 60, 120, 180, 300]; // Maximum time before missing aircraft is discarded const AIRCRAFT_TIMEOUT_MS = 180000; // 3 minutes // ADS-B fetch interval const AIRCRAFT_UPDATE_MS = 15000; // 15 seconds // Visual countdown ticker interval const COUNTDOWN_TICK_MS = 1000; // 1 second // TX database cache duration const DB_CACHE_HOURS = 24; // Score colour thresholds const SCORE_EXCELLENT = 80; const SCORE_HIGH = 60; const SCORE_MEDIUM = 40; // TX antenna height above terrain (default if not in DB) const TX_HEIGHT_DEFAULT_M = 150; // ADS-B pre-filter radius const AC_PREFILTER_KM = 1200; const AC_PREFILTER_LATDEG = 11.0;
The settings panel (⚙ button) exposes the following parameters. All values are saved to localStorage and persist across browser sessions.
| Setting | Default | Description |
|---|---|---|
| Min TX–RX Distance | 400 km | Minimum great-circle distance between transmitter and receiver. Paths shorter than this are skipped (direct LOS likely). |
| Min TX ERP | 100 kW | Minimum transmitter effective radiated power. Weaker stations are excluded from the database query. |
| TX Search Radius | 750 km | Maximum distance from the receiver within which transmitters are loaded from the database. |
| Aircraft Search Radius | 750 km | Maximum distance from the receiver within which ADS-B aircraft are fetched. |
| Min Score | 70 | Scatter candidates below this score threshold are silently discarded. |
| RX Antenna AGL | 10 m | Height of the receiver antenna above ground level. Used in LOS envelope calculations. |
| Unit System | Metric | Switches all distance and altitude displays between metric (km / m) and imperial (mi / ft / kts). |
| Lead Time | 3:00 | How far in advance (before the crossing point) a candidate appears in the list. |
| Trail Time | 1:00 | How long after the crossing point a candidate remains in the list. |
| Frequency Filter Mode | None | Activates blacklist or whitelist filtering from server-side text files (see §7.1). |
| Aircraft Category Filter | All | Minimum ADS-B category to consider. E.g. A3 = only large jets and heavier; A1 = all aircraft. |
invalidateTxEnvelopeCache()) to ensure that any changed parameters (e.g. RX AGL height) are correctly reflected in the next calculation cycle.
Place plain text files in the plugins/AirplaneScatter/ directory on the server:
blacklist.txt – frequencies to exclude (one per line, e.g. 98.00)whitelist.txt – frequencies to include exclusively (all others hidden)Lines starting with # are treated as comments and ignored. Both comma and period are accepted as decimal separators. Only values in the FM broadcast range (87.5–108.0 MHz) are accepted.
The userlist1.csv file provides beam directions and supplementary RDS data for transmitters in the vicinity of your receiver. It is generated specifically for your receiver location by fmscan.org and must be placed in the plugin directory.
…/fm-dx-webserver-main/plugins/AirplaneScatter/userlist1.csvuserlist1.csv is missing from the plugin directory, the plugin operates normally without beam filtering and without the additional RDS metadata. The file is entirely optional and does not affect the basic airplane scatter detection functionality.
| Plugin | Benefit with Airplane Scatter |
|---|---|
| DSP-Stereo-Spectrum | Real-time FFT spectrum display. During a scatter event you can visually confirm the incoming signal before it is decodable. |
| PST Rotator | Enables the Azimuth click-to-turn feature and the Compass Rotor Sync Lock in the scatter map. |
| RDS-Logger | Logs all RDS PI codes and station names received. Useful for post-event analysis of which scatter events actually produced a decodable signal. The PI codes visible in the TX detail panel (from userlist1.csv) can be cross-referenced directly. |
| GPS-Logger | Provides live GPS coordinates to the webserver. The Airplane Scatter plugin reads these automatically and updates the RX QTH in real time during mobile operation. |
| AI-Denoise | For equalising and denoising audio signals during scatter reception. Scatter events often produce short, flutter-distorted audio that benefits from AI-based noise reduction. |
| RDS-AI-Decoder | For decoding RDS as quickly as possible during short-term scatter receptions. Accelerates PI/PS identification within the narrow scatter window. |
userlist1.csv from https://fmscan.org/perseus.php?userlistmode=fm (select CSV format with semicolon separator) and place the file in the plugins/AirplaneScatter/ directory on the server. The plugin will then use the beam direction data to suppress off-beam transmitters and display additional station metadata (PI code, PS name, RDS programme name) in the TX detail panel. This can significantly reduce false positives from highly directional transmitters.Feature release adding optional beam-aware TX scoring via a personalised fmlist.org userlist, additional station metadata display, and two bug fixes.
userlist1.csv from the
plugins/AirplaneScatter/ directory on the server. This file is a
personalised transmitter database generated by the fmlist.org service
(registration required) and can be downloaded from
https://fmscan.org/perseus.php?userlistmode=fm
by selecting CSV format with semicolon separator and clicking
DOWNLOAD userlist1.csv.
khz ; country ; language ; program ; modulation ; location ; latitude ; longitude ; power_to_rx ; max_erp ; beam ; distance ; azimuth ; dB ; RDS_PS ; RDS_PI ; polarisation ; ID200), a range (e.g. 270-40), or is empty for
omnidirectional transmitters. The ID field (last column) is the unique
fmlist station identifier used to match entries to the fmdx.org TX database.
userlist1.csv is present and successfully loaded, the plugin:
getTxBeamScore(): any TX whose beam
does not cover both the aircraft position and the RX direction receives a score multiplier
of 0.0 and is hard-rejected from the candidate list.computePersistentCrossings()
before the expensive full scatter calculation, eliminating clearly off-beam TX stations
with two fast angular comparisons.270-40, meaning 270° clockwise to 40°):
If the target bearing is within the arc → score 1.0.
If outside by up to 3° → linear taper from 1.0 to 0.0.
If outside by ≥ 3° → hard reject (score 0.0).
200, optionally multiple values):
±30° half-beamwidth assumed.
Outside the 30° half-width by up to 10° → linear taper.
Outside by ≥ 10° → hard reject (score 0.0).
tx.beam nor in any
co-located sibling) → beam score defaults to 1.0 (omnidirectional treatment).
getActiveVisibleCrossings()
direction lookup table. As a result, clicking E filtered to the NE quadrant
and vice versa. The correct ranges are now:
/data_plugins and
/text were previously included in the FM-DX-Webserver's active listener
count, inflating the displayed connection number by up to 2. The plugin now correctly
identifies its connections so that they are excluded from the server's client counter.
loadUserlist() parser previously used a hardcoded field index
(f[17]) for the station ID. Lines in the fmlist CSV that contain more
than 18 semicolon-separated fields (e.g. entries with additional optional columns)
caused the ID to be read from the wrong position, resulting in the error
"Transmitter ID not found in CSV". The parser now always reads the ID from
f[f.length - 1] (the last field), correctly handling variable-width rows.
loadUserlist(), getUserlistEntry(),
enrichTxBeamData(), getTxBeamScore() added to the client plugin.loadUserlist() is called once during plugin
initialisation, before the first TX database load. enrichTxBeamData() is called
after txStations is populated (or reloaded), adding beam data to all matched
TX entries without a second network request.tx.beam is set,
computePersistentCrossings() evaluates both beam gates before calling
calcScatter(), avoiding the more expensive envelope and scoring pipeline for
off-beam transmitters.userlist1.csv file generated by fmlist.org is
personalised to your receiver location and fmlist account. It contains station distances and
azimuths calculated from your QTH coordinates. Do not share this file publicly if you wish to
keep your receiver location private.
userlist1.csv is absent, cannot be fetched
(HTTP 404), or contains no valid entries, the plugin logs a single warning to the browser console
and continues operating in omnidirectional mode — identical to v2.1a. No error is shown to the
user and no functionality is lost.
Performance optimisation release targeting low-power hardware (Intel Compute Stick, Raspberry Pi class devices). No functional or visual changes — all existing features and the UI remain identical to v2.1.
Float64Array horizon envelopes for each transmitter are now computed once per RX position and reused for all aircraft. Cache is invalidated on RX position change, TX database reload, after elevation enrichment, and when settings are applied. Reduces per-cycle CPU time by approximately 60–80%.computePersistentCrossings() eliminates ~60% of (aircraft, TX) pairs before the full scoring function is invoked.FORECAST_STEPS_SEC reduced from 11 steps to 5 steps ([0, 60, 120, 180, 300]).invalidateTxEnvelopeCache() is called when settings are applied._evalScatterAt().