246 lines
9.0 KiB
JavaScript
246 lines
9.0 KiB
JavaScript
/*
|
|
* DigestGlobe — a rotating "holo globe" for the political digest section.
|
|
*
|
|
* SVG orthographic projection, not CSS 3D transforms. CSS 3D can fake a sphere
|
|
* (stacked rotated rings, a textured div under perspective), but there is then no
|
|
* honest way to answer "where on screen is 34.05N, 118.24W" — you would be
|
|
* eyeballing marker placement against a fake, and it drifts as soon as anything
|
|
* about the container changes. Orthographic projection is four lines of
|
|
* trigonometry, gives exact marker positions and exact back-of-globe visibility,
|
|
* needs no 3D library, and the same maths projects both the markers and the
|
|
* coastline outline so they can never disagree.
|
|
*
|
|
* No external dependencies, no CDN, no network access of any kind — the world
|
|
* outline below is inlined as coarse lat/lon polylines. It is deliberately
|
|
* low-poly: recognisable as Earth, not a cartographic reference.
|
|
*/
|
|
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
var RADIUS = 95; // in viewBox units; viewBox is -100..100 on both axes
|
|
var DEG = Math.PI / 180;
|
|
|
|
// [lon, lat] rings, coarse. Antarctica is omitted deliberately: at this vertex
|
|
// count it reads as noise around the south limb rather than as a continent.
|
|
var WORLD = [
|
|
// Africa
|
|
[[-17, 14], [-16, 20], [-5, 30], [10, 37], [20, 32], [32, 31], [43, 12], [51, 12],
|
|
[41, -2], [40, -16], [35, -24], [20, -35], [18, -33], [12, -17], [9, 4], [-8, 4],
|
|
[-13, 9], [-17, 14]],
|
|
// Eurasia
|
|
[[-10, 36], [-2, 43], [3, 43], [9, 44], [16, 41], [23, 38], [28, 41], [35, 36],
|
|
[36, 31], [43, 30], [48, 30], [57, 25], [67, 24], [72, 19], [77, 8], [80, 13],
|
|
[87, 21], [92, 22], [97, 17], [100, 13], [104, 10], [109, 15], [117, 23],
|
|
[122, 31], [122, 40], [127, 39], [129, 43], [135, 48], [140, 53], [143, 59],
|
|
[160, 61], [170, 66], [180, 68], [160, 70], [140, 73], [110, 74], [80, 73],
|
|
[60, 71], [40, 68], [30, 70], [20, 70], [10, 64], [5, 58], [0, 51], [-5, 48],
|
|
[-10, 43], [-9, 39], [-6, 37], [-10, 36]],
|
|
// North America
|
|
[[-168, 66], [-160, 58], [-150, 60], [-135, 58], [-125, 49], [-122, 37], [-117, 32],
|
|
[-105, 23], [-97, 16], [-94, 18], [-90, 21], [-84, 22], [-81, 25], [-81, 31],
|
|
[-76, 35], [-70, 42], [-66, 45], [-60, 47], [-56, 52], [-64, 60], [-78, 62],
|
|
[-85, 70], [-95, 70], [-110, 69], [-125, 70], [-140, 70], [-155, 71], [-168, 66]],
|
|
// South America
|
|
[[-81, -5], [-79, 0], [-76, 8], [-71, 12], [-62, 10], [-52, 5], [-50, 0], [-44, -2],
|
|
[-38, -5], [-35, -8], [-39, -16], [-48, -25], [-54, -34], [-58, -38], [-62, -40],
|
|
[-65, -45], [-68, -52], [-75, -52], [-73, -45], [-73, -37], [-71, -30], [-70, -20],
|
|
[-76, -14], [-81, -5]],
|
|
// Australia
|
|
[[113, -22], [114, -27], [118, -34], [126, -32], [133, -32], [138, -35], [145, -38],
|
|
[150, -37], [153, -28], [146, -19], [142, -11], [136, -12], [130, -11], [125, -14],
|
|
[118, -20], [113, -22]],
|
|
// Greenland
|
|
[[-45, 60], [-52, 65], [-55, 70], [-60, 76], [-50, 82], [-30, 83], [-22, 74],
|
|
[-30, 68], [-42, 61], [-45, 60]]
|
|
];
|
|
|
|
var ICONS = {
|
|
'hammer-sickle': '☭',
|
|
star: '★',
|
|
default: '●'
|
|
};
|
|
|
|
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
|
function svgEl(name, attrs) {
|
|
var el = document.createElementNS(SVG_NS, name);
|
|
Object.keys(attrs || {}).forEach(function (key) {
|
|
el.setAttribute(key, attrs[key]);
|
|
});
|
|
return el;
|
|
}
|
|
|
|
// Orthographic projection about a rotating central meridian.
|
|
// Returns {x, y} in viewBox units and z, the cosine of angular distance from
|
|
// the projection centre — z <= 0 means the point is on the far side of the globe.
|
|
function project(lat, lon, lambda0) {
|
|
var phi = lat * DEG;
|
|
var lam = (lon - lambda0) * DEG;
|
|
return {
|
|
x: RADIUS * Math.cos(phi) * Math.sin(lam),
|
|
y: -RADIUS * Math.sin(phi),
|
|
z: Math.cos(phi) * Math.cos(lam)
|
|
};
|
|
}
|
|
|
|
function DigestGlobe(container, options) {
|
|
options = options || {};
|
|
|
|
this.container = container;
|
|
this.lambda0 = typeof options.longitude === 'number' ? options.longitude : 0;
|
|
this.speed = typeof options.speed === 'number' ? options.speed : 4; // degrees/second
|
|
this.markers = [];
|
|
|
|
container.classList.add('digest-globe');
|
|
|
|
this.svg = svgEl('svg', {
|
|
viewBox: '-100 -100 200 200',
|
|
class: 'digest-globe-svg',
|
|
'aria-hidden': 'true'
|
|
});
|
|
this.svg.appendChild(svgEl('circle', { cx: 0, cy: 0, r: RADIUS, class: 'digest-globe-ocean' }));
|
|
|
|
this.graticule = svgEl('g', { class: 'digest-globe-graticule' });
|
|
this.land = svgEl('g', { class: 'digest-globe-land' });
|
|
this.svg.appendChild(this.graticule);
|
|
this.svg.appendChild(this.land);
|
|
this.svg.appendChild(svgEl('circle', { cx: 0, cy: 0, r: RADIUS, class: 'digest-globe-limb' }));
|
|
container.appendChild(this.svg);
|
|
|
|
this.markerLayer = document.createElement('div');
|
|
this.markerLayer.className = 'digest-globe-markers';
|
|
container.appendChild(this.markerLayer);
|
|
|
|
this.landPaths = WORLD.map(function () {
|
|
var path = svgEl('path', {});
|
|
this.land.appendChild(path);
|
|
return path;
|
|
}, this);
|
|
|
|
this.graticulePaths = [];
|
|
for (var lon = -180; lon < 180; lon += 30) {
|
|
this.graticulePaths.push({ path: this.graticule.appendChild(svgEl('path', {})), meridian: lon });
|
|
}
|
|
for (var lat = -60; lat <= 60; lat += 30) {
|
|
this.graticulePaths.push({ path: this.graticule.appendChild(svgEl('path', {})), parallel: lat });
|
|
}
|
|
|
|
this.draw();
|
|
this.start();
|
|
}
|
|
|
|
// Builds an SVG path from a lat/lon ring, breaking the path wherever the ring
|
|
// crosses the limb so the far side of the globe isn't drawn through the near side.
|
|
DigestGlobe.prototype._ringPath = function (ring) {
|
|
var parts = [];
|
|
var pen = false;
|
|
for (var i = 0; i < ring.length; i++) {
|
|
var point = project(ring[i][1], ring[i][0], this.lambda0);
|
|
if (point.z <= 0) {
|
|
pen = false;
|
|
continue;
|
|
}
|
|
parts.push((pen ? 'L' : 'M') + point.x.toFixed(2) + ' ' + point.y.toFixed(2));
|
|
pen = true;
|
|
}
|
|
return parts.join(' ');
|
|
};
|
|
|
|
DigestGlobe.prototype.draw = function () {
|
|
this.landPaths.forEach(function (path, index) {
|
|
path.setAttribute('d', this._ringPath(WORLD[index]));
|
|
}, this);
|
|
|
|
this.graticulePaths.forEach(function (entry) {
|
|
var ring = [];
|
|
var step;
|
|
if (entry.meridian !== undefined) {
|
|
for (step = -90; step <= 90; step += 5) { ring.push([entry.meridian, step]); }
|
|
} else {
|
|
for (step = -180; step <= 180; step += 5) { ring.push([step, entry.parallel]); }
|
|
}
|
|
entry.path.setAttribute('d', this._ringPath(ring));
|
|
}, this);
|
|
|
|
this.markers.forEach(function (marker) {
|
|
var point = project(marker.lat, marker.lon, this.lambda0);
|
|
if (point.z <= 0) {
|
|
marker.el.style.opacity = '0';
|
|
marker.el.style.pointerEvents = 'none';
|
|
return;
|
|
}
|
|
marker.el.style.opacity = String(Math.min(1, 0.25 + point.z * 1.4));
|
|
marker.el.style.pointerEvents = 'auto';
|
|
marker.el.style.left = (50 + point.x / 2) + '%';
|
|
marker.el.style.top = (50 + point.y / 2) + '%';
|
|
}, this);
|
|
};
|
|
|
|
DigestGlobe.prototype.addMarker = function (lat, lon, options) {
|
|
options = options || {};
|
|
|
|
var el = document.createElement('span');
|
|
el.className = 'digest-globe-marker';
|
|
|
|
var icon = document.createElement('span');
|
|
var iconName = ICONS[options.icon] ? options.icon : 'default';
|
|
icon.className = 'digest-marker-icon digest-icon-' + iconName;
|
|
icon.textContent = ICONS[iconName];
|
|
if (options.color) {
|
|
icon.style.color = options.color;
|
|
icon.style.setProperty('--digest-glow-color', options.color);
|
|
}
|
|
// The glow class goes on the icon, not the wrapper: text-shadow is inherited
|
|
// as an already-resolved value, so a var() set on a child cannot re-colour a
|
|
// shadow declared on its parent.
|
|
if (options.glow) {
|
|
icon.classList.add('digest-glow');
|
|
}
|
|
el.appendChild(icon);
|
|
|
|
if (options.label) {
|
|
var label = document.createElement('span');
|
|
label.className = 'digest-marker-label';
|
|
label.textContent = options.label;
|
|
el.appendChild(label);
|
|
el.title = options.label;
|
|
}
|
|
|
|
this.markerLayer.appendChild(el);
|
|
|
|
var marker = { lat: Number(lat) || 0, lon: Number(lon) || 0, el: el };
|
|
this.markers.push(marker);
|
|
this.draw();
|
|
return marker;
|
|
};
|
|
|
|
DigestGlobe.prototype.start = function () {
|
|
if (this.frame) { return; }
|
|
if (global.matchMedia && global.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
return;
|
|
}
|
|
|
|
var self = this;
|
|
var last = null;
|
|
var tick = function (now) {
|
|
if (last !== null) {
|
|
self.lambda0 = (self.lambda0 + self.speed * (now - last) / 1000 + 180) % 360 - 180;
|
|
self.draw();
|
|
}
|
|
last = now;
|
|
self.frame = global.requestAnimationFrame(tick);
|
|
};
|
|
this.frame = global.requestAnimationFrame(tick);
|
|
};
|
|
|
|
DigestGlobe.prototype.stop = function () {
|
|
if (this.frame) {
|
|
global.cancelAnimationFrame(this.frame);
|
|
this.frame = null;
|
|
}
|
|
};
|
|
|
|
global.DigestGlobe = DigestGlobe;
|
|
})(window);
|