Skip to main content
Jira progress: loading…

SUPP-GLOB

3D Supplier Globe

An interactive, rotatable 3D Earth visualization that shows a company's suppliers connected to its HQ via air, sea, or land freight routes. Suppliers and routes are loaded from a JSON file. Built with vanilla Three.js — no build step, no framework dependencies.

What it does
  • Renders a dark-themed 3D Earth with country outlines, atmospheric glow, and a starfield backdrop
  • Plots HQ and supplier locations as glowing markers
  • Draws connection arcs between each supplier and HQ, color-coded by freight mode:
    • Air — cyan, arched high overhead
    • Sea — green, hugs the surface and routes through real maritime corridors (Suez, Panama, Malacca, Cape of Good Hope, etc.)
    • Land — amber, follows the surface along a great-circle path
  • Rotate by dragging, zoom with the scroll wheel, click any marker to focus on it
  • Animated pulses travel along each route from supplier toward HQ

Quick start

  1. Download supplier-globe.html
  2. Open it in a browser, or upload it to your web server
  3. Click Use sample data to verify it works
  4. Click Load JSON file to upload your own supplier data

That's it. The file is fully self-contained — Three.js and the country-outline data load from public CDNs.


File structure

You only need one file:

supplier-globe.html        # Self-contained HTML, CSS, and JavaScript
suppliers.json # Your data file (optional — can be uploaded via UI)

JSON data format

The globe expects a JSON object with two top-level keys: company (the HQ) and suppliers (an array).

Schema

{
"company": {
"name": "string",
"lat": number,
"lon": number
},
"suppliers": [
{
"name": "string",
"lat": number,
"lon": number,
"country": "string (optional)",
"freight": "air | sea | land (optional, defaults to air)"
}
]
}

Field reference

FieldRequiredTypeNotes
company.nameyesstringDisplayed in the HQ tooltip
company.latyesnumberLatitude in decimal degrees, range −90 to 90
company.lonyesnumberLongitude in decimal degrees, range −180 to 180
suppliers[].nameyesstringDisplayed in the supplier tooltip
suppliers[].latyesnumberLatitude in decimal degrees
suppliers[].lonyesnumberLongitude in decimal degrees
suppliers[].countrynostringShown in tooltip if present
suppliers[].freightnostringOne of "air", "sea", "land". Defaults to "air". Case-insensitive.

Example

suppliers.json
{
"company": {
"name": "Acme Norge AS",
"lat": 59.9139,
"lon": 10.7522
},
"suppliers": [
{ "name": "Foxconn", "lat": 22.5431, "lon": 114.0579, "country": "China", "freight": "sea" },
{ "name": "TSMC", "lat": 24.7736, "lon": 121.0452, "country": "Taiwan", "freight": "air" },
{ "name": "Bosch GmbH", "lat": 48.7758, "lon": 9.1829, "country": "Germany", "freight": "land" },
{ "name": "Embraer", "lat": -23.2237, "lon": -45.9009, "country": "Brazil", "freight": "sea" },
{ "name": "Anglo American", "lat": -26.2041, "lon": 28.0473, "country": "South Africa", "freight": "sea" }
]
}
Coordinates

Use decimal degrees, not degrees-minutes-seconds. South latitudes and west longitudes are negative. If your source data is in DMS format (e.g. 59°54'50"N), convert it first.


Visual design

Color scheme

ElementColorHex
OceanDark navy#0a1428
Country outlinesMuted blue#4a7fb8
HQ markerAmber#ffb547
Air freightCyan#4ec9ff
Sea freightGreen#66e6a3
Land freightAmber#ffb86b
Atmospheric glowBlue#4d99ff

To change colors, edit the FREIGHT_COLORS object near the top of the script:

const FREIGHT_COLORS = {
air: 0x4ec9ff,
sea: 0x66e6a3,
land: 0xffb86b
};

For your EcoWorld brand green, you might use 0x2D6A4F somewhere — though note that the route colors need to be bright enough to read against the dark ocean. The HQ marker color is set separately inside loadData() (0xffb547).

Route geometry

ModeGeometryLiftPulse speed
AirQuadratic Bézier archscales with distance, up to 80% above surfacefast
SeaPolyline through maritime waypoint graph1.2% above surfaceslow
LandGreat-circle path0.8% above surfacemedium

Maritime routing

This is the most distinctive feature of the globe. Sea routes don't follow naive great-circle paths (which would cut through continents) — they route through a hand-curated graph of real shipping corridors.

How it works

  1. Waypoint network: ~40 nodes representing major ports and chokepoint approaches (Rotterdam, Singapore, Suez North/South, Panama Atlantic/Pacific, Cape of Good Hope, etc.)
  2. Edge list: Each edge represents a navigable sea connection between two waypoints. There is no edge from the Mediterranean to the Persian Gulf except via Suez.
  3. Route lookup: When a sea route is drawn between supplier and HQ, the algorithm:
    • Finds the nearest waypoint to the supplier's coordinates
    • Finds the nearest waypoint to the HQ's coordinates
    • Runs Dijkstra's shortest-path algorithm across the graph
    • Renders a polyline through the resulting waypoints

What this gives you

  • A supplier in Shanghai routes to Oslo via Hong Kong → Singapore → Malacca → Indian Ocean → Suez → Mediterranean → Gibraltar → North Sea
  • A supplier in South Africa routes via Cape of Good Hope or Suez (whichever is shorter)
  • A supplier in Brazil routes up the Atlantic via New York
  • A supplier in Australia routes via Singapore and Suez

Adding new waypoints

To add a port or corridor not in the default graph, edit two structures inside the script.

1. Add the node to SEA_NODES with its lat/lon:

const SEA_NODES = {
// ...existing nodes...
'baltic_e': { lat: 59.50, lon: 24.80 }, // Tallinn approach
'st_petersburg': { lat: 59.95, lon: 30.30 }
};

2. Add edges to SEA_EDGES for every navigable connection:

const SEA_EDGES = [
// ...existing edges...
['oslo', 'baltic_e'],
['baltic_e', 'st_petersburg']
];

That's it — Dijkstra picks up the new nodes automatically.

Default waypoint network

Click to expand — full list of ~40 maritime nodes

North Atlantic: oslo, rotterdam, gibraltar, english_ch, iceland, ny, panama_atl, rio, cape_horn, cape_town

Mediterranean / Suez: med_west, med_east, suez_n, suez_s, red_sea, aden

Indian Ocean: arabian, mumbai, colombo, malacca_w, singapore, mauritius

East Asia: south_china, hk, shanghai, tokyo, taiwan_str

Australia / Pacific: jakarta, darwin, sydney, perth, auckland

Pacific crossings: panama_pac, la, hawaii, pac_mid, guam

South America: valparaiso, buenos_aires

Limitations

The graph is hand-curated and necessarily simplified.

  • Not modeled: Northern Sea Route (Arctic), Bering Strait, Caribbean inside passages, Great Lakes, Baltic interior, Black Sea
  • Approximation near shore: For a supplier far from any waypoint (e.g. a small island), the leg from supplier to nearest waypoint is a great-circle line that may briefly cut across coastline. For ~95% of real corporate supply chains, the major ports in the default graph are close enough to the supplier's actual city that this isn't visible.

If a particular client's supply chain needs Arctic routes, Baltic detail, or another corridor, extend the graph using the pattern above.


Controls

ActionEffect
DragRotate globe (with momentum)
Scroll wheelZoom in/out
Click markerSmooth-animate to focus on that location
Auto-rotate checkboxGlobe slowly rotates when idle
Reset view buttonAnimate back to default orientation and zoom
Use sample dataLoad the built-in 12-supplier example
Load JSON fileUpload your own JSON

Deployment

Upload supplier-globe.html to your web server and link to it directly.

https://yoursite.com/supplier-globe.html

Works as-is. No build step, no server-side requirements.


Loading data automatically

By default, the globe waits for a user to click Load JSON file or Use sample data. To load data automatically on page load, replace the bottom of the script.

Find this block:

const sample = {
company: { /* ... */ },
suppliers: [ /* ... */ ]
};
document.getElementById('sampleBtn').addEventListener('click', () => loadData(sample));

Replace with a fetch call:

fetch('/data/suppliers.json')
.then(r => r.json())
.then(data => loadData(data))
.catch(err => {
statusEl.textContent = 'Could not load supplier data: ' + err.message;
});

If the JSON lives behind authentication or a CRM API, swap in the appropriate fetch options.


CDN dependencies

The globe loads two libraries from public CDNs at runtime:

LibraryPurposeURL
Three.js r1283D rendering enginehttps://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
topojson-client 3Decodes country boundary datahttps://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js
world-atlas/countries-110mCountry outlines (TopoJSON)https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json

If your environment blocks these CDNs, the globe will still render — it just falls back gracefully (no country outlines, status bar shows the error). To make the globe fully offline, download the three files and update the script tags and fetch URL to point at local copies.


White-label customization

For client-specific branding:

WhatWhere in the file
Page title<title> tag in the <head>
Background color of outer pagebody { background: ... } in the <style> block
Globe ocean colorMeshPhongMaterial({ color: 0x0a1428, ... }) in the script
Country outline colorLineBasicMaterial({ color: 0x4a7fb8, ... }) in drawCountries()
Atmospheric glow colorThe gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) line in the glow shader
HQ marker colorThe 0xffb547 literal inside loadData() (the line after the company definition)
Freight route colorsFREIGHT_COLORS object near the top
Marker glow halo sizeThe haloSize = size * 8 line in addMarker()
Auto-rotate speedearthGroup.rotation.y += 0.0015 in animate()
Default zoominitialCamZ = 3.2 near the top

For large corporate clients receiving white-label output, you'd typically replace the EcoWorld defaults with the client's brand colors at the points listed above.


Browser support

  • Chrome, Firefox, Safari, Edge — current versions, all good
  • Mobile — works, but performance on older phones may stutter with 100+ suppliers
  • IE11 — not supported (Three.js r128 requires modern JS)

WebGL is required. The globe will show a blank canvas with no error message if WebGL is disabled, so if a user reports it's not working, that's the first thing to check.


Troubleshooting

SymptomLikely cause
Blank dark square, nothing rendersWebGL disabled in browser, or graphics driver issue
"Map outlines unavailable" in status barCDN for country data is blocked — globe still works
Routes appear to cut through landThe supplier or HQ is far from any waypoint — see Maritime routing → Limitations
JSON parse errorThe uploaded file isn't valid JSON. Check for trailing commas or missing quotes
Markers in wrong placeLikely lat/lon swapped, or coordinates in DMS instead of decimal degrees
Sea route looks weirdThe graph may not have a node near that location — add one (see Maritime routing → Adding new waypoints)

Versioning

Maintain a clear version on the file in case clients receive different builds:

<!-- supplier-globe.html v1.0.0 -->

Bump the patch when adding waypoints, the minor when adding visual features, and the major when changing the JSON schema.


HTML Code

supplier-globe.htmlGitHub ↗
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Supplier Globe</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0d1117;
color: #e6edf7;
padding: 16px;
}
.globe-app {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.globe-controls {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.globe-controls button {
padding: 8px 14px;
font-size: 13px;
background: transparent;
color: #e6edf7;
border: 0.5px solid rgba(255,255,255,0.25);
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.globe-controls button:hover {
background: rgba(255,255,255,0.08);
}
.globe-controls label {
font-size: 13px;
color: #9aa6b8;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.globe-status {
font-size: 12px;
color: #6e7a8f;
min-height: 16px;
}
.globe-stage {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
max-height: 700px;
background: #05070d;
border-radius: 12px;
overflow: hidden;
border: 0.5px solid rgba(255,255,255,0.15);
}
.globe-stage canvas {
display: block;
width: 100%;
height: 100%;
}
.globe-tooltip {
position: absolute;
pointer-events: none;
background: rgba(8,12,20,0.92);
color: #e6edf7;
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
border: 0.5px solid rgba(255,255,255,0.15);
display: none;
max-width: 240px;
line-height: 1.45;
}
.globe-legend {
position: absolute;
left: 12px;
bottom: 12px;
display: flex;
gap: 12px;
font-size: 11px;
color: #9aa6b8;
flex-wrap: wrap;
}
.globe-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.globe-format {
font-size: 12px;
color: #9aa6b8;
}
.globe-format summary {
cursor: pointer;
padding: 4px 0;
}
.globe-format pre {
background: #161b22;
padding: 10px;
border-radius: 6px;
overflow-x: auto;
font-size: 11px;
margin-top: 6px;
color: #c9d1d9;
}
</style>
</head>
<body>
<div class="globe-app">
<div class="globe-controls">
<button id="loadBtn">Load JSON file</button>
<button id="sampleBtn">Use sample data</button>
<button id="resetBtn">Reset view</button>
<label><input type="checkbox" id="autoRotate"> Auto-rotate</label>
<input type="file" id="fileInput" accept=".json" style="display: none;">
</div>

<div id="status" class="globe-status">Loading map data…</div>

<div class="globe-stage">
<canvas id="globe"></canvas>
<div id="tooltip" class="globe-tooltip"></div>
<div class="globe-legend">
<span><span style="width: 8px; height: 8px; border-radius: 50%; background: #ffb547; box-shadow: 0 0 8px #ffb547;"></span>HQ</span>
<span><span style="width: 8px; height: 8px; border-radius: 50%; background: #4ec9ff; box-shadow: 0 0 8px #4ec9ff;"></span>Supplier</span>
<span><span style="width: 16px; height: 2px; background: #4ec9ff;"></span>Air</span>
<span><span style="width: 16px; height: 2px; background: #66e6a3;"></span>Sea</span>
<span><span style="width: 16px; height: 2px; background: #ffb86b;"></span>Land</span>
</div>
</div>

<details class="globe-format">
<summary>Expected JSON format</summary>
<pre>{
"company": { "name": "Acme Corp", "lat": 59.9139, "lon": 10.7522 },
"suppliers": [
{
"name": "Foxconn",
"lat": 22.5431, "lon": 114.0579,
"country": "China",
"freight": "air" // "air" | "sea" | "land"
}
]
}</pre>
</details>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function() {
const canvas = document.getElementById('globe');
const tooltip = document.getElementById('tooltip');
const statusEl = document.getElementById('status');
const autoRotateChk = document.getElementById('autoRotate');

const FREIGHT_COLORS = { air: 0x4ec9ff, sea: 0x66e6a3, land: 0xffb86b };

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05070d);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
const initialCamZ = 3.2;
camera.position.set(0, 0, initialCamZ);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

function resize() {
const rect = canvas.getBoundingClientRect();
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
}
resize();
window.addEventListener('resize', resize);

scene.add(new THREE.AmbientLight(0x404a60, 0.7));
const dirLight = new THREE.DirectionalLight(0xaac4ff, 0.7);
dirLight.position.set(5, 3, 5);
scene.add(dirLight);

const earthGroup = new THREE.Group();
scene.add(earthGroup);
const RADIUS = 1;

earthGroup.add(new THREE.Mesh(
new THREE.SphereGeometry(RADIUS, 64, 64),
new THREE.MeshPhongMaterial({ color: 0x0a1428, emissive: 0x0a1f3d, emissiveIntensity: 0.2, shininess: 6, specular: 0x223355 })
));
earthGroup.add(new THREE.Mesh(
new THREE.SphereGeometry(RADIUS * 1.0008, 24, 16),
new THREE.MeshBasicMaterial({ color: 0x1a2d4a, wireframe: true, transparent: true, opacity: 0.18 })
));

const countriesGroup = new THREE.Group();
earthGroup.add(countriesGroup);

const glowMat = new THREE.ShaderMaterial({
vertexShader: 'varying vec3 vNormal; void main() { vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
fragmentShader: 'varying vec3 vNormal; void main() { float i = pow(0.7 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0); gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) * i; }',
side: THREE.BackSide, blending: THREE.AdditiveBlending, transparent: true
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(RADIUS * 1.06, 64, 64), glowMat));

// Stars
const starsGeo = new THREE.BufferGeometry();
const starPos = new Float32Array(1500 * 3);
for (let i = 0; i < 1500; i++) {
const r = 50 + Math.random() * 50;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
starPos[i*3] = r * Math.sin(phi) * Math.cos(theta);
starPos[i*3+1] = r * Math.cos(phi);
starPos[i*3+2] = r * Math.sin(phi) * Math.sin(theta);
}
starsGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
scene.add(new THREE.Points(starsGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.12, sizeAttenuation: true })));

function latLonToVec3(lat, lon, radius) {
const phi = (90 - lat) * Math.PI / 180;
const theta = (lon + 180) * Math.PI / 180;
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
);
}

// ============ MARITIME ROUTING NETWORK ============
const SEA_NODES = {
'oslo': { lat: 59.42, lon: 10.50 },
'rotterdam': { lat: 51.95, lon: 4.10 },
'gibraltar': { lat: 35.95, lon: -5.60 },
'english_ch': { lat: 50.10, lon: -1.50 },
'iceland': { lat: 63.50, lon: -20.00 },
'ny': { lat: 40.50, lon: -73.50 },
'panama_atl': { lat: 9.35, lon: -79.90 },
'rio': { lat:-23.00, lon: -42.00 },
'cape_horn': { lat:-56.00, lon: -67.00 },
'cape_town': { lat:-34.40, lon: 18.40 },
'med_west': { lat: 38.00, lon: 5.00 },
'med_east': { lat: 32.00, lon: 28.00 },
'suez_n': { lat: 31.20, lon: 32.30 },
'suez_s': { lat: 27.00, lon: 33.80 },
'red_sea': { lat: 15.00, lon: 42.00 },
'aden': { lat: 12.50, lon: 45.00 },
'arabian': { lat: 15.00, lon: 60.00 },
'mumbai': { lat: 18.50, lon: 72.50 },
'colombo': { lat: 6.00, lon: 80.00 },
'malacca_w': { lat: 5.00, lon: 96.00 },
'singapore': { lat: 1.30, lon: 104.00 },
'mauritius': { lat:-20.00, lon: 57.00 },
'south_china': { lat: 18.00, lon: 113.00 },
'hk': { lat: 22.30, lon: 114.20 },
'shanghai': { lat: 31.20, lon: 122.20 },
'tokyo': { lat: 35.50, lon: 140.00 },
'taiwan_str': { lat: 24.50, lon: 119.50 },
'jakarta': { lat: -6.10, lon: 106.80 },
'darwin': { lat:-12.50, lon: 130.80 },
'sydney': { lat:-34.00, lon: 151.50 },
'perth': { lat:-32.00, lon: 116.00 },
'auckland': { lat:-36.80, lon: 174.80 },
'panama_pac': { lat: 8.40, lon: -79.90 },
'la': { lat: 33.50, lon:-118.50 },
'hawaii': { lat: 21.30, lon:-157.80 },
'pac_mid': { lat: 20.00, lon:-160.00 },
'guam': { lat: 13.50, lon: 144.80 },
'valparaiso': { lat:-33.00, lon: -71.80 },
'buenos_aires': { lat:-35.00, lon: -56.00 }
};

const SEA_EDGES = [
['oslo','rotterdam'], ['rotterdam','english_ch'], ['english_ch','gibraltar'],
['oslo','iceland'], ['iceland','ny'], ['rotterdam','iceland'],
['english_ch','ny'], ['gibraltar','ny'],
['ny','panama_atl'], ['ny','rio'],
['rio','cape_town'], ['rio','buenos_aires'], ['buenos_aires','cape_horn'],
['cape_horn','valparaiso'], ['rio','panama_atl'],
['gibraltar','med_west'], ['med_west','med_east'], ['med_east','suez_n'],
['suez_n','suez_s'], ['suez_s','red_sea'], ['red_sea','aden'],
['aden','arabian'], ['arabian','mumbai'], ['mumbai','colombo'],
['colombo','malacca_w'], ['aden','mauritius'], ['mauritius','cape_town'],
['mauritius','perth'], ['colombo','mumbai'],
['cape_town','rio'], ['cape_town','mauritius'],
['malacca_w','singapore'], ['singapore','jakarta'], ['singapore','south_china'],
['singapore','hk'], ['singapore','darwin'],
['south_china','hk'], ['hk','taiwan_str'], ['taiwan_str','shanghai'],
['shanghai','tokyo'], ['hk','shanghai'],
['tokyo','hawaii'], ['hawaii','la'], ['la','panama_pac'], ['panama_pac','valparaiso'],
['hawaii','pac_mid'], ['pac_mid','guam'], ['guam','tokyo'], ['guam','singapore'],
['tokyo','la'],
['darwin','jakarta'], ['darwin','sydney'], ['sydney','auckland'],
['sydney','perth'], ['perth','jakarta'], ['sydney','valparaiso'],
['panama_atl','panama_pac']
];

function gcDist(a, b) {
const phi1 = a.lat * Math.PI/180, phi2 = b.lat * Math.PI/180;
const dphi = (b.lat-a.lat) * Math.PI/180;
const dlam = (b.lon-a.lon) * Math.PI/180;
const x = Math.sin(dphi/2)**2 + Math.cos(phi1)*Math.cos(phi2)*Math.sin(dlam/2)**2;
return 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1-x));
}
const seaGraph = {};
Object.keys(SEA_NODES).forEach(k => seaGraph[k] = []);
SEA_EDGES.forEach(([a,b]) => {
const d = gcDist(SEA_NODES[a], SEA_NODES[b]);
seaGraph[a].push({ to: b, w: d });
seaGraph[b].push({ to: a, w: d });
});

function nearestSeaNode(lat, lon) {
const p = { lat, lon };
let best = null, bestD = Infinity;
for (const k in SEA_NODES) {
const d = gcDist(p, SEA_NODES[k]);
if (d < bestD) { bestD = d; best = k; }
}
return best;
}

function shortestSeaPath(startKey, endKey) {
if (startKey === endKey) return [startKey];
const dist = {}, prev = {}, visited = {};
Object.keys(seaGraph).forEach(k => { dist[k] = Infinity; prev[k] = null; });
dist[startKey] = 0;
while (true) {
let u = null, uD = Infinity;
for (const k in dist) {
if (!visited[k] && dist[k] < uD) { u = k; uD = dist[k]; }
}
if (u === null) break;
if (u === endKey) break;
visited[u] = true;
seaGraph[u].forEach(e => {
if (visited[e.to]) return;
const nd = dist[u] + e.w;
if (nd < dist[e.to]) { dist[e.to] = nd; prev[e.to] = u; }
});
}
if (dist[endKey] === Infinity) return null;
const path = [];
let cur = endKey;
while (cur) { path.unshift(cur); cur = prev[cur]; }
return path;
}

function seaRouteCoords(fromLat, fromLon, toLat, toLon) {
const startKey = nearestSeaNode(fromLat, fromLon);
const endKey = nearestSeaNode(toLat, toLon);
const pathKeys = shortestSeaPath(startKey, endKey) || [startKey, endKey];
const coords = [{ lat: fromLat, lon: fromLon }];
pathKeys.forEach(k => coords.push(SEA_NODES[k]));
coords.push({ lat: toLat, lon: toLon });
return coords;
}

// Country outlines via TopoJSON
function loadCountries() {
const topojsonScript = document.createElement('script');
topojsonScript.src = 'https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js';
topojsonScript.onload = () => {
fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
.then(r => r.json())
.then(topo => {
const geo = topojson.feature(topo, topo.objects.countries);
drawCountries(geo);
statusEl.textContent = 'Map loaded. Click "Use sample data" to see suppliers.';
})
.catch(err => { statusEl.textContent = 'Map outlines unavailable (' + err.message + ').'; });
};
topojsonScript.onerror = () => { statusEl.textContent = 'Map library blocked. Globe still works.'; };
document.head.appendChild(topojsonScript);
}
function drawCountries(geojson) {
const surfaceR = RADIUS * 1.002;
const mat = new THREE.LineBasicMaterial({ color: 0x4a7fb8, transparent: true, opacity: 0.55 });
geojson.features.forEach(feature => {
const polys = feature.geometry.type === 'Polygon' ? [feature.geometry.coordinates] : feature.geometry.coordinates;
polys.forEach(poly => poly.forEach(ring => {
const pts = [];
for (let i = 0; i < ring.length; i++) {
const [lon, lat] = ring[i];
pts.push(latLonToVec3(lat, lon, surfaceR));
}
if (pts.length < 2) return;
countriesGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), mat));
}));
});
}
loadCountries();

const markersGroup = new THREE.Group();
earthGroup.add(markersGroup);
const arcsGroup = new THREE.Group();
earthGroup.add(arcsGroup);
const pickables = [];

function clearData() {
while (markersGroup.children.length) {
const m = markersGroup.children.pop();
if (m.geometry) m.geometry.dispose();
if (m.material && m.material.dispose) m.material.dispose();
}
while (arcsGroup.children.length) {
const a = arcsGroup.children.pop();
if (a.geometry) a.geometry.dispose();
if (a.material && a.material.dispose) a.material.dispose();
}
pickables.length = 0;
}

const glowTexCache = {};
function makeGlowTexture(colorHex) {
if (glowTexCache[colorHex]) return glowTexCache[colorHex];
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d');
const grad = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
const col = new THREE.Color(colorHex);
const r = Math.round(col.r * 255), g = Math.round(col.g * 255), b = Math.round(col.b * 255);
grad.addColorStop(0, 'rgba(' + r + ',' + g + ',' + b + ',1)');
grad.addColorStop(0.4, 'rgba(' + r + ',' + g + ',' + b + ',0.4)');
grad.addColorStop(1, 'rgba(' + r + ',' + g + ',' + b + ',0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(c);
glowTexCache[colorHex] = tex;
return tex;
}

function addMarker(lat, lon, color, info, size, supplierData) {
const pos = latLonToVec3(lat, lon, RADIUS * 1.005);
const mesh = new THREE.Mesh(new THREE.SphereGeometry(size, 16, 16), new THREE.MeshBasicMaterial({ color }));
mesh.position.copy(pos);
markersGroup.add(mesh);
const halo = new THREE.Sprite(new THREE.SpriteMaterial({
map: makeGlowTexture(color), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false
}));
halo.position.copy(pos);
const haloSize = size * 8;
halo.scale.set(haloSize, haloSize, 1);
markersGroup.add(halo);
pickables.push({ mesh, info, supplier: supplierData, lat, lon });
return mesh;
}

function slerp(a, b, t) {
const angle = a.angleTo(b);
if (angle < 0.0001) return a.clone();
const sinA = Math.sin(angle);
const w0 = Math.sin((1 - t) * angle) / sinA;
const w1 = Math.sin(t * angle) / sinA;
return a.clone().multiplyScalar(w0).add(b.clone().multiplyScalar(w1));
}

function buildCurvePoints(fromLat, fromLon, toLat, toLon, mode) {
if (mode === 'air') {
const start = latLonToVec3(fromLat, fromLon, RADIUS * 1.005);
const end = latLonToVec3(toLat, toLon, RADIUS * 1.005);
const dist = start.distanceTo(end);
const mid = start.clone().add(end).multiplyScalar(0.5);
const lift = 0.25 + Math.min(dist / 2.4, 0.55);
mid.normalize().multiplyScalar(RADIUS * (1 + lift));
const curve = new THREE.QuadraticBezierCurve3(start, mid, end);
return curve.getPoints(80);
}

let waypoints;
if (mode === 'sea') {
waypoints = seaRouteCoords(fromLat, fromLon, toLat, toLon);
} else {
waypoints = [{ lat: fromLat, lon: fromLon }, { lat: toLat, lon: toLon }];
}

const surfaceLift = mode === 'sea' ? 1.012 : 1.008;
const points = [];
for (let i = 0; i < waypoints.length - 1; i++) {
const a = latLonToVec3(waypoints[i].lat, waypoints[i].lon, 1).normalize();
const b = latLonToVec3(waypoints[i+1].lat, waypoints[i+1].lon, 1).normalize();
const segments = Math.max(8, Math.round(a.angleTo(b) * 40));
for (let s = 0; s < segments; s++) {
const p = slerp(a, b, s / segments).multiplyScalar(RADIUS * surfaceLift);
points.push(p);
}
}
const last = waypoints[waypoints.length - 1];
points.push(latLonToVec3(last.lat, last.lon, 1).normalize().multiplyScalar(RADIUS * surfaceLift));
return points;
}

function addArc(fromLat, fromLon, toLat, toLon, mode) {
const color = FREIGHT_COLORS[mode] || FREIGHT_COLORS.air;
const points = buildCurvePoints(fromLat, fromLon, toLat, toLon, mode);
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.9 });
const line = new THREE.Line(geo, mat);
arcsGroup.add(line);

const pulse = new THREE.Mesh(new THREE.SphereGeometry(0.012, 8, 8), new THREE.MeshBasicMaterial({ color }));
const speed = mode === 'air' ? 0.004 : (mode === 'sea' ? 0.0015 : 0.0025);
pulse.userData = { polyline: points, t: Math.random(), speed };
arcsGroup.add(pulse);
}

function pointAlongPolyline(points, t) {
if (points.length < 2) return points[0] || new THREE.Vector3();
if (!points._lens) {
const lens = [0];
for (let i = 1; i < points.length; i++) lens.push(lens[i-1] + points[i].distanceTo(points[i-1]));
points._lens = lens;
points._total = lens[lens.length - 1];
}
const target = t * points._total;
let i = 1;
while (i < points._lens.length && points._lens[i] < target) i++;
if (i >= points.length) return points[points.length - 1];
const segT = (target - points._lens[i-1]) / (points._lens[i] - points._lens[i-1]);
return points[i-1].clone().lerp(points[i], segT);
}

let focusAnim = null;
function focusOn(lat, lon) {
const local = latLonToVec3(lat, lon, 1).normalize();
const targetRotY = Math.atan2(-local.x, local.z);
const horiz = Math.sqrt(local.x*local.x + local.z*local.z);
const targetRotX = Math.atan2(local.y, horiz);
focusAnim = {
startTime: performance.now(), duration: 1200,
fromRotX: earthGroup.rotation.x, fromRotY: earthGroup.rotation.y,
toRotX: targetRotX, toRotY: targetRotY,
fromZ: camera.position.z, toZ: 2.2
};
autoRotateChk.checked = false;
}
function easeInOut(t) { return t < 0.5 ? 2*t*t : -1 + (4 - 2*t)*t; }

let isDragging = false, dragMoved = false;
let lastX = 0, lastY = 0, downX = 0, downY = 0;
let rotVelX = 0, rotVelY = 0;
canvas.addEventListener('pointerdown', (e) => {
isDragging = true; dragMoved = false;
lastX = e.clientX; lastY = e.clientY;
downX = e.clientX; downY = e.clientY;
canvas.setPointerCapture(e.pointerId);
focusAnim = null;
});
canvas.addEventListener('pointermove', (e) => {
handleHover(e);
if (!isDragging) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
if (Math.abs(e.clientX - downX) + Math.abs(e.clientY - downY) > 4) dragMoved = true;
earthGroup.rotation.y += dx * 0.005;
earthGroup.rotation.x += dy * 0.005;
earthGroup.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, earthGroup.rotation.x));
rotVelY = dx * 0.005; rotVelX = dy * 0.005;
lastX = e.clientX; lastY = e.clientY;
});
canvas.addEventListener('pointerup', (e) => {
isDragging = false;
if (!dragMoved) handleClick(e);
});
canvas.addEventListener('pointerleave', () => { isDragging = false; tooltip.style.display = 'none'; });
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
camera.position.z = Math.max(1.6, Math.min(6, camera.position.z + e.deltaY * 0.002));
}, { passive: false });

const raycaster = new THREE.Raycaster();
const mouseNDC = new THREE.Vector2();
function pickAt(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
mouseNDC.x = ((clientX - rect.left) / rect.width) * 2 - 1;
mouseNDC.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouseNDC, camera);
const meshes = pickables.map(p => p.mesh);
const hits = raycaster.intersectObjects(meshes, false);
if (!hits.length) return null;
return pickables.find(p => p.mesh === hits[0].object) || null;
}
function handleHover(e) {
const rect = canvas.getBoundingClientRect();
const hit = pickAt(e.clientX, e.clientY);
if (hit) {
canvas.style.cursor = 'pointer';
tooltip.style.display = 'block';
tooltip.style.left = (e.clientX - rect.left + 12) + 'px';
tooltip.style.top = (e.clientY - rect.top + 12) + 'px';
tooltip.innerHTML = hit.info;
} else {
canvas.style.cursor = 'grab';
tooltip.style.display = 'none';
}
}
function handleClick(e) {
const hit = pickAt(e.clientX, e.clientY);
if (hit) focusOn(hit.lat, hit.lon);
}

function animate() {
requestAnimationFrame(animate);
if (focusAnim) {
const t = Math.min(1, (performance.now() - focusAnim.startTime) / focusAnim.duration);
const k = easeInOut(t);
earthGroup.rotation.x = focusAnim.fromRotX + (focusAnim.toRotX - focusAnim.fromRotX) * k;
earthGroup.rotation.y = focusAnim.fromRotY + (focusAnim.toRotY - focusAnim.fromRotY) * k;
camera.position.z = focusAnim.fromZ + (focusAnim.toZ - focusAnim.fromZ) * k;
if (t >= 1) focusAnim = null;
} else if (!isDragging) {
earthGroup.rotation.y += rotVelY;
earthGroup.rotation.x += rotVelX;
rotVelY *= 0.95; rotVelX *= 0.95;
earthGroup.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, earthGroup.rotation.x));
if (autoRotateChk.checked && Math.abs(rotVelY) < 0.0005) earthGroup.rotation.y += 0.0015;
}
arcsGroup.children.forEach(obj => {
if (obj.userData && obj.userData.polyline) {
obj.userData.t += obj.userData.speed;
if (obj.userData.t > 1) obj.userData.t = 0;
const p = pointAlongPolyline(obj.userData.polyline, obj.userData.t);
obj.position.copy(p);
}
});
renderer.render(scene, camera);
}
animate();

function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}

function loadData(data) {
clearData();
if (!data || !data.company || !Array.isArray(data.suppliers)) {
statusEl.textContent = 'Invalid JSON — needs "company" object and "suppliers" array.';
return;
}
const c = data.company;
addMarker(c.lat, c.lon, 0xffb547,
'<strong>' + escapeHtml(c.name) + '</strong><br>HQ · ' + c.lat.toFixed(2) + ', ' + c.lon.toFixed(2),
0.022, c);
const counts = { air: 0, sea: 0, land: 0 };
data.suppliers.forEach(s => {
const mode = (s.freight || 'air').toLowerCase();
if (!FREIGHT_COLORS[mode]) return;
counts[mode]++;
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
addMarker(s.lat, s.lon, FREIGHT_COLORS[mode],
'<strong>' + escapeHtml(s.name) + '</strong>' + (s.country ? '<br>' + escapeHtml(s.country) : '') +
'<br>Freight: ' + modeLabel + '<br>' + s.lat.toFixed(2) + ', ' + s.lon.toFixed(2) +
'<br><em style="color:#9aa6b8">Click to focus</em>',
0.014, s);
addArc(s.lat, s.lon, c.lat, c.lon, mode);
});
statusEl.textContent = data.suppliers.length + ' suppliers · ' + counts.air + ' air, ' + counts.sea + ' sea, ' + counts.land + ' land';
}

document.getElementById('loadBtn').addEventListener('click', () => document.getElementById('fileInput').click());
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try { loadData(JSON.parse(ev.target.result)); }
catch (err) { statusEl.textContent = 'JSON parse error: ' + err.message; }
};
reader.readAsText(file);
});
document.getElementById('resetBtn').addEventListener('click', () => {
focusAnim = {
startTime: performance.now(), duration: 800,
fromRotX: earthGroup.rotation.x, fromRotY: earthGroup.rotation.y,
toRotX: 0, toRotY: 0,
fromZ: camera.position.z, toZ: initialCamZ
};
});

const sample = {
company: { name: "Acme Norge AS", lat: 59.9139, lon: 10.7522 },
suppliers: [
{ name: "Foxconn", lat: 22.5431, lon: 114.0579, country: "China", freight: "sea" },
{ name: "TSMC", lat: 24.7736, lon: 121.0452, country: "Taiwan", freight: "air" },
{ name: "Samsung Components", lat: 37.5665, lon: 126.9780, country: "South Korea", freight: "sea" },
{ name: "Bosch GmbH", lat: 48.7758, lon: 9.1829, country: "Germany", freight: "land" },
{ name: "ASML", lat: 51.4416, lon: 5.4697, country: "Netherlands", freight: "land" },
{ name: "Texas Instruments", lat: 32.7767, lon: -96.7970, country: "USA", freight: "air" },
{ name: "Embraer", lat:-23.2237, lon: -45.9009, country: "Brazil", freight: "sea" },
{ name: "Tata Consultancy", lat: 19.0760, lon: 72.8777, country: "India", freight: "air" },
{ name: "Anglo American", lat:-26.2041, lon: 28.0473, country: "South Africa", freight: "sea" },
{ name: "BHP", lat:-31.9523, lon: 115.8613, country: "Australia", freight: "sea" },
{ name: "Volvo Trucks", lat: 57.7089, lon: 11.9746, country: "Sweden", freight: "land" },
{ name: "Wartsila", lat: 60.1699, lon: 24.9384, country: "Finland", freight: "land" }
]
};
document.getElementById('sampleBtn').addEventListener('click', () => loadData(sample));
})();
</script>
</body>
</html>



GitHub RepoRequest for Change (RFC)