// ==UserScript==
// @name RampRadar — GeoFS Live Charts
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description Live airport surface charts for GeoFS — traffic, METAR, digital ATIS.
// @author machpoint82
// @match *://www.geo-fs.com/*
// @match *://geo-fs.com/*
// @icon https://raw.githubusercontent.com/machpoint82/GeoFsRampRadar/main/preview/icon.png
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect cdn.jsdelivr.net
// @connect raw.githubusercontent.com
// @connect aviationweather.gov
// @connect datis.clowd.io
// @connect metar.vatsim.net
// @connect data.vatsim.net
// ==/UserScript==
(function () {
'use strict';
const CHARTS_BASE_URL = 'https://cdn.jsdelivr.net/gh/machpoint82/GeoFsRampRadar@main/charts';
const AIRPORTS_URL = 'https://raw.githubusercontent.com/mwgg/airports/master/airports.json';
const SCRIPT_VERSION = '1.0.2';
const VERSION_CHECK_URL = 'https://raw.githubusercontent.com/machpoint82/GeoFsRampRadar/main/rampradar.user.js';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/machpoint82/GeoFsRampRadar/main/CHANGELOG.md';
const ISSUES_URL = 'https://github.com/machpoint82/GeoFsRampRadar/issues';
const SCRIPT_INSTALL_URL = 'https://raw.githubusercontent.com/machpoint82/GeoFsRampRadar/main/rampradar.user.js';
const METAR_CACHE_MS = 10 * 60 * 1000;
const ATIS_CACHE_MS = 5 * 60 * 1000;
const VATSIM_CACHE_MS = 60 * 1000;
const SCRIPT_CHANGELOG = {
'1.0.2': [
'ATIS now sourced live from the VATSIM network (real controller-issued ATIS) when a station is online',
'Falls back to automated D-ATIS, then decoded METAR, when no live VATSIM ATIS is available'
],
'1.0.1': [
'Chart ALIGN nudges (N/S/E/W) per airport when GeoFS coords disagree with diagram',
'Better live position source and aircraft icon pivot centering',
'Scrollable METAR/ATIS tab'
],
'1.0.0': [
'Initial release: live airport charts, GeoFS traffic, METAR + digital ATIS',
'Minimize mode, bookmarks, origin/dest chips, optional callsign'
]
};
const STORAGE = {
GEOMETRY: 'rampradar_geometry_v1',
MINI_GEOMETRY: 'rampradar_mini_geometry_v1',
CHART_LAYERS: 'rampradar_layers_v1',
CHART_FOLLOW: 'rampradar_follow_v1',
LAST_ICAO: 'rampradar_last_icao',
BOOKMARKS: 'rampradar_bookmarks',
OPEN_SHORTCUT: 'rampradar_open_shortcut',
ROUTE_ORIGIN: 'rampradar_route_origin',
ROUTE_DEST: 'rampradar_route_dest',
CALLSIGN: 'rampradar_callsign',
CHART_OFFSETS: 'rampradar_chart_offsets_v1' // { ICAO: { eastM, northM } }
};
function pageGeofs() {
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.geofs) return unsafeWindow.geofs; } catch (e) {}
try { if (typeof window !== 'undefined' && window.geofs) return window.geofs; } catch (e) {}
try { if (typeof geofs !== 'undefined') return geofs; } catch (e) {}
return null;
}
function pageMultiplayer() {
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.multiplayer) return unsafeWindow.multiplayer; } catch (e) {}
try { if (typeof multiplayer !== 'undefined') return multiplayer; } catch (e) {}
return null;
}
function gmGet(key, fallback) {
try {
if (typeof GM_getValue === 'function') {
const v = GM_getValue(key, undefined);
return v === undefined ? fallback : v;
}
} catch (e) {}
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (e) { return fallback; }
}
function gmSet(key, value) {
try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } } catch (e) {}
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) {}
}
function gmFetchText(url, timeoutMs) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
fetch(url).then((r) => r.ok ? r.text() : Promise.reject(new Error('bad status ' + r.status))).then(resolve).catch(reject);
return;
}
GM_xmlhttpRequest({
method: 'GET', url, timeout: timeoutMs || 12000,
onload: (res) => {
if (res.status >= 200 && res.status < 300) resolve(res.responseText);
else reject(new Error('bad status ' + res.status));
},
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('timeout'))
});
});
}
function escapeHtml(s) {
return (s || '').toString().replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function formatClockHMS(d) {
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0');
}
function getCurrentLatLon() {
try {
const av = (typeof geofs !== 'undefined' && geofs.animation && geofs.animation.values) || null;
if (av && typeof av.lat === 'number' && typeof av.lon === 'number' &&
Number.isFinite(av.lat) && Number.isFinite(av.lon) &&
!(av.lat === 0 && av.lon === 0)) {
return { lat: av.lat, lon: av.lon };
}
} catch (e) {}
try {
const g = pageGeofs();
const inst = g && g.aircraft && g.aircraft.instance;
if (inst && Array.isArray(inst.llaLocation) && inst.llaLocation.length >= 2) {
const lat = inst.llaLocation[0], lon = inst.llaLocation[1];
if (Number.isFinite(lat) && Number.isFinite(lon)) return { lat, lon };
}
} catch (e) {}
return null;
}
function getCurrentGroundSpeedKts() { try { return geofs.animation.values.groundSpeedKnt; } catch (e) { return null; } }
function getCurrentHeading() { try { return geofs.animation.values.heading; } catch (e) { return null; } }
function getCurrentAltitudeFt() { try { return Math.round(geofs.animation.values.altitude); } catch (e) { return null; } }
const groups = {
default: [
"9",
"51",
"52",
"102",
"1000",
"1025",
"1027",
"2726",
"2806",
"2840",
"2844",
"2852",
"3049",
"4090",
"4197",
"4949",
"5002"
],
fighter: ["7", "15", "2808", "4251"],
fighterJet: [
"3",
"18",
"27",
"29",
"1024",
"2310",
"2364",
"2556",
"2581",
"2857",
"2948",
"2988",
"3591",
"3617",
"5229",
"5347",
"5405",
"5431"
],
glider: ["11", "41", "50", "53", "103", "2953", "2968"],
singleEngine: [
"1",
"2",
"8",
"12",
"13",
"21",
"22",
"23",
"31",
"40",
"1019",
"1022",
"1026",
"1069",
"2000",
"2750",
"2786",
"2976",
"2989",
"3211",
"4341",
"4390",
"4409",
"4596",
"5061",
"5486",
"5499"
],
twinPistonEngine: ["14", "16", "28", "4398", "4401"],
privateJet: [],
twinTurboprop: ["6", "26", "2864", "3460"],
twinjetNarrowBody: [
"4",
"238",
"242",
"1001",
"1003",
"1007",
"1008",
"2003",
"2769",
"2772",
"2843",
"2865",
"2870",
"2871",
"2878",
"2879",
"2899",
"3011",
"3054",
"3140",
"3292",
"3534",
"4140",
"4646",
"4743",
"4745",
"5086",
"5156",
"5203",
"5551"
],
rearMountedTwinJet: [],
wideBody4Engine: [
"10",
"252",
"1002",
"1010",
"1012",
"2153",
"2752",
"2951",
"5193",
"5211",
"5314",
"5409"
],
twinjetNarrowBody2: [],
twinjetWideBody: [
"24",
"25",
"235",
"237",
"239",
"240",
"244",
"1004",
"1005",
"1006",
"1009",
"1011",
"2386",
"2856",
"2973",
"3179",
"3180",
"3575",
"4402",
"4631",
"4764"
],
narrowBody4Engine: ["20", "1014", "2395"],
trijet: ["1023", "5038"],
regionalJet: [
"236",
"1015",
"1016",
"1017",
"1018",
"2004",
"2700",
"2706",
"3036",
"3307",
"3341",
"4017"
],
heavyCargo: ["2788", "5516"],
businessJet: ["5", "1021", "2461", "3109", "5073"],
turbopropCommuter: [
"247",
"1013",
"1020",
"2418",
"2420",
"2426",
"2892",
"2943",
"3289",
"3436"
]
};
const AIRCRAFT_GROUPS = groups;
const ICON_PATHS = {
businessJet: 'M 20 4 C 20.8 4 21.5 6 21.5 8 L 21.5 15 L 35 19 L 35 21 L 21.5 18 L 21.5 23 L 23.5 23 L 23.5 28 L 21.5 28 L 21.5 30 L 26 32 L 26 34 L 20 32.5 L 14 34 L 14 32 L 18.5 30 L 18.5 28 L 16.5 28 L 16.5 23 L 18.5 23 L 18.5 18 L 5 21 L 5 19 L 18.5 15 L 18.5 8 C 18.5 6 19.2 4 20 4 Z',
default: 'M 5.101 15.347 C 2.964 16.627 1.651 20.48 4.784 22.347 L 18.428 22.427 L 18.34 27.221 L 14.479 29.308 L 14.454 32.061 L 15.93 33.312 L 24.341 33.439 L 25.637 32.184 L 25.606 29.62 L 21.474 27.133 L 21.513 22.427 L 35.197 22.387 C 38.144 20.244 36.826 17.341 35.356 15.585 L 22.554 15.521 L 23.117 12.941 L 21.93 11.38 L 19.954 10.848 L 18.043 11.497 L 16.884 12.976 L 17.333 15.443 L 5.101 15.347',
fighter: 'M 7.462 27.371 L 7.079 28.003 L 7.04 28.816 L 8.516 28.759 L 8.562 28.151 L 17.252 28.185 L 17.146 30.781 L 13.182 34.386 L 13.098 36.219 L 13.519 36.754 L 17.208 36.717 L 18.096 36.428 L 18.176 33.765 L 18.56 35.681 L 19.085 37.248 L 19.707 37.266 L 20 38.6 L 20.299 37.281 L 20.83 37.224 L 21.42 35.624 L 21.69 33.696 L 21.877 36.45 L 22.749 36.722 L 26.521 36.658 L 26.981 35.989 L 26.822 34.255 L 22.927 30.89 L 22.966 28.044 L 31.426 28.065 L 31.576 28.775 L 32.862 28.687 L 32.869 27.938 L 32.413 27.582 L 32.243 21.254 L 31.474 25.144 L 23.77 18.686 L 22.474 14.206 L 21.604 8.572 L 21.17 4.509 L 19.945 1.396 L 18.829 4.558 L 18.389 8.531 L 17.389 14.241 L 16.334 18.726 L 8.577 24.995 L 7.694 21.259 L 7.424 27.369',
fighterJet: 'M 19.745 4.207 C 19.819 3.899 20.298 2.806 20.654 4.205 C 20.921 5.256 21.28 7.204 21.28 7.247 C 21.28 7.317 24.835 10.529 25.08 10.757 C 25.323 10.99 25.029 11.865 24.871 11.805 L 21.911 11.006 C 21.503 11.12 23.396 18.508 23.573 18.733 C 23.759 19.18 32.785 28.852 32.57 28.708 C 32.784 29.296 32.515 31.546 32.435 31.33 C 32.419 31.437 24.948 32.67 24.143 32.52 C 23.45 32.313 21.734 32.018 21.734 32.095 L 20.199 35.862 L 18.579 32.108 C 18.484 32.021 16.159 32.567 15.963 32.596 C 15.771 32.626 7.871 31.262 7.926 31.295 C 7.76 31.368 7.538 28.83 7.718 28.747 L 16.511 19.141 C 16.888 18.946 18.961 11.135 18.225 11.043 L 15.319 11.771 C 15.064 11.771 15.195 10.47 15.446 10.47 L 19.041 7.227 L 19.745 4.207 Z',
glider: 'M 19.379 15.812 C 19.444 14.978 20.585 14.995 20.644 15.869 L 20.761 18.971 C 21.713 18.887 35.793 19.418 35.913 19.479 C 36.8 19.932 36.096 20.578 35.889 20.577 C 35.888 20.577 20.713 21.155 20.713 21.155 L 20.47 27.107 C 21.332 27.712 22.318 28.887 21.939 28.85 L 18.043 28.842 C 17.652 28.875 18.651 27.784 19.522 27.112 L 19.162 21.152 L 3.91 20.643 C 3.479 20.6 2.919 19.876 3.875 19.484 C 3.875 19.484 18.355 18.958 19.162 19.021 L 19.379 15.812 Z',
heavyCargo: 'M 20 3 C 20.8 3 21.5 6 21.5 9 L 21.5 14 L 23.5 14.5 L 23.5 12 L 26 12 L 26 15.5 L 38 20 L 38 23 L 21.5 18 L 21.5 31 L 29 35 L 29 37 L 20 35 L 11 37 L 11 35 L 18.5 31 L 18.5 18 L 2 23 L 2 20 L 14 15.5 L 14 12 L 16.5 12 L 16.5 14.5 L 18.5 14 L 18.5 9 C 18.5 6 19.2 3 20 3 Z',
narrowBody4Engine: 'M 17.854 4.596 C 17.854 4.596 18.928 1.371 20.004 1.371 C 21.079 1.371 22.153 4.596 22.153 4.596 L 22.153 14.273 L 24.304 15.347 L 24.304 13.197 C 25.38 12.768 25.38 12.758 26.454 13.197 L 26.454 16.423 L 29 17.7 L 29 15.5 C 30.075 15.1 30.075 15.1 31.15 15.5 L 31.15 18.8 L 37.205 21.799 C 37.59 21.799 37.613 23.948 37.205 23.948 L 26.454 20.723 C 24.304 20.442 22.153 20.723 22.153 20.723 C 22.153 20.723 21.76 34.735 21.079 34.699 L 26.454 37.29 C 26.745 37.924 26.454 39 26.454 39 C 26.454 39 20.004 37.561 20.004 37.561 C 20.004 37.561 13.553 39 13.553 39 C 13.553 39 13.277 37.924 13.553 37.315 C 13.562 37.297 18.928 34.699 18.928 34.699 C 18.283 34.699 17.854 20.723 17.854 20.723 C 17.854 20.723 15.703 20.428 13.553 20.723 L 2.802 23.948 C 2.4 23.948 2.406 21.799 2.802 21.799 L 8.85 18.8 L 8.85 15.5 C 9.925 15.1 9.925 15.1 11 15.5 L 11 17.7 L 13.553 16.371 L 13.553 13.197 C 14.628 12.788 14.628 12.752 15.703 13.197 L 15.703 15.347 L 17.854 14.273 L 17.854 4.596 Z',
privateJet: 'M 18.285 17.094 L 7.606 19.124 C 7.458 19.101 6.512 22.72 6.591 22.734 L 16.329 22.745 L 16.713 28.721 L 18.329 28.721 L 19.154 26.824 L 19.154 30.819 L 17.715 32.479 L 17.715 35.51 L 15.777 36.742 L 15.581 38.299 L 20.034 37.312 L 24.645 38.299 L 24.406 36.742 L 22.393 35.51 L 22.393 32.479 L 20.997 30.776 L 20.997 26.824 L 21.794 28.721 L 23.379 28.721 L 23.748 22.745 L 33.575 22.745 C 33.698 22.713 32.732 19.024 32.61 19.056 L 21.794 17.12 L 21.741 4.909 C 21.537 3.741 21.594 3.461 20.997 2.507 C 20.734 2.088 20.505 1.829 20.031 1.7 C 19.591 1.847 19.399 2.045 19.154 2.507 C 18.692 3.377 18.6 3.594 18.329 4.895 L 18.285 17.094 Z',
rearMountedTwinJet: 'M 18.5 15.064 L 18.61 2.931 C 18.573 2.931 18.716 2.43 18.904 1.977 C 18.904 1.977 19.485 0.905 20 0.905 C 20.542 0.905 21.121 1.977 21.121 1.977 C 21.313 2.44 21.466 2.941 21.466 2.941 L 21.466 15.117 L 34.454 20 L 34.454 21.003 L 21.466 21.003 L 21.466 23.205 C 21.499 23.041 23.437 23.041 23.404 23.205 L 23.124 28.571 L 21.466 28.571 L 20.717 33.893 L 25.435 37.187 C 25.495 37.187 25.494 38.688 25.435 38.688 L 20 37.187 L 14.487 38.688 C 14.429 38.688 14.429 37.187 14.487 37.187 L 19.301 33.893 L 18.585 28.571 L 16.795 28.571 L 16.5 23.205 C 16.5 23.073 18.585 23.073 18.585 23.205 L 18.585 21.003 L 5.963 21.003 L 5.963 20 L 18.5 15.064 Z',
regionalJet: 'M 20 2 C 20.8 2 21.8 5 21.8 8 L 21.8 15 L 36 22 L 36 24 L 21.8 19 L 21.8 25 L 24.5 25 L 24.5 31 L 21.8 30 L 21.8 33 L 27 36 L 27 38 L 20 37 L 13 38 L 13 36 L 18.2 33 L 18.2 30 L 15.5 31 L 15.5 25 L 18.2 25 L 18.2 19 L 4 24 L 4 22 L 18.2 15 L 18.2 8 C 18.2 5 19.2 2 20 2 Z',
singleEngine: 'M 17.826 17.655 L 18.29 13.267 C 18.394 11.302 21.829 11.302 21.725 13.267 L 22.214 17.656 L 36.888 17.967 C 37.101 17.957 36.977 21.653 36.888 21.621 C 36.801 21.59 22.292 22.471 22.292 22.471 C 21.955 22.463 21.278 30.615 21.633 30.606 L 26.166 31.806 C 26.339 31.806 26.346 34.326 26.243 34.326 L 21.008 35.254 L 20.033 37 L 19.076 35.254 L 13.885 34.326 C 13.77 34.326 13.77 31.806 13.885 31.806 L 18.338 30.606 C 18.693 30.412 17.925 22.355 17.718 22.471 L 3.111 21.621 C 2.97 21.621 2.97 17.899 3.111 17.899 L 17.826 17.655 Z',
trijet: 'M 20 2 C 21 2 22 5 22 8 L 22 14 L 36 19 L 36 22 L 22 18 L 22 32 L 28 35 L 28 37 L 20 35.5 L 12 37 L 12 35 L 18 32 L 18 18 L 4 22 L 4 19 L 18 14 L 18 8 C 18 5 19 2 20 2 Z M 20 16 C 21 16 22 17 22 18 C 22 19 21 20 20 20 C 19 20 18 19 18 18 C 18 17 19 16 20 16 Z',
turbopropCommuter: 'M 20 2 L 21.7 5 L 21.7 12 L 23.5 12 L 23.5 11 L 25 11 L 25 13.5 L 37 18 L 37 20.5 L 25 17 L 23.5 17.5 L 21.7 18 L 21.7 29 L 27 31.5 L 27 33.5 L 20 32 L 13 33.5 L 13 31.5 L 18.3 29 L 18.3 18 L 16.5 17.5 L 15 17 L 3 20.5 L 3 18 L 15 13.5 L 15 11 L 16.5 11 L 16.5 12 L 18.3 12 L 18.3 5 L 20 2 Z',
twinPistonEngine: 'M 18.155 17.368 L 18.456 13.739 C 18.456 13.143 18.966 10.1 19.071 9.848 C 19.369 9.128 20.023 8.709 20.043 8.761 C 20.114 8.732 20.782 9.056 21.112 9.848 C 21.287 10.275 21.712 13.143 21.712 13.739 L 21.997 17.368 L 25.158 17.368 L 25.158 13.739 C 25.89 13.143 26.898 13.143 27.654 13.739 L 27.654 17.368 L 38.007 17.803 L 38.007 21.981 L 27.654 21.981 C 26.898 23.701 25.858 23.701 25.158 21.981 C 25.158 21.981 22.028 21.981 21.997 21.981 L 21.112 33.163 L 26.898 33.429 L 26.898 36.79 L 13.402 36.79 L 13.402 33.429 L 19.071 33.163 L 18.155 21.981 L 15.21 21.981 C 14.463 23.701 13.402 23.701 12.694 21.981 L 2.007 21.981 L 2.007 17.803 L 12.694 17.368 L 12.694 13.739 C 13.402 13.143 14.525 13.143 15.21 13.739 L 15.21 17.368 L 18.155 17.368 Z',
twinTurboprop: 'M 21.699 17.936 L 21.699 7.409 C 21.719 6.981 21.408 6.24 20.924 5.756 C 20.406 5.237 19.986 5.248 19.986 5.248 C 19.986 5.248 19.614 5.276 19.134 5.756 C 18.674 6.217 18.334 7.007 18.334 7.409 L 18.334 17.936 L 18.334 18.315 L 15.338 18.315 L 15.338 15.644 C 14.884 14.551 13.978 14.551 13.546 15.644 L 13.546 18.315 L 1.918 19.041 C 1.727 18.968 1.727 21.517 1.918 21.589 L 13.546 22.256 C 14.193 23.82 14.692 23.821 15.338 22.256 L 18.334 22.256 C 18.334 22.256 19.114 36.07 19.134 36.116 L 13.546 36.854 L 13.546 39.487 L 26.655 39.487 L 26.655 36.91 L 20.924 36.145 L 21.699 22.256 L 24.47 22.256 C 25.11 23.803 25.667 23.808 26.31 22.256 L 37.649 21.589 C 37.856 21.552 37.773 19.017 37.649 19.041 L 26.31 18.315 L 26.31 15.644 C 25.894 14.551 24.936 14.551 24.511 15.644 L 24.511 18.315 L 21.699 18.315 L 21.699 17.936 Z',
twinjetNarrowBody: 'M 17.854 4.596 C 17.854 4.596 18.928 1.371 20.004 1.371 C 21.079 1.371 22.153 4.596 22.153 4.596 L 22.153 14.273 L 24.304 15.347 L 24.304 13.197 C 25.38 12.768 25.38 12.758 26.454 13.197 L 26.454 16.423 L 37.205 21.799 C 37.59 21.799 37.613 23.948 37.205 23.948 L 26.454 20.723 C 24.304 20.442 22.153 20.723 22.153 20.723 C 22.153 20.723 21.76 34.735 21.079 34.699 L 26.454 37.29 C 26.745 37.924 26.454 39 26.454 39 C 26.454 39 20.004 37.561 20.004 37.561 C 20.004 37.561 13.553 39 13.553 39 C 13.553 39 13.277 37.924 13.553 37.315 C 13.562 37.297 18.928 34.699 18.928 34.699 C 18.283 34.699 17.854 20.723 17.854 20.723 C 17.854 20.723 15.703 20.428 13.553 20.723 L 2.802 23.948 C 2.4 23.948 2.406 21.799 2.802 21.799 L 13.553 16.371 L 13.553 13.197 C 14.628 12.788 14.628 12.752 15.703 13.197 L 15.703 15.347 L 17.854 14.273 L 17.854 4.596 Z',
twinjetNarrowBody2: 'M 17.919 4.912 C 17.919 4.912 18.88 0.75 20 0.75 C 21.119 0.75 22.081 4.912 22.081 4.912 L 22.369 13.238 L 25.203 15.318 L 25.203 13.238 C 26.323 12.79 26.167 12.78 27.284 13.238 L 27.284 16.358 L 37.69 21.561 C 38.091 21.561 38.115 23.643 37.69 23.643 L 25.203 20.52 C 23.121 20.107 22.081 20.52 22.081 20.52 C 22.081 20.52 22.081 31.967 21.04 34.049 L 26.244 37.169 C 27.284 37.919 27.008 39.251 27.008 39.251 L 20 37.169 L 13.001 39.251 C 13.001 39.251 12.716 37.917 13.757 37.169 C 13.765 37.151 18.96 34.049 18.96 34.049 C 17.919 31.967 17.919 20.52 17.919 20.52 C 17.919 20.52 16.879 20.093 14.798 20.52 L 2.311 23.643 C 1.893 23.643 1.899 21.561 2.311 21.561 L 12.716 16.358 L 12.716 13.238 C 13.834 12.811 13.679 12.773 14.798 13.238 L 14.798 15.318 L 17.631 13.238 L 17.919 4.912 Z',
twinjetWideBody: 'M 20 2 C 21.5 2 23 6 23 10 L 23 15 L 25 15.8 L 25 13 L 28 13 L 28 17 L 38 21 L 38 24 L 23 19 L 23 32 L 30 36 L 30 38 L 20 36 L 10 38 L 10 36 L 17 32 L 17 19 L 2 24 L 2 21 L 12 17 L 12 13 L 15 13 L 15 15.8 L 17 15 L 17 10 C 17 6 18.5 2 20 2 Z',
wideBody4Engine: 'M 20 4 C 20.36 4 21 5 21 5 C 21 5 22 6.5 22 8 L 22 15.5 L 24 16 L 24 14 C 24 13.5 25.49 13.5 25.49 14 L 25.49 16.5 L 27 17 L 27 15 C 27 14.5 28.5 14.5 28.5 15 L 28.5 17.5 L 36 20 C 36.268 20 36.268 22 36 22 L 22 20.5 L 22 26 C 22 26 22 29 21 34 L 27 37 L 27 39 L 20 37.5 L 13 39 L 13 37 L 19 34 C 18 29 18 26 18 26 L 18 20.5 L 4 22 C 3.81 22 3.82 20 4 20 L 11.503 17.5 L 11.503 15 C 11.503 14.5 13 14.5 13 15 L 13 17 L 14.447 16.5 L 14.447 14 C 14.447 13.5 16 13.5 16 14 L 16 16 L 18 15.5 L 18 8 C 18 6.5 19 5 19 5 C 19 5 19.644 4 20 4 Z'
};
const AIRCRAFT_NAME_TABLE = [
{ re: /a380/i, group: 'wideBody4Engine' },
{ re: /747|a340|il-?86/i, group: 'wideBody4Engine' },
{ re: /777|787|a350|a330|767/i, group: 'twinjetWideBody' },
{ re: /md-?11|dc-?10/i, group: 'trijet' },
{ re: /757|737|a32[0-1]|a319|a318|a220/i, group: 'twinjetNarrowBody' },
{ re: /crj|e1[79]|bae ?146|rj ?100/i, group: 'regionalJet' },
{ re: /atr|dash-?8|q400|c-?130/i, group: 'turbopropCommuter' },
{ re: /citation|lear|gulfstream|challenger|global|falcon|phenom/i, group: 'businessJet' },
{ re: /an-?225|an-?124|il-?76|c-5\b|c-17|freighter|cargo/i, group: 'heavyCargo' },
{ re: /f-?1[68]|f-?22|f-?35|mig|su-2|rafale|typhoon/i, group: 'fighterJet' },
{ re: /cessna|piper|cirrus|skyhawk|archer|bonanza/i, group: 'singleEngine' },
{ re: /glider|ask-|discus/i, group: 'glider' }
];
function getGroup(aircraftId) {
const id = String(aircraftId);
for (const groupName in AIRCRAFT_GROUPS) {
if (AIRCRAFT_GROUPS[groupName].includes(id)) return groupName;
}
return 'default';
}
function groupFromName(name) {
if (!name) return null;
for (const e of AIRCRAFT_NAME_TABLE) if (e.re.test(name)) return e.group;
return null;
}
function detectCurrentAircraft() {
try {
const g = pageGeofs();
if (!g || !g.aircraft || !g.aircraft.instance) return null;
const inst = g.aircraft.instance;
const id = inst.id;
if (id == null || id === '') return null;
let name = null;
try {
const list = g.aircraftList || {};
const entry = list[id] || list[String(id)] || list[Number(id)];
if (entry && entry.name) name = entry.name;
} catch (e) {}
if (!name) {
try { name = inst.aircraftName || inst.name || (inst.definition && inst.definition.name) || null; } catch (e) {}
}
const byName = groupFromName(name);
return { id, name: name || ('Aircraft #' + id), group: byName || getGroup(id) };
} catch (e) { return null; }
}
function resolveGroup(aircraftId, name) {
return groupFromName(name) || (aircraftId != null ? getGroup(aircraftId) : 'default');
}
function createAircraftIconEl(NS, group, className) {
const d = ICON_PATHS[group] || ICON_PATHS.default;
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', d);
path.setAttribute('class', className || 'aircraft');
return path;
}
function makeAircraftMarker(NS, group, className, scale) {
const g = document.createElementNS(NS, 'g');
g.setAttribute('class', 'aircraft-marker');
const inner = document.createElementNS(NS, 'g');
const s = scale != null ? scale : 0.55;
inner.setAttribute('transform', `translate(${-20 * s},${-21 * s}) scale(${s})`);
inner.appendChild(createAircraftIconEl(NS, group, className));
g.appendChild(inner);
return g;
}
let cachedTraffic = [];
let lastTrafficFetch = 0;
function readGeofsTraffic() {
const mp = pageMultiplayer();
if (!mp || !mp.users) return [];
const out = [];
try {
for (const [userId, userData] of Object.entries(mp.users)) {
if (!userData) continue;
const coords = (userData.lastUpdate && userData.lastUpdate.co) || userData.referenceCoord;
if (!coords || !Array.isArray(coords) || coords.length < 2) continue;
const [lat, lon, alt = 0] = coords;
let heading = 0, speed = null;
try {
if (userData.lastUpdate) {
if (userData.lastUpdate.st != null) speed = userData.lastUpdate.st;
if (userData.lastUpdate.he != null) heading = userData.lastUpdate.he;
}
if (userData.heading != null) heading = userData.heading;
if (userData.speed != null) speed = userData.speed;
} catch (e) {}
const acId = userData.aircraft != null ? userData.aircraft : null;
let acName = null;
try {
const g = pageGeofs();
const list = g && g.aircraftList;
if (list && acId != null) {
const ent = list[acId] || list[String(acId)];
if (ent && ent.name) acName = ent.name;
}
} catch (e) {}
out.push({
id: userId,
callsign: userData.callsign || String(userId).slice(0, 6),
lat, lon, altitude: alt, heading: heading || 0, speed,
aircraftId: acId, aircraftName: acName,
group: resolveGroup(acId, acName)
});
}
} catch (e) {}
return out;
}
function refreshTraffic() {
if (Date.now() - lastTrafficFetch < 1500) return cachedTraffic;
lastTrafficFetch = Date.now();
cachedTraffic = readGeofsTraffic();
return cachedTraffic;
}
function detectRouteFromFlightPlan() {
const icaos = [];
try {
const g = pageGeofs();
const fp = g && ((g.nav && g.nav.flightPlan) || g.flightPlan);
if (Array.isArray(fp)) {
fp.forEach((p) => {
const ident = (p.ident || p.name || p.fix || '').toString().toUpperCase();
const alt = p.alt != null ? p.alt : (p.altitude != null ? p.altitude : p.elev);
if (/^[A-Z]{4}$/.test(ident) && (alt == null || Math.abs(Number(alt)) < 50)) icaos.push(ident);
});
}
} catch (e) {}
try {
document.querySelectorAll('.geofs-waypointIdent').forEach((el) => {
const name = (el.textContent || '').trim().toUpperCase().replace(/[0-9.,\s-]+$/, '');
if (/^[A-Z]{4}$/.test(name)) icaos.push(name);
});
} catch (e) {}
const seen = new Set(), uniq = [];
icaos.forEach((c) => { if (!seen.has(c)) { seen.add(c); uniq.push(c); } });
if (uniq.length >= 2) return { origin: uniq[0], dest: uniq[uniq.length - 1] };
if (uniq.length === 1) return { origin: uniq[0], dest: '' };
return null;
}
function syncRouteFromFlightPlan(force) {
const det = detectRouteFromFlightPlan();
if (!det) return false;
if (force || !pilotRoute.origin) pilotRoute.origin = det.origin || pilotRoute.origin;
if (force || !pilotRoute.dest) pilotRoute.dest = det.dest || pilotRoute.dest;
gmSet(STORAGE.ROUTE_ORIGIN, pilotRoute.origin);
gmSet(STORAGE.ROUTE_DEST, pilotRoute.dest);
return true;
}
function formatRoutePair(origin, dest) {
const o = origin ? String(origin).toUpperCase() : null;
const d = dest ? String(dest).toUpperCase() : null;
if (o && d) return o + '→' + d;
if (o) return o + '→—';
if (d) return '—→' + d;
return 'No route';
}
function getDisplayCallsign(fallback) {
const custom = (gmGet(STORAGE.CALLSIGN, '') || '').trim();
if (custom) return custom;
try {
const mp = pageMultiplayer();
if (mp && mp.myCallsign) return mp.myCallsign;
} catch (e) {}
try {
if (typeof geofs !== 'undefined' && geofs.userRecord && geofs.userRecord.callsign)
return geofs.userRecord.callsign;
} catch (e) {}
return fallback || 'You';
}
function buildTooltipLines(opts) {
const lines = [];
if (opts.callsign) lines.push(opts.callsign);
lines.push(formatRoutePair(opts.origin, opts.dest));
const spd = opts.speed != null && Number.isFinite(Number(opts.speed)) ? Math.round(opts.speed) + ' kt' : '— kt';
const alt = opts.altitude != null && Number.isFinite(Number(opts.altitude)) ? Math.round(opts.altitude).toLocaleString() + ' ft' : '— ft';
lines.push(spd + ' · ' + alt);
if (opts.aircraftName) lines.push(opts.aircraftName);
return lines.join('\n');
}
function showAcTooltip(e, text) {
let tip = document.getElementById('rampradar-ac-tooltip');
if (!tip) { tip = document.createElement('div'); tip.id = 'rampradar-ac-tooltip'; document.body.appendChild(tip); }
tip.textContent = text || '';
tip.style.display = text ? 'block' : 'none';
moveAcTooltip(e);
}
function moveAcTooltip(e) {
const tip = document.getElementById('rampradar-ac-tooltip');
if (!tip || tip.style.display === 'none') return;
const pad = 14;
let left = e.clientX + pad, top = e.clientY + pad;
const rect = tip.getBoundingClientRect();
if (left + rect.width > window.innerWidth - 8) left = e.clientX - rect.width - pad;
if (top + rect.height > window.innerHeight - 8) top = e.clientY - rect.height - pad;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
}
function hideAcTooltip() {
const tip = document.getElementById('rampradar-ac-tooltip');
if (tip) tip.style.display = 'none';
}
function defaultChartLayers() { return { taxi: true, gates: true, legend: true, aircraft: true }; }
function getChartLayers() {
const v = gmGet(STORAGE.CHART_LAYERS, null);
return Object.assign(defaultChartLayers(), v && typeof v === 'object' ? v : {});
}
function setChartLayer(key, on) {
const layers = getChartLayers();
layers[key] = !!on;
gmSet(STORAGE.CHART_LAYERS, layers);
applyChartLayers();
}
function getChartFollow() { return gmGet(STORAGE.CHART_FOLLOW, false) === true; }
function setChartFollow(on) { gmSet(STORAGE.CHART_FOLLOW, !!on); }
let followTargetId = null;
function getChartOffsets() {
const v = gmGet(STORAGE.CHART_OFFSETS, null);
return v && typeof v === 'object' ? v : {};
}
function getOffsetFor(icao) {
const all = getChartOffsets();
const o = all[(icao || '').toUpperCase()] || {};
return { eastM: Number(o.eastM) || 0, northM: Number(o.northM) || 0 };
}
function setOffsetFor(icao, eastM, northM) {
icao = (icao || '').toUpperCase();
if (!/^[A-Z0-9]{4}$/.test(icao)) return;
const all = getChartOffsets();
if (!eastM && !northM) delete all[icao];
else all[icao] = { eastM: Number(eastM) || 0, northM: Number(northM) || 0 };
gmSet(STORAGE.CHART_OFFSETS, all);
}
function nudgeOffset(icao, dEast, dNorth) {
const cur = getOffsetFor(icao);
setOffsetFor(icao, cur.eastM + dEast, cur.northM + dNorth);
if (chartState && chartState._rebuildPx) chartState._rebuildPx();
updateChartAircraft();
updateChartOtherAircraft();
}
function applyChartLayers() {
if (!chartState) return;
const L = getChartLayers();
const map = { taxi: ['gTaxi', 'gTaxiLabels'], gates: ['gGates'], legend: ['legend'], aircraft: ['acEl', 'gOthers'] };
Object.keys(map).forEach((k) => {
map[k].forEach((ref) => {
if (ref === 'legend') {
const el = panelEl && panelEl.querySelector('#rampradar-legend');
if (el) el.style.display = L[k] ? '' : 'none';
return;
}
const node = chartState[ref];
if (node) node.style.display = L[k] ? '' : 'none';
});
});
}
const chartDataCache = {};
const chartViewState = {};
let chartLoadState = { status: 'idle', icao: null, error: null };
let chartState = null;
let activeIcao = gmGet(STORAGE.LAST_ICAO, '') || '';
let pilotRoute = {
origin: gmGet(STORAGE.ROUTE_ORIGIN, '') || '',
dest: gmGet(STORAGE.ROUTE_DEST, '') || ''
};
let activeTab = 'charts';
let airportsDb = null;
async function loadChartData(icao) {
if (Object.prototype.hasOwnProperty.call(chartDataCache, icao)) return chartDataCache[icao];
try {
const text = await gmFetchText(`${CHARTS_BASE_URL}/${icao}.json`);
const json = JSON.parse(text);
chartDataCache[icao] = json;
return json;
} catch (e) { chartDataCache[icao] = null; return null; }
}
const CHART_FREQ_ORDER = { ATIS: 0, CLD: 1, GND: 2, TWR: 3, APP: 4, DEP: 5, UNICOM: 6 };
function normalizeFreqMhz(v) { return v > 200 ? Math.round((v / 10) * 1000) / 1000 : v; }
function buildChartSVG(container, data, icao) {
container.innerHTML = '';
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 1000 700');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.setAttribute('id', 'rampradar-svg');
container.appendChild(svg);
function el(tag, attrs) { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; }
const viewport = el('g', { id: 'chart-viewport' });
svg.appendChild(viewport);
const originLat = data.airport.lat, originLon = data.airport.lon;
const R = 6371000;
function toMeters(lat, lon) {
const dLat = (lat - originLat) * Math.PI / 180, dLon = (lon - originLon) * Math.PI / 180;
return [dLon * Math.cos(originLat * Math.PI / 180) * R, -dLat * R];
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
function extend(x, y) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; }
data.runways.forEach((r) => r.ends.forEach((e) => { const [x, y] = toMeters(e.lat, e.lon); extend(x, y); }));
data.pavements.forEach((p) => p.points.forEach((pt) => { const [x, y] = toMeters(pt.lat, pt.lon); extend(x, y); }));
const VB_W = 1000, VB_H = 700, PAD = 40;
const spanX = Math.max(maxX - minX, 1), spanY = Math.max(maxY - minY, 1);
const scale = Math.min((VB_W - 2 * PAD) / spanX, (VB_H - 2 * PAD) / spanY);
const cx = (minX + maxX) / 2, cyc = (minY + maxY) / 2;
let liveEastM = 0, liveNorthM = 0;
(function loadOff() {
const o = getOffsetFor(icao);
liveEastM = o.eastM; liveNorthM = o.northM;
})();
function px(lat, lon) {
const [x, y] = toMeters(lat, lon);
return [(x - cx) * scale + VB_W / 2, (y - cyc) * scale + VB_H / 2];
}
function pxLive(lat, lon) {
const [x, y] = toMeters(lat, lon);
const xo = x + liveEastM;
const yo = y - liveNorthM;
return [(xo - cx) * scale + VB_W / 2, (yo - cyc) * scale + VB_H / 2];
}
function _rebuildPx() {
const o = getOffsetFor(icao);
liveEastM = o.eastM; liveNorthM = o.northM;
}
const gApron = el('g', {});
function terminalKey(name) { if (!name) return null; const m = name.trim().match(/^(T\d+)\b/i); return m ? m[1].toUpperCase() : null; }
function pointInPoly(x, y, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i][0], yi = pts[i][1], xj = pts[j][0], yj = pts[j][1];
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
const apronShapes = data.pavements.filter((p) => p.kind === 'apron' && p.closed).map((p) => ({ pts: p.points.map((pt) => px(pt.lat, pt.lon)), votes: {} }));
(data.gates || []).forEach((g) => {
const key = terminalKey(g.name);
if (!key) return;
const [gx, gy] = px(g.lat, g.lon);
let match = apronShapes.find((a) => pointInPoly(gx, gy, a.pts));
if (!match) {
let best = null, bestD = Infinity;
apronShapes.forEach((a) => {
const acx = a.pts.reduce((s, p) => s + p[0], 0) / a.pts.length, acy = a.pts.reduce((s, p) => s + p[1], 0) / a.pts.length;
const d = Math.hypot(acx - gx, acy - gy);
if (d < bestD) { bestD = d; best = a; }
});
if (best && bestD < 55) match = best;
}
if (match) match.votes[key] = (match.votes[key] || 0) + 1;
});
apronShapes.forEach((a) => {
const entries = Object.entries(a.votes);
const label = entries.length ? entries.sort((x, y) => y[1] - x[1])[0][0] : null;
const pts = a.pts.map((p) => p.join(',')).join(' ');
gApron.appendChild(el('polygon', { points: pts, class: label ? 'apron terminal' : 'apron' }));
if (label) {
const acx = a.pts.reduce((s, p) => s + p[0], 0) / a.pts.length;
const topY = Math.min(...a.pts.map((p) => p[1]));
const t = el('text', { x: acx, y: topY - 6, class: 'terminal-label', 'text-anchor': 'middle' });
t.textContent = label;
gApron.appendChild(t);
}
});
viewport.appendChild(gApron);
const gRwy = el('g', {}), gRwyLabels = el('g', {}), runwayPolys = [];
const placedLabelRects = [];
function estLabelWidth(text, fontSize) { return String(text).length * fontSize * 0.62 + 2; }
function labelRect(cx, cy, text, fontSize) {
const w = estLabelWidth(text, fontSize), h = fontSize * 1.25;
return { x1: cx - w / 2, y1: cy - h / 2, x2: cx + w / 2, y2: cy + h / 2 };
}
function rectFree(rect, pad) {
const p = pad == null ? 1.5 : pad;
return !placedLabelRects.some((r) => !(rect.x2 + p < r.x1 || rect.x1 - p > r.x2 || rect.y2 + p < r.y1 || rect.y1 - p > r.y2));
}
data.runways.forEach((r) => {
const [x1, y1] = px(r.ends[0].lat, r.ends[0].lon), [x2, y2] = px(r.ends[1].lat, r.ends[1].lon);
const wPx = Math.max(r.width_m * scale, 5);
const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy);
const nx = -dy / len * wPx / 2, ny = dx / len * wPx / 2;
const polyPts = [[x1 + nx, y1 + ny], [x2 + nx, y2 + ny], [x2 - nx, y2 - ny], [x1 - nx, y1 - ny]];
runwayPolys.push(polyPts);
gRwy.appendChild(el('polygon', { points: polyPts.map((p) => p.join(',')).join(' '), class: 'runway' }));
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
const rot = angle > 90 || angle < -90 ? angle + 180 : angle;
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
const centerText = r.ends[0].id + ' / ' + r.ends[1].id;
const centerLabel = el('text', { x: 0, y: 3, class: 'rwy-label', 'text-anchor': 'middle' });
centerLabel.textContent = centerText;
centerLabel.setAttribute('transform', `translate(${mx},${my}) rotate(${rot})`);
gRwyLabels.appendChild(centerLabel);
placedLabelRects.push(labelRect(mx, my, centerText, 14));
[[x1, y1, dx, dy, r.ends[0].id], [x2, y2, -dx, -dy, r.ends[1].id]].forEach(([ex, ey, ddx, ddy, id]) => {
const inLen = Math.hypot(ddx, ddy);
const ix = ex + ddx / inLen * 22, iy = ey + ddy / inLen * 22;
const eLabel = el('text', { x: 0, y: 3, class: 'rwy-label', 'text-anchor': 'middle' });
eLabel.textContent = id;
eLabel.setAttribute('transform', `translate(${ix},${iy}) rotate(${rot})`);
gRwyLabels.appendChild(eLabel);
placedLabelRects.push(labelRect(ix, iy, id, 14));
});
});
viewport.appendChild(gRwy);
const gTaxi = el('g', {}), gTaxiLabels = el('g', {});
const nodes = data.taxi_network.nodes, groups = {};
(data.taxi_network.edges || []).forEach((e) => {
const a = nodes[e.n1], b = nodes[e.n2];
if (!a || !b) return;
const [x1, y1] = px(a.lat, a.lon), [x2, y2] = px(b.lat, b.lon);
const isLinkEdge = /^link\d*$/i.test((e.name || '').trim());
gTaxi.appendChild(el('line', { x1, y1, x2, y2, class: isLinkEdge ? 'taxi-line taxi-link' : 'taxi-line taxi-named' }));
(groups[e.name] = groups[e.name] || []).push({ x1, y1, x2, y2 });
});
viewport.appendChild(gTaxi);
function pointInAnyRunway(x, y) {
for (const pts of runwayPolys) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i][0], yi = pts[i][1], xj = pts[j][0], yj = pts[j][1];
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
if (inside) return true;
}
return false;
}
const MIN_LABEL_SEP = 150;
const groupNames = Object.keys(groups).filter((n) => !/^link\d*$/i.test((n || '').trim()))
.sort((a, b) => String(a).length - String(b).length);
groupNames.forEach((name) => {
const fontSize = 8;
const candidates = (groups[name] || []).map((seg) => {
const mx = (seg.x1 + seg.x2) / 2, my = (seg.y1 + seg.y2) / 2;
const len = Math.hypot(seg.x2 - seg.x1, seg.y2 - seg.y1);
return { mx, my, len };
}).filter((c) => !pointInAnyRunway(c.mx, c.my))
.sort((a, b) => b.len - a.len);
const picked = [];
for (const c of candidates) {
if (picked.length >= 2) break;
if (picked.some((p) => Math.hypot(p.mx - c.mx, p.my - c.my) < MIN_LABEL_SEP)) continue;
const rect = labelRect(c.mx, c.my, name, fontSize);
if (!rectFree(rect)) continue;
const tw = Math.max(10, String(name).length * 4.6);
const th = 10;
const bg = el('rect', {
x: c.mx - tw / 2 - 2, y: c.my - th / 2 - 0.5, width: tw + 4, height: th + 1, rx: 2, ry: 2,
class: 'taxi-badge'
});
gTaxiLabels.appendChild(bg);
const t = el('text', { x: c.mx, y: c.my + 3, class: 'taxi-label taxi-label-named', 'text-anchor': 'middle' });
t.textContent = name;
gTaxiLabels.appendChild(t);
placedLabelRects.push(rect);
picked.push(c);
}
});
viewport.appendChild(gTaxiLabels);
viewport.appendChild(gRwyLabels);
const gGates = el('g', {});
function shortGateLabel(name) { if (!name) return ''; const parts = name.trim().split(/\s+/); return parts[parts.length - 1]; }
const GATE_LABEL_SPACING = 12, GATE_TICK_SPACING = 4, placedGateLabels = [], placedGateTicks = [];
(data.gates || []).forEach((g) => {
const [x, y] = px(g.lat, g.lon);
const tickTooClose = placedGateTicks.some((p) => Math.hypot(p[0] - x, p[1] - y) < GATE_TICK_SPACING);
if (tickTooClose) return;
placedGateTicks.push([x, y]);
const tick = el('line', { x1: x, y1: y - 2.5, x2: x, y2: y + 2.5, class: 'gate-tick' });
const title = el('title', {});
title.textContent = (g.name || 'Gate') + (g.airline_code ? ' · ' + String(g.airline_code).toUpperCase() : '');
tick.appendChild(title);
gGates.appendChild(tick);
const gateText = shortGateLabel(g.name);
if (!gateText) return;
const tooCloseToGate = placedGateLabels.some((p) => Math.hypot(p[0] - x, p[1] - y) < GATE_LABEL_SPACING);
const rect = labelRect(x + 3 + estLabelWidth(gateText, 7.5) / 2, y + 2, gateText, 7.5);
if (!tooCloseToGate) {
const t = el('text', { x: x + 3, y: y + 2.5, class: 'gate-label' });
t.textContent = gateText;
gGates.appendChild(t);
placedGateLabels.push([x, y]);
placedLabelRects.push(rect);
}
});
viewport.appendChild(gGates);
const det0 = detectCurrentAircraft();
const ownGroup0 = det0 ? det0.group : 'default';
const acEl = makeAircraftMarker(NS, ownGroup0, 'aircraft', 0.6);
acEl.setAttribute('class', 'aircraft-marker own');
viewport.appendChild(acEl);
const gOthers = el('g', { id: 'chart-other-aircraft' });
viewport.appendChild(gOthers);
const freqByType = {};
(data.frequencies || []).forEach((f) => {
const mhz = normalizeFreqMhz(f.freq_mhz), key = f.label || f.type;
if (!freqByType[key] || freqByType[key] > mhz) freqByType[key] = mhz;
});
const freqRows = Object.keys(freqByType).map((label) => ({ label, mhz: freqByType[label], order: CHART_FREQ_ORDER[(data.frequencies.find((f) => (f.label || f.type) === label) || {}).type] ?? 9 })).sort((a, b) => a.order - b.order);
const savedView = icao ? chartViewState[icao] : null;
let zoom = savedView ? savedView.zoom : 1, panX = savedView ? savedView.panX : 0, panY = savedView ? savedView.panY : 0;
function applyTransform() {
viewport.setAttribute('transform', `translate(${panX},${panY}) scale(${zoom})`);
if (icao) chartViewState[icao] = { zoom, panX, panY };
}
applyTransform();
function onWheel(e) { e.preventDefault(); e.stopPropagation(); const factor = e.deltaY > 0 ? 0.9 : 1.1; zoom = Math.min(6, Math.max(0.5, zoom * factor)); applyTransform(); }
let dragging = false, lastX = 0, lastY = 0;
function onDown(e) { dragging = true; lastX = e.clientX; lastY = e.clientY; }
function onMove(e) { if (!dragging) return; panX += e.clientX - lastX; panY += e.clientY - lastY; lastX = e.clientX; lastY = e.clientY; applyTransform(); }
function onUp() { dragging = false; }
if (container.__rampradarPanCleanup) container.__rampradarPanCleanup();
container.addEventListener('wheel', onWheel, { passive: false });
container.addEventListener('mousedown', onDown);
container.addEventListener('mousemove', onMove);
container.addEventListener('mouseup', onUp);
container.addEventListener('mouseleave', onUp);
container.__rampradarPanCleanup = () => {
container.removeEventListener('wheel', onWheel);
container.removeEventListener('mousedown', onDown);
container.removeEventListener('mousemove', onMove);
container.removeEventListener('mouseup', onUp);
container.removeEventListener('mouseleave', onUp);
};
const out = { svg, px, pxLive, _rebuildPx, acEl, gOthers, gTaxi, gTaxiLabels, gGates, freqRows, _ownGroup: ownGroup0 };
setTimeout(applyChartLayers, 0);
return out;
}
function renderChartLegend(legendEl, freqRows) {
if (!legendEl) return;
const swatches = `
Apron
Runway
Taxiway (yellow)
Gate
You (cyan)
Traffic (pink)
`;
const freqHtml = freqRows.length ? `${freqRows.map((r) => `
${escapeHtml(r.label)} ${r.mhz.toFixed(3)}
`).join('')}
` : '';
legendEl.innerHTML = swatches + freqHtml;
}
function updateChartAircraft() {
if (!chartState) return;
const pos = getCurrentLatLon();
if (!pos) return;
const heading = getCurrentHeading() || 0;
const mapFn = chartState.pxLive || chartState.px;
const [x, y] = mapFn(pos.lat, pos.lon);
const layers = getChartLayers();
const within = x > -300 && x < 1300 && y > -300 && y < 1000;
const det = detectCurrentAircraft();
const group = det ? det.group : 'default';
if (chartState.acEl) {
chartState.acEl.style.display = (layers.aircraft && within) ? '' : 'none';
chartState.acEl.setAttribute('transform', `translate(${x},${y}) rotate(${heading})`);
if (chartState._ownGroup !== group) {
chartState._ownGroup = group;
const NS = 'http://www.w3.org/2000/svg';
chartState.acEl.innerHTML = '';
const inner = document.createElementNS(NS, 'g');
inner.setAttribute('transform', 'translate(-12,-12.6) scale(0.6)');
inner.appendChild(createAircraftIconEl(NS, group, 'aircraft'));
chartState.acEl.appendChild(inner);
}
}
if (getChartFollow() && chartState.svg && within && !followTargetId) {
try {
const icao = activeIcao || null;
const view = icao ? (chartViewState[icao] || { zoom: 1, panX: 0, panY: 0 }) : { zoom: 1, panX: 0, panY: 0 };
const zoom = view.zoom || 1;
view.panX = view.panX * 0.7 + (500 - x * zoom) * 0.3;
view.panY = view.panY * 0.7 + (350 - y * zoom) * 0.3;
if (icao) chartViewState[icao] = view;
const vp = chartState.svg.querySelector('#chart-viewport');
if (vp) vp.setAttribute('transform', `translate(${view.panX},${view.panY}) scale(${zoom})`);
} catch (e) {}
}
applyChartLayers();
}
function updateChartOtherAircraft() {
if (!chartState || !chartState.gOthers) return;
const NS = 'http://www.w3.org/2000/svg';
chartState.gOthers.innerHTML = '';
const layers = getChartLayers();
if (layers.aircraft === false) return;
const mapFn = chartState.pxLive || chartState.px;
refreshTraffic().forEach((u) => {
const [x, y] = mapFn(u.lat, u.lon);
if (x <= -300 || x >= 1300 || y <= -300 || y >= 1000) return;
const g = makeAircraftMarker(NS, u.group || 'default', 'other-aircraft', 0.5);
g.setAttribute('transform', `translate(${x},${y}) rotate(${u.heading || 0})`);
if (followTargetId === u.id) g.classList.add('focused');
const tipText = buildTooltipLines({
callsign: u.callsign, origin: null, dest: null,
speed: u.speed, altitude: u.altitude, aircraftName: u.aircraftName
});
g.style.cursor = 'pointer';
g.addEventListener('mouseenter', (e) => showAcTooltip(e, tipText));
g.addEventListener('mousemove', (e) => moveAcTooltip(e));
g.addEventListener('mouseleave', () => { if (followTargetId !== u.id) hideAcTooltip(); });
g.addEventListener('click', (e) => {
e.stopPropagation();
if (followTargetId === u.id) {
followTargetId = null;
hideAcTooltip();
} else {
followTargetId = u.id;
setChartFollow(true);
showAcTooltip(e, tipText + '\n[FOCUSED — click again to release]');
}
updateChartOtherAircraft();
});
chartState.gOthers.appendChild(g);
if (getChartFollow() && followTargetId === u.id) {
try {
const icao = activeIcao || null;
const view = icao ? (chartViewState[icao] || { zoom: 1, panX: 0, panY: 0 }) : { zoom: 1, panX: 0, panY: 0 };
const zoom = view.zoom || 1;
view.panX = view.panX * 0.7 + (500 - x * zoom) * 0.3;
view.panY = view.panY * 0.7 + (350 - y * zoom) * 0.3;
if (icao) chartViewState[icao] = view;
const vp = chartState.svg.querySelector('#chart-viewport');
if (vp) vp.setAttribute('transform', `translate(${view.panX},${view.panY}) scale(${zoom})`);
} catch (e) {}
}
});
}
function bindOwnHover() {
if (!chartState || !chartState.acEl) return;
const el = chartState.acEl;
el.style.cursor = 'default';
el.onmouseenter = (e) => {
const det = detectCurrentAircraft();
showAcTooltip(e, buildTooltipLines({
callsign: getDisplayCallsign('You'),
origin: pilotRoute.origin, dest: pilotRoute.dest,
speed: getCurrentGroundSpeedKts(), altitude: getCurrentAltitudeFt(),
aircraftName: det ? det.name : null
}));
};
el.onmousemove = (e) => moveAcTooltip(e);
el.onmouseleave = () => hideAcTooltip();
}
function haversineNM(lat1, lon1, lat2, lon2) {
const R = 3440.065, toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
async function ensureAirportsDb() {
if (airportsDb) return airportsDb;
try {
airportsDb = JSON.parse(await gmFetchText(AIRPORTS_URL, 25000));
} catch (e) {
airportsDb = {};
}
return airportsDb;
}
function lookupAirport(icao) {
if (!icao || !airportsDb) return null;
const a = airportsDb[icao] || airportsDb[String(icao).toUpperCase()];
if (!a) return null;
return {
icao: a.icao || icao,
iata: a.iata || '',
name: a.name || '',
city: a.city || '',
country: a.country || '',
lat: a.lat,
lon: a.lon
};
}
function airportLabel(icao) {
const a = lookupAirport(icao);
if (!a) return '';
const bits = [a.name];
if (a.city) bits.push(a.city);
if (a.country) bits.push(a.country);
return bits.filter(Boolean).join(' · ');
}
async function findNearestAirportIcao() {
const pos = getCurrentLatLon();
if (!pos) return null;
await ensureAirportsDb();
let best = null, bestD = Infinity;
for (const icao in airportsDb) {
const a = airportsDb[icao];
if (!a || a.lat == null || a.lon == null) continue;
if (!/^[A-Z0-9]{4}$/.test(icao)) continue;
const d = haversineNM(pos.lat, pos.lon, a.lat, a.lon);
if (d < bestD) { bestD = d; best = icao; }
}
return best ? { icao: best, nm: bestD, apt: lookupAirport(best) } : null;
}
function getBookmarks() { return gmGet(STORAGE.BOOKMARKS, []) || []; }
function setBookmarks(list) { gmSet(STORAGE.BOOKMARKS, list); }
function toggleBookmark(icao) {
icao = (icao || '').toUpperCase();
if (!/^[A-Z]{4}$/.test(icao)) return;
let list = getBookmarks();
if (list.includes(icao)) list = list.filter((x) => x !== icao);
else list.unshift(icao);
if (list.length > 40) list.length = 40;
setBookmarks(list);
}
const metarMemCache = {}, atisMemCache = {};
async function fetchMetarRaw(icao) {
const cached = metarMemCache[icao];
if (cached && (Date.now() - cached.ts < METAR_CACHE_MS)) return cached.result;
let result;
try {
const text = await gmFetchText('https://aviationweather.gov/api/data/metar?ids=' + encodeURIComponent(icao) + '&format=raw', 10000);
const raw = (text || '').trim().split('\n')[0];
if (!raw) throw new Error('empty');
result = { raw };
} catch (e1) {
try {
const text = await gmFetchText('https://metar.vatsim.net/' + icao, 8000);
const raw = (text || '').trim();
if (!raw || raw.toLowerCase().includes('not found')) throw new Error('empty');
result = { raw };
} catch (e2) {
result = { error: 'No METAR available for ' + icao };
}
}
metarMemCache[icao] = { ts: Date.now(), result };
return result;
}
function decodeMetarSummary(raw) {
if (!raw) return null;
const parts = raw.split(/\s+/);
const timeTok = parts.find((p) => /^\d{6}Z$/.test(p));
const time = timeTok ? timeTok.slice(2, 4) + ':' + timeTok.slice(4, 6) + 'Z' : '—';
const windTok = parts.find((p) => /^(\d{3}|VRB)\d{2,3}(G\d{2,3})?KT$/.test(p));
let wind = 'calm';
if (windTok) {
const m = windTok.match(/^(\d{3}|VRB)(\d{2,3})(G(\d{2,3}))?KT$/);
if (m) wind = (m[1] === 'VRB' ? 'variable' : m[1] + '°') + ' at ' + parseInt(m[2], 10) + ' kt' + (m[4] ? ' G' + parseInt(m[4], 10) : '');
}
const visTok = parts.find((p) => /^(\d{4}|P?\d{1,2}SM|CAVOK)$/.test(p));
let vis = visTok || '—';
if (visTok === 'CAVOK') vis = 'CAVOK';
else if (/^\d{4}$/.test(visTok || '')) vis = visTok + ' m';
const tempTok = parts.find((p) => /^M?\d{2}\/M?\d{2}$/.test(p));
let temp = '—', dew = '—';
if (tempTok) {
const [t, d] = tempTok.split('/');
temp = (t.startsWith('M') ? '-' + t.slice(1) : t) + '°C';
dew = (d.startsWith('M') ? '-' + d.slice(1) : d) + '°C';
}
const qnhTok = parts.find((p) => /^Q\d{4}$/.test(p)) || parts.find((p) => /^A\d{4}$/.test(p));
let qnh = '—';
if (qnhTok) qnh = qnhTok.startsWith('Q') ? qnhTok.slice(1) + ' hPa' : (parseInt(qnhTok.slice(1), 10) / 100).toFixed(2) + ' inHg';
const cloudToks = parts.filter((p) => /^(FEW|SCT|BKN|OVC|VV)\d{3}/.test(p));
const clouds = cloudToks.length ? cloudToks.map((c) => c.slice(0, 3) + ' ' + parseInt(c.slice(3, 6), 10) * 100 + 'ft').join(', ') : 'sky clear';
return { time, wind, vis, temp, dew, qnh, clouds };
}
async function fetchDigitalAtis(icao) {
const cached = atisMemCache[icao];
if (cached && (Date.now() - cached.ts < ATIS_CACHE_MS)) return cached.result;
let result;
try {
const text = await gmFetchText('https://datis.clowd.io/api/' + encodeURIComponent(icao), 10000);
const json = JSON.parse(text);
const arr = Array.isArray(json) ? json : [json];
if (!arr.length || !arr[0].datis) throw new Error('empty');
result = {
source: 'datis',
items: arr.map((x) => ({
type: x.type || 'atis',
code: x.code || '',
text: x.datis || '',
time: x.time || ''
}))
};
} catch (e) {
result = { source: 'none', error: 'No digital ATIS (DATIS is mainly US). Showing decoded METAR instead.' };
}
atisMemCache[icao] = { ts: Date.now(), result };
return result;
}
let vatsimDataCache = { ts: 0, data: null };
async function getVatsimData() {
if (vatsimDataCache.data && (Date.now() - vatsimDataCache.ts < VATSIM_CACHE_MS)) return vatsimDataCache.data;
try {
const text = await gmFetchText('https://data.vatsim.net/v3/vatsim-data.json', 15000);
vatsimDataCache = { ts: Date.now(), data: JSON.parse(text) };
} catch (e) {}
return vatsimDataCache.data;
}
async function fetchVatsimAtis(icao) {
const data = await getVatsimData();
if (!data || !Array.isArray(data.atis)) return null;
const matches = data.atis.filter((a) => (a.callsign || '').toUpperCase().startsWith(icao.toUpperCase() + '_'));
if (!matches.length) return null;
return matches.map((m) => ({
callsign: m.callsign,
code: m.atis_code || '',
freq: m.frequency || '',
text: Array.isArray(m.text_atis) ? m.text_atis.join(' ') : (m.text_atis || '')
}));
}
let remoteVersion = null;
let remoteChangelogText = null;
let versionCheckDone = false;
function versionParts(v) {
return String(v || '0').split('.').map((n) => parseInt(n, 10) || 0);
}
function isUpdateAvailable() {
if (!remoteVersion) return false;
const a = versionParts(SCRIPT_VERSION), b = versionParts(remoteVersion);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] || 0, y = b[i] || 0;
if (y > x) return true;
if (y < x) return false;
}
return false;
}
function changelogBulletsFor(version) {
if (!version) return [];
if (remoteChangelogText && typeof remoteChangelogText === 'object' && Array.isArray(remoteChangelogText[version])) {
return remoteChangelogText[version];
}
if (remoteChangelogText && typeof remoteChangelogText === 'string') {
const re = new RegExp('##\\s*v?' + version.replace(/\\./g, '\\.') + '\\b([\\s\\S]*?)(?=\\n##\\s|$)', 'i');
const m = remoteChangelogText.match(re);
if (m) {
return m[1].split('\n').map((l) => l.replace(/^[-*]\s*/, '').trim()).filter((l) => l && !l.startsWith('#'));
}
}
return SCRIPT_CHANGELOG[version] || [];
}
async function checkForUpdate() {
if (versionCheckDone) return;
versionCheckDone = true;
try {
const textBody = await gmFetchText(VERSION_CHECK_URL + '?t=' + Date.now(), 8000);
const m = textBody.match(/@version\s+([0-9]+(?:\.[0-9]+)*)/);
if (m) remoteVersion = m[1];
const cl = textBody.match(/const SCRIPT_CHANGELOG\s*=\s*(\{[\s\S]*?\n\s*\});/);
if (cl) {
try { remoteChangelogText = (new Function('return ' + cl[1]))(); } catch (e2) { remoteChangelogText = null; }
}
} catch (e) {
remoteVersion = null;
}
try {
if (!remoteChangelogText) {
const md = await gmFetchText(CHANGELOG_URL + '?t=' + Date.now(), 8000);
remoteChangelogText = md;
}
} catch (e) { }
if (panelOpen && !minimized && activeTab === 'tools') renderBody();
}
function getOpenShortcut() {
const v = gmGet(STORAGE.OPEN_SHORTCUT, null);
if (!v || typeof v !== 'object' || !v.key) return null;
return { ctrl: !!v.ctrl, alt: !!v.alt, shift: !!v.shift, meta: !!v.meta, key: String(v.key) };
}
function setOpenShortcut(spec) {
if (!spec || !spec.key) gmSet(STORAGE.OPEN_SHORTCUT, null);
else gmSet(STORAGE.OPEN_SHORTCUT, { ctrl: !!spec.ctrl, alt: !!spec.alt, shift: !!spec.shift, meta: !!spec.meta, key: String(spec.key) });
}
function formatShortcut(spec) {
if (!spec || !spec.key) return 'None';
const parts = [];
if (spec.ctrl) parts.push('Ctrl');
if (spec.alt) parts.push('Alt');
if (spec.shift) parts.push('Shift');
if (spec.meta) parts.push('Meta');
parts.push(spec.key === ' ' ? 'Space' : (spec.key.length === 1 ? spec.key.toUpperCase() : spec.key));
return parts.join('+');
}
function eventMatchesShortcut(e, spec) {
if (!spec || !spec.key) return false;
if (!!e.ctrlKey !== !!spec.ctrl || !!e.altKey !== !!spec.alt || !!e.shiftKey !== !!spec.shift || !!e.metaKey !== !!spec.meta) return false;
const ek = e.key, sk = spec.key;
if (sk === ' ' || sk === 'Space') return ek === ' ';
if (sk.length === 1 && ek.length === 1) return ek.toLowerCase() === sk.toLowerCase();
return ek === sk;
}
function isTypingTarget(el) {
if (!el) return false;
const tag = (el.tagName || '').toUpperCase();
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
let shortcutCaptureMode = false;
function onGlobalShortcutKeydown(e) {
if (shortcutCaptureMode || isTypingTarget(document.activeElement)) return;
const spec = getOpenShortcut();
if (!eventMatchesShortcut(e, spec)) return;
e.preventDefault(); e.stopPropagation();
if (!panelOpen) openPanel();
else if (minimized) restore();
else closePanel();
}
function installOpenShortcutListener() {
if (installOpenShortcutListener._done) return;
installOpenShortcutListener._done = true;
document.addEventListener('keydown', onGlobalShortcutKeydown, true);
}
let panelOpen = false, minimized = false, panelEl = null, backdropEl = null, dragAbort = null;
const MINI_SIZES = [{ w: 320, h: 240 }, { w: 400, h: 300 }, { w: 480, h: 340 }];
let miniSizeIndex = 1;
function injectStyles() {
if (document.getElementById('rampradar-styles')) return;
const style = document.createElement('style');
style.id = 'rampradar-styles';
style.textContent = `
#rampradar-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 99998; }
#rampradar-panel {
position: fixed; z-index: 99999; top: 50%; left: 50%; transform: translate(-50%,-50%);
width: min(960px, 96vw); height: min(680px, 92vh);
background: #0b1622; color: #eef2f7; border-radius: 16px;
box-shadow: 0 24px 80px rgba(0,0,0,0.65); border: 1px solid #1e3552;
display: flex; flex-direction: column; overflow: hidden;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
#rampradar-panel.is-max { width: min(98vw, 1400px); height: min(96vh, 900px); }
#rampradar-panel.is-mini {
width: 400px; height: 300px; border-radius: 14px; transform: none;
box-shadow: 0 14px 40px rgba(0,0,0,0.55);
}
#rampradar-panel.is-mini #rampradar-tabs,
#rampradar-panel.is-mini .rr-full-only { display: none !important; }
#rampradar-topbar {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
background: #0e2138; border-bottom: 1px solid #1e3552; cursor: move; flex-shrink: 0;
}
#rampradar-topbar .title { font-weight: 800; letter-spacing: 1px; font-size: 12px; color: #22d3ee; flex-shrink: 0; }
#rampradar-topbar .icao-tag { font-family: Consolas, monospace; font-size: 11px; color: #8397ae; flex-shrink: 0; }
#rampradar-topbar .rr-clock { font-family: Consolas, monospace; font-size: 11px; color: #cfe0ee; margin-left: 4px; }
#rampradar-topbar .spacer { flex: 1; min-width: 6px; }
#rampradar-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
#rampradar-tabs button {
background: #16304e; border: 1px solid #24405f; color: #9fb3c8; border-radius: 6px;
padding: 4px 8px; font-weight: 800; font-size: 9.5px; letter-spacing: 0.4px; cursor: pointer;
}
#rampradar-tabs button.active { background: linear-gradient(180deg,#5eead4,#22d3ee); color: #052024; border-color: transparent; }
#rampradar-topbar .icon-btn {
background: #16304e; border: 1px solid #24405f; color: #cfe0ee; border-radius: 8px;
width: 28px; height: 26px; cursor: pointer; font-size: 12px; flex-shrink: 0;
}
#rampradar-topbar .icon-btn:hover, #rampradar-tabs button:hover { border-color: #22d3ee; color: #22d3ee; }
#rampradar-body { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 10px 12px 12px; overflow-y: auto; overflow-x: hidden; }
#rampradar-panel.is-mini #rampradar-body { padding: 6px 8px 8px; overflow: hidden; }
#rampradar-body.rr-scroll { overflow-y: auto; }
#rampradar-body.rr-charts-mode { overflow: hidden; }
#rampradar-tools { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-bottom: 8px; flex-shrink: 0; }
#rampradar-tools .mode-btn {
background: #0e2138; border: 1px solid #1e3552; color: #6f85a0; border-radius: 6px;
padding: 5px 10px; font-size: 10px; font-weight: 800; letter-spacing: 0.4px; cursor: pointer;
}
#rampradar-tools .mode-btn.active { background: linear-gradient(180deg,#5eead4,#22d3ee); color: #052024; border-color: transparent; }
#rampradar-tools label.layer-toggle {
display: inline-flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;
font-size: 10px; font-weight: 700; color: #6f85a0;
}
#rampradar-tools .rr-align-group { display: inline-flex; align-items: center; gap: 3px; margin-left: 4px; }
#rampradar-tools .rr-align-label { font-size: 9px; font-weight: 800; color: #6f85a0; letter-spacing: 0.4px; }
#rampradar-tools .rr-align { padding: 4px 7px !important; min-width: 24px; }
#rampradar-tools input { accent-color: #22d3ee; }
#rampradar-search-row { display: flex; gap: 6px; margin-bottom: 8px; flex-shrink: 0; align-items: center; flex-wrap: wrap; }
#rampradar-search-row input {
background: #081527; border: 1px solid #1e3552; color: #eef2f7;
border-radius: 8px; padding: 7px 9px; font-size: 12px; font-family: Consolas, monospace; text-transform: uppercase;
}
#rampradar-search-row input#rampradar-icao-input { width: 88px; }
#rampradar-search-row input#rr-callsign { width: 100px; text-transform: none; }
#rampradar-search-row .route-chip {
background: #16304e; border: 1px solid #24405f; color: #22d3ee; border-radius: 6px;
padding: 6px 10px; font-weight: 800; font-size: 11px; font-family: Consolas, monospace; cursor: pointer;
}
#rampradar-search-row .route-chip:hover { border-color: #22d3ee; }
#rampradar-search-row .route-chip.empty { color: #5c7089; border-style: dashed; }
#rampradar-search-row button {
background: #16304e; border: 1px solid #24405f; color: #cfe0ee; border-radius: 8px;
padding: 0 12px; height: 32px; font-weight: 800; font-size: 11px; cursor: pointer;
}
#rampradar-status { font-size: 11px; color: #8397ae; margin-bottom: 6px; display: none; flex-shrink: 0; }
#rampradar-status .warn { color: #e0955c; }
#rampradar-status a { color: #22d3ee; }
.rampradar-chart-wrap { position: relative; flex: 1; min-height: 0; border: 1px solid #1e3552; border-radius: 12px; overflow: hidden; background: #0a1420; }
#rampradar-canvas { position: absolute; inset: 0; width: 100%; height: 100%; background: #0b1524; }
#rampradar-canvas svg { width: 100%; height: 100%; display: block; }
#rampradar-canvas .apron { fill: #1a334d; stroke: #2a4a68; stroke-width: 0.6; }
#rampradar-canvas .apron.terminal { fill: #243f5c; stroke: #3a5a7a; }
#rampradar-canvas .terminal-label { fill: #d6e6f4; font-size: 11px; font-weight: 800; }
#rampradar-canvas .runway { fill: #2a3038; stroke: #e8eef4; stroke-width: 1.4; }
#rampradar-canvas .rwy-label { fill: #ffffff; font-size: 13px; font-weight: 800; }
#rampradar-canvas .taxi-line { stroke-linecap: round; stroke-linejoin: round; }
#rampradar-canvas .taxi-named { stroke: #c9a227; stroke-width: 5.5; opacity: 0.95; }
#rampradar-canvas .taxi-link { stroke: #8a9a6a; stroke-width: 3.2; opacity: 0.75; }
#rampradar-canvas .taxi-badge { fill: #1a1a1a; stroke: #e8c84a; stroke-width: 1; }
#rampradar-canvas .taxi-badge-link { fill: #152030; stroke: #4a6280; stroke-width: 0.8; }
#rampradar-canvas .taxi-label { font-weight: 800; pointer-events: none; }
#rampradar-canvas .taxi-label-named { fill: #f5e6a0; font-size: 8.5px; }
#rampradar-canvas .taxi-label-link { fill: #9ab0c8; font-size: 9px; font-weight: 700; }
#rampradar-canvas .gate-tick { stroke: #c084fc; stroke-width: 1.8; }
#rampradar-canvas .gate-label { fill: #e9d5ff; font-size: 7.5px; font-weight: 700; }
#rampradar-canvas .aircraft { fill: #22d3ee; stroke: #052024; stroke-width: 0.9; }
#rampradar-canvas .other-aircraft { fill: #f472b6; stroke: #052024; stroke-width: 0.9; opacity: 0.95; }
#rampradar-canvas .aircraft-marker.focused .other-aircraft { fill: #fbbf24; }
#rampradar-legend {
position: absolute; left: 10px; top: 10px; background: rgba(5,10,18,0.82);
border: 1px solid #1e3552; border-radius: 8px; padding: 9px 11px; font-size: 10px;
color: #cfe0ee; display: flex; flex-direction: column; gap: 4px; max-width: 150px; z-index: 2;
}
#rampradar-panel.is-mini #rampradar-legend { display: none !important; }
#rampradar-legend > div { display: flex; align-items: center; gap: 7px; }
.chart-sw { width: 11px; height: 11px; border-radius: 3px; flex-shrink: 0; display: inline-block; }
.chart-legend-freqs { border-top: 1px solid #1e3552; margin-top: 4px; padding-top: 6px; display: flex; flex-direction: column; gap: 3px; }
.chart-legend-freqs > div { display: flex; justify-content: space-between; gap: 10px; }
.chart-freq-label { color: #8397ae; font-weight: 700; }
.chart-freq-val { color: #22d3ee; font-family: Consolas, monospace; }
#rampradar-resize { position: absolute; right: 2px; bottom: 2px; width: 16px; height: 16px; cursor: nwse-resize; z-index: 3; }
#rampradar-resize::after {
content: ''; position: absolute; right: 3px; bottom: 3px; width: 10px; height: 10px;
border-right: 2px solid #3d5675; border-bottom: 2px solid #3d5675;
}
#rampradar-ac-tooltip {
position: fixed; z-index: 100000; display: none; pointer-events: none;
background: rgba(5,12,22,0.94); border: 1px solid #2a4a68; border-radius: 8px;
padding: 8px 10px; color: #eef2f7; font-size: 11px; line-height: 1.45;
font-family: Consolas, ui-monospace, monospace; white-space: pre; max-width: 280px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
}
.rr-card { background: #0e2138; border: 1px solid #1e3552; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
.rr-label { font-size: 10px; font-weight: 800; letter-spacing: 0.6px; color: #6f85a0; margin-bottom: 6px; }
.rr-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
.rr-muted { color: #8397ae; font-size: 11px; line-height: 1.5; }
.rr-bm-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.rr-bm-grid button {
background: #16304e; border: 1px solid #24405f; color: #cfe0ee; border-radius: 8px;
padding: 10px 14px; font-size: 13px; cursor: pointer; font-family: Consolas, monospace; font-weight: 800;
}
.rr-bm-grid button:hover { border-color: #22d3ee; color: #22d3ee; }
.rr-atis { font-family: Consolas, monospace; font-size: 11px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; color: #dbe6f0; }
`;
document.head.appendChild(style);
}
function loadChartForIcao(icao) {
icao = (icao || '').trim().toUpperCase();
if (!/^[A-Z]{4}$/.test(icao)) {
chartLoadState = { status: 'none', icao: null, error: null };
updateStatus();
return;
}
activeIcao = icao;
gmSet(STORAGE.LAST_ICAO, icao);
chartLoadState = { status: 'loading', icao, error: null };
updateStatus();
const tag = panelEl && panelEl.querySelector('.icao-tag');
if (tag) tag.textContent = icao;
loadChartData(icao).then((data) => {
if (activeIcao !== icao) return;
const canvas = panelEl && panelEl.querySelector('#rampradar-canvas');
const legendEl = panelEl && panelEl.querySelector('#rampradar-legend');
if (!canvas) return;
if (!data) {
chartLoadState = { status: 'missing', icao, error: null };
canvas.innerHTML = '';
if (legendEl) legendEl.innerHTML = '';
chartState = null;
updateStatus();
return;
}
chartLoadState = { status: 'ready', icao, error: null };
updateStatus();
const built = buildChartSVG(canvas, data, icao);
chartState = built;
try {
const path = chartState.acEl && chartState.acEl.querySelector('path');
if (path) path.setAttribute('class', 'aircraft');
} catch (e) {}
bindOwnHover();
if (legendEl && !minimized) renderChartLegend(legendEl, built.freqRows || []);
applyChartLayers();
updateChartAircraft();
updateChartOtherAircraft();
}).catch((e) => {
chartLoadState = { status: 'error', icao, error: (e && e.message) || 'Failed' };
updateStatus();
});
}
function updateStatus() {
const statusEl = panelEl && panelEl.querySelector('#rampradar-status');
if (!statusEl) return;
const s = chartLoadState;
if (s.status === 'loading') { statusEl.style.display = 'block'; statusEl.innerHTML = 'Loading ' + escapeHtml(s.icao) + '…'; }
else if (s.status === 'missing') { statusEl.style.display = 'block'; statusEl.innerHTML = 'No diagram for ' + escapeHtml(s.icao) + ' yet. Request it via GitHub Issues . '; }
else if (s.status === 'error') { statusEl.style.display = 'block'; statusEl.innerHTML = '' + escapeHtml(s.error) + ' '; }
else if (s.status === 'none') { statusEl.style.display = 'block'; statusEl.innerHTML = 'Enter airport ICAO or use Nearest. '; }
else { statusEl.style.display = 'none'; statusEl.innerHTML = ''; }
}
function renderChartsTab(body) {
const L = getChartLayers();
const o = pilotRoute.origin, d = pilotRoute.dest;
body.innerHTML = `
NEAREST
${getBookmarks().includes(activeIcao) ? '★ SAVED' : '☆ SAVE'}
Taxi
Gates
Legend
Traffic
Follow
ALIGN
N
S
E
W
↺
LOAD
${o ? escapeHtml(o) : 'ORIG'}
${d ? escapeHtml(d) : 'DEST'}
FP
`;
body.querySelectorAll('input[data-layer]').forEach((inp) => {
inp.onchange = () => setChartLayer(inp.getAttribute('data-layer'), inp.checked);
});
const follow = body.querySelector('#rr-follow');
if (follow) follow.onchange = () => { setChartFollow(follow.checked); if (!follow.checked) followTargetId = null; };
const STEP = 8;
body.querySelectorAll('[data-align]').forEach((btn) => {
btn.onclick = () => {
if (!activeIcao) return;
const d = btn.getAttribute('data-align');
if (d === 'reset') { setOffsetFor(activeIcao, 0, 0); if (chartState && chartState._rebuildPx) chartState._rebuildPx(); updateChartAircraft(); updateChartOtherAircraft(); return; }
if (d === 'n') nudgeOffset(activeIcao, 0, STEP);
if (d === 's') nudgeOffset(activeIcao, 0, -STEP);
if (d === 'e') nudgeOffset(activeIcao, STEP, 0);
if (d === 'w') nudgeOffset(activeIcao, -STEP, 0);
};
});
const nearestBtn = body.querySelector('#rr-nearest');
if (nearestBtn) nearestBtn.onclick = async () => {
const st = body.querySelector('#rampradar-status');
if (st) { st.style.display = 'block'; st.textContent = 'Finding nearest airport…'; }
const n = await findNearestAirportIcao();
if (!n) { alert('Could not determine nearest airport (no position or airport data).'); return; }
const input = body.querySelector('#rampradar-icao-input');
if (input) input.value = n.icao;
const nameEl = body.querySelector('#rr-apt-name');
if (nameEl) nameEl.textContent = (n.apt && n.apt.name) ? (n.apt.name + (n.nm != null ? ' · ' + n.nm.toFixed(1) + ' NM' : '')) : '';
loadChartForIcao(n.icao);
};
const bmBtn = body.querySelector('#rr-bookmark');
if (bmBtn) bmBtn.onclick = () => { if (activeIcao) { toggleBookmark(activeIcao); renderBody(); } };
const input = body.querySelector('#rampradar-icao-input');
const loadBtn = body.querySelector('#rampradar-load-btn');
const nameEl = body.querySelector('#rr-apt-name');
const refreshAptName = async (code) => {
if (!nameEl) return;
code = (code || '').trim().toUpperCase();
if (!/^[A-Z0-9]{4}$/.test(code)) { nameEl.textContent = ''; return; }
await ensureAirportsDb();
const label = airportLabel(code);
nameEl.textContent = label || 'Unknown airport code';
};
if (loadBtn) {
const doLoad = () => loadChartForIcao((input.value || '').trim().toUpperCase());
loadBtn.onclick = doLoad;
if (input) {
input.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); doLoad(); } };
input.oninput = () => {
const v = (input.value || '').toUpperCase();
input.value = v;
refreshAptName(v);
};
}
}
refreshAptName(activeIcao);
ensureAirportsDb().then(() => refreshAptName(activeIcao));
const oc = body.querySelector('#rr-origin-chip');
const dc = body.querySelector('#rr-dest-chip');
if (oc) oc.onclick = () => { if (pilotRoute.origin) loadChartForIcao(pilotRoute.origin); };
if (dc) dc.onclick = () => { if (pilotRoute.dest) loadChartForIcao(pilotRoute.dest); };
const fp = body.querySelector('#rr-fp-sync');
if (fp) fp.onclick = () => {
if (syncRouteFromFlightPlan(true)) renderBody();
else alert('No ICAO waypoints found on the flight plan.');
};
const cs = body.querySelector('#rr-callsign');
if (cs) cs.onchange = () => gmSet(STORAGE.CALLSIGN, (cs.value || '').trim());
if (activeIcao) loadChartForIcao(activeIcao);
else updateStatus();
}
function renderBookmarksTab(body) {
const list = getBookmarks();
body.innerHTML = `
BOOKMARKED AIRPORTS
Click an ICAO to open its chart. Save from the Charts tab with ☆ SAVE.
${list.length
? `${list.map((b) => `
${escapeHtml(b)}
✕
`).join('')}
`
: `No bookmarks yet.
`}
`;
body.querySelectorAll('[data-bm]').forEach((btn) => {
btn.onclick = () => {
activeTab = 'charts';
activeIcao = btn.getAttribute('data-bm');
renderBody();
};
});
body.querySelectorAll('[data-rm]').forEach((btn) => {
btn.onclick = () => {
toggleBookmark(btn.getAttribute('data-rm'));
renderBody();
};
});
}
async function wxBlockHtml(label, icao) {
if (!icao) return `${escapeHtml(label)}
Not set — use FP sync or set route on Charts.
`;
await ensureAirportsDb();
const aptName = airportLabel(icao);
const [metar, vatisRes, datisRes] = await Promise.all([fetchMetarRaw(icao), fetchVatsimAtis(icao), fetchDigitalAtis(icao)]);
let html = `${escapeHtml(label)} · ${escapeHtml(icao)}
`;
if (aptName) html += `
${escapeHtml(aptName)}
`;
if (metar.error) html += `
${escapeHtml(metar.error)}
`;
else html += `
METAR
${escapeHtml(metar.raw)}
`;
if (vatisRes && vatisRes.length) {
html += `
LIVE ATIS — VATSIM NETWORK
`;
html += vatisRes.map((it) =>
`
${escapeHtml(it.callsign)} ${it.code ? '· Info ' + escapeHtml(it.code) : ''} ${it.freq ? '· ' + escapeHtml(it.freq) : ''}
${escapeHtml(it.text)}
`
).join('');
} else if (datisRes.source === 'datis' && datisRes.items && datisRes.items.length) {
html += `
AUTOMATED D-ATIS
`;
html += datisRes.items.map((it) =>
`
Info ${escapeHtml(it.code || '—')} ${it.time ? '· ' + escapeHtml(it.time) + 'Z' : ''} ${it.type ? '(' + escapeHtml(it.type) + ')' : ''}
${escapeHtml(it.text)}
`
).join('');
} else {
html += `
DECODED METAR (no live ATIS on station)
`;
if (metar.raw && !metar.error) {
const dec = decodeMetarSummary(metar.raw);
if (dec) {
html += `
Time ${escapeHtml(dec.time)}
Wind ${escapeHtml(dec.wind)}
Visibility ${escapeHtml(dec.vis)}
Clouds ${escapeHtml(dec.clouds)}
Temp / Dew ${escapeHtml(dec.temp)} / ${escapeHtml(dec.dew)}
QNH ${escapeHtml(dec.qnh)}
`;
}
} else {
html += `
${escapeHtml(datisRes.error || 'Unavailable')}
`;
}
}
html += `
`;
return html;
}
function renderWeatherTab(body) {
syncRouteFromFlightPlan(false);
body.innerHTML = `Origin & destination from your route / flight plan. Live ATIS from the VATSIM network when a station is online, then automated D-ATIS (mainly US), then decoded METAR. METAR from NOAA.
LOOKUP
FETCH
`;
const blocks = body.querySelector('#rr-wx-blocks');
Promise.all([
wxBlockHtml('ORIGIN', pilotRoute.origin),
wxBlockHtml('DESTINATION', pilotRoute.dest)
]).then(([a, b]) => { blocks.innerHTML = a + b; });
const go = async () => {
const icao = (body.querySelector('#rr-wx-icao').value || '').trim().toUpperCase();
const extra = body.querySelector('#rr-wx-extra');
if (!/^[A-Z]{4}$/.test(icao)) { extra.innerHTML = 'Invalid ICAO
'; return; }
extra.innerHTML = 'Loading…
';
extra.innerHTML = await wxBlockHtml('LOOKUP', icao);
};
body.querySelector('#rr-wx-go').onclick = go;
body.querySelector('#rr-wx-icao').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } };
}
function renderToolsTab(body) {
checkForUpdate();
const clLines = isUpdateAvailable() ? changelogBulletsFor(remoteVersion) : [];
const clHtml = clLines.length
? `${clLines.map((l) => `${escapeHtml(l)} `).join('')} `
: `See CHANGELOG.md on GitHub for details.
`;
const updateCard = isUpdateAvailable()
? `
UPDATE AVAILABLE — v${escapeHtml(remoteVersion)}
You are on v${escapeHtml(SCRIPT_VERSION)}
${clHtml}
Open update (raw script)
Opens the raw userscript URL in a new tab — Tampermonkey can install / update from there.
`
: (remoteVersion
? `Up to date · v${escapeHtml(SCRIPT_VERSION)}
`
: `RampRadar v${escapeHtml(SCRIPT_VERSION)}${versionCheckDone ? ' · could not check for updates' : ' · checking…'}
`);
body.innerHTML = `
${updateCard}
KEYBOARD SHORTCUT
Optional hotkey to open / restore / close. Ignored while typing.
${escapeHtml(formatShortcut(getOpenShortcut()))}
CLEAR
`;
body.querySelector('#rr-sc-clear').onclick = () => { setOpenShortcut(null); renderBody(); };
const cap = body.querySelector('#rr-sc-cap');
cap.onclick = () => {
if (shortcutCaptureMode) return;
shortcutCaptureMode = true;
cap.textContent = 'Press keys…';
const onCapture = (e) => {
if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation();
document.removeEventListener('keydown', onCapture, true);
shortcutCaptureMode = false; renderBody(); return;
}
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return;
e.preventDefault(); e.stopPropagation();
document.removeEventListener('keydown', onCapture, true);
setOpenShortcut({ ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey, key: e.key });
shortcutCaptureMode = false; renderBody();
};
document.addEventListener('keydown', onCapture, true);
};
}
function renderBody() {
if (!panelEl) return;
const body = panelEl.querySelector('#rampradar-body');
panelEl.querySelectorAll('#rampradar-tabs button').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-tab') === activeTab);
});
if (minimized) {
activeTab = 'charts';
body.classList.add('rr-charts-mode');
body.classList.remove('rr-scroll');
renderChartsTab(body);
return;
}
if (activeTab === 'charts') {
body.classList.add('rr-charts-mode');
body.classList.remove('rr-scroll');
renderChartsTab(body);
} else {
body.classList.remove('rr-charts-mode');
body.classList.add('rr-scroll');
if (activeTab === 'weather') renderWeatherTab(body);
else if (activeTab === 'bookmarks') renderBookmarksTab(body);
else renderToolsTab(body);
}
}
function makeDraggable(handle, target, signal, onEnd) {
let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0;
handle.addEventListener('mousedown', (e) => {
if (e.target.closest('button')) return;
dragging = true;
const rect = target.getBoundingClientRect();
target.style.left = rect.left + 'px'; target.style.top = rect.top + 'px';
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.transform = 'none';
startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top;
e.preventDefault();
}, { signal });
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
target.style.left = (startLeft + e.clientX - startX) + 'px';
target.style.top = (startTop + e.clientY - startY) + 'px';
}, { signal });
document.addEventListener('mouseup', () => { if (dragging && onEnd) onEnd(); dragging = false; }, { signal });
}
function makeResizable(handle, target, signal, onEnd, minW, minH) {
let resizing = false, startX = 0, startY = 0, startW = 0, startH = 0;
handle.addEventListener('mousedown', (e) => {
resizing = true;
const rect = target.getBoundingClientRect();
startX = e.clientX; startY = e.clientY; startW = rect.width; startH = rect.height;
e.preventDefault(); e.stopPropagation();
}, { signal });
document.addEventListener('mousemove', (e) => {
if (!resizing) return;
target.style.width = Math.max(minW || 280, Math.min(window.innerWidth - 16, startW + (e.clientX - startX))) + 'px';
target.style.height = Math.max(minH || 180, Math.min(window.innerHeight - 16, startH + (e.clientY - startY))) + 'px';
}, { signal });
document.addEventListener('mouseup', () => { if (resizing && onEnd) onEnd(); resizing = false; }, { signal });
}
function saveGeometry() {
if (!panelEl) return;
const rect = panelEl.getBoundingClientRect();
const payload = { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
if (minimized) gmSet(STORAGE.MINI_GEOMETRY, payload);
else gmSet(STORAGE.GEOMETRY, payload);
}
function openPanel() {
if (panelOpen) return;
panelOpen = true; minimized = false;
injectStyles();
syncRouteFromFlightPlan(false);
ensureAirportsDb();
checkForUpdate();
dragAbort = new AbortController();
backdropEl = document.createElement('div');
backdropEl.id = 'rampradar-backdrop';
backdropEl.onclick = closePanel;
document.body.appendChild(backdropEl);
panelEl = document.createElement('div');
panelEl.id = 'rampradar-panel';
panelEl.onclick = (e) => e.stopPropagation();
panelEl.innerHTML = `
RAMPRADAR
${escapeHtml(activeIcao || '')}
${formatClockHMS(new Date())}
CHARTS
METAR/ATIS
BOOKMARKS
TOOLS
—
▣
✕
`;
document.body.appendChild(panelEl);
const geom = gmGet(STORAGE.GEOMETRY, null);
if (geom && geom.width) {
panelEl.style.transform = 'none';
panelEl.style.top = Math.max(0, geom.top) + 'px';
panelEl.style.left = Math.max(0, geom.left) + 'px';
panelEl.style.width = geom.width + 'px';
panelEl.style.height = geom.height + 'px';
}
panelEl.querySelector('#rr-close').onclick = closePanel;
panelEl.querySelector('#rr-min').onclick = minimize;
panelEl.querySelector('#rr-max').onclick = () => {
if (minimized) { restore(); return; }
panelEl.classList.toggle('is-max');
};
panelEl.querySelectorAll('#rampradar-tabs button').forEach((b) => {
b.onclick = () => { activeTab = b.getAttribute('data-tab'); renderBody(); };
});
makeDraggable(panelEl.querySelector('#rampradar-topbar'), panelEl, dragAbort.signal, saveGeometry);
makeResizable(panelEl.querySelector('#rampradar-resize'), panelEl, dragAbort.signal, saveGeometry, 380, 280);
['keydown', 'keyup', 'keypress'].forEach((evt) => {
panelEl.addEventListener(evt, (e) => e.stopPropagation(), { signal: dragAbort.signal });
});
renderBody();
}
function minimize() {
if (!panelOpen || minimized) return;
saveGeometry();
minimized = true;
panelEl.classList.add('is-mini');
panelEl.classList.remove('is-max');
if (backdropEl) backdropEl.style.display = 'none';
const g = gmGet(STORAGE.MINI_GEOMETRY, null);
const s = MINI_SIZES[miniSizeIndex] || MINI_SIZES[1];
panelEl.style.transform = 'none';
if (g && g.left != null) {
panelEl.style.top = Math.max(0, g.top) + 'px';
panelEl.style.left = Math.max(0, g.left) + 'px';
panelEl.style.width = (g.width || s.w) + 'px';
panelEl.style.height = (g.height || s.h) + 'px';
} else {
panelEl.style.width = s.w + 'px';
panelEl.style.height = s.h + 'px';
panelEl.style.top = (window.innerHeight - s.h - 24) + 'px';
panelEl.style.left = (window.innerWidth - s.w - 24) + 'px';
}
panelEl.querySelector('#rr-min').title = 'Restore';
panelEl.querySelector('#rr-min').textContent = '□';
panelEl.querySelector('#rr-min').onclick = restore;
activeTab = 'charts';
renderBody();
}
function restore() {
if (!panelOpen || !minimized) return;
saveGeometry();
minimized = false;
panelEl.classList.remove('is-mini');
if (backdropEl) backdropEl.style.display = 'block';
const geom = gmGet(STORAGE.GEOMETRY, null);
if (geom && geom.width) {
panelEl.style.top = Math.max(0, geom.top) + 'px';
panelEl.style.left = Math.max(0, geom.left) + 'px';
panelEl.style.width = geom.width + 'px';
panelEl.style.height = geom.height + 'px';
} else {
panelEl.style.width = '';
panelEl.style.height = '';
panelEl.style.top = '50%';
panelEl.style.left = '50%';
panelEl.style.transform = 'translate(-50%,-50%)';
}
panelEl.querySelector('#rr-min').title = 'Minimize';
panelEl.querySelector('#rr-min').textContent = '—';
panelEl.querySelector('#rr-min').onclick = minimize;
renderBody();
}
function closePanel() {
panelOpen = false; minimized = false;
followTargetId = null;
hideAcTooltip();
if (dragAbort) { dragAbort.abort(); dragAbort = null; }
chartState = null;
if (panelEl) { panelEl.remove(); panelEl = null; }
if (backdropEl) { backdropEl.remove(); backdropEl = null; }
}
setInterval(() => {
if (!panelOpen) return;
const clock = panelEl && panelEl.querySelector('#rr-clock');
if (clock) clock.textContent = formatClockHMS(new Date());
if (activeTab === 'charts' && chartState) {
updateChartAircraft();
updateChartOtherAircraft();
}
}, 1000);
function addToolbarButton() {
if (document.getElementById('rampradar-toolbar-button')) return;
const buttonDiv = document.createElement('div');
buttonDiv.innerHTML = `CHARTS `;
let retryCount = 0;
function tryInsert() {
if (document.getElementById('rampradar-toolbar-button')) return;
const bottomUI = document.getElementsByClassName('geofs-ui-bottom')[0];
if (bottomUI) {
const element = buttonDiv.firstElementChild;
try {
if (typeof geofs !== 'undefined' && geofs.version >= 3.6) bottomUI.insertBefore(element, bottomUI.children[5] || null);
else bottomUI.insertBefore(element, bottomUI.children[4] || null);
} catch (e) { bottomUI.appendChild(element); }
element.onclick = function () {
if (!panelOpen) openPanel();
else if (minimized) restore();
else closePanel();
};
} else if (retryCount < 30) { retryCount++; setTimeout(tryInsert, 300); }
}
tryInsert();
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(() => { addToolbarButton(); installOpenShortcutListener(); });
setTimeout(addToolbarButton, 3000);
setTimeout(installOpenShortcutListener, 500);
})();