JetStream Labs
(function () {
"use strict";
/* ═══════════════════════════════════════════════════
CANVAS + CONTEXT
═══════════════════════════════════════════════════ */
const canvas = document.getElementById("globeScene");
if (!canvas) return;
const ctx = canvas.getContext("2d");
/* ═══════════════════════════════════════════════════
SCENE DIMENSIONS (recalculated on resize)
═══════════════════════════════════════════════════ */
let W = 0, H = 0;
let GLOBE_R = 0; // globe radius (px)
let GLOBE_CZ = 0; // globe centre Z in 3-D space
let PCX = 0, PCY = 0; // 2-D projection centre
let FOV = 0;
/* ═══════════════════════════════════════════════════
CONFIGURATION
═══════════════════════════════════════════════════ */
// ── Globe dots ──────────────────────────────────
const N_DOTS = 780;
const DOT_R = 2.7;
const DOTS = [];
// ── Graticule ───────────────────────────────────
const GRAT_LATS = [-60, -30, 0, 30, 60]; // degrees
const GRAT_LONS = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330];
const GRAT_SEG = 80; // segments per line
// ── Globe rotation ──────────────────────────────
let globeRot = 0;
const GLOBE_ROT_SPD = 0.000088;
// ── Stars ───────────────────────────────────────
const N_STARS = 320;
const STARS = [];
// ── Jet orbit ───────────────────────────────────
const INCLINATION = 32 * Math.PI / 180; // orbit tilt from equatorial plane
const JET_ALT = 1.20; // orbit radius as multiple of GLOBE_R
const JET_SPEED = 0.028; // radians per animation frame
const TRAIL_LEN = 65;
const BANK_MAG = 20 * Math.PI / 180; // banking magnitude (radians)
let jetAngle = 0;
const trail = [];
/* ═══════════════════════════════════════════════════
CORE PROJECTION
═══════════════════════════════════════════════════ */
function project(px, py, pz, sin, cos) {
const rx = cos * px + sin * (pz - GLOBE_CZ);
const rz = -sin * px + cos * (pz - GLOBE_CZ) + GLOBE_CZ;
const s = FOV / (FOV - rz);
return { x: rx * s + PCX, y: py * s + PCY, s };
}
// Is a 3-D point on the camera-facing hemisphere?
function isFront(px, pz, sin, cos) {
return (-sin * px + cos * (pz - GLOBE_CZ)) >= 0;
}
/* ═══════════════════════════════════════════════════
STARFIELD
═══════════════════════════════════════════════════ */
function createStars() {
STARS.length = 0;
for (let i = 0; i < N_STARS; i++) {
STARS.push({
nx: Math.random() * 2 - 1, // normalised –1..1
ny: Math.random() * 2 - 1,
r: Math.random() * 1.3 + 0.3,
base: Math.random() * 0.55 + 0.28,
phase: Math.random() * Math.PI * 2,
freq: Math.random() * 0.0025 + 0.0008,
});
}
}
function drawStars(ts) {
for (const s of STARS) {
const sx = PCX + s.nx * W * 0.74;
const sy = PCY + s.ny * H * 0.74;
// Hide stars inside the atmosphere radius
if (Math.hypot(sx - PCX, sy - PCY) < GLOBE_R * 1.06) continue;
const alpha = s.base * (0.82 + Math.sin(ts * s.freq + s.phase) * 0.18);
ctx.fillStyle = `rgba(210, 228, 255, ${alpha})`;
ctx.beginPath();
ctx.arc(sx, sy, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
/* ═══════════════════════════════════════════════════
GLOBE DOTS
═══════════════════════════════════════════════════ */
class Dot {
constructor(x, y, z) { this.x = x; this.y = y; this.z = z; }
draw(sin, cos, pulse) {
// Skip back hemisphere — was drawing all 780 dots every frame
if (!isFront(this.x, this.z, sin, cos)) return;
const rx = cos * this.x + sin * (this.z - GLOBE_CZ);
const rz = -sin * this.x + cos * (this.z - GLOBE_CZ) + GLOBE_CZ;
const s = FOV / (FOV - rz);
const px = rx * s + PCX;
const py = this.y * s + PCY;
const depth = Math.max(0.22, Math.min(1, s));
const alpha = (0.30 + depth * 0.60) * pulse;
const r = DOT_R * s;
// One soft glow + core (shadowBlur is the main GPU cost)
ctx.shadowBlur = 8 * depth * pulse;
ctx.shadowColor = `rgba(56,189,248,${alpha * 0.75})`;
ctx.fillStyle = `rgba(56,189,248,${alpha * 0.9})`;
ctx.beginPath(); ctx.arc(px, py, r * 0.85, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = `rgba(224,242,254,${Math.min(1, alpha + 0.22)})`;
ctx.beginPath(); ctx.arc(px, py, r * 0.36, 0, Math.PI * 2); ctx.fill();
}
}
function createDots() {
DOTS.length = 0;
for (let i = 0; i < N_DOTS; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 2 - 1);
DOTS.push(new Dot(
GLOBE_R * Math.sin(phi) * Math.cos(theta),
GLOBE_R * Math.sin(phi) * Math.sin(theta),
GLOBE_R * Math.cos(phi) + GLOBE_CZ
));
}
}
/* ═══════════════════════════════════════════════════
ATMOSPHERE LIMB GLOW
═══════════════════════════════════════════════════ */
function drawAtmosphere() {
// Rim glow — drawn once after the dots
const g = ctx.createRadialGradient(PCX, PCY, GLOBE_R * 0.90, PCX, PCY, GLOBE_R * 1.22);
g.addColorStop(0, 'rgba(14,80,140,0)');
g.addColorStop(0.28, 'rgba(22,90,180,0.11)');
g.addColorStop(0.65, 'rgba(56,189,248,0.09)');
g.addColorStop(1, 'rgba(56,189,248,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(PCX, PCY, GLOBE_R * 1.22, 0, Math.PI * 2);
ctx.fill();
}
/* ═══════════════════════════════════════════════════
GRATICULE (latitude & longitude grid lines)
═══════════════════════════════════════════════════ */
function drawGraticule(sin, cos) {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw a list of 3-D points as a polyline, clipped to front hemisphere
function polyline(pts3d) {
let open = false;
for (let i = 0; i < pts3d.length; i++) {
const [px, py, pz] = pts3d[i];
if (!isFront(px, pz, sin, cos)) { open = false; continue; }
const p = project(px, py, pz, sin, cos);
if (!open) { ctx.beginPath(); ctx.moveTo(p.x, p.y); open = true; }
else ctx.lineTo(p.x, p.y);
}
if (open) ctx.stroke();
}
// ── Latitude circles ──────────────────────────────
ctx.strokeStyle = 'rgba(56,189,248,0.13)';
ctx.lineWidth = 0.7;
for (const latDeg of GRAT_LATS) {
const lat = latDeg * Math.PI / 180;
const r = GLOBE_R * Math.cos(lat);
const yy = GLOBE_R * Math.sin(lat); // y for this latitude ring
const pts = [];
for (let i = 0; i <= GRAT_SEG; i++) {
const theta = (i / GRAT_SEG) * Math.PI * 2;
pts.push([r * Math.cos(theta), -yy, r * Math.sin(theta) + GLOBE_CZ]);
}
polyline(pts);
}
// ── Equator — slightly brighter ───────────────────
ctx.strokeStyle = 'rgba(56,189,248,0.22)';
ctx.lineWidth = 1.0;
{
const pts = [];
for (let i = 0; i <= GRAT_SEG; i++) {
const theta = (i / GRAT_SEG) * Math.PI * 2;
pts.push([GLOBE_R * Math.cos(theta), 0, GLOBE_R * Math.sin(theta) + GLOBE_CZ]);
}
polyline(pts);
}
// ── Meridians ─────────────────────────────────────
ctx.strokeStyle = 'rgba(56,189,248,0.09)';
ctx.lineWidth = 0.6;
for (const lonDeg of GRAT_LONS) {
const lon = lonDeg * Math.PI / 180;
const pts = [];
for (let i = 0; i <= GRAT_SEG; i++) {
const lat = ((i / GRAT_SEG) * 2 - 1) * Math.PI * 0.499; // avoid poles
pts.push([
GLOBE_R * Math.cos(lat) * Math.cos(lon),
-GLOBE_R * Math.sin(lat),
GLOBE_R * Math.cos(lat) * Math.sin(lon) + GLOBE_CZ,
]);
}
polyline(pts);
}
ctx.restore();
}
/* ═══════════════════════════════════════════════════
JET POSITION (inclined orbit)
═══════════════════════════════════════════════════ */
function getJetPos(angle) {
const r = GLOBE_R * JET_ALT;
const x = r * Math.cos(angle);
const yOrb = r * Math.sin(angle);
return {
x,
y: yOrb * Math.sin(INCLINATION),
z: yOrb * Math.cos(INCLINATION) + GLOBE_CZ,
};
}
/* ═══════════════════════════════════════════════════
DRAW JET + TRAIL
═══════════════════════════════════════════════════ */
function drawJetAndTrail(sin, cos, pulse) {
// ── Update trail buffer ────────────────────────────
const pos = getJetPos(jetAngle);
trail.push({ x: pos.x, y: pos.y, z: pos.z });
if (trail.length > TRAIL_LEN) trail.shift();
const proj = trail.map(p => project(p.x, p.y, p.z, sin, cos));
const front = trail.map(p => isFront(p.x, p.z, sin, cos));
/* ── 1. Smooth gradient contrail ─────────────────── */
if (proj.length > 2) {
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
// Soft wide glow pass
ctx.lineWidth = 7;
ctx.strokeStyle = `rgba(200,240,255,${0.07 * pulse})`;
ctx.shadowBlur = 0;
let began = false;
ctx.beginPath();
for (let i = proj.length - 1; i >= 0; i--) {
if (!front[i]) { began = false; continue; }
if (!began) { ctx.moveTo(proj[i].x, proj[i].y); began = true; }
else ctx.lineTo(proj[i].x, proj[i].y);
}
ctx.stroke();
// Tapered core contrail — segment by segment with fading alpha + width
for (let i = proj.length - 1; i > 0; i--) {
if (!front[i] || !front[i - 1]) continue;
const t = 1 - i / proj.length; // 0 near plane → 1 at tail
const alpha = Math.max(0, (0.60 - 0.54 * t)) * pulse;
const wid = Math.max(0.4, 2.8 * (1 - t) * proj[i].s);
ctx.strokeStyle = `rgba(210,245,255,${alpha})`;
ctx.lineWidth = wid;
ctx.beginPath();
ctx.moveTo(proj[i].x, proj[i].y);
ctx.lineTo(proj[i - 1].x, proj[i - 1].y);
ctx.stroke();
}
ctx.restore();
}
/* ── 2. "JETSTREAM LABS" lettered trail ──────────── */
const TRAIL_TEXT = "JETSTREAM LABS ";
const LETTER_GAP = 17; // px between character centres
if (proj.length > 2) {
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let cumDist = 0;
let nextDist = 28; // gap between plane nose and first letter
let charIdx = 0;
for (let i = proj.length - 1; i > 0; i--) {
const p1 = proj[i], p2 = proj[i - 1];
if (p1.s < 0.05 || p2.s < 0.05) continue;
if (!front[i] || !front[i - 1]) continue;
const segLen = Math.hypot(p2.x - p1.x, p2.y - p1.y);
if (segLen < 0.001) continue;
const segAng = Math.atan2(p2.y - p1.y, p2.x - p1.x);
const t = 1 - i / proj.length;
const alpha = (0.96 - 0.42 * t) * pulse;
const avgS = (p1.s + p2.s) * 0.5;
const fs = Math.max(8, 14 * avgS * pulse);
const segEnd = cumDist + segLen;
while (nextDist <= segEnd) {
const frac = (nextDist - cumDist) / segLen;
const cx = p1.x + (p2.x - p1.x) * frac;
const cy = p1.y + (p2.y - p1.y) * frac;
const ch = TRAIL_TEXT[charIdx % TRAIL_TEXT.length];
charIdx++;
nextDist += LETTER_GAP;
if (ch === " ") continue;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(segAng);
ctx.font = `700 ${fs}px system-ui,-apple-system,sans-serif`;
// Soft outline for contrast against globe/sky
ctx.lineWidth = Math.max(1.5, fs * 0.22);
ctx.strokeStyle = `rgba(4,10,22,${alpha * 0.55})`;
ctx.strokeText(ch, 0, 0);
ctx.shadowBlur = 6 * pulse;
ctx.shadowColor = `rgba(56,189,248,${alpha * 0.7})`;
ctx.fillStyle = `rgba(230,248,255,${alpha})`;
ctx.fillText(ch, 0, 0);
ctx.restore();
}
cumDist = segEnd;
}
ctx.restore();
}
/* ── 3. Aircraft ─────────────────────────────────── */
const jp = project(pos.x, pos.y, pos.z, sin, cos);
if (jp.s <= 0.1 || !isFront(pos.x, pos.z, sin, cos)) return;
ctx.save();
const S = 13 * jp.s * pulse; // base scale (px per unit)
// ── Heading from projected trail ──────────────────
let dir = jetAngle + Math.PI / 2;
if (proj.length >= 2) {
const ci = proj.length - 1, pi_ = proj.length - 2;
if (front[ci] || front[pi_]) {
const dx = proj[ci].x - proj[pi_].x;
const dy = proj[ci].y - proj[pi_].y;
if (Math.hypot(dx, dy) > 0.001) dir = Math.atan2(dy, dx);
}
}
const fx = Math.cos(dir), fy = Math.sin(dir); // forward unit vector
const rx = -Math.sin(dir), ry = Math.cos(dir); // pilot-right unit vector
// ── Banking — tilt toward projected globe centre ──
const gc2d = project(0, 0, GLOBE_CZ, sin, cos);
const tcx = gc2d.x - jp.x, tcy = gc2d.y - jp.y;
const tcl = Math.hypot(tcx, tcy);
let bankAngle = 0;
if (tcl > 1) {
const dot = (tcx / tcl) * rx + (tcy / tcl) * ry;
bankAngle = Math.sign(dot) * BANK_MAG;
}
const cosB = Math.cos(bankAngle), sinB = Math.sin(bankAngle);
// ── Coordinate helper ─────────────────────────────
// Maps (forward_units, right_units) in plane-local space to screen (x,y).
// Banking: right side drops (+y in screen) by sinB factor.
const pt = (fwd, side) => ({
x: jp.x + (fx * fwd + rx * side * cosB) * S,
y: jp.y + (fy * fwd + ry * side * cosB) * S + side * sinB * S,
});
// Maps points with a vertical (fin) component.
// Vertical fin tip tilts laterally when banking.
const ptV = (fwd, side, up) => ({
x: jp.x + (fx * fwd + rx * (side * cosB - up * sinB)) * S,
y: jp.y + (fy * fwd + ry * (side * cosB - up * sinB)) * S
+ side * sinB * S - up * cosB * S,
});
/* ── Engine exhaust plumes ──────────────────────── */
ctx.shadowBlur = 45 * pulse;
ctx.shadowColor = `rgba(255,145,30,${0.92 * pulse})`;
for (const es of [-0.20, 0.20]) {
const ep = pt(-2.10, es);
const eg = ctx.createRadialGradient(ep.x, ep.y, 0, ep.x, ep.y, S * 0.58);
eg.addColorStop(0, `rgba(255,255,200,${0.98 * pulse})`);
eg.addColorStop(0.25, `rgba(255,200,80,${0.80 * pulse})`);
eg.addColorStop(0.55, `rgba(255,120,25,${0.40 * pulse})`);
eg.addColorStop(1, `rgba(255,80,0,0)`);
ctx.fillStyle = eg;
ctx.beginPath();
ctx.arc(ep.x, ep.y, S * 0.58, 0, Math.PI * 2);
ctx.fill();
}
/* ── Fuselage ────────────────────────────────────── */
// Refined widebody silhouette: narrow nose, widening belly, clean taper
const fusR = [
[ 2.55, 0.00], // nose tip
[ 2.20, 0.09], // nose shoulder
[ 1.75, 0.17], // cockpit base
[ 1.20, 0.21], // fwd cabin
[ 0.35, 0.23], // widest point
[-0.60, 0.22], // mid cabin
[-1.35, 0.20], // aft cabin
[-1.85, 0.15], // tail taper
[-2.25, 0.08], // tail body
[-2.55, 0.00], // tail cone
];
const fusPts = fusR.map(([f, s]) => pt(f, s))
.concat(fusR.slice(1, -1).reverse().map(([f, s]) => pt(f, -s)));
ctx.shadowBlur = 55 * pulse;
ctx.shadowColor = `rgba(255,215,115,${0.88 * pulse})`;
ctx.fillStyle = `rgba(255,255,250,${0.97 * pulse})`;
ctx.beginPath();
ctx.moveTo(fusPts[0].x, fusPts[0].y);
for (let i = 1; i < fusPts.length; i++) ctx.lineTo(fusPts[i].x, fusPts[i].y);
ctx.closePath();
ctx.fill();
/* ── Main wings ──────────────────────────────────── */
// Highly swept, narrow-chord outer section with distinct leading-edge sweep
const wingR = [
[ 0.80, 0.23], // leading edge root (fuselage join)
[ 0.55, 0.23], // trailing edge root
[-0.70, 0.42], // inner trailing
[-0.82, 1.50], // outer trailing
[-0.52, 1.65], // wingtip trailing corner
[-0.22, 1.62], // wingtip leading corner
[ 0.10, 1.52], // outer leading edge
[ 0.68, 0.28], // inner leading edge
];
ctx.shadowBlur = 32 * pulse;
ctx.shadowColor = `rgba(255,205,105,${0.72 * pulse})`;
ctx.fillStyle = `rgba(255,252,240,${0.94 * pulse})`;
for (const sg of [1, -1]) {
const wp = wingR.map(([f, s]) => pt(f, sg * s));
ctx.beginPath();
ctx.moveTo(wp[0].x, wp[0].y);
for (let i = 1; i < wp.length; i++) ctx.lineTo(wp[i].x, wp[i].y);
ctx.closePath();
ctx.fill();
}
/* ── Winglets ────────────────────────────────────── */
// Upturned blade at each wingtip — rendered using ptV for slight 3-D lean
ctx.shadowBlur = 12 * pulse;
ctx.shadowColor = `rgba(210,235,255,${0.65 * pulse})`;
ctx.fillStyle = `rgba(248,255,255,${0.92 * pulse})`;
for (const sg of [1, -1]) {
const base1 = ptV(-0.22, sg * 1.62, 0.00);
const base2 = ptV(-0.52, sg * 1.65, 0.00);
const tip = ptV(-0.38, sg * 1.72, 0.42 * sg); // angled outward-upward
ctx.beginPath();
ctx.moveTo(base1.x, base1.y);
ctx.lineTo(base2.x, base2.y);
ctx.lineTo(tip.x, tip.y);
ctx.closePath();
ctx.fill();
}
/* ── Horizontal stabilisers ─────────────────────── */
const hstabR = [
[-1.82, 0.17],
[-1.92, 0.19],
[-2.38, 0.64],
[-2.52, 0.58],
[-2.22, 0.16],
];
ctx.shadowBlur = 22 * pulse;
ctx.fillStyle = `rgba(255,253,242,${0.93 * pulse})`;
for (const sg of [1, -1]) {
const hPts = hstabR.map(([f, s]) => pt(f, sg * s));
ctx.beginPath();
ctx.moveTo(hPts[0].x, hPts[0].y);
for (let i = 1; i < hPts.length; i++) ctx.lineTo(hPts[i].x, hPts[i].y);
ctx.closePath();
ctx.fill();
}
/* ── Vertical stabiliser (tail fin) ─────────────── */
// Uses ptV to render the fin leaning with bank angle.
// The "up" axis (out of the fuselage top) leans laterally when banking.
const finH = 0.44; // fin height in local units
const finPts = [
ptV(-1.78, 0.12, 0.00), // base — right
ptV(-1.78, -0.12, 0.00), // base — left
ptV(-2.40, -0.04, finH * 0.25), // trailing edge mid
ptV(-2.45, 0.00, finH * 0.55), // tip trailing
ptV(-2.25, 0.00, finH), // tip
ptV(-1.80, 0.00, finH * 0.62), // leading edge upper
];
ctx.shadowBlur = 16 * pulse;
ctx.shadowColor = `rgba(220,240,255,${0.70 * pulse})`;
ctx.fillStyle = `rgba(245,252,255,${0.90 * pulse})`;
ctx.beginPath();
ctx.moveTo(finPts[0].x, finPts[0].y);
for (let i = 1; i < finPts.length; i++) ctx.lineTo(finPts[i].x, finPts[i].y);
ctx.closePath();
ctx.fill();
/* ── Engine nacelles ─────────────────────────────── */
// One nacelle per wing, ~55% span, slung below wing leading edge
ctx.shadowBlur = 24 * pulse;
ctx.shadowColor = `rgba(255,205,105,${0.72 * pulse})`;
for (const sg of [1, -1]) {
ctx.fillStyle = `rgba(255,245,188,${0.90 * pulse})`;
const nc = pt(0.22, sg * 0.82);
ctx.beginPath();
ctx.ellipse(nc.x, nc.y, S * 0.37, S * 0.13, dir, 0, Math.PI * 2);
ctx.fill();
// Intake highlight — brighter disc at front of nacelle
ctx.fillStyle = `rgba(190,228,255,${0.72 * pulse})`;
const ni = pt(0.52, sg * 0.82);
ctx.beginPath();
ctx.ellipse(ni.x, ni.y, S * 0.14, S * 0.10, dir, 0, Math.PI * 2);
ctx.fill();
}
/* ── Fuselage spine line ─────────────────────────── */
ctx.shadowBlur = 18 * pulse;
ctx.shadowColor = `rgba(255,255,255,0.85)`;
ctx.strokeStyle = `rgba(255,255,255,${0.72 * pulse})`;
ctx.lineWidth = Math.max(0.8, S * 0.075);
ctx.lineCap = 'round';
const noseTip = pt( 2.55, 0);
const tailCone = pt(-2.55, 0);
ctx.beginPath();
ctx.moveTo(noseTip.x, noseTip.y);
ctx.lineTo(tailCone.x, tailCone.y);
ctx.stroke();
/* ── Window strip ────────────────────────────────── */
ctx.shadowBlur = 9 * pulse;
ctx.shadowColor = `rgba(180,230,255,0.9)`;
ctx.fillStyle = `rgba(185,235,255,${0.72 * pulse})`;
for (let w = 0; w < 7; w++) {
const wf = 1.55 - w * 0.43;
const wp = pt(wf, 0.11);
ctx.beginPath();
ctx.arc(wp.x, wp.y, Math.max(0.8, S * 0.052), 0, Math.PI * 2);
ctx.fill();
}
/* ── Cockpit canopy ──────────────────────────────── */
ctx.shadowBlur = 30 * pulse;
ctx.shadowColor = `rgba(180,238,255,0.95)`;
ctx.fillStyle = `rgba(205,248,255,${0.93 * pulse})`;
const cp = pt(1.68, 0);
ctx.beginPath();
ctx.ellipse(cp.x, cp.y, S * 0.30, S * 0.13, dir, 0, Math.PI * 2);
ctx.fill();
/* ── Nose radome shine ───────────────────────────── */
ctx.shadowBlur = 22;
ctx.shadowColor = `rgba(255,255,255,0.7)`;
ctx.fillStyle = `rgba(255,255,255,${0.52 * pulse})`;
ctx.beginPath();
ctx.arc(noseTip.x, noseTip.y, Math.max(1.2, S * 0.16), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/* ═══════════════════════════════════════════════════
SETUP (also called on resize)
═══════════════════════════════════════════════════ */
function setup() {
W = canvas.clientWidth;
H = canvas.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
GLOBE_R = Math.min(Math.min(W, H) * 0.42, 420);
GLOBE_CZ = -GLOBE_R;
PCX = W / 2;
PCY = H / 2;
FOV = W * 0.85;
createDots();
createStars();
trail.length = 0;
jetAngle = 0;
const ip = getJetPos(0);
for (let i = 0; i < TRAIL_LEN; i++) trail.push({ x: ip.x, y: ip.y, z: ip.z });
}
/* ═══════════════════════════════════════════════════
RENDER LOOP
═══════════════════════════════════════════════════ */
let rafId = 0;
let running = false;
function render(ts) {
if (!running) return;
ctx.clearRect(0, 0, W, H);
globeRot = ts * GLOBE_ROT_SPD;
const sin = Math.sin(globeRot);
const cos = Math.cos(globeRot);
const pulse = 0.88 + Math.sin(ts * 0.0019) * 0.12;
drawStars(ts); // 1. background stars
for (const d of DOTS) d.draw(sin, cos, pulse); // 2. globe dots
drawGraticule(sin, cos); // 3. lat / lon grid
drawAtmosphere(); // 4. limb glow
jetAngle += JET_SPEED;
drawJetAndTrail(sin, cos, pulse); // 5. aircraft + contrail
rafId = requestAnimationFrame(render);
}
function startLoop() {
if (running) return;
running = true;
rafId = requestAnimationFrame(render);
}
function stopLoop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
}
/* ═══════════════════════════════════════════════════
RESIZE + VISIBILITY
Pause when the tab is hidden so Chrome/YouTube aren't
fighting this canvas for the GPU.
═══════════════════════════════════════════════════ */
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(setup, 180);
});
function syncLoop() {
if (document.hidden || !document.hasFocus()) stopLoop();
else startLoop();
}
document.addEventListener("visibilitychange", syncLoop);
window.addEventListener("blur", stopLoop);
window.addEventListener("focus", syncLoop);
setup();
syncLoop();
})();