diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9e100c..7f08b4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.6.2
+
+* Relaxed browser requirements, with WebGL capability no long required.
+
## 1.6.1
* Added ability to open info display from collapsed control panel.
diff --git a/package-lock.json b/package-lock.json
index 61777c5..63f9788 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "prague-clock",
- "version": "1.6.1",
+ "version": "1.6.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "prague-clock",
- "version": "1.6.1",
+ "version": "1.6.2",
"dependencies": {
"@angular/animations": "~13.2.0",
"@angular/cdk": "^13.2.6",
diff --git a/package.json b/package.json
index f8a6602..bcfcb4c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "prague-clock",
- "version": "1.6.1",
+ "version": "1.6.2",
"scripts": {
"ng": "ng",
"start": "ng serve --configuration=development",
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index 41e1924..1ee790d 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -358,6 +358,10 @@ svg {
top: 36.333%;
transform: scale(-1, -1);
width: 27.333%;
+
+ &.no-web-gl {
+ transform: unset;
+ }
}
@media
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 5940724..d9d8775 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -2,7 +2,7 @@ import { Component, OnInit, ViewChild } from '@angular/core';
import { ConfirmationService, MenuItem, MessageService, PrimeNGConfig } from 'primeng/api';
import { abs, floor, max, min, mod, mod2 } from '@tubular/math';
import {
- clone, extendDelimited, forEach, getCssValue, isAndroid, isEqual, isIOS, isLikelyMobile, isMacOS, isObject, isSafari,
+ clone, extendDelimited, forEach, getCssValue, isAndroid, isEqual, isIOS, isLikelyMobile, isMacOS, isObject, isSafari, noop,
processMillis
} from '@tubular/util';
import { AngleStyle, DateTimeStyle, TimeEditorOptions } from '@tubular/ng-widgets';
@@ -865,7 +865,7 @@ export class AppComponent implements OnInit, SettingsHolder, SvgHost {
}
private updateGlobe(): void {
- this.globe.orient(this._longitude, this.latitude).finally();
+ this.globe.orient(this._longitude, this.latitude).catch(noop);
}
updateTime(forceUpdate = false): void {
diff --git a/src/assets/incompatible.html b/src/assets/incompatible.html
index 14be334..366cd72 100644
--- a/src/assets/incompatible.html
+++ b/src/assets/incompatible.html
@@ -29,6 +29,13 @@
+
Your web browser does not have the capabilities needed to run the simulator.
Váš webový prohlížeč nemá schopnosti potřebné ke spuštění simulátoru.
diff --git a/src/globe/globe.ts b/src/globe/globe.ts
index 14be186..04ecb8d 100644
--- a/src/globe/globe.ts
+++ b/src/globe/globe.ts
@@ -1,6 +1,6 @@
import { BufferGeometry, CanvasTexture, CylinderGeometry, DoubleSide, Mesh, MeshBasicMaterial, PerspectiveCamera, Scene, SphereGeometry, WebGLRenderer } from 'three';
-import { isString } from '@tubular/util';
-import { cos, PI, sin, to_radian } from '@tubular/math';
+import { getPixel, isString, noop, processMillis, strokeLine } from '@tubular/util';
+import { cos, max, mod, PI, round, sin, SphericalPosition3D, sqrt, tan_deg, to_radian } from '@tubular/math';
import { mergeBufferGeometries } from '../three/three-utils';
import { Appearance } from '../advanced-options/advanced-options.component';
@@ -19,6 +19,13 @@ const HAG_2018 = 0.05;
const GRID_COLOR = '#262F36';
+let hasWebGl = !/\bwebgl=[0fn]/i.test(location.search);
+
+try {
+ hasWebGl = hasWebGl && !!document.createElement('canvas').getContext('webgl2');
+}
+catch {}
+
export class Globe {
private static mapCanvas: HTMLCanvasElement;
private static mapCanvas2018: HTMLCanvasElement;
@@ -30,20 +37,26 @@ export class Globe {
private appearance = Appearance.CURRENT;
private camera: PerspectiveCamera;
+ private currentPixelSize = DEFAULT_GLOBE_PIXEL_SIZE;
+ private drawingTimer: any;
private globeMesh: Mesh;
private initialized = false;
+ private lastGlobeResolve: () => void;
private lastLatitude: number;
private lastLongitude: number;
private lastPixelSize = DEFAULT_GLOBE_PIXEL_SIZE;
private lastRenderer: HTMLElement;
+ private static mapPixels: ImageData[] = [];
+ private offscreen = document.createElement('canvas');
private renderer: WebGLRenderer;
private rendererHost: HTMLElement;
+ private renderIndex2d = 0;
private scene: Scene;
static loadMap(): void {
this.mapLoading = true;
- let map = 0;
+ let mapIndex = 0;
const loadOneMap = (): void => {
const imagePromise = new Promise
((resolve, reject) => {
@@ -68,26 +81,41 @@ export class Globe {
reject(new Error('Map image failed to load from: ' + image.src));
};
- image.src = map ? 'assets/world-p2018.jpg' : 'assets/world.jpg';
+ image.src = mapIndex ? 'assets/world-p2018.jpg' : 'assets/world.jpg';
});
imagePromise.then(image => {
const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
canvas.width = MAP_WIDTH;
canvas.height = MAP_HEIGHT;
- canvas.getContext('2d').drawImage(image, 0, 0, MAP_WIDTH, MAP_HEIGHT);
+ context.drawImage(image, 0, 0, MAP_WIDTH, MAP_HEIGHT);
+ context.strokeStyle = [GRID_COLOR, this.getGoldTrimColor()][mapIndex];
+ context.lineWidth = [1.5, 3][mapIndex];
+
+ this.drawGlobeGrid(context);
- if (map) {
+ if (mapIndex) {
this.mapImage2018 = image;
this.mapCanvas2018 = canvas;
+ this.mapPixels[Appearance.CURRENT] = context.getImageData(0, 0, MAP_WIDTH, MAP_HEIGHT);
+ // Make mapless grid for Appearance.CURRENT_NO_MAP
+ context.fillStyle = this.getSkyColorColor2018();
+ context.fillRect(0, 0, MAP_WIDTH, MAP_HEIGHT);
+ this.drawGlobeGrid(context);
+ this.mapPixels[Appearance.CURRENT_NO_MAP] = context.getImageData(0, 0, MAP_WIDTH, MAP_HEIGHT);
+ // Restore map pixels to canvas
+ context.putImageData(this.mapPixels[Appearance.CURRENT], 0, 0);
+
this.mapLoading = false;
this.waitList.forEach(cb => cb.resolve());
}
else {
this.mapImage = image;
this.mapCanvas = canvas;
- ++map;
+ this.mapPixels[Appearance.PRE_2018] = context.getImageData(0, 0, MAP_WIDTH, MAP_HEIGHT);
+ ++mapIndex;
loadOneMap();
}
}, reason => {
@@ -109,12 +137,31 @@ export class Globe {
return getComputedStyle(document.documentElement).getPropertyValue('--sky-color-2018').trim() || '#2F9DE7';
}
+ private static drawGlobeGrid(context: CanvasRenderingContext2D): void {
+ // Draw lines of latitude
+ for (let lat = -75; lat < 90; lat += 15) {
+ const y = (lat + 90) / 180 * MAP_HEIGHT + 2;
+
+ strokeLine(context, 0, y - 1, MAP_WIDTH, y - 1);
+ }
+
+ // Draw lines of longitude
+ for (let lon = 0; lon <= 360; lon += 15) {
+ const x = lon / 360 * MAP_WIDTH + 2;
+
+ strokeLine(context, x - 1, MAP_HEIGHT / 12, x - 1, MAP_HEIGHT * 11 / 12);
+ }
+ }
+
constructor(rendererHost: string | HTMLElement) {
if (isString(rendererHost))
this.rendererHost = document.getElementById(rendererHost);
else
this.rendererHost = rendererHost;
+ if (!hasWebGl)
+ this.rendererHost.classList.add('no-web-gl');
+
if (!Globe.mapImage && !Globe.mapFailed && !Globe.mapLoading)
Globe.loadMap();
}
@@ -125,14 +172,24 @@ export class Globe {
else if (!Globe.mapImage2018)
await new Promise((resolve, reject) => Globe.waitList.push({ resolve, reject }));
+ this.currentPixelSize = (this.rendererHost.getBoundingClientRect().width * 2) || DEFAULT_GLOBE_PIXEL_SIZE;
+
+ if (hasWebGl)
+ this.renderWebGl(lon, lat);
+ else
+ await this.render2D(lon, lat);
+
+ this.lastPixelSize = this.currentPixelSize;
+ this.lastLatitude = lat;
+ this.lastLongitude = lon;
+ }
+
+ private renderWebGl(lon: number, lat: number): void {
if (!this.initialized)
this.setUpRenderer();
- const currentPixelSize = (this.renderer.domElement.getBoundingClientRect().width * 2) || DEFAULT_GLOBE_PIXEL_SIZE;
-
- if (!this.initialized || this.lastPixelSize !== currentPixelSize) {
- this.renderer.setSize(currentPixelSize, currentPixelSize);
- this.lastPixelSize = currentPixelSize;
+ if (!this.initialized || this.lastPixelSize !== this.currentPixelSize) {
+ this.renderer.setSize(this.currentPixelSize, this.currentPixelSize);
this.initialized = true;
}
@@ -140,12 +197,74 @@ export class Globe {
this.globeMesh.rotation.x = to_radian(lat);
this.camera.rotation.z = (lat >= 0 || this.appearance === Appearance.CURRENT ||
this.appearance === Appearance.CURRENT_NO_MAP ? PI : 0);
- this.lastLatitude = lat;
- this.lastLongitude = lon;
requestAnimationFrame(() => this.renderer.render(this.scene, this.camera));
}
+ private async render2D(lon: number, lat: number): Promise {
+ if (this.lastGlobeResolve) {
+ ++this.renderIndex2d;
+ this.lastGlobeResolve();
+ this.lastGlobeResolve = undefined;
+ }
+
+ if (!this.drawingTimer)
+ this.drawingTimer = setTimeout(() => this.rendererHost.style.opacity = '0.25', 1000);
+
+ let target = this.rendererHost.querySelector('canvas') as HTMLCanvasElement;
+ let doDraw = true;
+
+ if (!target) {
+ target = document.createElement('canvas');
+ this.rendererHost.appendChild(target);
+ }
+
+ if (!this.initialized || this.lastPixelSize !== this.currentPixelSize) {
+ target.width = this.offscreen.width = this.currentPixelSize;
+ target.height = this.offscreen.height = this.currentPixelSize;
+ }
+
+ if (this.appearance === Appearance.ORIGINAL_1410) {
+ target.getContext('2d').clearRect(0, 0, this.currentPixelSize, this.currentPixelSize);
+ return;
+ }
+
+ if (!this.initialized || this.lastLatitude !== lat || this.lastLongitude !== lon) {
+ doDraw = false;
+ const generator = this.generateRotatedGlobe(lon, lat);
+
+ await new Promise(resolve => {
+ this.lastGlobeResolve = resolve;
+
+ const renderSome = (): void => {
+ const next = generator.next();
+
+ if (next.done) {
+ doDraw = next.value;
+ this.lastGlobeResolve = undefined;
+ resolve();
+ }
+ else
+ setTimeout(renderSome);
+ };
+
+ renderSome();
+ });
+ }
+
+ this.initialized = true;
+
+ if (doDraw) {
+ target.getContext('2d').drawImage(this.offscreen, 0, 0, target.width, target.height);
+ this.rendererHost.style.opacity = '1';
+
+ if (this.drawingTimer) {
+ clearTimeout(this.drawingTimer);
+ this.drawingTimer = undefined;
+ }
+ }
+ }
+
setAppearance(appearance: Appearance): void {
if (this.appearance !== appearance) {
this.appearance = appearance;
@@ -157,9 +276,14 @@ export class Globe {
if (!this.initialized)
return;
- this.setUpRenderer();
- this.renderer.setSize(this.lastPixelSize, this.lastPixelSize);
- this.orient(this.lastLongitude, this.lastLatitude).finally();
+ if (hasWebGl) {
+ this.setUpRenderer();
+ this.renderer.setSize(this.lastPixelSize, this.lastPixelSize);
+ }
+ else
+ this.initialized = false;
+
+ this.orient(this.lastLongitude, this.lastLatitude).catch(noop);
}
private setUpRenderer(): void {
@@ -221,4 +345,98 @@ export class Globe {
this.rendererHost.appendChild(this.renderer.domElement);
this.lastRenderer = this.renderer.domElement;
}
+
+ * generateRotatedGlobe(lon: number, lat: number): Generator {
+ const post2018 = (this.appearance === Appearance.CURRENT || this.appearance === Appearance.CURRENT_NO_MAP);
+ const cameraZ = post2018 ? VIEW_DISTANCE_2018 : VIEW_DISTANCE;
+ const fieldOfView = post2018 ? FIELD_OF_VIEW_2018 : FIELD_OF_VIEW;
+ const viewRadius = (cameraZ + GLOBE_RADIUS) * tan_deg(fieldOfView / 2);
+ const context = this.offscreen.getContext('2d');
+ const size = this.currentPixelSize;
+ let time = processMillis();
+
+ context.clearRect(0, 0, size, size);
+
+ const rt = size / 2;
+ const eye = new SphericalPosition3D(0, 0, cameraZ).xyz;
+ const signX = (cameraZ > GLOBE_RADIUS ? 1 : -1);
+ const yaw = to_radian(lon + (post2018 ? 0 : 180));
+ const pitch = to_radian(lat * (post2018 ? -1 : 1));
+ const roll = ((post2018 ? -1 : 1) * lat >= 0 ? PI : 0);
+
+ const cose = Math.cos(yaw);
+ const sina = Math.sin(yaw);
+ const cosb = Math.cos(pitch);
+ const sinb = Math.sin(pitch);
+ const cosc = Math.cos(roll);
+ const sinc = Math.sin(roll);
+
+ const Axx = cose * cosb;
+ const Axy = cose * sinb * sinc - sina * cosc;
+ const Axz = cose * sinb * cosc + sina * sinc;
+ const Ayx = sina * cosb;
+ const Ayy = sina * sinb * sinc + cose * cosc;
+ const Ayz = sina * sinb * cosc - cose * sinc;
+ const Azx = -sinb;
+ const Azy = cosb * sinc;
+ const Azz = cosb * cosc;
+
+ const pixels = Globe.mapPixels[this.appearance] ?? Globe.mapPixels[Appearance.CURRENT];
+ const renderIndex = this.renderIndex2d;
+
+ for (let yt = 0; yt < size; ++yt) {
+ if (processMillis() > time + 100) {
+ yield;
+
+ if (renderIndex !== this.renderIndex2d)
+ return false;
+
+ time = processMillis();
+ }
+
+ for (let xt = 0; xt < size; ++xt) {
+ const d = sqrt((xt - rt) ** 2 + (yt - rt) ** 2);
+ let alpha = 1;
+
+ if (d > rt + 0.5)
+ continue;
+ else if (d > rt - 0.5)
+ alpha = rt - d + 0.5;
+
+ const x0 = -GLOBE_RADIUS;
+ const y0 = ((xt - rt) * signX) / size * viewRadius * 2;
+ const z0 = (rt - yt) / size * viewRadius * 2;
+ const dx = eye.x - x0;
+ const dy = eye.y - y0;
+ const dz = eye.z - z0;
+ // Unit vector for line-of-sight
+ const mag = sqrt(dx ** 2 + dy ** 2 + dz ** 2);
+ const xu = dx / mag;
+ const yu = dy / mag;
+ const zu = dz / mag;
+ // Dot product of unit vector and origin
+ const dp = xu * eye.x + yu * eye.y + zu * eye.z;
+ const nabla = max(dp ** 2 - eye.x ** 2 + GLOBE_RADIUS ** 2, 0);
+ // Distance from eye to globe intersection
+ const di = -dp + sqrt(nabla) * signX;
+ // Point of intersection with surface of globe
+ const xi = eye.x + di * xu;
+ const yi = eye.y + di * yu;
+ const zi = eye.z + di * zu;
+ // Rotate to match lat/long
+ const x1 = Axx * xi + Axy * yi + Axz * zi;
+ const y1 = Ayx * xi + Ayy * yi + Ayz * zi;
+ const z1 = Azx * xi + Azy * yi + Azz * zi;
+ const i = SphericalPosition3D.convertRectangular(x1, y1, z1);
+ const xs = mod(i.longitude.degrees + 180, 360) / 360 * MAP_WIDTH;
+ const ys = (90 - i.latitude.degrees) / 180 * MAP_HEIGHT;
+ const pixel = getPixel(pixels, round(xs), round(ys));
+
+ context.fillStyle = `rgba(${(pixel & 0xFF0000) >> 16}, ${(pixel & 0xFF00) >> 8}, ${pixel & 0xFF}, ${alpha})`;
+ context.fillRect(xt, yt, 1, 1);
+ }
+ }
+
+ return true;
+ }
}
diff --git a/src/index.html b/src/index.html
index 4af2f48..f3fb136 100644
--- a/src/index.html
+++ b/src/index.html
@@ -19,7 +19,7 @@
(function () {
// Test browser feature support.
// noinspection ES6ConvertVarToLetConst
- var success;
+ var success, msg = '';
try {
eval('// noinspection JSUnusedLocalSymbols\nlet a = `a`');
@@ -28,14 +28,39 @@
eval('Symbol("symbol")');
eval('Array.from([])');
eval('new Promise(resolve => resolve()).finally()');
- success = !!(''.startsWith && document.createElement('canvas').getContext('webgl2'));
+ success = !!(''.startsWith);
+
+ if (success) {
+ (() => {
+ const ua = navigator.userAgent;
+ const chromeVersion = parseInt((/\bChrome\/(\d+)/.exec(ua) || ['0', '0'])[1]);
+
+ if (chromeVersion && chromeVersion < 79) {
+ msg = 'Chrome version (' + chromeVersion + ') must be 79 or later';
+ return;
+ }
+
+ const firefoxVersion = parseInt((/\bFirefox\/(\d+)/.exec(ua) || ['0', '0'])[1]);
+
+ if (firefoxVersion && firefoxVersion < 79) {
+ msg = 'Firefox version (' + firefoxVersion + ') must be 79 or later';
+ return;
+ }
+
+ const safariVersion = parseFloat((/\bVersion\/(\d+(\.\d+)?).*\bSafari\/\d+/.exec(ua) || ['0', '0'])[1]);
+
+ if (safariVersion && safariVersion < 13.1)
+ msg = 'Safari version (' + safariVersion + ') must be 13.1 or later';
+ })();
+ }
}
catch (e) {
success = false;
+ msg = e.message || e.toString();
}
- if (!success) {
- location.href = location.origin + '/orloj/assets/incompatible.html';
+ if (!success || msg) {
+ location.href = location.origin + '/orloj/assets/incompatible.html?msg=' + encodeURIComponent(msg);
return;
}
@@ -103,7 +128,7 @@