편집 요약 없음 |
편집 요약 없음 태그: 되돌려진 기여 |
||
| 1번째 줄: | 1번째 줄: | ||
/* ========================================= | /* ========================================= | ||
COASTLINE: BLACK ICE - NationsGlobe | |||
Real-world prototype / QGIS-ready globe viewer | |||
========================================= */ | |||
(function (mw, $) { | |||
'use strict'; | |||
var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; | |||
var TOPOJSON_URL = 'https://cdn.jsdelivr.net/npm/topojson-client@3/+esm'; | |||
var DEFAULT_WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'; | |||
var CELESTIAL_PLANET_TEXTURES = { | |||
mercury: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_mercury.jpg', | |||
venus: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_venus_surface.jpg', | |||
mars: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_mars.jpg', | |||
jupiter: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_jupiter.jpg', | |||
saturn: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_saturn.jpg', | |||
uranus: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_uranus.jpg', | |||
neptune: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_neptune.jpg' | |||
}; | |||
function | var CELESTIAL_SUN_TEXTURE_URL = 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_sun.jpg'; | ||
var CELESTIAL_MOON_TEXTURE_URL = 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_moon.jpg'; | |||
var | var SATURN_RING_TEXTURE_URL = 'https://upload.wikimedia.org/wikipedia/commons/2/29/Solarsystemscope_texture_8k_saturn_ring_alpha.png'; | ||
/* | |||
}); | * Decorative celestial model. | ||
* This is not a precise ephemeris. It keeps the planets on one distant | |||
* ecliptic plane so they read as real 3D bodies instead of screen-composed PNGs. | |||
*/ | |||
var CELESTIAL_ECLIPTIC = { | |||
depth: -22.0, | |||
tiltYPerX: 0.018, | |||
depthScale: 0.34 | |||
}; | |||
var CELESTIAL_PLANET_DEFS = [ | |||
{ | |||
key: 'mercury', | |||
radius: 0.052, | |||
orbitRadius: 4.15, | |||
orbitAngle: 2.03, | |||
inclination: -0.018, | |||
opacity: 0.36, | |||
rotationSpeed: 0.000056, | |||
fallbackA: '#7f786d', | |||
fallbackB: '#24211d' | |||
}, | |||
{ | |||
key: 'venus', | |||
radius: 0.086, | |||
orbitRadius: 4.65, | |||
orbitAngle: 2.96, | |||
inclination: 0.024, | |||
opacity: 0.36, | |||
rotationSpeed: 0.000022, | |||
fallbackA: '#b99765', | |||
fallbackB: '#4f341a' | |||
}, | |||
{ | |||
key: 'mars', | |||
radius: 0.105, | |||
orbitRadius: 5.25, | |||
orbitAngle: 3.84, | |||
inclination: 0.032, | |||
opacity: 0.42, | |||
rotationSpeed: 0.000040, | |||
fallbackA: '#8f4938', | |||
fallbackB: '#3a1712' | |||
}, | |||
{ | |||
key: 'jupiter', | |||
radius: 0.235, | |||
orbitRadius: 8.20, | |||
orbitAngle: 5.64, | |||
inclination: 0.023, | |||
opacity: 0.34, | |||
rotationSpeed: 0.000024, | |||
fallbackA: '#a98262', | |||
fallbackB: '#4c3628' | |||
}, | |||
{ | |||
key: 'saturn', | |||
radius: 0.225, | |||
orbitRadius: 9.15, | |||
orbitAngle: 4.76, | |||
inclination: -0.018, | |||
opacity: 0.44, | |||
rotationSpeed: 0.000022, | |||
fallbackA: '#b89a70', | |||
fallbackB: '#4d3724', | |||
ring: true, | |||
ringOpacity: 0.86, | |||
ringInnerScale: 1.34, | |||
ringOuterScale: 2.58, | |||
ringTiltX: 1.08, | |||
ringTiltY: 0.18, | |||
ringTiltZ: -0.34, | |||
ringTextureUrl: SATURN_RING_TEXTURE_URL | |||
}, | |||
{ | |||
key: 'uranus', | |||
radius: 0.116, | |||
orbitRadius: 10.70, | |||
orbitAngle: 1.43, | |||
inclination: -0.026, | |||
opacity: 0.29, | |||
rotationSpeed: 0.000030, | |||
fallbackA: '#7fb5bd', | |||
fallbackB: '#24424b' | |||
}, | |||
{ | |||
key: 'neptune', | |||
radius: 0.100, | |||
orbitRadius: 11.75, | |||
orbitAngle: 0.66, | |||
inclination: 0.031, | |||
opacity: 0.28, | |||
rotationSpeed: 0.000032, | |||
fallbackA: '#43658f', | |||
fallbackB: '#151d35' | |||
} | |||
]; | |||
var CELESTIAL_SUN_DEF = { | |||
radius: 0.35, | |||
orbitRadius: 12.4, | |||
orbitAngle: 2.72, | |||
inclination: 0.000, | |||
color: 0xffddb4, | |||
coreOpacity: 0.90, | |||
innerGlowOpacity: 0.74, | |||
innerGlowScale: 4.9, | |||
outerGlowOpacity: 0.24, | |||
outerGlowScale: 8.6, | |||
pulseA: 0.010, | |||
pulseB: 0.014, | |||
textureScrollSpeed: 0.000014 | |||
}; | |||
var CELESTIAL_MOON_DEF = { | |||
radius: 0.145, | |||
position: [1.72, 0.28, -0.62], | |||
opacity: 0.72, | |||
rotationSpeed: 0.000018, | |||
fallbackA: '#8f8f8f', | |||
fallbackB: '#343434' | |||
}; | |||
var instances = []; | |||
var threeModulePromise = null; | |||
var topoModulePromise = null; | |||
function escapeHtml(value) { | |||
return String(value == null ? '' : value) | |||
.replace(/&/g, '&') | |||
.replace(/</g, '<') | |||
.replace(/>/g, '>') | |||
.replace(/"/g, '"') | |||
.replace(/'/g, '''); | |||
} | |||
function loadThree() { | |||
if (window.CLBI_NATIONS_GLOBE_THREE) { | |||
return Promise.resolve(window.CLBI_NATIONS_GLOBE_THREE); | |||
} | |||
if (!threeModulePromise) { | |||
threeModulePromise = import(THREE_URL).then(function (module) { | |||
window.CLBI_NATIONS_GLOBE_THREE = module; | |||
return module; | |||
}); | |||
} | |||
return threeModulePromise; | |||
} | |||
function loadTopojson() { | |||
if (window.CLBI_NATIONS_GLOBE_TOPOJSON) { | |||
return Promise.resolve(window.CLBI_NATIONS_GLOBE_TOPOJSON); | |||
} | |||
if (!topoModulePromise) { | |||
topoModulePromise = import(TOPOJSON_URL).then(function (module) { | |||
window.CLBI_NATIONS_GLOBE_TOPOJSON = module; | |||
return module; | |||
}); | |||
} | |||
return topoModulePromise; | |||
} | |||
function toNumber(value, fallback) { | |||
var n = parseFloat(value); | |||
return Number.isFinite(n) ? n : fallback; | |||
} | |||
function lonLatToVector3(THREE, lon, lat, radius) { | |||
var phi = (90 - lat) * Math.PI / 180; | |||
var theta = (lon + 180) * Math.PI / 180; | |||
var sinPhi = Math.sin(phi); | |||
return new THREE.Vector3( | |||
-radius * sinPhi * Math.cos(theta), | |||
radius * Math.cos(phi), | |||
radius * sinPhi * Math.sin(theta) | |||
); | |||
} | |||
function vectorToLonLat(vec) { | |||
var n = vec.clone().normalize(); | |||
var lat = Math.asin(Math.max(-1, Math.min(1, n.y))) * 180 / Math.PI; | |||
var lon = Math.atan2(n.z, -n.x) * 180 / Math.PI - 180; | |||
while (lon < -180) lon += 360; | |||
while (lon > 180) lon -= 360; | |||
return { | |||
lon: lon, | |||
lat: lat | |||
}; | |||
} | |||
function ensureNationsGlobeInteractionStyle() { | |||
if (document.getElementById('nation-globe-interaction-style')) return; | |||
var style = document.createElement('style'); | |||
style.id = 'nation-globe-interaction-style'; | |||
style.textContent = [ | |||
'.clbi-nations-globe-window.has-nation-hover .clbi-nations-globe-stage{cursor:pointer;}', | |||
'.clbi-nations-globe-window.is-dragging .clbi-nations-globe-stage{cursor:grabbing;}', | |||
'.clbi-nations-globe-nation-tooltip{position:absolute;z-index:8;min-width:72px;max-width:220px;pointer-events:none;display:none;padding:4px 7px;background:rgba(5,5,5,.92);border:1px solid #000;color:#e2e2e2;font-size:11px;line-height:1.25;box-shadow:inset 0 1px 0 #333,inset 0 -1px 0 #111,0 4px 12px rgba(0,0,0,.45);white-space:nowrap;}', | |||
'.clbi-nations-globe-nation-tooltip.is-visible{display:block;}', | |||
'.clbi-nations-globe-nation-tooltip .nation-tooltip-name{font-weight:700;letter-spacing:.02em;}', | |||
'.clbi-nations-globe-nation-tooltip .nation-tooltip-code{margin-left:6px;color:#888;font-size:10px;font-weight:500;}', | |||
'.clbi-nations-globe-nation-tooltip .nation-tooltip-link{display:block;margin-top:2px;color:#a8a8a8;font-size:10px;}' | |||
].join('\n'); | |||
document.head.appendChild(style); | |||
} | |||
function getFeaturePage(feature) { | |||
return feature && feature.properties ? String(feature.properties.page || '').trim() : ''; | |||
} | |||
function calculateGeometryBBox(geometry) { | |||
var bbox = [Infinity, Infinity, -Infinity, -Infinity]; | |||
function visit(coords) { | |||
if (!Array.isArray(coords)) return; | |||
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') { | |||
bbox[0] = Math.min(bbox[0], coords[0]); | |||
bbox[1] = Math.min(bbox[1], coords[1]); | |||
bbox[2] = Math.max(bbox[2], coords[0]); | |||
bbox[3] = Math.max(bbox[3], coords[1]); | |||
return; | |||
} | |||
coords.forEach(visit); | |||
} | |||
if (geometry) visit(geometry.coordinates); | |||
if (!Number.isFinite(bbox[0])) return [-180, -90, 180, 90]; | |||
return bbox; | |||
} | |||
function isLonLatInsideBbox(lon, lat, bbox) { | |||
if (!bbox || bbox.length < 4) return true; | |||
if (lat < bbox[1] || lat > bbox[3]) return false; | |||
if ((bbox[2] - bbox[0]) >= 300) return true; | |||
return lon >= bbox[0] && lon <= bbox[2]; | |||
} | |||
function unwrapLonNear(lon, refLon) { | |||
var x = lon; | |||
while (x - refLon > 180) x -= 360; | |||
while (x - refLon < -180) x += 360; | |||
return x; | |||
} | |||
function pointInRing(lon, lat, ring) { | |||
var inside = false; | |||
var i; | |||
var j; | |||
var xi; | |||
var yi; | |||
var xj; | |||
var yj; | |||
var intersect; | |||
if (!Array.isArray(ring) || ring.length < 3) return false; | |||
for (i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { | |||
if (!ring[i] || !ring[j]) continue; | |||
xi = unwrapLonNear(Number(ring[i][0]), lon); | |||
yi = Number(ring[i][1]); | |||
xj = unwrapLonNear(Number(ring[j][0]), lon); | |||
yj = Number(ring[j][1]); | |||
if (!Number.isFinite(xi) || !Number.isFinite(yi) || !Number.isFinite(xj) || !Number.isFinite(yj)) continue; | |||
intersect = ((yi > lat) !== (yj > lat)) && (lon < (xj - xi) * (lat - yi) / ((yj - yi) || 1e-12) + xi); | |||
if (intersect) inside = !inside; | |||
} | |||
return inside; | |||
} | |||
function pointInPolygonCoordinates(lon, lat, polygon) { | |||
var i; | |||
if (!Array.isArray(polygon) || !polygon.length) return false; | |||
if (!pointInRing(lon, lat, polygon[0])) return false; | |||
for (i = 1; i < polygon.length; i += 1) { | |||
if (pointInRing(lon, lat, polygon[i])) return false; | |||
} | |||
return true; | |||
} | |||
function pointInGeometry(lon, lat, geometry) { | |||
var i; | |||
if (!geometry) return false; | |||
if (geometry.type === 'Polygon') { | |||
return pointInPolygonCoordinates(lon, lat, geometry.coordinates); | |||
} | |||
if (geometry.type === 'MultiPolygon') { | |||
for (i = 0; i < geometry.coordinates.length; i += 1) { | |||
if (pointInPolygonCoordinates(lon, lat, geometry.coordinates[i])) return true; | |||
} | |||
} | |||
return false; | |||
} | |||
function normalizeNationLinkMap(payload) { | |||
var source = payload && payload.items ? payload.items : (payload || {}); | |||
var map = {}; | |||
Object.keys(source).forEach(function (key) { | |||
var entry = source[key]; | |||
var page = String((entry && entry.page) || key || '').trim(); | |||
if (!page) return; | |||
if (typeof entry === 'string') { | |||
map[page] = { | |||
page: page, | |||
name_ko: entry, | |||
wiki_title: entry, | |||
enabled: true | |||
}; | |||
} else { | |||
map[page] = Object.assign({ | |||
page: page, | |||
name_ko: '', | |||
wiki_title: '', | |||
enabled: true, | |||
note: '' | |||
}, entry || {}, { page: page }); | |||
} | |||
}); | |||
return map; | |||
} | |||
function encodeWikiTitle(title) { | |||
return String(title || '') | |||
.trim() | |||
.replace(/ /g, '_') | |||
.split('/') | |||
.map(function (part) { return encodeURIComponent(part); }) | |||
.join('/'); | |||
} | |||
function normalizeLonDelta(a, b) { | |||
var d = b - a; | |||
if (d > 180) d -= 360; | |||
if (d < -180) d += 360; | |||
return d; | |||
} | |||
function pushLineSegment(THREE, positions, a, b, radius) { | |||
if (!a || !b || a.length < 2 || b.length < 2) return; | |||
var lonA = toNumber(a[0], 0); | |||
var latA = toNumber(a[1], 0); | |||
var lonB = toNumber(b[0], 0); | |||
var latB = toNumber(b[1], 0); | |||
var dLon = normalizeLonDelta(lonA, lonB); | |||
var dLat = latB - latA; | |||
var steps = Math.max(1, Math.ceil(Math.max(Math.abs(dLon), Math.abs(dLat)) / 3)); | |||
var i; | |||
for (i = 0; i < steps; i += 1) { | |||
var t0 = i / steps; | |||
var t1 = (i + 1) / steps; | |||
var p0 = lonLatToVector3(THREE, lonA + dLon * t0, latA + dLat * t0, radius); | |||
var p1 = lonLatToVector3(THREE, lonA + dLon * t1, latA + dLat * t1, radius); | |||
positions.push(p0.x, p0.y, p0.z, p1.x, p1.y, p1.z); | |||
} | |||
} | |||
function pushLineString(THREE, positions, coords, radius) { | |||
var i; | |||
if (!Array.isArray(coords) || coords.length < 2) return; | |||
for (i = 0; i < coords.length - 1; i += 1) { | |||
pushLineSegment(THREE, positions, coords[i], coords[i + 1], radius); | |||
} | |||
} | |||
function pushPolygonRings(THREE, positions, rings, radius) { | |||
if (!Array.isArray(rings)) return; | |||
rings.forEach(function (ring) { | |||
pushLineString(THREE, positions, ring, radius); | |||
}); | |||
} | |||
function pushGeometryLines(THREE, positions, geometry, radius) { | |||
if (!geometry) return; | |||
if (geometry.type === 'LineString') { | |||
pushLineString(THREE, positions, geometry.coordinates, radius); | |||
return; | |||
} | |||
if (geometry.type === 'MultiLineString') { | |||
geometry.coordinates.forEach(function (line) { | |||
pushLineString(THREE, positions, line, radius); | |||
}); | |||
return; | |||
} | |||
if (geometry.type === 'Polygon') { | |||
pushPolygonRings(THREE, positions, geometry.coordinates, radius); | |||
return; | |||
} | |||
if (geometry.type === 'MultiPolygon') { | |||
geometry.coordinates.forEach(function (polygon) { | |||
pushPolygonRings(THREE, positions, polygon, radius); | |||
}); | |||
return; | |||
} | |||
if (geometry.type === 'GeometryCollection') { | |||
geometry.geometries.forEach(function (child) { | |||
pushGeometryLines(THREE, positions, child, radius); | |||
}); | |||
} | |||
} | |||
function extractGeojsonFromPayload(payload, topojson, objectName) { | |||
if (!payload) throw new Error('empty data'); | |||
if (payload.type === 'Topology') { | |||
if (!topojson || typeof topojson.mesh !== 'function') { | |||
throw new Error('topojson-client unavailable'); | |||
} | |||
var objects = payload.objects || {}; | |||
var target = objects[objectName] || objects.countries || objects.land || objects[Object.keys(objects)[0]]; | |||
if (!target) throw new Error('topology object not found'); | |||
return topojson.mesh(payload, target, function (a, b) { | |||
return a !== b; | |||
}); | |||
} | |||
if (payload.type === 'FeatureCollection') { | |||
return payload; | |||
} | |||
if (payload.type === 'Feature') { | |||
return { | |||
type: 'FeatureCollection', | |||
features: [payload] | |||
}; | |||
} | |||
if (payload.type && payload.coordinates) { | |||
return payload; | |||
} | |||
throw new Error('unsupported map data'); | |||
} | |||
function buildLineGeometryFromGeojson(THREE, geojson, radius) { | |||
var positions = []; | |||
if (geojson.type === 'FeatureCollection') { | |||
geojson.features.forEach(function (feature) { | |||
pushGeometryLines(THREE, positions, feature.geometry, radius); | |||
}); | |||
} else if (geojson.type === 'Feature') { | |||
pushGeometryLines(THREE, positions, geojson.geometry, radius); | |||
} else { | |||
pushGeometryLines(THREE, positions, geojson, radius); | |||
} | |||
var geometry = new THREE.BufferGeometry(); | |||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |||
return { | |||
geometry: geometry, | |||
segmentCount: positions.length / 6 | |||
}; | |||
} | |||
function createEarthTexture(THREE) { | |||
var canvas = document.createElement('canvas'); | |||
var size = 1024; | |||
var ctx; | |||
var gradient; | |||
var i; | |||
canvas.width = size; | |||
canvas.height = size / 2; | |||
ctx = canvas.getContext('2d'); | |||
ctx.fillStyle = '#08111a'; | |||
ctx.fillRect(0, 0, canvas.width, canvas.height); | |||
gradient = ctx.createRadialGradient(size * 0.42, size * 0.20, 10, size * 0.45, size * 0.28, size * 0.50); | |||
gradient.addColorStop(0, 'rgba(100,135,160,0.38)'); | |||
gradient.addColorStop(0.28, 'rgba(32,57,74,0.28)'); | |||
gradient.addColorStop(1, 'rgba(0,0,0,0)'); | |||
ctx.fillStyle = gradient; | |||
ctx.fillRect(0, 0, canvas.width, canvas.height); | |||
ctx.strokeStyle = 'rgba(255,255,255,0.030)'; | |||
ctx.lineWidth = 1; | |||
for (i = 0; i <= 12; i += 1) { | |||
var y = Math.round(i * canvas.height / 12) + 0.5; | |||
ctx.beginPath(); | |||
ctx.moveTo(0, y); | |||
ctx.lineTo(canvas.width, y); | |||
ctx.stroke(); | |||
} | |||
for (i = 0; i <= 24; i += 1) { | |||
var x = Math.round(i * canvas.width / 24) + 0.5; | |||
ctx.beginPath(); | |||
ctx.moveTo(x, 0); | |||
ctx.lineTo(x, canvas.height); | |||
ctx.stroke(); | |||
} | |||
for (i = 0; i < 220; i += 1) { | |||
var px = Math.random() * canvas.width; | |||
var py = Math.random() * canvas.height; | |||
var r = 1 + Math.random() * 2.8; | |||
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(80,105,93,0.18)' : 'rgba(95,83,70,0.16)'; | |||
ctx.beginPath(); | |||
ctx.ellipse(px, py, r * 6, r * 1.7, Math.random() * Math.PI, 0, Math.PI * 2); | |||
ctx.fill(); | |||
} | |||
var texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.needsUpdate = true; | |||
return texture; | |||
} | |||
function loadImageTexture(THREE, url) { | |||
return new Promise(function (resolve, reject) { | |||
var loader = new THREE.TextureLoader(); | |||
loader.load( | |||
url, | |||
function (texture) { | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.wrapS = THREE.RepeatWrapping; | |||
texture.wrapT = THREE.ClampToEdgeWrapping; | |||
texture.anisotropy = 4; | |||
texture.needsUpdate = true; | |||
resolve(texture); | |||
}, | |||
undefined, | |||
function (err) { | |||
reject(err || new Error('texture load failed: ' + url)); | |||
} | |||
); | |||
}); | |||
} | |||
function loadImageElement(url) { | |||
return new Promise(function (resolve, reject) { | |||
var image = new Image(); | |||
image.crossOrigin = 'anonymous'; | |||
image.decoding = 'async'; | |||
image.onload = function () { | |||
resolve(image); | |||
}; | |||
image.onerror = function () { | |||
reject(new Error('image load failed: ' + url)); | |||
}; | |||
image.src = url; | |||
}); | |||
} | |||
function createCompositedEarthTextureLayers(THREE, baseUrl, layers, maxWidth) { | |||
var effectiveLayers = Array.isArray(layers) ? layers.filter(function (layer) { | |||
return !!(layer && layer.url); | |||
}) : []; | |||
return loadImageElement(baseUrl).then(function (base) { | |||
return Promise.all(effectiveLayers.map(function (layer) { | |||
return loadImageElement(layer.url) | |||
.then(function (image) { | |||
return { | |||
layer: layer, | |||
image: image | |||
}; | |||
}) | |||
.catch(function (err) { | |||
if (window.console && console.warn) { | |||
console.warn('CLBI earth composite layer skipped:', { | |||
label: layer.label || 'overlay', | |||
url: layer.url, | |||
error: err | |||
}); | |||
} | |||
return null; | |||
}); | |||
})).then(function (loadedLayers) { | |||
var width = Math.max(1024, Math.min(8192, Math.floor(maxWidth || 4096))); | |||
var height = Math.max(512, Math.floor(width / 2)); | |||
var canvas = document.createElement('canvas'); | |||
var ctx; | |||
var texture; | |||
canvas.width = width; | |||
canvas.height = height; | |||
ctx = canvas.getContext('2d'); | |||
ctx.imageSmoothingEnabled = true; | |||
ctx.imageSmoothingQuality = 'high'; | |||
ctx.clearRect(0, 0, width, height); | |||
ctx.globalCompositeOperation = 'source-over'; | |||
ctx.globalAlpha = 1; | |||
ctx.drawImage(base, 0, 0, width, height); | |||
loadedLayers.forEach(function (entry) { | |||
var layer; | |||
var opacity; | |||
var blend; | |||
if (!entry || !entry.image) return; | |||
layer = entry.layer; | |||
opacity = Math.max(0, Math.min(1.4, layer.opacity == null ? 1 : layer.opacity)); | |||
blend = String(layer.blend || 'source-over').toLowerCase(); | |||
ctx.globalCompositeOperation = blend; | |||
ctx.globalAlpha = opacity; | |||
if (layer.filter && typeof ctx.filter !== 'undefined') { | |||
if ( | ctx.filter = String(layer.filter); | ||
} else if (typeof ctx.filter !== 'undefined') { | |||
ctx.filter = 'none'; | |||
} else | |||
} | } | ||
ctx.drawImage(entry.image, 0, 0, width, height); | |||
}); | }); | ||
ctx.globalCompositeOperation = 'source-over'; | |||
ctx.globalAlpha = 1; | |||
if (typeof ctx.filter !== 'undefined') { | |||
ctx.filter = 'none'; | |||
} | |||
texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.wrapS = THREE.RepeatWrapping; | |||
texture.wrapT = THREE.ClampToEdgeWrapping; | |||
texture.anisotropy = 6; | |||
texture.needsUpdate = true; | |||
return texture; | |||
}); | |||
}); | |||
} | |||
function createFallbackPlanetTexture(THREE, colorA, colorB) { | |||
var canvas = document.createElement('canvas'); | |||
var size = 512; | |||
var ctx; | |||
var gradient; | |||
var i; | |||
canvas.width = size; | |||
canvas.height = size / 2; | |||
ctx = canvas.getContext('2d'); | |||
gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); | |||
gradient.addColorStop(0, colorA || '#777777'); | |||
gradient.addColorStop(1, colorB || '#151515'); | |||
ctx.fillStyle = gradient; | |||
ctx.fillRect(0, 0, canvas.width, canvas.height); | |||
for (i = 0; i < 18; i += 1) { | |||
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.055)' : 'rgba(0,0,0,0.095)'; | |||
ctx.fillRect(0, Math.round(i * canvas.height / 18), canvas.width, Math.max(1, Math.round(canvas.height / 46))); | |||
} | |||
var texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.wrapS = THREE.RepeatWrapping; | |||
texture.wrapT = THREE.ClampToEdgeWrapping; | |||
texture.needsUpdate = true; | |||
return texture; | |||
} | |||
function createSaturnRingTexture(THREE) { | |||
var canvas = document.createElement('canvas'); | |||
var size = 512; | |||
var ctx; | |||
var image; | |||
var data; | |||
var cx = size * 0.5; | |||
var cy = size * 0.5; | |||
var maxR = size * 0.5; | |||
var texture; | |||
var x; | |||
var y; | |||
canvas.width = size; | |||
canvas.height = size; | |||
ctx = canvas.getContext('2d'); | |||
image = ctx.createImageData(size, size); | |||
data = image.data; | |||
for (y = 0; y < size; y += 1) { | |||
for (x = 0; x < size; x += 1) { | |||
var dx = (x - cx) / maxR; | |||
var dy = (y - cy) / maxR; | |||
var r = Math.sqrt(dx * dx + dy * dy); | |||
var idx = (y * size + x) * 4; | |||
var band; | |||
var gap; | |||
var alpha; | |||
var warm; | |||
if (r < 0.200 || r > 0.500) { | |||
data[idx + 3] = 0; | |||
continue; | |||
} | |||
band = 0.58 | |||
+ 0.22 * Math.sin(r * 92.0) | |||
+ 0.13 * Math.sin(r * 177.0 + 0.6) | |||
+ 0.07 * Math.sin(r * 317.0 - 1.1); | |||
gap = (r > 0.365 && r < 0.386) ? 0.22 : 1.0; | |||
alpha = Math.max(0, Math.min(1, (band * 1.28 + 0.08) * gap)); | |||
warm = Math.max(0, Math.min(1, (0.50 - r) / 0.300)); | |||
data[idx + 0] = Math.round(216 + warm * 38); | |||
data[idx + 1] = Math.round(186 + warm * 44); | |||
data[idx + 2] = Math.round(126 + warm * 48); | |||
data[idx + 3] = Math.round(alpha * 248); | |||
} | } | ||
} | |||
ctx.putImageData(image, 0, 0); | |||
texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.needsUpdate = true; | |||
return texture; | |||
} | |||
function configureSaturnRingMap(THREE, texture) { | |||
if (!texture) return texture; | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.wrapS = THREE.ClampToEdgeWrapping; | |||
texture.wrapT = THREE.ClampToEdgeWrapping; | |||
texture.anisotropy = 6; | |||
texture.needsUpdate = true; | |||
return texture; | |||
} | |||
function loadSaturnRingTexture(THREE, url) { | |||
return loadLimitedImageTexture(THREE, url, 2048).then(function (texture) { | |||
return configureSaturnRingMap(THREE, texture); | |||
}); | |||
} | |||
function createSaturnRingGeometry(THREE, innerRadius, outerRadius) { | |||
var geometry = new THREE.RingGeometry(innerRadius, outerRadius, 256, 32); | |||
var position = geometry.attributes.position; | |||
var uv = geometry.attributes.uv; | |||
var range = Math.max(0.0001, outerRadius - innerRadius); | |||
var i; | |||
/* | |||
* The Solar System Scope / Commons file is a radial strip: left-to-right is | |||
* inner-to-outer ring structure. Three.js RingGeometry's default UVs map | |||
* the whole square, which turns the strip into a flat painted oval. Rewrite | |||
* the U coordinate from each vertex's local radius so the real ring bands | |||
* wrap around Saturn concentrically. | |||
*/ | |||
for (i = 0; i < position.count; i += 1) { | |||
var x = position.getX(i); | |||
var y = position.getY(i); | |||
var r = Math.sqrt(x * x + y * y); | |||
var u = Math.max(0, Math.min(1, (r - innerRadius) / range)); | |||
uv.setXY(i, u, 0.5); | |||
} | |||
uv.needsUpdate = true; | |||
return geometry; | |||
} | |||
function createSolarInnerGlowTexture(THREE) { | |||
var canvas = document.createElement('canvas'); | |||
var size = 384; | |||
var ctx = canvas.getContext('2d'); | |||
var gradient; | |||
var texture; | |||
canvas.width = size; | |||
canvas.height = size; | |||
gradient = ctx.createRadialGradient( | |||
size * 0.5, size * 0.5, 0, | |||
size * 0.5, size * 0.5, size * 0.5 | |||
); | |||
gradient.addColorStop(0.000, 'rgba(255,252,232,1.00)'); | |||
gradient.addColorStop(0.090, 'rgba(255,232,168,0.96)'); | |||
gradient.addColorStop(0.240, 'rgba(255,182,82,0.70)'); | |||
gradient.addColorStop(0.430, 'rgba(255,126,28,0.36)'); | |||
gradient.addColorStop(0.660, 'rgba(232,96,16,0.12)'); | |||
gradient.addColorStop(1.000, 'rgba(0,0,0,0.00)'); | |||
ctx.fillStyle = gradient; | |||
ctx.fillRect(0, 0, size, size); | |||
texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.needsUpdate = true; | |||
return texture; | |||
} | |||
function createSolarCoronaTexture(THREE) { | |||
var canvas = document.createElement('canvas'); | |||
var size = 512; | |||
var ctx; | |||
var image; | |||
var data; | |||
var cx = size * 0.5; | |||
var cy = size * 0.5; | |||
var maxR = size * 0.5; | |||
var texture; | |||
var x; | |||
var y; | |||
canvas.width = size; | |||
canvas.height = size; | |||
ctx = canvas.getContext('2d'); | |||
image = ctx.createImageData(size, size); | |||
data = image.data; | |||
for (y = 0; y < size; y += 1) { | |||
for (x = 0; x < size; x += 1) { | |||
var dx = (x - cx) / maxR; | |||
var dy = (y - cy) / maxR; | |||
var r = Math.sqrt(dx * dx + dy * dy); | |||
var idx = (y * size + x) * 4; | |||
var angle; | |||
var noise; | |||
var base; | |||
var alpha; | |||
var warm; | |||
if (r >= 1) { | |||
data[idx + 3] = 0; | |||
continue; | |||
} | |||
angle = Math.atan2(dy, dx); | |||
noise = 0.90 | |||
+ 0.10 * Math.sin(angle * 7.0 + r * 9.0) | |||
+ 0.06 * Math.sin(angle * 13.0 - r * 16.0) | |||
+ 0.04 * Math.sin(angle * 29.0 + r * 25.0); | |||
base = Math.pow(Math.max(0, 1 - r), 2.65); | |||
alpha = Math.max(0, Math.min(1, base * noise)); | |||
warm = Math.max(0, Math.min(1, 1 - r * 0.9)); | |||
data[idx + 0] = Math.round(255); | |||
data[idx + 1] = Math.round(170 + 45 * warm); | |||
data[idx + 2] = Math.round(72 + 26 * warm); | |||
data[idx + 3] = Math.round(alpha * 255); | |||
} | |||
} | |||
ctx.putImageData(image, 0, 0); | |||
texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.needsUpdate = true; | |||
return texture; | |||
} | |||
function loadLimitedImageTexture(THREE, url, maxWidth) { | |||
return new Promise(function (resolve, reject) { | |||
var image = new Image(); | |||
var limit = Math.max(64, Math.min(1024, Math.floor(maxWidth || 512))); | |||
image.crossOrigin = 'anonymous'; | |||
image.decoding = 'async'; | |||
image.onload = function () { | |||
var naturalW = image.naturalWidth || image.width || limit; | |||
var naturalH = image.naturalHeight || image.height || Math.max(1, Math.floor(limit / 2)); | |||
var scale = Math.min(1, limit / naturalW); | |||
var width = Math.max(1, Math.round(naturalW * scale)); | |||
var height = Math.max(1, Math.round(naturalH * scale)); | |||
var canvas = document.createElement('canvas'); | |||
var ctx; | |||
var texture; | |||
canvas.width = width; | |||
canvas.height = height; | |||
ctx = canvas.getContext('2d'); | |||
ctx.drawImage(image, 0, 0, width, height); | |||
texture = new THREE.CanvasTexture(canvas); | |||
texture.colorSpace = THREE.SRGBColorSpace; | |||
texture.wrapS = THREE.RepeatWrapping; | |||
texture.wrapT = THREE.ClampToEdgeWrapping; | |||
texture.anisotropy = 2; | |||
texture.needsUpdate = true; | |||
resolve(texture); | |||
}; | |||
image.onerror = function () { | |||
reject(new Error('limited texture load failed: ' + url)); | |||
}; | |||
image.src = url; | |||
}); | |||
} | |||
function clamp01(value) { | |||
return Math.max(0, Math.min(1, value)); | |||
} | |||
function easeOutCubic(t) { | |||
t = clamp01(t); | |||
return 1 - Math.pow(1 - t, 3); | |||
} | |||
function nearestEquivalentAngle(current, target) { | |||
var full = Math.PI * 2; | |||
return target + Math.round((current - target) / full) * full; | |||
} | |||
function canonicalAngle(value) { | |||
var full = Math.PI * 2; | |||
var angle = value % full; | |||
if (angle > Math.PI) angle -= full; | |||
if (angle < -Math.PI) angle += full; | |||
return angle; | |||
} | |||
function inverseLerp(a, b, value) { | |||
if (a === b) return 0; | |||
return (value - a) / (b - a); | |||
} | |||
function createStars(THREE, count) { | |||
var geometry = new THREE.BufferGeometry(); | |||
var positions = []; | |||
var i; | |||
for (i = 0; i < count; i += 1) { | |||
var r = 8 + Math.random() * 16; | |||
var theta = Math.random() * Math.PI * 2; | |||
var phi = Math.acos((Math.random() * 2) - 1); | |||
positions.push( | |||
r * Math.sin(phi) * Math.cos(theta), | |||
r * Math.cos(phi), | |||
r * Math.sin(phi) * Math.sin(theta) | |||
); | |||
} | |||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |||
return new THREE.Points( | |||
geometry, | |||
new THREE.PointsMaterial({ | |||
color: 0xd8d8d8, | |||
size: 0.018, | |||
transparent: true, | |||
opacity: 0.62, | |||
depthWrite: false | |||
}) | |||
); | |||
} | |||
function NationsGlobe(root) { | |||
this.root = root; | |||
this.stage = null; | |||
this.loading = null; | |||
this.error = null; | |||
this.status = null; | |||
this.readout = null; | |||
this.autoButton = null; | |||
this.resetButton = null; | |||
this.THREE = null; | |||
this.scene = null; | |||
this.camera = null; | |||
this.renderer = null; | |||
this.group = null; | |||
this.starGroup = null; | |||
this.celestialGroup = null; | |||
this.celestialPlanets = []; | |||
this.starDriftY = 0; | |||
this.celestialDriftY = 0; | |||
this.celestialSun = null; | |||
this.celestialSunGlowA = null; | |||
this.celestialSunGlowB = null; | |||
this.celestialSunCorona = null; | |||
this.sunOccultRendered = 0; | |||
this.sunOccultMotion = null; | |||
this.moon = null; | |||
this.moonShell = null; | |||
this.earth = null; | |||
this.earthMaterial = null; | |||
this.clouds = null; | |||
this.cloudMaterial = null; | |||
this.lakesOverlay = null; | |||
this.raycaster = null; | |||
this.pointer = null; | |||
this.pointerInside = false; | |||
this.pointerClientX = null; | |||
this.pointerClientY = null; | |||
this.pointerDownX = null; | |||
this.pointerDownY = null; | |||
this.pointerDownNationPage = null; | |||
this.clickMoveTolerance = toNumber(root.getAttribute('data-nation-click-tolerance'), 5); | |||
this.nationHitUrl = root.getAttribute('data-polygon-hit-url') || ''; | |||
this.nationLinkMapUrl = root.getAttribute('data-nation-link-map-url') || ''; | |||
this.nationLinkBase = root.getAttribute('data-nation-link-base') || '/index.php/'; | |||
this.nationHitFeatures = []; | |||
this.nationLinkMap = {}; | |||
this.currentNation = null; | |||
this.nationTooltip = null; | |||
this.radius = 1; | |||
this.rotationX = toNumber(root.getAttribute('data-rotation-x'), -0.18); | |||
this.rotationY = toNumber(root.getAttribute('data-rotation-y'), -0.48); | |||
this.cameraZ = toNumber(root.getAttribute('data-camera-z'), 3.75); | |||
this.cameraZMin = toNumber(root.getAttribute('data-camera-z-min'), 1.23); | |||
this.cameraZMax = toNumber(root.getAttribute('data-camera-z-max'), 5.2); | |||
this.resetRotationX = toNumber(root.getAttribute('data-reset-rotation-x'), this.rotationX); | |||
this.resetRotationY = toNumber(root.getAttribute('data-reset-rotation-y'), this.rotationY); | |||
this.resetCameraZ = toNumber(root.getAttribute('data-reset-camera-z'), this.cameraZ); | |||
if (!root.getAttribute('data-camera-z') && root.getAttribute('data-reset-camera-z') === '3.05') { | |||
this.resetCameraZ = this.cameraZ; | |||
} | |||
this.resetDuration = toNumber(root.getAttribute('data-reset-duration'), 520); | |||
this.resetMotion = null; | |||
this.terrainUrl = root.getAttribute('data-terrain-url') || ''; | |||
this.cloudsUrl = root.getAttribute('data-clouds-url') || ''; | |||
this.boundarySurfaceOffset = toNumber(root.getAttribute('data-boundary-surface-offset'), 0.0022); | |||
this.polygonOverlayEnabled = root.getAttribute('data-polygon-overlay') !== 'false'; | |||
this.polygonOverlayUrl = root.getAttribute('data-polygon-overlay-url') || ''; | |||
this.polygonOverlaySize = toNumber(root.getAttribute('data-polygon-overlay-size'), 4096); | |||
this.earthCompositeSize = toNumber(root.getAttribute('data-earth-composite-size'), this.polygonOverlaySize || 4096); | |||
this.polygonOverlayOpacity = toNumber(root.getAttribute('data-polygon-overlay-opacity'), 1); | |||
this.polygonOverlayBlend = root.getAttribute('data-polygon-overlay-blend') || 'source-over'; | |||
this.polygonOverlayFilter = root.getAttribute('data-polygon-overlay-filter') || 'none'; | |||
this.reliefOverlayUrl = root.getAttribute('data-relief-overlay-url') || ''; | |||
this.reliefOverlayOpacity = toNumber(root.getAttribute('data-relief-overlay-opacity'), 1); | |||
this.reliefOverlayBlend = root.getAttribute('data-relief-overlay-blend') || 'source-over'; | |||
this.reliefOverlayFilter = root.getAttribute('data-relief-overlay-filter') || 'none'; | |||
this.lakesOverlayUrl = root.getAttribute('data-lakes-overlay-url') || ''; | |||
this.lakesOverlayOpacity = toNumber(root.getAttribute('data-lakes-overlay-opacity'), 1); | |||
this.lakesSurfaceOffset = toNumber(root.getAttribute('data-lakes-surface-offset'), 0.0029); | |||
this.cloudOpacityNear = toNumber(root.getAttribute('data-cloud-opacity-near'), 0.10); | |||
this.cloudOpacityFar = toNumber(root.getAttribute('data-cloud-opacity-far'), 0.90); | |||
this.cloudDriftSpeed = toNumber(root.getAttribute('data-cloud-drift-speed'), 0.000010); | |||
this.cloudTiltDrift = toNumber(root.getAttribute('data-cloud-tilt-drift'), 0.006); | |||
this.celestialEnabled = root.getAttribute('data-celestial-enabled') !== 'false'; | |||
this.celestialTextureSize = toNumber(root.getAttribute('data-celestial-texture-size'), 512); | |||
this.sunTextureSize = toNumber(root.getAttribute('data-sun-texture-size'), 512); | |||
this.moonTextureSize = toNumber(root.getAttribute('data-moon-texture-size'), 512); | |||
this.moonOpacityFar = toNumber(root.getAttribute('data-moon-opacity-far'), 1.00); | |||
this.moonOpacityNear = toNumber(root.getAttribute('data-moon-opacity-near'), 0.00); | |||
this.moonShellOpacityFar = toNumber(root.getAttribute('data-moon-shell-opacity-far'), 0.040); | |||
this.moonShellOpacityNear = toNumber(root.getAttribute('data-moon-shell-opacity-near'), 0.00); | |||
this.moonVanishZ = toNumber(root.getAttribute('data-moon-vanish-z'), 3.15); | |||
this.moonFullVisibleZ = toNumber(root.getAttribute('data-moon-full-visible-z'), 4.20); | |||
this.moonEarthFadeOpacity = toNumber(root.getAttribute('data-moon-earth-fade-opacity'), 0.08); | |||
this.moonEarthFadeMargin = toNumber(root.getAttribute('data-moon-earth-fade-margin'), 28); | |||
this.moonFadeDuration = toNumber(root.getAttribute('data-moon-fade-duration'), this.resetDuration); | |||
this.sunOccultFadeDuration = toNumber(root.getAttribute('data-sun-occult-fade-duration'), this.moonFadeDuration); | |||
this.moonRenderedOpacity = null; | |||
this.moonRenderedShellOpacity = null; | |||
this.moonFadeMotion = null; | |||
this.celestialParallax = toNumber(root.getAttribute('data-celestial-parallax'), 0.070); | |||
this.starParallax = toNumber(root.getAttribute('data-star-parallax'), 0.012); | |||
this.starDriftSpeed = toNumber(root.getAttribute('data-star-drift-speed'), 0); | |||
this.celestialDriftSpeed = toNumber(root.getAttribute('data-celestial-drift-speed'), 0); | |||
this.autoRotate = root.getAttribute('data-auto-rotate') !== 'false'; | |||
this.resetAutoRotate = this.autoRotate; | |||
this.autoRotateSpeed = toNumber(root.getAttribute('data-auto-rotate-speed'), 0.000085); | |||
this.autoResumeDuration = toNumber(root.getAttribute('data-auto-resume-duration'), Math.max(1600, this.resetDuration * 3.5)); | |||
this.autoResumeMinFactor = toNumber(root.getAttribute('data-auto-resume-min-factor'), 0.06); | |||
this.autoResumeMotion = null; | |||
this.dragging = false; | |||
this.lastX = 0; | |||
this.lastY = 0; | |||
this.lastMoveTime = 0; | |||
this.velocityX = 0; | |||
this.velocityY = 0; | |||
this.dragSensitivityY = toNumber(root.getAttribute('data-drag-sensitivity-y'), 0.0030); | |||
this.dragSensitivityX = toNumber(root.getAttribute('data-drag-sensitivity-x'), 0.0020); | |||
this.inertiaStrength = toNumber(root.getAttribute('data-inertia-strength'), 0.58); | |||
this.inertiaDamping = toNumber(root.getAttribute('data-inertia-damping'), 0.993); | |||
this.inertiaMinVelocity = toNumber(root.getAttribute('data-inertia-min-velocity'), 0.000085); | |||
this.inertiaReleaseWindow = toNumber(root.getAttribute('data-inertia-release-window'), 150); | |||
this.stageEventsBound = false; | |||
this.frame = null; | |||
this.disposed = false; | |||
this.segmentCount = 0; | |||
this.lastTime = performance.now(); | |||
} | |||
NationsGlobe.prototype.ensureDom = function () { | |||
var root = this.root; | |||
var title = root.getAttribute('data-title') || 'TERRITORY GLOBE'; | |||
var era = root.getAttribute('data-era') || 'REAL WORLD / 110M'; | |||
var topbarHtml; | |||
var bottombarHtml; | |||
root.classList.add('clbi-nations-globe-window'); | |||
topbarHtml = | |||
'<div class="clbi-nations-globe-topbar">' + | |||
'<span class="clbi-nations-globe-title">' + escapeHtml(title) + '</span>' + | |||
'<span class="clbi-nations-globe-status">SYNC</span>' + | |||
'</div>'; | |||
bottombarHtml = | |||
'<div class="clbi-nations-globe-bottombar">' + | |||
'<span class="clbi-nations-globe-readout">' + escapeHtml(era) + '</span>' + | |||
'<span class="clbi-nations-globe-controls">' + | |||
'<button type="button" class="clbi-nations-globe-btn clbi-nations-globe-auto">AUTO</button>' + | |||
'<button type="button" class="clbi-nations-globe-btn clbi-nations-globe-reset">RESET</button>' + | |||
'</span>' + | |||
'</div>'; | |||
if (!root.querySelector('.clbi-nations-globe-stage')) { | |||
root.innerHTML = | |||
'<div class="clbi-nations-globe-stage" aria-label="3D globe viewport"></div>' + | |||
'<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>' + | |||
topbarHtml + | |||
bottombarHtml + | |||
'<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>' + | |||
'<div class="clbi-nations-globe-error"></div>'; | |||
} else { | |||
if (!root.querySelector('.clbi-nations-globe-vignette')) { | |||
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>'); | |||
} | |||
if (!root.querySelector('.clbi-nations-globe-topbar')) { | |||
root.insertAdjacentHTML('beforeend', topbarHtml); | |||
} | |||
if (!root.querySelector('.clbi-nations-globe-bottombar')) { | |||
root.insertAdjacentHTML('beforeend', bottombarHtml); | |||
} | |||
if (!root.querySelector('.clbi-nations-globe-loading')) { | |||
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>'); | |||
} | |||
if (!root.querySelector('.clbi-nations-globe-error')) { | |||
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-error"></div>'); | |||
} | |||
} | |||
this.stage = root.querySelector('.clbi-nations-globe-stage'); | |||
this.loading = root.querySelector('.clbi-nations-globe-loading'); | |||
this.error = root.querySelector('.clbi-nations-globe-error'); | |||
this.status = root.querySelector('.clbi-nations-globe-status'); | |||
this.readout = root.querySelector('.clbi-nations-globe-readout'); | |||
if (!root.querySelector('.clbi-nations-globe-nation-tooltip')) { | |||
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-nation-tooltip" aria-hidden="true"></div>'); | |||
} | |||
this.autoButton = root.querySelector('.clbi-nations-globe-auto'); | |||
this.resetButton = root.querySelector('.clbi-nations-globe-reset'); | |||
this.nationTooltip = root.querySelector('.clbi-nations-globe-nation-tooltip'); | |||
ensureNationsGlobeInteractionStyle(); | |||
this.syncAutoButton(); | |||
if (this.readout && !this.readout.textContent) { | |||
this.readout.textContent = era; | |||
} | |||
}; | |||
NationsGlobe.prototype.rehydrateDom = function () { | |||
if (!this.root || !document.body.contains(this.root)) return false; | |||
this.ensureDom(); | |||
this.bindControlEvents(); | |||
this.resize(); | |||
return true; | |||
}; | |||
NationsGlobe.prototype.setStatus = function (text) { | |||
if (this.status) this.status.textContent = text || ''; | |||
}; | |||
NationsGlobe.prototype.setReadout = function (text) { | |||
if (this.readout) this.readout.textContent = text || ''; | |||
}; | |||
NationsGlobe.prototype.setError = function (err) { | |||
var message = err && err.message ? err.message : String(err || 'unknown error'); | |||
this.root.classList.add('has-error'); | |||
this.root.classList.remove('is-ready'); | |||
if (this.error) this.error.textContent = 'GLOBE ERROR: ' + message; | |||
this.setStatus('ERROR'); | |||
}; | |||
NationsGlobe.prototype.init = function () { | |||
var self = this; | |||
this.ensureDom(); | |||
this.setStatus('LIBRARY'); | |||
return Promise.all([loadThree(), loadTopojson()]) | |||
.then(function (libs) { | |||
self.THREE = libs[0]; | |||
self.topojson = libs[1]; | |||
self.createScene(); | |||
self.bindEvents(); | |||
return self.loadOptionalTextures().then(function () { | |||
return self.loadBoundaryData(); | |||
}).then(function () { | |||
return self.loadNationInteractionData(); | |||
}); | }); | ||
}) | |||
.then(function () { | |||
self.root.classList.add('is-ready'); | |||
self.setStatus('READY'); | |||
} | self.animate(performance.now()); | ||
}) | |||
.catch(function (err) { | |||
self.setError(err); | |||
}); | |||
}; | |||
NationsGlobe.prototype.addMoon = function (texture) { | |||
var THREE = this.THREE; | |||
var def = CELESTIAL_MOON_DEF; | |||
var material; | |||
var mesh; | |||
var shell; | |||
if (!this.group || !texture) return; | |||
material = new THREE.MeshPhongMaterial({ | |||
map: texture, | |||
color: 0xffffff, | |||
transparent: true, | |||
opacity: this.moonOpacityFar, | |||
depthWrite: false, | |||
depthTest: true, | |||
shininess: 2, | |||
specular: new THREE.Color(0x050505), | |||
emissive: new THREE.Color(0x010101) | |||
}); | |||
mesh = new THREE.Mesh( | |||
new THREE.SphereGeometry(def.radius, 42, 28), | |||
material | |||
); | |||
mesh.position.set(def.position[0], def.position[1], def.position[2]); | |||
mesh.renderOrder = 20; | |||
shell = new THREE.Mesh( | |||
new THREE.SphereGeometry(def.radius * 1.022, 36, 24), | |||
new THREE.MeshBasicMaterial({ | |||
color: 0xd8d8d8, | |||
transparent: true, | |||
opacity: this.moonShellOpacityFar, | |||
side: THREE.BackSide, | |||
depthWrite: false | |||
}) | |||
); | |||
shell.position.copy(mesh.position); | |||
shell.renderOrder = 19; | |||
this.group.add(shell); | |||
this.group.add(mesh); | |||
this.moon = mesh; | |||
this.moonShell = shell; | |||
this.updateMoonOpacity(); | |||
}; | |||
NationsGlobe.prototype.loadMoon = function () { | |||
var self = this; | |||
var THREE = this.THREE; | |||
var textureSize = Math.max(64, Math.min(1024, Math.floor(this.moonTextureSize || 512))); | |||
loadLimitedImageTexture(THREE, CELESTIAL_MOON_TEXTURE_URL, textureSize) | |||
.then(function (texture) { | |||
self.addMoon(texture); | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI moon texture failed:', err); | |||
self.addMoon(createFallbackPlanetTexture(THREE, CELESTIAL_MOON_DEF.fallbackA, CELESTIAL_MOON_DEF.fallbackB)); | |||
}); | |||
}; | |||
NationsGlobe.prototype.resolveCelestialEclipticPosition = function (def) { | |||
var plane = CELESTIAL_ECLIPTIC; | |||
var orbitRadius = def.orbitRadius || 0; | |||
var angle = def.orbitAngle || 0; | |||
var x = Math.cos(angle) * orbitRadius; | |||
var z = plane.depth + Math.sin(angle) * orbitRadius * plane.depthScale; | |||
var y = x * plane.tiltYPerX + Math.sin(angle) * orbitRadius * (def.inclination || 0); | |||
return [x, y, z]; | |||
}; | |||
NationsGlobe.prototype.addCelestialPlanet = function (def, texture) { | |||
var THREE = this.THREE; | |||
var self = this; | |||
var mesh; | |||
var material; | |||
var shell; | |||
var shellColor; | |||
var ring; | |||
var ringGlow; | |||
var ringMaterial; | |||
var position; | |||
if (!this.celestialGroup || !def || !texture) return; | |||
material = new THREE.MeshPhongMaterial({ | |||
map: texture, | |||
color: 0xffffff, | |||
transparent: true, | |||
opacity: def.opacity, | |||
depthWrite: false, | |||
depthTest: true, | |||
shininess: 3, | |||
specular: new THREE.Color(0x050505), | |||
emissive: new THREE.Color(0x020202) | |||
}); | |||
mesh = new THREE.Mesh( | |||
new THREE.SphereGeometry(def.radius, 42, 28), | |||
material | |||
); | |||
position = this.resolveCelestialEclipticPosition(def); | |||
mesh.position.set(position[0], position[1], position[2]); | |||
mesh.renderOrder = 1; | |||
this.celestialGroup.add(mesh); | |||
if (def.ring) { | |||
var ringInnerRadius = def.radius * (def.ringInnerScale || 1.34); | |||
var ringOuterRadius = def.radius * (def.ringOuterScale || 2.58); | |||
ringMaterial = new THREE.MeshBasicMaterial({ | |||
map: configureSaturnRingMap(THREE, createSaturnRingTexture(THREE)), | |||
color: 0xffffff, | |||
transparent: true, | |||
opacity: def.ringOpacity || 0.86, | |||
side: THREE.DoubleSide, | |||
depthWrite: false, | |||
depthTest: true, | |||
blending: THREE.NormalBlending | |||
}); | |||
ring = new THREE.Mesh( | |||
createSaturnRingGeometry(THREE, ringInnerRadius, ringOuterRadius), | |||
ringMaterial | |||
); | |||
ring.position.copy(mesh.position); | |||
ring.rotation.x = def.ringTiltX || 1.08; | |||
ring.rotation.y = def.ringTiltY || 0.18; | |||
ring.rotation.z = def.ringTiltZ || -0.34; | |||
ring.renderOrder = 1.8; | |||
this.celestialGroup.add(ring); | |||
if (def.ringTextureUrl) { | |||
loadSaturnRingTexture(THREE, def.ringTextureUrl).then(function (ringTexture) { | |||
if (!ringMaterial) return; | |||
ringMaterial.map = ringTexture; | |||
ringMaterial.needsUpdate = true; | |||
}).catch(function (err) { | |||
console.warn('CLBI Saturn ring texture failed:', err); | |||
}); | }); | ||
} | |||
} | |||
shellColor = new THREE.Color(def.fallbackA || '#707070'); | |||
shell = new THREE.Mesh( | |||
new THREE.SphereGeometry(def.radius * 1.028, 36, 24), | |||
new THREE.MeshBasicMaterial({ | |||
color: shellColor, | |||
transparent: true, | |||
opacity: Math.max(0.05, Math.min(0.12, (def.opacity || 0.4) * 0.20)), | |||
side: THREE.BackSide, | |||
depthWrite: false | |||
}) | |||
); | |||
shell.position.copy(mesh.position); | |||
shell.renderOrder = 0; | |||
this.celestialGroup.add(shell); | |||
this.celestialPlanets.push({ | |||
mesh: mesh, | |||
shell: shell, | |||
ring: ring, | |||
ringGlow: ringGlow, | |||
speed: def.rotationSpeed || 0 | |||
}); | |||
}; | |||
NationsGlobe.prototype.addCelestialSun = function (texture) { | |||
var THREE = this.THREE; | |||
var def = CELESTIAL_SUN_DEF; | |||
var position = this.resolveCelestialEclipticPosition(def); | |||
var core; | |||
var innerGlow; | |||
var outerGlow; | |||
var coreMaterial; | |||
var innerGlowMaterial; | |||
var outerGlowMaterial; | |||
if (!this.celestialGroup) return; | |||
coreMaterial = new THREE.MeshBasicMaterial({ | |||
map: texture || null, | |||
color: def.color, | |||
transparent: true, | |||
opacity: def.coreOpacity, | |||
depthWrite: false | |||
}); | |||
core = new THREE.Mesh( | |||
new THREE.SphereGeometry(def.radius, 48, 32), | |||
coreMaterial | |||
); | |||
core.position.set(position[0], position[1], position[2]); | |||
core.renderOrder = 3; | |||
innerGlowMaterial = new THREE.SpriteMaterial({ | |||
map: createSolarInnerGlowTexture(THREE), | |||
color: 0xffffff, | |||
transparent: true, | |||
opacity: def.innerGlowOpacity, | |||
depthWrite: false, | |||
depthTest: true, | |||
blending: THREE.AdditiveBlending | |||
}); | |||
innerGlow = new THREE.Sprite(innerGlowMaterial); | |||
innerGlow.position.copy(core.position); | |||
innerGlow.scale.set(def.radius * def.innerGlowScale, def.radius * def.innerGlowScale, 1); | |||
innerGlow.renderOrder = 2; | |||
outerGlowMaterial = new THREE.SpriteMaterial({ | |||
map: createSolarCoronaTexture(THREE), | |||
color: 0xffffff, | |||
transparent: true, | |||
opacity: def.outerGlowOpacity, | |||
depthWrite: false, | |||
depthTest: true, | |||
blending: THREE.AdditiveBlending | |||
}); | |||
outerGlow = new THREE.Sprite(outerGlowMaterial); | |||
outerGlow.position.copy(core.position); | |||
outerGlow.scale.set(def.radius * def.outerGlowScale, def.radius * def.outerGlowScale, 1); | |||
outerGlow.renderOrder = 1; | |||
this.celestialGroup.add(outerGlow); | |||
this.celestialGroup.add(innerGlow); | |||
this.celestialGroup.add(core); | |||
this.celestialSun = core; | |||
this.celestialSunGlowA = innerGlow; | |||
this.celestialSunGlowB = outerGlow; | |||
this.celestialSunCorona = null; | |||
}; | |||
NationsGlobe.prototype.loadCelestialSun = function () { | |||
var self = this; | |||
var THREE = this.THREE; | |||
var textureSize = Math.max(64, Math.min(1024, Math.floor(this.sunTextureSize || 512))); | |||
loadLimitedImageTexture(THREE, CELESTIAL_SUN_TEXTURE_URL, textureSize) | |||
.then(function (texture) { | |||
self.addCelestialSun(texture); | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI sun texture failed:', err); | |||
self.addCelestialSun(createFallbackPlanetTexture(THREE, '#d08b3e', '#5f240b')); | |||
}); | }); | ||
}; | |||
NationsGlobe.prototype.loadCelestialPlanets = function () { | |||
var self = this; | |||
var THREE = this.THREE; | |||
var textureSize = Math.max(64, Math.min(1024, Math.floor(this.celestialTextureSize || 512))); | |||
if (!this.celestialEnabled || !this.celestialGroup) return; | |||
CELESTIAL_PLANET_DEFS.forEach(function (def) { | |||
var url = CELESTIAL_PLANET_TEXTURES[def.key]; | |||
if (!url) { | |||
self.addCelestialPlanet(def, createFallbackPlanetTexture(THREE, def.fallbackA, def.fallbackB)); | |||
return; | |||
} | |||
loadLimitedImageTexture(THREE, url, textureSize) | |||
.then(function (texture) { | |||
self.addCelestialPlanet(def, texture); | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI celestial texture failed:', err); | |||
self.addCelestialPlanet(def, createFallbackPlanetTexture(THREE, def.fallbackA, def.fallbackB)); | |||
}); | |||
}); | |||
}; | |||
NationsGlobe.prototype.createCelestialBackdrop = function () { | |||
var THREE = this.THREE; | |||
this.starGroup = new THREE.Group(); | |||
this.celestialGroup = new THREE.Group(); | |||
this.scene.add(this.starGroup); | |||
this.scene.add(this.celestialGroup); | |||
this.starGroup.add(createStars(THREE, 780)); | |||
this.loadCelestialSun(); | |||
this.loadCelestialPlanets(); | |||
}; | |||
NationsGlobe.prototype.updateCelestialBackdrop = function (dt, now) { | |||
var i; | |||
var planet; | |||
var def = CELESTIAL_SUN_DEF; | |||
var pulseA; | |||
var pulseB; | |||
var sunOccult = 0; | |||
if (!this.resetMotion) { | |||
this.starDriftY += dt * this.starDriftSpeed; | |||
this.celestialDriftY += dt * this.celestialDriftSpeed; | |||
} | |||
if (this.starGroup) { | |||
this.starGroup.rotation.x = -this.rotationX * this.starParallax; | |||
this.starGroup.rotation.y = -this.rotationY * this.starParallax + this.starDriftY; | |||
} | |||
if (this.celestialGroup) { | |||
this.celestialGroup.rotation.x = -this.rotationX * this.celestialParallax; | |||
this.celestialGroup.rotation.y = -this.rotationY * this.celestialParallax + this.celestialDriftY; | |||
} | |||
for (i = 0; i < this.celestialPlanets.length; i += 1) { | |||
planet = this.celestialPlanets[i]; | |||
if (planet && planet.mesh) { | |||
planet.mesh.rotation.y += dt * planet.speed; | |||
if (planet.shell) planet.shell.rotation.y += dt * planet.speed * 0.35; | |||
if (planet.ring) planet.ring.rotation.z += dt * planet.speed * 0.10; | |||
} | |||
} | |||
if (this.celestialSun) { | |||
this.celestialSun.rotation.y += dt * def.textureScrollSpeed; | |||
if (this.celestialSun.material) { | |||
this.celestialSun.material.opacity = def.coreOpacity * (1 - sunOccult); | |||
} | |||
} | |||
if (this.celestialSunGlowA) { | |||
pulseA = def.radius * def.innerGlowScale * (1 + Math.sin(now * 0.00110) * def.pulseA); | |||
this.celestialSunGlowA.scale.set(pulseA, pulseA, 1); | |||
if (this.celestialSunGlowA.material) { | |||
this.celestialSunGlowA.material.opacity = def.innerGlowOpacity * (1 + Math.sin(now * 0.00082 + 1.7) * (def.pulseB * 0.35)) * (1 - sunOccult); | |||
} | |||
} | |||
if (this.celestialSunGlowB) { | |||
pulseB = def.radius * def.outerGlowScale * (1 + Math.sin(now * 0.00057 + 0.9) * def.pulseB); | |||
this.celestialSunGlowB.scale.set(pulseB, pulseB, 1); | |||
if (this.celestialSunGlowB.material) { | |||
this.celestialSunGlowB.material.opacity = def.outerGlowOpacity * (1 + Math.sin(now * 0.00043 + 2.4) * (def.pulseA * 0.45)) * (1 - sunOccult); | |||
} | |||
} | |||
if (this.moon) { | |||
this.moon.rotation.y += dt * CELESTIAL_MOON_DEF.rotationSpeed; | |||
if (this.moonShell) this.moonShell.rotation.y += dt * CELESTIAL_MOON_DEF.rotationSpeed * 0.35; | |||
} | |||
this.updateMoonOpacity(dt); | |||
}; | |||
NationsGlobe.prototype.createScene = function () { | |||
var THREE = this.THREE; | |||
var rect = this.stage.getBoundingClientRect(); | |||
var width = Math.max(320, Math.floor(rect.width || this.root.clientWidth || 960)); | |||
var height = Math.max(240, Math.floor(rect.height || this.root.clientHeight || 360)); | |||
var texture = createEarthTexture(THREE); | |||
var atmosphere; | |||
var light; | |||
var ambient; | |||
this.scene = new THREE.Scene(); | |||
this.camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 80); | |||
this.camera.position.set(0, 0, this.cameraZ); | |||
this.renderer = new THREE.WebGLRenderer({ | |||
antialias: true, | |||
alpha: true, | |||
powerPreference: 'high-performance' | |||
}); | |||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.7)); | |||
this.renderer.setSize(width, height, false); | |||
this.renderer.setClearColor(0x000000, 0); | |||
this.stage.appendChild(this.renderer.domElement); | |||
this.group = new THREE.Group(); | |||
this.scene.add(this.group); | |||
this.earthMaterial = new THREE.MeshPhongMaterial({ | |||
map: texture, | |||
color: 0x9fb3bb, | |||
emissive: 0x05080a, | |||
shininess: 10, | |||
transparent: false | |||
}); | |||
this.earth = new THREE.Mesh( | |||
new THREE.SphereGeometry(this.radius, 96, 64), | |||
this.earthMaterial | |||
); | |||
this.group.add(this.earth); | |||
atmosphere = new THREE.Mesh( | |||
new THREE.SphereGeometry(this.radius * 1.035, 96, 64), | |||
new THREE.MeshBasicMaterial({ | |||
color: 0x5f88a8, | |||
transparent: true, | |||
opacity: 0.115, | |||
side: THREE.BackSide, | |||
depthWrite: false | |||
}) | |||
); | |||
this.group.add(atmosphere); | |||
this.loadMoon(); | |||
light = new THREE.DirectionalLight(0xffffff, 2.2); | |||
light.position.set(3.2, 2.4, 4.8); | |||
this.scene.add(light); | |||
ambient = new THREE.AmbientLight(0x607080, 0.66); | |||
this.scene.add(ambient); | |||
this.createCelestialBackdrop(); | |||
this.raycaster = new THREE.Raycaster(); | |||
this.pointer = new THREE.Vector2(); | |||
this.applyRotation(); | |||
}; | |||
NationsGlobe.prototype.updateCloudOpacity = function () { | |||
if (!this.cloudMaterial) return; | |||
var minZ = this.cameraZMin; | |||
var maxZ = this.cameraZMax; | |||
var t = clamp01((this.cameraZ - minZ) / (maxZ - minZ)); | |||
var nearOpacity = Math.max(0, Math.min(1, this.cloudOpacityNear)); | |||
var farOpacity = Math.max(0, Math.min(1, this.cloudOpacityFar)); | |||
this.cloudMaterial.opacity = nearOpacity + (farOpacity - nearOpacity) * t; | |||
this.cloudMaterial.needsUpdate = true; | |||
}; | |||
NationsGlobe.prototype.getProjectedScreenRadius = function (object3D, radius) { | |||
var THREE = this.THREE; | |||
var center; | |||
var edge; | |||
var rect; | |||
var cx; | |||
var cy; | |||
var ex; | |||
var ey; | |||
if (!this.camera || !this.renderer || !object3D || !THREE) return null; | |||
center = new THREE.Vector3(); | |||
edge = new THREE.Vector3(); | |||
object3D.getWorldPosition(center); | |||
edge.copy(center).add(new THREE.Vector3(radius, 0, 0)); | |||
center.project(this.camera); | |||
edge.project(this.camera); | |||
rect = this.renderer.domElement.getBoundingClientRect(); | |||
cx = (center.x * 0.5 + 0.5) * rect.width; | |||
cy = (-center.y * 0.5 + 0.5) * rect.height; | |||
ex = (edge.x * 0.5 + 0.5) * rect.width; | |||
ey = (-edge.y * 0.5 + 0.5) * rect.height; | |||
return { | |||
x: cx, | |||
y: cy, | |||
r: Math.hypot(ex - cx, ey - cy) | |||
}; | |||
}; | |||
NationsGlobe.prototype.getMoonEarthOverlapFactor = function () { | |||
var earthScreen; | |||
var moonScreen; | |||
var distance; | |||
var fadeStart; | |||
var fadeEnd; | |||
if (!this.moon || !this.camera || !this.renderer || !this.group) return 0; | |||
earthScreen = this.getProjectedScreenRadius(this.group, this.radius); | |||
moonScreen = this.getProjectedScreenRadius(this.moon, CELESTIAL_MOON_DEF.radius); | |||
if (!earthScreen || !moonScreen) return 0; | |||
distance = Math.hypot(moonScreen.x - earthScreen.x, moonScreen.y - earthScreen.y); | |||
fadeStart = earthScreen.r + moonScreen.r + this.moonEarthFadeMargin; | |||
fadeEnd = Math.max(0, earthScreen.r - moonScreen.r); | |||
if (distance >= fadeStart) return 0; | |||
if (distance <= fadeEnd) return 1; | |||
return clamp01(1 - inverseLerp(fadeEnd, fadeStart, distance)); | |||
}; | |||
NationsGlobe.prototype.getMoonSunOccultFactor = function () { | |||
var THREE = this.THREE; | |||
var moonScreen; | |||
var sunScreen; | |||
var moonWorld; | |||
var sunWorld; | |||
var moonCamera; | |||
var sunCamera; | |||
var distance; | |||
var fadeStart; | |||
var fadeEnd; | |||
var geometryOccult; | |||
var moonOpacityFactor; | |||
if (!this.moon || !this.celestialSun || !this.camera || !this.renderer || !THREE) return 0; | |||
moonOpacityFactor = Math.max(0, Math.min(1, this.moonRenderedOpacity == null ? 1 : this.moonRenderedOpacity)); | |||
/* | |||
* A faded moon is a weaker occluder, but it must not suddenly release the sun. | |||
* The final reveal is smoothed in updateSunOccultFactor. | |||
*/ | |||
moonWorld = new THREE.Vector3(); | |||
sunWorld = new THREE.Vector3(); | |||
this.moon.getWorldPosition(moonWorld); | |||
this.celestialSun.getWorldPosition(sunWorld); | |||
moonCamera = moonWorld.clone().applyMatrix4(this.camera.matrixWorldInverse); | |||
sunCamera = sunWorld.clone().applyMatrix4(this.camera.matrixWorldInverse); | |||
/* | |||
* In camera space, objects in front of the camera have negative z values. | |||
* The less negative value is closer. Only the closer moon can occult the sun. | |||
*/ | |||
if (moonCamera.z <= sunCamera.z) return 0; | |||
moonScreen = this.getProjectedScreenRadius(this.moon, CELESTIAL_MOON_DEF.radius); | |||
sunScreen = this.getProjectedScreenRadius(this.celestialSun, CELESTIAL_SUN_DEF.radius * 1.18); | |||
if (!moonScreen || !sunScreen) return 0; | |||
distance = Math.hypot(moonScreen.x - sunScreen.x, moonScreen.y - sunScreen.y); | |||
fadeStart = moonScreen.r + sunScreen.r; | |||
fadeEnd = Math.max(0, moonScreen.r - sunScreen.r * 0.35); | |||
if (distance >= fadeStart) return 0; | |||
geometryOccult = distance <= fadeEnd | |||
? 1 | |||
: clamp01(1 - inverseLerp(fadeEnd, fadeStart, distance)); | |||
return geometryOccult * moonOpacityFactor; | |||
}; | |||
NationsGlobe.prototype.updateSunOccultFactor = function (targetOccult, dt) { | |||
var target = Math.max(0, Math.min(1, targetOccult || 0)); | |||
var duration = Math.max(120, this.sunOccultFadeDuration || this.moonFadeDuration || this.resetDuration || 520); | |||
var step = dt == null ? 16.6667 : Math.max(0, Math.min(80, dt)); | |||
var alpha = 1 - Math.pow(1 - clamp01(step / duration), 3); | |||
/* | |||
* Do not create a new transition every time the target changes. | |||
* The moon is fading every frame, so targetOccult changes every frame too. | |||
* A continuous low-pass interpolation prevents the sun from popping out | |||
* when the moon finally stops acting as an occluder. | |||
*/ | |||
this.sunOccultRendered = (this.sunOccultRendered || 0) + (target - (this.sunOccultRendered || 0)) * alpha; | |||
if (Math.abs(this.sunOccultRendered - target) < 0.0008) { | |||
this.sunOccultRendered = target; | |||
} | |||
if (this.sunOccultRendered < 0.0008) this.sunOccultRendered = 0; | |||
if (this.sunOccultRendered > 0.9992) this.sunOccultRendered = 1; | |||
return this.sunOccultRendered; | |||
}; | |||
NationsGlobe.prototype.updateMoonOpacity = function (dt) { | |||
var vanishZ = Math.max(this.cameraZMin, this.moonVanishZ); | |||
var fullZ = Math.max(vanishZ + 0.01, this.moonFullVisibleZ); | |||
var t = clamp01((this.cameraZ - vanishZ) / (fullZ - vanishZ)); | |||
var moonNear = Math.max(0, Math.min(1, this.moonOpacityNear)); | |||
var moonFar = Math.max(0, Math.min(1, this.moonOpacityFar)); | |||
var shellNear = Math.max(0, Math.min(1, this.moonShellOpacityNear)); | |||
var shellFar = Math.max(0, Math.min(1, this.moonShellOpacityFar)); | |||
var earthFadeOpacity = Math.max(0, Math.min(1, this.moonEarthFadeOpacity)); | |||
var overlap = this.getMoonEarthOverlapFactor(); | |||
var targetOpacity; | |||
var targetShellOpacity; | |||
var duration = Math.max(120, this.moonFadeDuration || this.resetDuration || 520); | |||
var step = dt == null ? 16.6667 : Math.max(0, Math.min(80, dt)); | |||
var transition; | |||
var k; | |||
var isSolidMoon; | |||
if (this.cameraZ <= vanishZ) { | |||
targetOpacity = 0; | |||
targetShellOpacity = 0; | |||
} else { | |||
targetOpacity = moonNear + (moonFar - moonNear) * t; | |||
targetShellOpacity = shellNear + (shellFar - shellNear) * t; | |||
if (overlap > 0) { | |||
targetOpacity = targetOpacity + (earthFadeOpacity - targetOpacity) * overlap; | |||
targetShellOpacity = targetShellOpacity * (1 - overlap); | |||
} | |||
} | } | ||
if (!this.moonFadeMotion || | |||
Math.abs((this.moonFadeMotion.targetOpacity || 0) - targetOpacity) > 0.001 || | |||
Math.abs((this.moonFadeMotion.targetShellOpacity || 0) - targetShellOpacity) > 0.001) { | |||
this.moonFadeMotion = { | |||
elapsed: 0, | |||
duration: duration, | |||
fromOpacity: this.moonRenderedOpacity === null ? targetOpacity : this.moonRenderedOpacity, | |||
fromShellOpacity: this.moonRenderedShellOpacity === null ? targetShellOpacity : this.moonRenderedShellOpacity, | |||
targetOpacity: targetOpacity, | |||
targetShellOpacity: targetShellOpacity | |||
}; | |||
} | |||
transition = this.moonFadeMotion; | |||
if (transition) { | |||
transition.elapsed += step; | |||
k = easeOutCubic(clamp01(transition.elapsed / transition.duration)); | |||
this.moonRenderedOpacity = transition.fromOpacity + (transition.targetOpacity - transition.fromOpacity) * k; | |||
this.moonRenderedShellOpacity = transition.fromShellOpacity + (transition.targetShellOpacity - transition.fromShellOpacity) * k; | |||
if (transition.elapsed >= transition.duration) { | |||
this.moonRenderedOpacity = transition.targetOpacity; | |||
this.moonRenderedShellOpacity = transition.targetShellOpacity; | |||
this.moonFadeMotion = null; | |||
} | |||
} else { | |||
this.moonRenderedOpacity = targetOpacity; | |||
this.moonRenderedShellOpacity = targetShellOpacity; | |||
} | } | ||
window. | /* | ||
* Do not hide at a visible 1% tail. Keep rendering until the opacity is | |||
* effectively zero, so the final disappearance reads as a fade, not a pop. | |||
*/ | |||
if (this.moonRenderedOpacity < 0.0008) this.moonRenderedOpacity = 0; | |||
if (this.moonRenderedShellOpacity < 0.0008) this.moonRenderedShellOpacity = 0; | |||
isSolidMoon = overlap <= 0.001 && this.moonRenderedOpacity >= 0.999; | |||
if (this.moon && this.moon.material) { | |||
this.moon.material.transparent = !isSolidMoon; | |||
this.moon.material.opacity = isSolidMoon ? 1 : this.moonRenderedOpacity; | |||
this.moon.material.depthWrite = true; | |||
this.moon.visible = (isSolidMoon ? 1 : this.moonRenderedOpacity) > 0; | |||
this.moon.material.needsUpdate = true; | |||
} | |||
if (this.moonShell && this.moonShell.material) { | |||
this.moonShell.material.opacity = this.moonRenderedShellOpacity; | |||
this.moonShell.visible = this.moonRenderedShellOpacity > 0; | |||
this.moonShell.material.needsUpdate = true; | |||
} | |||
}; | |||
NationsGlobe.prototype.loadOptionalTextures = function () { | |||
var self = this; | |||
var THREE = this.THREE; | |||
var tasks = []; | |||
if (this.terrainUrl && this.earthMaterial) { | |||
var earthLayers = []; | |||
if (this.reliefOverlayUrl) { | |||
earthLayers.push({ | |||
label: 'relief', | |||
url: this.reliefOverlayUrl, | |||
opacity: this.reliefOverlayOpacity, | |||
blend: this.reliefOverlayBlend || 'source-over', | |||
filter: this.reliefOverlayFilter || 'none' | |||
}); | |||
} | |||
if (this.polygonOverlayEnabled && this.polygonOverlayUrl) { | |||
earthLayers.push({ | |||
label: 'polygon', | |||
url: this.polygonOverlayUrl, | |||
opacity: this.polygonOverlayOpacity, | |||
blend: this.polygonOverlayBlend || 'source-over', | |||
filter: this.polygonOverlayFilter || 'none' | |||
}); | |||
} | |||
if (earthLayers.length) { | |||
tasks.push( | |||
createCompositedEarthTextureLayers(THREE, this.terrainUrl, earthLayers, this.earthCompositeSize) | |||
.then(function (texture) { | |||
if (!self.earthMaterial) return; | |||
self.earthMaterial.map = texture; | |||
self.earthMaterial.color.set(0xffffff); | |||
self.earthMaterial.emissive.set(0x020304); | |||
self.earthMaterial.shininess = 6; | |||
self.earthMaterial.needsUpdate = true; | |||
if (window.console && console.info) { | |||
console.info('CLBI earth composite applied:', { | |||
terrainUrl: self.terrainUrl, | |||
earthCompositeSize: self.earthCompositeSize, | |||
reliefOverlayUrl: self.reliefOverlayUrl, | |||
reliefOverlayOpacity: self.reliefOverlayOpacity, | |||
reliefOverlayBlend: self.reliefOverlayBlend, | |||
reliefOverlayFilter: self.reliefOverlayFilter, | |||
polygonOverlayUrl: self.polygonOverlayUrl, | |||
polygonOverlayOpacity: self.polygonOverlayOpacity, | |||
polygonOverlayBlend: self.polygonOverlayBlend, | |||
polygonOverlayFilter: self.polygonOverlayFilter | |||
}); | |||
} | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI earth composite failed, falling back to base terrain:', err); | |||
return loadImageTexture(THREE, self.terrainUrl) | |||
.then(function (texture) { | |||
if (!self.earthMaterial) return; | |||
self.earthMaterial.map = texture; | |||
self.earthMaterial.color.set(0xffffff); | |||
self.earthMaterial.emissive.set(0x020304); | |||
self.earthMaterial.shininess = 6; | |||
self.earthMaterial.needsUpdate = true; | |||
}); | |||
}) | |||
); | |||
} else { | |||
tasks.push( | |||
loadImageTexture(THREE, this.terrainUrl) | |||
.then(function (texture) { | |||
if (!self.earthMaterial) return; | |||
self.earthMaterial.map = texture; | |||
self.earthMaterial.color.set(0xffffff); | |||
self.earthMaterial.emissive.set(0x020304); | |||
self.earthMaterial.shininess = 6; | |||
self.earthMaterial.needsUpdate = true; | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI terrain texture failed:', err); | |||
}) | |||
); | |||
} | |||
} | |||
if (this.lakesOverlayUrl && this.group) { | |||
tasks.push( | |||
loadImageTexture(THREE, this.lakesOverlayUrl) | |||
.then(function (texture) { | |||
var material; | |||
var mesh; | |||
var radius; | |||
if (!self.group) return; | |||
radius = self.radius + Math.max(0.0012, Math.min(0.008, self.lakesSurfaceOffset)); | |||
material = new THREE.MeshBasicMaterial({ | |||
map: texture, | |||
transparent: true, | |||
opacity: Math.max(0, Math.min(1.25, self.lakesOverlayOpacity)), | |||
depthWrite: false, | |||
depthTest: true | |||
}); | |||
mesh = new THREE.Mesh( | |||
new THREE.SphereGeometry(radius, 128, 64), | |||
material | |||
); | |||
mesh.renderOrder = 12; | |||
self.group.add(mesh); | |||
self.lakesOverlay = mesh; | |||
if (window.console && console.info) { | |||
console.info('CLBI 1950 lakes overlay applied:', { | |||
lakesOverlayUrl: self.lakesOverlayUrl, | |||
lakesOverlayOpacity: self.lakesOverlayOpacity, | |||
lakesSurfaceOffset: self.lakesSurfaceOffset | |||
}); | |||
} | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI lakes overlay failed:', err); | |||
}) | |||
); | |||
} | |||
if (this.cloudsUrl) { | |||
tasks.push( | |||
loadImageTexture(THREE, this.cloudsUrl) | |||
.then(function (texture) { | |||
var material = new THREE.MeshBasicMaterial({ | |||
map: texture, | |||
color: 0xffffff, | |||
transparent: true, | |||
opacity: self.cloudOpacityFar, | |||
depthWrite: false, | |||
side: THREE.DoubleSide | |||
}); | |||
self.cloudMaterial = material; | |||
self.clouds = new THREE.Mesh( | |||
new THREE.SphereGeometry(self.radius * 1.018, 96, 64), | |||
material | |||
); | |||
self.clouds.renderOrder = 4; | |||
self.group.add(self.clouds); | |||
self.updateCloudOpacity(); | |||
}) | |||
.catch(function (err) { | |||
console.warn('CLBI cloud texture failed:', err); | |||
}) | |||
); | |||
} | |||
return Promise.all(tasks).then(function () {}); | |||
}; | |||
NationsGlobe.prototype.cancelResetMotion = function () { | |||
this.resetMotion = null; | |||
this.autoResumeMotion = null; | |||
}; | |||
NationsGlobe.prototype.startAutoResumeMotion = function () { | |||
if (!this.autoRotate) { | |||
this.autoResumeMotion = null; | |||
return; | |||
} | |||
this.autoResumeMotion = { | |||
elapsed: 0, | |||
duration: Math.max(900, this.autoResumeDuration || this.resetDuration || 1800), | |||
from: Math.max(0, Math.min(0.35, this.autoResumeMinFactor || 0.06)), | |||
to: 1 | |||
}; | |||
}; | |||
NationsGlobe.prototype.getAutoRotateFactor = function (dt) { | |||
var motion = this.autoResumeMotion; | |||
var t; | |||
if (!motion) return 1; | |||
motion.elapsed += dt; | |||
t = clamp01(motion.elapsed / motion.duration); | |||
/* | |||
* Speedometer-style ramp: no hidden threshold, no late kick. | |||
* The factor rises almost linearly from a very slow crawl to normal speed, | |||
* so the last frame before completion is already nearly identical to 1.0. | |||
*/ | |||
if (t >= 1) { | |||
this.autoResumeMotion = null; | |||
return 1; | |||
} | |||
return motion.from + (motion.to - motion.from) * t; | |||
}; | |||
NationsGlobe.prototype.startResetMotion = function () { | |||
var toY; | |||
this.velocityX = 0; | |||
this.velocityY = 0; | |||
this.dragging = false; | |||
this.autoResumeMotion = null; | |||
this.autoRotate = this.resetAutoRotate; | |||
this.syncAutoButton(); | |||
this.root.classList.remove('is-dragging'); | |||
/* | |||
* RESET restores the exact first-load state. | |||
* The earth still takes the nearest visual path back, but the sky drift is | |||
* compensated during the animation so the celestial background lands on the | |||
* same phase it had when the globe first opened. | |||
*/ | |||
toY = nearestEquivalentAngle(this.rotationY, this.resetRotationY); | |||
this.resetMotion = { | |||
elapsed: 0, | |||
duration: Math.max(120, this.resetDuration), | |||
fromX: this.rotationX, | |||
fromY: this.rotationY, | |||
fromZ: this.cameraZ, | |||
fromStarDriftY: this.starDriftY, | |||
fromCelestialDriftY: this.celestialDriftY, | |||
toX: this.resetRotationX, | |||
toY: toY, | |||
finalY: this.resetRotationY, | |||
toZ: this.resetCameraZ, | |||
toStarDriftY: (toY - this.resetRotationY) * this.starParallax, | |||
toCelestialDriftY: (toY - this.resetRotationY) * this.celestialParallax | |||
}; | }; | ||
}; | |||
NationsGlobe.prototype.stepResetMotion = function (dt) { | |||
var motion = this.resetMotion; | |||
var t; | |||
var k; | |||
if (!motion) return false; | |||
motion.elapsed += dt; | |||
t = clamp01(motion.elapsed / motion.duration); | |||
k = easeOutCubic(t); | |||
this.rotationX = motion.fromX + (motion.toX - motion.fromX) * k; | |||
this.rotationY = motion.fromY + (motion.toY - motion.fromY) * k; | |||
this.cameraZ = motion.fromZ + (motion.toZ - motion.fromZ) * k; | |||
this.starDriftY = motion.fromStarDriftY + (motion.toStarDriftY - motion.fromStarDriftY) * k; | |||
this.celestialDriftY = motion.fromCelestialDriftY + (motion.toCelestialDriftY - motion.fromCelestialDriftY) * k; | |||
if (this.camera) this.camera.position.z = this.cameraZ; | |||
this.updateCloudOpacity(); | |||
this.updateMoonOpacity(dt); | |||
this.applyRotation(); | |||
if (t >= 1) { | |||
this.rotationX = motion.toX; | |||
this.rotationY = motion.finalY; | |||
this.cameraZ = motion.toZ; | |||
this.starDriftY = 0; | |||
this.celestialDriftY = 0; | |||
if (this.camera) this.camera.position.z = this.cameraZ; | |||
this.updateCloudOpacity(); | |||
this.applyRotation(); | |||
this.resetMotion = null; | |||
if (this.autoRotate) this.startAutoResumeMotion(); | |||
} | |||
return true; | |||
}; | |||
NationsGlobe.prototype.applyRotation = function () { | |||
if (!this.group) return; | |||
this.rotationX = Math.max(-1.15, Math.min(1.15, this.rotationX)); | |||
this.group.rotation.x = this.rotationX; | |||
this.group.rotation.y = this.rotationY; | |||
}; | |||
NationsGlobe.prototype.resize = function () { | |||
if (!this.renderer || !this.camera || !this.stage) return; | |||
var rect = this.stage.getBoundingClientRect(); | |||
var width = Math.max(320, Math.floor(rect.width || 0)); | |||
var height = Math.max(240, Math.floor(rect.height || 0)); | |||
if (!width || !height) return; | |||
this.camera.aspect = width / height; | |||
this.camera.updateProjectionMatrix(); | |||
this.renderer.setSize(width, height, false); | |||
}; | |||
NationsGlobe.prototype.syncAutoButton = function () { | |||
if (this.autoButton) { | |||
this.autoButton.classList.toggle('is-active', this.autoRotate); | |||
} | |||
}; | |||
NationsGlobe.prototype.bindControlEvents = function () { | |||
var self = this; | |||
if (this.autoButton && this.autoButton.getAttribute('data-clbi-globe-control-ready') !== '1') { | |||
this.autoButton.setAttribute('data-clbi-globe-control-ready', '1'); | |||
this.autoButton.addEventListener('click', function (e) { | |||
self.cancelResetMotion(); | |||
self.autoResumeMotion = null; | |||
self.autoRotate = !self.autoRotate; | |||
self.velocityX = 0; | |||
self.velocityY = 0; | |||
self.syncAutoButton(); | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
}); | |||
} | |||
if ( | if (this.resetButton && this.resetButton.getAttribute('data-clbi-globe-control-ready') !== '1') { | ||
this.resetButton.setAttribute('data-clbi-globe-control-ready', '1'); | |||
this.resetButton.addEventListener('click', function (e) { | |||
self.startResetMotion(); | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
}); | }); | ||
} | |||
}; | |||
NationsGlobe.prototype.trackPointerPosition = function (e) { | |||
if (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return; | |||
this.pointerInside = true; | |||
this.pointerClientX = e.clientX; | |||
this.pointerClientY = e.clientY; | |||
}; | |||
NationsGlobe.prototype.clearPointerPosition = function () { | |||
this.pointerInside = false; | |||
this.pointerClientX = null; | |||
this.pointerClientY = null; | |||
this.clearNationHover(); | |||
if (this.readout) { | |||
this.setReadout(this.getBaseReadout()); | |||
} | |||
}; | |||
NationsGlobe.prototype.getBaseReadout = function () { | |||
return (this.root.getAttribute('data-era') || 'REAL WORLD / 110M') + ' · SEG ' + this.segmentCount; | |||
}; | |||
NationsGlobe.prototype.bindEvents = function () { | |||
var self = this; | |||
if (this.stageEventsBound) { | |||
this.bindControlEvents(); | |||
return; | |||
} | |||
this.stageEventsBound = true; | |||
this.stage.addEventListener('pointerenter', function (e) { | |||
self.trackPointerPosition(e); | |||
self.updatePointerReadout(e); | |||
}); | |||
this.stage.addEventListener('pointerleave', function () { | |||
if (!self.dragging) { | |||
self.clearPointerPosition(); | |||
} | |||
}); | |||
this.stage.addEventListener('pointerdown', function (e) { | |||
self.trackPointerPosition(e); | |||
self.updatePointerReadout(e); | |||
self.cancelResetMotion(); | |||
self.dragging = true; | |||
self.pointerDownX = e.clientX; | |||
self.pointerDownY = e.clientY; | |||
self.pointerDownNationPage = self.currentNation ? self.currentNation.page : null; | |||
self.lastX = e.clientX; | |||
self.lastY = e.clientY; | |||
self.lastMoveTime = e.timeStamp || performance.now(); | |||
self.velocityX = 0; | |||
self.velocityY = 0; | |||
self.root.classList.add('is-dragging'); | |||
self.autoResumeMotion = null; | |||
self.autoRotate = false; | |||
self.syncAutoButton(); | |||
if (self.stage.setPointerCapture && e.pointerId != null) { | |||
try { self.stage.setPointerCapture(e.pointerId); } catch (err) {} | |||
} | |||
e.preventDefault(); | |||
}); | |||
this.stage.addEventListener('pointermove', function (e) { | |||
self.trackPointerPosition(e); | |||
if (self.dragging) { | |||
var dx = e.clientX - self.lastX; | |||
var dy = e.clientY - self.lastY; | |||
var now = e.timeStamp || performance.now(); | |||
var elapsed = Math.max(8, Math.min(80, now - (self.lastMoveTime || now || performance.now()))); | |||
var stepY = dx * self.dragSensitivityY; | |||
var stepX = dy * self.dragSensitivityX; | |||
var instantY = (stepY / elapsed) * self.inertiaStrength; | |||
var instantX = (stepX / elapsed) * self.inertiaStrength; | |||
self.rotationY += stepY; | |||
self.rotationX += stepX; | |||
self.velocityY = self.velocityY * 0.58 + instantY * 0.42; | |||
self.velocityX = self.velocityX * 0.58 + instantX * 0.42; | |||
self.lastX = e.clientX; | |||
self.lastY = e.clientY; | |||
self.lastMoveTime = now; | |||
self.applyRotation(); | |||
self.updatePointerReadout(e); | |||
e.preventDefault(); | |||
} else { | |||
self.updatePointerReadout(e); | |||
} | |||
}); | |||
function finishDrag(e) { | |||
var now; | |||
var releaseDelay; | |||
var releaseSpeed; | |||
if (!self.dragging) return; | |||
now = e && e.timeStamp ? e.timeStamp : performance.now(); | |||
releaseDelay = now - (self.lastMoveTime || now); | |||
releaseSpeed = Math.max(Math.abs(self.velocityX), Math.abs(self.velocityY)); | |||
/* | |||
* Only a deliberate throw should keep spinning. | |||
* If the user drags, holds, adjusts the viewing angle, and then releases, | |||
* the last movement is old or slow, so the globe must settle immediately. | |||
*/ | |||
if (releaseDelay > self.inertiaReleaseWindow || releaseSpeed < self.inertiaMinVelocity) { | |||
self.velocityX = 0; | |||
self.velocityY = 0; | |||
} | |||
if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') { | |||
self.trackPointerPosition(e); | |||
self.updatePointerReadout(e); | |||
} | |||
self.dragging = false; | |||
self.root.classList.remove('is-dragging'); | |||
if (self.stage.releasePointerCapture && e && e.pointerId != null) { | |||
try { self.stage.releasePointerCapture(e.pointerId); } catch (err) {} | |||
} | |||
self.handleNationClick(e); | |||
} | |||
this.stage.addEventListener('pointerup', finishDrag); | |||
this.stage.addEventListener('pointercancel', finishDrag); | |||
this.stage.addEventListener('wheel', function (e) { | |||
self.trackPointerPosition(e); | |||
self.cancelResetMotion(); | |||
self.velocityX = 0; | |||
self.velocityY = 0; | |||
self.cameraZ += e.deltaY * 0.0017; | |||
self.cameraZ = Math.max(self.cameraZMin, Math.min(self.cameraZMax, self.cameraZ)); | |||
if (self.camera) self.camera.position.z = self.cameraZ; | |||
self.updateCloudOpacity(); | |||
self.updateMoonOpacity(); | |||
self.updatePointerReadout(e); | |||
e.preventDefault(); | |||
}, { passive: false }); | |||
this.bindControlEvents(); | |||
window.addEventListener('resize', function () { | |||
self.resize(); | |||
}); | |||
}; | |||
NationsGlobe.prototype.updatePointerReadout = function (e) { | |||
if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') { | |||
this.trackPointerPosition(e); | |||
} | |||
if (this.pointerClientX === null || this.pointerClientY === null) return false; | |||
return this.updatePointerReadoutFromClient(this.pointerClientX, this.pointerClientY); | |||
}; | |||
NationsGlobe.prototype.updatePointerReadoutFromClient = function (clientX, clientY) { | |||
var THREE = this.THREE; | |||
var rect; | |||
var intersects; | |||
var local; | |||
var ll; | |||
if (!this.raycaster || !this.pointer || !this.camera || !this.earth || !this.renderer) return false; | |||
rect = this.renderer.domElement.getBoundingClientRect(); | |||
if (!rect.width || !rect.height) return false; | |||
if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) { | |||
this.setReadout(this.getBaseReadout()); | |||
return false; | |||
} | |||
this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1; | |||
this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1; | |||
this.raycaster.setFromCamera(this.pointer, this.camera); | |||
intersects = this.raycaster.intersectObject(this.earth, false); | |||
if (intersects && intersects.length) { | |||
local = this.earth.worldToLocal(intersects[0].point.clone()); | |||
ll = vectorToLonLat(local); | |||
this.updateNationHover(ll, clientX, clientY); | |||
if (this.currentNation) { | |||
this.setReadout(this.getNationLabel(this.currentNation.page) + ' · LAT ' + ll.lat.toFixed(2) + ' / LON ' + ll.lon.toFixed(2) + ' · SEG ' + this.segmentCount); | |||
} else { | |||
this.setReadout('LAT ' + ll.lat.toFixed(2) + ' / LON ' + ll.lon.toFixed(2) + ' · SEG ' + this.segmentCount); | |||
} | |||
return true; | |||
} | |||
this.clearNationHover(); | |||
this.setReadout(this.getBaseReadout()); | |||
return false; | |||
}; | |||
NationsGlobe.prototype.loadNationInteractionData = function () { | |||
var self = this; | |||
var tasks = []; | |||
if (this.nationHitUrl) { | |||
tasks.push( | |||
fetch(this.nationHitUrl, { credentials: 'same-origin' }) | |||
.then(function (res) { | |||
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + self.nationHitUrl); | |||
return res.json(); | |||
}) | |||
.then(function (payload) { | |||
var features = payload && payload.features ? payload.features : []; | |||
self.nationHitFeatures = features | |||
.filter(function (feature) { | |||
return getFeaturePage(feature) && feature.geometry; | |||
}) | |||
.map(function (feature) { | |||
var bbox = feature.bbox || calculateGeometryBBox(feature.geometry); | |||
return { | |||
page: getFeaturePage(feature), | |||
bbox: bbox, | |||
bboxArea: Math.max(0, (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])), | |||
geometry: feature.geometry | |||
}; | |||
}) | |||
.sort(function (a, b) { | |||
return a.bboxArea - b.bboxArea; | |||
}); | |||
if (window.console && console.info) { | |||
console.info('1950 nation hit data applied:', { | |||
url: self.nationHitUrl, | |||
features: self.nationHitFeatures.length | |||
}); | |||
} | |||
}) | |||
.catch(function (err) { | |||
self.nationHitFeatures = []; | |||
if (window.console && console.warn) console.warn('1950 nation hit data failed:', err); | |||
}) | |||
); | |||
} | |||
if (this.nationLinkMapUrl) { | |||
tasks.push( | |||
fetch(this.nationLinkMapUrl, { credentials: 'same-origin', cache: 'no-cache' }) | |||
.then(function (res) { | |||
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + self.nationLinkMapUrl); | |||
return res.json(); | |||
}) | |||
.then(function (payload) { | |||
self.nationLinkMap = normalizeNationLinkMap(payload); | |||
if (window.console && console.info) { | |||
console.info('1950 nation link map applied:', { | |||
url: self.nationLinkMapUrl, | |||
items: Object.keys(self.nationLinkMap).length | |||
}); | |||
} | |||
}) | |||
.catch(function (err) { | |||
self.nationLinkMap = {}; | |||
if (window.console && console.warn) console.warn('1950 nation link map failed:', err); | |||
}) | |||
); | |||
} | |||
if (!tasks.length) return Promise.resolve(); | |||
return Promise.all(tasks).then(function () {}); | |||
}; | |||
NationsGlobe.prototype.findNationAtLonLat = function (ll) { | |||
var lon = ll && Number(ll.lon); | |||
var lat = ll && Number(ll.lat); | |||
var i; | |||
var feature; | |||
if (!Number.isFinite(lon) || !Number.isFinite(lat) || !this.nationHitFeatures.length) return null; | |||
for (i = 0; i < this.nationHitFeatures.length; i += 1) { | |||
feature = this.nationHitFeatures[i]; | |||
if (!isLonLatInsideBbox(lon, lat, feature.bbox)) continue; | |||
if (pointInGeometry(lon, lat, feature.geometry)) { | |||
return { | |||
page: feature.page, | |||
feature: feature | |||
}; | |||
} | |||
} | |||
return null; | |||
}; | |||
NationsGlobe.prototype.getNationEntry = function (page) { | |||
return page && this.nationLinkMap ? this.nationLinkMap[page] || null : null; | |||
}; | |||
NationsGlobe.prototype.getNationLabel = function (page) { | |||
var entry = this.getNationEntry(page); | |||
return (entry && (entry.name_ko || entry.name || entry.wiki_title)) || page || ''; | |||
}; | |||
NationsGlobe.prototype.getNationUrl = function (page) { | |||
var entry = this.getNationEntry(page); | |||
var title; | |||
if (!entry || entry.enabled === false) return ''; | |||
title = entry.wiki_title || entry.title || ''; | |||
if (!title) return ''; | |||
if (/^https?:\/\//i.test(title)) return title; | |||
return this.nationLinkBase + encodeWikiTitle(title); | |||
}; | |||
NationsGlobe.prototype.positionNationTooltip = function (clientX, clientY) { | |||
var rootRect; | |||
var tooltipRect; | |||
var left; | |||
var top; | |||
if (!this.nationTooltip) return; | |||
rootRect = this.root.getBoundingClientRect(); | |||
left = clientX - rootRect.left + 14; | |||
top = clientY - rootRect.top + 14; | |||
this.nationTooltip.style.left = left + 'px'; | |||
this.nationTooltip.style.top = top + 'px'; | |||
tooltipRect = this.nationTooltip.getBoundingClientRect(); | |||
if (tooltipRect.right > rootRect.right - 6) { | |||
left = Math.max(6, clientX - rootRect.left - tooltipRect.width - 12); | |||
} | |||
if (tooltipRect.bottom > rootRect.bottom - 6) { | |||
top = Math.max(6, clientY - rootRect.top - tooltipRect.height - 12); | |||
} | } | ||
if ( | this.nationTooltip.style.left = left + 'px'; | ||
this.nationTooltip.style.top = top + 'px'; | |||
}; | |||
NationsGlobe.prototype.updateNationHover = function (ll, clientX, clientY) { | |||
var hit = this.findNationAtLonLat(ll); | |||
var page = hit ? hit.page : ''; | |||
var label; | |||
var url; | |||
if (!hit || !page) { | |||
this.clearNationHover(); | |||
return null; | |||
} | |||
this.currentNation = { | |||
page: page, | |||
entry: this.getNationEntry(page), | |||
lon: ll.lon, | |||
lat: ll.lat | |||
}; | |||
label = this.getNationLabel(page); | |||
url = this.getNationUrl(page); | |||
this.root.classList.add('has-nation-hover'); | |||
if (this.nationTooltip) { | |||
this.nationTooltip.innerHTML = '<span class="nation-tooltip-name">' + escapeHtml(label) + '</span>' + | |||
'<span class="nation-tooltip-code">' + escapeHtml(page) + '</span>' + | |||
(url ? '<span class="nation-tooltip-link">클릭하여 문서 열기 ›</span>' : '<span class="nation-tooltip-link">링크 미지정</span>'); | |||
this.nationTooltip.classList.add('is-visible'); | |||
this.nationTooltip.setAttribute('aria-hidden', 'false'); | |||
this.positionNationTooltip(clientX, clientY); | |||
} | |||
return this.currentNation; | |||
}; | |||
NationsGlobe.prototype.clearNationHover = function () { | |||
this.currentNation = null; | |||
this.root.classList.remove('has-nation-hover'); | |||
if (this.nationTooltip) { | |||
this.nationTooltip.classList.remove('is-visible'); | |||
this.nationTooltip.setAttribute('aria-hidden', 'true'); | |||
} | |||
}; | |||
NationsGlobe.prototype.handleNationClick = function (e) { | |||
var dx; | |||
var dy; | |||
var moved; | |||
var page; | |||
var url; | |||
if (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return false; | |||
if (this.pointerDownX === null || this.pointerDownY === null) return false; | |||
dx = e.clientX - this.pointerDownX; | |||
dy = e.clientY - this.pointerDownY; | |||
moved = Math.sqrt(dx * dx + dy * dy); | |||
page = this.currentNation ? this.currentNation.page : this.pointerDownNationPage; | |||
this.pointerDownX = null; | |||
this.pointerDownY = null; | |||
this.pointerDownNationPage = null; | |||
if (moved > this.clickMoveTolerance || !page) return false; | |||
url = this.getNationUrl(page); | |||
if (!url) return false; | |||
if (e.ctrlKey || e.metaKey || e.shiftKey) { | |||
window.open(url, '_blank', 'noopener'); | |||
} else { | } else { | ||
window.location.href = url; | |||
} | } | ||
})(); | |||
return true; | |||
}; | |||
NationsGlobe.prototype.loadBoundaryData = function () { | |||
var self = this; | |||
var THREE = this.THREE; | |||
var url = this.root.getAttribute('data-border-geojson-url') || this.root.getAttribute('data-topology-url') || this.root.getAttribute('data-geojson-url') || DEFAULT_WORLD_URL; | |||
var objectName = this.root.getAttribute('data-topology-object') || 'countries'; | |||
this.setStatus('DATA'); | |||
return fetch(url, { credentials: 'omit' }) | |||
.then(function (res) { | |||
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url); | |||
return res.json(); | |||
}) | |||
.then(function (payload) { | |||
var geojson = extractGeojsonFromPayload(payload, self.topojson, objectName); | |||
var built = buildLineGeometryFromGeojson(THREE, geojson, self.radius + Math.max(0.0008, Math.min(0.006, self.boundarySurfaceOffset))); | |||
var material = new THREE.LineBasicMaterial({ | |||
color: 0xb8b8b8, | |||
transparent: true, | |||
opacity: 0.58, | |||
depthWrite: false | |||
}); | |||
var lines = new THREE.LineSegments(built.geometry, material); | |||
lines.renderOrder = 8; | |||
self.segmentCount = Math.floor(built.segmentCount || 0); | |||
self.group.add(lines); | |||
if (self.pointerInside && self.pointerClientX !== null && self.pointerClientY !== null) { | |||
self.updatePointerReadoutFromClient(self.pointerClientX, self.pointerClientY); | |||
} else { | |||
self.setReadout(self.getBaseReadout()); | |||
} | |||
}); | |||
}; | |||
NationsGlobe.prototype.animate = function (now) { | |||
var self = this; | |||
var dt = Math.min(50, now - this.lastTime); | |||
this.lastTime = now; | |||
if (this.disposed || !document.body.contains(this.root)) { | |||
this.dispose(); | |||
return; | |||
} | |||
if (this.stepResetMotion(dt)) { | |||
/* reset interpolation owns rotation/camera for this frame */ | |||
} else if (this.autoRotate && !this.dragging) { | |||
this.rotationY += dt * this.autoRotateSpeed * this.getAutoRotateFactor(dt); | |||
this.applyRotation(); | |||
} else if (!this.dragging && (Math.abs(this.velocityX) > 0.000001 || Math.abs(this.velocityY) > 0.000001)) { | |||
var decay = Math.pow(this.inertiaDamping, dt / 16.6667); | |||
this.rotationY += this.velocityY * dt; | |||
this.rotationX += this.velocityX * dt; | |||
this.velocityY *= decay; | |||
this.velocityX *= decay; | |||
if (Math.abs(this.velocityY) < 0.000001) this.velocityY = 0; | |||
if (Math.abs(this.velocityX) < 0.000001) this.velocityX = 0; | |||
this.applyRotation(); | |||
} | |||
this.updateCelestialBackdrop(dt, now); | |||
if (this.clouds) { | |||
/* | |||
* Cloud motion is relative to the rotating earth group. | |||
* The parent group carries the globe's AUTO rotation; this local drift | |||
* only represents slow atmospheric flow over that rotating planet. | |||
*/ | |||
this.clouds.rotation.y += dt * this.cloudDriftSpeed; | |||
this.clouds.rotation.x = Math.sin(now * 0.000018) * this.cloudTiltDrift; | |||
this.updateCloudOpacity(); | |||
} | |||
this.resize(); | |||
if (this.pointerInside && this.pointerClientX !== null && this.pointerClientY !== null) { | |||
this.updatePointerReadoutFromClient(this.pointerClientX, this.pointerClientY); | |||
} | |||
if (this.renderer && this.scene && this.camera) { | |||
this.renderer.render(this.scene, this.camera); | |||
} | |||
this.frame = window.requestAnimationFrame(function (t) { | |||
self.animate(t); | |||
}); | |||
}; | |||
NationsGlobe.prototype.dispose = function () { | |||
this.disposed = true; | |||
if (this.frame) window.cancelAnimationFrame(this.frame); | |||
if (this.renderer) { | |||
try { this.renderer.dispose(); } catch (err) {} | |||
} | |||
}; | |||
function initNationsGlobes(root) { | |||
var scope = root && root.querySelectorAll ? root : document; | |||
var nodes = scope.querySelectorAll('[data-nations-globe], .clbi-nations-globe-window'); | |||
Array.prototype.forEach.call(nodes, function (node) { | |||
var instance = node.CLBI_NationsGlobeInstance || null; | |||
var hasStage = !!node.querySelector('.clbi-nations-globe-stage'); | |||
if (node.getAttribute('data-nations-globe-ready') === '1') { | |||
if (instance && hasStage) { | |||
instance.rehydrateDom(); | |||
return; | |||
} | |||
if (instance && !hasStage) { | |||
try { instance.dispose(); } catch (err) {} | |||
} | |||
node.removeAttribute('data-nations-globe-ready'); | |||
node.CLBI_NationsGlobeInstance = null; | |||
} | |||
node.setAttribute('data-nations-globe-ready', '1'); | |||
instance = new NationsGlobe(node); | |||
node.CLBI_NationsGlobeInstance = instance; | |||
instances.push(instance); | |||
instance.init(); | |||
}); | |||
} | |||
function scheduleNationsGlobeRefresh() { | |||
window.setTimeout(function () { initNationsGlobes(document); }, 0); | |||
window.setTimeout(function () { initNationsGlobes(document); }, 120); | |||
window.setTimeout(function () { initNationsGlobes(document); }, 360); | |||
} | |||
window.CLBI_NationsGlobe = { | |||
init: initNationsGlobes, | |||
instances: instances, | |||
defaultWorldUrl: DEFAULT_WORLD_URL, | |||
reload: function () { | |||
initNationsGlobes(document); | |||
} | |||
}; | |||
$(function () { | |||
initNationsGlobes(document); | |||
}); | |||
if (mw && mw.hook) { | |||
mw.hook('wikipage.content').add(function ($content) { | |||
initNationsGlobes($content && $content[0] ? $content[0] : document); | |||
scheduleNationsGlobeRefresh(); | |||
}); | |||
} | |||
window.addEventListener('pageshow', scheduleNationsGlobeRefresh); | |||
window.addEventListener('focus', scheduleNationsGlobeRefresh); | |||
document.addEventListener('visibilitychange', function () { | |||
if (!document.hidden) scheduleNationsGlobeRefresh(); | |||
}); | |||
})(window.mediaWiki || window.mw, window.jQuery); | |||
2026년 7월 4일 (토) 21:49 판
/* =========================================
COASTLINE: BLACK ICE - NationsGlobe
Real-world prototype / QGIS-ready globe viewer
========================================= */
(function (mw, $) {
'use strict';
var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
var TOPOJSON_URL = 'https://cdn.jsdelivr.net/npm/topojson-client@3/+esm';
var DEFAULT_WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
var CELESTIAL_PLANET_TEXTURES = {
mercury: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_mercury.jpg',
venus: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_venus_surface.jpg',
mars: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_mars.jpg',
jupiter: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_jupiter.jpg',
saturn: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_saturn.jpg',
uranus: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_uranus.jpg',
neptune: 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_neptune.jpg'
};
var CELESTIAL_SUN_TEXTURE_URL = 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_sun.jpg';
var CELESTIAL_MOON_TEXTURE_URL = 'https://images.weserv.nl/?w=512&url=https://www.solarsystemscope.com/textures/download/2k_moon.jpg';
var SATURN_RING_TEXTURE_URL = 'https://upload.wikimedia.org/wikipedia/commons/2/29/Solarsystemscope_texture_8k_saturn_ring_alpha.png';
/*
* Decorative celestial model.
* This is not a precise ephemeris. It keeps the planets on one distant
* ecliptic plane so they read as real 3D bodies instead of screen-composed PNGs.
*/
var CELESTIAL_ECLIPTIC = {
depth: -22.0,
tiltYPerX: 0.018,
depthScale: 0.34
};
var CELESTIAL_PLANET_DEFS = [
{
key: 'mercury',
radius: 0.052,
orbitRadius: 4.15,
orbitAngle: 2.03,
inclination: -0.018,
opacity: 0.36,
rotationSpeed: 0.000056,
fallbackA: '#7f786d',
fallbackB: '#24211d'
},
{
key: 'venus',
radius: 0.086,
orbitRadius: 4.65,
orbitAngle: 2.96,
inclination: 0.024,
opacity: 0.36,
rotationSpeed: 0.000022,
fallbackA: '#b99765',
fallbackB: '#4f341a'
},
{
key: 'mars',
radius: 0.105,
orbitRadius: 5.25,
orbitAngle: 3.84,
inclination: 0.032,
opacity: 0.42,
rotationSpeed: 0.000040,
fallbackA: '#8f4938',
fallbackB: '#3a1712'
},
{
key: 'jupiter',
radius: 0.235,
orbitRadius: 8.20,
orbitAngle: 5.64,
inclination: 0.023,
opacity: 0.34,
rotationSpeed: 0.000024,
fallbackA: '#a98262',
fallbackB: '#4c3628'
},
{
key: 'saturn',
radius: 0.225,
orbitRadius: 9.15,
orbitAngle: 4.76,
inclination: -0.018,
opacity: 0.44,
rotationSpeed: 0.000022,
fallbackA: '#b89a70',
fallbackB: '#4d3724',
ring: true,
ringOpacity: 0.86,
ringInnerScale: 1.34,
ringOuterScale: 2.58,
ringTiltX: 1.08,
ringTiltY: 0.18,
ringTiltZ: -0.34,
ringTextureUrl: SATURN_RING_TEXTURE_URL
},
{
key: 'uranus',
radius: 0.116,
orbitRadius: 10.70,
orbitAngle: 1.43,
inclination: -0.026,
opacity: 0.29,
rotationSpeed: 0.000030,
fallbackA: '#7fb5bd',
fallbackB: '#24424b'
},
{
key: 'neptune',
radius: 0.100,
orbitRadius: 11.75,
orbitAngle: 0.66,
inclination: 0.031,
opacity: 0.28,
rotationSpeed: 0.000032,
fallbackA: '#43658f',
fallbackB: '#151d35'
}
];
var CELESTIAL_SUN_DEF = {
radius: 0.35,
orbitRadius: 12.4,
orbitAngle: 2.72,
inclination: 0.000,
color: 0xffddb4,
coreOpacity: 0.90,
innerGlowOpacity: 0.74,
innerGlowScale: 4.9,
outerGlowOpacity: 0.24,
outerGlowScale: 8.6,
pulseA: 0.010,
pulseB: 0.014,
textureScrollSpeed: 0.000014
};
var CELESTIAL_MOON_DEF = {
radius: 0.145,
position: [1.72, 0.28, -0.62],
opacity: 0.72,
rotationSpeed: 0.000018,
fallbackA: '#8f8f8f',
fallbackB: '#343434'
};
var instances = [];
var threeModulePromise = null;
var topoModulePromise = null;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function loadThree() {
if (window.CLBI_NATIONS_GLOBE_THREE) {
return Promise.resolve(window.CLBI_NATIONS_GLOBE_THREE);
}
if (!threeModulePromise) {
threeModulePromise = import(THREE_URL).then(function (module) {
window.CLBI_NATIONS_GLOBE_THREE = module;
return module;
});
}
return threeModulePromise;
}
function loadTopojson() {
if (window.CLBI_NATIONS_GLOBE_TOPOJSON) {
return Promise.resolve(window.CLBI_NATIONS_GLOBE_TOPOJSON);
}
if (!topoModulePromise) {
topoModulePromise = import(TOPOJSON_URL).then(function (module) {
window.CLBI_NATIONS_GLOBE_TOPOJSON = module;
return module;
});
}
return topoModulePromise;
}
function toNumber(value, fallback) {
var n = parseFloat(value);
return Number.isFinite(n) ? n : fallback;
}
function lonLatToVector3(THREE, lon, lat, radius) {
var phi = (90 - lat) * Math.PI / 180;
var theta = (lon + 180) * Math.PI / 180;
var sinPhi = Math.sin(phi);
return new THREE.Vector3(
-radius * sinPhi * Math.cos(theta),
radius * Math.cos(phi),
radius * sinPhi * Math.sin(theta)
);
}
function vectorToLonLat(vec) {
var n = vec.clone().normalize();
var lat = Math.asin(Math.max(-1, Math.min(1, n.y))) * 180 / Math.PI;
var lon = Math.atan2(n.z, -n.x) * 180 / Math.PI - 180;
while (lon < -180) lon += 360;
while (lon > 180) lon -= 360;
return {
lon: lon,
lat: lat
};
}
function ensureNationsGlobeInteractionStyle() {
if (document.getElementById('nation-globe-interaction-style')) return;
var style = document.createElement('style');
style.id = 'nation-globe-interaction-style';
style.textContent = [
'.clbi-nations-globe-window.has-nation-hover .clbi-nations-globe-stage{cursor:pointer;}',
'.clbi-nations-globe-window.is-dragging .clbi-nations-globe-stage{cursor:grabbing;}',
'.clbi-nations-globe-nation-tooltip{position:absolute;z-index:8;min-width:72px;max-width:220px;pointer-events:none;display:none;padding:4px 7px;background:rgba(5,5,5,.92);border:1px solid #000;color:#e2e2e2;font-size:11px;line-height:1.25;box-shadow:inset 0 1px 0 #333,inset 0 -1px 0 #111,0 4px 12px rgba(0,0,0,.45);white-space:nowrap;}',
'.clbi-nations-globe-nation-tooltip.is-visible{display:block;}',
'.clbi-nations-globe-nation-tooltip .nation-tooltip-name{font-weight:700;letter-spacing:.02em;}',
'.clbi-nations-globe-nation-tooltip .nation-tooltip-code{margin-left:6px;color:#888;font-size:10px;font-weight:500;}',
'.clbi-nations-globe-nation-tooltip .nation-tooltip-link{display:block;margin-top:2px;color:#a8a8a8;font-size:10px;}'
].join('\n');
document.head.appendChild(style);
}
function getFeaturePage(feature) {
return feature && feature.properties ? String(feature.properties.page || '').trim() : '';
}
function calculateGeometryBBox(geometry) {
var bbox = [Infinity, Infinity, -Infinity, -Infinity];
function visit(coords) {
if (!Array.isArray(coords)) return;
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
bbox[0] = Math.min(bbox[0], coords[0]);
bbox[1] = Math.min(bbox[1], coords[1]);
bbox[2] = Math.max(bbox[2], coords[0]);
bbox[3] = Math.max(bbox[3], coords[1]);
return;
}
coords.forEach(visit);
}
if (geometry) visit(geometry.coordinates);
if (!Number.isFinite(bbox[0])) return [-180, -90, 180, 90];
return bbox;
}
function isLonLatInsideBbox(lon, lat, bbox) {
if (!bbox || bbox.length < 4) return true;
if (lat < bbox[1] || lat > bbox[3]) return false;
if ((bbox[2] - bbox[0]) >= 300) return true;
return lon >= bbox[0] && lon <= bbox[2];
}
function unwrapLonNear(lon, refLon) {
var x = lon;
while (x - refLon > 180) x -= 360;
while (x - refLon < -180) x += 360;
return x;
}
function pointInRing(lon, lat, ring) {
var inside = false;
var i;
var j;
var xi;
var yi;
var xj;
var yj;
var intersect;
if (!Array.isArray(ring) || ring.length < 3) return false;
for (i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
if (!ring[i] || !ring[j]) continue;
xi = unwrapLonNear(Number(ring[i][0]), lon);
yi = Number(ring[i][1]);
xj = unwrapLonNear(Number(ring[j][0]), lon);
yj = Number(ring[j][1]);
if (!Number.isFinite(xi) || !Number.isFinite(yi) || !Number.isFinite(xj) || !Number.isFinite(yj)) continue;
intersect = ((yi > lat) !== (yj > lat)) && (lon < (xj - xi) * (lat - yi) / ((yj - yi) || 1e-12) + xi);
if (intersect) inside = !inside;
}
return inside;
}
function pointInPolygonCoordinates(lon, lat, polygon) {
var i;
if (!Array.isArray(polygon) || !polygon.length) return false;
if (!pointInRing(lon, lat, polygon[0])) return false;
for (i = 1; i < polygon.length; i += 1) {
if (pointInRing(lon, lat, polygon[i])) return false;
}
return true;
}
function pointInGeometry(lon, lat, geometry) {
var i;
if (!geometry) return false;
if (geometry.type === 'Polygon') {
return pointInPolygonCoordinates(lon, lat, geometry.coordinates);
}
if (geometry.type === 'MultiPolygon') {
for (i = 0; i < geometry.coordinates.length; i += 1) {
if (pointInPolygonCoordinates(lon, lat, geometry.coordinates[i])) return true;
}
}
return false;
}
function normalizeNationLinkMap(payload) {
var source = payload && payload.items ? payload.items : (payload || {});
var map = {};
Object.keys(source).forEach(function (key) {
var entry = source[key];
var page = String((entry && entry.page) || key || '').trim();
if (!page) return;
if (typeof entry === 'string') {
map[page] = {
page: page,
name_ko: entry,
wiki_title: entry,
enabled: true
};
} else {
map[page] = Object.assign({
page: page,
name_ko: '',
wiki_title: '',
enabled: true,
note: ''
}, entry || {}, { page: page });
}
});
return map;
}
function encodeWikiTitle(title) {
return String(title || '')
.trim()
.replace(/ /g, '_')
.split('/')
.map(function (part) { return encodeURIComponent(part); })
.join('/');
}
function normalizeLonDelta(a, b) {
var d = b - a;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
}
function pushLineSegment(THREE, positions, a, b, radius) {
if (!a || !b || a.length < 2 || b.length < 2) return;
var lonA = toNumber(a[0], 0);
var latA = toNumber(a[1], 0);
var lonB = toNumber(b[0], 0);
var latB = toNumber(b[1], 0);
var dLon = normalizeLonDelta(lonA, lonB);
var dLat = latB - latA;
var steps = Math.max(1, Math.ceil(Math.max(Math.abs(dLon), Math.abs(dLat)) / 3));
var i;
for (i = 0; i < steps; i += 1) {
var t0 = i / steps;
var t1 = (i + 1) / steps;
var p0 = lonLatToVector3(THREE, lonA + dLon * t0, latA + dLat * t0, radius);
var p1 = lonLatToVector3(THREE, lonA + dLon * t1, latA + dLat * t1, radius);
positions.push(p0.x, p0.y, p0.z, p1.x, p1.y, p1.z);
}
}
function pushLineString(THREE, positions, coords, radius) {
var i;
if (!Array.isArray(coords) || coords.length < 2) return;
for (i = 0; i < coords.length - 1; i += 1) {
pushLineSegment(THREE, positions, coords[i], coords[i + 1], radius);
}
}
function pushPolygonRings(THREE, positions, rings, radius) {
if (!Array.isArray(rings)) return;
rings.forEach(function (ring) {
pushLineString(THREE, positions, ring, radius);
});
}
function pushGeometryLines(THREE, positions, geometry, radius) {
if (!geometry) return;
if (geometry.type === 'LineString') {
pushLineString(THREE, positions, geometry.coordinates, radius);
return;
}
if (geometry.type === 'MultiLineString') {
geometry.coordinates.forEach(function (line) {
pushLineString(THREE, positions, line, radius);
});
return;
}
if (geometry.type === 'Polygon') {
pushPolygonRings(THREE, positions, geometry.coordinates, radius);
return;
}
if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach(function (polygon) {
pushPolygonRings(THREE, positions, polygon, radius);
});
return;
}
if (geometry.type === 'GeometryCollection') {
geometry.geometries.forEach(function (child) {
pushGeometryLines(THREE, positions, child, radius);
});
}
}
function extractGeojsonFromPayload(payload, topojson, objectName) {
if (!payload) throw new Error('empty data');
if (payload.type === 'Topology') {
if (!topojson || typeof topojson.mesh !== 'function') {
throw new Error('topojson-client unavailable');
}
var objects = payload.objects || {};
var target = objects[objectName] || objects.countries || objects.land || objects[Object.keys(objects)[0]];
if (!target) throw new Error('topology object not found');
return topojson.mesh(payload, target, function (a, b) {
return a !== b;
});
}
if (payload.type === 'FeatureCollection') {
return payload;
}
if (payload.type === 'Feature') {
return {
type: 'FeatureCollection',
features: [payload]
};
}
if (payload.type && payload.coordinates) {
return payload;
}
throw new Error('unsupported map data');
}
function buildLineGeometryFromGeojson(THREE, geojson, radius) {
var positions = [];
if (geojson.type === 'FeatureCollection') {
geojson.features.forEach(function (feature) {
pushGeometryLines(THREE, positions, feature.geometry, radius);
});
} else if (geojson.type === 'Feature') {
pushGeometryLines(THREE, positions, geojson.geometry, radius);
} else {
pushGeometryLines(THREE, positions, geojson, radius);
}
var geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
return {
geometry: geometry,
segmentCount: positions.length / 6
};
}
function createEarthTexture(THREE) {
var canvas = document.createElement('canvas');
var size = 1024;
var ctx;
var gradient;
var i;
canvas.width = size;
canvas.height = size / 2;
ctx = canvas.getContext('2d');
ctx.fillStyle = '#08111a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
gradient = ctx.createRadialGradient(size * 0.42, size * 0.20, 10, size * 0.45, size * 0.28, size * 0.50);
gradient.addColorStop(0, 'rgba(100,135,160,0.38)');
gradient.addColorStop(0.28, 'rgba(32,57,74,0.28)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255,255,255,0.030)';
ctx.lineWidth = 1;
for (i = 0; i <= 12; i += 1) {
var y = Math.round(i * canvas.height / 12) + 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
for (i = 0; i <= 24; i += 1) {
var x = Math.round(i * canvas.width / 24) + 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (i = 0; i < 220; i += 1) {
var px = Math.random() * canvas.width;
var py = Math.random() * canvas.height;
var r = 1 + Math.random() * 2.8;
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(80,105,93,0.18)' : 'rgba(95,83,70,0.16)';
ctx.beginPath();
ctx.ellipse(px, py, r * 6, r * 1.7, Math.random() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
var texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function loadImageTexture(THREE, url) {
return new Promise(function (resolve, reject) {
var loader = new THREE.TextureLoader();
loader.load(
url,
function (texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.anisotropy = 4;
texture.needsUpdate = true;
resolve(texture);
},
undefined,
function (err) {
reject(err || new Error('texture load failed: ' + url));
}
);
});
}
function loadImageElement(url) {
return new Promise(function (resolve, reject) {
var image = new Image();
image.crossOrigin = 'anonymous';
image.decoding = 'async';
image.onload = function () {
resolve(image);
};
image.onerror = function () {
reject(new Error('image load failed: ' + url));
};
image.src = url;
});
}
function createCompositedEarthTextureLayers(THREE, baseUrl, layers, maxWidth) {
var effectiveLayers = Array.isArray(layers) ? layers.filter(function (layer) {
return !!(layer && layer.url);
}) : [];
return loadImageElement(baseUrl).then(function (base) {
return Promise.all(effectiveLayers.map(function (layer) {
return loadImageElement(layer.url)
.then(function (image) {
return {
layer: layer,
image: image
};
})
.catch(function (err) {
if (window.console && console.warn) {
console.warn('CLBI earth composite layer skipped:', {
label: layer.label || 'overlay',
url: layer.url,
error: err
});
}
return null;
});
})).then(function (loadedLayers) {
var width = Math.max(1024, Math.min(8192, Math.floor(maxWidth || 4096)));
var height = Math.max(512, Math.floor(width / 2));
var canvas = document.createElement('canvas');
var ctx;
var texture;
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.drawImage(base, 0, 0, width, height);
loadedLayers.forEach(function (entry) {
var layer;
var opacity;
var blend;
if (!entry || !entry.image) return;
layer = entry.layer;
opacity = Math.max(0, Math.min(1.4, layer.opacity == null ? 1 : layer.opacity));
blend = String(layer.blend || 'source-over').toLowerCase();
ctx.globalCompositeOperation = blend;
ctx.globalAlpha = opacity;
if (layer.filter && typeof ctx.filter !== 'undefined') {
ctx.filter = String(layer.filter);
} else if (typeof ctx.filter !== 'undefined') {
ctx.filter = 'none';
}
ctx.drawImage(entry.image, 0, 0, width, height);
});
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
if (typeof ctx.filter !== 'undefined') {
ctx.filter = 'none';
}
texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.anisotropy = 6;
texture.needsUpdate = true;
return texture;
});
});
}
function createFallbackPlanetTexture(THREE, colorA, colorB) {
var canvas = document.createElement('canvas');
var size = 512;
var ctx;
var gradient;
var i;
canvas.width = size;
canvas.height = size / 2;
ctx = canvas.getContext('2d');
gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, colorA || '#777777');
gradient.addColorStop(1, colorB || '#151515');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (i = 0; i < 18; i += 1) {
ctx.fillStyle = i % 2 ? 'rgba(255,255,255,0.055)' : 'rgba(0,0,0,0.095)';
ctx.fillRect(0, Math.round(i * canvas.height / 18), canvas.width, Math.max(1, Math.round(canvas.height / 46)));
}
var texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.needsUpdate = true;
return texture;
}
function createSaturnRingTexture(THREE) {
var canvas = document.createElement('canvas');
var size = 512;
var ctx;
var image;
var data;
var cx = size * 0.5;
var cy = size * 0.5;
var maxR = size * 0.5;
var texture;
var x;
var y;
canvas.width = size;
canvas.height = size;
ctx = canvas.getContext('2d');
image = ctx.createImageData(size, size);
data = image.data;
for (y = 0; y < size; y += 1) {
for (x = 0; x < size; x += 1) {
var dx = (x - cx) / maxR;
var dy = (y - cy) / maxR;
var r = Math.sqrt(dx * dx + dy * dy);
var idx = (y * size + x) * 4;
var band;
var gap;
var alpha;
var warm;
if (r < 0.200 || r > 0.500) {
data[idx + 3] = 0;
continue;
}
band = 0.58
+ 0.22 * Math.sin(r * 92.0)
+ 0.13 * Math.sin(r * 177.0 + 0.6)
+ 0.07 * Math.sin(r * 317.0 - 1.1);
gap = (r > 0.365 && r < 0.386) ? 0.22 : 1.0;
alpha = Math.max(0, Math.min(1, (band * 1.28 + 0.08) * gap));
warm = Math.max(0, Math.min(1, (0.50 - r) / 0.300));
data[idx + 0] = Math.round(216 + warm * 38);
data[idx + 1] = Math.round(186 + warm * 44);
data[idx + 2] = Math.round(126 + warm * 48);
data[idx + 3] = Math.round(alpha * 248);
}
}
ctx.putImageData(image, 0, 0);
texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function configureSaturnRingMap(THREE, texture) {
if (!texture) return texture;
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.anisotropy = 6;
texture.needsUpdate = true;
return texture;
}
function loadSaturnRingTexture(THREE, url) {
return loadLimitedImageTexture(THREE, url, 2048).then(function (texture) {
return configureSaturnRingMap(THREE, texture);
});
}
function createSaturnRingGeometry(THREE, innerRadius, outerRadius) {
var geometry = new THREE.RingGeometry(innerRadius, outerRadius, 256, 32);
var position = geometry.attributes.position;
var uv = geometry.attributes.uv;
var range = Math.max(0.0001, outerRadius - innerRadius);
var i;
/*
* The Solar System Scope / Commons file is a radial strip: left-to-right is
* inner-to-outer ring structure. Three.js RingGeometry's default UVs map
* the whole square, which turns the strip into a flat painted oval. Rewrite
* the U coordinate from each vertex's local radius so the real ring bands
* wrap around Saturn concentrically.
*/
for (i = 0; i < position.count; i += 1) {
var x = position.getX(i);
var y = position.getY(i);
var r = Math.sqrt(x * x + y * y);
var u = Math.max(0, Math.min(1, (r - innerRadius) / range));
uv.setXY(i, u, 0.5);
}
uv.needsUpdate = true;
return geometry;
}
function createSolarInnerGlowTexture(THREE) {
var canvas = document.createElement('canvas');
var size = 384;
var ctx = canvas.getContext('2d');
var gradient;
var texture;
canvas.width = size;
canvas.height = size;
gradient = ctx.createRadialGradient(
size * 0.5, size * 0.5, 0,
size * 0.5, size * 0.5, size * 0.5
);
gradient.addColorStop(0.000, 'rgba(255,252,232,1.00)');
gradient.addColorStop(0.090, 'rgba(255,232,168,0.96)');
gradient.addColorStop(0.240, 'rgba(255,182,82,0.70)');
gradient.addColorStop(0.430, 'rgba(255,126,28,0.36)');
gradient.addColorStop(0.660, 'rgba(232,96,16,0.12)');
gradient.addColorStop(1.000, 'rgba(0,0,0,0.00)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function createSolarCoronaTexture(THREE) {
var canvas = document.createElement('canvas');
var size = 512;
var ctx;
var image;
var data;
var cx = size * 0.5;
var cy = size * 0.5;
var maxR = size * 0.5;
var texture;
var x;
var y;
canvas.width = size;
canvas.height = size;
ctx = canvas.getContext('2d');
image = ctx.createImageData(size, size);
data = image.data;
for (y = 0; y < size; y += 1) {
for (x = 0; x < size; x += 1) {
var dx = (x - cx) / maxR;
var dy = (y - cy) / maxR;
var r = Math.sqrt(dx * dx + dy * dy);
var idx = (y * size + x) * 4;
var angle;
var noise;
var base;
var alpha;
var warm;
if (r >= 1) {
data[idx + 3] = 0;
continue;
}
angle = Math.atan2(dy, dx);
noise = 0.90
+ 0.10 * Math.sin(angle * 7.0 + r * 9.0)
+ 0.06 * Math.sin(angle * 13.0 - r * 16.0)
+ 0.04 * Math.sin(angle * 29.0 + r * 25.0);
base = Math.pow(Math.max(0, 1 - r), 2.65);
alpha = Math.max(0, Math.min(1, base * noise));
warm = Math.max(0, Math.min(1, 1 - r * 0.9));
data[idx + 0] = Math.round(255);
data[idx + 1] = Math.round(170 + 45 * warm);
data[idx + 2] = Math.round(72 + 26 * warm);
data[idx + 3] = Math.round(alpha * 255);
}
}
ctx.putImageData(image, 0, 0);
texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function loadLimitedImageTexture(THREE, url, maxWidth) {
return new Promise(function (resolve, reject) {
var image = new Image();
var limit = Math.max(64, Math.min(1024, Math.floor(maxWidth || 512)));
image.crossOrigin = 'anonymous';
image.decoding = 'async';
image.onload = function () {
var naturalW = image.naturalWidth || image.width || limit;
var naturalH = image.naturalHeight || image.height || Math.max(1, Math.floor(limit / 2));
var scale = Math.min(1, limit / naturalW);
var width = Math.max(1, Math.round(naturalW * scale));
var height = Math.max(1, Math.round(naturalH * scale));
var canvas = document.createElement('canvas');
var ctx;
var texture;
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, width, height);
texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.anisotropy = 2;
texture.needsUpdate = true;
resolve(texture);
};
image.onerror = function () {
reject(new Error('limited texture load failed: ' + url));
};
image.src = url;
});
}
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function easeOutCubic(t) {
t = clamp01(t);
return 1 - Math.pow(1 - t, 3);
}
function nearestEquivalentAngle(current, target) {
var full = Math.PI * 2;
return target + Math.round((current - target) / full) * full;
}
function canonicalAngle(value) {
var full = Math.PI * 2;
var angle = value % full;
if (angle > Math.PI) angle -= full;
if (angle < -Math.PI) angle += full;
return angle;
}
function inverseLerp(a, b, value) {
if (a === b) return 0;
return (value - a) / (b - a);
}
function createStars(THREE, count) {
var geometry = new THREE.BufferGeometry();
var positions = [];
var i;
for (i = 0; i < count; i += 1) {
var r = 8 + Math.random() * 16;
var theta = Math.random() * Math.PI * 2;
var phi = Math.acos((Math.random() * 2) - 1);
positions.push(
r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta)
);
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
return new THREE.Points(
geometry,
new THREE.PointsMaterial({
color: 0xd8d8d8,
size: 0.018,
transparent: true,
opacity: 0.62,
depthWrite: false
})
);
}
function NationsGlobe(root) {
this.root = root;
this.stage = null;
this.loading = null;
this.error = null;
this.status = null;
this.readout = null;
this.autoButton = null;
this.resetButton = null;
this.THREE = null;
this.scene = null;
this.camera = null;
this.renderer = null;
this.group = null;
this.starGroup = null;
this.celestialGroup = null;
this.celestialPlanets = [];
this.starDriftY = 0;
this.celestialDriftY = 0;
this.celestialSun = null;
this.celestialSunGlowA = null;
this.celestialSunGlowB = null;
this.celestialSunCorona = null;
this.sunOccultRendered = 0;
this.sunOccultMotion = null;
this.moon = null;
this.moonShell = null;
this.earth = null;
this.earthMaterial = null;
this.clouds = null;
this.cloudMaterial = null;
this.lakesOverlay = null;
this.raycaster = null;
this.pointer = null;
this.pointerInside = false;
this.pointerClientX = null;
this.pointerClientY = null;
this.pointerDownX = null;
this.pointerDownY = null;
this.pointerDownNationPage = null;
this.clickMoveTolerance = toNumber(root.getAttribute('data-nation-click-tolerance'), 5);
this.nationHitUrl = root.getAttribute('data-polygon-hit-url') || '';
this.nationLinkMapUrl = root.getAttribute('data-nation-link-map-url') || '';
this.nationLinkBase = root.getAttribute('data-nation-link-base') || '/index.php/';
this.nationHitFeatures = [];
this.nationLinkMap = {};
this.currentNation = null;
this.nationTooltip = null;
this.radius = 1;
this.rotationX = toNumber(root.getAttribute('data-rotation-x'), -0.18);
this.rotationY = toNumber(root.getAttribute('data-rotation-y'), -0.48);
this.cameraZ = toNumber(root.getAttribute('data-camera-z'), 3.75);
this.cameraZMin = toNumber(root.getAttribute('data-camera-z-min'), 1.23);
this.cameraZMax = toNumber(root.getAttribute('data-camera-z-max'), 5.2);
this.resetRotationX = toNumber(root.getAttribute('data-reset-rotation-x'), this.rotationX);
this.resetRotationY = toNumber(root.getAttribute('data-reset-rotation-y'), this.rotationY);
this.resetCameraZ = toNumber(root.getAttribute('data-reset-camera-z'), this.cameraZ);
if (!root.getAttribute('data-camera-z') && root.getAttribute('data-reset-camera-z') === '3.05') {
this.resetCameraZ = this.cameraZ;
}
this.resetDuration = toNumber(root.getAttribute('data-reset-duration'), 520);
this.resetMotion = null;
this.terrainUrl = root.getAttribute('data-terrain-url') || '';
this.cloudsUrl = root.getAttribute('data-clouds-url') || '';
this.boundarySurfaceOffset = toNumber(root.getAttribute('data-boundary-surface-offset'), 0.0022);
this.polygonOverlayEnabled = root.getAttribute('data-polygon-overlay') !== 'false';
this.polygonOverlayUrl = root.getAttribute('data-polygon-overlay-url') || '';
this.polygonOverlaySize = toNumber(root.getAttribute('data-polygon-overlay-size'), 4096);
this.earthCompositeSize = toNumber(root.getAttribute('data-earth-composite-size'), this.polygonOverlaySize || 4096);
this.polygonOverlayOpacity = toNumber(root.getAttribute('data-polygon-overlay-opacity'), 1);
this.polygonOverlayBlend = root.getAttribute('data-polygon-overlay-blend') || 'source-over';
this.polygonOverlayFilter = root.getAttribute('data-polygon-overlay-filter') || 'none';
this.reliefOverlayUrl = root.getAttribute('data-relief-overlay-url') || '';
this.reliefOverlayOpacity = toNumber(root.getAttribute('data-relief-overlay-opacity'), 1);
this.reliefOverlayBlend = root.getAttribute('data-relief-overlay-blend') || 'source-over';
this.reliefOverlayFilter = root.getAttribute('data-relief-overlay-filter') || 'none';
this.lakesOverlayUrl = root.getAttribute('data-lakes-overlay-url') || '';
this.lakesOverlayOpacity = toNumber(root.getAttribute('data-lakes-overlay-opacity'), 1);
this.lakesSurfaceOffset = toNumber(root.getAttribute('data-lakes-surface-offset'), 0.0029);
this.cloudOpacityNear = toNumber(root.getAttribute('data-cloud-opacity-near'), 0.10);
this.cloudOpacityFar = toNumber(root.getAttribute('data-cloud-opacity-far'), 0.90);
this.cloudDriftSpeed = toNumber(root.getAttribute('data-cloud-drift-speed'), 0.000010);
this.cloudTiltDrift = toNumber(root.getAttribute('data-cloud-tilt-drift'), 0.006);
this.celestialEnabled = root.getAttribute('data-celestial-enabled') !== 'false';
this.celestialTextureSize = toNumber(root.getAttribute('data-celestial-texture-size'), 512);
this.sunTextureSize = toNumber(root.getAttribute('data-sun-texture-size'), 512);
this.moonTextureSize = toNumber(root.getAttribute('data-moon-texture-size'), 512);
this.moonOpacityFar = toNumber(root.getAttribute('data-moon-opacity-far'), 1.00);
this.moonOpacityNear = toNumber(root.getAttribute('data-moon-opacity-near'), 0.00);
this.moonShellOpacityFar = toNumber(root.getAttribute('data-moon-shell-opacity-far'), 0.040);
this.moonShellOpacityNear = toNumber(root.getAttribute('data-moon-shell-opacity-near'), 0.00);
this.moonVanishZ = toNumber(root.getAttribute('data-moon-vanish-z'), 3.15);
this.moonFullVisibleZ = toNumber(root.getAttribute('data-moon-full-visible-z'), 4.20);
this.moonEarthFadeOpacity = toNumber(root.getAttribute('data-moon-earth-fade-opacity'), 0.08);
this.moonEarthFadeMargin = toNumber(root.getAttribute('data-moon-earth-fade-margin'), 28);
this.moonFadeDuration = toNumber(root.getAttribute('data-moon-fade-duration'), this.resetDuration);
this.sunOccultFadeDuration = toNumber(root.getAttribute('data-sun-occult-fade-duration'), this.moonFadeDuration);
this.moonRenderedOpacity = null;
this.moonRenderedShellOpacity = null;
this.moonFadeMotion = null;
this.celestialParallax = toNumber(root.getAttribute('data-celestial-parallax'), 0.070);
this.starParallax = toNumber(root.getAttribute('data-star-parallax'), 0.012);
this.starDriftSpeed = toNumber(root.getAttribute('data-star-drift-speed'), 0);
this.celestialDriftSpeed = toNumber(root.getAttribute('data-celestial-drift-speed'), 0);
this.autoRotate = root.getAttribute('data-auto-rotate') !== 'false';
this.resetAutoRotate = this.autoRotate;
this.autoRotateSpeed = toNumber(root.getAttribute('data-auto-rotate-speed'), 0.000085);
this.autoResumeDuration = toNumber(root.getAttribute('data-auto-resume-duration'), Math.max(1600, this.resetDuration * 3.5));
this.autoResumeMinFactor = toNumber(root.getAttribute('data-auto-resume-min-factor'), 0.06);
this.autoResumeMotion = null;
this.dragging = false;
this.lastX = 0;
this.lastY = 0;
this.lastMoveTime = 0;
this.velocityX = 0;
this.velocityY = 0;
this.dragSensitivityY = toNumber(root.getAttribute('data-drag-sensitivity-y'), 0.0030);
this.dragSensitivityX = toNumber(root.getAttribute('data-drag-sensitivity-x'), 0.0020);
this.inertiaStrength = toNumber(root.getAttribute('data-inertia-strength'), 0.58);
this.inertiaDamping = toNumber(root.getAttribute('data-inertia-damping'), 0.993);
this.inertiaMinVelocity = toNumber(root.getAttribute('data-inertia-min-velocity'), 0.000085);
this.inertiaReleaseWindow = toNumber(root.getAttribute('data-inertia-release-window'), 150);
this.stageEventsBound = false;
this.frame = null;
this.disposed = false;
this.segmentCount = 0;
this.lastTime = performance.now();
}
NationsGlobe.prototype.ensureDom = function () {
var root = this.root;
var title = root.getAttribute('data-title') || 'TERRITORY GLOBE';
var era = root.getAttribute('data-era') || 'REAL WORLD / 110M';
var topbarHtml;
var bottombarHtml;
root.classList.add('clbi-nations-globe-window');
topbarHtml =
'<div class="clbi-nations-globe-topbar">' +
'<span class="clbi-nations-globe-title">' + escapeHtml(title) + '</span>' +
'<span class="clbi-nations-globe-status">SYNC</span>' +
'</div>';
bottombarHtml =
'<div class="clbi-nations-globe-bottombar">' +
'<span class="clbi-nations-globe-readout">' + escapeHtml(era) + '</span>' +
'<span class="clbi-nations-globe-controls">' +
'<button type="button" class="clbi-nations-globe-btn clbi-nations-globe-auto">AUTO</button>' +
'<button type="button" class="clbi-nations-globe-btn clbi-nations-globe-reset">RESET</button>' +
'</span>' +
'</div>';
if (!root.querySelector('.clbi-nations-globe-stage')) {
root.innerHTML =
'<div class="clbi-nations-globe-stage" aria-label="3D globe viewport"></div>' +
'<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>' +
topbarHtml +
bottombarHtml +
'<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>' +
'<div class="clbi-nations-globe-error"></div>';
} else {
if (!root.querySelector('.clbi-nations-globe-vignette')) {
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>');
}
if (!root.querySelector('.clbi-nations-globe-topbar')) {
root.insertAdjacentHTML('beforeend', topbarHtml);
}
if (!root.querySelector('.clbi-nations-globe-bottombar')) {
root.insertAdjacentHTML('beforeend', bottombarHtml);
}
if (!root.querySelector('.clbi-nations-globe-loading')) {
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>');
}
if (!root.querySelector('.clbi-nations-globe-error')) {
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-error"></div>');
}
}
this.stage = root.querySelector('.clbi-nations-globe-stage');
this.loading = root.querySelector('.clbi-nations-globe-loading');
this.error = root.querySelector('.clbi-nations-globe-error');
this.status = root.querySelector('.clbi-nations-globe-status');
this.readout = root.querySelector('.clbi-nations-globe-readout');
if (!root.querySelector('.clbi-nations-globe-nation-tooltip')) {
root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-nation-tooltip" aria-hidden="true"></div>');
}
this.autoButton = root.querySelector('.clbi-nations-globe-auto');
this.resetButton = root.querySelector('.clbi-nations-globe-reset');
this.nationTooltip = root.querySelector('.clbi-nations-globe-nation-tooltip');
ensureNationsGlobeInteractionStyle();
this.syncAutoButton();
if (this.readout && !this.readout.textContent) {
this.readout.textContent = era;
}
};
NationsGlobe.prototype.rehydrateDom = function () {
if (!this.root || !document.body.contains(this.root)) return false;
this.ensureDom();
this.bindControlEvents();
this.resize();
return true;
};
NationsGlobe.prototype.setStatus = function (text) {
if (this.status) this.status.textContent = text || '';
};
NationsGlobe.prototype.setReadout = function (text) {
if (this.readout) this.readout.textContent = text || '';
};
NationsGlobe.prototype.setError = function (err) {
var message = err && err.message ? err.message : String(err || 'unknown error');
this.root.classList.add('has-error');
this.root.classList.remove('is-ready');
if (this.error) this.error.textContent = 'GLOBE ERROR: ' + message;
this.setStatus('ERROR');
};
NationsGlobe.prototype.init = function () {
var self = this;
this.ensureDom();
this.setStatus('LIBRARY');
return Promise.all([loadThree(), loadTopojson()])
.then(function (libs) {
self.THREE = libs[0];
self.topojson = libs[1];
self.createScene();
self.bindEvents();
return self.loadOptionalTextures().then(function () {
return self.loadBoundaryData();
}).then(function () {
return self.loadNationInteractionData();
});
})
.then(function () {
self.root.classList.add('is-ready');
self.setStatus('READY');
self.animate(performance.now());
})
.catch(function (err) {
self.setError(err);
});
};
NationsGlobe.prototype.addMoon = function (texture) {
var THREE = this.THREE;
var def = CELESTIAL_MOON_DEF;
var material;
var mesh;
var shell;
if (!this.group || !texture) return;
material = new THREE.MeshPhongMaterial({
map: texture,
color: 0xffffff,
transparent: true,
opacity: this.moonOpacityFar,
depthWrite: false,
depthTest: true,
shininess: 2,
specular: new THREE.Color(0x050505),
emissive: new THREE.Color(0x010101)
});
mesh = new THREE.Mesh(
new THREE.SphereGeometry(def.radius, 42, 28),
material
);
mesh.position.set(def.position[0], def.position[1], def.position[2]);
mesh.renderOrder = 20;
shell = new THREE.Mesh(
new THREE.SphereGeometry(def.radius * 1.022, 36, 24),
new THREE.MeshBasicMaterial({
color: 0xd8d8d8,
transparent: true,
opacity: this.moonShellOpacityFar,
side: THREE.BackSide,
depthWrite: false
})
);
shell.position.copy(mesh.position);
shell.renderOrder = 19;
this.group.add(shell);
this.group.add(mesh);
this.moon = mesh;
this.moonShell = shell;
this.updateMoonOpacity();
};
NationsGlobe.prototype.loadMoon = function () {
var self = this;
var THREE = this.THREE;
var textureSize = Math.max(64, Math.min(1024, Math.floor(this.moonTextureSize || 512)));
loadLimitedImageTexture(THREE, CELESTIAL_MOON_TEXTURE_URL, textureSize)
.then(function (texture) {
self.addMoon(texture);
})
.catch(function (err) {
console.warn('CLBI moon texture failed:', err);
self.addMoon(createFallbackPlanetTexture(THREE, CELESTIAL_MOON_DEF.fallbackA, CELESTIAL_MOON_DEF.fallbackB));
});
};
NationsGlobe.prototype.resolveCelestialEclipticPosition = function (def) {
var plane = CELESTIAL_ECLIPTIC;
var orbitRadius = def.orbitRadius || 0;
var angle = def.orbitAngle || 0;
var x = Math.cos(angle) * orbitRadius;
var z = plane.depth + Math.sin(angle) * orbitRadius * plane.depthScale;
var y = x * plane.tiltYPerX + Math.sin(angle) * orbitRadius * (def.inclination || 0);
return [x, y, z];
};
NationsGlobe.prototype.addCelestialPlanet = function (def, texture) {
var THREE = this.THREE;
var self = this;
var mesh;
var material;
var shell;
var shellColor;
var ring;
var ringGlow;
var ringMaterial;
var position;
if (!this.celestialGroup || !def || !texture) return;
material = new THREE.MeshPhongMaterial({
map: texture,
color: 0xffffff,
transparent: true,
opacity: def.opacity,
depthWrite: false,
depthTest: true,
shininess: 3,
specular: new THREE.Color(0x050505),
emissive: new THREE.Color(0x020202)
});
mesh = new THREE.Mesh(
new THREE.SphereGeometry(def.radius, 42, 28),
material
);
position = this.resolveCelestialEclipticPosition(def);
mesh.position.set(position[0], position[1], position[2]);
mesh.renderOrder = 1;
this.celestialGroup.add(mesh);
if (def.ring) {
var ringInnerRadius = def.radius * (def.ringInnerScale || 1.34);
var ringOuterRadius = def.radius * (def.ringOuterScale || 2.58);
ringMaterial = new THREE.MeshBasicMaterial({
map: configureSaturnRingMap(THREE, createSaturnRingTexture(THREE)),
color: 0xffffff,
transparent: true,
opacity: def.ringOpacity || 0.86,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
blending: THREE.NormalBlending
});
ring = new THREE.Mesh(
createSaturnRingGeometry(THREE, ringInnerRadius, ringOuterRadius),
ringMaterial
);
ring.position.copy(mesh.position);
ring.rotation.x = def.ringTiltX || 1.08;
ring.rotation.y = def.ringTiltY || 0.18;
ring.rotation.z = def.ringTiltZ || -0.34;
ring.renderOrder = 1.8;
this.celestialGroup.add(ring);
if (def.ringTextureUrl) {
loadSaturnRingTexture(THREE, def.ringTextureUrl).then(function (ringTexture) {
if (!ringMaterial) return;
ringMaterial.map = ringTexture;
ringMaterial.needsUpdate = true;
}).catch(function (err) {
console.warn('CLBI Saturn ring texture failed:', err);
});
}
}
shellColor = new THREE.Color(def.fallbackA || '#707070');
shell = new THREE.Mesh(
new THREE.SphereGeometry(def.radius * 1.028, 36, 24),
new THREE.MeshBasicMaterial({
color: shellColor,
transparent: true,
opacity: Math.max(0.05, Math.min(0.12, (def.opacity || 0.4) * 0.20)),
side: THREE.BackSide,
depthWrite: false
})
);
shell.position.copy(mesh.position);
shell.renderOrder = 0;
this.celestialGroup.add(shell);
this.celestialPlanets.push({
mesh: mesh,
shell: shell,
ring: ring,
ringGlow: ringGlow,
speed: def.rotationSpeed || 0
});
};
NationsGlobe.prototype.addCelestialSun = function (texture) {
var THREE = this.THREE;
var def = CELESTIAL_SUN_DEF;
var position = this.resolveCelestialEclipticPosition(def);
var core;
var innerGlow;
var outerGlow;
var coreMaterial;
var innerGlowMaterial;
var outerGlowMaterial;
if (!this.celestialGroup) return;
coreMaterial = new THREE.MeshBasicMaterial({
map: texture || null,
color: def.color,
transparent: true,
opacity: def.coreOpacity,
depthWrite: false
});
core = new THREE.Mesh(
new THREE.SphereGeometry(def.radius, 48, 32),
coreMaterial
);
core.position.set(position[0], position[1], position[2]);
core.renderOrder = 3;
innerGlowMaterial = new THREE.SpriteMaterial({
map: createSolarInnerGlowTexture(THREE),
color: 0xffffff,
transparent: true,
opacity: def.innerGlowOpacity,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending
});
innerGlow = new THREE.Sprite(innerGlowMaterial);
innerGlow.position.copy(core.position);
innerGlow.scale.set(def.radius * def.innerGlowScale, def.radius * def.innerGlowScale, 1);
innerGlow.renderOrder = 2;
outerGlowMaterial = new THREE.SpriteMaterial({
map: createSolarCoronaTexture(THREE),
color: 0xffffff,
transparent: true,
opacity: def.outerGlowOpacity,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending
});
outerGlow = new THREE.Sprite(outerGlowMaterial);
outerGlow.position.copy(core.position);
outerGlow.scale.set(def.radius * def.outerGlowScale, def.radius * def.outerGlowScale, 1);
outerGlow.renderOrder = 1;
this.celestialGroup.add(outerGlow);
this.celestialGroup.add(innerGlow);
this.celestialGroup.add(core);
this.celestialSun = core;
this.celestialSunGlowA = innerGlow;
this.celestialSunGlowB = outerGlow;
this.celestialSunCorona = null;
};
NationsGlobe.prototype.loadCelestialSun = function () {
var self = this;
var THREE = this.THREE;
var textureSize = Math.max(64, Math.min(1024, Math.floor(this.sunTextureSize || 512)));
loadLimitedImageTexture(THREE, CELESTIAL_SUN_TEXTURE_URL, textureSize)
.then(function (texture) {
self.addCelestialSun(texture);
})
.catch(function (err) {
console.warn('CLBI sun texture failed:', err);
self.addCelestialSun(createFallbackPlanetTexture(THREE, '#d08b3e', '#5f240b'));
});
};
NationsGlobe.prototype.loadCelestialPlanets = function () {
var self = this;
var THREE = this.THREE;
var textureSize = Math.max(64, Math.min(1024, Math.floor(this.celestialTextureSize || 512)));
if (!this.celestialEnabled || !this.celestialGroup) return;
CELESTIAL_PLANET_DEFS.forEach(function (def) {
var url = CELESTIAL_PLANET_TEXTURES[def.key];
if (!url) {
self.addCelestialPlanet(def, createFallbackPlanetTexture(THREE, def.fallbackA, def.fallbackB));
return;
}
loadLimitedImageTexture(THREE, url, textureSize)
.then(function (texture) {
self.addCelestialPlanet(def, texture);
})
.catch(function (err) {
console.warn('CLBI celestial texture failed:', err);
self.addCelestialPlanet(def, createFallbackPlanetTexture(THREE, def.fallbackA, def.fallbackB));
});
});
};
NationsGlobe.prototype.createCelestialBackdrop = function () {
var THREE = this.THREE;
this.starGroup = new THREE.Group();
this.celestialGroup = new THREE.Group();
this.scene.add(this.starGroup);
this.scene.add(this.celestialGroup);
this.starGroup.add(createStars(THREE, 780));
this.loadCelestialSun();
this.loadCelestialPlanets();
};
NationsGlobe.prototype.updateCelestialBackdrop = function (dt, now) {
var i;
var planet;
var def = CELESTIAL_SUN_DEF;
var pulseA;
var pulseB;
var sunOccult = 0;
if (!this.resetMotion) {
this.starDriftY += dt * this.starDriftSpeed;
this.celestialDriftY += dt * this.celestialDriftSpeed;
}
if (this.starGroup) {
this.starGroup.rotation.x = -this.rotationX * this.starParallax;
this.starGroup.rotation.y = -this.rotationY * this.starParallax + this.starDriftY;
}
if (this.celestialGroup) {
this.celestialGroup.rotation.x = -this.rotationX * this.celestialParallax;
this.celestialGroup.rotation.y = -this.rotationY * this.celestialParallax + this.celestialDriftY;
}
for (i = 0; i < this.celestialPlanets.length; i += 1) {
planet = this.celestialPlanets[i];
if (planet && planet.mesh) {
planet.mesh.rotation.y += dt * planet.speed;
if (planet.shell) planet.shell.rotation.y += dt * planet.speed * 0.35;
if (planet.ring) planet.ring.rotation.z += dt * planet.speed * 0.10;
}
}
if (this.celestialSun) {
this.celestialSun.rotation.y += dt * def.textureScrollSpeed;
if (this.celestialSun.material) {
this.celestialSun.material.opacity = def.coreOpacity * (1 - sunOccult);
}
}
if (this.celestialSunGlowA) {
pulseA = def.radius * def.innerGlowScale * (1 + Math.sin(now * 0.00110) * def.pulseA);
this.celestialSunGlowA.scale.set(pulseA, pulseA, 1);
if (this.celestialSunGlowA.material) {
this.celestialSunGlowA.material.opacity = def.innerGlowOpacity * (1 + Math.sin(now * 0.00082 + 1.7) * (def.pulseB * 0.35)) * (1 - sunOccult);
}
}
if (this.celestialSunGlowB) {
pulseB = def.radius * def.outerGlowScale * (1 + Math.sin(now * 0.00057 + 0.9) * def.pulseB);
this.celestialSunGlowB.scale.set(pulseB, pulseB, 1);
if (this.celestialSunGlowB.material) {
this.celestialSunGlowB.material.opacity = def.outerGlowOpacity * (1 + Math.sin(now * 0.00043 + 2.4) * (def.pulseA * 0.45)) * (1 - sunOccult);
}
}
if (this.moon) {
this.moon.rotation.y += dt * CELESTIAL_MOON_DEF.rotationSpeed;
if (this.moonShell) this.moonShell.rotation.y += dt * CELESTIAL_MOON_DEF.rotationSpeed * 0.35;
}
this.updateMoonOpacity(dt);
};
NationsGlobe.prototype.createScene = function () {
var THREE = this.THREE;
var rect = this.stage.getBoundingClientRect();
var width = Math.max(320, Math.floor(rect.width || this.root.clientWidth || 960));
var height = Math.max(240, Math.floor(rect.height || this.root.clientHeight || 360));
var texture = createEarthTexture(THREE);
var atmosphere;
var light;
var ambient;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 80);
this.camera.position.set(0, 0, this.cameraZ);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.7));
this.renderer.setSize(width, height, false);
this.renderer.setClearColor(0x000000, 0);
this.stage.appendChild(this.renderer.domElement);
this.group = new THREE.Group();
this.scene.add(this.group);
this.earthMaterial = new THREE.MeshPhongMaterial({
map: texture,
color: 0x9fb3bb,
emissive: 0x05080a,
shininess: 10,
transparent: false
});
this.earth = new THREE.Mesh(
new THREE.SphereGeometry(this.radius, 96, 64),
this.earthMaterial
);
this.group.add(this.earth);
atmosphere = new THREE.Mesh(
new THREE.SphereGeometry(this.radius * 1.035, 96, 64),
new THREE.MeshBasicMaterial({
color: 0x5f88a8,
transparent: true,
opacity: 0.115,
side: THREE.BackSide,
depthWrite: false
})
);
this.group.add(atmosphere);
this.loadMoon();
light = new THREE.DirectionalLight(0xffffff, 2.2);
light.position.set(3.2, 2.4, 4.8);
this.scene.add(light);
ambient = new THREE.AmbientLight(0x607080, 0.66);
this.scene.add(ambient);
this.createCelestialBackdrop();
this.raycaster = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
this.applyRotation();
};
NationsGlobe.prototype.updateCloudOpacity = function () {
if (!this.cloudMaterial) return;
var minZ = this.cameraZMin;
var maxZ = this.cameraZMax;
var t = clamp01((this.cameraZ - minZ) / (maxZ - minZ));
var nearOpacity = Math.max(0, Math.min(1, this.cloudOpacityNear));
var farOpacity = Math.max(0, Math.min(1, this.cloudOpacityFar));
this.cloudMaterial.opacity = nearOpacity + (farOpacity - nearOpacity) * t;
this.cloudMaterial.needsUpdate = true;
};
NationsGlobe.prototype.getProjectedScreenRadius = function (object3D, radius) {
var THREE = this.THREE;
var center;
var edge;
var rect;
var cx;
var cy;
var ex;
var ey;
if (!this.camera || !this.renderer || !object3D || !THREE) return null;
center = new THREE.Vector3();
edge = new THREE.Vector3();
object3D.getWorldPosition(center);
edge.copy(center).add(new THREE.Vector3(radius, 0, 0));
center.project(this.camera);
edge.project(this.camera);
rect = this.renderer.domElement.getBoundingClientRect();
cx = (center.x * 0.5 + 0.5) * rect.width;
cy = (-center.y * 0.5 + 0.5) * rect.height;
ex = (edge.x * 0.5 + 0.5) * rect.width;
ey = (-edge.y * 0.5 + 0.5) * rect.height;
return {
x: cx,
y: cy,
r: Math.hypot(ex - cx, ey - cy)
};
};
NationsGlobe.prototype.getMoonEarthOverlapFactor = function () {
var earthScreen;
var moonScreen;
var distance;
var fadeStart;
var fadeEnd;
if (!this.moon || !this.camera || !this.renderer || !this.group) return 0;
earthScreen = this.getProjectedScreenRadius(this.group, this.radius);
moonScreen = this.getProjectedScreenRadius(this.moon, CELESTIAL_MOON_DEF.radius);
if (!earthScreen || !moonScreen) return 0;
distance = Math.hypot(moonScreen.x - earthScreen.x, moonScreen.y - earthScreen.y);
fadeStart = earthScreen.r + moonScreen.r + this.moonEarthFadeMargin;
fadeEnd = Math.max(0, earthScreen.r - moonScreen.r);
if (distance >= fadeStart) return 0;
if (distance <= fadeEnd) return 1;
return clamp01(1 - inverseLerp(fadeEnd, fadeStart, distance));
};
NationsGlobe.prototype.getMoonSunOccultFactor = function () {
var THREE = this.THREE;
var moonScreen;
var sunScreen;
var moonWorld;
var sunWorld;
var moonCamera;
var sunCamera;
var distance;
var fadeStart;
var fadeEnd;
var geometryOccult;
var moonOpacityFactor;
if (!this.moon || !this.celestialSun || !this.camera || !this.renderer || !THREE) return 0;
moonOpacityFactor = Math.max(0, Math.min(1, this.moonRenderedOpacity == null ? 1 : this.moonRenderedOpacity));
/*
* A faded moon is a weaker occluder, but it must not suddenly release the sun.
* The final reveal is smoothed in updateSunOccultFactor.
*/
moonWorld = new THREE.Vector3();
sunWorld = new THREE.Vector3();
this.moon.getWorldPosition(moonWorld);
this.celestialSun.getWorldPosition(sunWorld);
moonCamera = moonWorld.clone().applyMatrix4(this.camera.matrixWorldInverse);
sunCamera = sunWorld.clone().applyMatrix4(this.camera.matrixWorldInverse);
/*
* In camera space, objects in front of the camera have negative z values.
* The less negative value is closer. Only the closer moon can occult the sun.
*/
if (moonCamera.z <= sunCamera.z) return 0;
moonScreen = this.getProjectedScreenRadius(this.moon, CELESTIAL_MOON_DEF.radius);
sunScreen = this.getProjectedScreenRadius(this.celestialSun, CELESTIAL_SUN_DEF.radius * 1.18);
if (!moonScreen || !sunScreen) return 0;
distance = Math.hypot(moonScreen.x - sunScreen.x, moonScreen.y - sunScreen.y);
fadeStart = moonScreen.r + sunScreen.r;
fadeEnd = Math.max(0, moonScreen.r - sunScreen.r * 0.35);
if (distance >= fadeStart) return 0;
geometryOccult = distance <= fadeEnd
? 1
: clamp01(1 - inverseLerp(fadeEnd, fadeStart, distance));
return geometryOccult * moonOpacityFactor;
};
NationsGlobe.prototype.updateSunOccultFactor = function (targetOccult, dt) {
var target = Math.max(0, Math.min(1, targetOccult || 0));
var duration = Math.max(120, this.sunOccultFadeDuration || this.moonFadeDuration || this.resetDuration || 520);
var step = dt == null ? 16.6667 : Math.max(0, Math.min(80, dt));
var alpha = 1 - Math.pow(1 - clamp01(step / duration), 3);
/*
* Do not create a new transition every time the target changes.
* The moon is fading every frame, so targetOccult changes every frame too.
* A continuous low-pass interpolation prevents the sun from popping out
* when the moon finally stops acting as an occluder.
*/
this.sunOccultRendered = (this.sunOccultRendered || 0) + (target - (this.sunOccultRendered || 0)) * alpha;
if (Math.abs(this.sunOccultRendered - target) < 0.0008) {
this.sunOccultRendered = target;
}
if (this.sunOccultRendered < 0.0008) this.sunOccultRendered = 0;
if (this.sunOccultRendered > 0.9992) this.sunOccultRendered = 1;
return this.sunOccultRendered;
};
NationsGlobe.prototype.updateMoonOpacity = function (dt) {
var vanishZ = Math.max(this.cameraZMin, this.moonVanishZ);
var fullZ = Math.max(vanishZ + 0.01, this.moonFullVisibleZ);
var t = clamp01((this.cameraZ - vanishZ) / (fullZ - vanishZ));
var moonNear = Math.max(0, Math.min(1, this.moonOpacityNear));
var moonFar = Math.max(0, Math.min(1, this.moonOpacityFar));
var shellNear = Math.max(0, Math.min(1, this.moonShellOpacityNear));
var shellFar = Math.max(0, Math.min(1, this.moonShellOpacityFar));
var earthFadeOpacity = Math.max(0, Math.min(1, this.moonEarthFadeOpacity));
var overlap = this.getMoonEarthOverlapFactor();
var targetOpacity;
var targetShellOpacity;
var duration = Math.max(120, this.moonFadeDuration || this.resetDuration || 520);
var step = dt == null ? 16.6667 : Math.max(0, Math.min(80, dt));
var transition;
var k;
var isSolidMoon;
if (this.cameraZ <= vanishZ) {
targetOpacity = 0;
targetShellOpacity = 0;
} else {
targetOpacity = moonNear + (moonFar - moonNear) * t;
targetShellOpacity = shellNear + (shellFar - shellNear) * t;
if (overlap > 0) {
targetOpacity = targetOpacity + (earthFadeOpacity - targetOpacity) * overlap;
targetShellOpacity = targetShellOpacity * (1 - overlap);
}
}
if (!this.moonFadeMotion ||
Math.abs((this.moonFadeMotion.targetOpacity || 0) - targetOpacity) > 0.001 ||
Math.abs((this.moonFadeMotion.targetShellOpacity || 0) - targetShellOpacity) > 0.001) {
this.moonFadeMotion = {
elapsed: 0,
duration: duration,
fromOpacity: this.moonRenderedOpacity === null ? targetOpacity : this.moonRenderedOpacity,
fromShellOpacity: this.moonRenderedShellOpacity === null ? targetShellOpacity : this.moonRenderedShellOpacity,
targetOpacity: targetOpacity,
targetShellOpacity: targetShellOpacity
};
}
transition = this.moonFadeMotion;
if (transition) {
transition.elapsed += step;
k = easeOutCubic(clamp01(transition.elapsed / transition.duration));
this.moonRenderedOpacity = transition.fromOpacity + (transition.targetOpacity - transition.fromOpacity) * k;
this.moonRenderedShellOpacity = transition.fromShellOpacity + (transition.targetShellOpacity - transition.fromShellOpacity) * k;
if (transition.elapsed >= transition.duration) {
this.moonRenderedOpacity = transition.targetOpacity;
this.moonRenderedShellOpacity = transition.targetShellOpacity;
this.moonFadeMotion = null;
}
} else {
this.moonRenderedOpacity = targetOpacity;
this.moonRenderedShellOpacity = targetShellOpacity;
}
/*
* Do not hide at a visible 1% tail. Keep rendering until the opacity is
* effectively zero, so the final disappearance reads as a fade, not a pop.
*/
if (this.moonRenderedOpacity < 0.0008) this.moonRenderedOpacity = 0;
if (this.moonRenderedShellOpacity < 0.0008) this.moonRenderedShellOpacity = 0;
isSolidMoon = overlap <= 0.001 && this.moonRenderedOpacity >= 0.999;
if (this.moon && this.moon.material) {
this.moon.material.transparent = !isSolidMoon;
this.moon.material.opacity = isSolidMoon ? 1 : this.moonRenderedOpacity;
this.moon.material.depthWrite = true;
this.moon.visible = (isSolidMoon ? 1 : this.moonRenderedOpacity) > 0;
this.moon.material.needsUpdate = true;
}
if (this.moonShell && this.moonShell.material) {
this.moonShell.material.opacity = this.moonRenderedShellOpacity;
this.moonShell.visible = this.moonRenderedShellOpacity > 0;
this.moonShell.material.needsUpdate = true;
}
};
NationsGlobe.prototype.loadOptionalTextures = function () {
var self = this;
var THREE = this.THREE;
var tasks = [];
if (this.terrainUrl && this.earthMaterial) {
var earthLayers = [];
if (this.reliefOverlayUrl) {
earthLayers.push({
label: 'relief',
url: this.reliefOverlayUrl,
opacity: this.reliefOverlayOpacity,
blend: this.reliefOverlayBlend || 'source-over',
filter: this.reliefOverlayFilter || 'none'
});
}
if (this.polygonOverlayEnabled && this.polygonOverlayUrl) {
earthLayers.push({
label: 'polygon',
url: this.polygonOverlayUrl,
opacity: this.polygonOverlayOpacity,
blend: this.polygonOverlayBlend || 'source-over',
filter: this.polygonOverlayFilter || 'none'
});
}
if (earthLayers.length) {
tasks.push(
createCompositedEarthTextureLayers(THREE, this.terrainUrl, earthLayers, this.earthCompositeSize)
.then(function (texture) {
if (!self.earthMaterial) return;
self.earthMaterial.map = texture;
self.earthMaterial.color.set(0xffffff);
self.earthMaterial.emissive.set(0x020304);
self.earthMaterial.shininess = 6;
self.earthMaterial.needsUpdate = true;
if (window.console && console.info) {
console.info('CLBI earth composite applied:', {
terrainUrl: self.terrainUrl,
earthCompositeSize: self.earthCompositeSize,
reliefOverlayUrl: self.reliefOverlayUrl,
reliefOverlayOpacity: self.reliefOverlayOpacity,
reliefOverlayBlend: self.reliefOverlayBlend,
reliefOverlayFilter: self.reliefOverlayFilter,
polygonOverlayUrl: self.polygonOverlayUrl,
polygonOverlayOpacity: self.polygonOverlayOpacity,
polygonOverlayBlend: self.polygonOverlayBlend,
polygonOverlayFilter: self.polygonOverlayFilter
});
}
})
.catch(function (err) {
console.warn('CLBI earth composite failed, falling back to base terrain:', err);
return loadImageTexture(THREE, self.terrainUrl)
.then(function (texture) {
if (!self.earthMaterial) return;
self.earthMaterial.map = texture;
self.earthMaterial.color.set(0xffffff);
self.earthMaterial.emissive.set(0x020304);
self.earthMaterial.shininess = 6;
self.earthMaterial.needsUpdate = true;
});
})
);
} else {
tasks.push(
loadImageTexture(THREE, this.terrainUrl)
.then(function (texture) {
if (!self.earthMaterial) return;
self.earthMaterial.map = texture;
self.earthMaterial.color.set(0xffffff);
self.earthMaterial.emissive.set(0x020304);
self.earthMaterial.shininess = 6;
self.earthMaterial.needsUpdate = true;
})
.catch(function (err) {
console.warn('CLBI terrain texture failed:', err);
})
);
}
}
if (this.lakesOverlayUrl && this.group) {
tasks.push(
loadImageTexture(THREE, this.lakesOverlayUrl)
.then(function (texture) {
var material;
var mesh;
var radius;
if (!self.group) return;
radius = self.radius + Math.max(0.0012, Math.min(0.008, self.lakesSurfaceOffset));
material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: Math.max(0, Math.min(1.25, self.lakesOverlayOpacity)),
depthWrite: false,
depthTest: true
});
mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 128, 64),
material
);
mesh.renderOrder = 12;
self.group.add(mesh);
self.lakesOverlay = mesh;
if (window.console && console.info) {
console.info('CLBI 1950 lakes overlay applied:', {
lakesOverlayUrl: self.lakesOverlayUrl,
lakesOverlayOpacity: self.lakesOverlayOpacity,
lakesSurfaceOffset: self.lakesSurfaceOffset
});
}
})
.catch(function (err) {
console.warn('CLBI lakes overlay failed:', err);
})
);
}
if (this.cloudsUrl) {
tasks.push(
loadImageTexture(THREE, this.cloudsUrl)
.then(function (texture) {
var material = new THREE.MeshBasicMaterial({
map: texture,
color: 0xffffff,
transparent: true,
opacity: self.cloudOpacityFar,
depthWrite: false,
side: THREE.DoubleSide
});
self.cloudMaterial = material;
self.clouds = new THREE.Mesh(
new THREE.SphereGeometry(self.radius * 1.018, 96, 64),
material
);
self.clouds.renderOrder = 4;
self.group.add(self.clouds);
self.updateCloudOpacity();
})
.catch(function (err) {
console.warn('CLBI cloud texture failed:', err);
})
);
}
return Promise.all(tasks).then(function () {});
};
NationsGlobe.prototype.cancelResetMotion = function () {
this.resetMotion = null;
this.autoResumeMotion = null;
};
NationsGlobe.prototype.startAutoResumeMotion = function () {
if (!this.autoRotate) {
this.autoResumeMotion = null;
return;
}
this.autoResumeMotion = {
elapsed: 0,
duration: Math.max(900, this.autoResumeDuration || this.resetDuration || 1800),
from: Math.max(0, Math.min(0.35, this.autoResumeMinFactor || 0.06)),
to: 1
};
};
NationsGlobe.prototype.getAutoRotateFactor = function (dt) {
var motion = this.autoResumeMotion;
var t;
if (!motion) return 1;
motion.elapsed += dt;
t = clamp01(motion.elapsed / motion.duration);
/*
* Speedometer-style ramp: no hidden threshold, no late kick.
* The factor rises almost linearly from a very slow crawl to normal speed,
* so the last frame before completion is already nearly identical to 1.0.
*/
if (t >= 1) {
this.autoResumeMotion = null;
return 1;
}
return motion.from + (motion.to - motion.from) * t;
};
NationsGlobe.prototype.startResetMotion = function () {
var toY;
this.velocityX = 0;
this.velocityY = 0;
this.dragging = false;
this.autoResumeMotion = null;
this.autoRotate = this.resetAutoRotate;
this.syncAutoButton();
this.root.classList.remove('is-dragging');
/*
* RESET restores the exact first-load state.
* The earth still takes the nearest visual path back, but the sky drift is
* compensated during the animation so the celestial background lands on the
* same phase it had when the globe first opened.
*/
toY = nearestEquivalentAngle(this.rotationY, this.resetRotationY);
this.resetMotion = {
elapsed: 0,
duration: Math.max(120, this.resetDuration),
fromX: this.rotationX,
fromY: this.rotationY,
fromZ: this.cameraZ,
fromStarDriftY: this.starDriftY,
fromCelestialDriftY: this.celestialDriftY,
toX: this.resetRotationX,
toY: toY,
finalY: this.resetRotationY,
toZ: this.resetCameraZ,
toStarDriftY: (toY - this.resetRotationY) * this.starParallax,
toCelestialDriftY: (toY - this.resetRotationY) * this.celestialParallax
};
};
NationsGlobe.prototype.stepResetMotion = function (dt) {
var motion = this.resetMotion;
var t;
var k;
if (!motion) return false;
motion.elapsed += dt;
t = clamp01(motion.elapsed / motion.duration);
k = easeOutCubic(t);
this.rotationX = motion.fromX + (motion.toX - motion.fromX) * k;
this.rotationY = motion.fromY + (motion.toY - motion.fromY) * k;
this.cameraZ = motion.fromZ + (motion.toZ - motion.fromZ) * k;
this.starDriftY = motion.fromStarDriftY + (motion.toStarDriftY - motion.fromStarDriftY) * k;
this.celestialDriftY = motion.fromCelestialDriftY + (motion.toCelestialDriftY - motion.fromCelestialDriftY) * k;
if (this.camera) this.camera.position.z = this.cameraZ;
this.updateCloudOpacity();
this.updateMoonOpacity(dt);
this.applyRotation();
if (t >= 1) {
this.rotationX = motion.toX;
this.rotationY = motion.finalY;
this.cameraZ = motion.toZ;
this.starDriftY = 0;
this.celestialDriftY = 0;
if (this.camera) this.camera.position.z = this.cameraZ;
this.updateCloudOpacity();
this.applyRotation();
this.resetMotion = null;
if (this.autoRotate) this.startAutoResumeMotion();
}
return true;
};
NationsGlobe.prototype.applyRotation = function () {
if (!this.group) return;
this.rotationX = Math.max(-1.15, Math.min(1.15, this.rotationX));
this.group.rotation.x = this.rotationX;
this.group.rotation.y = this.rotationY;
};
NationsGlobe.prototype.resize = function () {
if (!this.renderer || !this.camera || !this.stage) return;
var rect = this.stage.getBoundingClientRect();
var width = Math.max(320, Math.floor(rect.width || 0));
var height = Math.max(240, Math.floor(rect.height || 0));
if (!width || !height) return;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
};
NationsGlobe.prototype.syncAutoButton = function () {
if (this.autoButton) {
this.autoButton.classList.toggle('is-active', this.autoRotate);
}
};
NationsGlobe.prototype.bindControlEvents = function () {
var self = this;
if (this.autoButton && this.autoButton.getAttribute('data-clbi-globe-control-ready') !== '1') {
this.autoButton.setAttribute('data-clbi-globe-control-ready', '1');
this.autoButton.addEventListener('click', function (e) {
self.cancelResetMotion();
self.autoResumeMotion = null;
self.autoRotate = !self.autoRotate;
self.velocityX = 0;
self.velocityY = 0;
self.syncAutoButton();
e.preventDefault();
e.stopPropagation();
});
}
if (this.resetButton && this.resetButton.getAttribute('data-clbi-globe-control-ready') !== '1') {
this.resetButton.setAttribute('data-clbi-globe-control-ready', '1');
this.resetButton.addEventListener('click', function (e) {
self.startResetMotion();
e.preventDefault();
e.stopPropagation();
});
}
};
NationsGlobe.prototype.trackPointerPosition = function (e) {
if (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return;
this.pointerInside = true;
this.pointerClientX = e.clientX;
this.pointerClientY = e.clientY;
};
NationsGlobe.prototype.clearPointerPosition = function () {
this.pointerInside = false;
this.pointerClientX = null;
this.pointerClientY = null;
this.clearNationHover();
if (this.readout) {
this.setReadout(this.getBaseReadout());
}
};
NationsGlobe.prototype.getBaseReadout = function () {
return (this.root.getAttribute('data-era') || 'REAL WORLD / 110M') + ' · SEG ' + this.segmentCount;
};
NationsGlobe.prototype.bindEvents = function () {
var self = this;
if (this.stageEventsBound) {
this.bindControlEvents();
return;
}
this.stageEventsBound = true;
this.stage.addEventListener('pointerenter', function (e) {
self.trackPointerPosition(e);
self.updatePointerReadout(e);
});
this.stage.addEventListener('pointerleave', function () {
if (!self.dragging) {
self.clearPointerPosition();
}
});
this.stage.addEventListener('pointerdown', function (e) {
self.trackPointerPosition(e);
self.updatePointerReadout(e);
self.cancelResetMotion();
self.dragging = true;
self.pointerDownX = e.clientX;
self.pointerDownY = e.clientY;
self.pointerDownNationPage = self.currentNation ? self.currentNation.page : null;
self.lastX = e.clientX;
self.lastY = e.clientY;
self.lastMoveTime = e.timeStamp || performance.now();
self.velocityX = 0;
self.velocityY = 0;
self.root.classList.add('is-dragging');
self.autoResumeMotion = null;
self.autoRotate = false;
self.syncAutoButton();
if (self.stage.setPointerCapture && e.pointerId != null) {
try { self.stage.setPointerCapture(e.pointerId); } catch (err) {}
}
e.preventDefault();
});
this.stage.addEventListener('pointermove', function (e) {
self.trackPointerPosition(e);
if (self.dragging) {
var dx = e.clientX - self.lastX;
var dy = e.clientY - self.lastY;
var now = e.timeStamp || performance.now();
var elapsed = Math.max(8, Math.min(80, now - (self.lastMoveTime || now || performance.now())));
var stepY = dx * self.dragSensitivityY;
var stepX = dy * self.dragSensitivityX;
var instantY = (stepY / elapsed) * self.inertiaStrength;
var instantX = (stepX / elapsed) * self.inertiaStrength;
self.rotationY += stepY;
self.rotationX += stepX;
self.velocityY = self.velocityY * 0.58 + instantY * 0.42;
self.velocityX = self.velocityX * 0.58 + instantX * 0.42;
self.lastX = e.clientX;
self.lastY = e.clientY;
self.lastMoveTime = now;
self.applyRotation();
self.updatePointerReadout(e);
e.preventDefault();
} else {
self.updatePointerReadout(e);
}
});
function finishDrag(e) {
var now;
var releaseDelay;
var releaseSpeed;
if (!self.dragging) return;
now = e && e.timeStamp ? e.timeStamp : performance.now();
releaseDelay = now - (self.lastMoveTime || now);
releaseSpeed = Math.max(Math.abs(self.velocityX), Math.abs(self.velocityY));
/*
* Only a deliberate throw should keep spinning.
* If the user drags, holds, adjusts the viewing angle, and then releases,
* the last movement is old or slow, so the globe must settle immediately.
*/
if (releaseDelay > self.inertiaReleaseWindow || releaseSpeed < self.inertiaMinVelocity) {
self.velocityX = 0;
self.velocityY = 0;
}
if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
self.trackPointerPosition(e);
self.updatePointerReadout(e);
}
self.dragging = false;
self.root.classList.remove('is-dragging');
if (self.stage.releasePointerCapture && e && e.pointerId != null) {
try { self.stage.releasePointerCapture(e.pointerId); } catch (err) {}
}
self.handleNationClick(e);
}
this.stage.addEventListener('pointerup', finishDrag);
this.stage.addEventListener('pointercancel', finishDrag);
this.stage.addEventListener('wheel', function (e) {
self.trackPointerPosition(e);
self.cancelResetMotion();
self.velocityX = 0;
self.velocityY = 0;
self.cameraZ += e.deltaY * 0.0017;
self.cameraZ = Math.max(self.cameraZMin, Math.min(self.cameraZMax, self.cameraZ));
if (self.camera) self.camera.position.z = self.cameraZ;
self.updateCloudOpacity();
self.updateMoonOpacity();
self.updatePointerReadout(e);
e.preventDefault();
}, { passive: false });
this.bindControlEvents();
window.addEventListener('resize', function () {
self.resize();
});
};
NationsGlobe.prototype.updatePointerReadout = function (e) {
if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
this.trackPointerPosition(e);
}
if (this.pointerClientX === null || this.pointerClientY === null) return false;
return this.updatePointerReadoutFromClient(this.pointerClientX, this.pointerClientY);
};
NationsGlobe.prototype.updatePointerReadoutFromClient = function (clientX, clientY) {
var THREE = this.THREE;
var rect;
var intersects;
var local;
var ll;
if (!this.raycaster || !this.pointer || !this.camera || !this.earth || !this.renderer) return false;
rect = this.renderer.domElement.getBoundingClientRect();
if (!rect.width || !rect.height) return false;
if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
this.setReadout(this.getBaseReadout());
return false;
}
this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.pointer, this.camera);
intersects = this.raycaster.intersectObject(this.earth, false);
if (intersects && intersects.length) {
local = this.earth.worldToLocal(intersects[0].point.clone());
ll = vectorToLonLat(local);
this.updateNationHover(ll, clientX, clientY);
if (this.currentNation) {
this.setReadout(this.getNationLabel(this.currentNation.page) + ' · LAT ' + ll.lat.toFixed(2) + ' / LON ' + ll.lon.toFixed(2) + ' · SEG ' + this.segmentCount);
} else {
this.setReadout('LAT ' + ll.lat.toFixed(2) + ' / LON ' + ll.lon.toFixed(2) + ' · SEG ' + this.segmentCount);
}
return true;
}
this.clearNationHover();
this.setReadout(this.getBaseReadout());
return false;
};
NationsGlobe.prototype.loadNationInteractionData = function () {
var self = this;
var tasks = [];
if (this.nationHitUrl) {
tasks.push(
fetch(this.nationHitUrl, { credentials: 'same-origin' })
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + self.nationHitUrl);
return res.json();
})
.then(function (payload) {
var features = payload && payload.features ? payload.features : [];
self.nationHitFeatures = features
.filter(function (feature) {
return getFeaturePage(feature) && feature.geometry;
})
.map(function (feature) {
var bbox = feature.bbox || calculateGeometryBBox(feature.geometry);
return {
page: getFeaturePage(feature),
bbox: bbox,
bboxArea: Math.max(0, (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])),
geometry: feature.geometry
};
})
.sort(function (a, b) {
return a.bboxArea - b.bboxArea;
});
if (window.console && console.info) {
console.info('1950 nation hit data applied:', {
url: self.nationHitUrl,
features: self.nationHitFeatures.length
});
}
})
.catch(function (err) {
self.nationHitFeatures = [];
if (window.console && console.warn) console.warn('1950 nation hit data failed:', err);
})
);
}
if (this.nationLinkMapUrl) {
tasks.push(
fetch(this.nationLinkMapUrl, { credentials: 'same-origin', cache: 'no-cache' })
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + self.nationLinkMapUrl);
return res.json();
})
.then(function (payload) {
self.nationLinkMap = normalizeNationLinkMap(payload);
if (window.console && console.info) {
console.info('1950 nation link map applied:', {
url: self.nationLinkMapUrl,
items: Object.keys(self.nationLinkMap).length
});
}
})
.catch(function (err) {
self.nationLinkMap = {};
if (window.console && console.warn) console.warn('1950 nation link map failed:', err);
})
);
}
if (!tasks.length) return Promise.resolve();
return Promise.all(tasks).then(function () {});
};
NationsGlobe.prototype.findNationAtLonLat = function (ll) {
var lon = ll && Number(ll.lon);
var lat = ll && Number(ll.lat);
var i;
var feature;
if (!Number.isFinite(lon) || !Number.isFinite(lat) || !this.nationHitFeatures.length) return null;
for (i = 0; i < this.nationHitFeatures.length; i += 1) {
feature = this.nationHitFeatures[i];
if (!isLonLatInsideBbox(lon, lat, feature.bbox)) continue;
if (pointInGeometry(lon, lat, feature.geometry)) {
return {
page: feature.page,
feature: feature
};
}
}
return null;
};
NationsGlobe.prototype.getNationEntry = function (page) {
return page && this.nationLinkMap ? this.nationLinkMap[page] || null : null;
};
NationsGlobe.prototype.getNationLabel = function (page) {
var entry = this.getNationEntry(page);
return (entry && (entry.name_ko || entry.name || entry.wiki_title)) || page || '';
};
NationsGlobe.prototype.getNationUrl = function (page) {
var entry = this.getNationEntry(page);
var title;
if (!entry || entry.enabled === false) return '';
title = entry.wiki_title || entry.title || '';
if (!title) return '';
if (/^https?:\/\//i.test(title)) return title;
return this.nationLinkBase + encodeWikiTitle(title);
};
NationsGlobe.prototype.positionNationTooltip = function (clientX, clientY) {
var rootRect;
var tooltipRect;
var left;
var top;
if (!this.nationTooltip) return;
rootRect = this.root.getBoundingClientRect();
left = clientX - rootRect.left + 14;
top = clientY - rootRect.top + 14;
this.nationTooltip.style.left = left + 'px';
this.nationTooltip.style.top = top + 'px';
tooltipRect = this.nationTooltip.getBoundingClientRect();
if (tooltipRect.right > rootRect.right - 6) {
left = Math.max(6, clientX - rootRect.left - tooltipRect.width - 12);
}
if (tooltipRect.bottom > rootRect.bottom - 6) {
top = Math.max(6, clientY - rootRect.top - tooltipRect.height - 12);
}
this.nationTooltip.style.left = left + 'px';
this.nationTooltip.style.top = top + 'px';
};
NationsGlobe.prototype.updateNationHover = function (ll, clientX, clientY) {
var hit = this.findNationAtLonLat(ll);
var page = hit ? hit.page : '';
var label;
var url;
if (!hit || !page) {
this.clearNationHover();
return null;
}
this.currentNation = {
page: page,
entry: this.getNationEntry(page),
lon: ll.lon,
lat: ll.lat
};
label = this.getNationLabel(page);
url = this.getNationUrl(page);
this.root.classList.add('has-nation-hover');
if (this.nationTooltip) {
this.nationTooltip.innerHTML = '<span class="nation-tooltip-name">' + escapeHtml(label) + '</span>' +
'<span class="nation-tooltip-code">' + escapeHtml(page) + '</span>' +
(url ? '<span class="nation-tooltip-link">클릭하여 문서 열기 ›</span>' : '<span class="nation-tooltip-link">링크 미지정</span>');
this.nationTooltip.classList.add('is-visible');
this.nationTooltip.setAttribute('aria-hidden', 'false');
this.positionNationTooltip(clientX, clientY);
}
return this.currentNation;
};
NationsGlobe.prototype.clearNationHover = function () {
this.currentNation = null;
this.root.classList.remove('has-nation-hover');
if (this.nationTooltip) {
this.nationTooltip.classList.remove('is-visible');
this.nationTooltip.setAttribute('aria-hidden', 'true');
}
};
NationsGlobe.prototype.handleNationClick = function (e) {
var dx;
var dy;
var moved;
var page;
var url;
if (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return false;
if (this.pointerDownX === null || this.pointerDownY === null) return false;
dx = e.clientX - this.pointerDownX;
dy = e.clientY - this.pointerDownY;
moved = Math.sqrt(dx * dx + dy * dy);
page = this.currentNation ? this.currentNation.page : this.pointerDownNationPage;
this.pointerDownX = null;
this.pointerDownY = null;
this.pointerDownNationPage = null;
if (moved > this.clickMoveTolerance || !page) return false;
url = this.getNationUrl(page);
if (!url) return false;
if (e.ctrlKey || e.metaKey || e.shiftKey) {
window.open(url, '_blank', 'noopener');
} else {
window.location.href = url;
}
return true;
};
NationsGlobe.prototype.loadBoundaryData = function () {
var self = this;
var THREE = this.THREE;
var url = this.root.getAttribute('data-border-geojson-url') || this.root.getAttribute('data-topology-url') || this.root.getAttribute('data-geojson-url') || DEFAULT_WORLD_URL;
var objectName = this.root.getAttribute('data-topology-object') || 'countries';
this.setStatus('DATA');
return fetch(url, { credentials: 'omit' })
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url);
return res.json();
})
.then(function (payload) {
var geojson = extractGeojsonFromPayload(payload, self.topojson, objectName);
var built = buildLineGeometryFromGeojson(THREE, geojson, self.radius + Math.max(0.0008, Math.min(0.006, self.boundarySurfaceOffset)));
var material = new THREE.LineBasicMaterial({
color: 0xb8b8b8,
transparent: true,
opacity: 0.58,
depthWrite: false
});
var lines = new THREE.LineSegments(built.geometry, material);
lines.renderOrder = 8;
self.segmentCount = Math.floor(built.segmentCount || 0);
self.group.add(lines);
if (self.pointerInside && self.pointerClientX !== null && self.pointerClientY !== null) {
self.updatePointerReadoutFromClient(self.pointerClientX, self.pointerClientY);
} else {
self.setReadout(self.getBaseReadout());
}
});
};
NationsGlobe.prototype.animate = function (now) {
var self = this;
var dt = Math.min(50, now - this.lastTime);
this.lastTime = now;
if (this.disposed || !document.body.contains(this.root)) {
this.dispose();
return;
}
if (this.stepResetMotion(dt)) {
/* reset interpolation owns rotation/camera for this frame */
} else if (this.autoRotate && !this.dragging) {
this.rotationY += dt * this.autoRotateSpeed * this.getAutoRotateFactor(dt);
this.applyRotation();
} else if (!this.dragging && (Math.abs(this.velocityX) > 0.000001 || Math.abs(this.velocityY) > 0.000001)) {
var decay = Math.pow(this.inertiaDamping, dt / 16.6667);
this.rotationY += this.velocityY * dt;
this.rotationX += this.velocityX * dt;
this.velocityY *= decay;
this.velocityX *= decay;
if (Math.abs(this.velocityY) < 0.000001) this.velocityY = 0;
if (Math.abs(this.velocityX) < 0.000001) this.velocityX = 0;
this.applyRotation();
}
this.updateCelestialBackdrop(dt, now);
if (this.clouds) {
/*
* Cloud motion is relative to the rotating earth group.
* The parent group carries the globe's AUTO rotation; this local drift
* only represents slow atmospheric flow over that rotating planet.
*/
this.clouds.rotation.y += dt * this.cloudDriftSpeed;
this.clouds.rotation.x = Math.sin(now * 0.000018) * this.cloudTiltDrift;
this.updateCloudOpacity();
}
this.resize();
if (this.pointerInside && this.pointerClientX !== null && this.pointerClientY !== null) {
this.updatePointerReadoutFromClient(this.pointerClientX, this.pointerClientY);
}
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
this.frame = window.requestAnimationFrame(function (t) {
self.animate(t);
});
};
NationsGlobe.prototype.dispose = function () {
this.disposed = true;
if (this.frame) window.cancelAnimationFrame(this.frame);
if (this.renderer) {
try { this.renderer.dispose(); } catch (err) {}
}
};
function initNationsGlobes(root) {
var scope = root && root.querySelectorAll ? root : document;
var nodes = scope.querySelectorAll('[data-nations-globe], .clbi-nations-globe-window');
Array.prototype.forEach.call(nodes, function (node) {
var instance = node.CLBI_NationsGlobeInstance || null;
var hasStage = !!node.querySelector('.clbi-nations-globe-stage');
if (node.getAttribute('data-nations-globe-ready') === '1') {
if (instance && hasStage) {
instance.rehydrateDom();
return;
}
if (instance && !hasStage) {
try { instance.dispose(); } catch (err) {}
}
node.removeAttribute('data-nations-globe-ready');
node.CLBI_NationsGlobeInstance = null;
}
node.setAttribute('data-nations-globe-ready', '1');
instance = new NationsGlobe(node);
node.CLBI_NationsGlobeInstance = instance;
instances.push(instance);
instance.init();
});
}
function scheduleNationsGlobeRefresh() {
window.setTimeout(function () { initNationsGlobes(document); }, 0);
window.setTimeout(function () { initNationsGlobes(document); }, 120);
window.setTimeout(function () { initNationsGlobes(document); }, 360);
}
window.CLBI_NationsGlobe = {
init: initNationsGlobes,
instances: instances,
defaultWorldUrl: DEFAULT_WORLD_URL,
reload: function () {
initNationsGlobes(document);
}
};
$(function () {
initNationsGlobes(document);
});
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
initNationsGlobes($content && $content[0] ? $content[0] : document);
scheduleNationsGlobeRefresh();
});
}
window.addEventListener('pageshow', scheduleNationsGlobeRefresh);
window.addEventListener('focus', scheduleNationsGlobeRefresh);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) scheduleNationsGlobeRefresh();
});
})(window.mediaWiki || window.mw, window.jQuery);