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