참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/* =========================================
COASTLINE: BLACK ICE - NationsGlobe
Real-world prototype / QGIS-ready globe viewer
========================================= */

(function (mw, $) {
'use strict';

var NATIONS_GLOBE_BUILD = '20260710-live-inertia-hover-hit-001';
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';
var DEFAULT_LOADING_GIF_FILE = 'Gfx-vhs-glitch-001.gif';
var CONNECTED_HOLD_MS = 1000;
var LOADING_DOTS_INTERVAL_MS = 420;

function interactionPerfMeasure(name, meta, fn) {
    var perf = window.InteractionPerf;
    if (perf && typeof perf.measureSync === 'function') {
        return perf.measureSync(name, meta || {}, fn);
    }
    return fn();
}

function interactionPerfMark(name, meta) {
    var perf = window.InteractionPerf;
    if (perf && typeof perf.mark === 'function') {
        perf.mark(name, meta || {});
    }
}

function interactionPerfNow() {
    return window.performance && performance.now ? performance.now() : Date.now();
}


/*
 * 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, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
}


function stripBorderLayerReadout(value) {
    var parts;

    parts = String(value == null ? '' : value).split('·').map(function (part) {
        return part.trim();
    }).filter(function (part) {
        return part && part !== 'CLBI BORDER LAYER';
    });

    return parts.join(' · ');
}

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

var GLOBE_TEXTURE_6K_SIZE = 6144;

function useSixKGlobeTextureUrl(url) {
    var value = String(url || '');

    if (!value) return '';

    return value
        .replace(/NASA_Satellite_Terrain\.webp/g, 'NASA_Satellite_Terrain_6144.webp')
        .replace(/Relief_8192_overlay\.webp/g, 'Relief_6144_overlay.webp');
}

function useSixKGlobeTextureSize(value, fallback) {
    var n = toNumber(value, fallback == null ? GLOBE_TEXTURE_6K_SIZE : fallback);

    if (n === 8192) {
        return GLOBE_TEXTURE_6K_SIZE;
    }

    return n;
}

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 uvToLonLat(uv) {
    var u;
    var v;

    if (!uv) return null;

    u = Number(uv.x);
    v = Number(uv.y);

    if (!Number.isFinite(u) || !Number.isFinite(v)) return null;

    return {
        lon: normalizeLongitude(u * 360 - 180),
        lat: Math.max(-90, Math.min(90, v * 180 - 90))
    };
}

function normalizeLongitude(lon) {
    while (lon < -180) lon += 360;
    while (lon > 180) lon -= 360;
    return lon;
}


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:240px;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-main{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;column-gap:6px;min-width:0;}',
        '.clbi-nations-globe-nation-tooltip .nation-tooltip-title-line{display:flex;align-items:center;min-width:0;min-height:18px;line-height:18px;}',
        '.clbi-nations-globe-nation-tooltip .nation-tooltip-flag-slot{display:flex;align-items:center;justify-content:center;gap:3px;min-width:18px;max-width:58px;height:100%;min-height:18px;flex:0 0 auto;align-self:stretch;overflow:visible;line-height:0;}',
        '.clbi-nations-globe-nation-tooltip .nation-tooltip-flag-slot.is-empty{display:none;}',
        '.clbi-nations-globe-nation-tooltip .nation-tooltip-flag{display:block;width:auto;height:auto;max-width:16px;max-height:16px;object-fit:contain;border:0;background:transparent;box-shadow:none;outline:0;box-sizing:content-box;filter:var(--flag-border-filter,drop-shadow(1px 0 0 #050505) drop-shadow(-1px 0 0 #050505) drop-shadow(0 1px 0 #050505) drop-shadow(0 -1px 0 #050505) drop-shadow(1px 0 0 #3a3a3a) drop-shadow(-1px 0 0 #3a3a3a) drop-shadow(0 1px 0 #3a3a3a) drop-shadow(0 -1px 0 #3a3a3a));}',
        '.clbi-nations-globe-nation-tooltip .nation-tooltip-text{display:flex;flex-direction:column;justify-content:center;min-width:0;min-height:18px;}',
        '.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-group{display:block;margin-top:2px;color:#b7a85f;font-size:10px;letter-spacing:.01em;}',
        '.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 parseNationHitRequiredTags(value) {
    var seen = {};
    var out = [];

    String(value || '').split(/[\s,;/|]+/).forEach(function (tag) {
        tag = String(tag || '').trim().toUpperCase();
        if (!tag || seen[tag]) return;
        seen[tag] = true;
        out.push(tag);
    });

    return out;
}

function defaultNationHitRequiredTags(url) {
    var text = String(url || '').toLowerCase();

    if (text.indexOf('1950') === -1) return [];
    return ['KAC', 'SOR', 'GDL', 'MRT', 'BER', 'MTR'];
}

function summarizeNationHitFeatures(features) {
    var tags = {};
    var list = [];

    (features || []).forEach(function (feature) {
        var page = getFeaturePage(feature).toUpperCase();
        if (!page || tags[page]) return;
        tags[page] = true;
        list.push(page);
    });

    list.sort();

    return {
        tags: tags,
        tagList: list,
        tagCount: list.length
    };
}

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 buildNationHitGrid(features, lonCells, latCells) {
    var grid = [];
    var cellLon;
    var cellLat;
    var lonMax = Math.max(1, Math.round(lonCells || 72));
    var latMax = Math.max(1, Math.round(latCells || 36));
    var i;
    var feature;
    var bbox;
    var minLon;
    var maxLon;
    var minLat;
    var maxLat;
    var lonStart;
    var lonEnd;
    var latStart;
    var latEnd;
    var x;
    var y;

    cellLon = 360 / lonMax;
    cellLat = 180 / latMax;

    for (i = 0; i < lonMax * latMax; i += 1) {
        grid[i] = [];
    }

    (features || []).forEach(function (item) {
        feature = item;
        bbox = feature && feature.bbox;
        if (!bbox || bbox.length < 4) return;

        minLat = Math.max(-90, Number(bbox[1]) - 0.25);
        maxLat = Math.min(90, Number(bbox[3]) + 0.25);
        if (!Number.isFinite(minLat) || !Number.isFinite(maxLat)) return;

        latStart = Math.max(0, Math.min(latMax - 1, Math.floor((minLat + 90) / cellLat)));
        latEnd = Math.max(0, Math.min(latMax - 1, Math.floor((maxLat + 90) / cellLat)));

        if ((Number(bbox[2]) - Number(bbox[0])) >= 300) {
            lonStart = 0;
            lonEnd = lonMax - 1;
        } else {
            minLon = Math.max(-180, Number(bbox[0]) - 0.25);
            maxLon = Math.min(180, Number(bbox[2]) + 0.25);
            if (!Number.isFinite(minLon) || !Number.isFinite(maxLon)) return;
            lonStart = Math.max(0, Math.min(lonMax - 1, Math.floor((minLon + 180) / cellLon)));
            lonEnd = Math.max(0, Math.min(lonMax - 1, Math.floor((maxLon + 180) / cellLon)));
        }

        for (y = latStart; y <= latEnd; y += 1) {
            for (x = lonStart; x <= lonEnd; x += 1) {
                grid[y * lonMax + x].push(feature);
            }
        }
    });

    return {
        grid: grid,
        lonCells: lonMax,
        latCells: latMax,
        cellLon: cellLon,
        cellLat: cellLat
    };
}

function getNationHitGridCandidates(index, lon, lat) {
    var lonCells;
    var latCells;
    var x;
    var y;

    if (!index || !index.grid || !index.grid.length) return null;
    lonCells = index.lonCells || 72;
    latCells = index.latCells || 36;

    lon = normalizeLongitude(Number(lon));
    lat = Number(lat);
    if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;

    x = Math.floor((lon + 180) / (index.cellLon || (360 / lonCells)));
    y = Math.floor((Math.max(-90, Math.min(90, lat)) + 90) / (index.cellLat || (180 / latCells)));
    x = Math.max(0, Math.min(lonCells - 1, x));
    y = Math.max(0, Math.min(latCells - 1, y));

    return index.grid[y * lonCells + x] || [];
}

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 = {};

    function put(key, entry) {
        key = String(key || '').trim();
        if (!key || map[key]) return;
        map[key] = entry;
    }

    Object.keys(source).forEach(function (key) {
        var raw = source[key];
        var page = String((raw && raw.page) || key || '').trim();
        var entry;
        var tag;
        var title;

        if (!page && !key) return;

        if (typeof raw === 'string') {
            entry = {
                page: page || key,
                tag: key,
                name_ko: raw,
                wiki_title: raw,
                enabled: true
            };
        } else {
            entry = Object.assign({
                page: page || key,
                tag: String((raw && raw.tag) || key || '').trim(),
                name_ko: '',
                wiki_title: '',
                enabled: true,
                note: ''
            }, raw || {});
            entry.page = String(entry.page || page || key || '').trim();
            entry.tag = String(entry.tag || key || entry.page || '').trim();
        }

        tag = String(entry.tag || key || '').trim();
        title = String(entry.wiki_title || entry.title || '').trim();

        put(key, entry);
        put(tag, entry);
        put(entry.page, entry);
        put(title, entry);
    });

    return map;
}


function normalizeNationListSymbolMap(payload) {
    var map = {};
    var continents = payload && Array.isArray(payload.continents) ? payload.continents : [];

    function put(key, entry) {
        key = String(key || '').trim();
        if (!key || map[key]) return;
        map[key] = entry;
    }

    function addItem(raw) {
        var tag;
        var page;
        var title;
        var label;
        var files;
        var entry;

        if (!raw || typeof raw !== 'object') return;

        tag = String(raw.tag || raw.code || raw.id || '').trim();
        page = String(raw.page || tag || '').trim();
        title = String(raw.wiki_title || raw.title || '').trim();
        label = String(raw.globe_label || raw.globeLabel || raw.label || raw.name_ko || raw.name || '').trim();
        files = collectNationEntryFlagFiles(raw);
        if (!files.length) return;

        entry = {
            flag_file: files[0],
            symbols: files.slice(0, 3).map(function (file) { return { file: file }; })
        };

        put(tag, entry);
        put(page, entry);
        put(title, entry);
        put(label, entry);
    }

    continents.forEach(function (continent) {
        var regions = continent && Array.isArray(continent.regions) ? continent.regions : [];
        regions.forEach(function (region) {
            var items = region && Array.isArray(region.items) ? region.items : [];
            items.forEach(addItem);
        });
    });

    if (!continents.length && payload && payload.items && typeof payload.items === 'object') {
        Object.keys(payload.items).forEach(function (key) {
            var raw = payload.items[key];
            if (raw && typeof raw === 'object' && !raw.tag) raw = Object.assign({ tag: key }, raw);
            addItem(raw);
        });
    }

    return map;
}

function mergeNationListSymbols(linkMap, symbolMap) {
    var map = linkMap || {};
    var symbols = symbolMap || {};

    Object.keys(symbols).forEach(function (key) {
        var target = map[key];
        var files = collectNationEntryFlagFiles(symbols[key]);

        if (!target || !files.length) return;
        target.symbols = files.slice(0, 3).map(function (file) { return { file: file }; });
        target.flag_file = files[0];
    });

    return map;
}

function encodeWikiTitle(title) {
    return String(title || '')
        .trim()
        .replace(/ /g, '_')
        .split('/')
        .map(function (part) { return encodeURIComponent(part); })
        .join('/');
}

function fileRedirectUrl(file) {
    var title = String(file || '').trim();
    if (!title) return '';
    title = title.replace(/^File:/i, '').replace(/^파일:/i, '').trim();
    return '/index.php/Special:Redirect/file/' + encodeWikiTitle(title);
}

function getLoadingGifRef(root) {
    var direct = root && (
        root.getAttribute('data-loading-gif-url') ||
        root.getAttribute('data-globe-loading-gif-url') ||
        root.getAttribute('data-vhs-loading-gif-url')
    );
    var file = root && (
        root.getAttribute('data-loading-gif-file') ||
        root.getAttribute('data-globe-loading-gif-file') ||
        root.getAttribute('data-vhs-loading-gif-file')
    );

    if (direct) return String(direct).trim();
    if (window.NationsGlobeLoadingGifUrl) return String(window.NationsGlobeLoadingGifUrl).trim();
    if (file) return fileRedirectUrl(file);
    if (window.NationsGlobeLoadingGifFile) return fileRedirectUrl(window.NationsGlobeLoadingGifFile);
    if (window.NationsGlobeLoadingGifRef) return String(window.NationsGlobeLoadingGifRef).trim();
    return fileRedirectUrl(DEFAULT_LOADING_GIF_FILE);
}

function extractRawTitleFromUrl(url) {
    var text = String(url || '').trim();
    var match;
    if (!text) return '';
    match = text.match(/[?&]title=([^&#]+)/i);
    if (match) {
        try { return decodeURIComponent(match[1].replace(/\+/g, ' ')).replace(/_/g, ' ').trim(); }
        catch (err) { return match[1].replace(/_/g, ' ').trim(); }
    }
    return '';
}

function appendCacheBust(url, ref) {
    var text = String(url || '').trim();
    var resourceRef = String(ref || extractRawTitleFromUrl(text) || '').trim();

    if (!text) return text;
    if (/[?&](?:_clbiBust|_|_entryAsset|_entryFileRev|_clbiFileRev|_entryRev)=/.test(text)) return text;
    if (resourceRef && window.RevisionManifest && typeof window.RevisionManifest.addRevisionParam === 'function') {
        return window.RevisionManifest.addRevisionParam(text, resourceRef);
    }
    return text;
}

function fetchJsonResource(url, fallbackRef) {
    var ref = String(extractRawTitleFromUrl(url) || fallbackRef || '').trim();
    var requestUrl;
    if (ref && window.EntryStore && typeof window.EntryStore.fetchJsonRef === 'function') {
        return window.EntryStore.fetchJsonRef(ref, { noStore: false });
    }
    requestUrl = appendCacheBust(url, ref);
    return fetch(requestUrl, { credentials: 'same-origin', cache: 'force-cache' })
        .then(function (res) {
            if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + requestUrl);
            return res.json();
        });
}

function getWikiApiUrl(params) {
    var query = [];
    Object.keys(params || {}).forEach(function (key) {
        if (params[key] == null) return;
        query.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(params[key])));
    });
    return (window.mw && mw.util && mw.util.wikiScript ? mw.util.wikiScript('api') : '/api.php') + '?' + query.join('&');
}

var latestFileUrlCache = {};

function isWikiFileRedirectUrl(url) {
    var text = String(url || '').trim();
    return /^(?:file|파일)\s*:/i.test(text) || /(?:^|\/)(?:(?:Special|특수)\s*[:\/]\s*)+(?:Redirect|넘겨주기)\s*\/\s*file\s*\//i.test(text);
}

function appendFileRevisionBust(url, revision) {
    var out = String(url || '').trim();
    var separator;

    if (!out) return '';
    if (/[?&](?:_clbiFileRev|_entryFileRev)=/.test(out)) return out;
    separator = out.indexOf('?') === -1 ? '?' : '&';
    out += separator + '_clbiFileRev=' + encodeURIComponent(String(revision || 'file'));
    return out;
}

function resolveLatestWikiFileUrl(url) {
    var original = String(url || '').trim();
    var file;
    var key;

    if (!original) return Promise.resolve(original);

    if (window.EntryStore && typeof window.EntryStore.resolveFileUrl === 'function') {
        return window.EntryStore.resolveFileUrl(original).then(function (resolved) {
            var finalUrl = resolved || appendCacheBust(original);
            if (window.EntryStore && typeof window.EntryStore.getImageDisplayUrl === 'function') {
                return window.EntryStore.getImageDisplayUrl(finalUrl) || finalUrl;
            }
            return finalUrl;
        }).catch(function () {
            return appendCacheBust(original);
        });
    }

    if (!isWikiFileRedirectUrl(original)) return Promise.resolve(appendCacheBust(original));

    file = normalizeFileTitle(original);
    if (!file) return Promise.resolve(appendCacheBust(original));

    key = file.replace(/_/g, ' ').trim().toLowerCase();
    if (latestFileUrlCache[key]) return latestFileUrlCache[key];

    latestFileUrlCache[key] = fetch(getWikiApiUrl({
        action: 'query',
        format: 'json',
        formatversion: '2',
        prop: 'imageinfo',
        iiprop: 'url|sha1|timestamp|size|mime',
        titles: 'File:' + file
    }), { credentials: 'same-origin', cache: 'force-cache' })
        .then(function (res) {
            if (!res.ok) throw new Error('fileinfo failed: ' + res.status);
            return res.json();
        })
        .then(function (json) {
            var pages = (json && json.query && json.query.pages) || [];
            var page = pages[0] || null;
            var info = page && page.imageinfo && page.imageinfo[0] ? page.imageinfo[0] : null;
            var resolved = info && info.url ? info.url : original;
            var revision = info && (info.sha1 || info.timestamp || info.size) ? (info.sha1 || info.timestamp || info.size) : 'file';

            if (window.console && console.info) {
                console.info('CLBI globe file resolved:', {
                    file: file,
                    url: resolved,
                    revision: revision,
                    size: info && info.size ? info.size : null,
                    mime: info && info.mime ? info.mime : ''
                });
            }

            return appendFileRevisionBust(resolved, revision);
        })
        .catch(function (err) {
            if (window.console && console.warn) console.warn('CLBI globe file resolve fallback:', file, err);
            return appendCacheBust(original);
        });

    return latestFileUrlCache[key];
}


function normalizeFileTitle(value) {
    var text = String(value || '').trim();
    var titleMatch;
    var redirectMatch;
    var previous;
    var i;
    var j;

    if (!text) return '';

    text = text.replace(/^\[\[:?\s*/i, '').replace(/\]\]$/i, '').trim();
    if (text.indexOf('|') !== -1) text = text.split('|')[0].trim();

    for (i = 0; i < 8; i += 1) {
        previous = text;

        for (j = 0; j < 3; j += 1) {
            try {
                if (/%[0-9a-f]{2}/i.test(text)) text = decodeURIComponent(text);
            } catch (err) {
                break;
            }
        }

        text = text.replace(/\\/g, '/').trim();

        titleMatch = text.match(/[?&]title=([^&#]+)/i);
        if (titleMatch) {
            text = titleMatch[1];
            continue;
        }

        text = text
            .replace(/[?#].*$/g, '')
            .replace(/^https?:\/\/[^/]+/i, '')
            .replace(/^\/+/, '')
            .replace(/^index\.php\/?/i, '')
            .replace(/^wiki\/?/i, '')
            .trim();

        redirectMatch = text.match(/^(?:(?:Special|특수)\s*[:/]\s*)+(?:Redirect|넘겨주기)\s*\/\s*file\s*\/(.+)$/i) ||
            text.match(/^(?:Redirect|넘겨주기)\s*\/\s*file\s*\/(.+)$/i) ||
            text.match(/(?:^|\/)(?:(?:Special|특수)\s*[:/]\s*)+(?:Redirect|넘겨주기)\s*\/\s*file\s*\/(.+)$/i);

        if (redirectMatch) {
            text = redirectMatch[1].trim();
            continue;
        }

        for (j = 0; j < 6; j += 1) {
            var stripped = text
                .replace(/^\s*파일\s*:/i, '')
                .replace(/^\s*File\s*:/i, '')
                .replace(/^\s*이미지\s*:/i, '')
                .replace(/^\s*Image\s*:/i, '')
                .replace(/^:+/, '')
                .trim();
            if (stripped === text) break;
            text = stripped;
        }

        if (text === previous) break;
    }

    return text.replace(/^\/+/, '').replace(/^:+/, '').trim();
}

function buildFileRedirectUrl(value, width) {
    var file = normalizeFileTitle(value);
    var url;

    if (!file) return '';
    url = '/index.php/Special:Redirect/file/' + encodeWikiTitle(file);
    if (width) url += '?width=' + encodeURIComponent(String(width));
    return url;
}

function parseHexColor(value, fallback) {
    var text = String(value || '').trim();
    var match;

    if (/^#[0-9a-f]{3}$/i.test(text)) {
        text = '#' + text.charAt(1) + text.charAt(1) + text.charAt(2) + text.charAt(2) + text.charAt(3) + text.charAt(3);
    }

    match = /^#([0-9a-f]{6})$/i.exec(text);
    if (!match) return fallback;
    return parseInt(match[1], 16);
}

function hexToRgb(value, fallback) {
    var color = parseHexColor(value, fallback == null ? 0xffffff : fallback);
    return {
        r: (color >> 16) & 255,
        g: (color >> 8) & 255,
        b: color & 255
    };
}

function projectLonLatToCanvas(lon, lat, width, height) {
    return {
        x: ((normalizeLongitude(lon) + 180) / 360) * width,
        y: ((90 - Math.max(-90, Math.min(90, lat))) / 180) * height
    };
}

function drawRingOnCanvas(ctx, ring, width, height) {
    var i;
    var point;
    var projected;

    if (!Array.isArray(ring) || ring.length < 2) return;

    for (i = 0; i < ring.length; i += 1) {
        point = ring[i];
        if (!point || point.length < 2) continue;
        projected = projectLonLatToCanvas(Number(point[0]), Number(point[1]), width, height);
        if (i === 0) ctx.moveTo(projected.x, projected.y);
        else ctx.lineTo(projected.x, projected.y);
    }

    ctx.closePath();
}

function drawPolygonOnCanvas(ctx, polygon, width, height) {
    if (!Array.isArray(polygon) || !polygon.length) return;
    polygon.forEach(function (ring) {
        drawRingOnCanvas(ctx, ring, width, height);
    });
}

function drawGeometryOnCanvas(ctx, geometry, width, height) {
    if (!geometry) return;

    if (geometry.type === 'Polygon') {
        drawPolygonOnCanvas(ctx, geometry.coordinates, width, height);
    } else if (geometry.type === 'MultiPolygon') {
        geometry.coordinates.forEach(function (polygon) {
            drawPolygonOnCanvas(ctx, polygon, width, height);
        });
    } else if (geometry.type === 'GeometryCollection') {
        geometry.geometries.forEach(function (child) {
            drawGeometryOnCanvas(ctx, child, width, height);
        });
    }
}

function disposeObject3D(object) {
    if (!object) return;
    if (object.geometry && typeof object.geometry.dispose === 'function') object.geometry.dispose();
    if (object.material) {
        if (object.material.map && typeof object.material.map.dispose === 'function') object.material.map.dispose();
        if (typeof object.material.dispose === 'function') object.material.dispose();
    }
}

function normalizeLonDelta(a, b) {
    var d = b - a;
    if (d > 180) d -= 360;
    if (d < -180) d += 360;
    return d;
}


function squaredPointSegmentDistance(point, start, end) {
    var x = Number(point[0]);
    var y = Number(point[1]);
    var x1 = Number(start[0]);
    var y1 = Number(start[1]);
    var x2 = Number(end[0]);
    var y2 = Number(end[1]);
    var dx = x2 - x1;
    var dy = y2 - y1;
    var t;
    var px;
    var py;

    if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) return 0;
    if (dx === 0 && dy === 0) {
        dx = x - x1;
        dy = y - y1;
        return dx * dx + dy * dy;
    }

    t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
    t = Math.max(0, Math.min(1, t));
    px = x1 + t * dx;
    py = y1 + t * dy;
    dx = x - px;
    dy = y - py;
    return dx * dx + dy * dy;
}

function simplifyCoordinateLine(coords, tolerance) {
    var closed;
    var line;
    var keep;
    var stack;
    var sqTolerance;
    var first;
    var last;
    var maxSqDist;
    var index;
    var i;
    var sqDist;
    var result;

    if (!Array.isArray(coords) || coords.length <= 2 || !tolerance) return coords;

    closed = coords.length > 3 && coords[0] && coords[coords.length - 1] && Number(coords[0][0]) === Number(coords[coords.length - 1][0]) && Number(coords[0][1]) === Number(coords[coords.length - 1][1]);
    line = closed ? coords.slice(0, -1) : coords.slice();
    if (line.length <= 2) return coords;

    keep = new Array(line.length).fill(false);
    keep[0] = true;
    keep[line.length - 1] = true;
    stack = [[0, line.length - 1]];
    sqTolerance = tolerance * tolerance;

    while (stack.length) {
        first = stack[stack.length - 1][0];
        last = stack[stack.length - 1][1];
        stack.pop();
        maxSqDist = 0;
        index = -1;

        for (i = first + 1; i < last; i += 1) {
            sqDist = squaredPointSegmentDistance(line[i], line[first], line[last]);
            if (sqDist > maxSqDist) {
                index = i;
                maxSqDist = sqDist;
            }
        }

        if (index !== -1 && maxSqDist > sqTolerance) {
            keep[index] = true;
            stack.push([first, index]);
            stack.push([index, last]);
        }
    }

    result = line.filter(function (_, idx) { return keep[idx]; });
    if (closed && result.length) result.push(result[0]);
    return result.length >= 2 ? result : coords;
}

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, simplifyTolerance) {
    var i;
    if (!Array.isArray(coords) || coords.length < 2) return;

    coords = simplifyCoordinateLine(coords, simplifyTolerance || 0);

    for (i = 0; i < coords.length - 1; i += 1) {
        pushLineSegment(THREE, positions, coords[i], coords[i + 1], radius);
    }
}

function pushPolygonRings(THREE, positions, rings, radius, simplifyTolerance) {
    if (!Array.isArray(rings)) return;

    rings.forEach(function (ring) {
        pushLineString(THREE, positions, ring, radius, simplifyTolerance);
    });
}

function pushGeometryLines(THREE, positions, geometry, radius, simplifyTolerance) {
    if (!geometry) return;

    if (geometry.type === 'LineString') {
        pushLineString(THREE, positions, geometry.coordinates, radius, simplifyTolerance);
        return;
    }

    if (geometry.type === 'MultiLineString') {
        geometry.coordinates.forEach(function (line) {
            pushLineString(THREE, positions, line, radius, simplifyTolerance);
        });
        return;
    }

    if (geometry.type === 'Polygon') {
        pushPolygonRings(THREE, positions, geometry.coordinates, radius, simplifyTolerance);
        return;
    }

    if (geometry.type === 'MultiPolygon') {
        geometry.coordinates.forEach(function (polygon) {
            pushPolygonRings(THREE, positions, polygon, radius, simplifyTolerance);
        });
        return;
    }

    if (geometry.type === 'GeometryCollection') {
        geometry.geometries.forEach(function (child) {
            pushGeometryLines(THREE, positions, child, radius, simplifyTolerance);
        });
    }
}

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, simplifyTolerance) {
    var positions = [];

    if (geojson.type === 'FeatureCollection') {
        geojson.features.forEach(function (feature) {
            pushGeometryLines(THREE, positions, feature.geometry, radius, simplifyTolerance || 0);
        });
    } else if (geojson.type === 'Feature') {
        pushGeometryLines(THREE, positions, geojson.geometry, radius, simplifyTolerance || 0);
    } else {
        pushGeometryLines(THREE, positions, geojson, radius, simplifyTolerance || 0);
    }

    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 resolveLatestWikiFileUrl(url).then(function (resolvedUrl) {
        return new Promise(function (resolve, reject) {
            var loader = new THREE.TextureLoader();

            loader.load(
                resolvedUrl,
                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: ' + resolvedUrl));
                }
            );
        });
    });
}

function loadImageElement(url) {
    return resolveLatestWikiFileUrl(url).then(function (resolvedUrl) {
        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: ' + resolvedUrl));
            };

            image.src = resolvedUrl;
        });
    });
}

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 resolveLatestWikiFileUrl(url).then(function (resolvedUrl) {
        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: ' + resolvedUrl));
            };

            image.src = resolvedUrl;
        });
    });
}

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.loadingScreen = null;
    this.loadingGif = null;
    this.loadingFreeze = null;
    this.loadingText = null;
    this.loadingLoopTimer = null;
    this.connectedTimer = null;
    this.loadingDotIndex = 0;
    this.loadingGifRef = getLoadingGifRef(root);
    this.loadingGifLoaded = false;
    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.nationHitRequiredTags = parseNationHitRequiredTags(root.getAttribute('data-nation-hit-required-tags'));
    if (!this.nationHitRequiredTags.length) this.nationHitRequiredTags = defaultNationHitRequiredTags(this.nationHitUrl);
    this.nationHitMinFeatureCount = Math.max(0, Math.round(toNumber(root.getAttribute('data-nation-hit-min-features'), this.nationHitRequiredTags.length ? 1540 : 0)));
    this.nationHitMinTagCount = Math.max(0, Math.round(toNumber(root.getAttribute('data-nation-hit-min-tags'), this.nationHitRequiredTags.length ? 190 : 0)));
    this.nationHitValidationWarning = '';
    this.nationHitGeojsonLazy = root.getAttribute('data-nation-hit-geojson-lazy') === '1';
    this.nationHitCoordinateMode = root.getAttribute('data-nation-hit-coordinate-mode') || 'uv';
    this.nationHitLonOffset = toNumber(root.getAttribute('data-nation-hit-lon-offset'), 0);
    this.nationHitLatOffset = toNumber(root.getAttribute('data-nation-hit-lat-offset'), 0);
    this.nationLinkMapUrl = root.getAttribute('data-nation-link-map-url') || '';
    this.nationListUrl = root.getAttribute('data-nation-list-url') || String(this.nationLinkMapUrl || '')
        .replace(/_Nation_Link_Map/gi, '_Nation_List')
        .replace(/ Nation Link Map/gi, ' Nation List');
    this.nationLinkBase = root.getAttribute('data-nation-link-base') || '/index.php/';
    this.nationHitFeatures = [];
    this.nationHitSpatialIndex = null;
    this.nationHitSpatialLonCells = 72;
    this.nationHitSpatialLatCells = 36;
    this.nationHitFeaturesLoading = null;
    this.nationLinkMap = {};
    this.nationListSymbolMap = {};
    this.currentNation = null;
    this.currentNationVisualPage = '';
    this.currentNationVisualKey = '';
    this.nationTooltip = null;
    this.nationTooltipFlagSlot = null;
    this.nationTooltipFlag = null;
    this.nationTooltipName = null;
    this.nationTooltipCode = null;
    this.nationTooltipGroup = null;
    this.nationTooltipLink = null;
    this.nationFlagImageCache = {};
    this.nationFlagImageCacheOrder = [];
    this.nationFlagImageCacheMax = Math.max(16, Math.min(512, Math.round(toNumber(root.getAttribute('data-nation-flag-cache-limit'), 128))));
    this.nationFlagIdlePreloadEnabled = root.getAttribute('data-nation-flag-idle-preload') === '1';
    this.nationFlagIdlePreloadDelay = Math.max(0, Math.min(8000, Math.round(toNumber(root.getAttribute('data-nation-flag-idle-preload-delay'), 900))));
    this.nationFlagIdlePreloadConcurrency = Math.max(1, Math.min(4, Math.round(toNumber(root.getAttribute('data-nation-flag-idle-preload-concurrency'), 2))));
    this.nationFlagIdlePreloadScheduled = false;
    this.nationFlagIdlePreloadStarted = false;
    this.nationHoverSurface = null;
    this.nationHoverBorder = null;
    this.nationHoverBorders = [];
    this.nationHoverCanvas = null;
    this.nationHoverTexture = null;
    this.nationHoverFillMaterial = null;
    this.nationHoverBorderMaterial = null;
    this.nationHoverLinkedBorderMaterial = null;
    this.nationHoverAncestorBorderMaterial = null;
    this.nationHoverDescendantBorderMaterial = null;
    this.nationHoverRelatedBorderMaterial = null;
    this.nationHoverBorderCache = {};
    this.nationHoverBorderCacheOrder = [];
    this.nationHoverPendingPage = '';
    this.pointerReadoutRaf = null;
    this.pendingPointerClientX = null;
    this.pendingPointerClientY = null;
    this.tooltipFollowRaf = null;
    this.pendingTooltipClientX = null;
    this.pendingTooltipClientY = null;
    this.tooltipFollowRootRect = null;
    this.tooltipFollowRootRectAt = 0;
    this.tooltipFollowWidth = 0;
    this.tooltipFollowHeight = 0;
    this.nationHoverEnabled = root.getAttribute('data-nation-hover-highlight') !== '0';
    this.nationHoverFillOpacity = toNumber(root.getAttribute('data-nation-hover-fill-opacity'), 0.16);
    this.nationHoverBorderEnabled = root.getAttribute('data-nation-hover-border-enabled') === '1' || root.getAttribute('data-nation-hover-border') === '1';
    this.nationHoverBorderColor = root.getAttribute('data-nation-hover-border-color') || '#f2c300';
    this.nationHoverBorderOpacity = toNumber(root.getAttribute('data-nation-hover-border-opacity'), 0);
    this.nationHoverBorderWidth = toNumber(root.getAttribute('data-nation-hover-border-width'), 1.1);
    this.nationHoverBorderTextureEnabled = root.getAttribute('data-nation-hover-border-texture') === '1';
    this.nationHoverBorderGeometryEnabled = root.getAttribute('data-nation-hover-border-geometry') === '1';
    this.nationHoverSurfaceOffset = toNumber(root.getAttribute('data-nation-hover-surface-offset'), 0.0024);
    this.nationHoverBorderSurfaceOffset = toNumber(root.getAttribute('data-nation-hover-border-surface-offset'), 0.00255);
    this.nationHoverTextureWidth = Math.max(256, Math.min(2048, Math.round(toNumber(root.getAttribute('data-nation-hover-texture-width'), 512))));
    this.nationHoverBorderSimplify = Math.max(0, Math.min(0.25, toNumber(root.getAttribute('data-nation-hover-border-simplify'), 0.025)));
    this.nationHoverBorderCacheMax = Math.max(1, Math.min(256, Math.round(toNumber(root.getAttribute('data-nation-hover-border-cache-max'), 96))));
    this.nationHoverGroupsEnabled = root.getAttribute('data-nation-hover-groups') !== '0';
    this.nationHoverTreeEnabled = root.getAttribute('data-nation-hover-tree') !== '0';
    this.nationHoverTreeScope = root.getAttribute('data-nation-hover-tree-scope') || 'root';
    this.nationHoverLinkedFillOpacity = Math.max(0, Math.min(0.24, toNumber(root.getAttribute('data-nation-hover-linked-fill-opacity'), 0.055)));
    this.nationHoverLinkedBorderOpacity = Math.max(0, Math.min(1, toNumber(root.getAttribute('data-nation-hover-linked-border-opacity'), 0.42)));
    this.nationHoverAncestorFillOpacity = Math.max(0, Math.min(0.24, toNumber(root.getAttribute('data-nation-hover-ancestor-fill-opacity'), 0.085)));
    this.nationHoverAncestorBorderOpacity = Math.max(0, Math.min(1, toNumber(root.getAttribute('data-nation-hover-ancestor-border-opacity'), 0.58)));
    this.nationHoverDescendantFillOpacity = Math.max(0, Math.min(0.24, toNumber(root.getAttribute('data-nation-hover-descendant-fill-opacity'), 0.065)));
    this.nationHoverDescendantBorderOpacity = Math.max(0, Math.min(1, toNumber(root.getAttribute('data-nation-hover-descendant-border-opacity'), 0.48)));
    this.nationHoverRelatedFillOpacity = Math.max(0, Math.min(0.24, toNumber(root.getAttribute('data-nation-hover-related-fill-opacity'), this.nationHoverLinkedFillOpacity)));
    this.nationHoverRelatedBorderOpacity = Math.max(0, Math.min(1, toNumber(root.getAttribute('data-nation-hover-related-border-opacity'), this.nationHoverLinkedBorderOpacity)));
    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.nationFocusMotion = null;
    this.nationFocusDuration = Math.max(180, Math.min(1800, toNumber(root.getAttribute('data-nation-panel-focus-duration'), 820)));
    this.nationFocusCameraZ = root.getAttribute('data-nation-panel-focus-camera-z') ? toNumber(root.getAttribute('data-nation-panel-focus-camera-z'), this.cameraZ) : null;
    this.nationFocusZoomMode = String(root.getAttribute('data-nation-panel-focus-zoom-mode') || 'auto').toLowerCase();
    this.nationFocusFillRatio = Math.max(0.08, Math.min(0.82, toNumber(root.getAttribute('data-nation-panel-focus-fill-ratio'), 1 / 3)));
    this.nationFocusZoomPadding = Math.max(0.72, Math.min(2.4, toNumber(root.getAttribute('data-nation-panel-focus-zoom-padding'), 1.08)));
    this.nationFocusMinSpanDeg = Math.max(0.015, Math.min(20, toNumber(root.getAttribute('data-nation-panel-focus-min-span-deg'), 0.28)));
    this.nationFocusMinCameraZ = Math.max(1.01, Math.min(this.cameraZMax, toNumber(root.getAttribute('data-nation-panel-focus-min-camera-z'), this.cameraZMin)));
    this.nationFocusMaxCameraZ = Math.max(this.nationFocusMinCameraZ, Math.min(this.cameraZMax, toNumber(root.getAttribute('data-nation-panel-focus-max-camera-z'), this.cameraZMax)));
    this.nationFocusReleaseInertiaEnabled = root.getAttribute('data-nation-panel-focus-release-inertia') !== 'false';
    this.nationFocusReleaseInertiaStrength = Math.max(0, Math.min(1, toNumber(root.getAttribute('data-nation-panel-focus-release-inertia-strength'), 0.11)));
    this.nationFocusReleaseInertiaMaxVelocity = Math.max(0.00002, Math.min(0.0012, toNumber(root.getAttribute('data-nation-panel-focus-release-inertia-max-velocity'), 0.00032)));
    this.nationFocusTargetPage = '';
    this.nationFocusCenterCache = {};
    this.nationFocusCameraZCache = {};
    this.terrainUrl = useSixKGlobeTextureUrl(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 = useSixKGlobeTextureSize(root.getAttribute('data-polygon-overlay-size'), GLOBE_TEXTURE_6K_SIZE);
    this.earthCompositeSize = useSixKGlobeTextureSize(root.getAttribute('data-earth-composite-size'), this.polygonOverlaySize || GLOBE_TEXTURE_6K_SIZE);
    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 = useSixKGlobeTextureUrl(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.celestialBodiesEnabled = root.getAttribute('data-celestial-bodies') !== 'false';
    this.celestialTextureSize = toNumber(root.getAttribute('data-celestial-texture-size'), 512);
    this.sunTextureSize = toNumber(root.getAttribute('data-sun-texture-size'), 512);
    this.moonEnabled = root.getAttribute('data-moon-enabled') !== 'false';
    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.0024);
    this.dragSensitivityX = toNumber(root.getAttribute('data-drag-sensitivity-x'), 0.0016);
    this.inertiaStrength = toNumber(root.getAttribute('data-inertia-strength'), 0.464);
    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.resizeDirty = true;
    this.lastResizeCheckAt = 0;
    this.resizeCheckInterval = Math.max(80, Math.min(1000, toNumber(root.getAttribute('data-resize-check-interval'), 260)));
    this.uiReliefUntil = 0;
    this.uiReliefTimer = null;
    this.halftoneBackgroundBusyUntil = 0;
    this.halftoneBackgroundBusyTimer = null;
    this.openRendererWarmupStarted = false;
    this.openRendererWarmupCompleted = false;
    this.lastAutoPointerReadoutAt = 0;
    this.lastPointerReadoutAt = 0;
    this.pointerReadoutTimer = null;
    this.hoverPointerReadoutInterval = Math.max(12, Math.min(120, toNumber(root.getAttribute('data-hover-pointer-readout-interval'), 24)));
    this.dragPointerReadoutInterval = Math.max(12, Math.min(120, toNumber(root.getAttribute('data-drag-pointer-readout-interval'), 24)));
    this.autoPointerReadoutInterval = Math.max(48, Math.min(320, toNumber(root.getAttribute('data-auto-pointer-readout-interval'), 96)));
    this.motionPointerReadoutInterval = Math.max(12, Math.min(96, toNumber(root.getAttribute('data-motion-pointer-readout-interval'), this.hoverPointerReadoutInterval || 24)));
    this.boundResizeHandler = null;
    this.boundContinentSwitchingHandler = null;
    this.lastTime = performance.now();
}

NationsGlobe.prototype.ensureDom = function () {
    var root = this.root;
    var title = root.getAttribute('data-title') || 'TERRITORY GLOBE';
    var era = stripBorderLayerReadout(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" aria-hidden="true"></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>' +
            '<div class="clbi-nations-globe-loading-screen" aria-hidden="true">' +
                '<img class="clbi-nations-globe-loading-gif" alt="" decoding="async" loading="eager" />' +
                '<canvas class="clbi-nations-globe-loading-freeze" aria-hidden="true"></canvas>' +
            '</div>' +
            topbarHtml +
            bottombarHtml +
            '<div class="clbi-nations-globe-loading"><span class="clbi-nations-globe-loading-text">연결중.</span></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-loading-screen')) {
            root.insertAdjacentHTML('beforeend', '<div class="clbi-nations-globe-loading-screen" aria-hidden="true"><img class="clbi-nations-globe-loading-gif" alt="" decoding="async" loading="eager" /><canvas class="clbi-nations-globe-loading-freeze" aria-hidden="true"></canvas></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"><span class="clbi-nations-globe-loading-text">연결중.</span></div>');
        } else if (!root.querySelector('.clbi-nations-globe-loading-text')) {
            root.querySelector('.clbi-nations-globe-loading').innerHTML = '<span class="clbi-nations-globe-loading-text">연결중.</span>';
        }

        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.loadingScreen = root.querySelector('.clbi-nations-globe-loading-screen');
    this.loadingGif = root.querySelector('.clbi-nations-globe-loading-gif');
    this.loadingFreeze = root.querySelector('.clbi-nations-globe-loading-freeze');
    this.loading = root.querySelector('.clbi-nations-globe-loading');
    this.loadingText = root.querySelector('.clbi-nations-globe-loading-text');
    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');
    this.ensureNationTooltipDom();
    ensureNationsGlobeInteractionStyle();
    this.setupLoadingVisual();

    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 = stripBorderLayerReadout(text || '');
};

NationsGlobe.prototype.setLoadingLabel = function (text) {
    if (this.loadingText) this.loadingText.textContent = text || '';
    else if (this.loading) this.loading.textContent = text || '';
};

NationsGlobe.prototype.setupLoadingVisual = function () {
    var self = this;
    var ref = this.loadingGifRef || '';

    if (!this.loadingGif || !ref) return;
    if (this.loadingGif.getAttribute('data-loading-ref') === ref) return;

    this.loadingGifLoaded = false;
    this.loadingGif.setAttribute('data-loading-ref', ref);
    this.loadingGif.onload = function () {
        if (!self.isCurrentInstance()) return;
        self.loadingGifLoaded = true;
    };
    this.loadingGif.onerror = function () {
        if (!self.isCurrentInstance()) return;
        self.loadingGifLoaded = false;
        if (window.console && console.warn) console.warn('Nations globe loading GIF failed:', ref);
    };

    resolveLatestWikiFileUrl(ref).then(function (url) {
        if (!self.isCurrentInstance() || !self.loadingGif) return;
        self.loadingGif.src = url || ref;
    }).catch(function () {
        if (!self.isCurrentInstance() || !self.loadingGif) return;
        self.loadingGif.src = ref;
    });
};

NationsGlobe.prototype.startLoadingLoop = function () {
    var self = this;

    this.stopLoadingTimers();
    this.root.classList.remove('is-ready', 'has-error', 'is-connected', 'is-no-signal');
    this.root.classList.add('is-loading');
    this.loadingDotIndex = 0;
    this.setLoadingLabel('연결중.');
    if (this.loadingFreeze) this.loadingFreeze.style.display = 'none';
    if (this.loadingGif) this.loadingGif.style.display = '';

    this.loadingLoopTimer = window.setInterval(function () {
        if (!self.isCurrentInstance()) return;
        self.loadingDotIndex = (self.loadingDotIndex + 1) % 3;
        self.setLoadingLabel('연결중' + new Array(self.loadingDotIndex + 2).join('.'));
    }, LOADING_DOTS_INTERVAL_MS);
};

NationsGlobe.prototype.stopLoadingTimers = function () {
    if (this.loadingLoopTimer) {
        window.clearInterval(this.loadingLoopTimer);
        this.loadingLoopTimer = null;
    }
    if (this.connectedTimer) {
        window.clearTimeout(this.connectedTimer);
        this.connectedTimer = null;
    }
};

NationsGlobe.prototype.freezeLoadingGifFrame = function () {
    var canvas = this.loadingFreeze;
    var img = this.loadingGif;
    var rect;
    var ctx;

    if (!canvas || !img || !this.loadingGifLoaded) return false;

    rect = this.loadingScreen ? this.loadingScreen.getBoundingClientRect() : this.root.getBoundingClientRect();
    if (!rect || !rect.width || !rect.height) return false;

    canvas.width = Math.max(1, Math.round(rect.width));
    canvas.height = Math.max(1, Math.round(rect.height));

    try {
        ctx = canvas.getContext('2d');
        if (!ctx) return false;
        ctx.imageSmoothingEnabled = false;
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
        canvas.style.display = 'block';
        img.style.display = 'none';
        return true;
    } catch (err) {
        if (window.console && console.warn) console.warn('Nations globe loading GIF freeze failed:', err);
        return false;
    }
};

NationsGlobe.prototype.finishLoadingAndOpen = function () {
    var self = this;

    if (!this.isCurrentInstance()) return Promise.resolve(false);

    this.stopLoadingTimers();
    this.freezeLoadingGifFrame();
    this.root.classList.remove('has-error', 'is-no-signal');
    this.root.classList.add('is-connected');
    this.setLoadingLabel('연결됨!');

    return new Promise(function (resolve) {
        self.connectedTimer = window.setTimeout(function () {
            if (!self.isCurrentInstance()) return resolve(false);
            self.connectedTimer = null;
            self.root.classList.remove('is-loading', 'is-connected', 'has-error', 'is-no-signal');
            self.root.classList.add('is-ready');
            if (self.loadingGif) self.loadingGif.style.display = '';
            if (self.loadingFreeze) self.loadingFreeze.style.display = 'none';
            resolve(true);
        }, CONNECTED_HOLD_MS);

        /*
         * Open warm-up — 20260710.
         *
         * The first render after the 6K composite/lakes textures are attached can
         * force a large WebGL upload.  If that first upload waits until the globe
         * is visible, it feels like the globe or the tab UI froze.  The connected
         * hold is already an intentional one-second visual state, so spend that
         * hidden interval on a couple of warm renders while the loading overlay is
         * still covering the stage.
         */
        self.prewarmRendererBeforeOpen(CONNECTED_HOLD_MS + 260);
    });
};

NationsGlobe.prototype.showNoSignal = function () {
    this.stopLoadingTimers();
    if (this.loadingFreeze) this.loadingFreeze.style.display = 'none';
    if (this.loadingGif) this.loadingGif.style.display = '';
    this.root.classList.remove('is-ready', 'is-connected');
    this.root.classList.add('has-error', 'is-no-signal');
    this.setLoadingLabel('신호 없음');
};

NationsGlobe.prototype.isCurrentInstance = function () {
    return !this.disposed && this.root && this.root.CLBI_NationsGlobeInstance === this && document.body.contains(this.root);
};

NationsGlobe.prototype.markHalftoneBackgroundBusy = function (duration) {
    var self = this;
    var ms = Math.max(80, Math.min(2600, Number(duration || 300)));
    var until = Date.now() + ms;

    this.halftoneBackgroundBusyUntil = Math.max(Number(this.halftoneBackgroundBusyUntil || 0), until);
    if (this.root) this.root.setAttribute('data-clbi-globe-bg-busy', '1');

    if (this.halftoneBackgroundBusyTimer) {
        window.clearTimeout(this.halftoneBackgroundBusyTimer);
        this.halftoneBackgroundBusyTimer = null;
    }

    this.halftoneBackgroundBusyTimer = window.setTimeout(function () {
        self.halftoneBackgroundBusyTimer = null;
        if (!self.root) return;
        if (Date.now() < Number(self.halftoneBackgroundBusyUntil || 0)) {
            self.markHalftoneBackgroundBusy(Math.max(80, Number(self.halftoneBackgroundBusyUntil || 0) - Date.now()));
            return;
        }
        self.root.removeAttribute('data-clbi-globe-bg-busy');
    }, ms + 24);
};

NationsGlobe.prototype.isHalftoneBackgroundBusy = function () {
    return !!(this.dragging || Date.now() < Number(this.halftoneBackgroundBusyUntil || 0));
};

NationsGlobe.prototype.isHalftoneBackgroundHardBusy = function () {
    return Date.now() < Number(this.halftoneBackgroundBusyUntil || 0);
};

NationsGlobe.prototype.prewarmRendererBeforeOpen = function (duration) {
    var self = this;
    var count = 0;
    var maxPasses = 2;

    if (this.openRendererWarmupStarted) return;
    if (!this.renderer || !this.scene || !this.camera) return;

    this.openRendererWarmupStarted = true;
    this.markHalftoneBackgroundBusy(duration || 1200);

    function warmFrame() {
        var started;
        var ms;

        if (!self.isCurrentInstance() || !self.renderer || !self.scene || !self.camera) return;

        started = interactionPerfNow();
        try {
            if (self.resizeDirty) {
                self.resizeDirty = false;
                self.resize();
            }
            self.renderer.render(self.scene, self.camera);
        } catch (err) {
            if (window.console && console.warn) console.warn('CLBI globe open warm render failed:', err);
            return;
        }

        ms = interactionPerfNow() - started;
        interactionPerfMark('globe open warm render', {
            pass: count + 1,
            ms: Math.round(ms * 100) / 100
        });

        count += 1;
        if (count < maxPasses) {
            window.requestAnimationFrame(warmFrame);
        } else {
            self.openRendererWarmupCompleted = true;
        }
    }

    window.requestAnimationFrame(warmFrame);
};

NationsGlobe.prototype.setError = function (err) {
    var message;

    if (!this.isCurrentInstance()) return;

    message = err && err.message ? err.message : String(err || 'unknown error');
    this.showNoSignal();
    if (this.error) this.error.textContent = 'GLOBE ERROR: ' + message;
    this.setStatus('ERROR');
};

NationsGlobe.prototype.init = function () {
    var self = this;

    this.ensureDom();
    this.startLoadingLoop();
    this.setStatus('LIBRARY');

    return Promise.all([loadThree(), loadTopojson()])
        .then(function (libs) {
            if (!self.isCurrentInstance()) return Promise.reject({ staleGlobeInstance: true });

            self.THREE = libs[0];
            self.topojson = libs[1];
            self.createScene();
            self.bindEvents();
            return self.loadOptionalTextures().then(function () {
                if (!self.isCurrentInstance()) return Promise.reject({ staleGlobeInstance: true });
                return self.loadBoundaryData();
            }).then(function () {
                if (!self.isCurrentInstance()) return Promise.reject({ staleGlobeInstance: true });
                return self.loadNationInteractionData();
            });
        })
        .then(function () {
            if (!self.isCurrentInstance()) return;

            if (self.error) self.error.textContent = '';
            self.setStatus(self.nationHitValidationWarning ? 'HIT OLD' : 'READY');
            self.animate(performance.now());
            return self.finishLoadingAndOpen();
        })
        .catch(function (err) {
            if (err && err.staleGlobeInstance) return;
            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) {
            if (!self.isCurrentInstance()) {
                if (texture && typeof texture.dispose === 'function') texture.dispose();
                return;
            }
            self.addMoon(texture);
        })
        .catch(function (err) {
            var fallbackTexture;

            if (!self.isCurrentInstance()) return;

            console.warn('CLBI moon texture failed:', err);
            fallbackTexture = createFallbackPlanetTexture(THREE, CELESTIAL_MOON_DEF.fallbackA, CELESTIAL_MOON_DEF.fallbackB);
            self.addMoon(fallbackTexture);
        });
};

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

    if (this.celestialBodiesEnabled) {
        this.scene.add(this.celestialGroup);
        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);

    if (this.moonEnabled) {
        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: appendCacheBust(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.isCurrentInstance() || !self.earthMaterial) {
                            if (texture && typeof texture.dispose === 'function') texture.dispose();
                            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.isCurrentInstance() || !self.earthMaterial) {
                                    if (texture && typeof texture.dispose === 'function') texture.dispose();
                                    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.isCurrentInstance() || !self.earthMaterial) {
                            if (texture && typeof texture.dispose === 'function') texture.dispose();
                            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.isCurrentInstance() || !self.group) {
                        if (texture && typeof texture.dispose === 'function') texture.dispose();
                        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;

                    if (!self.isCurrentInstance() || !self.group) {
                        if (texture && typeof texture.dispose === 'function') texture.dispose();
                        return;
                    }

                    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.nationFocusMotion = null;
    this.nationFocusTargetPage = '';
    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.getNationFocusCenter = function (page) {
    var key = String(page || '').trim();
    var cached;
    var best = null;
    var bestArea = -1;
    var bbox;
    var area;
    var rawLonSpan;
    var lonSpan;
    var latSpan;
    var lonCenter;
    var latCenter;
    var latRad;
    var minSpanDeg = this.nationFocusMinSpanDeg || 0.28;
    var center;

    if (!key || !this.nationHitFeatures || !this.nationHitFeatures.length) return null;

    cached = this.nationFocusCenterCache && this.nationFocusCenterCache[key];
    if (cached) return cached;

    this.nationHitFeatures.forEach(function (feature) {
        if (!feature || feature.page !== key || !feature.bbox) return;
        bbox = feature.bbox;
        area = Math.max(0, Number(feature.bboxArea) || ((Number(bbox[2]) - Number(bbox[0])) * (Number(bbox[3]) - Number(bbox[1]))));
        if (area > bestArea) {
            best = bbox;
            bestArea = area;
        }
    });

    if (!best) return null;

    rawLonSpan = Math.max(0, Math.abs(Number(best[2]) - Number(best[0])));
    lonSpan = rawLonSpan > 180 ? Math.max(0, 360 - rawLonSpan) : rawLonSpan;
    latSpan = Math.max(0, Math.abs(Number(best[3]) - Number(best[1])));

    lonCenter = (Number(best[0]) + Number(best[2])) / 2;
    if (rawLonSpan > 180) lonCenter += 180;
    lonCenter = normalizeLongitude(lonCenter);

    latCenter = Math.max(-86, Math.min(86, (Number(best[1]) + Number(best[3])) / 2));
    latRad = latCenter * Math.PI / 180;

    center = {
        page: key,
        lon: lonCenter,
        lat: latCenter,
        area: bestArea,
        bbox: [Number(best[0]), Number(best[1]), Number(best[2]), Number(best[3])],
        lonSpanDeg: lonSpan,
        latSpanDeg: latSpan,
        widthSpanDeg: Math.max(minSpanDeg, lonSpan * Math.max(0.12, Math.abs(Math.cos(latRad)))),
        heightSpanDeg: Math.max(minSpanDeg, latSpan),
        maxSpanDeg: Math.max(minSpanDeg, lonSpan * Math.max(0.12, Math.abs(Math.cos(latRad))), latSpan)
    };

    this.nationFocusCenterCache[key] = center;
    return center;
};

NationsGlobe.prototype.getNationFocusCameraZ = function (center, options) {
    var fillRatio;
    var padding;
    var minZ;
    var maxZ;
    var fovRad;
    var aspect;
    var tanV;
    var tanH;
    var widthRad;
    var heightRad;
    var widthWorld;
    var heightWorld;
    var targetByWidth;
    var targetByHeight;
    var targetZ;
    var cacheKey = '';

    options = options || {};

    if (options.cameraZ != null) {
        targetZ = toNumber(options.cameraZ, this.cameraZ);
        return Math.max(this.cameraZMin, Math.min(this.cameraZMax, targetZ));
    }

    if (this.nationFocusZoomMode === 'fixed' && this.nationFocusCameraZ != null) {
        targetZ = this.nationFocusCameraZ;
        return Math.max(this.cameraZMin, Math.min(this.cameraZMax, targetZ));
    }

    if (!center) {
        targetZ = this.nationFocusCameraZ == null ? this.cameraZ : this.nationFocusCameraZ;
        return Math.max(this.cameraZMin, Math.min(this.cameraZMax, targetZ));
    }

    fillRatio = Math.max(0.08, Math.min(0.82, toNumber(options.fillRatio, this.nationFocusFillRatio || (1 / 3))));
    padding = Math.max(0.72, Math.min(2.4, toNumber(options.zoomPadding, this.nationFocusZoomPadding || 1)));
    minZ = Math.max(this.cameraZMin, Math.min(this.cameraZMax, toNumber(options.minCameraZ, this.nationFocusMinCameraZ || this.cameraZMin)));
    maxZ = Math.max(minZ, Math.min(this.cameraZMax, toNumber(options.maxCameraZ, this.nationFocusMaxCameraZ || this.cameraZMax)));

    fovRad = ((this.camera && this.camera.fov ? this.camera.fov : 38) * Math.PI) / 180;
    aspect = this.camera && this.camera.aspect ? Math.max(0.2, this.camera.aspect) : 1.6;

    if (center.page &&
        options.fillRatio == null &&
        options.zoomPadding == null &&
        options.minCameraZ == null &&
        options.maxCameraZ == null) {
        cacheKey = [
            center.page,
            fillRatio,
            padding,
            minZ,
            maxZ,
            Math.round((this.camera && this.camera.fov ? this.camera.fov : 38) * 1000) / 1000,
            Math.round(aspect * 1000) / 1000,
            Math.round((center.widthSpanDeg || 0) * 10000) / 10000,
            Math.round((center.heightSpanDeg || 0) * 10000) / 10000
        ].join('|');

        if (this.nationFocusCameraZCache && this.nationFocusCameraZCache[cacheKey] != null) {
            return this.nationFocusCameraZCache[cacheKey];
        }
    }

    tanV = Math.tan(fovRad / 2);
    tanH = tanV * aspect;

    widthRad = Math.max(0.0001, Math.min(Math.PI, (Number(center.widthSpanDeg || center.maxSpanDeg || 0) * Math.PI) / 180));
    heightRad = Math.max(0.0001, Math.min(Math.PI, (Number(center.heightSpanDeg || center.maxSpanDeg || 0) * Math.PI) / 180));
    widthWorld = 2 * Math.sin(widthRad / 2);
    heightWorld = 2 * Math.sin(heightRad / 2);

    /*
     * The focused country is on the visible surface of the globe, not on
     * the earth-center plane.  Use the near-surface depth so the requested
     * fill ratio matches what the user actually sees after the country is
     * centered.
     */
    targetByWidth = Math.cos(widthRad / 2) + (widthWorld / (2 * tanH * fillRatio));
    targetByHeight = Math.cos(heightRad / 2) + (heightWorld / (2 * tanV * fillRatio));
    targetZ = Math.max(targetByWidth, targetByHeight) * padding;

    if (!Number.isFinite(targetZ) || targetZ <= 0) {
        targetZ = this.nationFocusCameraZ == null ? this.cameraZ : this.nationFocusCameraZ;
    }

    targetZ = Math.max(minZ, Math.min(maxZ, targetZ));
    if (cacheKey) this.nationFocusCameraZCache[cacheKey] = targetZ;
    return targetZ;
};

NationsGlobe.prototype.getRotationTargetForLonLat = function (lon, lat) {
    var lonRad = normalizeLongitude(Number(lon) || 0) * Math.PI / 180;
    var latRad = Math.max(-1.15, Math.min(1.15, (Number(lat) || 0) * Math.PI / 180));
    var targetY = Math.atan2(-Math.cos(lonRad), -Math.sin(lonRad));

    return {
        x: latRad,
        y: nearestEquivalentAngle(this.rotationY, targetY)
    };
};

NationsGlobe.prototype.startNationFocusMotion = function (center, options) {
    var target;
    var duration;
    var targetZ;

    if (!center) return false;

    options = options || {};
    target = this.getRotationTargetForLonLat(center.lon, center.lat);
    duration = Math.max(120, Math.min(1800, toNumber(options.duration, this.nationFocusDuration)));
    targetZ = this.getNationFocusCameraZ(center, options);

    this.velocityX = 0;
    this.velocityY = 0;
    this.dragging = false;
    this.autoRotate = false;
    this.autoResumeMotion = null;
    this.resetMotion = null;
    this.root.classList.remove('is-dragging');
    this.syncAutoButton();

    this.nationFocusMotion = {
        elapsed: 0,
        duration: duration,
        fromX: this.rotationX,
        fromY: this.rotationY,
        fromZ: this.cameraZ,
        toX: target.x,
        toY: target.y,
        finalY: canonicalAngle(target.y),
        toZ: targetZ
    };

    return true;
};

NationsGlobe.prototype.applyNationFocusReleaseInertia = function () {
    var motion = this.nationFocusMotion;
    var t;
    var derivative;
    var strength;
    var limit;
    var velocityX;
    var velocityY;

    if (!this.nationFocusReleaseInertiaEnabled || !motion || !motion.duration) return false;

    t = clamp01((motion.elapsed || 0) / motion.duration);
    if (t >= 0.985) return false;

    derivative = 3 * Math.pow(1 - t, 2);
    strength = this.nationFocusReleaseInertiaStrength;
    limit = this.nationFocusReleaseInertiaMaxVelocity;

    velocityX = ((motion.toX - motion.fromX) * derivative / motion.duration) * strength;
    velocityY = ((motion.toY - motion.fromY) * derivative / motion.duration) * strength;

    if (velocityX > limit) velocityX = limit;
    else if (velocityX < -limit) velocityX = -limit;

    if (velocityY > limit) velocityY = limit;
    else if (velocityY < -limit) velocityY = -limit;

    if (Math.abs(velocityX) < 0.000001 && Math.abs(velocityY) < 0.000001) return false;

    this.velocityX = velocityX;
    this.velocityY = velocityY;
    this.autoRotate = false;
    this.autoResumeMotion = null;
    this.syncAutoButton();
    return true;
};


NationsGlobe.prototype.stepNationFocusMotion = function (dt) {
    var motion = this.nationFocusMotion;
    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.updateMoonOpacity(dt);
    this.applyRotation();

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

    return true;
};

NationsGlobe.prototype.startResetMotion = function () {
    var toY;

    this.nationFocusMotion = null;
    this.nationFocusTargetPage = '';
    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.cancelNationTooltipFollow();
    this.clearNationHover();

    if (this.readout) {
        this.setReadout(this.getBaseReadout());
    }
};

NationsGlobe.prototype.getBaseReadout = function () {
    return stripBorderLayerReadout(this.root.getAttribute('data-era') || 'REAL WORLD / 110M') + ' · SEG ' + this.segmentCount;
};


NationsGlobe.prototype.isInUiReliefWindow = function (now) {
    return Number(now || Date.now()) < Number(this.uiReliefUntil || 0);
};

NationsGlobe.prototype.requestUiRelief = function (duration) {
    var self = this;
    var now = Date.now();
    var ms = Math.max(80, Math.min(360, Number(duration || 180)));

    /*
     * Balanced panel/globe relief — 20260708.
     *
     * The globe is the primary direct-manipulation surface.  A continent tab
     * switch may ask the globe to avoid optional housekeeping for a short window,
     * but it must never throttle WebGL drawing, drag, inertia, focus motion, or
     * the visible star/planet backdrop.  Earlier relief code improved tab clicks
     * by slowing the draw loop; that made globe manipulation feel broken.  Keep
     * the render loop hot and only postpone resize polling plus automatic hover
     * raycasts when the user is not actively dragging the globe.
     */
    if (this.dragging) return;

    this.uiReliefUntil = Math.max(Number(this.uiReliefUntil || 0), now + ms);
    this.markHalftoneBackgroundBusy(Math.min(520, ms + 120));
    this.lastAutoPointerReadoutAt = now;
    this.cancelPointerReadoutFrame();

    if (this.root) this.root.classList.add('is-ui-relief');
    if (this.uiReliefTimer) window.clearTimeout(this.uiReliefTimer);

    this.uiReliefTimer = window.setTimeout(function () {
        self.uiReliefTimer = null;
        if (!self.root || Date.now() < Number(self.uiReliefUntil || 0)) return;
        self.root.classList.remove('is-ui-relief');

        if (self.pointerInside && self.pointerClientX !== null && self.pointerClientY !== null) {
            self.schedulePointerReadout();
        }
    }, ms + 24);
};

NationsGlobe.prototype.markResizeDirty = function () {
    this.resizeDirty = true;
};

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.schedulePointerReadout(e);
    });

    this.stage.addEventListener('pointerleave', function () {
        if (!self.dragging) {
            self.cancelPointerReadoutFrame();
            self.clearPointerPosition();
            self.clearNationHover();
        }
    });

    this.stage.addEventListener('pointerdown', function (e) {
        interactionPerfMeasure('globe pointerdown handler', { x: e.clientX, y: e.clientY }, function () {
            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');
            /* Keep the background flowing during ordinary drag; hard-busy is reserved for upload/warm-render windows. */
            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) {
        var wasDragging = !!self.dragging;
        interactionPerfMeasure(wasDragging ? 'globe pointermove drag handler' : 'globe pointermove hover handler', { dragging: wasDragging }, function () {
            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.scheduleNationTooltipFollow(e);
                self.scheduleDragPointerReadout(e);
            } else {
                self.scheduleNationTooltipFollow(e);
                self.schedulePointerReadout(e);
            }
        });

        if (wasDragging) e.preventDefault();
    });

    function finishDrag(e) {
        var now;
        var releaseDelay;
        var releaseSpeed;

        if (!self.dragging) return;

        var perf = window.InteractionPerf;
        var perfId = perf && typeof perf.start === 'function' ? perf.start('globe pointerup finish drag', {}) : null;

        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.dragging = false;
        self.root.classList.remove('is-dragging');
        /* Do not freeze the halftone on release; it should continue from its current visual phase. */
        if (self.stage.releasePointerCapture && e && e.pointerId != null) {
            try { self.stage.releasePointerCapture(e.pointerId); } catch (err) {}
        }

        if (!self.handleNationClick(e) && self.pointerInside && e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
            window.setTimeout(function () {
                if (self.isCurrentInstance() && !self.dragging) self.schedulePointerReadout(e);
            }, 48);
        }
        if (perf && typeof perf.end === 'function' && perfId) {
            perf.end(perfId, { releaseDelay: Math.round(releaseDelay * 100) / 100, releaseSpeed: Math.round(releaseSpeed * 100000) / 100000 });
        }
    }

    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.schedulePointerReadout(e);
        e.preventDefault();
    }, { passive: false });

    this.bindControlEvents();

    this.boundResizeHandler = function () {
        self.markResizeDirty();
    };
    window.addEventListener('resize', this.boundResizeHandler);

    this.boundContinentSwitchingHandler = function (event) {
        var detail = event && event.detail ? event.detail : {};
        self.requestUiRelief(detail.duration || 180);
    };
    window.addEventListener('clbi:nations-continent-switching', this.boundContinentSwitchingHandler);
};

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.schedulePointerReadout = function (e) {
    var self = this;
    var now;
    var wait;

    if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
        this.trackPointerPosition(e);
    }

    if (this.pointerClientX === null || this.pointerClientY === null) return false;

    /*
     * Pointer readout throttle — 20260710.
     *
     * Hover raycasts are gently throttled, but they must still be based on the
     * real mouse position.  Dragging uses a separate live readout path below so
     * the tooltip never keeps showing the country that happened to be grabbed
     * before the sphere moved.
     */
    if (this.dragging) return this.scheduleDragPointerReadout(e);

    if (this.isInUiReliefWindow()) {
        this.pendingPointerClientX = this.pointerClientX;
        this.pendingPointerClientY = this.pointerClientY;
        return true;
    }

    this.pendingPointerClientX = this.pointerClientX;
    this.pendingPointerClientY = this.pointerClientY;

    if (this.pointerReadoutRaf || this.pointerReadoutTimer) return true;

    now = performance.now ? performance.now() : Date.now();
    wait = Math.max(0, Number(this.hoverPointerReadoutInterval || 32) - (now - Number(this.lastPointerReadoutAt || 0)));

    if (wait > 2) {
        this.pointerReadoutTimer = window.setTimeout(function () {
            self.pointerReadoutTimer = null;
            self.schedulePointerReadout();
        }, wait);
        return true;
    }

    this.pointerReadoutRaf = requestAnimationFrame(function () {
        self.pointerReadoutRaf = null;
        if (!self.isCurrentInstance()) return;
        if (self.pendingPointerClientX === null || self.pendingPointerClientY === null) return;
        self.lastPointerReadoutAt = performance.now ? performance.now() : Date.now();
        self.updatePointerReadoutFromClient(self.pendingPointerClientX, self.pendingPointerClientY);
    });

    return true;
};

NationsGlobe.prototype.scheduleDragPointerReadout = function (e) {
    var self = this;
    var now;
    var wait;

    if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
        this.trackPointerPosition(e);
    }

    if (this.pointerClientX === null || this.pointerClientY === null) return false;

    this.pendingPointerClientX = this.pointerClientX;
    this.pendingPointerClientY = this.pointerClientY;

    if (this.pointerReadoutRaf || this.pointerReadoutTimer) return true;

    now = performance.now ? performance.now() : Date.now();
    wait = Math.max(0, Number(this.dragPointerReadoutInterval || 30) - (now - Number(this.lastPointerReadoutAt || 0)));

    if (wait > 2) {
        this.pointerReadoutTimer = window.setTimeout(function () {
            self.pointerReadoutTimer = null;
            self.scheduleDragPointerReadout();
        }, wait);
        return true;
    }

    this.pointerReadoutRaf = requestAnimationFrame(function () {
        self.pointerReadoutRaf = null;
        if (!self.isCurrentInstance()) return;
        if (self.pendingPointerClientX === null || self.pendingPointerClientY === null) return;
        self.lastPointerReadoutAt = performance.now ? performance.now() : Date.now();
        self.updatePointerReadoutFromClient(self.pendingPointerClientX, self.pendingPointerClientY, { allowDuringDrag: true });
    });

    return true;
};

NationsGlobe.prototype.cancelPointerReadoutFrame = function () {
    if (this.pointerReadoutRaf) {
        cancelAnimationFrame(this.pointerReadoutRaf);
        this.pointerReadoutRaf = null;
    }
    if (this.pointerReadoutTimer) {
        window.clearTimeout(this.pointerReadoutTimer);
        this.pointerReadoutTimer = null;
    }
    this.pendingPointerClientX = null;
    this.pendingPointerClientY = null;
};

NationsGlobe.prototype.getNationHitLonLat = function (ll) {
    var lon;
    var lat;

    if (!ll) return ll;

    lon = Number(ll.lon);
    lat = Number(ll.lat);

    if (!Number.isFinite(lon) || !Number.isFinite(lat)) return ll;

    /*
     * The country color overlay and the hit GeoJSON are both based on the same
     * equirectangular longitude/latitude map.  Do not add live globe rotation
     * here.  Rotation is already resolved by raycasting the visible sphere;
     * adding it again makes hover labels drift as the globe rotates.
     */
    lon += this.nationHitLonOffset || 0;
    lat += this.nationHitLatOffset || 0;

    return {
        lon: normalizeLongitude(lon),
        lat: Math.max(-90, Math.min(90, lat))
    };
};

NationsGlobe.prototype.updatePointerReadoutFromClient = function (clientX, clientY, options) {
    var THREE = this.THREE;
    var rect;
    var intersects;
    var local;
    var ll;
    var hitLl;
    var allowDuringDrag;
    var perf = window.InteractionPerf;
    var perfId;
    var result = false;

    options = options || {};
    allowDuringDrag = !!options.allowDuringDrag;
    perfId = perf && typeof perf.start === 'function' ? perf.start('globe pointer raycast readout', { dragging: !!this.dragging, liveDrag: allowDuringDrag }) : null;

    try {
    if (this.dragging && !allowDuringDrag) return false;
    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.clearNationHover();
        this.setReadout(this.getBaseReadout());
        result = false;
        return result;
    }

    this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
    this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;

    /*
     * Live hover hit-test must use the globe transform for this exact frame.
     * Inertia/auto/focus motion can change the group rotation without a mouse
     * event, and this readout is called before the renderer commits the frame.
     * Force matrices current so the stationary pointer is re-tested against the
     * country that is visually under it now, not the country from the previous
     * pointermove.
     */
    if (this.camera && typeof this.camera.updateMatrixWorld === 'function') this.camera.updateMatrixWorld(true);
    if (this.group && typeof this.group.updateMatrixWorld === 'function') this.group.updateMatrixWorld(true);
    else if (this.earth && typeof this.earth.updateMatrixWorld === 'function') this.earth.updateMatrixWorld(true);

    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 = uvToLonLat(intersects[0].uv) || vectorToLonLat(local);
        hitLl = this.getNationHitLonLat(ll);
        this.updateNationHover(hitLl, 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);
        }
        result = true;
        return result;
    }

    this.clearNationHover();
    this.setReadout(this.getBaseReadout());
    result = false;
    return result;
    } finally {
        if (perf && typeof perf.end === 'function' && perfId) {
            perf.end(perfId, { hit: result });
        }
    }
};



NationsGlobe.prototype.validateNationHitPayload = function (payload) {
    var features = payload && payload.features ? payload.features : [];
    var summary = summarizeNationHitFeatures(features);
    var required = this.nationHitRequiredTags || [];
    var missing = [];
    var warning = '';

    required.forEach(function (tag) {
        if (!summary.tags[tag]) missing.push(tag);
    });

    if (missing.length) {
        warning = '필수 TAG 누락: ' + missing.join(', ');
    } else if (this.nationHitMinTagCount && summary.tagCount < this.nationHitMinTagCount) {
        warning = 'TAG 수 부족: ' + summary.tagCount + ' < ' + this.nationHitMinTagCount;
    } else if (this.nationHitMinFeatureCount && features.length < this.nationHitMinFeatureCount) {
        warning = 'feature 수 부족: ' + features.length + ' < ' + this.nationHitMinFeatureCount;
    }

    this.nationHitValidationWarning = warning;

    if (warning) {
        if (window.console && console.warn) {
            console.warn('CLBI nation hit data looks stale:', {
                url: this.nationHitUrl,
                warning: warning,
                features: features.length,
                tagCount: summary.tagCount,
                requiredTags: required,
                sampleTags: summary.tagList.slice(0, 24)
            });
        }
        this.root.classList.add('has-hit-data-warning');
        this.setStatus('HIT OLD');
    } else {
        this.root.classList.remove('has-hit-data-warning');
    }

    return {
        warning: warning,
        features: features.length,
        tagCount: summary.tagCount,
        requiredTags: required,
        missingTags: missing
    };
};

NationsGlobe.prototype.processNationHitPayload = function (payload) {
    var features = payload && payload.features ? payload.features : [];
    var validation = this.validateNationHitPayload(payload);

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

    this.nationHitSpatialIndex = buildNationHitGrid(this.nationHitFeatures, this.nationHitSpatialLonCells, this.nationHitSpatialLatCells);

    this.nationFocusCenterCache = {};
    this.nationFocusCameraZCache = {};

    if (window.console && console.info) {
        console.info('1950 nation hit data applied:', {
            url: this.nationHitUrl,
            features: this.nationHitFeatures.length,
            coordinateMode: this.nationHitCoordinateMode,
            coordinateSource: 'texture-uv',
            lonOffset: this.nationHitLonOffset,
            latOffset: this.nationHitLatOffset,
            lazy: this.nationHitGeojsonLazy,
            tagCount: validation.tagCount,
            validationWarning: validation.warning || ''
        });
    }

    return this.nationHitFeatures;
};

NationsGlobe.prototype.loadNationHitGeojson = function () {
    var self = this;

    if (!this.nationHitUrl) return Promise.resolve([]);
    if (this.nationHitFeatures && this.nationHitFeatures.length) return Promise.resolve(this.nationHitFeatures);
    if (this.nationHitFeaturesLoading) return this.nationHitFeaturesLoading;

    this.nationHitFeaturesLoading = resolveLatestWikiFileUrl(this.nationHitUrl)
        .then(function (requestUrl) {
            return fetch(requestUrl, { credentials: 'same-origin', cache: 'force-cache' })
                .then(function (res) {
                    if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + requestUrl);
                    return res.json();
                });
        })
        .then(function (payload) {
            if (!self.isCurrentInstance()) return [];
            return self.processNationHitPayload(payload);
        })
        .catch(function (err) {
            if (!self.isCurrentInstance()) return [];
            self.nationHitFeatures = [];
            self.nationHitSpatialIndex = null;
            if (window.console && console.warn) console.warn('1950 nation hit data failed:', err);
            return [];
        })
        .finally(function () {
            if (self.isCurrentInstance()) self.nationHitFeaturesLoading = null;
        });

    return this.nationHitFeaturesLoading;
};


NationsGlobe.prototype.scheduleLazyNationHitGeojson = function () {
    var self = this;
    if (!this.nationHitUrl || !this.nationHitGeojsonLazy) return;

    if (window.requestIdleCallback) {
        window.requestIdleCallback(function () {
            if (self.isCurrentInstance()) self.loadNationHitGeojson();
        }, { timeout: 1600 });
    } else {
        window.setTimeout(function () {
            if (self.isCurrentInstance()) self.loadNationHitGeojson();
        }, 700);
    }
};

NationsGlobe.prototype.loadNationInteractionData = function () {
    var self = this;
    var tasks = [];


    if (this.nationHitUrl && !this.nationHitGeojsonLazy) {
        tasks.push(this.loadNationHitGeojson());
    } else {
        this.scheduleLazyNationHitGeojson();
    }

    if (this.nationLinkMapUrl) {
        var linkMapPromise = fetchJsonResource(
            this.nationLinkMapUrl,
            extractRawTitleFromUrl(this.nationLinkMapUrl) || ''
        );
        var nationListPromise = this.nationListUrl ? fetchJsonResource(
            this.nationListUrl,
            extractRawTitleFromUrl(this.nationListUrl) || ''
        ).catch(function (err) {
            if (window.console && console.warn) console.warn('nation list symbol fallback failed:', err);
            return null;
        }) : Promise.resolve(null);

        tasks.push(
            Promise.all([linkMapPromise, nationListPromise])
                .then(function (payloads) {
                    var linkPayload = payloads[0];
                    var listPayload = payloads[1];

                    if (!self.isCurrentInstance()) return;
                    self.nationListSymbolMap = listPayload ? normalizeNationListSymbolMap(listPayload) : {};
                    self.nationLinkMap = mergeNationListSymbols(
                        normalizeNationLinkMap(linkPayload),
                        self.nationListSymbolMap
                    );
                    self.scheduleNationFlagIdlePreload();
                    if (self.currentNation && self.currentNation.page) {
                        self.applyNationTooltipFlag(self.currentNation.page);
                    }
                    if (window.console && console.info) {
                        console.info('1950 nation link map applied:', {
                            url: self.nationLinkMapUrl,
                            listUrl: self.nationListUrl,
                            items: Object.keys(self.nationLinkMap).length,
                            symbolFallbackItems: Object.keys(self.nationListSymbolMap).length,
                            SUD: self.nationLinkMap.SUD || null
                        });
                    }
                })
                .catch(function (err) {
                    if (!self.isCurrentInstance()) return;
                    self.nationLinkMap = {};
                    self.nationListSymbolMap = {};
                    if (window.console && console.warn) console.warn('nation link map failed:', err);
                    throw new Error('nation link map unavailable: ' + self.nationLinkMapUrl);
                })
        );
    }

    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;
    var candidates;
    if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;


    if (!this.nationHitFeatures.length) return null;

    candidates = getNationHitGridCandidates(this.nationHitSpatialIndex, lon, lat);
    if (!candidates || !candidates.length) candidates = this.nationHitFeatures;

    for (i = 0; i < candidates.length; i += 1) {
        feature = candidates[i];
        if (!isLonLatInsideBbox(lon, lat, feature.bbox)) continue;
        if (pointInGeometry(lon, lat, feature.geometry)) {
            return {
                page: feature.page,
                feature: feature,
                source: 'geojson'
            };
        }
    }

    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();
    this.tooltipFollowRootRect = rootRect;
    this.tooltipFollowRootRectAt = performance.now ? performance.now() : Date.now();
    this.tooltipFollowWidth = tooltipRect.width || this.nationTooltip.offsetWidth || this.tooltipFollowWidth || 0;
    this.tooltipFollowHeight = tooltipRect.height || this.nationTooltip.offsetHeight || this.tooltipFollowHeight || 0;

    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.positionNationTooltipFast = function (clientX, clientY) {
    var now;
    var rootRect;
    var width;
    var height;
    var localX;
    var localY;
    var left;
    var top;

    if (!this.nationTooltip || !this.currentNation) return false;
    if (!this.nationTooltip.classList.contains('is-visible')) return false;

    now = performance.now ? performance.now() : Date.now();
    rootRect = this.tooltipFollowRootRect;
    if (!rootRect || now - Number(this.tooltipFollowRootRectAt || 0) > 250) {
        rootRect = this.root.getBoundingClientRect();
        this.tooltipFollowRootRect = rootRect;
        this.tooltipFollowRootRectAt = now;
    }

    width = this.tooltipFollowWidth || this.nationTooltip.offsetWidth || 0;
    height = this.tooltipFollowHeight || this.nationTooltip.offsetHeight || 0;
    localX = clientX - rootRect.left;
    localY = clientY - rootRect.top;
    left = localX + 14;
    top = localY + 14;

    if (width && left + width > rootRect.width - 6) {
        left = Math.max(6, localX - width - 12);
    }
    if (height && top + height > rootRect.height - 6) {
        top = Math.max(6, localY - height - 12);
    }

    this.nationTooltip.style.left = left + 'px';
    this.nationTooltip.style.top = top + 'px';
    return true;
};

NationsGlobe.prototype.scheduleNationTooltipFollow = function (e) {
    var self = this;

    if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
        this.pendingTooltipClientX = e.clientX;
        this.pendingTooltipClientY = e.clientY;
    } else if (this.pointerClientX !== null && this.pointerClientY !== null) {
        this.pendingTooltipClientX = this.pointerClientX;
        this.pendingTooltipClientY = this.pointerClientY;
    }

    if (!this.currentNation || !this.nationTooltip) return false;
    if (this.pendingTooltipClientX === null || this.pendingTooltipClientY === null) return false;
    if (this.tooltipFollowRaf) return true;

    this.tooltipFollowRaf = requestAnimationFrame(function () {
        self.tooltipFollowRaf = null;
        if (!self.isCurrentInstance()) return;
        if (self.pendingTooltipClientX === null || self.pendingTooltipClientY === null) return;
        self.positionNationTooltipFast(self.pendingTooltipClientX, self.pendingTooltipClientY);
    });

    return true;
};

NationsGlobe.prototype.cancelNationTooltipFollow = function () {
    if (this.tooltipFollowRaf) {
        cancelAnimationFrame(this.tooltipFollowRaf);
        this.tooltipFollowRaf = null;
    }
    this.pendingTooltipClientX = null;
    this.pendingTooltipClientY = null;
};


NationsGlobe.prototype.ensureNationTooltipDom = function () {
    if (!this.nationTooltip) return;

    if (!this.nationTooltip.querySelector('.nation-tooltip-main')) {
        this.nationTooltip.innerHTML = '' +
            '<span class="nation-tooltip-main">' +
                '<span class="nation-tooltip-flag-slot is-empty" aria-hidden="true"></span>' +
                '<span class="nation-tooltip-text">' +
                    '<span class="nation-tooltip-title-line">' +
                        '<span class="nation-tooltip-name"></span>' +
                        '<span class="nation-tooltip-code"></span>' +
                    '</span>' +
                    '<span class="nation-tooltip-group"></span>' +
                '</span>' +
            '</span>' +
            '<span class="nation-tooltip-link"></span>';
    }

    this.nationTooltipFlagSlot = this.nationTooltip.querySelector('.nation-tooltip-flag-slot');
    this.nationTooltipFlag = this.nationTooltipFlagSlot ? this.nationTooltipFlagSlot.querySelector('.nation-tooltip-flag') : null;
    this.nationTooltipName = this.nationTooltip.querySelector('.nation-tooltip-name');
    this.nationTooltipCode = this.nationTooltip.querySelector('.nation-tooltip-code');
    this.nationTooltipGroup = this.nationTooltip.querySelector('.nation-tooltip-group');
    this.nationTooltipLink = this.nationTooltip.querySelector('.nation-tooltip-link');

    if (this.nationTooltipFlagSlot && !this.nationTooltipFlagSlot.querySelector('.nation-tooltip-flag')) {
        this.nationTooltipFlagSlot.classList.add('is-empty');
    }
};


function collectNationEntryFlagFiles(entry) {
    var files = [];
    var seen = {};

    function push(value) {
        var file = normalizeFileTitle(value || '');
        var key;

        if (!file) return;
        key = file.toLowerCase();
        if (seen[key]) return;
        seen[key] = true;
        files.push(file);
    }

    function pushSymbol(symbol) {
        if (typeof symbol === 'string') {
            push(symbol);
            return;
        }
        if (!symbol || typeof symbol !== 'object') return;
        push(symbol.file || symbol.flag_file || symbol.flag || symbol.flag_title || symbol.symbol_file || symbol.symbol || '');
    }

    if (!entry || typeof entry !== 'object') return files;

    if (Array.isArray(entry.symbols)) entry.symbols.forEach(pushSymbol);
    if (Array.isArray(entry.flags)) entry.flags.forEach(pushSymbol);
    if (Array.isArray(entry.flag_files)) entry.flag_files.forEach(pushSymbol);
    push(entry.flag_file || entry.flag || entry.flag_title || '');

    return files.slice(0, 3);
}

NationsGlobe.prototype.touchNationFlagCache = function (file, record) {
    var order = this.nationFlagImageCacheOrder;
    var oldIndex;
    var removeFile;

    if (!file || !record) return record || null;

    oldIndex = order.indexOf(file);
    if (oldIndex !== -1) order.splice(oldIndex, 1);
    order.push(file);
    this.nationFlagImageCache[file] = record;

    while (order.length > this.nationFlagImageCacheMax) {
        removeFile = order.shift();
        if (removeFile === file) continue;
        delete this.nationFlagImageCache[removeFile];
    }

    return record;
};

NationsGlobe.prototype.ensureNationFlagCached = function (file) {
    var self = this;
    var normalized = normalizeFileTitle(file);
    var record;
    var image;

    if (!normalized) return null;

    record = this.nationFlagImageCache[normalized];
    if (record) {
        if (record.failed && record.retryCount < 2 && Date.now() - Number(record.failedAt || 0) >= 900) {
            delete this.nationFlagImageCache[normalized];
            this.nationFlagImageCacheOrder = this.nationFlagImageCacheOrder.filter(function (file) {
                return file !== normalized;
            });
            record = null;
        } else {
            return this.touchNationFlagCache(normalized, record);
        }
    }

    image = new Image();
    image.className = 'nation-tooltip-flag';
    image.alt = '';
    image.setAttribute('aria-hidden', 'true');
    image.decoding = 'async';
    image.loading = 'eager';
    image.draggable = false;

    record = {
        file: normalized,
        url: buildFileRedirectUrl(normalized, 32),
        image: image,
        loaded: false,
        decoded: false,
        failed: false,
        failedAt: 0,
        retryCount: 0,
        promise: null
    };

    record.promise = new Promise(function (resolve) {
        image.onload = function () {
            record.loaded = true;
            record.failed = false;

            if (image.decode && typeof image.decode === 'function') {
                image.decode().then(function () {
                    record.decoded = true;
                    resolve(record);
                    if (self.currentNation && self.getNationFlagFiles(self.currentNation.page).indexOf(normalized) !== -1) {
                        self.applyNationTooltipFlag(self.currentNation.page);
                        if (Number.isFinite(self.pointerClientX) && Number.isFinite(self.pointerClientY)) {
                            self.positionNationTooltip(self.pointerClientX, self.pointerClientY);
                        }
                    }
                }).catch(function () {
                    record.decoded = true;
                    resolve(record);
                    if (self.currentNation && self.getNationFlagFiles(self.currentNation.page).indexOf(normalized) !== -1) {
                        self.applyNationTooltipFlag(self.currentNation.page);
                    }
                });
            } else {
                record.decoded = true;
                resolve(record);
                if (self.currentNation && self.getNationFlagFiles(self.currentNation.page).indexOf(normalized) !== -1) {
                    self.applyNationTooltipFlag(self.currentNation.page);
                    if (Number.isFinite(self.pointerClientX) && Number.isFinite(self.pointerClientY)) {
                        self.positionNationTooltip(self.pointerClientX, self.pointerClientY);
                    }
                }
            }
        };

        image.onerror = function () {
            record.loaded = false;
            record.decoded = false;
            record.failed = true;
            record.failedAt = Date.now();
            record.retryCount += 1;
            resolve(record);

            if (record.retryCount < 2) {
                window.setTimeout(function () {
                    if (!self.isCurrentInstance()) return;
                    if (self.nationFlagImageCache[normalized] === record) {
                        delete self.nationFlagImageCache[normalized];
                        self.nationFlagImageCacheOrder = self.nationFlagImageCacheOrder.filter(function (file) {
                            return file !== normalized;
                        });
                    }
                    if (self.currentNation && self.getNationFlagFiles(self.currentNation.page).indexOf(normalized) !== -1) {
                        self.applyNationTooltipFlag(self.currentNation.page);
                    }
                }, 1000);
            } else if (self.currentNation && self.getNationFlagFiles(self.currentNation.page).indexOf(normalized) !== -1) {
                self.applyNationTooltipFlag(self.currentNation.page);
            }
        };
    });

    image.src = record.url;
    return this.touchNationFlagCache(normalized, record);
};

NationsGlobe.prototype.collectNationFlagFiles = function () {
    var map = this.nationLinkMap || {};
    var seen = {};
    var files = [];

    Object.keys(map).forEach(function (page) {
        var entry = map[page] || {};
        collectNationEntryFlagFiles(entry).forEach(function (file) {
            if (file && !seen[file]) {
                seen[file] = true;
                files.push(file);
            }
        });
    });

    files.sort(function (a, b) {
        return String(a).localeCompare(String(b), 'ko', { numeric: true, sensitivity: 'base' });
    });

    return files;
};

NationsGlobe.prototype.startNationFlagIdlePreload = function () {
    var self = this;
    var files;
    var index = 0;
    var active = 0;

    if (!this.nationFlagIdlePreloadEnabled || this.nationFlagIdlePreloadStarted) return;
    this.nationFlagIdlePreloadStarted = true;

    files = this.collectNationFlagFiles();
    if (!files.length) return;

    function pump() {
        var file;
        var record;

        while (active < self.nationFlagIdlePreloadConcurrency && index < files.length) {
            file = files[index];
            index += 1;
            record = self.ensureNationFlagCached(file);
            if (record && record.promise && !record.loaded && !record.failed) {
                active += 1;
                record.promise.then(function () {
                    active -= 1;
                    if (window.requestIdleCallback) {
                        window.requestIdleCallback(pump, { timeout: 500 });
                    } else {
                        window.setTimeout(pump, 40);
                    }
                });
            }
        }
    }

    pump();
};

NationsGlobe.prototype.scheduleNationFlagIdlePreload = function () {
    var self = this;

    if (!this.nationFlagIdlePreloadEnabled || this.nationFlagIdlePreloadScheduled) return;
    this.nationFlagIdlePreloadScheduled = true;

    window.setTimeout(function () {
        if (window.requestIdleCallback) {
            window.requestIdleCallback(function () {
                self.startNationFlagIdlePreload();
            }, { timeout: 1600 });
        } else {
            self.startNationFlagIdlePreload();
        }
    }, this.nationFlagIdlePreloadDelay);
};

NationsGlobe.prototype.hideNationTooltipFlag = function () {
    if (!this.nationTooltipFlagSlot) return;
    while (this.nationTooltipFlagSlot.firstChild) {
        this.nationTooltipFlagSlot.removeChild(this.nationTooltipFlagSlot.firstChild);
    }
    this.nationTooltipFlagSlot.classList.add('is-empty');
    this.nationTooltipFlagSlot.removeAttribute('data-flag-file');
    this.nationTooltipFlagSlot.removeAttribute('data-flag-files');
    this.nationTooltipFlag = null;
};

NationsGlobe.prototype.applyNationTooltipFlag = function (page) {
    var files;
    var records = [];
    var key;
    var currentKey;

    this.ensureNationTooltipDom();
    if (!this.nationTooltipFlagSlot) return;

    files = this.getNationFlagFiles(page).slice(0, 3);
    if (!files.length) {
        this.hideNationTooltipFlag();
        return;
    }

    files.forEach(function (file) {
        var record = this.ensureNationFlagCached(file);
        if (record && !record.failed) records.push(record);
    }, this);

    if (!records.length) {
        this.hideNationTooltipFlag();
        return;
    }

    key = records.map(function (record) { return record.file; }).join('|');
    currentKey = this.nationTooltipFlagSlot.getAttribute('data-flag-files') || this.nationTooltipFlagSlot.getAttribute('data-flag-file') || '';
    if (currentKey === key && this.nationTooltipFlagSlot.childNodes.length === records.length) {
        this.nationTooltipFlagSlot.classList.remove('is-empty');
        this.nationTooltipFlag = records[0].image;
        return;
    }

    while (this.nationTooltipFlagSlot.firstChild) {
        this.nationTooltipFlagSlot.removeChild(this.nationTooltipFlagSlot.firstChild);
    }

    records.forEach(function (record) {
        this.nationTooltipFlagSlot.appendChild(record.image);
    }, this);

    this.nationTooltipFlagSlot.setAttribute('data-flag-files', key);
    this.nationTooltipFlagSlot.setAttribute('data-flag-file', records[0].file);
    this.nationTooltipFlagSlot.classList.remove('is-empty');
    this.nationTooltipFlag = records[0].image;
};

NationsGlobe.prototype.updateNationTooltipContent = function (page, label, url, groupText, clientX, clientY) {
    this.ensureNationTooltipDom();
    if (!this.nationTooltip) return;

    this.applyNationTooltipFlag(page);

    if (this.nationTooltipName) this.nationTooltipName.textContent = label || page || '';
    if (this.nationTooltipCode) this.nationTooltipCode.textContent = page || '';

    if (this.nationTooltipGroup) {
        if (groupText) {
            this.nationTooltipGroup.textContent = groupText;
            this.nationTooltipGroup.style.display = '';
        } else {
            this.nationTooltipGroup.textContent = '';
            this.nationTooltipGroup.style.display = 'none';
        }
    }

    if (this.nationTooltipLink) {
        this.nationTooltipLink.textContent = url ? '클릭하여 문서 열기 ›' : '링크 미지정';
    }

    this.nationTooltip.classList.add('is-visible');
    this.nationTooltip.setAttribute('aria-hidden', 'false');
    this.positionNationTooltip(clientX, clientY);
};


NationsGlobe.prototype.getNationFlagFiles = function (page) {
    var files = [];
    var seen = {};
    var sources = [
        this.getNationEntry(page),
        this.nationListSymbolMap ? this.nationListSymbolMap[page] : null
    ];

    sources.forEach(function (entry) {
        collectNationEntryFlagFiles(entry).forEach(function (file) {
            var key = String(file || '').toLowerCase();
            if (!file || seen[key]) return;
            seen[key] = true;
            files.push(file);
        });
    });

    return files.slice(0, 3);
};

NationsGlobe.prototype.getNationFlagFile = function (page) {
    var files = this.getNationFlagFiles(page);
    return files.length ? files[0] : '';
};

NationsGlobe.prototype.getNationFlagUrl = function (page) {
    return buildFileRedirectUrl(this.getNationFlagFile(page), 32);
};


NationsGlobe.prototype.getNationHoverParent = function (page) {
    var entry = this.getNationEntry(page);
    var parent;
    var legacyGroup;

    if (!entry || !page) return '';

    parent = String(entry.hover_parent || entry.hoverParent || entry.parent_page || entry.parentPage || entry.parent || '').trim();
    if (!parent) {
        legacyGroup = String(entry.hover_group || entry.hoverGroup || entry.group || '').trim();
        if (legacyGroup && legacyGroup !== page) parent = legacyGroup;
    }

    if (!parent || parent === page || !this.getNationEntry(parent)) return '';
    return parent;
};

NationsGlobe.prototype.getNationHoverRelation = function (page) {
    var entry = this.getNationEntry(page);
    return entry ? String(entry.hover_relation || entry.hoverRelation || entry.relation || '').trim() : '';
};

NationsGlobe.prototype.getNationHoverAncestors = function (page) {
    var ancestors = [];
    var seen = {};
    var parent;

    page = String(page || '').trim();
    while (page && !seen[page]) {
        seen[page] = true;
        parent = this.getNationHoverParent(page);
        if (!parent || seen[parent]) break;
        ancestors.push(parent);
        page = parent;
    }

    return ancestors;
};

NationsGlobe.prototype.getNationHoverRoot = function (page) {
    var ancestors = this.getNationHoverAncestors(page);
    return ancestors.length ? ancestors[ancestors.length - 1] : page;
};

NationsGlobe.prototype.getNationHoverDescendants = function (page) {
    var descendants = [];
    var map = this.nationLinkMap || {};

    if (!page) return descendants;

    Object.keys(map).forEach(function (code) {
        if (code !== page && this.getNationHoverAncestors(code).indexOf(page) !== -1) {
            descendants.push(code);
        }
    }, this);

    return descendants.sort(function (a, b) { return a.localeCompare(b); });
};

NationsGlobe.prototype.getNationHoverRootMembers = function (page) {
    var root;
    var members = [];
    var seen = {};
    var map = this.nationLinkMap || {};

    if (!page) return [];
    root = this.getNationHoverRoot(page);

    Object.keys(map).forEach(function (code) {
        if (this.getNationHoverRoot(code) === root && !seen[code]) {
            seen[code] = true;
            members.push(code);
        }
    }, this);

    if (!seen[page]) members.unshift(page);
    members.sort(function (a, b) {
        if (a === page) return -1;
        if (b === page) return 1;
        return a.localeCompare(b);
    });

    return members;
};

NationsGlobe.prototype.getNationHoverGroupMembers = function (page) {
    if (!page) return [];
    if (!this.nationHoverGroupsEnabled) return [page];
    return this.getNationHoverRootMembers(page);
};

NationsGlobe.prototype.getNationHoverContext = function (page) {
    var ancestors;
    var descendants;
    var rootMembers;
    var related = [];
    var used = {};
    var primary = page ? [page] : [];

    if (!page) {
        return { primary: [], ancestors: [], descendants: [], related: [], members: [], root: '' };
    }

    if (!this.nationHoverGroupsEnabled) {
        return { primary: primary, ancestors: [], descendants: [], related: [], members: primary.slice(), root: page };
    }

    ancestors = this.nationHoverTreeEnabled ? this.getNationHoverAncestors(page) : [];
    descendants = this.nationHoverTreeEnabled ? this.getNationHoverDescendants(page) : [];
    rootMembers = this.getNationHoverRootMembers(page);

    used[page] = true;
    ancestors.forEach(function (item) { used[item] = true; });
    descendants.forEach(function (item) { used[item] = true; });

    if (!this.nationHoverTreeEnabled) {
        related = rootMembers.filter(function (item) { return item !== page; });
    } else if (this.nationHoverTreeScope !== 'branch') {
        related = rootMembers.filter(function (item) { return !used[item]; });
    }

    return {
        primary: primary,
        ancestors: ancestors,
        descendants: descendants,
        related: related,
        members: rootMembers,
        root: this.getNationHoverRoot(page)
    };
};

NationsGlobe.prototype.getNationHoverLineageText = function (page, context) {
    var parent = this.getNationHoverParent(page);
    var relation = this.getNationHoverRelation(page);
    var count = context && context.members ? context.members.length : this.getNationHoverGroupMembers(page).length;
    var label = '';
    var text = '';

    if (parent) {
        label = this.getNationLabel(parent) || parent;
    } else if (context && context.root && context.root !== page) {
        label = this.getNationLabel(context.root) || context.root;
    } else if (count > 1) {
        label = this.getNationLabel(page) || page;
    }

    if (!label && count <= 1 && !relation) return '';

    if (label) text = label;
    if (relation) text += (text ? ' · ' : '') + relation;
    if (count > 1) text += (text ? ' · ' : '') + count + '개 지역';

    return text;
};

NationsGlobe.prototype.getNationHoverGroupLabel = function (page) {
    var root = this.getNationHoverRoot(page);
    if (!root || root === page) return this.getNationLabel(page);
    return this.getNationLabel(root) || root;
};

NationsGlobe.prototype.focusNationByPage = function (page, options) {
    var self = this;
    var label;

    page = String(page || '').trim();
    options = options || {};
    if (!page) return Promise.resolve(false);

    if (this.nationFocusTargetPage === page &&
        this.root && this.root.classList.contains('has-panel-nation-focus') &&
        this.currentNation && this.currentNation.page === page) {
        label = this.getNationLabel(page) || options.label || page;
        this.setReadout(label + ' · PANEL FOCUS');
        return Promise.resolve(true);
    }

    return (window.InteractionPerf && typeof window.InteractionPerf.measureAsync === 'function' ?
        window.InteractionPerf.measureAsync('globe panel focus load hit data', { page: page }, function () { return self.loadNationHitGeojson(); }) :
        self.loadNationHitGeojson()).then(function () {
        var center;
        var targetCameraZ;
        var focusOptions;

        if (!self.isCurrentInstance()) return false;
        center = self.getNationFocusCenter(page);
        if (!center) {
            if (window.console && console.warn) console.warn('CLBI nation panel focus target not found:', page);
            return false;
        }

        targetCameraZ = self.getNationFocusCameraZ(center, options);
        focusOptions = Object.assign({}, options, { cameraZ: targetCameraZ });

        self.nationFocusTargetPage = page;
        label = self.getNationLabel(page) || options.label || page;
        self.currentNation = {
            page: page,
            entry: self.getNationEntry(page),
            lon: center.lon,
            lat: center.lat,
            bbox: center.bbox,
            spanDeg: center.maxSpanDeg,
            cameraZ: targetCameraZ
        };
        self.applyNationVisualHover(page);
        self.root.classList.add('has-nation-hover', 'has-panel-nation-focus');
        self.setReadout(label + ' · PANEL FOCUS');
        self.startNationFocusMotion(center, focusOptions);
        return true;
    });
};

NationsGlobe.prototype.clearPanelNationFocus = function (page) {
    page = String(page || '').trim();
    if (!this.root || !this.root.classList.contains('has-panel-nation-focus')) return false;
    if (page && this.nationFocusTargetPage && page !== this.nationFocusTargetPage) return false;

    this.nationFocusTargetPage = '';
    this.applyNationFocusReleaseInertia();
    this.nationFocusMotion = null;
    this.clearNationHover();
    this.setReadout(this.getBaseReadout());
    return true;
};


NationsGlobe.prototype.getNationFeatureCollection = function (page) {
    var features = [];

    if (!page || !this.nationHitFeatures || !this.nationHitFeatures.length) return null;

    this.nationHitFeatures.forEach(function (feature) {
        if (feature.page === page && feature.geometry) {
            features.push({
                type: 'Feature',
                properties: { page: page },
                geometry: feature.geometry
            });
        }
    });

    return features.length ? {
        type: 'FeatureCollection',
        features: features
    } : null;
};

NationsGlobe.prototype.getNationFeatureCollectionForPages = function (pages) {
    var features = [];
    var wanted = {};

    if (!pages || !pages.length || !this.nationHitFeatures || !this.nationHitFeatures.length) return null;
    pages.forEach(function (page) {
        if (page) wanted[page] = true;
    });

    this.nationHitFeatures.forEach(function (feature) {
        if (feature.page && wanted[feature.page] && feature.geometry) {
            features.push({
                type: 'Feature',
                properties: { page: feature.page },
                geometry: feature.geometry
            });
        }
    });

    return features.length ? {
        type: 'FeatureCollection',
        features: features
    } : null;
};

NationsGlobe.prototype.clearNationVisualHover = function () {
    this.currentNationVisualPage = '';
    this.currentNationVisualKey = '';
    this.nationHoverPendingPage = '';

    if (this.nationHoverSurface) {
        this.nationHoverSurface.visible = false;
    }

    if (this.nationHoverBorder) {
        this.nationHoverBorder.visible = false;
        this.nationHoverBorder = null;
    }

    if (this.nationHoverBorders && this.nationHoverBorders.length) {
        this.nationHoverBorders.forEach(function (mesh) {
            if (mesh) mesh.visible = false;
        });
        this.nationHoverBorders = [];
    }
};


NationsGlobe.prototype.ensureNationHoverBorderCacheRoom = function (needed) {
    needed = Math.max(1, Math.min(256, Math.round(toNumber(needed, 1))));
    if (needed > this.nationHoverBorderCacheMax) {
        this.nationHoverBorderCacheMax = needed;
    }
};

NationsGlobe.prototype.touchNationHoverBorderCache = function (page, mesh) {
    var order = this.nationHoverBorderCacheOrder;
    var oldIndex = order.indexOf(page);
    var removePage;
    var removeMesh;

    if (oldIndex !== -1) order.splice(oldIndex, 1);
    order.push(page);
    this.nationHoverBorderCache[page] = mesh;

    while (order.length > this.nationHoverBorderCacheMax) {
        removePage = order.shift();
        if (removePage === page) continue;
        removeMesh = this.nationHoverBorderCache[removePage];
        delete this.nationHoverBorderCache[removePage];
        if (removeMesh) {
            if (this.group) this.group.remove(removeMesh);
            if (removeMesh.geometry && typeof removeMesh.geometry.dispose === 'function') removeMesh.geometry.dispose();
        }
    }
};

NationsGlobe.prototype.ensureNationHoverFillSurface = function (THREE, width, height) {
    var surfaceRadius;

    if (this.nationHoverSurface && this.nationHoverTexture && this.nationHoverCanvas) return;

    this.nationHoverCanvas = this.nationHoverCanvas || document.createElement('canvas');
    this.nationHoverCanvas.width = width;
    this.nationHoverCanvas.height = height;
    this.nationHoverTexture = new THREE.CanvasTexture(this.nationHoverCanvas);
    if ('colorSpace' in this.nationHoverTexture && THREE.SRGBColorSpace) this.nationHoverTexture.colorSpace = THREE.SRGBColorSpace;

    this.nationHoverFillMaterial = new THREE.MeshBasicMaterial({
        map: this.nationHoverTexture,
        transparent: true,
        opacity: 1,
        alphaTest: 0.01,
        depthWrite: false,
        depthTest: true
    });

    surfaceRadius = this.radius + Math.max(0.0018, Math.min(0.006, this.nationHoverSurfaceOffset));
    this.nationHoverSurface = new THREE.Mesh(new THREE.SphereGeometry(surfaceRadius, 64, 32), this.nationHoverFillMaterial);
    this.nationHoverSurface.renderOrder = 60;
    this.nationHoverSurface.visible = false;
    this.group.add(this.nationHoverSurface);
};

NationsGlobe.prototype.drawNationHoverFillCollection = function (ctx, collection, width, height, opacity) {
    var rgb;

    if (!collection || !collection.features || !collection.features.length || opacity <= 0) return;
    ctx.beginPath();
    collection.features.forEach(function (feature) {
        drawGeometryOnCanvas(ctx, feature.geometry, width, height);
    });
    rgb = hexToRgb('#ffffff', 0xffffff);
    ctx.fillStyle = 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' + Math.max(0, Math.min(0.35, opacity)) + ')';
    ctx.fill('evenodd');
};


NationsGlobe.prototype.updateNationHoverFillSurface = function (collections) {
    var THREE = this.THREE;
    var width;
    var height;
    var ctx;
    var hasFill;

    collections = collections || {};
    hasFill = this.nationHoverFillOpacity > 0 || this.nationHoverAncestorFillOpacity > 0 || this.nationHoverDescendantFillOpacity > 0 || this.nationHoverRelatedFillOpacity > 0 || this.nationHoverLinkedFillOpacity > 0;

    if (!this.nationHoverEnabled || !THREE || !this.group || !hasFill) {
        if (this.nationHoverSurface) this.nationHoverSurface.visible = false;
        return;
    }

    width = this.nationHoverTextureWidth || 512;
    height = Math.max(256, Math.round(width / 2));
    this.ensureNationHoverFillSurface(THREE, width, height);

    if (this.nationHoverCanvas.width !== width || this.nationHoverCanvas.height !== height) {
        this.nationHoverCanvas.width = width;
        this.nationHoverCanvas.height = height;
    }

    ctx = this.nationHoverCanvas.getContext('2d', { alpha: true });
    ctx.clearRect(0, 0, width, height);
    this.drawNationHoverFillCollection(ctx, collections.related, width, height, this.nationHoverRelatedFillOpacity);
    this.drawNationHoverFillCollection(ctx, collections.descendants, width, height, this.nationHoverDescendantFillOpacity);
    this.drawNationHoverFillCollection(ctx, collections.ancestors, width, height, this.nationHoverAncestorFillOpacity);
    this.drawNationHoverFillCollection(ctx, collections.linked, width, height, this.nationHoverLinkedFillOpacity);
    this.drawNationHoverFillCollection(ctx, collections.primary, width, height, this.nationHoverFillOpacity);

    this.nationHoverTexture.needsUpdate = true;
    this.nationHoverSurface.visible = true;
};


NationsGlobe.prototype.getNationHoverRoleBorderOpacity = function (role) {
    if (role === 'ancestor') return this.nationHoverAncestorBorderOpacity;
    if (role === 'descendant') return this.nationHoverDescendantBorderOpacity;
    if (role === 'related') return this.nationHoverRelatedBorderOpacity;
    if (role === 'linked') return this.nationHoverLinkedBorderOpacity;
    return this.nationHoverBorderOpacity;
};

NationsGlobe.prototype.getNationHoverBorderMaterial = function (role) {
    var THREE = this.THREE;
    var materialName;
    var opacity = this.getNationHoverRoleBorderOpacity(role || 'primary');
    var material;

    if (!THREE) return null;

    role = role || 'primary';
    if (role === 'ancestor') materialName = 'nationHoverAncestorBorderMaterial';
    else if (role === 'descendant') materialName = 'nationHoverDescendantBorderMaterial';
    else if (role === 'related') materialName = 'nationHoverRelatedBorderMaterial';
    else if (role === 'linked') materialName = 'nationHoverLinkedBorderMaterial';
    else materialName = 'nationHoverBorderMaterial';

    material = this[materialName];

    if (!material) {
        material = new THREE.LineBasicMaterial({
            color: parseHexColor(this.nationHoverBorderColor, 0xf2c300),
            transparent: true,
            opacity: Math.max(0, Math.min(1, opacity)),
            linewidth: Math.max(1, Math.min(2, this.nationHoverBorderWidth)),
            depthWrite: false,
            depthTest: true
        });
        this[materialName] = material;
    } else {
        if (material.color && typeof material.color.setHex === 'function') {
            material.color.setHex(parseHexColor(this.nationHoverBorderColor, 0xf2c300));
        }
        material.opacity = Math.max(0, Math.min(1, opacity));
    }

    return material;
};

NationsGlobe.prototype.getNationHoverBorderMesh = function (page, collection, role) {
    var THREE = this.THREE;
    var borderRadius;
    var lineBuilt;
    var mesh;
    var material;

    if (!this.nationHoverBorderGeometryEnabled || !this.nationHoverBorderEnabled || this.nationHoverBorderOpacity <= 0) return null;

    mesh = this.nationHoverBorderCache[page];
    material = this.getNationHoverBorderMaterial(role || 'primary');

    if (mesh) {
        if (material && mesh.material !== material) mesh.material = material;
        this.touchNationHoverBorderCache(page, mesh);
        return mesh;
    }

    if (!THREE || !collection || !material) return null;

    borderRadius = this.radius + Math.max(0.0019, Math.min(0.0065, this.nationHoverBorderSurfaceOffset));
    lineBuilt = buildLineGeometryFromGeojson(THREE, collection, borderRadius, this.nationHoverBorderSimplify);

    mesh = new THREE.LineSegments(lineBuilt.geometry, material);
    mesh.renderOrder = role === 'primary' ? 72 : (role === 'ancestor' ? 71 : 70);
    mesh.visible = false;
    this.group.add(mesh);
    this.touchNationHoverBorderCache(page, mesh);
    return mesh;
};


NationsGlobe.prototype.applyNationVisualHover = function (page) {
    var self = this;
    var context;
    var visualKey;
    var collections;
    var primaryCollection;
    var borderMesh;

    if (!this.nationHoverEnabled || !this.THREE || !this.group || !page) return;

    context = this.getNationHoverContext(page);
    visualKey = page + '|a:' + context.ancestors.join(',') + '|d:' + context.descendants.join(',') + '|r:' + context.related.join(',');
    if (this.currentNationVisualKey === visualKey) return;

    primaryCollection = this.getNationFeatureCollection(page);
    if (!primaryCollection) {
        this.nationHoverPendingPage = page;
        this.loadNationHitGeojson().then(function () {
            if (self.nationHoverPendingPage === page && self.currentNation && self.currentNation.page === page) {
                self.currentNationVisualPage = '';
                self.currentNationVisualKey = '';
                self.applyNationVisualHover(page);
            }
        });
        return;
    }

    collections = {
        primary: primaryCollection,
        ancestors: this.getNationFeatureCollectionForPages(context.ancestors),
        descendants: this.getNationFeatureCollectionForPages(context.descendants),
        related: this.getNationFeatureCollectionForPages(context.related),
        linked: null
    };

    if (this.nationHoverBorder) {
        this.nationHoverBorder.visible = false;
        this.nationHoverBorder = null;
    }
    if (this.nationHoverBorders && this.nationHoverBorders.length) {
        this.nationHoverBorders.forEach(function (mesh) {
            if (mesh) mesh.visible = false;
        });
        this.nationHoverBorders = [];
    }

    this.currentNationVisualPage = page;
    this.currentNationVisualKey = visualKey;
    this.nationHoverPendingPage = '';
    this.updateNationHoverFillSurface(collections);

    this.ensureNationHoverBorderCacheRoom((context.members ? context.members.length : 1) + 8);

    function showBorderForPages(pages, role) {
        (pages || []).forEach(function (targetPage) {
            var collection = self.getNationFeatureCollection(targetPage);
            var mesh = self.getNationHoverBorderMesh(targetPage, collection, role);
            if (mesh) {
                mesh.visible = true;
                self.nationHoverBorders.push(mesh);
            }
        });
    }

    showBorderForPages(context.members.filter(function (item) { return item && item !== page; }), 'linked');

    borderMesh = this.getNationHoverBorderMesh(page, primaryCollection, 'primary');
    if (borderMesh) {
        borderMesh.visible = true;
        this.nationHoverBorder = borderMesh;
        this.nationHoverBorders.push(borderMesh);
    }
};

NationsGlobe.prototype.updateNationHover = function (ll, clientX, clientY) {
    var hit = this.findNationAtLonLat(ll);
    var page = hit ? hit.page : '';
    var label;
    var url;
    var groupText = '';
    var oldPage = this.currentNation ? this.currentNation.page : '';
    var context;

    if (!hit || !page) {
        this.clearNationHover();
        return null;
    }

    this.currentNation = {
        page: page,
        entry: this.getNationEntry(page),
        lon: ll.lon,
        lat: ll.lat
    };

    if (oldPage === page) {
        if (this.nationTooltip) this.positionNationTooltip(clientX, clientY);
        return this.currentNation;
    }

    context = this.getNationHoverContext(page);
    label = this.getNationLabel(page);
    url = this.getNationUrl(page);
    groupText = this.getNationHoverLineageText(page, context);

    this.applyNationVisualHover(page);
    this.root.classList.add('has-nation-hover');

    this.updateNationTooltipContent(page, label, url, groupText, clientX, clientY);

    return this.currentNation;
};

NationsGlobe.prototype.clearNationHover = function () {
    this.currentNation = null;
    this.cancelNationTooltipFollow();
    this.root.classList.remove('has-nation-hover', 'has-panel-nation-focus');
    this.clearNationVisualHover();

    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 resolveLatestWikiFileUrl(url)
        .then(function (requestUrl) {
            return fetch(requestUrl, { credentials: 'same-origin', cache: 'force-cache' })
                .then(function (res) {
                    if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + requestUrl);
                    return res.json();
                });
        })
        .then(function (payload) {
            var geojson;
            var built;
            var material;
            var lines;

            if (!self.isCurrentInstance() || !self.group) return;

            geojson = extractGeojsonFromPayload(payload, self.topojson, objectName);
            built = buildLineGeometryFromGeojson(THREE, geojson, self.radius + Math.max(0.0008, Math.min(0.006, self.boundarySurfaceOffset)));
            material = new THREE.LineBasicMaterial({
                color: 0xb8b8b8,
                transparent: true,
                opacity: 0.58,
                depthWrite: false
            });
            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 frameStartedAt = interactionPerfNow();
    var renderStartedAt;
    var frameCost;
    var pointerInterval;
    var globeMotionChanged = false;
    var dt = Math.min(50, now - this.lastTime);
    var reliefActive = this.isInUiReliefWindow(now) && !this.dragging;
    this.lastTime = now;

    if (this.disposed || !document.body.contains(this.root)) {
        this.dispose();
        return;
    }

    if (this.stepNationFocusMotion(dt)) {
        globeMotionChanged = true;
        /* nation-panel focus interpolation owns rotation/camera for this frame */
    } else if (this.stepResetMotion(dt)) {
        globeMotionChanged = true;
        /* reset interpolation owns rotation/camera for this frame */
    } else if (this.autoRotate && !this.dragging) {
        this.rotationY += dt * this.autoRotateSpeed * this.getAutoRotateFactor(dt);
        this.applyRotation();
        globeMotionChanged = true;
    } 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();
        globeMotionChanged = true;
    }

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

    if (this.resizeDirty || now - (this.lastResizeCheckAt || 0) > this.resizeCheckInterval) {
        if (!reliefActive) {
            this.lastResizeCheckAt = now;
            this.resizeDirty = false;
            this.resize();
        }
    }

    if (!this.dragging && this.pointerInside && this.pointerClientX !== null && this.pointerClientY !== null) {
        pointerInterval = globeMotionChanged ? this.motionPointerReadoutInterval : this.autoPointerReadoutInterval;
        if (!reliefActive && now - (this.lastAutoPointerReadoutAt || 0) > pointerInterval) {
            this.lastAutoPointerReadoutAt = now;
            this.updatePointerReadoutFromClient(this.pointerClientX, this.pointerClientY);
        }
    }

    if (this.renderer && this.scene && this.camera) {
        renderStartedAt = interactionPerfNow();
        this.renderer.render(this.scene, this.camera);
        frameCost = interactionPerfNow() - renderStartedAt;
        if (frameCost > 8) {
            interactionPerfMark('globe renderer render cost', { ms: Math.round(frameCost * 100) / 100, dragging: !!this.dragging, relief: !!reliefActive });
        }
    }

    frameCost = interactionPerfNow() - frameStartedAt;
    if (frameCost > 18) {
        interactionPerfMark('globe animate long frame', { ms: Math.round(frameCost * 100) / 100, dragging: !!this.dragging, relief: !!reliefActive, dt: Math.round(dt * 100) / 100 });
    }

    this.frame = window.requestAnimationFrame(function (t) {
        self.animate(t);
    });
};

NationsGlobe.prototype.dispose = function () {
    var canvas;
    var stage;
    var index;

    if (this.disposed) return;

    this.disposed = true;
    this.stopLoadingTimers();

    if (this.frame) {
        window.cancelAnimationFrame(this.frame);
        this.frame = null;
    }

    this.cancelPointerReadoutFrame();
    this.cancelNationTooltipFollow();
    this.dragging = false;
    this.pointerInside = false;
    this.pointerClientX = null;
    this.pointerClientY = null;
    this.pointerDownX = null;
    this.pointerDownY = null;
    this.pointerDownNationPage = null;
    this.currentNation = null;

    if (this.boundResizeHandler) {
        window.removeEventListener('resize', this.boundResizeHandler);
        this.boundResizeHandler = null;
    }

    if (this.boundContinentSwitchingHandler) {
        window.removeEventListener('clbi:nations-continent-switching', this.boundContinentSwitchingHandler);
        this.boundContinentSwitchingHandler = null;
    }

    if (this.uiReliefTimer) {
        window.clearTimeout(this.uiReliefTimer);
        this.uiReliefTimer = null;
    }

    if (this.halftoneBackgroundBusyTimer) {
        window.clearTimeout(this.halftoneBackgroundBusyTimer);
        this.halftoneBackgroundBusyTimer = null;
    }

    if (this.root) {
        this.root.classList.remove('is-dragging', 'has-nation-hover', 'is-ui-relief');
        this.root.removeAttribute('data-clbi-globe-bg-busy');
    }

    if (this.nationTooltip) {
        this.nationTooltip.classList.remove('is-visible');
        this.nationTooltip.setAttribute('aria-hidden', 'true');
    }

    if (this.scene) {
        disposeObject3D(this.scene);
    }

    if (this.renderer) {
        canvas = this.renderer.domElement || null;
        stage = canvas && canvas.parentNode ? canvas.parentNode : null;

        try { this.renderer.renderLists.dispose(); } catch (err) {}
        try { this.renderer.dispose(); } catch (err) {}
        try { this.renderer.forceContextLoss(); } catch (err) {}

        if (stage && canvas) {
            try { stage.removeChild(canvas); } catch (err) {}
        }
    }

    this.renderer = null;
    this.scene = null;
    this.camera = null;
    this.group = null;
    this.starGroup = null;
    this.celestialGroup = null;
    this.earth = null;
    this.earthMaterial = null;
    this.clouds = null;
    this.cloudMaterial = null;
    this.lakesOverlay = null;
    this.raycaster = null;
    this.pointer = null;

    index = instances.indexOf(this);
    if (index !== -1) instances.splice(index, 1);
};

function initNationsGlobes(root) {
    var scope = root && root.querySelectorAll ? root : document;
    var nodes = [];

    if (scope.matches && scope.matches('[data-nations-globe], .clbi-nations-globe-window')) {
        nodes.push(scope);
    }

    nodes = nodes.concat(Array.prototype.slice.call(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);
}


function reloadNationsGlobe(target) {
    var node = null;
    var instance = null;

    if (target && target.matches && target.matches('[data-nations-globe], .clbi-nations-globe-window')) {
        node = target;
    } else if (target && target.querySelector) {
        node = target.querySelector('[data-nations-globe], .clbi-nations-globe-window');
    }

    if (!node) {
        initNationsGlobes(document);
        return;
    }

    instance = node.CLBI_NationsGlobeInstance || null;
    if (instance && typeof instance.dispose === 'function') {
        try { instance.dispose(); } catch (err) {}
    }

    node.CLBI_NationsGlobeInstance = null;
    node.removeAttribute('data-nations-globe-ready');
    node.classList.remove('is-ready', 'is-loading', 'is-connected', 'is-no-signal', 'has-error', 'is-dragging', 'has-nation-hover');
    node.innerHTML = '';

    initNationsGlobes(node);
}

window.CLBI_NationsGlobe = {
    build: NATIONS_GLOBE_BUILD,
    init: initNationsGlobes,
    instances: instances,
    defaultWorldUrl: DEFAULT_WORLD_URL,
    reload: reloadNationsGlobe,
    focus: function (target, page, options) {
        var node = target && target.matches && target.matches('[data-nations-globe], .clbi-nations-globe-window') ? target : (target && target.querySelector ? target.querySelector('[data-nations-globe], .clbi-nations-globe-window') : null);
        var instance = node && node.CLBI_NationsGlobeInstance ? node.CLBI_NationsGlobeInstance : null;
        return instance && typeof instance.focusNationByPage === 'function' ? instance.focusNationByPage(page, options || {}) : Promise.resolve(false);
    }
};

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