FR24X

This userscript calculates the distance and bearing between an observer and an aircraft given their respective latitudes, longitudes, and altitudes.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         FR24X
// @namespace    https://github.com/ttheek/FR24X
// @version      1.2.1
// @description  This userscript calculates the distance and bearing between an observer and an aircraft given their respective latitudes, longitudes, and altitudes.
// @author       Ttheek
// @match        https://www.flightradar24.com/*
// @grant        none
// @license      Unlicense license
// ==/UserScript==

(function() {
    'use strict';    
    const observer = JSON.parse(localStorage.getItem('location'));
    const R = 6371; // Earth's radius in km
    const toRadians = angle => angle * (Math.PI / 180);
    const toDegrees = angle => angle * (180 / Math.PI);

function haversineDistance(lat1, lon1, lat2, lon2) {

    const phi1 = toRadians(lat1);
    const phi2 = toRadians(lat2);
    const deltaPhi = toRadians(lat2 - lat1);
    const deltaLambda = toRadians(lon2 - lon1);

    const a = Math.sin(deltaPhi / 2) ** 2 +
              Math.cos(phi1) * Math.cos(phi2) *
              Math.sin(deltaLambda / 2) ** 2;

    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

    const distance = R * c; // in kilometers
    return distance;
}

function threeDDistance(lat1, lon1, alt1, lat2, lon2, alt2) {
    const d2d = haversineDistance(lat1, lon1, lat2, lon2);
    const deltaH = Math.abs(alt2 - alt1) / 1000; // convert altitude from meters to kilometers
    const d3d = Math.sqrt(d2d ** 2 + deltaH ** 2);
    return d3d;
}

function initialBearing(lat1, lon1, lat2, lon2) {    

    const phi1 = toRadians(lat1);
    const phi2 = toRadians(lat2);
    const deltaLambda = toRadians(lon2 - lon1);

    const x = Math.sin(deltaLambda) * Math.cos(phi2);
    const y = Math.cos(phi1) * Math.sin(phi2) -
              Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);

    let bearing = Math.atan2(x, y);
    bearing = toDegrees(bearing);
    bearing = (bearing + 360) % 360; // normalize to 0-360 degrees
    return bearing;
}
    function toRad(degrees){
	return degrees * Math.PI/180;
}

function toDeg(radians){
	return radians * (180/Math.PI);
}
function createOverlay() {
    const overlay = document.createElement('div');
    overlay.className = 'overlay';
  overlay.innerHTML = `
  <button class="toggle-button" id="toggleButton">VIEW <span>Details</span> ▲</button>
  <main class="content">
        <div class="row">
            <p>ALTITUDE</p><div class="data"><strong><span id="altVal" class="data">-</span></strong></div>
        </div>
        <div class="cell row">
            <p>DISTANCE</p><div class="data"><strong><span id="distance" class="data">-</span></strong></div>
        </div>
        <div class="row">
            <p>BEARING</p><div class="data"><strong><span id="bearing" class="data">-</span></strong>&nbsp;<span class="unicode-arrow" id="unicodeArrow">↑</span></div>
        </div>

        <div class="footer">
        <p id="settingsButton">SETTINGS</p></div></main>
        <main class="settings">
                <div class="row">
                    <p>Set your location:</p>
                    <div>
                        <label for="latitude">Latitude:</label>
                        <input type="text" id="latitude" name="latitude">
                    </div>
                    <div>
                        <label for="longitude">Longitude:</label>
                        <input type="text" id="longitude" name="longitude">
                    </div>
                    <div>
                        <label for="altitude">Altitude:</label>
                        <input type="text" id="altitude" name="Altitude">
                    </div>
                    <button id="saveSettings">Save</button>
                    <button id="backButton">Back</button>
                </div>
            </main>
  `;
  document.body.appendChild(overlay);


  const style = document.createElement('style');
  style.innerHTML = `
 .overlay{
  position: fixed;
      bottom: 16px;
      right: 10px;
      z-index: 1000;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
      font-family: Arial, sans-serif;
      width: 160px;
      padding: 0;
      margin: 0;
      }
    .content, .settings {
    display: none;
      background-color: #f0f0f0;
      width: 160px;
      color: #393939;
      padding: 10px 0 0 0;
      border: 1px #f0f0f0;
      border-radius: 10px;
      transition: opacity 0.5s ease, max-height 0.5s ease;
    }
    .footer p,
  .content p{
    font-size: 12px;
    color:#6D6D6D;
  }
  .row{
  padding: 0 10px 0 10px;
  }
  .footer .data,
  .content .data{
  width: 160px;
    font-size: 15px;
    color:#393939;
  }
  .content .cell{
   border: none;
    border-top: 2px solid #fff;
    border-bottom: 2px solid #fff;
  }
  .bearing-container {
    display: flex;
    align-items: center;
  }
  .unicode-arrow {
    font-size: 20px;
    margin-left: 5px;
    display: inline-block;
    transform-origin: center;
  }
  .footer{
  background-color:#303030;
    border: 1px #303030;
    border-radius: 0 0 10px 10px;
  width: 100%;
  padding:0 10px 0 10px;
  color:#000;
  cursor: pointer;
    text-align: center;
  }
  .expanded .content {
    display: block;
  }
  .toggle-button {
    background-color: #444;
    border: none;
    padding: 10px 15px;
    cursor: pointer;
    font-size: 14px;
    margin-bottom: 10px;
    border-radius: 20px;
    color: white;
    display: flex;
    align-items: center;
  }
  .toggle-button span {
    color: #ffd700; /* Gold color */
    margin-left: 5px;
  }
  `;
  document.head.appendChild(style);
    const toggleButton = document.getElementById('toggleButton');
        const content = overlay.querySelector('.content');
    const settings = overlay.querySelector('.settings');
        const settingsButton = document.getElementById('settingsButton');
        const backButton = document.getElementById('backButton');
        const saveSettings = document.getElementById('saveSettings');

        toggleButton.addEventListener('click', () => {
            if (content.style.display === 'block') {
                content.style.display = 'none';
                toggleButton.innerHTML = 'VIEW <span>Details</span> ▲';
            } else {
                content.style.display = 'block';
                toggleButton.innerHTML = 'VIEW <span>Details</span> ▼';
            }
        });
    settingsButton.addEventListener('click', () => {
            content.style.display = 'none';
            settings.style.display = 'block';

            // Load saved location from localStorage
            const savedLocation = JSON.parse(localStorage.getItem('location'));
            if (savedLocation) {
                document.getElementById('latitude').value = savedLocation.lat;
                document.getElementById('longitude').value = savedLocation.lon;
                document.getElementById('altitude').value = savedLocation.alt;
            }
        });

        backButton.addEventListener('click', () => {
            content.style.display = 'block';
            settings.style.display = 'none';
        });

        saveSettings.addEventListener('click', () => {
            const latitude = parseFloat(document.getElementById('latitude').value);
            const longitude = parseFloat(document.getElementById('longitude').value);
            const altitude = parseFloat(document.getElementById('altitude').value);

            if (isNaN(latitude) || isNaN(longitude) || isNaN(altitude)) {
                alert('Please enter valid numbers for latitude, longitude, and altitude.');
                return;
            }

            const location = { lat: latitude, lon: longitude, alt: altitude };
            localStorage.setItem('location', JSON.stringify(location));
            alert('Location saved!');
        });
}

function parseAltitude(altitudeString) {
  return parseFloat(altitudeString.replace(/,/g, '').replace(/[^\d.]/g, ''));
}

function waitForElement(selector, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const element = document.querySelector(selector);
    if (element) {
      resolve(element);
      return;
    }

    const observer = new MutationObserver((mutations, obs) => {
      const element = document.querySelector(selector);
      if (element) {
        obs.disconnect();
        resolve(element);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });

    setTimeout(() => {
      observer.disconnect();
      reject(new Error(`Element with selector "${selector}" not found within ${timeout}ms`));
    }, timeout);
  });
}
    function isBelowHorizon(lat1, lon1, alt1, lat2, lon2, alt2) {
    
    const horizonDistance = altitude => Math.sqrt(2 * R * (altitude / 1000) + (altitude / 1000) ^ 2);

    const distance = haversineDistance(lat1, lon1, lat2, lon2);

    const observerHorizon = horizonDistance(alt1);
    const aircraftHorizon = horizonDistance(alt2);

    // Check if the aircraft is below the horizon
    return distance > (observerHorizon + aircraftHorizon);
}

function spotIt(observer,aircraft){
    const distance = threeDDistance(observer.lat, observer.lon, observer.alt, aircraft.lat, aircraft.lon, aircraft.alt);
    const bearing = initialBearing(observer.lat, observer.lon, aircraft.lat, aircraft.lon);
    const belowHorizon = isBelowHorizon(observer.lat, observer.lon, observer.alt, aircraft.lat, aircraft.lon, aircraft.alt);

 return {d:distance.toFixed(2),b:bearing.toFixed(0),horizon:belowHorizon};
}

function getAircraftData() {
  const selectors = {
    latitude: 'p.text-md.leading-tight.text-gray-1300[data-testid="aircraft-panel__lat"]',
    longitude: 'p.text-md.leading-tight.text-gray-1300[data-testid="aircraft-panel__lng"]',
    altitude: 'p.text-md.leading-tight.text-gray-1300[data-testid="aircraft-panel__calibrated-altitude"]'
  };

  const latitudeElement = document.querySelector(selectors.latitude);
  const longitudeElement = document.querySelector(selectors.longitude);
  const altitudeElement = document.querySelector(selectors.altitude);

  const latitude = latitudeElement ? latitudeElement.textContent || '0' : '0';
  const longitude = longitudeElement ? longitudeElement.textContent || '0' : '0';
  const altitudeString = altitudeElement ? altitudeElement.innerHTML || '0' : '0';
  const altitude = parseAltitude(altitudeString);

  return {
    'lat': parseFloat(latitude),
    'lon': parseFloat(longitude),
    'alt': altitude
  };
}

function updateOverlay(data) {
    const distance = document.getElementById('distance');
    const bearing = document.getElementById('bearing');
    const altVal = document.getElementById('altVal');
    const unicodeArrow = document.getElementById('unicodeArrow');

    if (data) {
        const alt = data.alt;
        const spot = spotIt(observer,data);
        //const belowHorizon = spot.horizon;
        altVal.textContent = `${alt} m (${Math.round(alt*3.281 / 5) * 5} ft)`;
        distance.textContent = spot.d + ' km';
        bearing.textContent = `${spot.b}°`;
        unicodeArrow.style.transform = `rotate(${spot.b}deg)`;
    } else {
        distance.textContent = 'N/A';
        bearing.textContent = 'N/A';
        altVal.textContent = 'N/A';
  }
}

function updateData() {
  const data = getAircraftData();
  //console.log('Updated data:', data);
  updateOverlay(data);
}

async function monitorAircraftData() {
  const selectors = [
    'p.text-md.leading-tight.text-gray-1300[data-testid="aircraft-panel__lat"]',
    'p.text-md.leading-tight.text-gray-1300[data-testid="aircraft-panel__lng"]',
    'p.text-md.leading-tight.text-gray-1300[data-testid="aircraft-panel__calibrated-altitude"]'
  ];

  let elements = await Promise.all(selectors.map(selector => waitForElement(selector).catch(() => null)));

  const observer = new MutationObserver((mutations) => {
    updateData();
  });

  elements.forEach(element => {
    if (element) {
      observer.observe(element, {
        childList: true,
        subtree: true,
        characterData: true
      });
    }
  });

  // Observe the body for changes to handle disappearance and reappearance of elements
  const bodyObserver = new MutationObserver(async (mutations) => {
    elements = await Promise.all(selectors.map(selector => waitForElement(selector).catch(() => null)));
    elements.forEach(element => {
      if (element) {
        observer.observe(element, {
          childList: true,
          subtree: true,
          characterData: true
        });
      }
    });
  });

  bodyObserver.observe(document.body, {
    childList: true,
    subtree: true
  });
}

window.onload = function() {
    // Create and display the overlay
    createOverlay();

    // Start monitoring aircraft data
    monitorAircraftData();
};



})();