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 @@