Real-time animated flight visualiser for Torn City. SVG world map, curved animated flight path, plane animation, ATC commentary and live flight stats.
// ==UserScript==
// @name TORN CITY Flight Visualiser
// @namespace sanxion.tc.flightvisualiser
// @version 31.0.0
// @license MIT
// @description Real-time animated flight visualiser for Torn City. SVG world map, curved animated flight path, plane animation, ATC commentary and live flight stats.
// @author Sanxion [2987640]
// @match https://www.torn.com/page.php?sid=travel*
// @connect api.torn.com
// @connect c.statcounter.com
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
/* ─────────────────────────────────────────────────────────────
DESTINATIONS (Torn City canon names — no "New York")
───────────────────────────────────────────────────────────── */
const MAP_W = 1000;
const MAP_H = 500;
const DESTS = {
torn: { label:'Torn City', country:'USA', city:'Torn City', lat:40.71, lon:-74.01, col:'#ff4444' },
mexico: { label:'Mexico', country:'Mexico', city:'Ciudad Juarez', lat:31.73, lon:-106.49, col:'#ff8800' },
caymans: { label:'Cayman Islands', country:'Cayman Islands', city:'George Town', lat:19.30, lon:-81.37, col:'#ffcc00' },
canada: { label:'Canada', country:'Canada', city:'Toronto', lat:44.5, lon:-83.5, col:'#ff44cc' },
hawaii: { label:'Hawaii', country:'USA', city:'Honolulu', lat:21.31, lon:-157.83, col:'#00ffcc' },
uk: { label:'United Kingdom', country:'United Kingdom', city:'London', lat:51.51, lon:-0.13, col:'#4488ff' },
argentina: { label:'Argentina', country:'Argentina', city:'Buenos Aires', lat:-34.61, lon:-58.38, col:'#44ffaa' },
switzerland: { label:'Switzerland', country:'Switzerland', city:'Zurich', lat:47.38, lon:8.54, col:'#aa44ff' },
japan: { label:'Japan', country:'Japan', city:'Tokyo', lat:35.69, lon:139.69, col:'#ff4488' },
china: { label:'China', country:'China', city:'Beijing', lat:39.91, lon:116.39, col:'#ff8844' },
uae: { label:'UAE', country:'United Arab Emirates', city:'Dubai', lat:25.20, lon:55.27, col:'#44ccff' },
southafrica: { label:'South Africa', country:'South Africa', city:'Johannesburg', lat:-26.20, lon:28.04, col:'#88ff44' },
};
/* ─────────────────────────────────────────────────────────────
BASE DURATIONS ms (standard ticket)
───────────────────────────────────────────────────────────── */
const BASE_DUR = {
torn_mexico:5400000, torn_caymans:4500000, torn_canada:2700000, torn_hawaii:14400000,
torn_uk:10800000, torn_argentina:14400000, torn_switzerland:12600000,
torn_japan:25200000, torn_china:25200000, torn_uae:21600000, torn_southafrica:25200000,
};
/* ─────────────────────────────────────────────────────────────
TICKET TYPES (per Torn City wiki)
Standard = Jumbo Jet (max alt 32,000 ft)
Business = Jumbo Jet (max alt 32,000 ft)
Private = Private Plane (max alt 32,000 ft)
Airstrip = Private Plane single-prop (max alt 12,000 ft)
───────────────────────────────────────────────────────────── */
const TICKETS = {
standard: { label:'Standard', plane:'jumbo', size:'large', mult:1.00, fuel:42000, speed:545, maxAlt:32000, col:'#aaaaaa' },
business: { label:'Business Class', plane:'jumbo', size:'large', mult:1.15, fuel:47000, speed:575, maxAlt:32000, col:'#4488ff' },
private: { label:'Private Plane', plane:'private_plane', size:'small', mult:1.80, fuel:18000, speed:480, maxAlt:32000, col:'#ff6644' },
airstrip: { label:'Airstrip', plane:'prop_plane', size:'small', mult:1.60, fuel:6000, speed:180, maxAlt:12000, col:'#88ff44' },
};
/* ─────────────────────────────────────────────────────────────
FLIGHT PHASES
───────────────────────────────────────────────────────────── */
const PHASE_CFG = {
ready: { label:'READY', col:'#6699aa' },
takeoff: { label:'TAKE-OFF', col:'#ffcc44' },
inflight: { label:'IN FLIGHT', col:'#44ccff' },
descent: { label:'DESCENT', col:'#ffaa44' },
landing: { label:'LANDING', col:'#ff8844' },
arrived: { label:'LANDED', col:'#44ff88' },
airport_closed: { label:'AIRPORT CLOSED', col:'#ff3333' },
};
const WEATHER = ['clear skies','partly cloudy','overcast','light rain','warm and humid','cool and breezy','sunny with light winds','scattered showers'];
const rndW = () => WEATHER[Math.floor(Math.random() * WEATHER.length)];
/* ─────────────────────────────────────────────────────────────
COMMENTARY (keyed by phase; each fn(params)->string)
No duplicates — each phase fires exactly once per flight.
───────────────────────────────────────────────────────────── */
// ── INFLIGHT POOLS — split by plane size ──────────────────────
// Fixed messages always shown at start of inflight phase
const INFLIGHT_FIXED_START_LARGE = [
p => `Levelling off at ${p.maxAlt.toLocaleString()} feet. Weather good. All clear.`,
() => 'Seat belt sign has been turned off.',
];
const INFLIGHT_FIXED_START_SMALL = [
p => `Levelling off at ${p.maxAlt.toLocaleString()} feet. Weather good. All clear.`,
() => 'A jet flies past, upside down.',
];
// Fixed messages always shown at end of inflight phase
const INFLIGHT_FIXED_END = [
p => `Cruising at ${p.speed} mph. Estimated arrival: ${p.eta}.`,
p => `Arrival time about ${p.arrivalTime}.`,
];
// Random pool for large planes — subset picked each flight
const INFLIGHT_RANDOM_LARGE = [
() => 'A baby starts crying across the aisle.',
() => 'Chedburn flies past.',
p => `${p.name}'s seat gets constantly kicked from behind by a small child.`,
() => 'A couple a few rows back start fighting each other.',
() => "Someone starts shouting, 'I'm sick of these m*fucking snakes on this m*fucking plane.'",
() => 'Outside the window, a shadowy figure smashes up the wing.',
() => 'A Canadian guy stares wide-eyed at the destruction to the wing.',
() => 'WARNING: Flight proximity alert!',
() => 'ATC stand by, unsure of error reason.',
() => 'A jet flies past, upside down.',
];
// Random pool for small planes — subset picked each flight
const INFLIGHT_RANDOM_SMALL = [
() => 'Up here, the sun shines brightly.',
() => 'The engine hums steadily.',
() => 'WARNING: Flight proximity alert!',
() => 'ATC stand by, unsure of error reason.',
p => `${p.name} glances down at the patchwork of fields below.`,
];
// Helper — returns the right commentary array based on plane size
const isSmallPlane = () => TICKETS[S.ticket]?.size === 'small';
const COMMENTARY = {
ready_large: [
() => 'Tower, pre-flight checks complete.',
p => `Flight requesting clearance for take-off from ${p.src} Airport.`,
() => 'Ladies and gentlemen, we are ready for take off.',
],
ready_small: [
p => `${p.name} requesting clearance for take-off from ${p.src} Airport.`,
p => `Preflight checks confirmed. ${p.name}.`,
],
takeoff_large: [
() => 'Cabin crew, cross-check ready for departure.',
() => 'The airplane picks up speed.',
() => 'The airplane leaves the ground.',
() => 'Sit back and relax.',
p => `Climbing to ${p.maxAlt.toLocaleString()} feet.`,
],
takeoff_small: [
p => `ATC: ${p.name}, you are cleared for take-off. Runway 1C. Proceed.`,
() => 'Increasing speed, throttle engaged.',
p => `Climbing to ${p.maxAlt.toLocaleString()} feet.`,
],
turbulence: [
() => 'Slight turbulence — nothing to worry about.',
],
descent_large: [
() => 'Cabin crew, prepare for descent.',
() => 'Miss Mile High Club pops her head up from behind a seat near the front.',
() => 'Someone honks up their in-flight meal.',
() => 'Ladies and gentlemen, please fasten your seat belts.',
p => `Weather in ${p.dst} is ${rndW()}. Have a nice day.`,
p => `${p.name} checks their weapons ready for plane disembarkation.`,
],
descent_small: [
p => `${p.name} flicks a few switches, initiating descent.`,
p => `${p.name} requesting clearance into ${p.dst}.`,
() => 'ATC: Confirmed, follow pre-planned flight path.',
p => `Weather in ${p.dst} is ${rndW()}. Have a nice day.`,
p => `${p.name} checks their weapons ready for plane disembarkation.`,
],
landing: [
() => 'Slight turbulence, but not too bad.',
() => 'Yes, weapons look good and oiled.',
],
arrived: [
() => '*Screech of tyres on tarmac.*',
p => `Arrival confirmed at ${p.dst}.`,
p => p.isTornCity
? 'Welcome to Torn City, please enjoy your stay, however long it will be. Stay safe. Thank you.'
: 'Remember: due to current circumstances, it is advisable to get your business done, and then leave the country. Thank you.',
],
return_start: [
() => 'Refuel complete. Taxiing to runway. Have a nice flight.',
p => `ATC: ${p.name}, you are cleared for take-off. Runway 2A. Proceed.`,
() => 'Wheels up. Heading home.',
],
};
// Get commentary for size-dependent phases
function getComm(phase, small) {
const key = `${phase}_${small ? 'small' : 'large'}`;
return COMMENTARY[key] || COMMENTARY[phase] || [];
}
/* ─────────────────────────────────────────────────────────────
STATE — persisted via GM_setValue
───────────────────────────────────────────────────────────── */
let S = {
src:'torn', dst:null, depTime:null, arrTime:null,
ticket:'standard', player:'Pilot', flying:false, isReturn:false,
prevPhase:'', phasesTriggered:{}, turbTriggered:false, halfwayFired:false,
log:[], px:20, py:60, pw:680, ph_panel:520, min:false, page:'main', apiKey:'',
previewDst:null, inflightSchedule:null, planeScale:100, inflightLogStart:null, diagnostics:null,
};
const saveS = () => {
try {
GM_setValue('tcfv_v3', JSON.stringify({
src:S.src, dst:S.dst, depTime:S.depTime, arrTime:S.arrTime,
ticket:S.ticket, player:S.player, flying:S.flying, isReturn:S.isReturn,
prevPhase:S.prevPhase, phasesTriggered:S.phasesTriggered, turbTriggered:S.turbTriggered, halfwayFired:S.halfwayFired,
log:S.log.slice(-30), px:S.px, py:S.py, pw:S.pw, ph_panel:S.ph_panel,
min:S.min, apiKey:S.apiKey, previewDst:S.previewDst, inflightSchedule:S.inflightSchedule, planeScale:S.planeScale, inflightLogStart:S.inflightLogStart, diagnostics:S.diagnostics,
}));
} catch(e) {}
};
const loadS = () => {
try {
const r = GM_getValue('tcfv_v3', null);
if (r) Object.assign(S, JSON.parse(r));
if (!S.phasesTriggered) S.phasesTriggered = {};
if (!S.inflightSchedule) S.inflightSchedule = null;
if (S.halfwayFired === undefined) S.halfwayFired = false;
if (!S.planeScale) S.planeScale = 100;
if (S.inflightLogStart === undefined) S.inflightLogStart = null;
} catch(e) {}
};
/* ─────────────────────────────────────────────────────────────
GEOMETRY
───────────────────────────────────────────────────────────── */
const toXY = (lon, lat) => ({
x: ((lon + 180) / 360) * MAP_W,
y: ((90 - lat) / 180) * MAP_H,
});
const haversine = (a, b) => {
const R = 3958.8, r = Math.PI / 180;
const dLat = (b.lat - a.lat) * r, dLon = (b.lon - a.lon) * r;
const x = Math.sin(dLat/2)**2 + Math.cos(a.lat*r) * Math.cos(b.lat*r) * Math.sin(dLon/2)**2;
return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1-x)));
};
const getDur = (sk, dk, tk) => {
const b = BASE_DUR[`${sk}_${dk}`] || BASE_DUR[`${dk}_${sk}`] || 7200000;
return Math.round(b / (TICKETS[tk]?.mult || 1));
};
const buildBez = (sk, dk) => {
const s = toXY(DESTS[sk].lon, DESTS[sk].lat);
const d = toXY(DESTS[dk].lon, DESTS[dk].lat);
const sag = Math.abs(d.x - s.x) * 0.22 + Math.abs(d.y - s.y) * 0.08;
const c = { x:(s.x+d.x)/2, y:Math.max(8,(s.y+d.y)/2 - sag) };
return { s, d, c };
};
const bPt = (t, s, c, d) => ({
x: (1-t)**2*s.x + 2*(1-t)*t*c.x + t**2*d.x,
y: (1-t)**2*s.y + 2*(1-t)*t*c.y + t**2*d.y,
});
const bAng = (t, s, c, d) => Math.atan2(
2*(1-t)*(c.y-s.y) + 2*t*(d.y-c.y),
2*(1-t)*(c.x-s.x) + 2*t*(d.x-c.x)
) * 180 / Math.PI;
/* ─────────────────────────────────────────────────────────────
FLIGHT CALCULATORS
───────────────────────────────────────────────────────────── */
const getPhase = p => {
if (!S.flying) return 'ready';
if (p < 0.05) return 'takeoff';
if (p < 0.75) return 'inflight';
if (p < 0.90) return 'descent';
if (p < 0.98) return 'landing';
return 'arrived';
};
// timeLeftMs optional — when supplied, altitude drops to 0 at 60s before landing
const getAlt = (p, timeLeftMs) => {
const maxAlt = TICKETS[S.ticket]?.maxAlt || 32000;
if (!S.flying || p <= 0) return 0;
if (p < 0.05) return Math.round(maxAlt * (p / 0.05));
if (p < 0.75) return maxAlt;
if (timeLeftMs !== undefined && timeLeftMs <= 60000) return 0;
return Math.max(0, Math.round(maxAlt * (1 - (p - 0.75) / 0.23)));
};
const getSpd = (p, mx) => {
if (!S.flying || p <= 0) return 0;
if (p < 0.05) return Math.round(mx * (p / 0.05));
if (p < 0.90) return mx;
if (p < 0.98) return Math.round(mx * (1 - (p - 0.90) / 0.08));
return 0;
};
const getFuel = (p, tk) => Math.max(0, Math.round((TICKETS[tk]?.fuel || 42000) * (1 - Math.max(0, p))));
const fmtTime = ms => {
if (ms <= 0) return 'Arrived';
const s = Math.floor(ms/1000), h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
return h > 0 ? `${h}h ${String(m).padStart(2,'0')}m` : m > 0 ? `${m}m ${String(ss).padStart(2,'0')}s` : `${ss}s`;
};
/* ─────────────────────────────────────────────────────────────
MAP VIEWPORT ZOOM — zooms SVG viewBox to frame the route
───────────────────────────────────────────────────────────── */
function getZoomedViewBox(sk, dk) {
if (!sk || !dk) return `0 0 ${MAP_W} ${MAP_H}`;
const s = toXY(DESTS[sk].lon, DESTS[sk].lat);
const d = toXY(DESTS[dk].lon, DESTS[dk].lat);
// Scale padding to route length so short routes zoom in closer
const routeW = Math.abs(d.x - s.x);
const routeH = Math.abs(d.y - s.y);
const routeSpan = Math.sqrt(routeW * routeW + routeH * routeH);
// Minimum span of 120px so very close routes still zoom in tightly
const minSpan = 120;
const effectiveSpan = Math.max(routeSpan, minSpan);
const pad = Math.max(40, effectiveSpan * 0.35);
let minX = Math.min(s.x, d.x) - pad;
let maxX = Math.max(s.x, d.x) + pad;
let minY = Math.min(s.y, d.y) - pad;
let maxY = Math.max(s.y, d.y) + pad;
// Clamp to map bounds
minX = Math.max(0, minX);
minY = Math.max(0, minY);
maxX = Math.min(MAP_W, maxX);
maxY = Math.min(MAP_H, maxY);
// Enforce minimum viewbox so dots are readable
const MIN_VW = 160;
if (maxX - minX < MIN_VW) {
const cx = (minX + maxX) / 2;
minX = Math.max(0, cx - MIN_VW / 2);
maxX = Math.min(MAP_W, cx + MIN_VW / 2);
}
// Maintain 2:1 aspect ratio
const vw = maxX - minX, vh = maxY - minY;
if (vw / vh < 2) {
const extra = (vh * 2 - vw) / 2;
minX = Math.max(0, minX - extra);
maxX = Math.min(MAP_W, maxX + extra);
}
return `${minX.toFixed(0)} ${minY.toFixed(0)} ${(maxX - minX).toFixed(0)} ${(maxY - minY).toFixed(0)}`;
}
/* ─────────────────────────────────────────────────────────────
SVG WORLD MAP (detailed coastline polygons, equirectangular)
───────────────────────────────────────────────────────────── */
function buildMapSVG() {
let dots = '';
for (const [key, d] of Object.entries(DESTS)) {
const { x, y } = toXY(d.lon, d.lat);
const right = x < MAP_W * 0.55, lx = right ? 12 : -12, anc = right ? 'start' : 'end';
dots += `<g id="tcfv-dot-${key}" class="dest-dot" transform="translate(${x.toFixed(1)},${y.toFixed(1)})">
<circle class="dot-glow" r="10" fill="${d.col}" opacity="0.08"/>
<circle class="dot-ring" r="5.5" fill="none" stroke="${d.col}" stroke-width="0.8" opacity="0.4"/>
<circle class="dot-core" r="3.5" fill="${d.col}" opacity="0.85"/>
<circle r="1.4" fill="#fff"/>
<text class="dot-lbl" x="${lx}" y="4" font-size="9" fill="${d.col}" text-anchor="${anc}" font-family="Courier New,monospace" opacity="0.7" style="pointer-events:none">${d.city}</text>
</g>`;
}
return `<defs>
<radialGradient id="og" cx="50%" cy="45%" r="65%">
<stop offset="0%" stop-color="#0c2040"/>
<stop offset="100%" stop-color="#05101a"/>
</radialGradient>
<filter id="gl" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glb" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="5" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<marker id="arr" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="rgba(255,255,255,0.5)"/>
</marker>
</defs>
<rect width="${MAP_W}" height="${MAP_H}" fill="url(#og)"/>
<!-- Graticule -->
<line x1="0" y1="${((90/180)*MAP_H).toFixed(0)}" x2="${MAP_W}" y2="${((90/180)*MAP_H).toFixed(0)}" stroke="#0d2035" stroke-width="1"/>
<line x1="${(MAP_W/2).toFixed(0)}" y1="0" x2="${(MAP_W/2).toFixed(0)}" y2="${MAP_H}" stroke="#0d2035" stroke-width="0.6"/>
<line x1="0" y1="${((66.5/180)*MAP_H).toFixed(0)}" x2="${MAP_W}" y2="${((66.5/180)*MAP_H).toFixed(0)}" stroke="#0c2a18" stroke-width="0.6" stroke-dasharray="6,6"/>
<line x1="0" y1="${((113.5/180)*MAP_H).toFixed(0)}" x2="${MAP_W}" y2="${((113.5/180)*MAP_H).toFixed(0)}" stroke="#0c2a18" stroke-width="0.6" stroke-dasharray="6,6"/>
<!-- ── NORTH AMERICA ── -->
<polygon points="130,64 171,41 253,34 306,36 349,45 388,65 391,89 376,114 360,137 370,170 348,208 318,235 289,255 260,272 232,260 203,235 175,215 149,185 130,143 119,105" fill="#1a4418" stroke="#2a6030" stroke-width="1.2"/>
<!-- Alaska -->
<polygon points="34,64 87,47 114,59 107,82 67,91 29,81" fill="#1a4418" stroke="#2a6030" stroke-width="0.8"/>
<!-- Aleutians (simplified) -->
<ellipse cx="20" cy="105" rx="18" ry="4" fill="#1a4418" stroke="#2a6030" stroke-width="0.5"/>
<!-- Greenland -->
<polygon points="334,23 383,15 411,33 398,64 352,71 329,51" fill="#22503a" stroke="#2e6c40" stroke-width="0.8"/>
<!-- Baja + Central America -->
<polygon points="170,174 193,196 185,238 165,254 148,225 163,190" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<polygon points="218,235 253,258 246,279 228,285 204,266 200,245" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- Caribbean islands -->
<ellipse cx="294" cy="240" rx="17" ry="7" fill="#1a4418" stroke="#2a6030" stroke-width="0.5"/>
<ellipse cx="256" cy="248" rx="7" ry="4" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- ── SOUTH AMERICA ── -->
<polygon points="237,254 280,242 338,249 373,277 385,326 367,380 338,412 296,420 261,398 242,350 234,303" fill="#1a4418" stroke="#2a6030" stroke-width="1.2"/>
<polygon points="237,254 266,244 271,262 251,269 234,264" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- Falkland Islands -->
<ellipse cx="289" cy="415" rx="9" ry="5" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- ── EUROPE ── -->
<polygon points="444,60 483,46 530,41 573,50 602,64 608,84 587,101 556,111 516,107 480,97 447,82" fill="#1a4418" stroke="#2a6030" stroke-width="1"/>
<polygon points="444,82 481,77 484,115 461,129 435,110" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- Scandinavia -->
<polygon points="491,42 523,28 561,34 570,50 548,60 506,58" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- British Isles -->
<polygon points="454,67 482,60 489,80 474,88 452,81" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<ellipse cx="462" cy="58" rx="10" ry="6" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- Italy -->
<polygon points="519,101 539,95 545,116 536,138 521,141 513,123" fill="#1a4418" stroke="#2a6030" stroke-width="0.5"/>
<!-- Iberian Peninsula -->
<polygon points="444,82 480,76 486,116 461,129 434,111" fill="#1a4418" stroke="#2a6030" stroke-width="0.5"/>
<!-- Greece -->
<polygon points="573,102 590,97 588,115 574,118 565,109" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- ── AFRICA ── -->
<polygon points="470,143 524,131 602,130 659,155 683,200 666,257 628,302 587,335 540,351 496,325 469,283 462,236 469,188" fill="#1a4418" stroke="#2a6030" stroke-width="1.2"/>
<!-- Horn of Africa -->
<polygon points="659,207 689,199 695,228 668,237 649,222" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- Madagascar -->
<polygon points="622,294 643,285 651,317 634,330 614,313" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- ── MIDDLE EAST ── -->
<polygon points="590,139 671,130 713,148 723,193 692,217 642,210 604,185" fill="#1a4418" stroke="#2a6030" stroke-width="0.8"/>
<!-- Turkey -->
<polygon points="578,103 647,94 668,109 663,129 598,136 572,120" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- ── ASIA ── -->
<polygon points="575,46 697,29 822,33 905,50 933,90 928,131 892,156 875,178 834,189 780,185 740,148 694,140 639,145 614,136 584,119 578,88" fill="#1a4418" stroke="#2a6030" stroke-width="1.2"/>
<!-- Indian Subcontinent -->
<polygon points="656,147 722,140 750,162 745,231 710,248 676,229 649,190" fill="#1a4418" stroke="#2a6030" stroke-width="0.7"/>
<ellipse cx="744" cy="246" rx="8" ry="11" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- SE Asia / Indochina -->
<polygon points="736,148 793,139 820,164 797,197 757,201 736,178" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<!-- Malaysia / Indonesia (simplified) -->
<polygon points="793,202 832,192 848,210 826,225 798,218" fill="#1a4418" stroke="#2a6030" stroke-width="0.5"/>
<polygon points="836,220 877,215 890,234 862,244 838,235" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- Japan -->
<polygon points="869,113 892,107 906,132 891,152 872,145" fill="#1a4418" stroke="#2a6030" stroke-width="0.6"/>
<polygon points="888,96 907,91 918,110 904,118 887,112" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- Korean Peninsula -->
<polygon points="841,115 858,109 862,133 848,139 837,128" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- Taiwan -->
<polygon points="839,181 850,175 855,192 845,198" fill="#1a4418" stroke="#2a6030" stroke-width="0.3"/>
<!-- Philippines (simplified) -->
<ellipse cx="862" cy="196" rx="9" ry="14" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- ── AUSTRALIA ── -->
<polygon points="782,285 875,264 933,286 941,334 904,361 851,375 789,350 770,313" fill="#1a4418" stroke="#2a6030" stroke-width="1.2"/>
<!-- Tasmania -->
<ellipse cx="875" cy="380" rx="12" ry="10" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- New Zealand -->
<polygon points="934,338 952,328 958,352 945,362 930,353" fill="#1a4418" stroke="#2a6030" stroke-width="0.5"/>
<polygon points="940,364 955,357 962,378 950,388 936,377" fill="#1a4418" stroke="#2a6030" stroke-width="0.4"/>
<!-- ── ANTARCTICA ── -->
<rect x="0" y="${MAP_H - 24}" width="${MAP_W}" height="24" fill="#1a3a26" opacity="0.7"/>
<!-- Dynamic layers (drawn on top of land) -->
<g id="tcfv-pathg"></g>
${dots}
<g id="tcfv-planeg"></g>`;
}
/* ─────────────────────────────────────────────────────────────
ANIMATED DASH OFFSET
───────────────────────────────────────────────────────────── */
let dashAnimId = null;
let dashOffset = 0;
function startDashAnim() {
if (dashAnimId) cancelAnimationFrame(dashAnimId);
const step = () => {
dashOffset = (dashOffset + 0.4) % 20;
const ahead = document.getElementById('tcfv-route-ahead');
if (ahead) ahead.style.strokeDashoffset = -dashOffset;
dashAnimId = requestAnimationFrame(step);
};
dashAnimId = requestAnimationFrame(step);
}
function stopDashAnim() {
if (dashAnimId) { cancelAnimationFrame(dashAnimId); dashAnimId = null; }
}
// Pre-computed bezier point array for current route (cached to avoid recalc every frame)
let _pathPts = null;
let _pathKey = '';
function getPathPts(sk, dk) {
const key = `${sk}-${dk}`;
if (_pathKey === key && _pathPts) return _pathPts;
const { s, d, c } = buildBez(sk, dk);
const pts = [];
for (let i = 0; i <= 120; i++) {
const p = bPt(i / 120, s, c, d);
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
}
_pathPts = pts;
_pathKey = key;
return pts;
}
/* ─────────────────────────────────────────────────────────────
DRAW FLIGHT PATH — solid trail behind plane, dashes ahead
───────────────────────────────────────────────────────────── */
function drawPath(sk, dk) {
const g = document.getElementById('tcfv-pathg');
if (!g) return;
if (!sk || !dk || sk === dk) {
g.innerHTML = '';
stopDashAnim();
_pathPts = null;
_pathKey = '';
return;
}
const pts = getPathPts(sk, dk);
const col = TICKETS[S.ticket]?.col || '#fff';
const { s } = buildBez(sk, dk);
const { d } = buildBez(sk, dk);
// Initially draw full path as dashes (progress=0). updatePathProgress() splits it when flying.
g.innerHTML = `
<polyline id="tcfv-route-trail" points="${pts[0]}" fill="none" stroke="${col}" stroke-width="2.2" stroke-linecap="round" opacity="0.85"/>
<polyline id="tcfv-route-ahead" points="${pts.join(' ')}" fill="none" stroke="${col}" stroke-width="2" stroke-dasharray="12,8" stroke-linecap="round" opacity="0.55"/>
<circle cx="${s.x.toFixed(1)}" cy="${s.y.toFixed(1)}" r="5" fill="${DESTS[sk]?.col||'#fff'}" opacity="0.9" filter="url(#gl)"/>
<circle cx="${d.x.toFixed(1)}" cy="${d.y.toFixed(1)}" r="5" fill="${DESTS[dk]?.col||'#fff'}" opacity="0.9" filter="url(#gl)"/>`;
startDashAnim();
}
// Called every tick to split the path at the plane's current position
function updatePathProgress(progress, sk, dk) {
if (!sk || !dk || sk === dk) return;
const trail = document.getElementById('tcfv-route-trail');
const ahead = document.getElementById('tcfv-route-ahead');
if (!trail || !ahead) return;
const pts = getPathPts(sk, dk);
const N = pts.length - 1;
// Split index based on progress
const splitIdx = Math.max(0, Math.min(N, Math.round(progress * N)));
// Trail: solid line from start to plane position
const trailPts = pts.slice(0, splitIdx + 1);
const aheadPts = pts.slice(splitIdx);
if (trailPts.length >= 2) trail.setAttribute('points', trailPts.join(' '));
else trail.setAttribute('points', pts[0] + ' ' + pts[0]);
if (aheadPts.length >= 2) ahead.setAttribute('points', aheadPts.join(' '));
else ahead.setAttribute('points', pts[N] + ' ' + pts[N]);
}
function drawPlane(progress, sk, dk) {
const g = document.getElementById('tcfv-planeg');
if (!g) return;
if (!sk || !dk || sk === dk) { g.innerHTML = ''; return; }
const { s, d, c } = buildBez(sk, dk);
const t = Math.max(0.001, Math.min(0.999, progress));
const pos = bPt(t, s, c, d), ang = bAng(t, s, c, d);
const plane = TICKETS[S.ticket]?.plane || 'jumbo';
const scale = (S.planeScale || 100) / 100;
// Top-down airplane silhouette — white fill, black stroke, transparent background
// Sized to be smaller than the destination dots (dot-core r=3.5, dot-ring r=5.5)
let svgShape;
if (plane === 'jumbo') {
// Wide-body top-down: broad fuselage, swept wings, horizontal stabiliser
svgShape = `
<ellipse cx="0" cy="0" rx="1.5" ry="4.5" fill="white" stroke="black" stroke-width="0.8"/>
<polygon points="0,-2 -6.5,1 -5.5,2 0,-0.5 5.5,2 6.5,1" fill="white" stroke="black" stroke-width="0.7"/>
<polygon points="0,2.5 -2.5,4.5 -2,5 0,3.5 2,5 2.5,4.5" fill="white" stroke="black" stroke-width="0.6"/>`;
} else if (plane === 'private_plane') {
// Slim private jet: narrow fuselage, swept wings, delta tail
svgShape = `
<ellipse cx="0" cy="0" rx="1" ry="4" fill="white" stroke="black" stroke-width="0.8"/>
<polygon points="0,-1.5 -5,1.5 -4.5,2.5 0,0.5 4.5,2.5 5,1.5" fill="white" stroke="black" stroke-width="0.7"/>
<polygon points="0,2.5 -2,4 -1.5,4.5 0,3.25 1.5,4.5 2,4" fill="white" stroke="black" stroke-width="0.6"/>`;
} else {
// Single-prop: straight wings, prop crossbar at nose
svgShape = `
<ellipse cx="0" cy="0.5" rx="1" ry="3.5" fill="white" stroke="black" stroke-width="0.8"/>
<polygon points="-4.5,-0.5 -4,0.5 4,0.5 4.5,-0.5" fill="white" stroke="black" stroke-width="0.7"/>
<polygon points="0,2.5 -1.5,4 -1,4.5 0,3.25 1,4.5 1.5,4" fill="white" stroke="black" stroke-width="0.6"/>
<line x1="-1.5" y1="-4" x2="1.5" y2="-4" stroke="black" stroke-width="1.2" stroke-linecap="round"/>`;
}
// Rotation: bAng gives tangent angle where 0°=right, 90°=down (SVG convention).
// The plane nose points up (-y = -90°). Adding 90° corrects this so nose aligns with travel direction.
const rotAngle = ang + 90;
g.innerHTML = `<g transform="translate(${pos.x.toFixed(1)},${pos.y.toFixed(1)}) rotate(${rotAngle.toFixed(1)}) scale(${scale})">
<g filter="url(#gl)">${svgShape}
</g>
</g>`;
}
/* ─────────────────────────────────────────────────────────────
HIGHLIGHT SELECTED DOTS
───────────────────────────────────────────────────────────── */
function highlightDots(srcK, dstK) {
for (const key of Object.keys(DESTS)) {
const isSelected = key === srcK || key === dstK;
const dotG = document.getElementById(`tcfv-dot-${key}`);
if (!dotG) continue;
const core = dotG.querySelector('.dot-core');
const glow = dotG.querySelector('.dot-glow');
const lbl = dotG.querySelector('.dot-lbl');
const ring = dotG.querySelector('.dot-ring');
if (isSelected) {
if (core) { core.setAttribute('r','5'); core.setAttribute('opacity','1'); }
if (glow) { glow.setAttribute('r','16'); glow.setAttribute('opacity','0.28'); }
if (lbl) { lbl.setAttribute('opacity','1'); lbl.setAttribute('font-size','11'); lbl.setAttribute('font-weight','bold'); }
if (ring) { ring.setAttribute('opacity','1'); ring.setAttribute('stroke-width','1.4'); }
} else {
if (core) { core.setAttribute('r','3.5'); core.setAttribute('opacity','0.85'); }
if (glow) { glow.setAttribute('r','10'); glow.setAttribute('opacity','0.08'); }
if (lbl) { lbl.setAttribute('opacity','0.7'); lbl.setAttribute('font-size','9'); lbl.setAttribute('font-weight','normal'); }
if (ring) { ring.setAttribute('opacity','0.4'); ring.setAttribute('stroke-width','0.8'); }
}
}
}
/* ─────────────────────────────────────────────────────────────
ELEMENT CACHE
───────────────────────────────────────────────────────────── */
let el = {};
/* ─────────────────────────────────────────────────────────────
STATS UPDATE
───────────────────────────────────────────────────────────── */
function updateStats(progress, timeLeftMs) {
if (!el.status) return;
const phase = getPhase(progress);
const src = DESTS[S.src], dst = S.dst ? DESTS[S.dst] : (S.previewDst ? DESTS[S.previewDst] : null);
const tkt = TICKETS[S.ticket] || TICKETS.standard;
const totalDist = src && dst ? haversine(src, dst) : 0;
let distRem;
if (S.flying && timeLeftMs !== undefined && timeLeftMs <= 60000 && totalDist > 0) {
// Smoothly interpolate from ~5 miles down to 0 over final 60 seconds
distRem = Math.max(0, Math.round(5 * (timeLeftMs / 60000)));
} else if (S.flying && progress > 0 && progress < 1 && totalDist > 0) {
distRem = Math.round(totalDist * (1 - progress));
} else {
distRem = totalDist;
}
const ph = PHASE_CFG[phase] || PHASE_CFG.ready;
el.status.textContent = ph.label;
el.status.style.color = ph.col;
el.destname.textContent = dst ? `${dst.city}, ${dst.country}` : '—';
el.dist.textContent = totalDist > 0 ? `${distRem.toLocaleString()} mi` : '— mi';
const dstKey = S.flying ? S.dst : S.previewDst;
const srcKey = S.src;
const dur = (srcKey && dstKey) ? getDur(srcKey, dstKey, S.ticket) : 0;
el.eta.textContent = S.flying && timeLeftMs > 0 ? fmtTime(timeLeftMs) : (dur > 0 ? fmtTime(dur) : '—');
el.alt.textContent = `${getAlt(progress, timeLeftMs).toLocaleString()} ft`;
el.spd.textContent = `${getSpd(progress, tkt.speed)} mph`;
el.fuel.textContent = `${getFuel(Math.max(0, progress), S.ticket).toLocaleString()} lbs`;
el.tkt.textContent = tkt.label;
}
/* ─────────────────────────────────────────────────────────────
COMMENTARY — fires once per phase, persists across refresh
───────────────────────────────────────────────────────────── */
let phRunId = {};
function addLog(text) {
S.log.push(text);
if (S.log.length > 30) S.log.shift();
renderLog();
}
function renderLog() {
if (!el.log) return;
// On refresh during inflight, show only inflight+ messages (not takeoff/ready)
const startIdx = (S.flying && S.inflightLogStart !== null) ? S.inflightLogStart : 0;
const lines = S.log.slice(startIdx).slice(-8);
el.log.innerHTML = lines.map((t, i) =>
`<div class="tl${i === lines.length-1 ? ' tln' : ''}">› ${t.replace(/&/g,'&').replace(/</g,'<')}</div>`
).join('');
el.log.scrollTop = el.log.scrollHeight;
}
// Fire commentary messages for a phase, staggered — only once per flight per phase
function triggerComm(phase, params) {
if (S.phasesTriggered[phase]) return; // already fired this phase this flight
S.phasesTriggered[phase] = true;
saveS();
const msgs = getComm(phase, params.isSmall);
if (!msgs || !msgs.length) return;
const rid = (phRunId[phase] = (phRunId[phase] || 0) + 1);
msgs.forEach((fn, i) => {
setTimeout(() => {
if (phRunId[phase] === rid) addLog(fn(params));
}, i * 3800);
});
}
/* ─────────────────────────────────────────────────────────────
FLIGHT LOOP
───────────────────────────────────────────────────────────── */
/* ─────────────────────────────────────────────────────────────
INFLIGHT RANDOM SCHEDULER
Picks a random subset of funny messages and spaces them
evenly across the full inflight period. Persists to storage
so a page refresh shows the same messages without repeats.
───────────────────────────────────────────────────────────── */
function buildInflightSchedule() {
if (S.inflightSchedule) return; // already built for this flight
const total = S.arrTime - S.depTime;
const inflightStart = S.depTime + total * 0.05;
const inflightEnd = S.depTime + total * 0.75;
const duration = inflightEnd - inflightStart;
const small = TICKETS[S.ticket]?.size === 'small';
const fixedStart = small ? INFLIGHT_FIXED_START_SMALL : INFLIGHT_FIXED_START_LARGE;
const randomPool = small ? INFLIGHT_RANDOM_SMALL : INFLIGHT_RANDOM_LARGE;
// Pick 3–5 random messages from the pool (never more than pool size)
const poolSize = randomPool.length;
const pickCount = Math.min(poolSize, 3 + Math.floor(Math.random() * 3));
const indices = Array.from({ length: poolSize }, (_, i) => i);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
const chosen = indices.slice(0, pickCount).sort((a, b) => a - b);
// Build schedule — fixed-start messages at beginning, random in middle, fixed-end near end
const schedule = [];
const fixedStartCount = fixedStart.length;
const fixedEndCount = INFLIGHT_FIXED_END.length;
const totalSlots = fixedStartCount + pickCount + fixedEndCount;
const slotSize = duration / (totalSlots + 1);
let slot = 1;
// Fixed start messages
for (let i = 0; i < fixedStartCount; i++) {
schedule.push({ pool:'fixed_start', idx:i, fireAt: inflightStart + slotSize * slot, fired:false });
slot++;
}
// Random pool messages
for (const idx of chosen) {
schedule.push({ pool:'random', idx, fireAt: inflightStart + slotSize * slot, fired:false });
slot++;
}
// Fixed end messages
for (let i = 0; i < fixedEndCount; i++) {
schedule.push({ pool:'fixed_end', idx:i, fireAt: inflightStart + slotSize * slot, fired:false });
slot++;
}
S.inflightSchedule = schedule;
saveS();
}
let loopTmr = null;
let turbFired = false;
function startLoop() {
if (loopTmr) clearTimeout(loopTmr);
tick();
}
function tick() {
// Airport closed check — runs regardless of flying state
const pageBody = document.body ? document.body.innerText : '';
if (pageBody.includes('You are currently in a race, you must leave or wait')) {
if (!S._airportClosedShown) {
S._airportClosedShown = true;
if (el.status) {
el.status.textContent = PHASE_CFG.airport_closed.label;
el.status.style.color = PHASE_CFG.airport_closed.col;
}
addLog('Airport closed — you are in a race.');
saveS();
}
loopTmr = setTimeout(tick, 3000);
return;
}
S._airportClosedShown = false;
if (!S.flying || !S.dst) {
updateStats(0, 0);
loopTmr = setTimeout(tick, 2000);
return;
}
const now = Date.now();
const total = S.arrTime - S.depTime;
const elapsed = now - S.depTime;
const progress = Math.min(1, Math.max(0, elapsed / total));
const timeLeft = Math.max(0, S.arrTime - now);
const phase = getPhase(progress);
const altNow = getAlt(progress, timeLeft);
const arrDate = new Date(S.arrTime);
const arrivalTime = `${String(arrDate.getHours()).padStart(2,'0')}:${String(arrDate.getMinutes()).padStart(2,'0')}`;
const params = {
name: S.player,
src: DESTS[S.src]?.city || 'the airport',
dst: DESTS[S.dst]?.city || 'your destination',
eta: fmtTime(timeLeft),
speed: TICKETS[S.ticket]?.speed || 545,
maxAlt: TICKETS[S.ticket]?.maxAlt || 32000,
arrivalTime,
isTornCity: S.dst === 'torn',
isSmall: TICKETS[S.ticket]?.size === 'small',
};
// Phase transition commentary (fires only once per phase)
// Note: 'landing' phase commentary is handled separately via landing_screech at 60s mark
// Note: 'inflight' phase is handled by the random scheduler below
// Note: 'arrived' phase is handled manually below to ensure messages are saved before state resets
if (phase !== S.prevPhase) {
S.prevPhase = phase;
if (phase !== 'landing' && phase !== 'inflight' && phase !== 'arrived') triggerComm(phase, params);
if (phase === 'inflight') {
// Mark as triggered so triggerComm won't double-fire, build schedule
S.phasesTriggered.inflight = true;
// Record log index so refresh only shows inflight messages, not takeoff
if (S.inflightLogStart === null) {
S.inflightLogStart = S.log.length;
saveS();
}
buildInflightSchedule();
}
if (phase === 'arrived') {
// Handle arrived manually: log all messages with staggered delays,
// then reset state ONLY after the last message has been logged and saved.
S.phasesTriggered.arrived = true;
const arrivedFns = COMMENTARY.arrived;
const capturedParams = Object.assign({}, params); // snapshot before any state change
arrivedFns.forEach((fn, i) => {
setTimeout(() => {
addLog(fn(capturedParams));
if (i === arrivedFns.length - 1) {
// All arrived messages are now in the log — safe to reset and save
const newSrc = S.dst;
S.flying = false;
S.src = newSrc;
S.dst = null;
S.phasesTriggered = {};
S.inflightSchedule = null;
turbFired = false;
saveS();
drawPath(null, null);
drawPlane(0, S.src, S.src);
highlightDots(S.src, null);
updateStats(0, 0);
}
}, i * 3800);
});
// Keep ticking slowly until state has fully reset
loopTmr = setTimeout(tick, arrivedFns.length * 3800 + 2500);
return;
}
}
// Fire scheduled inflight messages
if (phase === 'inflight' && S.inflightSchedule) {
const small = TICKETS[S.ticket]?.size === 'small';
const fixedStart = small ? INFLIGHT_FIXED_START_SMALL : INFLIGHT_FIXED_START_LARGE;
const randomPool = small ? INFLIGHT_RANDOM_SMALL : INFLIGHT_RANDOM_LARGE;
let scheduleChanged = false;
for (const item of S.inflightSchedule) {
if (!item.fired && now >= item.fireAt) {
item.fired = true;
scheduleChanged = true;
let fn;
if (item.pool === 'fixed_start') fn = fixedStart[item.idx];
else if (item.pool === 'fixed_end') fn = INFLIGHT_FIXED_END[item.idx];
else fn = randomPool[item.idx];
if (fn) addLog(fn(params));
}
}
if (scheduleChanged) saveS();
}
// Halfway message — fires once at 50% progress during inflight, branched by plane size
if (!S.halfwayFired && progress >= 0.5 && phase === 'inflight') {
S.halfwayFired = true;
const minsLeft = Math.round(timeLeft / 60000);
const small = TICKETS[S.ticket]?.size === 'small';
if (small) {
addLog('Halfway there.');
setTimeout(() => {
addLog(`Probably land at ${arrivalTime}, which is about ${minsLeft} minutes time.`);
saveS();
}, 2000);
} else {
addLog('Ladies and gentlemen, we are now halfway.');
setTimeout(() => {
addLog(`We are expected to land at ${arrivalTime}, which is in about ${minsLeft} minutes time.`);
saveS();
}, 2000);
}
saveS();
}
if (!S.turbTriggered && (phase === 'inflight' || phase === 'descent') && Math.random() < 0.003) {
S.turbTriggered = true;
triggerComm('turbulence', params);
saveS();
}
// Screech of tyres fires when altitude hits 0 — 60 seconds before end of flight
if (timeLeft <= 60000 && S.flying && !S.phasesTriggered.landing_screech) {
S.phasesTriggered.landing_screech = true;
triggerComm('landing', params);
saveS();
}
updateStats(progress, timeLeft);
drawPlane(progress, S.src, S.dst);
updatePathProgress(progress, S.src, S.dst);
loopTmr = setTimeout(tick, 1000);
}
/* ─────────────────────────────────────────────────────────────
BUILD HUD
───────────────────────────────────────────────────────────── */
function buildHUD() {
const panel = document.createElement('div');
panel.id = 'tcfv';
panel.style.width = S.pw + 'px';
panel.style.height = S.ph_panel + 'px';
panel.innerHTML = `
<div id="tcfv-hdr">
<span id="tcfv-title">✈ TORN CITY FLIGHT VISUALISER</span>
<div id="tcfv-hbtns">
<button class="thb ta" id="thb-main" title="Flight View">✈</button>
<button class="thb" id="thb-diag" title="Diagnostics">⚒</button>
<button class="thb" id="thb-set" title="API Settings">⚙</button>
<button class="thb" id="thb-more" title="General Setting">⛭</button>
<button class="thb" id="thb-radar" title="Overlay">◕</button>
<button class="thb" id="thb-cred" title="Credits">★</button>
<button class="thb" id="thb-min" title="Minimise">—</button>
</div>
</div>
<div id="tcfv-bod">
<div id="tcfv-main" class="tcfv-pg">
<div id="tcfv-mapbox">
<svg id="tcfv-svg" viewBox="0 0 ${MAP_W} ${MAP_H}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">${buildMapSVG()}</svg>
</div>
<div id="tcfv-lower">
<div id="tcfv-stats">
<div class="ts"><span class="tsl">Status</span> <span class="tsv" id="ts-status">READY</span></div>
<div class="ts"><span class="tsl">Destination</span> <span class="tsv" id="ts-destname">—</span></div>
<div class="ts"><span class="tsl">Distance</span> <span class="tsv" id="ts-dist">— mi</span></div>
<div class="ts"><span class="tsl">ETA</span> <span class="tsv" id="ts-eta">—</span></div>
<div class="ts"><span class="tsl">Altitude</span> <span class="tsv" id="ts-alt">0 ft</span></div>
<div class="ts"><span class="tsl">Airspeed</span> <span class="tsv" id="ts-spd">— mph</span></div>
<div class="ts"><span class="tsl">Fuel</span> <span class="tsv" id="ts-fuel">— lbs</span></div>
<div class="ts"><span class="tsl">Ticket</span> <span class="tsv" id="ts-tkt">Standard</span></div>
</div>
<div id="tcfv-atc">
<div id="tcfv-atc-ttl">📻 ATC / FLIGHT DECK</div>
<div id="tcfv-log"></div>
</div>
</div>
</div>
<div id="tcfv-set" class="tcfv-pg" style="display:none">
<h3>⚙ API Settings</h3>
<p>An <strong>API key</strong> lets the visualiser read your live flight data directly from the Torn City servers, giving accurate real departure and arrival times.</p>
<p>To get your API key: log in to Torn City → <strong>Preferences</strong> → <strong>API Keys</strong> tab → create a new key with at least <em>Public Access</em> enabled. It is a 16-character alphanumeric string.</p>
<label for="tcfv-api-inp">API Key</label><br>
<input id="tcfv-api-inp" type="password" placeholder="Paste your Torn API key here" autocomplete="off" spellcheck="false">
<br><br>
<button class="tcfv-btn" id="tcfv-api-save">💾 Save Key</button>
<button class="tcfv-btn" id="tcfv-api-test">🔗 Test Connection</button>
<p id="tcfv-api-msg"></p>
<hr>
<p class="note">Your API key is stored locally in Tampermonkey's secure storage and is only ever sent to api.torn.com. It is never transmitted anywhere else.</p>
</div>
<div id="tcfv-cred" class="tcfv-pg" style="display:none">
<h3>★ Credits</h3>
<p class="big-t">TORN CITY<br>Flight Visualiser</p>
<p class="ver-t">Version 31.0.0</p>
<p>Designed & developed by</p>
<a href="https://www.torn.com/profiles.php?XID=2987640" target="_blank" id="tcfv-author">✈ Sanxion [2987640]</a>
<hr>
<p class="note">Built for the Torn City community. Not affiliated with Torn Ltd.<br>
Flight timings, altimeter, airspeed, fuel loads and ATC commentary are approximations for entertainment purposes only.</p>
</div>
<div id="tcfv-diag" class="tcfv-pg" style="display:none">
<div id="tcfv-diag-inner"></div>
</div>
<div id="tcfv-more" class="tcfv-pg" style="display:none">
<h3>⛭ General Setting</h3>
<p>Adjust the size of the airplane image on the flight map.</p>
<div id="tcfv-scale-wrap">
<div class="scale-row">
<label for="tcfv-scale-slider">Plane Size</label>
<span id="tcfv-scale-val">100%</span>
</div>
<input id="tcfv-scale-slider" type="range" min="10" max="300" value="100" step="5">
<div id="tcfv-plane-preview-wrap">
<svg id="tcfv-plane-preview" viewBox="-20 -20 40 40" xmlns="http://www.w3.org/2000/svg" width="80" height="80">
<rect width="40" height="40" x="-20" y="-20" fill="#06101c" rx="4"/>
<g id="tcfv-preview-plane" transform="scale(1)">
<ellipse cx="0" cy="0" rx="1.5" ry="4.5" fill="white" stroke="black" stroke-width="0.8"/>
<polygon points="0,-2 -6.5,1 -5.5,2 0,-0.5 5.5,2 6.5,1" fill="white" stroke="black" stroke-width="0.7"/>
<polygon points="0,2.5 -2.5,4.5 -2,5 0,3.5 2,5 2.5,4.5" fill="white" stroke="black" stroke-width="0.6"/>
</g>
</svg>
<p class="note" style="margin-top:6px">Preview updates in real time. Actual plane on map rotates with flight direction.</p>
</div>
</div>
</div>
</div>
<div id="tcfv-resize-handle" title="Drag to resize"></div>`;
document.body.appendChild(panel);
el = {
panel,
bod: panel.querySelector('#tcfv-bod'),
status: panel.querySelector('#ts-status'),
destname: panel.querySelector('#ts-destname'),
dist: panel.querySelector('#ts-dist'),
eta: panel.querySelector('#ts-eta'),
alt: panel.querySelector('#ts-alt'),
spd: panel.querySelector('#ts-spd'),
fuel: panel.querySelector('#ts-fuel'),
tkt: panel.querySelector('#ts-tkt'),
log: panel.querySelector('#tcfv-log'),
pgMain: panel.querySelector('#tcfv-main'),
pgSet: panel.querySelector('#tcfv-set'),
pgCred: panel.querySelector('#tcfv-cred'),
pgMore: panel.querySelector('#tcfv-more'),
pgDiag: panel.querySelector('#tcfv-diag'),
svg: panel.querySelector('#tcfv-svg'),
};
panel.style.left = S.px + 'px';
panel.style.top = S.py + 'px';
makeDrag(panel, panel.querySelector('#tcfv-hdr'));
makeResize(panel, panel.querySelector('#tcfv-resize-handle'));
panel.querySelector('#thb-min').addEventListener('click', () => doMin(false));
panel.querySelector('#thb-radar').addEventListener('click', doRadar);
panel.querySelector('#thb-main').addEventListener('click', () => showPg('main'));
panel.querySelector('#thb-diag').addEventListener('click', () => showPg('diag'));
panel.querySelector('#thb-set').addEventListener('click', () => showPg('set'));
panel.querySelector('#thb-more').addEventListener('click', () => showPg('more'));
panel.querySelector('#thb-cred').addEventListener('click', () => showPg('cred'));
const apiInp = panel.querySelector('#tcfv-api-inp');
apiInp.value = S.apiKey || '';
panel.querySelector('#tcfv-api-save').addEventListener('click', () => {
S.apiKey = apiInp.value.trim(); saveS();
const m = panel.querySelector('#tcfv-api-msg');
m.textContent = 'Key saved successfully.'; m.style.color = '#44ff88';
});
panel.querySelector('#tcfv-api-test').addEventListener('click', () =>
testApiKey(apiInp.value.trim(), panel.querySelector('#tcfv-api-msg'))
);
// Plane size slider
const slider = panel.querySelector('#tcfv-scale-slider');
const scaleVal = panel.querySelector('#tcfv-scale-val');
const previewPlane = panel.querySelector('#tcfv-preview-plane');
slider.value = S.planeScale || 100;
scaleVal.textContent = `${slider.value}%`;
slider.addEventListener('input', () => {
S.planeScale = parseInt(slider.value, 10);
scaleVal.textContent = `${S.planeScale}%`;
const sc = S.planeScale / 100;
if (previewPlane) previewPlane.setAttribute('transform', `scale(${sc})`);
saveS();
});
// Set initial preview scale
if (previewPlane) previewPlane.setAttribute('transform', `scale(${(S.planeScale || 100) / 100})`);
// Restore radar mode
try {
const saved = GM_getValue('tcfv_radar', 0);
radarMode = typeof saved === 'number' ? saved : (saved ? 1 : 0);
if (radarMode > 0) applyRadarMode(el.panel);
} catch(e) {}
// Restore minimise state directly (doMin toggles so cannot be used here)
if (S.min) {
el.bod.style.display = 'none';
document.querySelector('#tcfv-resize-handle').style.display = 'none';
el.panel.style.height = 'auto';
el.panel.style.minHeight = '0';
el.panel.style.resize = 'none';
document.querySelector('#thb-min').innerHTML = '□';
}
showPg(S.page || 'main');
}
function showPg(pg) {
S.page = pg;
el.pgMain.style.display = pg === 'main' ? 'flex' : 'none';
el.pgSet.style.display = pg === 'set' ? 'block' : 'none';
el.pgCred.style.display = pg === 'cred' ? 'block' : 'none';
el.pgMore.style.display = pg === 'more' ? 'block' : 'none';
el.pgDiag.style.display = pg === 'diag' ? 'flex' : 'none';
if (pg === 'diag') renderDiagPage();
document.querySelectorAll('.thb').forEach(b => b.classList.remove('ta'));
const map = { main:'#thb-main', set:'#thb-set', cred:'#thb-cred', more:'#thb-more', diag:'#thb-diag' };
document.querySelector(map[pg])?.classList.add('ta');
saveS();
}
// ── DIAGNOSTICS ──────────────────────────────────────────────────────────
const DIAG_STATUS_COLS = { green:'#44ff88', yellow:'#ffcc44', red:'#ff4444' };
function generateDiagnostics() {
const isSmall = TICKETS[S.ticket]?.size === 'small';
const rnd = () => {
const r = Math.random();
if (r < 0.72) return 'green';
if (r < 0.92) return 'yellow';
return 'red';
};
const largeSystems = [
{ id:'electrical', name:'Electrical Systems', detail:'All buses nominal', x:140, y:52 },
{ id:'pressure', name:'Cabin Pressure', detail:'8.0 psi differential', x:140, y:80 },
{ id:'engines', name:'Engines (x4)', detail:'CFM56-7B thrust nominal', x:38, y:118 },
{ id:'wings', name:'Wings', detail:'Control surfaces nominal', x:252, y:108 },
{ id:'gear', name:'Flight Gear', detail:'Gear retracted', x:140, y:143 },
{ id:'tail', name:'Tail Wing', detail:'Stabilisers nominal', x:140, y:178 },
];
const smallSystems = [
{ id:'electrical', name:'Electrical Systems', detail:'Battery & alternator OK', x:140, y:68 },
{ id:'engine', name:'Engine', detail:'Lycoming O-360 nominal', x:140, y:22 },
{ id:'wings', name:'Wings', detail:'Control surfaces nominal', x:28, y:96 },
{ id:'gear', name:'Flight Gear', detail:'Gear retracted', x:140, y:128 },
{ id:'tail', name:'Tail Wing', detail:'Stabiliser nominal', x:140, y:162 },
];
const systems = (isSmall ? smallSystems : largeSystems).map(s => ({ ...s, status: rnd() }));
return { isSmall, systems };
}
function diagSVGLarge(systems) {
const sysMap = {};
systems.forEach(s => { sysMap[s.id] = s.status; });
const col = id => DIAG_STATUS_COLS[sysMap[id]] || '#444';
return `<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-height:200px">
<rect width="280" height="200" fill="#050e05"/>
<!-- Fuselage -->
<ellipse cx="140" cy="100" rx="13" ry="88" fill="none" stroke="#5ab0e8" stroke-width="1.5"/>
<!-- Main wings -->
<polygon points="130,75 22,120 26,130 134,92" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<polygon points="150,75 258,120 254,130 146,92" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<!-- Engine nacelles (L) -->
<ellipse cx="38" cy="118" rx="8" ry="13" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<ellipse cx="78" cy="105" rx="7" ry="11" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<!-- Engine nacelles (R) -->
<ellipse cx="202" cy="105" rx="7" ry="11" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<ellipse cx="242" cy="118" rx="8" ry="13" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<!-- Tail stabilisers -->
<polygon points="131,168 96,179 99,185 133,175" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<polygon points="149,168 184,179 181,185 147,175" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<!-- Nose -->
<ellipse cx="140" cy="18" rx="6" ry="8" fill="#0a1a2a" stroke="#5ab0e8" stroke-width="1"/>
<!-- Indicator dots with labels -->
<circle cx="140" cy="52" r="5" fill="${col('electrical')}" opacity="0.9"/>
<circle cx="140" cy="80" r="5" fill="${col('pressure')}" opacity="0.9"/>
<circle cx="38" cy="118" r="5" fill="${col('engines')}" opacity="0.9"/>
<circle cx="252" cy="108" r="5" fill="${col('wings')}" opacity="0.9"/>
<circle cx="140" cy="143" r="5" fill="${col('gear')}" opacity="0.9"/>
<circle cx="140" cy="178" r="5" fill="${col('tail')}" opacity="0.9"/>
<!-- Connecting lines to labels -->
<line x1="145" y1="52" x2="165" y2="52" stroke="#5ab0e8" stroke-width="0.5" opacity="0.4"/>
<line x1="145" y1="80" x2="165" y2="80" stroke="#5ab0e8" stroke-width="0.5" opacity="0.4"/>
<line x1="43" y1="118" x2="63" y2="118" stroke="#5ab0e8" stroke-width="0.5" opacity="0.4"/>
<line x1="247" y1="108" x2="227" y2="108" stroke="#5ab0e8" stroke-width="0.5" opacity="0.4"/>
<line x1="145" y1="143" x2="165" y2="143" stroke="#5ab0e8" stroke-width="0.5" opacity="0.4"/>
<line x1="145" y1="178" x2="165" y2="178" stroke="#5ab0e8" stroke-width="0.5" opacity="0.4"/>
</svg>`;
}
function diagSVGSmall(systems) {
const sysMap = {};
systems.forEach(s => { sysMap[s.id] = s.status; });
const col = id => DIAG_STATUS_COLS[sysMap[id]] || '#444';
return `<svg viewBox="0 0 280 190" xmlns="http://www.w3.org/2000/svg" width="100%" style="max-height:190px">
<rect width="280" height="190" fill="#050e05"/>
<!-- Fuselage -->
<ellipse cx="140" cy="95" rx="10" ry="74" fill="none" stroke="#88ff44" stroke-width="1.5"/>
<!-- Straight wings -->
<polygon points="132,88 20,96 22,104 134,95" fill="#0a1a0a" stroke="#88ff44" stroke-width="1"/>
<polygon points="148,88 260,96 258,104 146,95" fill="#0a1a0a" stroke="#88ff44" stroke-width="1"/>
<!-- Propeller at nose -->
<ellipse cx="140" cy="25" rx="6" ry="6" fill="#0a1a0a" stroke="#88ff44" stroke-width="1"/>
<line x1="140" y1="8" x2="140" y2="22" stroke="#88ff44" stroke-width="2"/>
<line x1="125" y1="20" x2="155" y2="20" stroke="#88ff44" stroke-width="2" stroke-linecap="round"/>
<!-- Tail stabilisers -->
<polygon points="132,155 100,165 102,171 134,162" fill="#0a1a0a" stroke="#88ff44" stroke-width="1"/>
<polygon points="148,155 180,165 178,171 146,162" fill="#0a1a0a" stroke="#88ff44" stroke-width="1"/>
<!-- Indicator dots -->
<circle cx="140" cy="68" r="5" fill="${col('electrical')}" opacity="0.9"/>
<circle cx="140" cy="22" r="5" fill="${col('engine')}" opacity="0.9"/>
<circle cx="28" cy="96" r="5" fill="${col('wings')}" opacity="0.9"/>
<circle cx="140" cy="128" r="5" fill="${col('gear')}" opacity="0.9"/>
<circle cx="140" cy="162" r="5" fill="${col('tail')}" opacity="0.9"/>
<!-- Lines -->
<line x1="145" y1="68" x2="165" y2="68" stroke="#88ff44" stroke-width="0.5" opacity="0.4"/>
<line x1="145" y1="22" x2="165" y2="22" stroke="#88ff44" stroke-width="0.5" opacity="0.4"/>
<line x1="33" y1="96" x2="53" y2="96" stroke="#88ff44" stroke-width="0.5" opacity="0.4"/>
<line x1="145" y1="128" x2="165" y2="128" stroke="#88ff44" stroke-width="0.5" opacity="0.4"/>
<line x1="145" y1="162" x2="165" y2="162" stroke="#88ff44" stroke-width="0.5" opacity="0.4"/>
</svg>`;
}
function renderDiagPage() {
const inner = document.getElementById('tcfv-diag-inner');
if (!inner) return;
if (!S.diagnostics) S.diagnostics = generateDiagnostics();
const d = S.diagnostics;
// Update flight gear status based on flight phase
const gearSys = d.systems.find(s => s.id === 'gear');
if (gearSys) {
const phase = S.flying ? (S.arrTime && (S.arrTime - Date.now() < 120000) ? 'landing' : 'flying') : 'ground';
if (phase === 'ground' || phase === 'landing') gearSys.status = 'green';
}
const schematic = d.isSmall ? diagSVGSmall(d.systems) : diagSVGLarge(d.systems);
const acType = d.isSmall ? 'PRIVATE PLANE' : 'JUMBO JET';
const rows = d.systems.map(s => {
const col = DIAG_STATUS_COLS[s.status];
const label = s.status.toUpperCase();
return `<div class="diag-row">
<span class="diag-ind" style="background:${col}"></span>
<span class="diag-name">${s.name}</span>
<span class="diag-detail">${s.detail}</span>
<span class="diag-status" style="color:${col}">${label}</span>
</div>`;
}).join('');
inner.innerHTML = `<div class="diag-header">
<span class="diag-title">⚒ AIRCRAFT DIAGNOSTICS</span>
<span class="diag-type">${acType}</span>
</div>
<div class="diag-schematic">${schematic}</div>
<div class="diag-systems">${rows}</div>`;
}
const RADAR_MODES = [
null,
{ name:'green', rc:'#00ff44', mid:'#006622', dark:'#000a00', line:'#004400', glow:'rgba(0,255,68,.3)', hue:90 },
{ name:'yellow', rc:'#ffee00', mid:'#665500', dark:'#0a0a00', line:'#444400', glow:'rgba(255,238,0,.3)', hue:45 },
{ name:'cyan', rc:'#00ffee', mid:'#006655', dark:'#000a09', line:'#004440', glow:'rgba(0,255,238,.3)', hue:170 },
{ name:'blue', rc:'#4488ff', mid:'#1a3a88', dark:'#000518', line:'#1a3060', glow:'rgba(68,136,255,.3)', hue:200 },
{ name:'purple', rc:'#cc44ff', mid:'#551a88', dark:'#080010', line:'#440088', glow:'rgba(204,68,255,.3)', hue:270 },
{ name:'orange', rc:'#ff8800', mid:'#883300', dark:'#0a0500', line:'#662200', glow:'rgba(255,136,0,.3)', hue:20 },
{ name:'red', rc:'#ff2244', mid:'#881122', dark:'#0a0005', line:'#660022', glow:'rgba(255,34,68,.3)', hue:0 },
{ name:'grey', rc:'#cccccc', mid:'#666666', dark:'#0a0a0a', line:'#333333', glow:'rgba(200,200,200,.2)', hue:0 },
];
let radarMode = 0; // 0=normal, 1-8=colour modes
function applyRadarMode(panel) {
const mode = RADAR_MODES[radarMode];
const btn = document.querySelector('#thb-radar');
if (!mode) {
panel.classList.remove('radar-mode');
['--rc','--rc-mid','--rc-dark','--rc-line','--rc-glow','--rc-filter'].forEach(v => panel.style.removeProperty(v));
if (btn) { btn.classList.remove('ta'); btn.title = 'Overlay'; }
} else {
panel.classList.add('radar-mode');
panel.style.setProperty('--rc', mode.rc);
panel.style.setProperty('--rc-mid', mode.mid);
panel.style.setProperty('--rc-dark', mode.dark);
panel.style.setProperty('--rc-line', mode.line);
panel.style.setProperty('--rc-glow', mode.glow);
const g = mode.name === 'grey' ? 'grayscale(0.7) ' : '';
panel.style.setProperty('--rc-filter', `${g}sepia(1) saturate(4) hue-rotate(${mode.hue}deg) brightness(0.85)`);
if (btn) { btn.classList.add('ta'); btn.title = `Overlay (${mode.name})`; }
}
try { GM_setValue('tcfv_radar', radarMode); } catch(e) {}
}
function doRadar() {
radarMode = (radarMode + 1) % RADAR_MODES.length;
applyRadarMode(el.panel);
}
function doMin(silent) {
S.min = !S.min;
const panel = el.panel;
const resizeHandle = document.querySelector('#tcfv-resize-handle');
if (S.min) {
// Collapse to just the header bar
el.bod.style.display = 'none';
resizeHandle.style.display = 'none';
panel.style.height = 'auto';
panel.style.minHeight = '0';
panel.style.resize = 'none';
} else {
// Restore to full size
el.bod.style.display = 'block';
resizeHandle.style.display = 'block';
panel.style.height = S.ph_panel + 'px';
panel.style.minHeight = '420px';
}
document.querySelector('#thb-min').innerHTML = S.min ? '□' : '—';
if (!silent) saveS();
}
/* ─────────────────────────────────────────────────────────────
DRAG & RESIZE
───────────────────────────────────────────────────────────── */
function makeDrag(panel, handle) {
let drag = false, ox = 0, oy = 0;
handle.addEventListener('mousedown', e => {
if (e.target.closest('button')) return;
drag = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop;
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!drag) return;
const nx = e.clientX - ox;
const ny = e.clientY - oy;
panel.style.left = nx + 'px'; panel.style.top = ny + 'px';
S.px = nx; S.py = ny;
});
document.addEventListener('mouseup', () => { if (drag) { drag = false; saveS(); } });
}
function makeResize(panel, handle) {
let resz = false, sx = 0, sy = 0, sw = 0, sh = 0;
handle.addEventListener('mousedown', e => {
resz = true; sx = e.clientX; sy = e.clientY;
sw = panel.offsetWidth; sh = panel.offsetHeight;
e.preventDefault(); e.stopPropagation();
});
document.addEventListener('mousemove', e => {
if (!resz) return;
const nw = Math.max(500, sw + (e.clientX - sx));
const nh = Math.max(420, sh + (e.clientY - sy));
panel.style.width = nw + 'px'; panel.style.height = nh + 'px';
S.pw = nw; S.ph_panel = nh;
});
document.addEventListener('mouseup', () => { if (resz) { resz = false; saveS(); } });
}
/* ─────────────────────────────────────────────────────────────
PREVIEW DESTINATION (immediate update when dest clicked)
───────────────────────────────────────────────────────────── */
function previewDest(dstK) {
if (S.flying) return;
S.previewDst = dstK;
drawPath(S.src, dstK);
// Zoom map to frame the route
if (el.svg) el.svg.setAttribute('viewBox', getZoomedViewBox(S.src, dstK));
highlightDots(S.src, dstK);
updateStats(0, 0);
saveS();
}
/* ─────────────────────────────────────────────────────────────
TORN PAGE DETECTION
───────────────────────────────────────────────────────────── */
const norm = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
function matchDest(text) {
if (!text) return null;
const t = norm(text);
for (const [k, d] of Object.entries(DESTS)) {
if (t.includes(norm(d.city)) || t.includes(norm(d.country)) || t.includes(norm(d.label))) return k;
}
return null;
}
function matchTicket(text) {
const t = (text || '').toLowerCase();
if (t.includes('private') && t.includes('jet')) return 'private';
if (t.includes('airstrip') || t.includes('private plane')) return 'airstrip';
if (t.includes('business')) return 'business';
return 'standard';
}
function readSelectedDest() {
const sels = [
'[class*="travel"][class*="active"]', '[class*="destination"][class*="active"]',
'[class*="country"][class*="active"]', '[class*="selected"]',
];
for (const sel of sels) {
for (const node of document.querySelectorAll(sel)) {
const m = matchDest(node.textContent);
if (m) return m;
}
}
return null;
}
function readSelectedTicket() {
const sels = [
'[class*="ticket"][class*="active"]', '[class*="class"][class*="active"]',
'[class*="method"][class*="active"]', '[class*="travel-method"][class*="active"]',
];
for (const sel of sels) {
const found = document.querySelector(sel);
if (found) return matchTicket(found.textContent);
}
return null;
}
/* ─────────────────────────────────────────────────────────────
HOOK — capture-phase click listener
───────────────────────────────────────────────────────────── */
function hookClicks() {
document.addEventListener('click', e => {
let t = e.target;
for (let i = 0; i < 5; i++) {
if (!t) break;
const txt = (t.textContent || '').trim().toLowerCase();
const cls = (t.className || '').toString().toLowerCase();
const id = (t.id || '').toLowerCase();
// Destination dot click → preview immediately
if (!S.flying && (cls.includes('country') || cls.includes('destination') || cls.includes('travel') || cls.includes('city') || cls.includes('location'))) {
const dm = matchDest(t.textContent);
if (dm && dm !== S.src) { previewDest(dm); }
}
// Ticket type selection → update ticket on visualiser immediately
if (cls.includes('ticket') || cls.includes('class') || cls.includes('method') || cls.includes('airstrip')) {
const tk = matchTicket(t.textContent);
if (S.ticket !== tk) {
S.ticket = tk;
if (el.tkt) el.tkt.textContent = TICKETS[tk]?.label || tk;
if (S.previewDst && !S.flying) {
drawPath(S.src, S.previewDst);
updateStats(0, 0);
}
saveS();
}
}
// Return home / fly back button detection
if ((txt.includes('return') && (txt.includes('home') || txt.includes('torn') || txt.includes('back'))) ||
txt === 'fly home' || txt === 'return home' || txt === 'go home' ||
cls.includes('return') || cls.includes('fly-home') || id.includes('return') || id.includes('home')) {
if (S.src !== 'torn' && !S.flying) {
startFlight('torn', S.ticket, true);
return;
}
}
// Fly button
if (txt === 'fly' || txt === 'fly now' || txt === 'fly!' || txt === 'take off' ||
cls.includes('fly-btn') || cls.includes('flybtn') || id.includes('fly') || id.includes('takeoff')) {
const dst = readSelectedDest() || S.previewDst || S.dst;
const tkt = readSelectedTicket() || S.ticket;
if (dst) { startFlight(dst, tkt, S.src !== 'torn'); return; }
}
t = t.parentElement;
}
}, true);
}
/* ─────────────────────────────────────────────────────────────
NETWORK HOOK (XHR + fetch intercept)
───────────────────────────────────────────────────────────── */
function hookNetwork() {
const oOpen = XMLHttpRequest.prototype.open;
const oSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u, ...r) { this._turl = u; return oOpen.apply(this, [m, u, ...r]); };
XMLHttpRequest.prototype.send = function(...a) {
this.addEventListener('load', function() {
try { handleNetResponse(this._turl, JSON.parse(this.responseText)); } catch(e) {}
});
return oSend.apply(this, a);
};
const oFetch = window.fetch;
window.fetch = function(...a) {
const url = typeof a[0] === 'string' ? a[0] : (a[0]?.url || '');
const pr = oFetch.apply(this, a);
pr.then(r => r.clone().text().then(t => {
try { handleNetResponse(url, JSON.parse(t)); } catch(e) {}
})).catch(() => {});
return pr;
};
}
function handleNetResponse(url, data) {
if (!data) return;
const travel = data.travel || data.travelling || null;
if (!travel) return;
const dest = travel.destination || travel.dest || '';
const method = travel.method || travel.ticket || '';
const dep = (travel.departed || 0) * 1000;
const arr = (travel.timestamp || 0) * 1000;
if (!dest || !dep || !arr) return;
const dk = matchDest(dest), tk = matchTicket(method);
// Do not reinitialise an already-tracked flight — would clear log and commentary.
// Use arrival time with tolerance since dep time may differ (click vs API timestamps).
if (S.flying && Math.abs(S.arrTime - arr) < 10000) return;
if (dk && dk !== 'torn') {
startFlightTimes('torn', dk, tk, dep, arr, false);
} else if ((!dk || dk === 'torn') && S.src !== 'torn') {
startFlightTimes(S.src, 'torn', tk, dep, arr, true);
}
}
/* ─────────────────────────────────────────────────────────────
MUTATION OBSERVER
───────────────────────────────────────────────────────────── */
function watchDOM() {
let db;
const obs = new MutationObserver(() => {
clearTimeout(db);
db = setTimeout(() => {
// Check for ticket type changes
const tk = readSelectedTicket();
if (tk && tk !== S.ticket) {
S.ticket = tk;
if (el.tkt) el.tkt.textContent = TICKETS[tk]?.label || tk;
if (S.previewDst && !S.flying) {
drawPath(S.src, S.previewDst);
updateStats(0, 0);
}
saveS();
}
// Check for flying text appearing in DOM
if (!S.flying) {
const body = document.body.textContent;
const m = body.match(/(?:travelling|traveling|flying)\s+to\s+([A-Za-z\s]{3,30})(?:[.,\n]|$)/i);
if (m) {
const dk = matchDest(m[1]);
if (dk && dk !== S.dst) {
const dur = getDur(S.src, dk, S.ticket);
startFlightTimes(S.src, dk, S.ticket, Date.now(), Date.now() + dur, S.src !== 'torn');
}
}
}
}, 500);
});
obs.observe(document.body, { childList:true, subtree:true, characterData:true, attributes:true, attributeFilter:['class'] });
}
/* ─────────────────────────────────────────────────────────────
START FLIGHT
───────────────────────────────────────────────────────────── */
function startFlight(dk, tk, isReturn) {
const dur = getDur(S.src, dk, tk);
startFlightTimes(S.src, dk, tk, Date.now(), Date.now() + dur, isReturn);
}
function startFlightTimes(sk, dk, tk, dep, arr, isReturn) {
S.src = sk; S.dst = dk; S.ticket = tk;
S.depTime = dep; S.arrTime = arr;
S.flying = true; S.isReturn = isReturn;
S.prevPhase = ''; S.phasesTriggered = {}; S.turbTriggered = false; S.halfwayFired = false;
turbFired = false;
S.inflightSchedule = null;
S.inflightLogStart = null;
S.diagnostics = null;
S.log = [];
S.previewDst = null;
saveS();
drawPath(sk, dk);
if (el.svg) el.svg.setAttribute('viewBox', getZoomedViewBox(sk, dk));
highlightDots(sk, dk);
if (isReturn) {
const p = {
name: S.player,
src: DESTS[sk]?.city || '',
dst: DESTS[dk]?.city || 'Torn City',
eta: fmtTime(arr - Date.now()),
speed: TICKETS[tk]?.speed || 545,
maxAlt: TICKETS[tk]?.maxAlt || 32000,
arrivalTime: (() => { const d = new Date(arr); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; })(),
isTornCity: dk === 'torn',
};
triggerComm('return_start', p);
// Suppress the standard takeoff ATC message — return_start already has one
S.phasesTriggered.takeoff = true;
saveS();
}
startLoop();
}
/* ─────────────────────────────────────────────────────────────
TORN API
───────────────────────────────────────────────────────────── */
function apiGet(key, cb) {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.torn.com/user/?selections=travel,basic&key=${key}`,
onload: r => { try { cb(null, JSON.parse(r.responseText)); } catch(e) { cb(e); } },
onerror: e => cb(e),
});
}
function testApiKey(key, msgEl) {
if (!key) { msgEl.textContent = 'Please enter an API key first.'; return; }
msgEl.textContent = 'Testing\u2026'; msgEl.style.color = '#aaa';
apiGet(key, (err, data) => {
if (err || data?.error) {
msgEl.textContent = `Error: ${data?.error?.error || String(err)}`;
msgEl.style.color = '#ff4444';
} else {
S.player = data.name || S.player;
msgEl.textContent = `Connected as: ${data.name} [${data.player_id}]`;
msgEl.style.color = '#44ff88';
}
});
}
function initFromApi() {
if (!S.apiKey) return;
apiGet(S.apiKey, (err, data) => {
if (err || !data || data.error) return;
if (data.name) S.player = data.name;
const tr = data.travel;
if (!tr || !tr.departed || !tr.timestamp) return;
if (Date.now() > tr.timestamp * 1000) return;
const dk = matchDest(tr.destination || '');
const tk = matchTicket(tr.method || '');
const dep = tr.departed * 1000, arr = tr.timestamp * 1000;
// Do not reinitialise an already-tracked flight — would clear log and commentary
if (S.flying && Math.abs(S.arrTime - arr) < 10000) return;
if (dk && dk !== 'torn') {
startFlightTimes('torn', dk, tk, dep, arr, false);
} else if (S.src !== 'torn') {
startFlightTimes(S.src, 'torn', tk, dep, arr, true);
}
});
}
/* ─────────────────────────────────────────────────────────────
CSS
───────────────────────────────────────────────────────────── */
function injectCSS() {
GM_addStyle(`
#tcfv {
position: fixed;
z-index: 999999;
min-width: 500px;
min-height: 420px;
background: #0a131f;
border: 1px solid #1e3d5c;
border-radius: 8px;
box-shadow: 0 6px 40px rgba(0,80,160,.5), inset 0 1px 0 rgba(100,180,255,.06);
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
color: #b8d4ee;
display: flex;
flex-direction: column;
overflow: hidden;
resize: none;
}
#tcfv-hdr {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: linear-gradient(90deg,#070f1a,#0d1f35,#070f1a);
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #1a3550;
cursor: move;
user-select: none;
flex-shrink: 0;
}
#tcfv-title {
font-size: 10px;
font-weight: bold;
color: #5ab0e8;
letter-spacing: 3px;
text-shadow: 0 0 10px rgba(80,180,255,.35);
}
#tcfv-hbtns { display: flex; gap: 3px; }
.thb {
background: #0f1e30;
border: 1px solid #1e3d5c;
color: #5a8ab8;
border-radius: 3px;
padding: 1px 7px;
cursor: pointer;
font-size: 12px;
line-height: 1.7;
transition: background .15s, color .15s;
}
.thb:hover, .ta { background: #1a3a5a; color: #8ac8ff; border-color: #3a6a9a; }
#tcfv-bod { display: block; flex: 1; overflow: hidden; }
.tcfv-pg { height: 100%; }
#tcfv-main { display: flex; flex-direction: column; height: 100%; }
#tcfv-mapbox { flex: 1; overflow: hidden; background: #06101c; border-bottom: 1px solid #0e2035; min-height: 0; }
#tcfv-svg { width: 100%; height: 100%; display: block; transition: all 0.5s ease; }
#tcfv-lower { height: 170px; flex-shrink: 0; display: flex; overflow: hidden; }
#tcfv-stats { width: 220px; min-width: 220px; padding: 8px 10px; border-right: 1px solid #0e2035; overflow: hidden; }
.ts { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; padding-bottom: 3px; border-bottom: 1px solid #0c1a28; }
.tsl { font-size: 9px; color: #3a6a8a; letter-spacing: 1.5px; text-transform: uppercase; white-space: nowrap; }
.tsv { font-size: 11px; color: #6abcee; font-weight: bold; text-align: right; }
#tcfv-atc { flex: 1; display: flex; flex-direction: column; padding: 6px 8px; overflow: hidden; min-width: 0; }
#tcfv-atc-ttl { font-size: 9px; color: #3a6a8a; letter-spacing: 2px; text-transform: uppercase; padding-bottom: 4px; margin-bottom: 4px; border-bottom: 1px solid #0e2035; flex-shrink: 0; }
#tcfv-log { flex: 1; overflow-y: auto; font-size: 10.5px; color: #8ab8d8; line-height: 1.65; }
#tcfv-log::-webkit-scrollbar { width: 3px; }
#tcfv-log::-webkit-scrollbar-thumb { background: #1e3d5c; border-radius: 2px; }
.tl { padding: 1px 0; border-bottom: 1px dotted #08121e; }
.tln { color: #c8e890 !important; }
#tcfv-set, #tcfv-cred { padding: 14px 16px; overflow-y: auto; height: 100%; box-sizing: border-box; }
#tcfv-set h3, #tcfv-cred h3 { color: #5ab0e8; font-size: 11px; margin: 0 0 12px; border-bottom: 1px solid #1e3d5c; padding-bottom: 6px; letter-spacing: 2px; text-transform: uppercase; }
#tcfv-set p, #tcfv-cred p { margin: 8px 0; color: #8ab8d8; font-size: 11px; line-height: 1.65; }
#tcfv-set label { color: #4a7a9a; font-size: 10px; letter-spacing: 1px; text-transform: uppercase; }
#tcfv-api-inp { width: 92%; margin: 6px 0; padding: 5px 8px; background: #0c1a28; color: #b8d4ee; border: 1px solid #1e3d5c; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; outline: none; }
#tcfv-api-inp:focus { border-color: #3a6a9a; }
#tcfv-api-msg { font-size: 11px; min-height: 18px; margin: 6px 0; }
.tcfv-btn { background: #0e2035; border: 1px solid #1e3d5c; color: #5ab0e8; border-radius: 4px; padding: 4px 11px; cursor: pointer; font-size: 11px; margin-right: 6px; font-family: monospace; transition: background .15s; }
.tcfv-btn:hover { background: #1a3a5a; }
hr { border: none; border-top: 1px solid #1a3550; margin: 12px 0; }
.note { color: #445566 !important; font-size: 11px !important; line-height: 1.6 !important; }
.big-t { font-size: 18px; font-weight: bold; color: #5ab0e8 !important; line-height: 1.4 !important; letter-spacing: 1px; }
.ver-t { font-size: 11px; color: #3a6a8a !important; margin-bottom: 14px !important; }
#tcfv-author { display: inline-block; margin: 6px 0; color: #44aaff; font-size: 16px; font-weight: bold; text-decoration: none; letter-spacing: 1px; }
#tcfv-author:hover { color: #88ccff; text-decoration: underline; }
#tcfv-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 40%, #1e3d5c 40%, #1e3d5c 55%, transparent 55%, transparent 70%, #1e3d5c 70%, #1e3d5c 85%, transparent 85%);
border-radius: 0 0 8px 0;
opacity: 0.7;
}
#tcfv-resize-handle:hover { opacity: 1; }
/* ── DIAGNOSTICS ── */
#tcfv-diag { flex-direction: column; height: 100%; overflow-y: auto; background: #050e05; }
#tcfv-diag-inner { padding: 0; flex: 1; }
.diag-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px 4px; border-bottom: 1px solid #1a3520; }
.diag-title { font-size: 9px; color: #44ff88; letter-spacing: 2.5px; text-transform: uppercase; }
.diag-type { font-size: 9px; color: #336633; letter-spacing: 1px; }
.diag-schematic { padding: 6px 8px 2px; border-bottom: 1px solid #0a2010; }
.diag-systems { padding: 6px 8px; }
.diag-row { display: grid; grid-template-columns: 10px 1fr 1fr 56px; gap: 4px; align-items: center; padding: 3px 0; border-bottom: 1px dotted #0a1a0a; }
.diag-ind { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 5px currentColor; }
.diag-name { font-size: 10px; color: #66bb66; }
.diag-detail { font-size: 9px; color: #336633; }
.diag-status { font-size: 9px; font-weight: bold; text-align: right; letter-spacing: 0.5px; }
#tcfv.radar-mode #tcfv-diag { background: var(--rc-dark); }
#tcfv.radar-mode .diag-header { border-bottom-color: var(--rc-line); }
#tcfv.radar-mode .diag-title { color: var(--rc); }
#tcfv.radar-mode .diag-type { color: var(--rc-mid); }
#tcfv.radar-mode .diag-name { color: var(--rc); }
#tcfv.radar-mode .diag-detail { color: var(--rc-mid); }
#tcfv.radar-mode .diag-row { border-bottom-color: var(--rc-line); }
/* ── MORE SETTINGS ── */
#tcfv-more { padding: 14px 16px; overflow-y: auto; height: 100%; box-sizing: border-box; }
#tcfv-more h3 { color: #5ab0e8; font-size: 11px; margin: 0 0 12px; border-bottom: 1px solid #1e3d5c; padding-bottom: 6px; letter-spacing: 2px; text-transform: uppercase; }
#tcfv-more p { margin: 8px 0; color: #8ab8d8; font-size: 11px; line-height: 1.65; }
#tcfv-scale-wrap { margin-top: 10px; }
.scale-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.scale-row label { color: #4a7a9a; font-size: 10px; letter-spacing: 1px; text-transform: uppercase; }
#tcfv-scale-val { color: #6abcee; font-size: 13px; font-weight: bold; }
#tcfv-scale-slider { width: 100%; accent-color: #4488ff; cursor: pointer; margin-bottom: 14px; }
#tcfv-plane-preview-wrap { display: flex; flex-direction: column; align-items: center; margin-top: 6px; }
#tcfv-plane-preview { border: 1px solid #1e3d5c; border-radius: 6px; }
#tcfv.radar-mode #tcfv-more h3 { color: var(--rc) !important; border-bottom-color: var(--rc-line) !important; }
#tcfv.radar-mode #tcfv-more p { color: var(--rc-mid); }
#tcfv.radar-mode #tcfv-scale-val { color: var(--rc); }
#tcfv.radar-mode #tcfv-scale-slider { accent-color: var(--rc); }
#tcfv.radar-mode #tcfv-plane-preview { border-color: var(--rc-line); background: var(--rc-dark); }
#tcfv.radar-mode .scale-row label { color: var(--rc-mid); }
#tcfv.radar-mode {
background: var(--rc-dark);
border-color: var(--rc);
box-shadow: 0 0 30px var(--rc-glow), 0 0 60px var(--rc-glow), inset 0 0 20px rgba(0,0,0,.4);
}
#tcfv.radar-mode::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,.18) 2px, rgba(0,0,0,.18) 4px);
pointer-events: none;
z-index: 1000000;
border-radius: 8px;
}
#tcfv.radar-mode #tcfv-hdr {
background: linear-gradient(90deg, var(--rc-dark), var(--rc-line), var(--rc-dark));
border-bottom-color: var(--rc-line);
}
#tcfv.radar-mode #tcfv-title { color: var(--rc); text-shadow: 0 0 12px var(--rc); }
#tcfv.radar-mode .thb { background: var(--rc-dark); border-color: var(--rc-line); color: var(--rc-mid); }
#tcfv.radar-mode .thb:hover,#tcfv.radar-mode .ta { background: var(--rc-line); color: var(--rc); border-color: var(--rc-mid); }
#tcfv.radar-mode #tcfv-mapbox { background: var(--rc-dark); }
#tcfv.radar-mode #tcfv-svg { filter: var(--rc-filter); }
#tcfv.radar-mode #tcfv-lower,#tcfv.radar-mode #tcfv-set,#tcfv.radar-mode #tcfv-cred { background: var(--rc-dark); }
#tcfv.radar-mode .ts { border-bottom-color: var(--rc-line); }
#tcfv.radar-mode .tsl { color: var(--rc-mid); }
#tcfv.radar-mode .tsv { color: var(--rc); text-shadow: 0 0 6px var(--rc); }
#tcfv.radar-mode #tcfv-atc-ttl { color: var(--rc-mid); border-bottom-color: var(--rc-line); }
#tcfv.radar-mode #tcfv-log { color: var(--rc); }
#tcfv.radar-mode .tln { color: var(--rc) !important; text-shadow: 0 0 8px var(--rc); }
#tcfv.radar-mode #tcfv-set p,#tcfv.radar-mode #tcfv-cred p { color: var(--rc-mid); }
#tcfv.radar-mode h3 { color: var(--rc) !important; border-bottom-color: var(--rc-line) !important; }
#tcfv.radar-mode #tcfv-api-inp { background: var(--rc-dark); color: var(--rc); border-color: var(--rc-line); }
#tcfv.radar-mode .tcfv-btn { background: var(--rc-dark); color: var(--rc-mid); border-color: var(--rc-line); }
#tcfv.radar-mode .tcfv-btn:hover { background: var(--rc-line); }
#tcfv.radar-mode hr { border-top-color: var(--rc-line); }
#tcfv.radar-mode .note { color: var(--rc-line) !important; }
#tcfv.radar-mode .big-t { color: var(--rc) !important; }
#tcfv.radar-mode .ver-t { color: var(--rc-mid) !important; }
#tcfv.radar-mode #tcfv-author { color: var(--rc); }
#tcfv.radar-mode #tcfv-author:hover { color: var(--rc); opacity: 0.7; }
`);
}
/* ─────────────────────────────────────────────────────────────
INIT
───────────────────────────────────────────────────────────── */
function injectStatcounter() {
// Fires a 1×1 invisible tracking pixel to c.statcounter.com via a hidden <img>.
// Waits for window.load first (or fires immediately if already loaded) so it
// behaves like a standard bottom-of-page analytics snippet.
// The { once: true } option removes the listener automatically after it fires.
const fire = () => {
try {
const sid = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const img = document.createElement('img');
img.src = 'https://c.statcounter.com/13031782/0/af9e448b/1/?sc_sid=' + sid;
img.width = 1;
img.height = 1;
img.style.cssText = 'position:absolute;left:-9999px;top:-9999px;pointer-events:none;';
img.alt = '';
document.body.appendChild(img);
} catch(e) {}
};
if (document.readyState === 'complete') {
fire();
} else {
window.addEventListener('load', fire, { once: true });
}
}
function init() {
loadS();
injectStatcounter();
// Only build the HUD if fastRestore hasn't already created the panel
if (!document.getElementById('tcfv')) {
injectCSS();
buildHUD();
renderLog();
} else {
// Panel already exists — just wire up the dynamic hooks
injectCSS(); // safe to call again (adds/overwrites styles)
}
hookClicks();
hookNetwork();
watchDOM();
// Restore in-flight or preview state from previous session
if (S.flying && S.dst) {
if (Date.now() >= S.arrTime) {
// Landed while page was closed
S.flying = false; S.src = S.dst; S.dst = null;
S.phasesTriggered = {}; saveS();
} else {
drawPath(S.src, S.dst);
if (el.svg) el.svg.setAttribute('viewBox', getZoomedViewBox(S.src, S.dst));
highlightDots(S.src, S.dst);
}
} else if (S.previewDst) {
drawPath(S.src, S.previewDst);
if (el.svg) el.svg.setAttribute('viewBox', getZoomedViewBox(S.src, S.previewDst));
highlightDots(S.src, S.previewDst);
}
showPg(S.page || 'main');
startLoop();
initFromApi();
}
// Fast path: always restore panel immediately at 100ms so minimise/position/page
// state is applied before the user sees anything. init() at 400ms wires up hooks
// and skips rebuilding the panel (guards with getElementById).
function fastRestore() {
loadS();
injectCSS();
buildHUD();
renderLog();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(fastRestore, 100);
setTimeout(init, 400);
});
} else {
setTimeout(fastRestore, 100);
setTimeout(init, 400);
}
})();