미디어위키:NationsGlobe.js: 두 판 사이의 차이

편집 요약 없음
편집 요약 없음
393번째 줄: 393번째 줄:
     this.inertiaMinVelocity = toNumber(root.getAttribute('data-inertia-min-velocity'), 0.000085);
     this.inertiaMinVelocity = toNumber(root.getAttribute('data-inertia-min-velocity'), 0.000085);
     this.inertiaReleaseWindow = toNumber(root.getAttribute('data-inertia-release-window'), 150);
     this.inertiaReleaseWindow = toNumber(root.getAttribute('data-inertia-release-window'), 150);
    this.stageEventsBound = false;
     this.frame = null;
     this.frame = null;
     this.disposed = false;
     this.disposed = false;
403번째 줄: 404번째 줄:
     var title = root.getAttribute('data-title') || 'TERRITORY GLOBE';
     var title = root.getAttribute('data-title') || 'TERRITORY GLOBE';
     var era = root.getAttribute('data-era') || 'REAL WORLD / 110M';
     var era = root.getAttribute('data-era') || 'REAL WORLD / 110M';
    var topbarHtml;
    var bottombarHtml;


     root.classList.add('clbi-nations-globe-window');
     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')) {
     if (!root.querySelector('.clbi-nations-globe-stage')) {
410번째 줄: 428번째 줄:
             '<div class="clbi-nations-globe-stage" aria-label="3D globe viewport"></div>' +
             '<div class="clbi-nations-globe-stage" aria-label="3D globe viewport"></div>' +
             '<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>' +
             '<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>' +
             '<div class="clbi-nations-globe-topbar">' +
             topbarHtml +
                '<span class="clbi-nations-globe-title">' + escapeHtml(title) + '</span>' +
             bottombarHtml +
                '<span class="clbi-nations-globe-status">SYNC</span>' +
            '</div>' +
            '<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>' +
             '<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>' +
             '<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>' +
             '<div class="clbi-nations-globe-error"></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>');
        }
     }
     }


436번째 줄: 465번째 줄:
         this.autoButton.classList.toggle('is-active', this.autoRotate);
         this.autoButton.classList.toggle('is-active', this.autoRotate);
     }
     }
    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) {
NationsGlobe.prototype.setStatus = function (text) {
689번째 줄: 733번째 줄:
     this.camera.updateProjectionMatrix();
     this.camera.updateProjectionMatrix();
     this.renderer.setSize(width, height, false);
     this.renderer.setSize(width, height, false);
};
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.autoRotate = !self.autoRotate;
            self.velocityX = 0;
            self.velocityY = 0;
            self.autoButton.classList.toggle('is-active', self.autoRotate);
            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.bindEvents = function () {
NationsGlobe.prototype.bindEvents = function () {
     var self = this;
     var self = this;
    if (this.stageEventsBound) {
        this.bindControlEvents();
        return;
    }
    this.stageEventsBound = true;


     this.stage.addEventListener('pointerdown', function (e) {
     this.stage.addEventListener('pointerdown', function (e) {
780번째 줄: 858번째 줄:
     }, { passive: false });
     }, { passive: false });


     if (this.autoButton) {
     this.bindControlEvents();
        this.autoButton.addEventListener('click', function (e) {
            self.cancelResetMotion();
            self.autoRotate = !self.autoRotate;
            self.velocityX = 0;
            self.velocityY = 0;
            self.autoButton.classList.toggle('is-active', self.autoRotate);
            e.preventDefault();
            e.stopPropagation();
        });
    }
 
    if (this.resetButton) {
        this.resetButton.addEventListener('click', function (e) {
            self.startResetMotion();
            e.preventDefault();
            e.stopPropagation();
        });
    }


     window.addEventListener('resize', function () {
     window.addEventListener('resize', function () {
923번째 줄: 983번째 줄:


     Array.prototype.forEach.call(nodes, function (node) {
     Array.prototype.forEach.call(nodes, function (node) {
         if (node.getAttribute('data-nations-globe-ready') === '1') return;
        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');
         node.setAttribute('data-nations-globe-ready', '1');


         var instance = new NationsGlobe(node);
         instance = new NationsGlobe(node);
        node.CLBI_NationsGlobeInstance = instance;
         instances.push(instance);
         instances.push(instance);
         instance.init();
         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 = {
window.CLBI_NationsGlobe = {
948번째 줄: 1,032번째 줄:
     mw.hook('wikipage.content').add(function ($content) {
     mw.hook('wikipage.content').add(function ($content) {
         initNationsGlobes($content && $content[0] ? $content[0] : document);
         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);
})(window.mediaWiki || window.mw, window.jQuery);

2026년 7월 1일 (수) 06:30 판

/* =========================================
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 instances = [];
var threeModulePromise = null;
var topoModulePromise = null;

function escapeHtml(value) {
    return String(value == null ? '' : value)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
}

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 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 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 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.earth = null;
    this.earthMaterial = null;
    this.clouds = null;
    this.cloudMaterial = null;
    this.raycaster = null;
    this.pointer = 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.05);
    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);
    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.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.autoRotate = root.getAttribute('data-auto-rotate') !== 'false';
    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');
    this.autoButton = root.querySelector('.clbi-nations-globe-auto');
    this.resetButton = root.querySelector('.clbi-nations-globe-reset');

    if (this.autoButton) {
        this.autoButton.classList.toggle('is-active', this.autoRotate);
    }

    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 () {
            self.root.classList.add('is-ready');
            self.setStatus('READY');
            self.animate(performance.now());
        })
        .catch(function (err) {
            self.setError(err);
        });
};

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);

    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.scene.add(createStars(THREE, 780));

    this.raycaster = new THREE.Raycaster();
    this.pointer = new THREE.Vector2();

    this.applyRotation();
};


NationsGlobe.prototype.updateCloudOpacity = function () {
    if (!this.cloudMaterial) return;

    var minZ = 1.85;
    var maxZ = 5.2;
    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.loadOptionalTextures = function () {
    var self = this;
    var THREE = this.THREE;
    var tasks = [];

    if (this.terrainUrl && this.earthMaterial) {
        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.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;
};

NationsGlobe.prototype.startResetMotion = function () {
    this.velocityX = 0;
    this.velocityY = 0;
    this.dragging = false;
    this.root.classList.remove('is-dragging');

    this.resetMotion = {
        elapsed: 0,
        duration: Math.max(120, this.resetDuration),
        fromX: this.rotationX,
        fromY: this.rotationY,
        fromZ: this.cameraZ,
        toX: this.resetRotationX,
        toY: nearestEquivalentAngle(this.rotationY, this.resetRotationY),
        toZ: this.resetCameraZ
    };
};

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;

    if (this.camera) this.camera.position.z = this.cameraZ;
    this.updateCloudOpacity();
    this.applyRotation();

    if (t >= 1) {
        this.rotationX = motion.toX;
        this.rotationY = motion.toY;
        this.cameraZ = motion.toZ;
        if (this.camera) this.camera.position.z = this.cameraZ;
        this.updateCloudOpacity();
        this.applyRotation();
        this.resetMotion = null;
    }

    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.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.autoRotate = !self.autoRotate;
            self.velocityX = 0;
            self.velocityY = 0;
            self.autoButton.classList.toggle('is-active', self.autoRotate);
            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.bindEvents = function () {
    var self = this;

    if (this.stageEventsBound) {
        this.bindControlEvents();
        return;
    }

    this.stageEventsBound = true;

    this.stage.addEventListener('pointerdown', function (e) {
        self.cancelResetMotion();
        self.dragging = true;
        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.autoRotate = false;
        if (self.autoButton) self.autoButton.classList.remove('is-active');

        if (self.stage.setPointerCapture && e.pointerId != null) {
            try { self.stage.setPointerCapture(e.pointerId); } catch (err) {}
        }

        e.preventDefault();
    });

    this.stage.addEventListener('pointermove', function (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();
            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;
        }

        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) {}
        }
    }

    this.stage.addEventListener('pointerup', finishDrag);
    this.stage.addEventListener('pointercancel', finishDrag);

    this.stage.addEventListener('wheel', function (e) {
        self.cancelResetMotion();
        self.velocityX = 0;
        self.velocityY = 0;
        self.cameraZ += e.deltaY * 0.0017;
        self.cameraZ = Math.max(1.85, Math.min(5.2, self.cameraZ));
        if (self.camera) self.camera.position.z = self.cameraZ;
        self.updateCloudOpacity();
        e.preventDefault();
    }, { passive: false });

    this.bindControlEvents();

    window.addEventListener('resize', function () {
        self.resize();
    });
};

NationsGlobe.prototype.updatePointerReadout = function (e) {
    var THREE = this.THREE;
    var rect;
    var intersects;
    var local;
    var ll;

    if (!this.raycaster || !this.pointer || !this.camera || !this.earth) return;

    rect = this.renderer.domElement.getBoundingClientRect();
    this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
    this.pointer.y = -((e.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());
        local.applyEuler(new THREE.Euler(-this.group.rotation.x, -this.group.rotation.y, 0, 'XYZ'));
        ll = vectorToLonLat(local);
        this.setReadout('LAT ' + ll.lat.toFixed(2) + ' / LON ' + ll.lon.toFixed(2) + ' · SEG ' + this.segmentCount);
    }
};

NationsGlobe.prototype.loadBoundaryData = function () {
    var self = this;
    var THREE = this.THREE;
    var 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 * 1.009);
            var material = new THREE.LineBasicMaterial({
                color: 0xb8b8b8,
                transparent: true,
                opacity: 0.58,
                depthWrite: false
            });
            var lines = new THREE.LineSegments(built.geometry, material);

            self.segmentCount = Math.floor(built.segmentCount || 0);
            self.group.add(lines);
            self.setReadout((self.root.getAttribute('data-era') || 'REAL WORLD / 110M') + ' · SEG ' + self.segmentCount);
        });
};

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 * 0.000085;
        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();
    }

    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.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);