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.
- 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
- Download
supplier-globe.html - Open it in a browser, or upload it to your web server
- Click Use sample data to verify it works
- 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
| Field | Required | Type | Notes |
|---|---|---|---|
company.name | yes | string | Displayed in the HQ tooltip |
company.lat | yes | number | Latitude in decimal degrees, range −90 to 90 |
company.lon | yes | number | Longitude in decimal degrees, range −180 to 180 |
suppliers[].name | yes | string | Displayed in the supplier tooltip |
suppliers[].lat | yes | number | Latitude in decimal degrees |
suppliers[].lon | yes | number | Longitude in decimal degrees |
suppliers[].country | no | string | Shown in tooltip if present |
suppliers[].freight | no | string | One of "air", "sea", "land". Defaults to "air". Case-insensitive. |
Example
{
"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" }
]
}
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
| Element | Color | Hex |
|---|---|---|
| Ocean | Dark navy | #0a1428 |
| Country outlines | Muted blue | #4a7fb8 |
| HQ marker | Amber | #ffb547 |
| Air freight | Cyan | #4ec9ff |
| Sea freight | Green | #66e6a3 |
| Land freight | Amber | #ffb86b |
| Atmospheric glow | Blue | #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
| Mode | Geometry | Lift | Pulse speed |
|---|---|---|---|
| Air | Quadratic Bézier arch | scales with distance, up to 80% above surface | fast |
| Sea | Polyline through maritime waypoint graph | 1.2% above surface | slow |
| Land | Great-circle path | 0.8% above surface | medium |
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
- Waypoint network: ~40 nodes representing major ports and chokepoint approaches (Rotterdam, Singapore, Suez North/South, Panama Atlantic/Pacific, Cape of Good Hope, etc.)
- 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.
- 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
| Action | Effect |
|---|---|
| Drag | Rotate globe (with momentum) |
| Scroll wheel | Zoom in/out |
| Click marker | Smooth-animate to focus on that location |
| Auto-rotate checkbox | Globe slowly rotates when idle |
| Reset view button | Animate back to default orientation and zoom |
| Use sample data | Load the built-in 12-supplier example |
| Load JSON file | Upload your own JSON |
Deployment
- Standalone page
- Embed via iframe
- Inline in existing page
- Webflow
- In a Docusaurus page
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.
Cleanest option for embedding into an existing page, since it isolates the globe's CSS from the rest of your site.
<iframe
src="/supplier-globe.html"
width="900"
height="800"
style={{border: 'none'}}
title="Supplier globe"
></iframe>
Open supplier-globe.html in a text editor and copy three pieces into your page:
- The
<style>block from the<head>— paste into your page's<head>(strip thebodyselector since you already have one) - The
<div class="globe-app">…</div>block — paste wherever you want the globe to appear - Both
<script>tags at the bottom — paste before your closing</body>
If your site already uses class names like globe-controls or globe-stage, rename them to avoid collisions. All globe CSS is namespaced with globe- as a precaution.
Webflow can host the file directly:
- Upload
supplier-globe.htmlvia Project Settings → Custom Code → Asset upload, or host it on AWS S3 and reference the URL - On the page where you want the globe, drop in an Embed element (or Iframe element)
- Point it at the file URL with the iframe approach above
For tight integration with the rest of your Webflow page styling, use the inline approach instead — paste the script and div into a Webflow Embed component.
Drop the HTML into static/supplier-globe.html and reference it from any MDX page:
import BrowserOnly from '@docusaurus/BrowserOnly';
<BrowserOnly>
{() => (
<iframe
src="/supplier-globe.html"
width="100%"
height="700"
style={{border: 'none', borderRadius: '12px'}}
title="Supplier globe"
/>
)}
</BrowserOnly>
The BrowserOnly wrapper avoids SSR issues since Three.js needs a real DOM.
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:
| Library | Purpose | URL |
|---|---|---|
| Three.js r128 | 3D rendering engine | https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js |
| topojson-client 3 | Decodes country boundary data | https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js |
| world-atlas/countries-110m | Country 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:
| What | Where in the file |
|---|---|
| Page title | <title> tag in the <head> |
| Background color of outer page | body { background: ... } in the <style> block |
| Globe ocean color | MeshPhongMaterial({ color: 0x0a1428, ... }) in the script |
| Country outline color | LineBasicMaterial({ color: 0x4a7fb8, ... }) in drawCountries() |
| Atmospheric glow color | The gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) line in the glow shader |
| HQ marker color | The 0xffb547 literal inside loadData() (the line after the company definition) |
| Freight route colors | FREIGHT_COLORS object near the top |
| Marker glow halo size | The haloSize = size * 8 line in addMarker() |
| Auto-rotate speed | earthGroup.rotation.y += 0.0015 in animate() |
| Default zoom | initialCamZ = 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
| Symptom | Likely cause |
|---|---|
| Blank dark square, nothing renders | WebGL disabled in browser, or graphics driver issue |
| "Map outlines unavailable" in status bar | CDN for country data is blocked — globe still works |
| Routes appear to cut through land | The supplier or HQ is far from any waypoint — see Maritime routing → Limitations |
JSON parse error | The uploaded file isn't valid JSON. Check for trailing commas or missing quotes |
| Markers in wrong place | Likely lat/lon swapped, or coordinates in DMS instead of decimal degrees |
| Sea route looks weird | The 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
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>