편집 요약 없음 태그: 되돌려진 기여 |
편집 요약 없음 태그: 수동 되돌리기 |
||
| 317번째 줄: | 317번째 줄: | ||
this.autoButton = null; | this.autoButton = null; | ||
this.resetButton = null; | this.resetButton = null; | ||
this.THREE = null; | this.THREE = null; | ||
this.scene = null; | this.scene = null; | ||
| 352번째 줄: | 350번째 줄: | ||
'<div class="clbi-nations-globe-stage" aria-label="3D globe viewport"></div>' + | '<div class="clbi-nations-globe-stage" aria-label="3D globe viewport"></div>' + | ||
'<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>' + | '<div class="clbi-nations-globe-vignette" aria-hidden="true"></div>' + | ||
'<div class="clbi-nations-globe-topbar">' + | '<div class="clbi-nations-globe-topbar">' + | ||
'<span class="clbi-nations-globe-title">' + escapeHtml(title) + '</span>' + | '<span class="clbi-nations-globe-title">' + escapeHtml(title) + '</span>' + | ||
| 376번째 줄: | 372번째 줄: | ||
this.autoButton = root.querySelector('.clbi-nations-globe-auto'); | this.autoButton = root.querySelector('.clbi-nations-globe-auto'); | ||
this.resetButton = root.querySelector('.clbi-nations-globe-reset'); | this.resetButton = root.querySelector('.clbi-nations-globe-reset'); | ||
if (this.autoButton) { | if (this.autoButton) { | ||
| 581번째 줄: | 575번째 줄: | ||
e.preventDefault(); | e.preventDefault(); | ||
e.stopPropagation(); | e.stopPropagation(); | ||
}); | }); | ||
} | } | ||
2026년 6월 30일 (화) 21:47 판
/* =========================================
COASTLINE: BLACK ICE - NationsGlobe
Real-world prototype / QGIS-ready globe viewer
========================================= */
(function (mw, $) {
'use strict';
var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
var TOPOJSON_URL = 'https://cdn.jsdelivr.net/npm/topojson-client@3/+esm';
var DEFAULT_WORLD_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
var instances = [];
var threeModulePromise = null;
var topoModulePromise = null;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function loadThree() {
if (window.CLBI_NATIONS_GLOBE_THREE) {
return Promise.resolve(window.CLBI_NATIONS_GLOBE_THREE);
}
if (!threeModulePromise) {
threeModulePromise = import(THREE_URL).then(function (module) {
window.CLBI_NATIONS_GLOBE_THREE = module;
return module;
});
}
return threeModulePromise;
}
function loadTopojson() {
if (window.CLBI_NATIONS_GLOBE_TOPOJSON) {
return Promise.resolve(window.CLBI_NATIONS_GLOBE_TOPOJSON);
}
if (!topoModulePromise) {
topoModulePromise = import(TOPOJSON_URL).then(function (module) {
window.CLBI_NATIONS_GLOBE_TOPOJSON = module;
return module;
});
}
return topoModulePromise;
}
function toNumber(value, fallback) {
var n = parseFloat(value);
return Number.isFinite(n) ? n : fallback;
}
function lonLatToVector3(THREE, lon, lat, radius) {
var phi = (90 - lat) * Math.PI / 180;
var theta = (lon + 180) * Math.PI / 180;
var sinPhi = Math.sin(phi);
return new THREE.Vector3(
-radius * sinPhi * Math.cos(theta),
radius * Math.cos(phi),
radius * sinPhi * Math.sin(theta)
);
}
function vectorToLonLat(vec) {
var n = vec.clone().normalize();
var lat = Math.asin(Math.max(-1, Math.min(1, n.y))) * 180 / Math.PI;
var lon = Math.atan2(n.z, -n.x) * 180 / Math.PI - 180;
while (lon < -180) lon += 360;
while (lon > 180) lon -= 360;
return {
lon: lon,
lat: lat
};
}
function normalizeLonDelta(a, b) {
var d = b - a;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
}
function pushLineSegment(THREE, positions, a, b, radius) {
if (!a || !b || a.length < 2 || b.length < 2) return;
var lonA = toNumber(a[0], 0);
var latA = toNumber(a[1], 0);
var lonB = toNumber(b[0], 0);
var latB = toNumber(b[1], 0);
var dLon = normalizeLonDelta(lonA, lonB);
var dLat = latB - latA;
var steps = Math.max(1, Math.ceil(Math.max(Math.abs(dLon), Math.abs(dLat)) / 3));
var i;
for (i = 0; i < steps; i += 1) {
var t0 = i / steps;
var t1 = (i + 1) / steps;
var p0 = lonLatToVector3(THREE, lonA + dLon * t0, latA + dLat * t0, radius);
var p1 = lonLatToVector3(THREE, lonA + dLon * t1, latA + dLat * t1, radius);
positions.push(p0.x, p0.y, p0.z, p1.x, p1.y, p1.z);
}
}
function pushLineString(THREE, positions, coords, radius) {
var i;
if (!Array.isArray(coords) || coords.length < 2) return;
for (i = 0; i < coords.length - 1; i += 1) {
pushLineSegment(THREE, positions, coords[i], coords[i + 1], radius);
}
}
function pushPolygonRings(THREE, positions, rings, radius) {
if (!Array.isArray(rings)) return;
rings.forEach(function (ring) {
pushLineString(THREE, positions, ring, radius);
});
}
function pushGeometryLines(THREE, positions, geometry, radius) {
if (!geometry) return;
if (geometry.type === 'LineString') {
pushLineString(THREE, positions, geometry.coordinates, radius);
return;
}
if (geometry.type === 'MultiLineString') {
geometry.coordinates.forEach(function (line) {
pushLineString(THREE, positions, line, radius);
});
return;
}
if (geometry.type === 'Polygon') {
pushPolygonRings(THREE, positions, geometry.coordinates, radius);
return;
}
if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach(function (polygon) {
pushPolygonRings(THREE, positions, polygon, radius);
});
return;
}
if (geometry.type === 'GeometryCollection') {
geometry.geometries.forEach(function (child) {
pushGeometryLines(THREE, positions, child, radius);
});
}
}
function extractGeojsonFromPayload(payload, topojson, objectName) {
if (!payload) throw new Error('empty data');
if (payload.type === 'Topology') {
if (!topojson || typeof topojson.mesh !== 'function') {
throw new Error('topojson-client unavailable');
}
var objects = payload.objects || {};
var target = objects[objectName] || objects.countries || objects.land || objects[Object.keys(objects)[0]];
if (!target) throw new Error('topology object not found');
return topojson.mesh(payload, target, function (a, b) {
return a !== b;
});
}
if (payload.type === 'FeatureCollection') {
return payload;
}
if (payload.type === 'Feature') {
return {
type: 'FeatureCollection',
features: [payload]
};
}
if (payload.type && payload.coordinates) {
return payload;
}
throw new Error('unsupported map data');
}
function buildLineGeometryFromGeojson(THREE, geojson, radius) {
var positions = [];
if (geojson.type === 'FeatureCollection') {
geojson.features.forEach(function (feature) {
pushGeometryLines(THREE, positions, feature.geometry, radius);
});
} else if (geojson.type === 'Feature') {
pushGeometryLines(THREE, positions, geojson.geometry, radius);
} else {
pushGeometryLines(THREE, positions, geojson, radius);
}
var geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
return {
geometry: geometry,
segmentCount: positions.length / 6
};
}
function createEarthTexture(THREE) {
var canvas = document.createElement('canvas');
var size = 1024;
var ctx;
var gradient;
var i;
canvas.width = size;
canvas.height = size / 2;
ctx = canvas.getContext('2d');
ctx.fillStyle = '#08111a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
gradient = ctx.createRadialGradient(size * 0.42, size * 0.20, 10, size * 0.45, size * 0.28, size * 0.50);
gradient.addColorStop(0, 'rgba(100,135,160,0.38)');
gradient.addColorStop(0.28, 'rgba(32,57,74,0.28)');
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255,255,255,0.030)';
ctx.lineWidth = 1;
for (i = 0; i <= 12; i += 1) {
var y = Math.round(i * canvas.height / 12) + 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
for (i = 0; i <= 24; i += 1) {
var x = Math.round(i * canvas.width / 24) + 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (i = 0; i < 220; i += 1) {
var px = Math.random() * canvas.width;
var py = Math.random() * canvas.height;
var r = 1 + Math.random() * 2.8;
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(80,105,93,0.18)' : 'rgba(95,83,70,0.16)';
ctx.beginPath();
ctx.ellipse(px, py, r * 6, r * 1.7, Math.random() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
var texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function createStars(THREE, count) {
var geometry = new THREE.BufferGeometry();
var positions = [];
var i;
for (i = 0; i < count; i += 1) {
var r = 8 + Math.random() * 16;
var theta = Math.random() * Math.PI * 2;
var phi = Math.acos((Math.random() * 2) - 1);
positions.push(
r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta)
);
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
return new THREE.Points(
geometry,
new THREE.PointsMaterial({
color: 0xd8d8d8,
size: 0.018,
transparent: true,
opacity: 0.62,
depthWrite: false
})
);
}
function NationsGlobe(root) {
this.root = root;
this.stage = null;
this.loading = null;
this.error = null;
this.status = null;
this.readout = null;
this.autoButton = null;
this.resetButton = null;
this.THREE = null;
this.scene = null;
this.camera = null;
this.renderer = null;
this.group = null;
this.earth = null;
this.raycaster = null;
this.pointer = null;
this.radius = 1;
this.rotationX = toNumber(root.getAttribute('data-rotation-x'), -0.18);
this.rotationY = toNumber(root.getAttribute('data-rotation-y'), -0.48);
this.cameraZ = toNumber(root.getAttribute('data-camera-z'), 3.05);
this.autoRotate = root.getAttribute('data-auto-rotate') !== 'false';
this.dragging = false;
this.lastX = 0;
this.lastY = 0;
this.frame = null;
this.disposed = false;
this.segmentCount = 0;
this.lastTime = performance.now();
}
NationsGlobe.prototype.ensureDom = function () {
var root = this.root;
var title = root.getAttribute('data-title') || 'TERRITORY GLOBE';
var era = root.getAttribute('data-era') || 'REAL WORLD / 110M';
root.classList.add('clbi-nations-globe-window');
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-topbar">' +
'<span class="clbi-nations-globe-title">' + escapeHtml(title) + '</span>' +
'<span class="clbi-nations-globe-status">SYNC</span>' +
'</div>' +
'<div class="clbi-nations-globe-bottombar">' +
'<span class="clbi-nations-globe-readout">' + escapeHtml(era) + '</span>' +
'<span class="clbi-nations-globe-controls">' +
'<button type="button" class="clbi-nations-globe-btn clbi-nations-globe-auto">AUTO</button>' +
'<button type="button" class="clbi-nations-globe-btn clbi-nations-globe-reset">RESET</button>' +
'</span>' +
'</div>' +
'<div class="clbi-nations-globe-loading">LOADING REAL-WORLD TERRITORY DATA</div>' +
'<div class="clbi-nations-globe-error"></div>';
}
this.stage = root.querySelector('.clbi-nations-globe-stage');
this.loading = root.querySelector('.clbi-nations-globe-loading');
this.error = root.querySelector('.clbi-nations-globe-error');
this.status = root.querySelector('.clbi-nations-globe-status');
this.readout = root.querySelector('.clbi-nations-globe-readout');
this.autoButton = root.querySelector('.clbi-nations-globe-auto');
this.resetButton = root.querySelector('.clbi-nations-globe-reset');
if (this.autoButton) {
this.autoButton.classList.toggle('is-active', this.autoRotate);
}
};
NationsGlobe.prototype.setStatus = function (text) {
if (this.status) this.status.textContent = text || '';
};
NationsGlobe.prototype.setReadout = function (text) {
if (this.readout) this.readout.textContent = text || '';
};
NationsGlobe.prototype.setError = function (err) {
var message = err && err.message ? err.message : String(err || 'unknown error');
this.root.classList.add('has-error');
this.root.classList.remove('is-ready');
if (this.error) this.error.textContent = 'GLOBE ERROR: ' + message;
this.setStatus('ERROR');
};
NationsGlobe.prototype.init = function () {
var self = this;
this.ensureDom();
this.setStatus('LIBRARY');
return Promise.all([loadThree(), loadTopojson()])
.then(function (libs) {
self.THREE = libs[0];
self.topojson = libs[1];
self.createScene();
self.bindEvents();
return self.loadBoundaryData();
})
.then(function () {
self.root.classList.add('is-ready');
self.setStatus('READY');
self.animate(performance.now());
})
.catch(function (err) {
self.setError(err);
});
};
NationsGlobe.prototype.createScene = function () {
var THREE = this.THREE;
var rect = this.stage.getBoundingClientRect();
var width = Math.max(320, Math.floor(rect.width || this.root.clientWidth || 960));
var height = Math.max(240, Math.floor(rect.height || this.root.clientHeight || 360));
var texture = createEarthTexture(THREE);
var atmosphere;
var light;
var ambient;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 80);
this.camera.position.set(0, 0, this.cameraZ);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.7));
this.renderer.setSize(width, height, false);
this.renderer.setClearColor(0x000000, 0);
this.stage.appendChild(this.renderer.domElement);
this.group = new THREE.Group();
this.scene.add(this.group);
this.earth = new THREE.Mesh(
new THREE.SphereGeometry(this.radius, 96, 64),
new THREE.MeshPhongMaterial({
map: texture,
color: 0x9fb3bb,
emissive: 0x05080a,
shininess: 10,
transparent: false
})
);
this.group.add(this.earth);
atmosphere = new THREE.Mesh(
new THREE.SphereGeometry(this.radius * 1.035, 96, 64),
new THREE.MeshBasicMaterial({
color: 0x5f88a8,
transparent: true,
opacity: 0.115,
side: THREE.BackSide,
depthWrite: false
})
);
this.group.add(atmosphere);
light = new THREE.DirectionalLight(0xffffff, 2.2);
light.position.set(3.2, 2.4, 4.8);
this.scene.add(light);
ambient = new THREE.AmbientLight(0x607080, 0.66);
this.scene.add(ambient);
this.scene.add(createStars(THREE, 780));
this.raycaster = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
this.applyRotation();
};
NationsGlobe.prototype.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.bindEvents = function () {
var self = this;
this.stage.addEventListener('pointerdown', function (e) {
self.dragging = true;
self.lastX = e.clientX;
self.lastY = e.clientY;
self.root.classList.add('is-dragging');
self.autoRotate = false;
if (self.autoButton) self.autoButton.classList.remove('is-active');
if (self.stage.setPointerCapture && e.pointerId != null) {
try { self.stage.setPointerCapture(e.pointerId); } catch (err) {}
}
e.preventDefault();
});
this.stage.addEventListener('pointermove', function (e) {
if (self.dragging) {
var dx = e.clientX - self.lastX;
var dy = e.clientY - self.lastY;
self.rotationY += dx * 0.006;
self.rotationX += dy * 0.004;
self.lastX = e.clientX;
self.lastY = e.clientY;
self.applyRotation();
e.preventDefault();
} else {
self.updatePointerReadout(e);
}
});
function finishDrag(e) {
if (!self.dragging) return;
self.dragging = false;
self.root.classList.remove('is-dragging');
if (self.stage.releasePointerCapture && e && e.pointerId != null) {
try { self.stage.releasePointerCapture(e.pointerId); } catch (err) {}
}
}
this.stage.addEventListener('pointerup', finishDrag);
this.stage.addEventListener('pointercancel', finishDrag);
this.stage.addEventListener('wheel', function (e) {
self.cameraZ += e.deltaY * 0.0017;
self.cameraZ = Math.max(1.85, Math.min(5.2, self.cameraZ));
if (self.camera) self.camera.position.z = self.cameraZ;
e.preventDefault();
}, { passive: false });
if (this.autoButton) {
this.autoButton.addEventListener('click', function (e) {
self.autoRotate = !self.autoRotate;
self.autoButton.classList.toggle('is-active', self.autoRotate);
e.preventDefault();
e.stopPropagation();
});
}
if (this.resetButton) {
this.resetButton.addEventListener('click', function (e) {
self.rotationX = -0.18;
self.rotationY = -0.48;
self.cameraZ = 3.05;
if (self.camera) self.camera.position.z = self.cameraZ;
self.applyRotation();
e.preventDefault();
e.stopPropagation();
});
}
window.addEventListener('resize', function () {
self.resize();
});
};
NationsGlobe.prototype.updatePointerReadout = function (e) {
var THREE = this.THREE;
var rect;
var intersects;
var local;
var ll;
if (!this.raycaster || !this.pointer || !this.camera || !this.earth) return;
rect = this.renderer.domElement.getBoundingClientRect();
this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.pointer, this.camera);
intersects = this.raycaster.intersectObject(this.earth, false);
if (intersects && intersects.length) {
local = this.earth.worldToLocal(intersects[0].point.clone());
local.applyEuler(new THREE.Euler(-this.group.rotation.x, -this.group.rotation.y, 0, 'XYZ'));
ll = vectorToLonLat(local);
this.setReadout('LAT ' + ll.lat.toFixed(2) + ' / LON ' + ll.lon.toFixed(2) + ' · SEG ' + this.segmentCount);
}
};
NationsGlobe.prototype.loadBoundaryData = function () {
var self = this;
var THREE = this.THREE;
var url = this.root.getAttribute('data-topology-url') || this.root.getAttribute('data-geojson-url') || DEFAULT_WORLD_URL;
var objectName = this.root.getAttribute('data-topology-object') || 'countries';
this.setStatus('DATA');
return fetch(url, { credentials: 'omit' })
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url);
return res.json();
})
.then(function (payload) {
var geojson = extractGeojsonFromPayload(payload, self.topojson, objectName);
var built = buildLineGeometryFromGeojson(THREE, geojson, self.radius * 1.009);
var material = new THREE.LineBasicMaterial({
color: 0xb8b8b8,
transparent: true,
opacity: 0.58,
depthWrite: false
});
var lines = new THREE.LineSegments(built.geometry, material);
self.segmentCount = Math.floor(built.segmentCount || 0);
self.group.add(lines);
self.setReadout((self.root.getAttribute('data-era') || 'REAL WORLD / 110M') + ' · SEG ' + self.segmentCount);
});
};
NationsGlobe.prototype.animate = function (now) {
var self = this;
var dt = Math.min(50, now - this.lastTime);
this.lastTime = now;
if (this.disposed || !document.body.contains(this.root)) {
this.dispose();
return;
}
if (this.autoRotate && !this.dragging) {
this.rotationY += dt * 0.000085;
this.applyRotation();
}
this.resize();
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
this.frame = window.requestAnimationFrame(function (t) {
self.animate(t);
});
};
NationsGlobe.prototype.dispose = function () {
this.disposed = true;
if (this.frame) window.cancelAnimationFrame(this.frame);
if (this.renderer) {
try { this.renderer.dispose(); } catch (err) {}
}
};
function initNationsGlobes(root) {
var scope = root && root.querySelectorAll ? root : document;
var nodes = scope.querySelectorAll('[data-nations-globe], .clbi-nations-globe-window');
Array.prototype.forEach.call(nodes, function (node) {
if (node.getAttribute('data-nations-globe-ready') === '1') return;
node.setAttribute('data-nations-globe-ready', '1');
var instance = new NationsGlobe(node);
instances.push(instance);
instance.init();
});
}
window.CLBI_NationsGlobe = {
init: initNationsGlobes,
instances: instances,
defaultWorldUrl: DEFAULT_WORLD_URL,
reload: function () {
initNationsGlobes(document);
}
};
$(function () {
initNationsGlobes(document);
});
if (mw && mw.hook) {
mw.hook('wikipage.content').add(function ($content) {
initNationsGlobes($content && $content[0] ? $content[0] : document);
});
}
})(window.mediaWiki || window.mw, window.jQuery);