<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SixthSensor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #ffffff;
color: #1a1a1a;
overflow-x: hidden;
}
/* Navigation */
nav {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #e5e5e5;
z-index: 1000;
padding: 20px 40px;
}
.nav-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
}
.nav-tabs {
display: flex;
gap: 40px;
}
.nav-tab {
cursor: pointer;
padding: 8px 16px;
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
color: #666;
}
.nav-tab:hover {
color: #1a1a1a;
background: #f5f5f5;
}
.nav-tab.active {
color: #1a1a1a;
background: #e5e5e5;
}
/* Canvas Container */
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 90vh;
z-index: 0;
}
#canvas-container::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 100px;
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.3));
pointer-events: none;
z-index: 1;
}
canvas {
display: block;
}
/* Visibility Controls */
.visibility-controls {
position: fixed;
top: 100px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 16px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.visibility-controls h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
}
.control-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.control-item:last-child {
margin-bottom: 0;
}
.control-item input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.control-item label {
font-size: 13px;
color: #333;
cursor: pointer;
user-select: none;
}
/* Content */
.content-wrapper {
position: relative;
z-index: 10;
margin-top: 75vh;
min-height: calc(100vh - 75vh);
background: #ffffff;
padding-top: 40px;
}
.content-section {
display: none;
}
.content-section.active {
display: block;
}
/* All pages use centered card layout */
.content-section.active {
display: block;
max-width: 900px;
margin: 0 auto 60px;
padding: 0 40px;
}
.content-card {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 60px;
animation: fadeIn 0.6s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 48px;
margin-bottom: 24px;
font-weight: 700;
}
h2 {
font-size: 32px;
margin: 40px 0 20px;
font-weight: 600;
}
p {
font-size: 18px;
line-height: 1.8;
margin-bottom: 20px;
color: #444;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 24px;
margin: 40px 0;
}
.feature-card {
padding: 24px;
background: #f8f8f8;
border-radius: 12px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.feature-card h3 {
font-size: 20px;
margin-bottom: 12px;
}
.feature-card p {
font-size: 14px;
color: #666;
}
/* Form */
form {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 40px;
}
input, textarea {
padding: 14px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 16px;
font-family: inherit;
transition: border 0.3s ease;
}
input:focus, textarea:focus {
outline: none;
border-color: #1a1a1a;
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
padding: 14px 32px;
background: #1a1a1a;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
background: #333;
transform: translateY(-2px);
}
@media (max-width: 768px) {
#canvas-container {
height: 80vh;
}
.content-wrapper {
margin-top: 80vh;
}
nav {
padding: 16px 20px;
}
.nav-tabs {
gap: 20px;
}
.nav-tab {
padding: 6px 12px;
font-size: 14px;
}
.content-section.active {
padding: 0 20px;
}
.content-card {
padding: 40px 24px;
}
h1 {
font-size: 36px;
}
}
/* Time acceleration indicator */
#time-indicator {
position: fixed;
top: 60vh;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
padding: 8px 16px;
border-radius: 20px;
color: white;
font-size: 12px;
font-weight: 500;
z-index: 100;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
#time-indicator.visible {
opacity: 1;
}
.progress-bar {
width: 120px;
height: 3px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #00ff00, #00cc00);
border-radius: 2px;
width: 0%;
transition: width 0.1s linear;
}
</style>
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.157.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.157.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- Navigation -->
<nav>
<div class="nav-content">
<div class="logo">SixthSensor</div>
<div class="nav-tabs">
<div class="nav-tab active" onclick="showTab('about')">About</div>
<div class="nav-tab" onclick="showTab('offerings')">Offerings</div>
<div class="nav-tab" onclick="showTab('case studies')">Case Studies</div>
<div class="nav-tab" onclick="showTab('inquiries')">Inquiries</div>
</div>
</div>
</nav>
<!-- Canvas Container -->
<div id="canvas-container"></div>
<div class="visibility-controls">
<h3>Visibility</h3>
<div class="control-item">
<input type="checkbox" id="showPlanes">
<label for="showPlanes">Aircraft</label>
</div>
<div class="control-item">
<input type="checkbox" id="showShips">
<label for="showShips">Ships (experimental)</label>
</div>
</div>
<!-- Time Acceleration Indicator -->
<div id="time-indicator">
<div id="time-display" style="text-align: center;">60x</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<!-- Content -->
<div class="content-wrapper">
<!-- About Section -->
<div id="about" class="content-section active">
<div class="content-card">
<h1>About</h1>
<p>Under construction. Welcome to my website!
<br>
I've designed constellations for various customers. SixthSensor is my startup based in Northern Virginia.</p>
<h2>The Mission</h2>
<p>Design constellations that do more for a lot less</p>
<div class="feature-grid">
<div class="feature-card">
<h3>Cost</h3>
<p>Fewer satellites required to achieve more mission</p>
</div>
<div class="feature-card">
<h3>Schedule</h3>
<p>Achieve mission goals faster than the competition</p>
</div>
<div class="feature-card">
<h3>Risk</h3>
<p>Less time to validate conceptual constellations before launch</p>
</div>
</div>
<h2>Customer Testimonials</h2>
<p>...</p>
</div>
</div>
<!-- Offerings Section -->
<div id="offerings" class="content-section">
<div class="content-card">
<h1>Offerings</h1>
<h2>Constellation Design</h2>
<p>Design optimal orbits/sizing/launch cadence for your mission. Extract the maximum performance from your satellites.</p>
<h2>Mission Planning</h2>
<p>Model your mission to validate your constellations performance.
Launch scheduling,
Collection/contact scheduling,
Predict performance over constellation lifetime</p>
<h2>Due Dilligence</h2>
<p>Before investing, ensure that physics agrees with your business case</p>
<div class="feature-grid">
<div class="feature-card">
<h3>Optimization</h3>
<p>Orbit optimization tailored to your mission</p>
</div>
<div class="feature-card">
<h3>Consultation</h3>
<p>Expert guidance on constellation design challenges</p>
</div>
<div class="feature-card">
<h3>Simulation</h3>
<p>Tailored analysis and visualization software</p>
</div>
</div>
</div>
</div>
<!-- Case Studies Section -->
<div id="case studies" class="content-section">
<div class="content-card">
<h1>Case Studies</h1>
<h2>Why Focus on Orbit Selection</h2>
<p>What if more satellites = worse coverage?</p>
<h2>Beating the Walker Constellation</h2>
<p>Tried and true, but often not the optimal solution</p>
<h2>Propagating 1M+ satellites real-time</h2>
<p>https://github.com/ApoPeri/tensorgator</p>
</div>
<!-- Inquiries Section -->
<div id="inquiries" class="content-section">
<div class="content-card">
<h1>Inquiries</h1>
<p>Get in touch with us to discuss your mission needs. <br> (Only accepting US based inquiries at this time)</p>
<form onsubmit="handleSubmit(event)">
<input type="text" placeholder="Name" required>
<input type="email" placeholder="Email" required>
<input type="text" placeholder="Organization">
<textarea placeholder="Message" required></textarea>
<button type="submit">Send Inquiry</button>
</form>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let renderer, scene, camera, controls, earth;
let satelliteMeshes = [];
let satellites = [];
let orbitTrails = [];
let planeRoutes = []; // Visual great circle routes for aircraft
// Visibility state
let showShips = false;
let showPlanes = false;
let sensorCones = [];
let ships = [];
let shipMeshes = [];
let planes = [];
let planeMeshes = [];
let debugAxes = []; // Debug arrows for sensor orientation
// Debug flag for sensor axes
const SHOW_DEBUG_AXES = false; // Set to true to show debug arrows
// Crosslinks/downlinks and target selection
let crosslinkLines = [];
let downlinkLines = [];
let selectedTargets = [];
let crosslinkMaterial, downlinkMaterial;
// Performance: throttle expensive updates
let frameCount = 0;
const CROSSLINK_UPDATE_INTERVAL = 3; // Update every 3 frames (~20fps)
const DOWNLINK_UPDATE_INTERVAL = 2; // Update every 2 frames (~30fps)
const TRAIL_UPDATE_INTERVAL = 10; // Update every 10 frames (~6fps)
// Time acceleration variables
let simulationTime = Date.now(); // Start at current time
let timeAcceleration = 1; // Start at real-time
let lastFrameTime = Date.now();
const startTime = Date.now();
const EARTHRADIUS = 1.0;
// Texture longitude offset - adjust this to align geographic features with lat/lon coordinates
// If vessels don't line up with coastlines, increase/decrease this value
// This compensates for how the Earth texture is oriented on the sphere
const SHIP_PLANE_TEXTURE_LON_OFFSET = 90; // degrees (positive = shift features eastward)
const coneFov = 45; // degrees
const coneRangeKm = 700; // kilometers
const PHYSICS = {
MU: 3.986004418e14,
EARTH_RADIUS: 6371000,
EARTH_RADIUS_KM: 6371,
J2: 1.08262668e-3,
OMEGA_EARTH: 7.2921159e-5
};
const atmosphere = {
Kr: 0.0025,
Km: 0.0010,
ESun: 10.0,
g: -0.950,
innerRadius: EARTHRADIUS,
outerRadius: 1.025 * EARTHRADIUS,
wavelength: [0.650, 0.570, 0.475],
scaleDepth: 0.25
};
const AtmUniforms = {
v3LightPosition: { value: new THREE.Vector3(1, 0, 0).normalize() },
cPs: { value: new THREE.Vector3(1, 0, 0) },
v3InvWavelength: { value: new THREE.Vector3(
1 / Math.pow(atmosphere.wavelength[0], 4),
1 / Math.pow(atmosphere.wavelength[1], 4),
1 / Math.pow(atmosphere.wavelength[2], 4)
) },
fInnerRadius: { value: atmosphere.innerRadius },
fInnerRadius2: { value: atmosphere.innerRadius * atmosphere.innerRadius },
fOuterRadius: { value: atmosphere.outerRadius },
fOuterRadius2: { value: atmosphere.outerRadius * atmosphere.outerRadius },
fKrESun: { value: atmosphere.Kr * atmosphere.ESun },
fKmESun: { value: atmosphere.Km * atmosphere.ESun },
fKr4PI: { value: atmosphere.Kr * 4.0 * Math.PI },
fKm4PI: { value: atmosphere.Km * 4.0 * Math.PI },
fScale: { value: 1 / (atmosphere.outerRadius - atmosphere.innerRadius) },
fScaleDepth: { value: atmosphere.scaleDepth },
fScaleOverScaleDepth: { value: 1 / (atmosphere.outerRadius - atmosphere.innerRadius) / atmosphere.scaleDepth },
g: { value: atmosphere.g },
g2: { value: atmosphere.g * atmosphere.g },
tDiffuse: { value: null },
tDiffuseNight: { value: null },
fNightScale: { value: 1 }
};
const vertexSky = `
uniform vec3 v3LightPosition;
uniform vec3 v3InvWavelength;
uniform vec3 cPs;
uniform float fOuterRadius;
uniform float fOuterRadius2;
uniform float fInnerRadius;
uniform float fInnerRadius2;
uniform float fKrESun;
uniform float fKmESun;
uniform float fKr4PI;
uniform float fKm4PI;
uniform float fScale;
uniform float fScaleDepth;
uniform float fScaleOverScaleDepth;
const int nSamples = 3;
const float fSamples = 3.0;
varying vec3 v3Direction;
varying vec3 c0;
varying vec3 c1;
float scale(float fCos) {
float x = 1.0 - fCos;
return fScaleDepth * exp(-0.00287 + x*(0.459 + x*(3.83 + x*(-6.80 + x*5.25))));
}
void main(void) {
float fCameraHeight = length(cPs);
float fCameraHeight2 = fCameraHeight * fCameraHeight;
vec3 v3Ray = position - cPs;
float fFar = length(v3Ray);
v3Ray /= fFar;
float B = 2.0 * dot(cPs, v3Ray);
float C = fCameraHeight2 - fOuterRadius2;
float fDet = max(0.0, B*B - 4.0*C);
float fNear = 0.5 * (-B - sqrt(fDet));
vec3 v3Start = cPs + v3Ray * fNear;
fFar -= fNear;
float fStartAngle = dot(v3Ray, v3Start) / fOuterRadius;
float fStartDepth = exp(-1.0 / fScaleDepth);
float fStartOffset = fStartDepth * scale(fStartAngle);
float fSampleLength = fFar / fSamples;
float fScaledLength = fSampleLength * fScale;
vec3 v3SampleRay = v3Ray * fSampleLength;
vec3 v3SamplePoint = v3Start + v3SampleRay * 0.5;
vec3 v3FrontColor = vec3(0.0, 0.0, 0.0);
for(int i = 0; i < nSamples; i++) {
float fHeight = length(v3SamplePoint);
float fDepth = exp(fScaleOverScaleDepth * (fInnerRadius - fHeight));
float fLightAngle = dot(v3LightPosition, v3SamplePoint) / fHeight;
float fCameraAngle = dot(v3Ray, v3SamplePoint) / fHeight;
float fScatter = (fStartOffset + fDepth * (scale(fLightAngle) - scale(fCameraAngle)));
vec3 v3Attenuate = exp(-fScatter * (v3InvWavelength * fKr4PI + fKm4PI));
v3FrontColor += v3Attenuate * (fDepth * fScaledLength);
v3SamplePoint += v3SampleRay;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
c0 = v3FrontColor * (v3InvWavelength * fKrESun);
c1 = v3FrontColor * fKmESun;
v3Direction = cPs - position;
}`;
const fragmentSky = `
uniform vec3 v3LightPosition;
uniform float g;
uniform float g2;
varying vec3 v3Direction;
varying vec3 c0;
varying vec3 c1;
float getMiePhase(float fCos, float fCos2, float g, float g2) {
return 1.5 * ((1.0 - g2) / (2.0 + g2)) * (1.0 + fCos2) / pow(1.0 + g2 - 2.0 * g * fCos, 1.5);
}
float getRayleighPhase(float fCos2) {
return 0.75 + 0.75 * fCos2;
}
void main (void) {
float fCos = dot(v3LightPosition, v3Direction) / length(v3Direction);
float fCos2 = fCos * fCos;
vec3 color = getRayleighPhase(fCos2) * c0 + getMiePhase(fCos, fCos2, g, g2) * c1;
gl_FragColor = vec4(color, 1.0);
gl_FragColor.a = gl_FragColor.b;
}`;
const vertexGround = `
uniform vec3 v3LightPosition;
uniform vec3 cPs;
uniform vec3 v3InvWavelength;
uniform float fOuterRadius;
uniform float fOuterRadius2;
uniform float fInnerRadius;
uniform float fInnerRadius2;
uniform float fKrESun;
uniform float fKmESun;
uniform float fKr4PI;
uniform float fKm4PI;
uniform float fScale;
uniform float fScaleDepth;
uniform float fScaleOverScaleDepth;
varying vec3 c0;
varying vec3 c1;
varying vec2 vUv;
const int nSamples = 3;
const float fSamples = 3.0;
float scale(float fCos) {
float x = 1.0 - fCos;
return fScaleDepth * exp(-0.00287 + x*(0.459 + x*(3.83 + x*(-6.80 + x*5.25))));
}
void main(void) {
float fCameraHeight = length(cPs);
float fCameraHeight2 = fCameraHeight*fCameraHeight;
vec3 v3Ray = position - cPs;
float fFar = length(v3Ray);
v3Ray /= fFar;
float B = 2.0 * dot(cPs, v3Ray);
float C = fCameraHeight2 - fOuterRadius2;
float fDet = max(0.0, B*B - 4.0 * C);
float fNear = 0.5 * (-B - sqrt(fDet));
vec3 v3Start = cPs + v3Ray * fNear;
fFar -= fNear;
float fDepth = exp((fInnerRadius - fOuterRadius) / fScaleDepth);
float fCameraAngle = dot(-v3Ray, position) / length(position);
float fLightAngle = dot(v3LightPosition, position) / length(position);
float fCameraScale = scale(fCameraAngle);
float fLightScale = scale(fLightAngle);
float fCameraOffset = fDepth*fCameraScale;
float fTemp = (fLightScale + fCameraScale);
float fSampleLength = fFar / fSamples;
float fScaledLength = fSampleLength * fScale;
vec3 v3SampleRay = v3Ray * fSampleLength;
vec3 v3SamplePoint = v3Start + v3SampleRay * 0.5;
vec3 v3FrontColor = vec3(0.0, 0.0, 0.0);
vec3 v3Attenuate;
for(int i = 0; i < nSamples; i++) {
float fHeight = length(v3SamplePoint);
float fDepth = exp(fScaleOverScaleDepth * (fInnerRadius - fHeight));
float fScatter = fDepth*fTemp - fCameraOffset;
v3Attenuate = exp(-fScatter * (v3InvWavelength * fKr4PI + fKm4PI));
v3FrontColor += v3Attenuate * (fDepth * fScaledLength);
v3SamplePoint += v3SampleRay;
}
c0 = v3Attenuate;
c1 = v3FrontColor * (v3InvWavelength * fKrESun + fKmESun);
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;
const fragmentGround = `
uniform float fNightScale;
uniform sampler2D tDiffuse;
uniform sampler2D tDiffuseNight;
varying vec3 c0;
varying vec3 c1;
varying vec2 vUv;
void main (void) {
vec3 diffuseTex = texture2D(tDiffuse, vUv).SixthSensor;
vec3 diffuseNightTex = texture2D(tDiffuseNight, vUv).SixthSensor;
vec3 day = 0.75 * diffuseTex * c0;
vec3 night = fNightScale * diffuseNightTex * (1.0 - c0);
gl_FragColor = vec4(c1, 1.0) + vec4(day + night, 1.0);
}`;
class Earth3d extends THREE.Group {
constructor(camera) {
super();
this.ground = new THREE.Mesh(
new THREE.SphereGeometry(atmosphere.innerRadius, 64, 64),
new THREE.ShaderMaterial({
uniforms: AtmUniforms,
vertexShader: vertexGround,
fragmentShader: fragmentGround
}));
this.add(this.ground);
this.sky = new THREE.Mesh(
new THREE.SphereGeometry(atmosphere.outerRadius, 64, 64),
new THREE.ShaderMaterial({
uniforms: AtmUniforms,
vertexShader: vertexSky,
fragmentShader: fragmentSky,
side: THREE.BackSide,
transparent: true,
depthWrite: false,
}));
this.add(this.sky);
this._sunvect = new THREE.Vector3(1,0,0);
this.sunvect = new THREE.Vector3(1,0,0);
this.camera = camera;
this.parentObj = new THREE.Group();
this.cameracPs = new THREE.Vector3();
this.axisY = new THREE.Vector3(0,1,0);
}
update() {
this.cameracPs.copy(this.camera.position);
this.cameracPs.sub(this.parentObj.position);
this._sunvect.copy(this.sunvect);
this.cameracPs.applyAxisAngle(this.axisY,-this.rotation.y);
this._sunvect.applyAxisAngle(this.axisY,-this.rotation.y);
this.ground.material.uniforms.cPs.value = this.cameracPs;
this.ground.material.uniforms.v3LightPosition.value = this._sunvect;
this.sky.material.uniforms.cPs.value = this.cameracPs;
this.sky.material.uniforms.v3LightPosition.value = this._sunvect;
}
setSun(sun) {
this.sunvect.copy(sun);
}
loadTextures(texDay, texNight) {
texDay.anisotropy = 16;
texNight.anisotropy = 16;
AtmUniforms.tDiffuse.value = texDay;
AtmUniforms.tDiffuseNight.value = texNight;
}
}
function getJulianDate(date) {
return date.getTime() / 86400000 + 2440587.5;
}
function getSunPosition(julianDate) {
// High-precision solar position (NOAA/Meeus-style)
const DEG2RAD = Math.PI / 180;
const T = (julianDate - 2451545.0) / 36525.0; // Julian centuries from J2000.0
// Geometric Mean Longitude of the Sun (deg)
let L0 = 280.46646 + T * (36000.76983 + 0.0003032 * T);
L0 = ((L0 % 360) + 360) % 360;
// Mean anomaly of the Sun (deg)
let M = 357.52911 + T * (35999.05029 - 0.0001537 * T);
M = ((M % 360) + 360) % 360;
const Mrad = M * DEG2RAD;
// Eccentricity of Earth's orbit
const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T);
// Sun's equation of center (deg)
const C = Math.sin(Mrad) * (1.914602 - T * (0.004817 + 0.000014 * T))
+ Math.sin(2 * Mrad) * (0.019993 - 0.000101 * T)
+ Math.sin(3 * Mrad) * 0.000289;
// True longitude (deg)
const trueLong = L0 + C;
// Apparent longitude (deg), include nutation/aberration via Ω term
const Omega = (125.04 - 1934.136 * T) * DEG2RAD;
const lambda = (trueLong - 0.00569 - 0.00478 * Math.sin(Omega)) * DEG2RAD;
// Mean obliquity of the ecliptic (deg)
const eps0 = 23 + 26 / 60 + (21.448 - 46.8150 * T - 0.00059 * T * T + 0.001813 * T * T * T) / 3600;
// Corrected (true) obliquity (deg)
const eps = (eps0 + 0.00256 * Math.cos(Omega)) * DEG2RAD;
// Convert ecliptic to equatorial (ECI, J2000-based)
const x = Math.cos(lambda);
const y = Math.cos(eps) * Math.sin(lambda);
const z = Math.sin(eps) * Math.sin(lambda);
// Map to Three.js coordinates (Y-up): ECI z -> Y
return new THREE.Vector3(x, z, y);
}
// Convert lat/lon to 3D position on Earth surface
// Standard spherical coordinates: lon=0° at +Z, lon=90°E at +X
// This assumes the Earth texture is oriented with Prime Meridian aligned to +Z axis in initial state
function latLonToVector3(lat, lon, radius = EARTHRADIUS) {
const phi = (90 - lat) * (Math.PI / 180); // Polar angle from north pole (0 to π)
const theta = lon * (Math.PI / 180); // Azimuthal angle (longitude in radians)
// Spherical to Cartesian: x = r·sin(φ)·sin(θ), y = r·cos(φ), z = r·sin(φ)·cos(θ)
const x = radius * Math.sin(phi) * Math.sin(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.cos(theta);
return new THREE.Vector3(x, y, z);
}
// Major shipping routes (lat, lon waypoints)
const shippingRoutes = [
// Route 1 (Minor, 23 waypoints, 17334 km)
[
[-4.67, -34.41],
[-0.67, -37.20],
[4.95, -41.07],
[12.94, -46.80],
[18.17, -50.77],
[22.59, -54.37],
[29.99, -61.19],
[34.95, -66.57],
[36.66, -64.84],
[33.26, -58.40],
[29.94, -53.05],
[25.93, -47.40],
[19.97, -40.09],
[15.95, -35.67],
[10.01, -29.58],
[2.27, -22.11],
[-5.11, -15.08],
[-9.97, -10.37],
[-14.57, -5.70],
[-20.07, 0.30],
[-23.83, 4.77],
[-29.56, 12.53],
[-32.93, 17.87],
],
// Route 2 (Minor, 20 waypoints, 15567 km)
[
[30.52, -180.00],
[32.43, -173.00],
[33.96, -165.54],
[35.01, -157.96],
[35.58, -150.29],
[35.66, -142.57],
[35.25, -134.85],
[34.34, -127.18],
[32.59, -118.10],
[31.30, -118.84],
[25.03, -126.08],
[17.01, -133.91],
[13.07, -137.41],
[5.06, -144.08],
[-1.18, -149.13],
[-8.98, -155.50],
[-13.22, -159.09],
[-19.96, -165.19],
[-26.92, -172.32],
[-33.21, -180.00],
],
// Route 3 (Minor, 17 waypoints, 12164 km)
[
[33.58, -180.00],
[35.65, -173.24],
[37.23, -166.55],
[38.42, -159.70],
[39.18, -152.72],
[39.46, -138.55],
[38.95, -131.46],
[37.68, -122.62],
[34.99, -131.19],
[31.28, -140.17],
[29.18, -144.42],
[24.99, -151.79],
[25.03, -154.53],
[31.42, -148.76],
[38.34, -141.14],
[45.00, -131.40],
[48.41, -124.75],
],
// Route 4 (Minor, 15 waypoints, 11424 km)
[
[21.23, -157.89],
[12.47, -150.95],
[7.65, -147.40],
[0.36, -142.22],
[-5.12, -138.34],
[-9.98, -134.83],
[-14.96, -131.09],
[-20.63, -126.55],
[-24.99, -122.75],
[-30.04, -117.88],
[-37.64, -108.98],
[-42.30, -102.00],
[-45.76, -95.50],
[-48.73, -88.43],
[-52.57, -74.69],
],
// Route 5 (Minor, 8 waypoints, 11285 km)
[
[8.86, -79.39],
[6.98, -79.89],
[6.06, -133.97],
[5.96, -139.98],
[5.77, -149.99],
[5.37, -160.10],
[5.04, -168.02],
[5.01, -180.00],
],
// Route 6 (Major, 15 waypoints, 11069 km)
[
[35.89, -6.73],
[35.40, -16.08],
[34.43, -23.88],
[32.90, -31.84],
[30.80, -39.72],
[27.49, -49.18],
[25.02, -54.98],
[20.84, -63.36],
[18.28, -67.92],
[31.25, -71.38],
[39.36, -72.53],
[39.40, -73.90],
[28.10, -72.73],
[21.75, -71.36],
[20.83, -74.28],
],
// Route 7 (Minor, 14 waypoints, 10616 km)
[
[20.20, -154.75],
[14.32, -145.82],
[10.98, -141.17],
[7.57, -136.61],
[4.09, -132.12],
[0.58, -127.65],
[-5.14, -120.35],
[-8.79, -115.60],
[-15.03, -106.99],
[-19.95, -99.48],
[-23.55, -93.35],
[-26.29, -88.12],
[-29.38, -81.34],
[-33.00, -71.51],
],
// Route 8 (Minor, 14 waypoints, 10356 km)
[
[-30.56, -180.00],
[-25.04, -175.86],
[-20.22, -172.62],
[-13.73, -168.58],
[-9.13, -165.88],
[-2.32, -162.03],
[1.76, -159.74],
[9.95, -155.35],
[15.22, -152.45],
[20.14, -149.60],
[25.00, -146.57],
[31.19, -142.25],
[39.94, -134.80],
[48.41, -124.75],
],
// Route 9 (Major, 17 waypoints, 10002 km)
[
[-8.14, -34.86],
[-8.07, -34.67],
[16.96, -25.63],
[23.55, -22.59],
[30.16, -19.17],
[35.40, -16.08],
[38.73, -13.87],
[45.03, -8.93],
[48.46, -5.68],
[50.75, 0.85],
[53.71, 4.68],
[57.04, 7.17],
[58.28, 10.58],
[54.99, 12.54],
[56.76, 18.80],
[59.59, 22.73],
[60.00, 27.46],
],
// Route 10 (Minor, 12 waypoints, 9864 km)
[
[-33.18, -180.00],
[-26.10, -172.74],
[-18.11, -166.27],
[-12.95, -162.49],
[-7.00, -158.36],
[0.24, -153.49],
[8.57, -147.90],
[14.32, -143.87],
[19.98, -139.62],
[25.01, -135.52],
[30.41, -130.58],
[37.68, -122.62],
],
// Route 11 (Middle, 22 waypoints, 8899 km)
[
[21.41, -157.84],
[21.40, -157.90],
[21.40, -157.90],
[21.46, -158.74],
[21.40, -157.90],
[21.40, -157.90],
[21.41, -157.84],
[21.32, -157.62],
[21.39, -147.96],
[20.80, -138.47],
[20.06, -131.71],
[18.61, -122.42],
[17.60, -117.34],
[15.77, -109.44],
[13.40, -100.82],
[10.74, -92.36],
[9.31, -88.17],
[6.75, -81.01],
[7.53, -79.70],
[8.27, -79.57],
[8.90, -79.45],
[8.98, -79.51],
],
// Route 12 (Middle, 11 waypoints, 8394 km)
[
[11.87, 51.97],
[10.40, 54.14],
[-3.85, 73.57],
[-7.02, 76.90],
[-12.57, 82.95],
[-17.13, 88.22],
[-20.45, 92.33],
[-24.18, 97.34],
[-29.96, 106.35],
[-34.33, 114.78],
[-34.33, 114.78],
],
// Route 13 (Middle, 32 waypoints, 7980 km)
[
[35.57, 139.79],
[35.46, 139.78],
[35.37, 139.85],
[35.28, 139.83],
[35.15, 139.81],
[35.06, 139.79],
[35.06, 139.79],
[35.06, 139.79],
[28.57, 142.52],
[27.11, 143.05],
[23.52, 144.40],
[17.23, 146.53],
[8.68, 149.40],
[-3.05, 153.76],
[-3.05, 153.76],
[-6.54, 156.27],
[-8.95, 154.75],
[-8.95, 154.75],
[-10.69, 150.45],
[-9.07, 144.02],
[-9.70, 143.18],
[-9.85, 143.10],
[-10.37, 142.40],
[-10.36, 142.31],
[-10.36, 142.31],
[-10.36, 142.31],
[-10.38, 142.28],
[-10.38, 142.23],
[-10.43, 142.15],
[-10.43, 142.08],
[-8.67, 135.65],
[-6.59, 132.59],
],
// Route 14 (Minor, 10 waypoints, 7930 km)
[
[-17.70, -149.71],
[-22.96, -145.38],
[-29.97, -138.88],
[-34.93, -133.45],
[-40.80, -125.61],
[-44.95, -118.52],
[-50.01, -106.42],
[-52.65, -96.72],
[-54.93, -79.94],
[-56.00, -67.18],
],
// Route 15 (Minor, 8 waypoints, 7695 km)
[
[-26.81, 153.09],
[-20.93, 154.60],
[-11.03, 154.96],
[-4.35, 152.45],
[-2.64, 149.52],
[9.32, 137.99],
[25.43, 126.40],
[30.98, 122.24],
],
// Route 16 (Major, 11 waypoints, 7121 km)
[
[31.27, 32.74],
[31.27, 32.74],
[29.52, 32.74],
[27.91, 35.28],
[25.48, 36.59],
[13.52, 42.86],
[12.53, 43.57],
[11.95, 51.61],
[7.31, 73.21],
[5.83, 80.00],
[9.79, 75.82],
],
// Route 17 (Major, 10 waypoints, 7079 km)
[
[43.95, -180.00],
[43.69, -166.78],
[42.43, -156.72],
[40.32, -147.15],
[37.45, -138.16],
[34.95, -132.06],
[30.94, -124.07],
[24.96, -114.53],
[19.95, -107.80],
[19.95, -107.80],
],
// Route 18 (Minor, 10 waypoints, 6924 km)
[
[9.08, -180.00],
[9.21, -179.78],
[13.73, -172.15],
[17.73, -164.74],
[21.44, -157.04],
[24.81, -149.04],
[27.87, -140.15],
[29.90, -132.67],
[31.48, -124.97],
[32.57, -117.20],
],
// Route 19 (Minor, 5 waypoints, 6908 km)
[
[8.86, -79.39],
[5.62, -79.81],
[-5.26, -81.87],
[-49.31, -76.84],
[-52.57, -74.69],
],
// Route 20 (Minor, 3 waypoints, 6549 km)
[
[5.01, 180.00],
[4.98, 167.95],
[19.94, 122.22],
],
// Route 21 (Middle, 14 waypoints, 6486 km)
[
[21.90, -71.25],
[23.72, -67.68],
[28.20, -57.53],
[30.17, -52.07],
[32.51, -44.18],
[33.76, -38.84],
[35.22, -30.36],
[36.04, -21.98],
[36.27, -13.82],
[36.00, -6.45],
[36.00, -6.45],
[36.00, -6.45],
[36.00, -6.45],
[36.08, -5.30],
],
// Route 22 (Middle, 11 waypoints, 6423 km)
[
[18.43, -67.82],
[22.22, -64.08],
[28.20, -57.53],
[31.75, -53.08],
[35.01, -48.49],
[40.89, -38.24],
[41.73, -36.45],
[44.99, -28.32],
[47.52, -19.72],
[49.87, -6.32],
[49.87, -6.32],
],
// Route 23 (Middle, 13 waypoints, 6384 km)
[
[21.90, -71.25],
[25.26, -67.61],
[29.96, -62.00],
[34.83, -55.18],
[37.73, -50.41],
[41.67, -42.55],
[41.67, -42.55],
[41.91, -41.99],
[44.87, -34.17],
[47.53, -24.41],
[49.18, -14.71],
[49.87, -6.32],
[49.87, -6.32],
],
// Route 24 (Minor, 9 waypoints, 6373 km)
[
[-4.67, -34.41],
[-1.85, -36.54],
[4.95, -41.63],
[9.76, -45.33],
[13.72, -48.48],
[22.72, -56.23],
[29.51, -63.06],
[34.54, -69.03],
[38.73, -74.94],
],
// Route 25 (Minor, 8 waypoints, 6368 km)
[
[37.68, -122.62],
[34.99, -135.13],
[32.52, -142.93],
[29.57, -150.32],
[24.96, -159.65],
[20.00, -168.01],
[15.81, -174.21],
[11.57, -180.00],
],
// Route 26 (Minor, 10 waypoints, 6307 km)
[
[-4.67, -34.41],
[-0.28, -37.99],
[5.00, -42.26],
[9.99, -46.43],
[15.57, -51.29],
[19.25, -54.68],
[24.01, -59.41],
[27.57, -63.30],
[32.97, -69.99],
[36.87, -75.87],
],
// Route 27 (Middle, 7 waypoints, 6305 km)
[
[6.32, 95.15],
[-7.02, 76.90],
[-18.87, 58.69],
[-19.64, 57.55],
[-19.98, 57.55],
[-20.56, 55.34],
[-25.43, 47.09],
],
// Route 28 (Major, 8 waypoints, 6255 km)
[
[10.05, -17.61],
[4.96, -13.72],
[-2.04, -8.81],
[-10.01, -3.16],
[-18.52, 3.30],
[-25.05, 8.81],
[-30.03, 13.58],
[-34.57, 18.54],
],
// Route 29 (Middle, 14 waypoints, 6175 km)
[
[-4.59, -34.65],
[5.02, -39.03],
[9.20, -40.97],
[18.04, -45.26],
[24.99, -48.99],
[30.17, -52.07],
[31.75, -53.08],
[34.83, -55.18],
[40.70, -59.76],
[42.55, -61.40],
[42.67, -61.51],
[42.67, -61.51],
[44.62, -63.39],
[44.62, -63.39],
],
// Route 30 (Middle, 6 waypoints, 6159 km)
[
[-33.82, 18.24],
[-33.80, 18.13],
[-32.91, 17.46],
[-31.71, 9.72],
[-26.93, -20.14],
[-22.97, -43.15],
],
// Route 31 (Middle, 10 waypoints, 6113 km)
[
[-7.92, -34.74],
[-7.92, -34.74],
[-11.35, -29.93],
[-15.37, -24.01],
[-19.76, -16.92],
[-24.22, -8.75],
[-27.17, -2.45],
[-29.89, 4.33],
[-31.71, 9.72],
[-33.95, 18.03],
],
// Route 32 (Minor, 9 waypoints, 5977 km)
[
[12.69, 124.22],
[15.40, 132.80],
[17.15, 139.27],
[18.69, 145.83],
[19.99, 152.50],
[21.05, 159.27],
[21.83, 166.14],
[22.34, 173.10],
[22.53, 179.97],
],
// Route 33 (Major, 10 waypoints, 5921 km)
[
[44.49, -63.71],
[31.25, -71.38],
[28.10, -72.73],
[21.99, -74.76],
[20.17, -74.05],
[17.60, -76.25],
[9.33, -79.95],
[9.33, -79.95],
[9.33, -79.95],
[18.28, -67.92],
],
// Route 34 (Minor, 9 waypoints, 5866 km)
[
[30.52, -180.00],
[32.43, -173.00],
[33.96, -165.54],
[35.01, -157.96],
[35.58, -150.29],
[35.66, -142.57],
[35.25, -134.85],
[34.34, -127.18],
[32.57, -117.20],
],
// Route 35 (Minor, 8 waypoints, 5811 km)
[
[20.08, -180.00],
[27.79, -171.49],
[32.44, -165.40],
[36.40, -159.29],
[39.96, -152.66],
[44.89, -140.20],
[46.98, -132.46],
[48.41, -124.75],
],
// Route 36 (Middle, 13 waypoints, 5802 km)
[
[36.70, -9.19],
[38.60, -15.80],
[40.12, -22.84],
[41.73, -36.45],
[41.91, -41.99],
[41.91, -44.12],
[41.91, -44.12],
[41.51, -52.70],
[40.70, -59.76],
[39.79, -65.05],
[39.61, -65.91],
[37.48, -74.43],
[37.04, -75.89],
],
// Route 37 (Minor, 8 waypoints, 5717 km)
[
[9.42, 138.05],
[14.98, 142.26],
[19.93, 146.22],
[26.82, 152.38],
[35.00, 161.18],
[39.09, 166.62],
[42.37, 171.79],
[46.53, 179.99],
],
// Route 38 (Minor, 8 waypoints, 5705 km)
[
[11.57, 180.00],
[6.01, 172.85],
[0.99, 166.28],
[-2.27, 162.02],
[2.13, 163.71],
[10.49, 170.95],
[16.44, 176.42],
[20.08, 180.00],
],
// Route 39 (Middle, 7 waypoints, 5518 km)
[
[-8.78, 115.71],
[-10.45, 115.48],
[-24.98, 112.38],
[-34.32, 114.78],
[-35.56, 116.65],
[-38.74, 143.39],
[-38.76, 143.51],
],
// Route 40 (Middle, 7 waypoints, 5466 km)
[
[-19.98, 57.55],
[-19.64, 57.55],
[-12.57, 82.95],
[-6.27, 104.74],
[-6.17, 105.55],
[-6.16, 105.60],
[-5.99, 105.71],
],
// Route 41 (Minor, 9 waypoints, 5449 km)
[
[9.42, 138.05],
[12.92, 141.21],
[18.33, 146.36],
[23.09, 151.31],
[26.74, 155.46],
[31.35, 161.35],
[34.86, 166.53],
[39.98, 175.83],
[41.84, 179.98],
],
// Route 42 (Major, 7 waypoints, 5338 km)
[
[8.82, -79.53],
[7.37, -79.72],
[6.59, -81.01],
[9.14, -87.65],
[19.95, -107.80],
[26.62, -115.07],
[32.59, -117.34],
],
// Route 43 (Major, 8 waypoints, 5332 km)
[
[35.38, 139.75],
[35.17, 139.81],
[33.71, 138.49],
[20.36, 122.63],
[19.75, 121.43],
[8.06, 109.93],
[2.97, 105.44],
[1.10, 104.04],
],
// Route 44 (Middle, 3 waypoints, 5254 km)
[
[-4.59, -34.65],
[11.04, -60.93],
[18.00, -76.72],
],
// Route 45 (Minor, 6 waypoints, 5141 km)
[
[-10.02, 180.00],
[-10.01, 179.96],
[-0.03, 153.92],
[7.64, 140.51],
[8.66, 138.99],
[9.42, 138.05],
],
// Route 46 (Minor, 5 waypoints, 5135 km)
[
[-10.01, 179.96],
[-0.03, 153.92],
[7.64, 140.51],
[8.66, 138.99],
[9.32, 137.99],
],
// Route 47 (Middle, 13 waypoints, 5099 km)
[
[-3.05, 153.76],
[-3.05, 153.76],
[-8.95, 154.75],
[-10.89, 155.05],
[-20.80, 154.67],
[-23.87, 154.08],
[-26.69, 153.17],
[-26.91, 153.81],
[-31.38, 166.98],
[-34.12, 172.98],
[-35.08, 174.86],
[-36.30, 174.89],
[-36.48, 174.78],
],
// Route 48 (Major, 8 waypoints, 5089 km)
[
[34.20, -120.65],
[34.20, -120.65],
[37.22, -126.53],
[40.00, -133.09],
[43.71, -145.14],
[45.21, -152.58],
[46.66, -168.16],
[46.31, -180.00],
],
// Route 49 (Minor, 7 waypoints, 5048 km)
[
[9.42, 138.05],
[14.53, 144.76],
[19.94, 152.52],
[24.96, 160.80],
[27.94, 166.44],
[31.69, 174.86],
[33.57, 179.98],
],
// Route 50 (Minor, 7 waypoints, 5035 km)
[
[30.26, -180.00],
[34.99, -172.80],
[38.91, -165.45],
[41.66, -159.07],
[44.00, -152.29],
[47.27, -137.57],
[48.41, -124.75],
],
// Route 51 (Middle, 15 waypoints, 5021 km)
[
[-6.54, 156.27],
[-3.05, 153.76],
[-3.05, 153.76],
[-1.77, 153.08],
[8.68, 149.40],
[17.23, 146.53],
[27.11, 143.05],
[35.06, 139.79],
[35.06, 139.79],
[35.06, 139.79],
[35.28, 139.83],
[35.37, 139.85],
[35.37, 139.85],
[35.46, 139.78],
[35.57, 139.79],
],
// Route 52 (Minor, 6 waypoints, 4930 km)
[
[9.42, 138.05],
[14.07, 145.32],
[19.99, 155.29],
[24.66, 164.59],
[28.33, 173.48],
[30.52, 180.00],
],
// Route 53 (Middle, 7 waypoints, 4892 km)
[
[47.22, -52.15],
[52.88, -41.70],
[57.01, -32.14],
[62.26, -17.13],
[65.49, -5.67],
[69.10, 10.41],
[71.69, 26.81],
],
// Route 54 (Middle, 122 waypoints, 4870 km)
[
[44.62, -63.39],
[44.62, -63.39],
[44.04, -58.14],
[44.04, -58.14],
[44.04, -58.14],
[42.60, -49.90],
[42.56, -49.46],
[42.54, -49.27],
[42.53, -49.16],
[42.52, -49.10],
[42.30, -47.05],
[42.29, -46.98],
[42.29, -46.97],
[41.94, -44.29],
[41.94, -44.29],
[41.93, -44.21],
[41.92, -44.20],
[41.91, -44.14],
[41.91, -44.12],
[41.91, -44.11],
[41.90, -44.06],
[41.90, -44.03],
[41.89, -43.98],
[41.88, -43.94],
[41.88, -43.90],
[41.87, -43.84],
[41.87, -43.83],
[41.86, -43.75],
[41.86, -43.75],
[41.67, -42.55],
[41.25, -40.13],
[41.25, -40.12],
[41.24, -40.06],
[41.24, -40.03],
[41.23, -40.00],
[41.22, -39.94],
[41.22, -39.93],
[40.97, -38.63],
[40.95, -38.54],
[40.89, -38.24],
[40.87, -38.12],
[39.77, -33.32],
[39.62, -32.71],
[39.59, -32.60],
[39.56, -32.48],
[39.53, -32.37],
[39.50, -32.25],
[39.47, -32.14],
[39.44, -32.02],
[39.41, -31.91],
[39.38, -31.79],
[39.34, -31.68],
[39.31, -31.57],
[39.28, -31.45],
[39.25, -31.34],
[39.22, -31.23],
[39.19, -31.11],
[39.16, -31.00],
[39.13, -30.89],
[39.01, -30.48],
[38.81, -29.79],
[38.60, -29.06],
[38.52, -28.77],
[38.42, -28.28],
[38.52, -28.77],
[38.60, -29.06],
[38.81, -29.79],
[39.01, -30.48],
[39.13, -30.89],
[39.16, -31.00],
[39.19, -31.11],
[39.22, -31.23],
[39.25, -31.34],
[39.28, -31.45],
[39.31, -31.57],
[39.34, -31.68],
[39.38, -31.79],
[39.41, -31.91],
[39.44, -32.02],
[39.47, -32.14],
[39.50, -32.25],
[39.53, -32.37],
[39.56, -32.48],
[39.59, -32.60],
[39.62, -32.71],
[39.77, -33.32],
[40.87, -38.12],
[40.89, -38.24],
[40.95, -38.54],
[40.97, -38.63],
[41.22, -39.93],
[41.22, -39.94],
[41.23, -40.00],
[41.24, -40.03],
[41.24, -40.06],
[41.25, -40.12],
[41.25, -40.13],
[41.67, -42.55],
[41.86, -43.75],
[41.86, -43.75],
[41.87, -43.83],
[41.87, -43.84],
[41.88, -43.90],
[41.88, -43.94],
[41.89, -43.98],
[41.90, -44.03],
[41.90, -44.06],
[41.91, -44.11],
[41.91, -44.12],
[41.91, -44.14],
[41.92, -44.20],
[41.93, -44.21],
[41.94, -44.29],
[41.94, -44.29],
[42.29, -46.97],
[42.29, -46.98],
[42.30, -47.05],
[42.52, -49.10],
[42.53, -49.16],
[42.54, -49.27],
[42.56, -49.46],
[42.60, -49.90],
],
// Route 55 (Middle, 9 waypoints, 4869 km)
[
[8.98, -79.51],
[8.90, -79.45],
[8.90, -79.45],
[8.89, -79.46],
[5.73, -79.94],
[-5.16, -82.02],
[-9.22, -81.02],
[-32.95, -71.75],
[-32.95, -71.75],
],
// Route 56 (Minor, 11 waypoints, 4799 km)
[
[9.42, 138.05],
[8.58, 134.53],
[4.85, 125.32],
[0.92, 119.42],
[-3.78, 118.15],
[-5.49, 116.95],
[-5.73, 114.29],
[-3.81, 109.41],
[-2.05, 108.31],
[-0.24, 106.59],
[1.17, 103.70],
],
// Route 57 (Minor, 7 waypoints, 4663 km)
[
[-3.20, 153.65],
[3.69, 158.70],
[9.85, 163.33],
[14.97, 167.12],
[19.98, 170.91],
[25.00, 175.05],
[30.36, 179.97],
],
// Route 58 (Major, 7 waypoints, 4658 km)
[
[50.56, -180.00],
[50.74, -173.24],
[49.99, -160.41],
[47.28, -145.86],
[42.49, -132.04],
[39.20, -125.38],
[37.71, -122.77],
],
// Route 59 (Minor, 7 waypoints, 4653 km)
[
[-3.20, 153.65],
[3.68, 158.70],
[9.85, 163.33],
[14.97, 167.12],
[19.98, 170.91],
[25.00, 175.05],
[30.24, 179.97],
],
// Route 60 (Middle, 24 waypoints, 4647 km)
[
[1.35, 103.91],
[1.35, 103.91],
[1.42, 104.64],
[0.98, 106.33],
[-1.87, 108.50],
[-1.87, 108.50],
[-1.90, 108.59],
[-2.10, 109.28],
[-2.12, 109.38],
[-2.12, 109.38],
[-2.27, 109.44],
[-2.49, 109.55],
[-3.64, 109.60],
[-3.64, 109.60],
[-3.64, 109.60],
[-3.64, 109.60],
[-3.64, 109.60],
[-7.44, 116.40],
[-7.76, 117.10],
[-7.86, 125.54],
[-7.96, 125.72],
[-8.13, 127.35],
[-10.04, 135.87],
[-10.47, 141.87],
],
// Route 61 (Minor, 7 waypoints, 4644 km)
[
[19.78, 180.00],
[18.89, 173.95],
[17.79, 167.90],
[16.51, 161.94],
[15.06, 156.05],
[12.49, 146.97],
[9.42, 138.05],
],
// Route 62 (Middle, 16 waypoints, 4634 km)
[
[35.57, 139.79],
[35.46, 139.78],
[35.37, 139.85],
[35.37, 139.85],
[35.28, 139.83],
[35.06, 139.79],
[35.46, 139.78],
[35.06, 139.79],
[35.06, 139.79],
[35.06, 139.79],
[28.57, 142.52],
[23.52, 144.40],
[17.23, 146.53],
[8.68, 149.40],
[-1.77, 153.08],
[-3.05, 153.76],
],
// Route 63 (Minor, 6 waypoints, 4550 km)
[
[30.39, -180.00],
[36.17, -173.75],
[43.09, -164.14],
[47.40, -156.07],
[52.10, -143.17],
[54.32, -133.25],
],
// Route 64 (Middle, 12 waypoints, 4348 km)
[
[-5.99, 105.71],
[-6.16, 105.60],
[-6.17, 105.55],
[-6.87, 104.74],
[-7.00, 105.19],
[-10.45, 115.48],
[-11.36, 118.04],
[-11.11, 122.94],
[-9.14, 128.22],
[-10.04, 135.87],
[-10.47, 141.87],
[-10.47, 141.87],
],
// Route 65 (Minor, 5 waypoints, 4300 km)
[
[41.84, -180.00],
[46.31, -166.35],
[48.68, -153.06],
[49.42, -139.16],
[48.41, -124.75],
],
// Route 66 (Middle, 5 waypoints, 4173 km)
[
[21.32, -157.62],
[25.58, -147.40],
[28.76, -137.67],
[31.18, -127.50],
[32.75, -117.19],
],
// Route 67 (Minor, 6 waypoints, 4068 km)
[
[13.67, 144.68],
[17.73, 151.31],
[20.00, 155.31],
[24.91, 165.15],
[27.47, 171.20],
[30.52, 180.00],
],
// Route 68 (Middle, 11 waypoints, 3947 km)
[
[21.90, -71.25],
[21.90, -71.25],
[37.48, -74.43],
[37.60, -74.45],
[38.90, -74.95],
[40.60, -69.16],
[41.76, -64.90],
[41.90, -64.39],
[42.67, -61.51],
[44.04, -58.14],
[46.75, -52.95],
],
// Route 69 (Minor, 10 waypoints, 3903 km)
[
[-6.16, 105.51],
[-3.81, 109.41],
[-2.05, 108.31],
[-0.24, 106.59],
[1.83, 104.77],
[10.27, 106.94],
[9.95, 109.95],
[11.82, 113.94],
[14.28, 120.51],
[14.48, 120.82],
],
// Route 70 (Middle, 13 waypoints, 3896 km)
[
[20.11, -107.72],
[9.30, -87.63],
[6.75, -81.01],
[7.53, -79.70],
[7.53, -79.70],
[7.53, -79.70],
[8.27, -79.57],
[8.90, -79.45],
[8.90, -79.45],
[8.90, -79.45],
[7.53, -79.70],
[7.53, -79.70],
[6.75, -81.00],
],
// Route 71 (Middle, 4 waypoints, 3839 km)
[
[-11.42, 42.92],
[8.16, 52.75],
[10.40, 54.14],
[19.39, 58.69],
],
// Route 72 (Major, 7 waypoints, 3727 km)
[
[36.60, -9.49],
[38.73, -13.87],
[42.02, -22.14],
[45.01, -32.73],
[46.93, -44.37],
[47.43, -50.42],
[47.55, -52.87],
],
// Route 73 (Major, 5 waypoints, 3590 km)
[
[19.95, -107.80],
[9.14, -87.65],
[6.59, -81.01],
[7.37, -79.72],
[8.82, -79.53],
],
// Route 74 (Major, 7 waypoints, 3571 km)
[
[46.31, 179.93],
[45.62, 173.73],
[43.66, 163.39],
[40.81, 153.83],
[37.20, 145.05],
[34.64, 140.02],
[34.64, 140.02],
],
// Route 75 (Major, 6 waypoints, 3567 km)
[
[46.63, -53.30],
[47.43, -50.42],
[49.75, -38.89],
[50.81, -27.11],
[50.70, -16.13],
[48.99, -4.85],
],
// Route 76 (Major, 7 waypoints, 3549 km)
[
[34.64, 140.02],
[34.64, 140.02],
[36.95, 145.46],
[39.98, 154.51],
[42.15, 163.78],
[43.58, 174.12],
[43.95, 179.94],
],
// Route 77 (Minor, 5 waypoints, 3533 km)
[
[41.56, 140.74],
[41.00, 150.13],
[39.93, 157.96],
[37.82, 167.56],
[33.66, 179.98],
],
// Route 78 (Minor, 5 waypoints, 3405 km)
[
[46.54, -180.00],
[49.97, -170.75],
[52.16, -162.27],
[53.95, -150.70],
[54.40, -132.47],
],
// Route 79 (Middle, 25 waypoints, 3399 km)
[
[13.75, 120.89],
[13.66, 120.17],
[7.35, 115.64],
[6.42, 112.61],
[3.14, 108.22],
[0.98, 106.33],
[0.98, 106.33],
[0.98, 106.33],
[-0.06, 106.79],
[-1.87, 108.50],
[-1.87, 108.50],
[-1.88, 108.52],
[-2.12, 109.37],
[-2.12, 109.38],
[-2.13, 109.38],
[-2.27, 109.44],
[-2.49, 109.55],
[-2.60, 109.60],
[-2.61, 109.60],
[-2.61, 109.60],
[-3.64, 109.60],
[-3.64, 109.60],
[-5.01, 107.05],
[-5.14, 106.79],
[-5.99, 105.71],
],
// Route 80 (Major, 11 waypoints, 3357 km)
[
[22.21, 114.27],
[22.23, 114.31],
[22.22, 114.34],
[22.21, 114.36],
[22.13, 114.42],
[22.11, 114.45],
[23.93, 119.10],
[26.70, 121.38],
[34.80, 129.28],
[41.60, 140.80],
[41.60, 140.80],
],
// Route 81 (Middle, 8 waypoints, 3332 km)
[
[-5.99, 105.71],
[-6.16, 105.60],
[-6.16, 105.60],
[-7.00, 105.19],
[-24.98, 112.38],
[-34.32, 114.78],
[-34.33, 114.78],
[-34.33, 114.78],
],
// Route 82 (Middle, 6 waypoints, 3326 km)
[
[10.25, -17.64],
[13.38, -17.94],
[20.91, -18.17],
[26.45, -15.23],
[36.00, -6.45],
[36.08, -5.30],
],
// Route 83 (Middle, 8 waypoints, 3270 km)
[
[-55.98, -67.48],
[-56.37, -75.15],
[-52.21, -78.31],
[-47.44, -78.69],
[-44.03, -77.90],
[-35.90, -74.76],
[-32.95, -71.75],
[-32.95, -71.75],
],
// Route 84 (Major, 6 waypoints, 3135 km)
[
[26.37, 56.70],
[25.82, 57.17],
[25.46, 57.46],
[18.56, 72.77],
[9.79, 75.82],
[8.07, 76.92],
],
// Route 85 (Middle, 4 waypoints, 3079 km)
[
[-32.52, 30.00],
[-25.43, 47.09],
[-20.56, 55.34],
[-19.98, 57.55],
],
// Route 86 (Middle, 11 waypoints, 3069 km)
[
[4.69, 125.19],
[1.84, 127.00],
[0.28, 126.37],
[-1.74, 127.07],
[-2.75, 125.56],
[-3.17, 125.54],
[-3.24, 125.54],
[-3.90, 125.74],
[-6.59, 132.59],
[-8.67, 135.65],
[-10.47, 141.87],
],
// Route 87 (Major, 12 waypoints, 2968 km)
[
[35.38, 139.75],
[35.17, 139.81],
[33.71, 138.49],
[30.51, 130.22],
[26.70, 121.38],
[23.93, 119.10],
[22.11, 114.45],
[22.13, 114.42],
[22.21, 114.36],
[22.22, 114.34],
[22.23, 114.31],
[22.21, 114.27],
],
// Route 88 (Major, 4 waypoints, 2952 km)
[
[41.70, 143.31],
[46.25, 155.15],
[48.49, 163.96],
[50.55, 179.90],
],
// Route 89 (Middle, 8 waypoints, 2947 km)
[
[44.62, -63.39],
[41.90, -64.39],
[41.90, -64.39],
[41.38, -64.56],
[39.79, -65.05],
[25.26, -67.61],
[23.72, -67.68],
[18.43, -67.82],
],
// Route 90 (Middle, 33 waypoints, 2885 km)
[
[13.53, 100.65],
[12.81, 100.63],
[12.81, 100.63],
[2.33, 104.92],
[1.35, 103.91],
[1.35, 103.91],
[0.98, 106.33],
[0.98, 106.33],
[-0.06, 106.79],
[-0.08, 106.80],
[-0.10, 106.82],
[-1.83, 108.47],
[-1.86, 108.49],
[-1.87, 108.50],
[-1.87, 108.50],
[-1.88, 108.52],
[-1.90, 108.59],
[-1.91, 108.64],
[-1.95, 108.79],
[-2.04, 109.07],
[-2.08, 109.22],
[-2.10, 109.28],
[-2.12, 109.37],
[-2.12, 109.38],
[-2.13, 109.38],
[-2.60, 109.60],
[-2.61, 109.60],
[-2.64, 109.60],
[-3.64, 109.60],
[-3.64, 109.60],
[-5.01, 107.05],
[-5.14, 106.79],
[-5.91, 106.93],
],
// Route 91 (Major, 4 waypoints, 2849 km)
[
[53.94, -165.50],
[53.34, -146.88],
[51.76, -136.90],
[48.47, -124.94],
],
// Route 92 (Middle, 65 waypoints, 2793 km)
[
[-16.77, 146.05],
[-15.90, 145.52],
[-15.61, 145.52],
[-14.95, 145.42],
[-14.40, 144.94],
[-14.21, 144.69],
[-14.07, 144.62],
[-14.03, 144.57],
[-13.99, 144.49],
[-13.97, 144.26],
[-13.85, 143.85],
[-13.79, 143.83],
[-13.54, 143.73],
[-13.34, 143.69],
[-12.49, 143.50],
[-12.11, 143.27],
[-12.08, 143.27],
[-11.83, 143.33],
[-11.45, 142.99],
[-11.45, 142.99],
[-11.23, 143.02],
[-10.64, 142.74],
[-10.62, 142.70],
[-10.48, 142.58],
[-10.36, 142.31],
[-10.36, 142.31],
[-10.38, 142.28],
[-10.38, 142.23],
[-10.43, 142.08],
[-10.47, 141.87],
[-10.43, 142.08],
[-10.38, 142.23],
[-10.38, 142.28],
[-10.36, 142.31],
[-10.36, 142.31],
[-10.48, 142.58],
[-10.62, 142.70],
[-10.64, 142.74],
[-11.23, 143.02],
[-11.45, 142.99],
[-11.45, 142.99],
[-11.83, 143.33],
[-12.08, 143.27],
[-12.11, 143.27],
[-12.49, 143.50],
[-13.34, 143.69],
[-13.54, 143.73],
[-13.79, 143.83],
[-13.85, 143.85],
[-13.97, 144.26],
[-13.99, 144.49],
[-14.03, 144.57],
[-14.07, 144.62],
[-14.21, 144.69],
[-14.40, 144.94],
[-14.95, 145.42],
[-15.61, 145.52],
[-15.90, 145.52],
[-16.77, 146.05],
[-16.86, 146.07],
[-17.01, 146.12],
[-18.01, 146.41],
[-18.42, 146.70],
[-21.54, 150.55],
[-22.62, 152.00],
],
// Route 93 (Minor, 6 waypoints, 2742 km)
[
[21.15, -157.67],
[18.53, -163.16],
[14.99, -169.90],
[11.96, -175.21],
[9.21, -179.78],
[9.08, -180.00],
],
// Route 94 (Middle, 19 waypoints, 2732 km)
[
[13.53, 100.65],
[12.81, 100.63],
[12.81, 100.63],
[2.33, 104.92],
[0.98, 106.33],
[-0.08, 106.81],
[-0.10, 106.82],
[-1.83, 108.47],
[-1.85, 108.48],
[-1.92, 108.65],
[-1.95, 108.79],
[-2.04, 109.07],
[-2.08, 109.22],
[-2.61, 109.60],
[-2.64, 109.60],
[-3.17, 109.60],
[-3.64, 109.60],
[-3.64, 109.60],
[-5.99, 105.71],
],
// Route 95 (Minor, 6 waypoints, 2723 km)
[
[13.33, 100.44],
[7.90, 103.95],
[7.75, 105.87],
[11.82, 113.94],
[14.28, 120.51],
[14.48, 120.82],
],
// Route 96 (Middle, 22 waypoints, 2672 km)
[
[-5.91, 106.93],
[-5.14, 106.79],
[-5.14, 106.79],
[-5.14, 106.79],
[-5.01, 107.05],
[-3.64, 109.60],
[-3.64, 109.60],
[-2.61, 109.60],
[-2.61, 109.60],
[-2.61, 109.60],
[-2.12, 109.38],
[-2.12, 109.36],
[-2.08, 109.22],
[-1.91, 108.64],
[-1.86, 108.49],
[-0.08, 106.80],
[0.98, 106.33],
[0.98, 106.33],
[2.33, 104.92],
[12.81, 100.63],
[12.81, 100.63],
[13.53, 100.65],
],
// Route 97 (Major, 11 waypoints, 2660 km)
[
[22.21, 114.27],
[22.23, 114.31],
[22.22, 114.34],
[22.21, 114.36],
[22.13, 114.42],
[22.11, 114.45],
[22.11, 114.45],
[16.06, 113.39],
[9.83, 109.63],
[2.97, 105.44],
[1.10, 104.04],
],
// Route 98 (Middle, 8 waypoints, 2660 km)
[
[-26.69, 153.16],
[-23.87, 154.08],
[-20.80, 154.67],
[-10.89, 155.05],
[-8.95, 154.75],
[-3.05, 153.76],
[-3.05, 153.76],
[-3.05, 153.76],
],
// Route 99 (Middle, 8 waypoints, 2628 km)
[
[44.62, -63.39],
[41.76, -64.90],
[41.76, -64.90],
[41.14, -65.20],
[39.61, -65.91],
[21.90, -71.25],
[21.90, -71.25],
[21.90, -71.25],
],
];
// Major air routes (lat, lon pairs between top airports)
const airRoutes = [
// Trans-Atlantic
[[40.6413, -73.7781], [51.4700, -0.4543]], // JFK (New York) - LHR (London)
[[33.9416, -118.4085], [51.4700, -0.4543]], // LAX (Los Angeles) - LHR (London)
[[33.6407, -84.4277], [49.0097, 2.5479]], // ATL (Atlanta) - CDG (Paris)
[[25.7959, -80.2870], [41.2974, 2.0833]], // MIA (Miami) - BCN (Barcelona)
[[41.9742, -87.9073], [50.0379, 8.5622]], // ORD (Chicago) - FRA (Frankfurt)
// Trans-Pacific
[[37.7749, -122.4194], [35.5494, 139.7798]], // SFO (San Francisco) - HND (Tokyo)
[[34.0522, -118.2437], [31.1432, 121.8054]], // LAX - PVG (Shanghai)
[[47.4502, -122.3088], [35.5494, 139.7798]], // SEA (Seattle) - HND (Tokyo)
[[49.1939, -123.1844], [22.3080, 113.9185]], // YVR (Vancouver) - HKG (Hong Kong)
[[37.6213, -122.3790], [1.3644, 103.9915]], // SFO - SIN (Singapore)
// Asia-Pacific
[[35.5494, 139.7798], [1.3644, 103.9915]], // HND (Tokyo) - SIN (Singapore)
[[22.3080, 113.9185], [13.6900, 100.7501]], // HKG (Hong Kong) - BKK (Bangkok)
[[31.1432, 121.8054], [37.4602, 126.4407]], // PVG (Shanghai) - ICN (Seoul)
[[28.5383, 77.3910], [1.3644, 103.9915]], // DEL (Delhi) - SIN (Singapore)
[[35.5494, 139.7798], [37.4602, 126.4407]], // HND (Tokyo) - ICN (Seoul)
// Europe-Asia
[[51.4700, -0.4543], [25.2532, 55.3657]], // LHR (London) - DXB (Dubai)
[[49.0097, 2.5479], [25.2532, 55.3657]], // CDG (Paris) - DXB (Dubai)
[[50.0379, 8.5622], [28.5383, 77.3910]], // FRA (Frankfurt) - DEL (Delhi)
[[52.3105, 4.7683], [1.3644, 103.9915]], // AMS (Amsterdam) - SIN (Singapore)
[[41.2764, 28.7279], [25.2532, 55.3657]], // IST (Istanbul) - DXB (Dubai)
// Middle East-Asia
[[25.2532, 55.3657], [13.6900, 100.7501]], // DXB (Dubai) - BKK (Bangkok)
[[25.2532, 55.3657], [22.3080, 113.9185]], // DXB (Dubai) - HKG (Hong Kong)
[[25.2532, 55.3657], [31.1432, 121.8054]], // DXB (Dubai) - PVG (Shanghai)
// Europe Internal
[[51.4700, -0.4543], [49.0097, 2.5479]], // London Heathrow (LHR) → Paris Charles de Gaulle (CDG)
[[48.1186, 11.5673], [41.8026, 12.2387]], // Munich (MUC) → Rome Fiumicino (FCO)
[[40.4722, -3.5608], [48.8566, 2.3522]], // Madrid Barajas (MAD) → Paris (CDG)
[[41.2974, 2.0833], [51.4700, -0.4543]], // Barcelona (BCN) → London Heathrow (LHR)
[[45.6301, 8.7281], [52.3105, 4.7683]], // Milan Malpensa (MXP) → Amsterdam Schiphol (AMS)
[[52.5597, 13.2877], [41.8026, 12.2387]], // Berlin Brandenburg (BER) → Rome Fiumicino (FCO)
[[37.9364, 23.9445], [50.0379, 8.5622]], // Athens (ATH) → Frankfurt (FRA)
[[59.6519, 17.9186], [55.9736, -3.3433]], // Stockholm Arlanda (ARN) → Edinburgh (EDI)
// Africa Internal
[[-26.1337, 28.2420], [-1.3192, 36.9278]], // Johannesburg (JNB) → Nairobi (NBO)
[[30.1219, 31.4056], [-33.9696, 18.5972]], // Cairo (CAI) → Cape Town (CPT)
[[-1.3192, 36.9278], [25.2532, 55.3657]], // Nairobi (NBO) → Entebbe (EBB)
[[14.6700, -17.0733], [-26.1337, 28.2420]], // Dakar (DSS) → Lomé (LFW)
[[-4.3858, 15.4446], [-6.1657, 39.2026]], // Kinshasa (FIH) → Dar es Salaam (DAR)
[[-33.9696, 18.5972], [-29.6144, 31.1197]], // Cape Town (CPT) → Durban (DUR)
[[9.0068, 7.2632], [30.1219, 31.4056]], // Abuja (ABV) → Cairo (CAI)
[[12.3532, -1.5124], [5.6052, -0.1668]], // Ouagadougou (OUA) → Accra (ACC)
[[-1.3192, 36.9278], [-4.3858, 15.4446]], // Nairobi (NBO) → Kinshasa (FIH)
// Oceania
[[-33.9461, 151.1772], [1.3644, 103.9915]], // SYD (Sydney) - SIN (Singapore)
[[-37.0082, 174.7850], [-33.9461, 151.1772]], // AKL (Auckland) - SYD (Sydney)
[[-33.9461, 151.1772], [19.8987, -155.6659]],
// North America Internal
[[40.6413, -73.7781], [33.9416, -118.4085]], // JFK - LAX
[[32.8998, -97.0403], [33.6407, -84.4277]], // DFW (Dallas) - ATL (Atlanta)
[[33.9416, -118.4085], [19.8987, -155.6659]],
// North America → South America
[[25.7933, -80.2906], [-12.0219, -77.1143]], // Miami (MIA) → Lima (LIM)
[[40.6413, -73.7781], [-34.8222, -58.5358]], // New York (JFK) → Buenos Aires (EZE)
[[29.9902, -95.3368], [4.7016, -74.1469]], // Houston (IAH) → Bogotá (BOG)
[[33.9416, -118.4085], [-23.4319, -46.4695]],// Los Angeles (LAX) → São Paulo (GRU)
[[25.7933, -80.2906], [9.0714, -79.3835]], // Miami (MIA) → Panama City (PTY)
// South America Internal
[[-23.4319, -46.4695], [-33.3930, -70.7858]], // São Paulo (GRU) → Santiago (SCL)
[[-12.0219, -77.1143], [-33.3930, -70.7858]], // Lima (LIM) → Santiago (SCL)
[[4.7016, -74.1469], [-12.0219, -77.1143]], // Bogotá (BOG) → Lima (LIM)
[[-34.8222, -58.5358], [-22.9083, -43.1964]], // Buenos Aires (EZE) → Rio de Janeiro (GIG)
[[-12.0219, -77.1143], [-0.1292, -78.3575]], // Lima (LIM) → Quito (UIO)
];
// Calculate great circle distance between two lat/lon points (in km)
function greatCircleDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in km
const phi1 = lat1 * Math.PI / 180;
const phi2 = lat2 * Math.PI / 180;
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
const deltaLambda = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(deltaPhi/2) * Math.sin(deltaPhi/2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(deltaLambda/2) * Math.sin(deltaLambda/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// Spherical linear interpolation for great circle paths
function slerp(v1, v2, t) {
const dot = v1.dot(v2);
const clampedDot = Math.max(-1, Math.min(1, dot));
const theta = Math.acos(clampedDot) * t;
const relative = v2.clone().sub(v1.clone().multiplyScalar(clampedDot)).normalize();
return v1.clone().multiplyScalar(Math.cos(theta)).add(relative.multiplyScalar(Math.sin(theta)));
}
// Returns true if the straight line between p and q does NOT intersect the Earth sphere
function hasLineOfSight(p, q, radius = EARTHRADIUS) {
const u = new THREE.Vector3().subVectors(q, p);
const A = u.dot(u);
const B = 2 * p.dot(u);
const C = p.dot(p) - radius * radius;
const D = B * B - 4 * A * C;
if (D <= 0) return true; // no intersection (or tangent)
const sqrtD = Math.sqrt(D);
const t1 = (-B - sqrtD) / (2 * A);
const t2 = (-B + sqrtD) / (2 * A);
// occluded only if an intersection exists within the segment bounds
const intersectsWithinSegment = (t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1);
return !intersectsWithinSegment;
}
// --- Sensor FOV helpers ---
// Minimal signed angular difference on a circle
function angleDiff(a, b) {
const TWO_PI = Math.PI * 2;
let d = (a - b) % TWO_PI;
if (d > Math.PI) d -= TWO_PI;
if (d < -Math.PI) d += TWO_PI;
return Math.abs(d);
}
// Returns true if a normalized direction in sensor-local space lies within the sensor FOV
// Local frame convention: sensor looks along -Y. So forward means localDir.y < 0
function isWithinSensorFOVLocal(localDir, cone) {
const type = cone.userData.sensorType;
// Must be in front of sensor
if (-localDir.y <= 0) return false;
if (type === 'cone') {
// Angle between -Y axis and direction
const halfFovRad = cone.userData.halfFovRad != null
? cone.userData.halfFovRad
: (coneFov * Math.PI / 180) / 2;
const cosAngle = Math.max(-1, Math.min(1, -localDir.y));
const angle = Math.acos(cosAngle);
return angle <= halfFovRad;
}
if (type === 'rectangular') {
const h = cone.userData.height ?? 0.3;
const tanX = (cone.userData.rectWidth ?? 0.1) / h;
const tanZ = (cone.userData.rectDepth ?? 0.1) / h;
const y = -localDir.y; // forward distance (normalized units)
const withinX = Math.abs(localDir.x / y) <= tanX;
const withinZ = Math.abs(localDir.z / y) <= tanZ;
return withinX && withinZ;
}
if (type === 'axeBlade') {
const h = cone.userData.height ?? 1;
const outerR = cone.userData.outerRadius ?? 1;
const cutoutHalf = ((cone.userData.cutoutAngle ?? 40) * Math.PI / 180) / 2;
const outerHalf = Math.atan2(outerR, h); // half-angle to outer rim
// Polar angle from -Y axis
const theta = Math.acos(Math.max(-1, Math.min(1, -localDir.y)));
if (theta < cutoutHalf || theta > outerHalf) return false;
// Azimuth around -Y axis in XZ plane
const phi = Math.atan2(localDir.z, localDir.x);
const bladeAngleRad = (cone.userData.bladeAngle ?? 135) * Math.PI / 180;
const halfBlade = bladeAngleRad / 2;
const centers = cone.userData.bladeCenters ?? [Math.PI / 2, 3 * Math.PI / 2];
// Accept if phi is within +/- halfBlade of either center
return centers.some(c => angleDiff(phi, c) <= halfBlade);
}
return false;
}
function createShips() {
ships = [];
// Create multiple ships per route - use all available routes
const routeIndicesToUse = [...Array(shippingRoutes.length).keys()];
routeIndicesToUse.forEach(routeIndex => {
const route = shippingRoutes[routeIndex];
// Skip if route doesn't exist or is invalid
if (!route || route.length < 2) return;
// Calculate total route distance
let totalDistance = 0;
for (let i = 0; i < route.length - 1; i++) {
const [lat1, lon1] = route[i];
const [lat2, lon2] = route[i + 1];
totalDistance += greatCircleDistance(lat1, lon1, lat2, lon2);
}
const shipsPerRoute = 20; // 20 ships per route to show trace
for (let i = 0; i < shipsPerRoute; i++) {
ships.push({
route: route,
routeIndex: routeIndex,
progress: i / shipsPerRoute,
speed: 25, // 25-40 km/hour (typical cargo ship speed)
routeDistance: totalDistance,
type: 'ship'
});
}
});
}
function createPlanes() {
planes = [];
// Create aircraft for each air route
airRoutes.forEach((route, routeIndex) => {
const planesPerRoute = 2; // 2 planes per route
// Calculate route distance (simple 2-point great circle)
const [lat1, lon1] = route[0];
const [lat2, lon2] = route[1];
const routeDistance = greatCircleDistance(lat1, lon1, lat2, lon2);
for (let i = 0; i < planesPerRoute; i++) {
planes.push({
route: route,
routeIndex: routeIndex,
progress: i / planesPerRoute,
speed: 100*(800 + Math.random() * 150), // 800-950 km/hour (typical cruising speed)
routeDistance: routeDistance,
type: 'plane'
});
}
});
}
function createPlaneRoutes() {
// Clear existing route lines
planeRoutes.forEach(line => earth.remove(line));
planeRoutes = [];
// Create visual great circle routes for each air route
airRoutes.forEach(route => {
const [lat1, lon1] = route[0];
const [lat2, lon2] = route[1];
// Generate points along the great circle
const segments = 50; // Number of segments for smooth curve
const points = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
// Calculate position along great circle
const phi1 = (90 - lat1) * (Math.PI / 180);
const theta1 = (lon1 + SHIP_PLANE_TEXTURE_LON_OFFSET) * (Math.PI / 180);
const pos1 = new THREE.Vector3(
EARTHRADIUS * Math.sin(phi1) * Math.sin(theta1),
EARTHRADIUS * Math.cos(phi1),
EARTHRADIUS * Math.sin(phi1) * Math.cos(theta1)
);
const phi2 = (90 - lat2) * (Math.PI / 180);
const theta2 = (lon2 + SHIP_PLANE_TEXTURE_LON_OFFSET) * (Math.PI / 180);
const pos2 = new THREE.Vector3(
EARTHRADIUS * Math.sin(phi2) * Math.sin(theta2),
EARTHRADIUS * Math.cos(phi2),
EARTHRADIUS * Math.sin(phi2) * Math.cos(theta2)
);
// Use slerp for great circle interpolation
const position = slerp(pos1.normalize(), pos2.normalize(), t);
position.multiplyScalar(EARTHRADIUS * 1.005); // Slightly above surface
points.push(position);
}
// Create line geometry
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0xffff00, // Yellow to match aircraft
transparent: true,
opacity: 0.1,
linewidth: 1
});
const line = new THREE.Line(geometry, material);
line.visible = showPlanes; // Match visibility with planes
earth.add(line); // Add to Earth so it rotates with it
planeRoutes.push(line);
});
}
function createShipMeshes() {
shipMeshes.forEach(mesh => scene.remove(mesh));
shipMeshes = [];
// Create square geometry for ships
const size = 0.006;
const shipGeometry = new THREE.PlaneGeometry(size, size);
const shipMaterial = new THREE.MeshBasicMaterial({
color: 0xf9dfff,
transparent: true,
opacity: 0.9,
side: THREE.DoubleSide
});
ships.forEach(() => {
const shipMesh = new THREE.Mesh(shipGeometry, shipMaterial);
shipMesh.visible = showShips; // Set initial visibility
earth.add(shipMesh); // Add to earth so they rotate with it
shipMeshes.push(shipMesh);
});
}
function createPlaneMeshes() {
planeMeshes.forEach(mesh => scene.remove(mesh));
planeMeshes = [];
// Create triangle geometry for planes (rotated 90 degrees to lie flat on Earth surface)
const size = 0.008;
const planeGeometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
0, 0, -size, // top vertex (pointing forward)
-size/2, 0, size/2, // bottom left
size/2, 0, size/2 // bottom right
]);
planeGeometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
const planeMaterial = new THREE.MeshBasicMaterial({
color: 0xffff00, // Yellow for aircraft
transparent: true,
opacity: 0.95,
side: THREE.DoubleSide
});
planes.forEach(() => {
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.visible = showPlanes; // Set initial visibility
earth.add(planeMesh); // Add to earth so they rotate with it
planeMeshes.push(planeMesh);
});
}
// Randomly select ship and plane targets and mark them red
function selectRandomTargets(numShips = 8, numPlanes = 8) {
selectedTargets = [];
const pickRandomIndices = (n, k) => {
const idx = [...Array(n).keys()];
for (let i = idx.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[idx[i], idx[j]] = [idx[j], idx[i]];
}
return idx.slice(0, Math.min(k, n));
};
// Ships
pickRandomIndices(shipMeshes.length, numShips).forEach(index => {
const mesh = shipMeshes[index];
if (!mesh) return;
mesh.material = mesh.material.clone();
mesh.material.color.set(0xff0000);
mesh.material.opacity = 1.0;
selectedTargets.push({ type: 'ship', index, mesh });
});
// Planes
pickRandomIndices(planeMeshes.length, numPlanes).forEach(index => {
const mesh = planeMeshes[index];
if (!mesh) return;
mesh.material = mesh.material.clone();
mesh.material.color.set(0xff0000);
mesh.material.opacity = 1.0;
selectedTargets.push({ type: 'plane', index, mesh });
});
}
function updateShips(deltaTime) {
if (!showShips) return;
ships.forEach((ship, index) => {
// Update progress based on absolute speed (km/hour)
// deltaTime is in ms, speed is in km/hour
const distanceTraveled = (ship.speed * deltaTime) / 3600000; // Convert to km
const progressIncrement = distanceTraveled / ship.routeDistance;
ship.progress += progressIncrement;
if (ship.progress >= 1) ship.progress = 0; // Loop
// Get current and next waypoint
const route = ship.route;
const segmentCount = route.length - 1;
const totalProgress = ship.progress * segmentCount;
const currentIndex = Math.floor(totalProgress);
const nextIndex = (currentIndex + 1) % route.length;
const segmentProgress = totalProgress - currentIndex;
// Get 3D positions for waypoints (in Earth's local space)
// Apply texture offset to align with actual Earth texture orientation
const [lat1, lon1] = route[currentIndex];
const [lat2, lon2] = route[nextIndex];
const phi1 = (90 - lat1) * (Math.PI / 180);
const theta1 = (lon1 + SHIP_PLANE_TEXTURE_LON_OFFSET) * (Math.PI / 180);
const pos1 = new THREE.Vector3(
EARTHRADIUS * Math.sin(phi1) * Math.sin(theta1),
EARTHRADIUS * Math.cos(phi1),
EARTHRADIUS * Math.sin(phi1) * Math.cos(theta1)
);
const phi2 = (90 - lat2) * (Math.PI / 180);
const theta2 = (lon2 + SHIP_PLANE_TEXTURE_LON_OFFSET) * (Math.PI / 180);
const pos2 = new THREE.Vector3(
EARTHRADIUS * Math.sin(phi2) * Math.sin(theta2),
EARTHRADIUS * Math.cos(phi2),
EARTHRADIUS * Math.sin(phi2) * Math.cos(theta2)
);
// Use spherical interpolation (great circle path)
const position = slerp(pos1.normalize(), pos2.normalize(), segmentProgress);
position.multiplyScalar(EARTHRADIUS * 1.003); // Slightly above surface
shipMeshes[index].position.copy(position);
// Orient ship to lie flat (tangent) to Earth's surface
// Default PlaneGeometry normal is +Z; align it to local radial (up) vector
const upVec = position.clone().normalize();
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), upVec);
shipMeshes[index].quaternion.copy(quat);
});
}
function updatePlanes(deltaTime) {
if (!showPlanes) return;
planes.forEach((plane, index) => {
// Update progress based on absolute speed (km/hour)
// deltaTime is in ms, speed is in km/hour
const distanceTraveled = (plane.speed * deltaTime) / 3600000; // Convert to km
const progressIncrement = distanceTraveled / plane.routeDistance;
plane.progress += progressIncrement;
if (plane.progress >= 1) plane.progress = 0;
// Air routes are simple 2-point great circles (in Earth's local space)
// Apply texture offset to align with actual Earth texture orientation
const route = plane.route;
const [lat1, lon1] = route[0];
const [lat2, lon2] = route[1];
const phi1 = (90 - lat1) * (Math.PI / 180);
const theta1 = (lon1 + SHIP_PLANE_TEXTURE_LON_OFFSET) * (Math.PI / 180);
const pos1 = new THREE.Vector3(
EARTHRADIUS * Math.sin(phi1) * Math.sin(theta1),
EARTHRADIUS * Math.cos(phi1),
EARTHRADIUS * Math.sin(phi1) * Math.cos(theta1)
);
const phi2 = (90 - lat2) * (Math.PI / 180);
const theta2 = (lon2 + SHIP_PLANE_TEXTURE_LON_OFFSET) * (Math.PI / 180);
const pos2 = new THREE.Vector3(
EARTHRADIUS * Math.sin(phi2) * Math.sin(theta2),
EARTHRADIUS * Math.cos(phi2),
EARTHRADIUS * Math.sin(phi2) * Math.cos(theta2)
);
// Use spherical interpolation for great circle path
const position = slerp(pos1.normalize(), pos2.normalize(), plane.progress);
position.multiplyScalar(EARTHRADIUS * 1.01); // Higher altitude than ships
planeMeshes[index].position.copy(position);
// Orient triangle to point in direction of travel
const nextProgress = plane.progress + 0.01;
const nextPosition = slerp(pos1.normalize(), pos2.normalize(), nextProgress > 1 ? 0 : nextProgress);
nextPosition.multiplyScalar(EARTHRADIUS * 1.01);
// Calculate direction vector
const direction = new THREE.Vector3().subVectors(nextPosition, position).normalize();
const up = position.clone().normalize();
// Create rotation to point triangle in direction of travel
const quaternion = new THREE.Quaternion();
const targetMatrix = new THREE.Matrix4().lookAt(position, nextPosition, up);
quaternion.setFromRotationMatrix(targetMatrix);
planeMeshes[index].quaternion.copy(quaternion);
});
}
// Draw satellite-to-satellite crosslinks where LoS is clear (no Earth occlusion)
function updateCrosslinks() {
// Clear previous lines
crosslinkLines.forEach(l => { scene.remove(l); if (l.geometry) l.geometry.dispose(); });
crosslinkLines = [];
const satPositions = satelliteMeshes.map(m => {
const v = new THREE.Vector3();
m.getWorldPosition(v);
return v;
});
for (let i = 0; i < satPositions.length; i++) {
for (let j = i + 1; j < satPositions.length; j++) {
const p1 = satPositions[i];
const p2 = satPositions[j];
// Early rejection: skip if too far apart (satellites beyond horizon can't crosslink)
const distSq = p1.distanceToSquared(p2);
if (distSq > 16) continue; // ~4 Earth radii max practical crosslink distance
if (hasLineOfSight(p1, p2)) {
const geometry = new THREE.BufferGeometry().setFromPoints([p1, p2]);
const line = new THREE.Line(geometry, crosslinkMaterial);
scene.add(line);
crosslinkLines.push(line);
}
}
}
}
// Draw satellite-to-target downlinks (targets are randomly selected ships/planes and colored red)
// Only draw links when target is within sensor FOV
function updateDownlinks() {
// Clear previous lines
downlinkLines.forEach(l => { scene.remove(l); if (l.geometry) l.geometry.dispose(); });
downlinkLines = [];
const satPositions = satelliteMeshes.map(m => {
const v = new THREE.Vector3();
m.getWorldPosition(v);
return v;
});
// Precompute sensor data for all satellites
const sensorData = [];
for (let i = 0; i < satPositions.length; i++) {
const cone = sensorCones[i];
if (!cone) continue;
const satPos = satPositions[i];
const satR = satPos.length();
const satNorm = satPos.clone().multiplyScalar(1 / satR);
const invQuat = cone.quaternion.clone().invert();
const altitudeKm = (satR - 1) * PHYSICS.EARTH_RADIUS_KM;
const maxRange = (coneRangeKm + altitudeKm) / PHYSICS.EARTH_RADIUS_KM;
const maxRangeSq = maxRange * maxRange;
const cosMax = 1 / satR; // horizon limit: acos(1/r)
sensorData.push({ satPos, satNorm, invQuat, cone, maxRangeSq, cosMax, index: i });
}
selectedTargets.forEach(target => {
if (!target.mesh || !target.mesh.visible) return; // respect visibility toggles
const tpos = new THREE.Vector3();
target.mesh.getWorldPosition(tpos);
for (const sensor of sensorData) {
// Early rejection: check distance squared before computing actual distance
const distSq = sensor.satPos.distanceToSquared(tpos);
if (distSq > sensor.maxRangeSq) continue;
// Fast horizon reject using subsatellite dot target normal
const tnorm = tpos.clone().normalize();
if (sensor.satNorm.dot(tnorm) < sensor.cosMax) continue;
// Vector from satellite to target
const toTarget = new THREE.Vector3().subVectors(tpos, sensor.satPos);
const distance = Math.sqrt(distSq);
toTarget.divideScalar(distance); // normalize
// Sensor-local FOV acceptance
const localDir = toTarget.clone().applyQuaternion(sensor.invQuat);
if (!isWithinSensorFOVLocal(localDir, sensor.cone)) continue;
// Final robust LOS check
if (!hasLineOfSight(sensor.satPos, tpos)) continue;
// Draw the link
const geometry = new THREE.BufferGeometry().setFromPoints([sensor.satPos, tpos]);
const line = new THREE.Line(geometry, downlinkMaterial);
scene.add(line);
downlinkLines.push(line);
}
});
}
function getEarthRotation(date) {
// Greenwich Apparent Sidereal Time (GAST) with small nutation correction
const DEG2RAD = Math.PI / 180;
const jd = getJulianDate(date);
const T = (jd - 2451545.0) / 36525.0;
// GMST (deg)
let gmst = 280.46061837 + 360.98564736629 * (jd - 2451545.0)
+ 0.000387933 * T * T - (T * T * T) / 38710000.0;
// Small equation-of-equinoxes approximation to get GAST (deg)
// Using leading terms dependent on Ω and L0
let L0 = 280.46646 + T * (36000.76983 + 0.0003032 * T);
L0 = ((L0 % 360) + 360) % 360;
const Omega = (125.04 - 1934.136 * T) * DEG2RAD;
const eqeq = 0.00264 * Math.sin(Omega) + 0.000063 * Math.sin(2 * L0 * DEG2RAD); // degrees
let gast = gmst + eqeq - 35; // GAST in degrees
// Normalize to 0-360 range
gast = gast % 360;
if (gast < 0) gast += 360;
// Convert to radians (positive so rotation matches day/night texture orientation)
return gast * DEG2RAD;
}
class OrbitPropagator {
static propagateSingle(satellite, time) {
const dt = time - satellite.epoch;
const { a, e, i, raan, argp, M0 } = satellite;
const n0 = Math.sqrt(PHYSICS.MU / (a * a * a));
const j2Factor = (n0 * PHYSICS.EARTH_RADIUS * PHYSICS.EARTH_RADIUS * PHYSICS.J2) /
(a * a * Math.pow(1 - e * e, 2));
const dRaanDt = -1.5 * j2Factor * Math.cos(i);
const dArgpDt = 0.75 * j2Factor * (4 - 5 * Math.pow(Math.sin(i), 2));
const dMDt = 0.75 * j2Factor * Math.sqrt(1 - e * e) * (2 - 3 * Math.pow(Math.sin(i), 2));
const raan_t = raan + dRaanDt * dt;
const argp_t = argp + dArgpDt * dt;
const M_t = M0 + (n0 + dMDt) * dt;
const E = this.solveKeplerEquation(M_t, e);
const sinE = Math.sin(E);
const cosE = Math.cos(E);
const sqrtOneMinusE2 = Math.sqrt(1 - e * e);
const nu = 2 * Math.atan2(sqrtOneMinusE2 * sinE, cosE + e);
const r = a * (1 - e * cosE);
const cosNu = Math.cos(nu);
const sinNu = Math.sin(nu);
const x_orb = r * cosNu;
const y_orb = r * sinNu;
const cosRaan = Math.cos(raan_t);
const sinRaan = Math.sin(raan_t);
const cosArgp = Math.cos(argp_t);
const sinArgp = Math.sin(argp_t);
const cosI = Math.cos(i);
const sinI = Math.sin(i);
const x_eci = (cosRaan * cosArgp - sinRaan * sinArgp * cosI) * x_orb +
(-cosRaan * sinArgp - sinRaan * cosArgp * cosI) * y_orb;
const y_eci = (sinRaan * cosArgp + cosRaan * sinArgp * cosI) * x_orb +
(-sinRaan * sinArgp + cosRaan * cosArgp * cosI) * y_orb;
const z_eci = (sinArgp * sinI) * x_orb + (cosArgp * sinI) * y_orb;
const theta = PHYSICS.OMEGA_EARTH * time;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
const x_ecef = cosTheta * x_eci + sinTheta * y_eci;
const y_ecef = -sinTheta * x_eci + cosTheta * y_eci;
const z_ecef = z_eci;
return new THREE.Vector3(
x_ecef / PHYSICS.EARTH_RADIUS,
z_ecef / PHYSICS.EARTH_RADIUS,
-y_ecef / PHYSICS.EARTH_RADIUS
);
}
static propagateWithVelocity(satellite, time) {
const dt = time - satellite.epoch;
const { a, e, i, raan, argp, M0 } = satellite;
const n0 = Math.sqrt(PHYSICS.MU / (a * a * a));
const j2Factor = (n0 * PHYSICS.EARTH_RADIUS * PHYSICS.EARTH_RADIUS * PHYSICS.J2) /
(a * a * Math.pow(1 - e * e, 2));
const dRaanDt = -1.5 * j2Factor * Math.cos(i);
const dArgpDt = 0.75 * j2Factor * (4 - 5 * Math.pow(Math.sin(i), 2));
const dMDt = 0.75 * j2Factor * Math.sqrt(1 - e * e) * (2 - 3 * Math.pow(Math.sin(i), 2));
const raan_t = raan + dRaanDt * dt;
const argp_t = argp + dArgpDt * dt;
const M_t = M0 + (n0 + dMDt) * dt;
const E = this.solveKeplerEquation(M_t, e);
const sinE = Math.sin(E);
const cosE = Math.cos(E);
const sqrtOneMinusE2 = Math.sqrt(1 - e * e);
const nu = 2 * Math.atan2(sqrtOneMinusE2 * sinE, cosE + e);
const r = a * (1 - e * cosE);
const cosNu = Math.cos(nu);
const sinNu = Math.sin(nu);
// Position in orbital frame
const x_orb = r * cosNu;
const y_orb = r * sinNu;
// Velocity in orbital frame
const vx_orb = -(a * n0 * sinE) / (1 - e * cosE);
const vy_orb = (a * n0 * sqrtOneMinusE2 * cosE) / (1 - e * cosE);
const cosRaan = Math.cos(raan_t);
const sinRaan = Math.sin(raan_t);
const cosArgp = Math.cos(argp_t);
const sinArgp = Math.sin(argp_t);
const cosI = Math.cos(i);
const sinI = Math.sin(i);
// Position in ECI
const x_eci = (cosRaan * cosArgp - sinRaan * sinArgp * cosI) * x_orb +
(-cosRaan * sinArgp - sinRaan * cosArgp * cosI) * y_orb;
const y_eci = (sinRaan * cosArgp + cosRaan * sinArgp * cosI) * x_orb +
(-sinRaan * sinArgp + cosRaan * cosArgp * cosI) * y_orb;
const z_eci = (sinArgp * sinI) * x_orb + (cosArgp * sinI) * y_orb;
// Velocity in ECI
const vx_eci = (cosRaan * cosArgp - sinRaan * sinArgp * cosI) * vx_orb +
(-cosRaan * sinArgp - sinRaan * cosArgp * cosI) * vy_orb;
const vy_eci = (sinRaan * cosArgp + cosRaan * sinArgp * cosI) * vx_orb +
(-sinRaan * sinArgp + cosRaan * cosArgp * cosI) * vy_orb;
const vz_eci = (sinArgp * sinI) * vx_orb + (cosArgp * sinI) * vy_orb;
const theta = PHYSICS.OMEGA_EARTH * time;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
// Position in ECEF
const x_ecef = cosTheta * x_eci + sinTheta * y_eci;
const y_ecef = -sinTheta * x_eci + cosTheta * y_eci;
const z_ecef = z_eci;
// Velocity in ECEF (accounting for Earth rotation)
const vx_ecef = cosTheta * vx_eci + sinTheta * vy_eci - PHYSICS.OMEGA_EARTH * y_ecef;
const vy_ecef = -sinTheta * vx_eci + cosTheta * vy_eci + PHYSICS.OMEGA_EARTH * x_ecef;
const vz_ecef = vz_eci;
return {
position: new THREE.Vector3(
x_ecef / PHYSICS.EARTH_RADIUS,
z_ecef / PHYSICS.EARTH_RADIUS,
-y_ecef / PHYSICS.EARTH_RADIUS
),
velocity: new THREE.Vector3(
vx_ecef / PHYSICS.EARTH_RADIUS,
vz_ecef / PHYSICS.EARTH_RADIUS,
-vy_ecef / PHYSICS.EARTH_RADIUS
)
};
}
static solveKeplerEquation(M, e) {
M = M % (2 * Math.PI);
if (M < 0) M += 2 * Math.PI;
let E = e < 0.8 ? M : Math.PI;
for (let i = 0; i < 8; i++) {
const f = E - e * Math.sin(E) - M;
const df = 1 - e * Math.cos(E);
const deltaE = f / df;
E -= deltaE;
if (Math.abs(deltaE) < 1e-12) break;
}
return E;
}
}
function createSatellites() {
const epoch = Date.now() / 1000;
satellites = [];
// Create 12 LEO satellites with random inclinations
for (let i = 1; i < 13; i++) {
const altitude = 400000 + Math.random() * 600000; // 400-1000 km
const inclination = Math.random() * Math.PI/2; // 0-180 degrees
const raan = Math.random() * 2 * Math.PI;
const argp = Math.random() * 2 * Math.PI;
const ma = Math.random() * 2 * Math.PI;
satellites.push({
a: PHYSICS.EARTH_RADIUS + altitude,
e: 0,//0.001 + Math.random() * 0.02, // Low eccentricity
i: inclination,
raan: raan,
argp: argp,
M0: ma,
epoch: epoch
});
}
}
function createSatelliteMeshes() {
const geometry = new THREE.SphereGeometry(0.008, 8, 8);
for (let i = 0; i < satellites.length; i++) {
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
satelliteMeshes.push(mesh);
}
}
function updateOrbitTrails(currentTimeSeconds) {
// Clear existing trails
orbitTrails.forEach(trail => scene.remove(trail));
orbitTrails = [];
const numPoints = 64; // Points for the trail
const trailDuration = 5 * 60; // 15 minutes in seconds
for (let i = 0; i < satellites.length; i++) {
const sat = satellites[i];
const points = [];
// Calculate points from 15 minutes ago to current time
for (let j = 0; j <= numPoints; j++) {
const timeOffset = (j / numPoints) * trailDuration;
const trailTime = currentTimeSeconds - trailDuration + timeOffset;
const position = OrbitPropagator.propagateSingle(sat, trailTime);
points.push(position);
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x2CFF05,
transparent: true,
opacity: 0.3
});
const line = new THREE.Line(geometry, material);
scene.add(line);
orbitTrails.push(line);
}
}
/**
* Creates a custom Three.js geometry resembling a double-sided axe blade.
* The shape is a cone with a central conical cutout and two azimuthal wedges.
*
* @param {number} height The height of the cone from base to apex.
* @param {number} outerRadius The radius of the cone's base.
* @param {number} cutoutAngle The full angle (in degrees) of the central conical cutout.
* @param {number} bladeAngle The angular width (in degrees) of each of the two blades.
* @param {number} segments The number of segments to build each blade's curve.
* @returns {THREE.BufferGeometry} The custom axe blade geometry.
*/
/**
* Creates a custom Three.js geometry resembling a double-sided axe blade.
*/
function createAxeBladeGeometry(height, outerRadius, cutoutAngle, bladeAngle, segments) {
const geometry = new THREE.BufferGeometry();
const vertices = [];
const indices = [];
const apexIndex = 0;
vertices.push(0, 0, 0);
const innerRadius = height * Math.tan((cutoutAngle * Math.PI / 180) / 2);
const bladeAngleRad = (bladeAngle * Math.PI) / 180;
const bladeCenters = [Math.PI / 2, 3 * Math.PI / 2];
let vertexIndexCounter = 1;
for (const centerAngle of bladeCenters) {
const startAngle = centerAngle - bladeAngleRad / 2;
const baseVertexStart = vertexIndexCounter;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + (i / segments) * bladeAngleRad;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
vertices.push(outerRadius * cos, -height, outerRadius * sin);
vertices.push(innerRadius * cos, -height, innerRadius * sin);
}
const baseVertexEnd = vertexIndexCounter + (segments * 2);
for (let i = 0; i < segments; i++) {
const outer1 = baseVertexStart + i * 2;
const inner1 = baseVertexStart + i * 2 + 1;
const outer2 = baseVertexStart + (i + 1) * 2;
const inner2 = baseVertexStart + (i + 1) * 2 + 1;
indices.push(apexIndex, outer1, outer2);
indices.push(apexIndex, inner2, inner1);
indices.push(outer1, inner1, outer2);
indices.push(inner1, inner2, outer2);
}
indices.push(apexIndex, baseVertexStart + 1, baseVertexStart);
indices.push(apexIndex, baseVertexEnd, baseVertexEnd + 1);
vertexIndexCounter = baseVertexEnd + 2;
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
/**
* Creates sensor meshes (cone, rectangular, axeBlade) and debug arrows.
*/
function createSensorCones() {
// remove old
sensorCones.forEach(c => scene.remove(c));
sensorCones = [];
debugAxes.forEach(g => scene.remove(g));
debugAxes = [];
const baseConeHeight = 0.6;
const baseConeRadius = 0.2;
const sensorTypes = ['cone', 'rectangular', 'axeBlade'];
for (let i = 0; i < satellites.length; i++) {
let geometry;
const sensorType = sensorTypes[i % sensorTypes.length];
let rectWidth, rectDepth;
let axeParams = null;
if (sensorType === 'rectangular') {
rectWidth = baseConeRadius * .5;
rectDepth = baseConeRadius * 1.2;
geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
0, 0, 0,
-rectWidth, -baseConeHeight, -rectDepth,
rectWidth, -baseConeHeight, -rectDepth,
rectWidth, -baseConeHeight, rectDepth,
-rectWidth, -baseConeHeight, rectDepth,
]);
const indices = [0,1,2, 0,2,3, 0,3,4, 0,4,1];
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
} else if (sensorType === 'axeBlade') {
const heightParam = 1, outerRadiusParam = 1, cutoutAngleParam = 40, bladeAngleParam = 135;
geometry = createAxeBladeGeometry(heightParam, outerRadiusParam, cutoutAngleParam, bladeAngleParam, 32);
axeParams = { height: heightParam, outerRadius: outerRadiusParam, cutoutAngle: cutoutAngleParam, bladeAngle: bladeAngleParam };
} else {
geometry = new THREE.ConeGeometry(baseConeRadius * 2, baseConeHeight, 32);
geometry.translate(0, -baseConeHeight / 2, 0);
}
const mat = new THREE.MeshBasicMaterial({
color: 0x00ff00,
transparent: true,
opacity: 0.2,
side: THREE.DoubleSide,
depthWrite: false
});
const cone = new THREE.Mesh(geometry, mat);
cone.userData.sensorType = sensorType;
// Store FOV parameters for precise checks
if (sensorType === 'rectangular') {
cone.userData.height = baseConeHeight;
cone.userData.rectWidth = rectWidth;
cone.userData.rectDepth = rectDepth;
} else if (sensorType === 'axeBlade') {
Object.assign(cone.userData, axeParams, { bladeCenters: [Math.PI / 2, 3 * Math.PI / 2] });
} else {
cone.userData.halfFovRad = (coneFov * Math.PI / 180) / 2;
}
cone.userData.prevQuat = cone.quaternion.clone(); // initialize continuity anchor
scene.add(cone);
sensorCones.push(cone);
// Debug axes group for this sensor
if (SHOW_DEBUG_AXES) {
const group = new THREE.Group();
const axisLen = 0.1;
// ArrowHelper( dir, origin, length, color )
const xArrow = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,0), axisLen, 0xff0000); // red X (in-track)
const yArrow = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,0), axisLen, 0x00ff00); // green Y (down)
const zArrow = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), new THREE.Vector3(0,0,0), axisLen, 0x0000ff); // blue Z (cross-track)
group.add(xArrow, yArrow, zArrow);
scene.add(group);
debugAxes.push(group);
}
}
}
/**
* Robust orientation + visual debugger for sensors.
* Fixes spinning and 180° flips by:
* - using a fallback chain for in-track
* - enforcing quaternion hemisphere continuity by negating components (NOT multiplyScalar)
*/
function updateSensorCones(timeSeconds) {
const EPS = 1e-8;
const SMOOTH_FACTOR = 0.28;
const worldUp = new THREE.Vector3(0, 1, 0);
for (let i = 0; i < satellites.length; i++) {
const sat = satellites[i];
const cone = sensorCones[i];
const axes = SHOW_DEBUG_AXES ? debugAxes[i] : null;
if (!cone) continue;
const sensorType = cone.userData.sensorType;
let position, velocity;
if (sensorType === 'axeBlade' || sensorType === 'rectangular') {
const state = OrbitPropagator.propagateWithVelocity(sat, timeSeconds);
position = new THREE.Vector3().copy(state.position);
velocity = new THREE.Vector3().copy(state.velocity);
} else {
position = new THREE.Vector3().copy(OrbitPropagator.propagateSingle(sat, timeSeconds));
velocity = null;
}
// place objects
cone.position.copy(position);
if (SHOW_DEBUG_AXES && axes) {
axes.position.copy(position);
}
// compute down/nadir
const nadir = position.clone().negate();
if (nadir.lengthSq() < EPS) continue;
nadir.normalize();
// compute inTrack robustly (projected velocity -> orbit normal trick -> worldUp projection -> arbitrary)
let inTrack = null;
if (velocity && velocity.lengthSq() > EPS) {
const vnorm = velocity.clone().normalize();
inTrack = vnorm.clone().sub(nadir.clone().multiplyScalar(vnorm.dot(nadir)));
if (inTrack.lengthSq() > EPS) inTrack.normalize();
else inTrack = null;
}
if (!inTrack && velocity && position.lengthSq() > EPS) {
const h = position.clone().cross(velocity);
if (h.lengthSq() > EPS) {
const orbitNormal = h.normalize();
inTrack = new THREE.Vector3().crossVectors(nadir, orbitNormal);
if (inTrack.lengthSq() > EPS) inTrack.normalize();
else inTrack = null;
}
}
if (!inTrack) {
inTrack = worldUp.clone().sub(nadir.clone().multiplyScalar(worldUp.dot(nadir)));
if (inTrack.lengthSq() > EPS) inTrack.normalize();
else {
// deterministic arbitrary perpendicular
const alt = Math.abs(nadir.x) < 0.9 ? new THREE.Vector3(1,0,0) : new THREE.Vector3(0,1,0);
inTrack = new THREE.Vector3().crossVectors(alt, nadir);
if (inTrack.lengthSq() > EPS) inTrack.normalize();
else inTrack.set(1,0,0); // last-ditch fallback
}
}
// Ensure inTrack is prograde relative to velocity if available
if (velocity && velocity.lengthSq() > EPS) {
if (inTrack.dot(velocity) < 0) inTrack.negate();
}
// cross-track and down
const down = nadir.clone();
const crossTrack = new THREE.Vector3().crossVectors(inTrack, down);
if (crossTrack.lengthSq() < EPS) {
// ensure crossTrack never degenerates
crossTrack.copy(new THREE.Vector3(0,0,1).cross(inTrack));
if (crossTrack.lengthSq() < EPS) crossTrack.set(0,0,1);
}
crossTrack.normalize();
// make basis matrix (X=inTrack, Y=down, Z=crossTrack)
const rotMatrix = new THREE.Matrix4().makeBasis(inTrack, down, crossTrack);
const targetQuat = new THREE.Quaternion().setFromRotationMatrix(rotMatrix);
// Mesh points along local -Y; flip 180° about local X so -Y aligns with 'down'
const flipXQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), Math.PI);
const meshTargetQuat = targetQuat.clone().multiply(flipXQuat);
// --- hemisphere / anti-flip fix ---
const prevQuat = cone.userData.prevQuat || new THREE.Quaternion();
if (prevQuat.dot(meshTargetQuat) < 0) {
// Quaternion.multiplyScalar doesn't exist — negate components instead
meshTargetQuat.x *= -1;
meshTargetQuat.y *= -1;
meshTargetQuat.z *= -1;
meshTargetQuat.w *= -1;
}
// smooth the orientation to avoid sudden flips
cone.quaternion.slerp(meshTargetQuat, SMOOTH_FACTOR);
cone.userData.prevQuat = cone.quaternion.clone();
// rotate debug axes with the sensor so arrows show basis
if (SHOW_DEBUG_AXES && axes) {
axes.quaternion.copy(targetQuat);
}
// scale sensor based on altitude + FOV (keeps your previous scaling logic)
const altitude = Math.max(position.length() - EARTHRADIUS, 1e-3);
const coneHeightNorm = Math.min(coneRangeKm / PHYSICS.EARTH_RADIUS_KM, 10);
const coneHeight = Math.min(coneHeightNorm, altitude);
const coneRadius = coneHeight * Math.tan((coneFov * Math.PI / 180) / 2);
cone.scale.set(
Math.max(coneRadius / 0.1, 1e-3),
Math.max(coneHeight / 0.3, 1e-3),
Math.max(coneRadius / 0.1, 1e-3)
);
}
}
/**
* Updates satellite mesh positions.
*/
function updateSatellites(timeSeconds) {
for (let i = 0; i < satellites.length; i++) {
const pos = OrbitPropagator.propagateSingle(satellites[i], timeSeconds);
satelliteMeshes[i].position.copy(pos);
}
}
function init() {
const container = document.getElementById('canvas-container');
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
const width = window.innerWidth;
const heightRatio = window.innerWidth <= 768 ? 0.8 : 0.9;
const height = window.innerHeight * heightRatio;
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0xffffff, 1);
container.appendChild(renderer.domElement);
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(50, width / height, 0.01, 1000);
camera.position.set(2.5, 1.5, 2.5);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 1.5;
controls.maxDistance = 10;
const texldr = new THREE.TextureLoader();
// Try loading with error handling
const diffuse = texldr.load('https://i.imgur.com/fmzPPx4.jpeg');
const diffuseNight = texldr.load('https://i.imgur.com/p4SHkfw.jpeg');
earth = new Earth3d(camera);
earth.loadTextures(diffuse, diffuseNight);
earth.parentObj = earth;
scene.add(earth);
// Initialize link materials
crosslinkMaterial = new THREE.LineBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.5 });
downlinkMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.8, depthTest: true, depthWrite: false });
createSatellites();
createSatelliteMeshes();
createSensorCones();
createShips();
createShipMeshes();
createPlanes();
createPlaneMeshes();
createPlaneRoutes();
// Choose random air and sea targets and mark them red
selectRandomTargets(25, 25);
// Setup visibility controls
document.getElementById('showShips').addEventListener('change', (e) => {
showShips = e.target.checked;
shipMeshes.forEach(mesh => mesh.visible = showShips);
});
document.getElementById('showPlanes').addEventListener('change', (e) => {
showPlanes = e.target.checked;
planeMeshes.forEach(mesh => mesh.visible = showPlanes);
planeRoutes.forEach(line => line.visible = showPlanes);
});
animate();
}
function animate() {
requestAnimationFrame(animate);
// Calculate frame delta time
const now = Date.now();
const frameDelta = now - lastFrameTime;
lastFrameTime = now;
// Update time acceleration (smoothly transition from 60x to 1x over 5 seconds)
const elapsed = now - startTime;
if (elapsed < 1500) {
// Stay at real-time for 5 seconds
timeAcceleration = 1;
} else {
// Ramp up to 10x speed after 5 seconds at real-time
const rampProgress = Math.min((elapsed - 1500) / 1000, 1); // 1 second ramp
timeAcceleration = 1 + (99 * rampProgress); // Smoothly go from 1x to 10x
}
// Update simulation time with acceleration (for satellites only)
simulationTime += frameDelta * timeAcceleration;
const timeInSeconds = simulationTime / 1000;
// Use ACTUAL real-world time for sun/Earth (not accelerated)
const actualCurrentTime = new Date();
// Update indicator
const indicator = document.getElementById('time-indicator');
const progressFill = document.getElementById('progress-fill');
if (elapsed < 1500) {
// Hide indicator during real-time phase
indicator.classList.remove('visible');
} else if (elapsed < 2500) {
// Show ramping up to 10x
indicator.classList.add('visible');
const rampProgress = (elapsed - 1500) / 1000;
progressFill.style.width = (rampProgress * 100) + '%';
const timeDisplay = document.getElementById('time-display');
const accelDisplay = Math.round(timeAcceleration);
timeDisplay.textContent = accelDisplay + 'x';
} else {
// Show 10x briefly then hide
if (elapsed < 2500) {
indicator.classList.add('visible');
progressFill.style.width = '100%';
const timeDisplay = document.getElementById('time-display');
timeDisplay.textContent = '100x';
} else {
indicator.classList.remove('visible');
}
}
// Use actual real-world time for sun position and Earth rotation
const julianDate = getJulianDate(actualCurrentTime);
const sunVector = getSunPosition(julianDate);
earth.setSun(sunVector);
const earthRotation = getEarthRotation(actualCurrentTime);
earth.rotation.y = earthRotation;
earth.update();
updateSatellites(timeInSeconds);
updateSensorCones(timeInSeconds);
updateShips(frameDelta);
updatePlanes(frameDelta);
// Throttle expensive updates to improve performance
if (frameCount % TRAIL_UPDATE_INTERVAL === 0) {
updateOrbitTrails(timeInSeconds);
}
if (frameCount % CROSSLINK_UPDATE_INTERVAL === 0) {
updateCrosslinks();
}
if (frameCount % DOWNLINK_UPDATE_INTERVAL === 0) {
updateDownlinks();
}
frameCount++;
controls.update();
renderer.render(scene, camera);
}
window.addEventListener('resize', function() {
const width = window.innerWidth;
const heightRatio = window.innerWidth <= 768 ? 0.8 : 0.9;
const height = window.innerHeight * heightRatio;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
});
init();
</script>
<script>
function showTab(tabName) {
// Hide all sections
document.querySelectorAll('.content-section').forEach(section => {
section.classList.remove('active');
});
// Remove active class from all tabs
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.classList.remove('active');
});
// Show selected section
document.getElementById(tabName).classList.add('active');
// Add active class to clicked tab
event.target.classList.add('active');
}
function handleSubmit(event) {
event.preventDefault();
alert('Thank you for your inquiry! We will get back to you soon.');
event.target.reset();
}
</script>
</body>
</html>