SmartestHome/digest-engine/render/digest-canvas-sdk/globe.js

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