import {Clock, OrthographicCamera, PerspectiveCamera, Scene, Vector2, WebGLRenderer} from 'three'; import {OrbitControls} from './vendor/orbit-controls'; import {MapControls} from 'three/examples/jsm/controls/MapControls'; import {TileManager} from './tile-manager'; import {MarkerHtml} from './marker-html'; import {cameraViewToGlobePosition, globePositionToCameraView} from './lib/convert-spaces'; import {RenderMode, RenderOptions} from './types/renderer'; import type {RenderTile} from './types/tile'; import type {CameraView} from './types/camera-view'; import type {MarkerProps} from './types/marker'; import { GLOBE_VIEWPORT_WIDTH_PERCENTAGE, MAP_HEIGHT, MAP_WIDTH, MOBILE_BREAKPOINT_WIDTH, MOBILE_HORIZONTAL_FOV } from './config'; import {Atmosphere} from './atmosphere'; export class Renderer extends EventTarget { readonly container: HTMLElement; private readonly webglRenderer: WebGLRenderer; private readonly scene: Scene = new Scene(); private readonly globeCamera: PerspectiveCamera = new PerspectiveCamera(); private readonly mapCamera: OrthographicCamera = new OrthographicCamera(); public globeControls: OrbitControls; public mapControls: MapControls; private tileManager: TileManager; private markersById: Record = {}; private renderMode: RenderMode = RenderMode.GLOBE; private rendererSize: Vector2 = new Vector2(); private atmosphere: Atmosphere = new Atmosphere(); private clock = new Clock(); constructor(container?: HTMLElement) { super(); this.container = container || document.body; const renderer = new WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); renderer.setClearColor(0xffffff, 0); renderer.setAnimationLoop(this.animationLoopUpdate.bind(this)); // for rendering performance, we don't use a pixel-ratio above 2 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.webglRenderer = renderer; this.container.appendChild(this.webglRenderer.domElement); this.container.style.position = 'relative'; this.container.style.overflow = 'hidden'; this.tileManager = new TileManager(this.scene); this.configureCameras(); this.globeControls = new OrbitControls(this.globeCamera, this.container); this.mapControls = new MapControls(this.mapCamera, this.container); this.scene.add(this.atmosphere); this.configureControls(); const {width, height} = this.container.getBoundingClientRect(); this.resize(width, height); } getGlobeControls(): OrbitControls { return this.globeControls; } getRenderMode() { return this.renderMode; } setRenderMode(renderMode: RenderMode) { this.renderMode = renderMode; // switch to appropriate controls this.globeControls.enabled = this.renderMode === RenderMode.GLOBE; this.mapControls.enabled = this.renderMode === RenderMode.MAP; this.atmosphere.visible = this.renderMode === RenderMode.GLOBE; this.tileManager.setRenderMode(renderMode); } resize(width: number, height: number) { this.rendererSize.set(width, height); this.webglRenderer.setSize(width, height); const aspectRatio = width / height; this.globeCamera.aspect = aspectRatio; // We want the globe to occupy a fixed percentage of the viewport *width*. // This is because of the way the globe has to match to navigation in FE Desktop version // The camera's `fov` property is the *vertical* field of view. // We need to calculate the vertical FOV that will result in our desired *horizontal* FOV. // The relationship is: tan(hFOV / 2) = tan(vFOV / 2) * aspect // So, vFOV = 2 * atan(tan(hFOV / 2) / aspect) const globeRadius = 1; const cameraDistance = 5; // Use initial distance as reference for sizing // The atmosphere sprite is slightly larger than the globe model. To prevent it // from being clipped, we must base our calculations on its size. // The scale factor is derived from the texture properties in `atmosphere.ts`. const atmosphereScaleFactor = 2048 / 1997; const effectiveRadius = globeRadius * atmosphereScaleFactor; const baseHorizontalFovInRadians = 2 * Math.atan(effectiveRadius / (cameraDistance * GLOBE_VIEWPORT_WIDTH_PERCENTAGE)); let hFov = baseHorizontalFovInRadians * (180 / Math.PI); if (width <= MOBILE_BREAKPOINT_WIDTH) { hFov = MOBILE_HORIZONTAL_FOV; } let hFovRadians = hFov * (Math.PI / 180); // Ensure the globe fits vertically, especially on wide aspect ratios. // For wider aspect ratios (smaller heights), we increase the padding. const basePadding = 1.1; const dynamicPadding = 0.2 * Math.max(0, aspectRatio - 1.5); const PADDING = basePadding + dynamicPadding; // The minimum FOV to fit the globe with atmosphere at the reference distance. const minFovForGlobe = 2 * Math.asin(effectiveRadius / cameraDistance) * PADDING; // The hFov needed to maintain the aspect ratio while fitting vertically. const hFovRequiredByAspect = 2 * Math.atan(Math.tan(minFovForGlobe / 2) * aspectRatio); if (hFovRadians < hFovRequiredByAspect) { hFovRadians = hFovRequiredByAspect; } const vFovRadians = 2 * Math.atan(Math.tan(hFovRadians / 2) / aspectRatio); this.globeCamera.fov = vFovRadians * (180 / Math.PI); this.globeCamera.updateProjectionMatrix(); const halfWidth = MAP_WIDTH / 2; this.mapCamera.top = halfWidth / aspectRatio; this.mapCamera.bottom = -halfWidth / aspectRatio; } getRendererSize() { return this.rendererSize; } updateTiles(tiles: RenderTile[]) { this.tileManager.updateTiles(tiles); } getCamera() { if (this.renderMode === RenderMode.GLOBE) return this.globeCamera; return this.mapCamera; } getCameraView(): CameraView | undefined { if (this.renderMode === RenderMode.GLOBE) { return globePositionToCameraView(this.globeCamera.position); } else if (this.renderMode === RenderMode.MAP) { return { renderMode: RenderMode.MAP, lat: this.mapCamera.position.y * 90, lng: this.mapCamera.position.x * 90, zoom: this.mapCamera.zoom, altitude: 0 }; } return undefined; } public updateMapCamera(cameraView: CameraView) { this.mapCamera.position.x = cameraView.lng / 90; this.mapCamera.position.y = cameraView.lat / 90; this.mapCamera.zoom = cameraView.zoom; this.mapControls.target.copy(this.mapCamera.position); } setMarkers(markerProps: MarkerProps[]) { // remove markers that are no longer needeed const newMarkerIds = markerProps.map(m => m.id); const toRemove = Object.keys(this.markersById).filter(id => !newMarkerIds.includes(id)); for (const markerId of toRemove) { this.markersById[markerId].destroy(); delete this.markersById[markerId]; } for (let props of markerProps) { // known markers get updated const knownMarker = this.markersById[props.id]; if (knownMarker) { knownMarker.setProps(props); continue; } // otherwise create the marker this.markersById[props.id] = new MarkerHtml(this, props); } } destroy() { this.webglRenderer.dispose(); this.webglRenderer.setAnimationLoop(null); this.webglRenderer.domElement.remove(); } private configureCameras() { const {width, height} = this.container.getBoundingClientRect(); const aspectRatio = width / height; this.globeCamera.fov = 35; this.globeCamera.aspect = aspectRatio; this.globeCamera.near = 0.001; this.globeCamera.far = 1000; this.globeCamera.zoom = 1; this.globeCamera.position.set(0, 0, 5); this.globeCamera.updateProjectionMatrix(); const halfWidth = MAP_WIDTH / 2; this.mapCamera.left = -halfWidth; this.mapCamera.right = halfWidth; this.mapCamera.top = halfWidth / aspectRatio; this.mapCamera.bottom = -halfWidth / aspectRatio; this.mapCamera.near = 0.1; this.mapCamera.far = 2; this.mapCamera.position.set(0, 0, 1); this.mapCamera.updateProjectionMatrix(); } private configureControls() { this.globeControls.enableDamping = true; this.globeControls.dampingFactor = 0.2; this.globeControls.enablePan = false; this.globeControls.enableZoom = true; this.globeControls.rotateSpeed = 1; this.globeControls.zoomSpeed = 2.5; this.globeControls.maxPolarAngle = Math.PI; this.globeControls.minPolarAngle = 0; this.globeControls.minDistance = 1.01; // ~ zoom level 11 this.globeControls.addEventListener('change', () => { const event = new CustomEvent('cameraViewChanged', { detail: globePositionToCameraView(this.globeCamera.position) }); this.dispatchEvent(event); }); this.mapControls.enableRotate = false; this.mapControls.enablePan = true; this.mapControls.enableZoom = true; this.mapControls.screenSpacePanning = true; this.mapControls.minZoom = 1; this.mapControls.maxZoom = 20; this.mapControls.addEventListener('change', () => { // camera-position is x [-2..2] and y [-1..1] const lng = this.mapCamera.position.x * 90; const lat = this.mapCamera.position.y * 90; const zoom = this.mapCamera.zoom; const view: CameraView = {renderMode: RenderMode.MAP, lat, lng, zoom, altitude: 0, isAnimated: false}; const event = new CustomEvent('cameraViewChanged', {detail: view}); this.dispatchEvent(event); }); const origUpdate = this.mapControls.update.bind(this.mapControls); // override the update-function to limit map bounds this.mapControls.update = (deltaTime?: number) => { origUpdate(deltaTime); const camera = this.mapCamera; const controls = this.mapControls; const dx = (camera.right - camera.left) / (2 * camera.zoom); const dy = (camera.top - camera.bottom) / (2 * camera.zoom); const xMax = Math.max(0, MAP_WIDTH / 2 - dx); const yMax = Math.max(0, MAP_HEIGHT / 2 - dy); const x = Math.max(-xMax, Math.min(xMax, camera.position.x)); const y = Math.max(-yMax, Math.min(yMax, camera.position.y)); camera.position.x = controls.target.x = x; camera.position.y = controls.target.y = y; return true; }; this.globeControls.enabled = this.renderMode === RenderMode.GLOBE; this.mapControls.enabled = this.renderMode === RenderMode.MAP; } private animationLoopUpdate() { const deltaTime = this.clock.getDelta(); if (this.globeControls.enabled) { this.globeControls.update(); const cameraDistance = this.globeCamera.position.length() - 1; this.globeControls.rotateSpeed = Math.max(0.05, Math.min(1.0, cameraDistance - 0.2)); } else if (this.mapControls.enabled) { this.mapControls.update(deltaTime); } this.webglRenderer.render(this.scene, this.getCamera()); } public updateGlobeCamera(cameraView: CameraView) { cameraViewToGlobePosition(cameraView, this.globeCamera.position); this.globeCamera.lookAt(0, 0, 0); } setRenderOptions(renderOptions: RenderOptions) { this.atmosphere.setRenderOptions(renderOptions); } } export interface RendererEventMap { cameraViewChanged: CustomEvent; } export interface Renderer { addEventListener( type: K, listener: (this: Renderer, ev: RendererEventMap[K]) => any, options?: boolean | AddEventListenerOptions ): void; addEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions ): void; removeEventListener( type: K, listener: (this: Renderer, ev: RendererEventMap[K]) => any, options?: boolean | EventListenerOptions ): void; removeEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions ): void; }