Spaces:
Configuration error
Configuration error
| import { | |
| Cartesian3, | |
| Cartographic, | |
| type CesiumWidget, | |
| Color, | |
| Credit, | |
| ImageryLayer, | |
| JulianDate, | |
| Math as CesiumMath, | |
| Matrix4, | |
| PerspectiveFrustum, | |
| Resource, | |
| type Scene, | |
| sampleTerrainMostDetailed, | |
| SceneMode, | |
| ScreenSpaceEventHandler, | |
| ScreenSpaceEventType, | |
| SkyBox, | |
| type TerrainProvider, | |
| TimeInterval, | |
| Transforms, | |
| defined, | |
| } from "@cesium/engine"; | |
| import type { Viewer } from "@cesium/widgets"; | |
| import dayjs from "dayjs"; | |
| import utc from "dayjs/plugin/utc"; | |
| import { currentPosition } from "../composables/useGeolocation"; | |
| import { usePostHog } from "../composables/usePostHog"; | |
| import { useToastProxy } from "../composables/useToastProxy"; | |
| import { parseLayer } from "../config/layers"; | |
| import { MSAA_RATES, PIXEL_RATIOS, msaaSamplesFor, resolutionScaleFor } from "../config/rendering"; | |
| import { STAR_MAPS, type StarMapSources, starMapSources } from "../config/starMaps"; | |
| import { CAMERA_MODES, SCENE_MODES } from "../config/viewModes"; | |
| import { useCesiumStore } from "../stores/cesium"; | |
| import { useSatStore } from "../stores/sat"; | |
| import { | |
| baseLayerNames, | |
| type ImageryProviderEntry, | |
| imageryProviders, | |
| overlayLayerNames, | |
| type TerrainProviderEntry, | |
| terrainProviders, | |
| terrainProviderNames as visibleTerrainProviderNames, | |
| } from "./CesiumLayerProviders"; | |
| import { cesiumSceneMode } from "./satelliteGraphics"; | |
| import { SatelliteManager } from "./SatelliteManager"; | |
| import { SimulationManager } from "./simulation/SimulationManager"; | |
| import { SkyInteraction } from "./SkyInteraction"; | |
| import { type Observer, SkyView } from "./SkyView"; | |
| import { SurfaceModel } from "./SurfaceModel"; | |
| import { CesiumPerformanceStats } from "./util/CesiumPerformanceStats"; | |
| import { DeviceDetect } from "./util/DeviceDetect"; | |
| import { PushManager } from "./util/PushManager"; | |
| import { Suppressible } from "./util/Suppressible"; | |
| dayjs.extend(utc); | |
| /** | |
| * The components drawn into a shared polyline primitive rather than per entity, | |
| * and so the ones a scene morph has to suppress and wait out. See `sceneMode`. | |
| */ | |
| const BATCHED_COMPONENTS = ["Orbit", "Orbit track"] as const; | |
| /** | |
| * Where the globe opens: Europe's meridian, a little north of the equator. | |
| * | |
| * Cesium's own default is `Rectangle.fromDegrees(-95, -20, -70, 90)`, a slice up | |
| * the Americas, which is why the camera used to start over the Carolinas. | |
| * | |
| * North of the equator rather than on it so that Europe is clear of the limb, | |
| * and only a little, because orbits culminate toward the equator and a view that | |
| * climbs much further north starts cutting off the southern hemisphere. | |
| */ | |
| const DEFAULT_VIEW_LON = 15; | |
| const DEFAULT_VIEW_LAT = 25; | |
| /** | |
| * How much of the screen's narrower axis the globe spans on opening. Under one so | |
| * the whole disc is in frame with room around it, rather than touching two edges. | |
| */ | |
| const DEFAULT_VIEW_FILL = 0.82; | |
| /** | |
| * The value the error panel received, as an error worth a report. | |
| * | |
| * A provider that cannot get a tile gives a `RequestErrorEvent`, not an `Error`. It | |
| * has no message and no stack, so error tracking files it under the minified name of | |
| * the constructor. Each release then opens a new copy of the same unreadable issue. | |
| * The title and message from Cesium describe the error instead, and the original | |
| * value stays readable on the `cause`. | |
| */ | |
| export function reportableError(error: unknown, title: string, message?: string): Error { | |
| if (error instanceof Error) { | |
| return error; | |
| } | |
| const detail = [title, message].filter(Boolean).join(" — "); | |
| return new Error(`Cesium: ${detail || "render error"}`, { cause: error }); | |
| } | |
| /** | |
| * Skip the frames that have no drawing buffer. | |
| * | |
| * The `_canRender` gate of Cesium reads the CSS box of the canvas, so it catches a | |
| * container that collapses. It does not catch a buffer that the browser withdraws | |
| * on a loss of context. The client size does not change then, so `resize` leaves | |
| * `_canRender` true. `Scene.render` takes a viewport of zero, `GlobeDepth` asks for | |
| * a texture of zero width, and the error panel stops the app until a reload. | |
| * | |
| * A skipped frame still ticks the clock, as Cesium does when `_canRender` is false. | |
| * `_renderRequested` holds any request until a frame draws. | |
| */ | |
| export function skipUnsizedFrames(widget: CesiumWidget): void { | |
| const { scene, clock } = widget; | |
| const proxied = widget.render; | |
| widget.render = function guardedRender(this: unknown) { | |
| if (scene.drawingBufferWidth === 0 || scene.drawingBufferHeight === 0) { | |
| clock.tick(); | |
| return; | |
| } | |
| proxied.apply(this, []); | |
| }; | |
| } | |
| export class CesiumController { | |
| viewer: Viewer; | |
| minimalUI: boolean; | |
| sats!: SatelliteManager; | |
| simulations!: SimulationManager; | |
| skyView!: SkyView; | |
| skyInteraction!: SkyInteraction; | |
| surface!: SurfaceModel; | |
| pm!: PushManager; | |
| sceneModes: string[] = []; | |
| cameraModes: string[] = []; | |
| activeLayers: string[] = []; | |
| performanceStats: CesiumPerformanceStats | undefined; | |
| /** Whether the app's chrome is showing. See the `showUI` accessors. */ | |
| #uiVisible: boolean = true; | |
| #removeCameraTrackEci: (() => void) | undefined; | |
| /** | |
| * The reference frame the camera is pinned to. Suppressed by the sky view, | |
| * which drives the camera itself — expressed as an override with "Fixed", | |
| * since that is what "do not track the inertial frame" means in force, while | |
| * the user's own choice stays untouched underneath. | |
| */ | |
| readonly camera: Suppressible<string>; | |
| /** The terrain, which a surface model can insist on. See ADR-0005. */ | |
| readonly terrain: Suppressible<string>; | |
| // The last surface model the store asked for, so the selection is reported once | |
| // rather than every time the view mode makes it re-apply. | |
| #selectedSurfaceModel: string | undefined; | |
| // Which `applyStarMap` call owns the sky box. Two switches in quick succession | |
| // race over a fetch, and the one that started last should win rather than the | |
| // one whose faces happen to arrive last. | |
| #starMapGeneration = 0; | |
| /** | |
| * Takes the viewer rather than making one (see src/modules/createViewer.ts). | |
| * | |
| * The constructor used to call `new Viewer("cesiumContainer", …)`, which meant | |
| * the class could not be brought into existence outside a browser and neither | |
| * could anything holding one. Everything here is now wiring: constructing the | |
| * managers and connecting them to each other, against a viewer that is | |
| * somebody else's problem to produce. | |
| */ | |
| constructor(viewer: Viewer) { | |
| this.preloadReferenceFrameData(); | |
| this.minimalUI = DeviceDetect.minimalUI(); | |
| this.viewer = viewer; | |
| this.setDefaultView(); | |
| this.camera = new Suppressible<string>("Fixed", (mode) => this.#applyCameraMode(mode)); | |
| this.terrain = new Suppressible<string>("None", (name, isCurrent) => this.#applyTerrain(name, isCurrent)); | |
| this.sceneModes = [...SCENE_MODES]; | |
| this.cameraModes = [...CAMERA_MODES]; | |
| this.createInputHandler(); | |
| this.addErrorHandler(); | |
| skipUnsizedFrames(this.viewer.cesiumWidget); | |
| this.sats = new SatelliteManager(this.viewer); | |
| this.simulations = new SimulationManager(this.viewer); | |
| this.skyView = new SkyView(this.viewer.scene); | |
| this.skyInteraction = new SkyInteraction({ | |
| scene: this.viewer.scene, | |
| skyView: this.skyView, | |
| sats: this.sats, | |
| // Selecting by entity identity is all the info panel needs: it resolves | |
| // the selection itself off `viewer.selectedEntity`. | |
| onSelect: (target) => { | |
| this.viewer.selectedEntity = target.sat.defaultEntity; | |
| }, | |
| }); | |
| this.surface = new SurfaceModel({ | |
| scene: this.viewer.scene, | |
| setTerrainOverride: (name) => (name === undefined ? this.releaseTerrain() : this.suppressTerrain(name)), | |
| skyLanded: () => this.skyView.settled, | |
| onFailure: (name, error) => { | |
| // The selection goes back to None so the radio, the url and the scene | |
| // cannot disagree — the same correction the imagery fallback makes — and | |
| // it is said out loud, because the commonest cause is a token this | |
| // origin is not allowed to use and nothing else would explain that. | |
| useCesiumStore().surfaceModel = "None"; | |
| useToastProxy().add({ | |
| title: `${name} unavailable`, | |
| description: `${error instanceof Error ? error.message : "The tileset could not be loaded"}. Cesium ion needs a token valid for this origin.`, | |
| color: "warning", | |
| }); | |
| }, | |
| }); | |
| // Permanent, and asked again whenever what the observer stands on changes. The | |
| // sky view then has a measured height for every case — terrain, surface model, | |
| // bare ellipsoid — instead of following whichever tile has loaded so far. | |
| this.skyView.setGroundHeightSource((observer) => this.#observerGroundHeight(observer)); | |
| this.pm = new PushManager(); | |
| if (!DeviceDetect.inIframe()) { | |
| this.viewer.creditDisplay.addStaticCredit(new Credit(`<a href="/data/privacy.html" target="_blank"><u>Privacy</u></a>`, true)); | |
| } | |
| this.viewer.creditDisplay.addStaticCredit(new Credit(`Satellite TLE data provided by <a href="https://celestrak.org/NORAD/elements/" target="_blank"><u>Celestrak</u></a>`)); | |
| if (this.minimalUI) { | |
| setTimeout(() => { | |
| this.fixLogo(); | |
| }, 2500); | |
| } | |
| this.activeLayers = []; | |
| } | |
| /** | |
| * Open on the default view, far enough out that the whole globe is in frame. | |
| * | |
| * `Camera.DEFAULT_VIEW_RECTANGLE` — what Cesium places the camera by while | |
| * constructing the viewer, and what put it over the Americas — cannot express | |
| * this. It frames a *rectangle* in the current frustum, and Cesium's `fov` is | |
| * the horizontal angle on a landscape viewport and the vertical one otherwise, | |
| * so a single rectangle means very different distances on different shapes of | |
| * screen: the 25°-by-110° default lands at 12,700 km on a phone and 23,200 km on | |
| * a desktop window. On the phone that is not far enough. The globe spans 39° of | |
| * the 30° the narrow axis has to give and is clipped left and right — Cesium's | |
| * own default does this too, so the app has always been cutting the globe off | |
| * there. | |
| * | |
| * Framing the globe instead of a rectangle is one line of trigonometry and is | |
| * the same picture at every aspect ratio. Called after the viewer exists, which | |
| * is when there is a canvas to measure; Cesium has placed the camera once by | |
| * then, but no frame has been drawn, so there is nothing to see move. | |
| */ | |
| setDefaultView(): void { | |
| const { camera, canvas, globe } = this.viewer.scene; | |
| if (!(camera.frustum instanceof PerspectiveFrustum) || camera.frustum.fov === undefined) { | |
| return; | |
| } | |
| const aspectRatio = canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1; | |
| // Whichever angle Cesium is *not* reporting is the narrow one, by the rule | |
| // above — so the derived angle is always the one the globe has to fit inside. | |
| const { fov } = camera.frustum; | |
| const narrow = aspectRatio > 1 ? 2 * Math.atan(Math.tan(fov / 2) / aspectRatio) : 2 * Math.atan(Math.tan(fov / 2) * aspectRatio); | |
| // The equatorial radius, because that is the widest the disc can be. | |
| const radius = globe.ellipsoid.maximumRadius; | |
| const height = radius / Math.sin((narrow / 2) * DEFAULT_VIEW_FILL) - radius; | |
| camera.setView({ destination: Cartesian3.fromDegrees(DEFAULT_VIEW_LON, DEFAULT_VIEW_LAT, height) }); | |
| } | |
| preloadReferenceFrameData(): void { | |
| const timeInterval = new TimeInterval({ | |
| start: JulianDate.addDays(JulianDate.now(), -60, new JulianDate()), | |
| stop: JulianDate.addDays(JulianDate.now(), 120, new JulianDate()), | |
| }); | |
| Transforms.preloadIcrfFixed(timeInterval).then(() => { | |
| console.log("Reference frame data loaded"); | |
| }); | |
| } | |
| get imageryProviderNames(): string[] { | |
| return Object.keys(imageryProviders); | |
| } | |
| get baseLayers(): string[] { | |
| return baseLayerNames(); | |
| } | |
| get overlayLayers(): string[] { | |
| return overlayLayerNames(); | |
| } | |
| set imageryLayers(newLayerNames: string[]) { | |
| this.clearImageryLayers(); | |
| newLayerNames.forEach((layerName) => { | |
| const selection = parseLayer(layerName); | |
| if (selection === undefined) { | |
| return; | |
| } | |
| const layer = this.createImageryLayer(selection.provider, selection.alpha); | |
| if (layer) { | |
| this.viewer.scene.imageryLayers.add(layer); | |
| } | |
| }); | |
| // Providers resolve asynchronously — `fromUrl` fetches a manifest before the | |
| // layer exists — so the imagery lands after `requestRenderMode` has gone quiet, | |
| // and a globe whose imagery changed between frames is not something Cesium's | |
| // input handling can notice. Without this the new layer is never tiled and the | |
| // globe stays blank. | |
| this.viewer.scene.requestRender(); | |
| } | |
| clearImageryLayers(): void { | |
| this.viewer.scene.imageryLayers.removeAll(); | |
| } | |
| createImageryLayer(imageryProviderName: string, alpha?: number): ImageryLayer | false { | |
| if (!this.imageryProviderNames.includes(imageryProviderName)) { | |
| console.error("Unknown imagery layer"); | |
| return false; | |
| } | |
| const provider = imageryProviders[imageryProviderName] as ImageryProviderEntry; | |
| const layer = ImageryLayer.fromProviderAsync(Promise.resolve(provider.create()), {}); | |
| layer.alpha = alpha === undefined ? provider.alpha : alpha; | |
| return layer; | |
| } | |
| get terrainProviderNames(): string[] { | |
| return visibleTerrainProviderNames(); | |
| } | |
| set terrainProvider(terrainProviderName: string) { | |
| if (!this.terrainProviderNames.includes(terrainProviderName)) { | |
| console.error("Unknown terrain provider"); | |
| return; | |
| } | |
| // Recorded but not applied while a surface model is imposing a terrain: this | |
| // is the choice that comes back on release, and building it now would only | |
| // fetch a provider nothing is going to show. `choose` is what knows that. | |
| this.terrain.choose(terrainProviderName); | |
| } | |
| /** The terrain the user picked, whether or not it is the one being drawn. */ | |
| get terrainProvider(): string { | |
| return this.terrain.chosen; | |
| } | |
| /** | |
| * Impose a terrain over the user's choice. Suppression rather than a write, the | |
| * way the camera mode is suppressed: OSM Buildings needs the terrain it was | |
| * authored against, and the user's own terrain has to come back untouched the | |
| * moment it is deselected. | |
| * | |
| * Validated against every registered provider, not just the selectable ones, so | |
| * an override may name a terrain a url is not allowed to. | |
| */ | |
| suppressTerrain(terrainProviderName: string): void { | |
| if (!(terrainProviderName in terrainProviders)) { | |
| console.error("Unknown terrain provider override"); | |
| return; | |
| } | |
| this.terrain.suppress(terrainProviderName); | |
| } | |
| /** Honour the user's own terrain again. */ | |
| releaseTerrain(): void { | |
| this.terrain.release(); | |
| } | |
| /** | |
| * Build the terrain that is actually in force and hand it to the viewer. | |
| * | |
| * The staleness check is not ceremony: creating a provider is a fetch, and two | |
| * changes in quick succession — which is exactly what selecting a surface model | |
| * does, since it overrides the terrain in the same tick — can resolve out of | |
| * order and leave the loser applied. `isCurrent` is Suppressible's generation | |
| * guard; this used to be a counter kept here, and one of four written separately | |
| * across the codebase. | |
| * | |
| * Deliberately no "already applied" short-circuit. There was one, remembering the | |
| * name before awaiting the provider, and it turned a provider that never resolved | |
| * — a terrain host that hangs rather than refusing — into a terrain that could | |
| * never be selected again: the marker said applied, the viewer showed something | |
| * else, and every later request for it returned early. The callers already avoid | |
| * redundant work, so the only thing the marker bought was a way to lie. | |
| */ | |
| async #applyTerrain(name: string, isCurrent: () => boolean): Promise<void> { | |
| try { | |
| const provider = await (terrainProviders[name] as TerrainProviderEntry).create(); | |
| if (!isCurrent()) { | |
| return; | |
| } | |
| // Measured before the swap, not after: the sky view stands on this terrain, and | |
| // a height that arrives a beat later leaves the eye under the new ground for | |
| // that beat. Nothing is standing on it if the sky view is not up, so the | |
| // round trip is only spent when it buys something. | |
| const observer = this.skyView.active ? this.skyView.observer : undefined; | |
| const groundHeight = observer ? await this.#terrainHeightAt(provider, observer) : undefined; | |
| if (!isCurrent()) { | |
| return; | |
| } | |
| this.viewer.terrainProvider = provider; | |
| if (groundHeight !== undefined) { | |
| this.skyView.setGroundHeight(groundHeight); | |
| } | |
| } catch (error) { | |
| // Terrain can fail for a reason the network is not responsible for now that | |
| // one of them is ion-backed. The previous terrain stays, and the next | |
| // selection is free to try again. | |
| console.error(`Terrain provider ${name} failed to load`, error); | |
| } | |
| } | |
| /** | |
| * Put a surface model selection into effect, and tell the sky view what it is | |
| * now standing on. | |
| * | |
| * The two halves live together because the second is a consequence of the | |
| * first and neither module should have to know about the other: the surface | |
| * model can measure a height and the sky view needs one, and wiring them is | |
| * exactly what this class is for. | |
| */ | |
| /** | |
| * What the observer is standing on, measured rather than guessed: the surface model | |
| * when one is drawing, otherwise the terrain in force. | |
| * | |
| * The sky view's own per-frame `globe.getHeight` is a fallback of last resort. It | |
| * answers from whatever tile happens to be loaded, so while terrain streams it | |
| * reports a coarse approximation, then a better one, then a better one — and the eye | |
| * follows each, which is a stagger of jumps rather than a move. | |
| */ | |
| async #observerGroundHeight(observer: Observer): Promise<number | undefined> { | |
| if (this.surface.active) { | |
| return this.surface.surfaceHeight(observer); | |
| } | |
| return this.#terrainHeightAt(this.viewer.terrainProvider, observer); | |
| } | |
| /** | |
| * The height a given terrain provider puts under a point, at its most detailed. | |
| * | |
| * `sampleTerrainMostDetailed` needs the provider's availability metadata, which the | |
| * ellipsoid provider has none of because it does not need any: its surface is height | |
| * zero everywhere, and that is the answer rather than a missing one. | |
| */ | |
| async #terrainHeightAt(provider: TerrainProvider, observer: Observer): Promise<number | undefined> { | |
| if (!provider.availability) { | |
| return 0; | |
| } | |
| try { | |
| const [sample] = await sampleTerrainMostDetailed(provider, [Cartographic.fromDegrees(observer.lon, observer.lat)]); | |
| return sample?.height; | |
| } catch (error) { | |
| console.warn("Could not sample the terrain under the observer", error); | |
| return undefined; | |
| } | |
| } | |
| async applySurfaceModel(surfaceModel: string, viewMode: string): Promise<void> { | |
| // On selection, not on load: what the quota is exposed to is people choosing | |
| // this, and a choice that fails or that lands in a view mode it cannot apply | |
| // in is exactly the kind of thing worth seeing. The view mode goes along so | |
| // the two are still distinguishable. | |
| if (surfaceModel !== this.#selectedSurfaceModel) { | |
| this.#selectedSurfaceModel = surfaceModel; | |
| if (surfaceModel !== "None") { | |
| usePostHog().posthog.capture("surface_model_selected", { surface_model: surfaceModel, view_mode: viewMode }); | |
| } | |
| } | |
| const before = this.surface.active; | |
| await this.surface.apply(surfaceModel, viewMode); | |
| // Only on a change: what the observer stands on is the same as it was otherwise, | |
| // and re-measuring it is a needless round trip. | |
| if (this.surface.active !== before) { | |
| this.skyView.remeasureGround(); | |
| } | |
| } | |
| /** | |
| * Switch the Cesium projection. Only the three view modes that name a Cesium | |
| * `SceneMode` come here — "Sky" is a camera placement rather than a | |
| * projection, and is driven from sceneSync because it needs an observer that | |
| * only the store can supply. | |
| */ | |
| morphTo(sceneMode: string): void { | |
| const target = cesiumSceneMode(sceneMode); | |
| if (target === undefined) { | |
| console.error(`Unknown scene mode ${sceneMode}`); | |
| return; | |
| } | |
| // Already there, so there is nothing to morph and — the part that matters — | |
| // Cesium will not raise `morphComplete`. Returning here rather than below the | |
| // suppression is what keeps 3D → Sky → 3D working: the sky view renders in 3D, | |
| // so leaving it asks for a projection the scene is already in, and suppressing | |
| // against a completion that never comes would hide both batched components for | |
| // the life of the page. | |
| if (this.viewer.scene.mode === target) { | |
| return; | |
| } | |
| const morph = (): void => { | |
| if (target === SceneMode.SCENE3D) { | |
| this.viewer.scene.morphTo3D(); | |
| } else if (target === SceneMode.SCENE2D) { | |
| this.viewer.scene.morphTo2D(); | |
| } else { | |
| this.viewer.scene.morphToColumbusView(); | |
| } | |
| }; | |
| // Suppressed rather than disabled: the user still has these switched on and | |
| // the toolbar has to keep saying so through the morph. Asking the manager | |
| // whether it actually suppressed anything avoids reading back a value that | |
| // this call has already changed. | |
| // | |
| // Both batched components, not just Orbit: outside 3D each of them falls | |
| // back to a per-entity path graphic, and the only thing that re-asks the | |
| // question is a component being created again on release. | |
| // | |
| // This runs for every direction, including back to 3D. It used to return | |
| // before getting here, which left a round trip through 2D or Columbus drawing | |
| // every orbit as a per-entity path graphic once back in 3D — correct, and | |
| // measured at 342 ms a frame against 1.24 ms batched, because nothing re-asked | |
| // the question after the morph. | |
| const suppressed = BATCHED_COMPONENTS.filter((name) => this.sats.suppressComponent(name)); | |
| if (suppressed.length > 0) { | |
| const release = (): void => { | |
| suppressed.forEach((name) => this.sats.releaseComponent(name)); | |
| this.viewer.scene.morphComplete.removeEventListener(release); | |
| }; | |
| this.viewer.scene.morphComplete.addEventListener(release); | |
| // Suppressing them drops every geometry from the shared batches, and a | |
| // batch rebuilds asynchronously — morphing before they have caught up | |
| // would rebuild them into the projection being left behind. | |
| void Promise.all([this.sats.orbits.settled(), this.sats.tracks.settled()]).then(() => { | |
| morph(); | |
| // A morph that did not start raises no completion. The guard above catches | |
| // the reachable case, but Cesium refuses in others too — mid-morph, most | |
| // notably — and a suppression with no completion to release it is an empty | |
| // globe, so release now instead of trusting an event that may not come. | |
| if (this.viewer.scene.mode !== SceneMode.MORPHING) { | |
| release(); | |
| } | |
| }); | |
| } else { | |
| morph(); | |
| } | |
| } | |
| set cameraMode(cameraMode: string) { | |
| if (cameraMode !== "Inertial" && cameraMode !== "Fixed") { | |
| console.error("Unknown camera mode"); | |
| return; | |
| } | |
| this.camera.choose(cameraMode); | |
| } | |
| /** The camera mode the user picked, whether or not it is being honoured. */ | |
| get cameraMode(): string { | |
| return this.camera.chosen; | |
| } | |
| /** | |
| * Stop honouring the camera mode without changing it — the sky view drives | |
| * the camera itself, and inertial tracking re-parents it on every frame, so | |
| * the two cannot share it. | |
| * | |
| * Suppressed rather than forced back to Fixed: "Fixed" is what is in force, but | |
| * `camera.chosen` still says Inertial, so the toolbar keeps saying so, no history | |
| * entry is pushed for a change nobody asked for, and `?camera=Inertial` survives | |
| * the round trip. | |
| */ | |
| suppressCameraMode(): void { | |
| this.camera.suppress("Fixed"); | |
| } | |
| releaseCameraMode(): void { | |
| this.camera.release(); | |
| } | |
| #applyCameraMode(mode: string): void { | |
| const trackEci = mode === "Inertial"; | |
| // Tracked by its removal callback rather than by re-deriving it: Cesium's | |
| // Event happily registers the same listener twice, so asking for Inertial | |
| // while already inertial would otherwise stack a second one. | |
| if (trackEci && !this.#removeCameraTrackEci) { | |
| this.#removeCameraTrackEci = this.viewer.scene.postUpdate.addEventListener(this.cameraTrackEci); | |
| } else if (!trackEci && this.#removeCameraTrackEci) { | |
| this.#removeCameraTrackEci(); | |
| this.#removeCameraTrackEci = undefined; | |
| } | |
| } | |
| cameraTrackEci(scene: Scene, time: JulianDate): void { | |
| if (scene.mode !== SceneMode.SCENE3D) { | |
| return; | |
| } | |
| const icrfToFixed = Transforms.computeIcrfToFixedMatrix(time); | |
| if (defined(icrfToFixed)) { | |
| const { camera } = scene; | |
| const offset = Cartesian3.clone(camera.position); | |
| const transform = Matrix4.fromRotationTranslation(icrfToFixed); | |
| camera.lookAtTransform(transform, offset); | |
| } | |
| } | |
| setTime( | |
| current: string | number | Date, | |
| start: string = dayjs.utc(current).subtract(12, "hour").toISOString(), | |
| stop: string = dayjs.utc(current).add(7, "day").toISOString(), | |
| ): void { | |
| this.viewer.clock.startTime = JulianDate.fromIso8601(dayjs.utc(start).toISOString()); | |
| this.viewer.clock.stopTime = JulianDate.fromIso8601(dayjs.utc(stop).toISOString()); | |
| this.viewer.clock.currentTime = JulianDate.fromIso8601(dayjs.utc(current).toISOString()); | |
| } | |
| createInputHandler(): void { | |
| const handler = new ScreenSpaceEventHandler(this.viewer.scene.canvas); | |
| handler.setInputAction((event: ScreenSpaceEventHandler.PositionedEvent) => { | |
| const { pickMode } = useCesiumStore(); | |
| if (!pickMode) { | |
| return; | |
| } | |
| this.setGroundStationFromClickEvent(event); | |
| }, ScreenSpaceEventType.LEFT_CLICK); | |
| } | |
| setGroundStationFromClickEvent(event: ScreenSpaceEventHandler.PositionedEvent): void { | |
| const cartesian = this.viewer.camera.pickEllipsoid(event.position); | |
| if (!defined(cartesian)) { | |
| return; | |
| } | |
| const cartographicPosition = Cartographic.fromCartesian(cartesian); | |
| this.addGroundStation(CesiumMath.toDegrees(cartographicPosition.latitude), CesiumMath.toDegrees(cartographicPosition.longitude)); | |
| useCesiumStore().pickMode = false; | |
| } | |
| /** | |
| * Through `currentPosition` rather than `navigator.geolocation` directly: this | |
| * used to pass no error callback at all, so a declined permission left the | |
| * button doing nothing, silently and forever. | |
| */ | |
| async setGroundStationFromGeolocation(): Promise<void> { | |
| const fix = await currentPosition(); | |
| if (!fix) { | |
| useToastProxy().add({ | |
| title: "Location unavailable", | |
| description: "No position came back. Check this site's location permission, and note that geolocation needs a secure context.", | |
| color: "warning", | |
| }); | |
| return; | |
| } | |
| this.addGroundStation(fix.lat, fix.lon, "Geolocation"); | |
| } | |
| setGroundStationFromLatLon(lat: number, lon: number): void { | |
| this.addGroundStation(lat, lon); | |
| } | |
| // Ground stations are store state; the scene sync turns them into entities. | |
| // Note the coordinates are not truth-tested here: 0 is a real latitude, and | |
| // the filter that used to live downstream tested `lat && lon`, so it erased | |
| // any station on the equator or the Greenwich meridian. | |
| private addGroundStation(lat: number, lon: number, name = ""): void { | |
| const satStore = useSatStore(); | |
| satStore.setGroundStations([...satStore.groundStations, { lat, lon, ...(name ? { name } : {}) }]); | |
| } | |
| /** | |
| * Cesium's own chrome, which is the fullscreen button and nothing else: no clock | |
| * widgets are built, and the deck places the credit line while it is up. The flag | |
| * is stored because no widget's `visibility` can be read back for it. | |
| */ | |
| set showUI(enabled: boolean) { | |
| this.#uiVisible = enabled; | |
| const fullscreen = this.viewer._fullscreenButton?._container; | |
| if (fullscreen) { | |
| fullscreen.style.visibility = enabled ? "" : "hidden"; | |
| } | |
| } | |
| get showUI(): boolean { | |
| return this.#uiVisible; | |
| } | |
| fixLogo(): void { | |
| if (this.minimalUI) { | |
| this.viewer._bottomContainer.style.left = "5px"; | |
| } | |
| if (DeviceDetect.isiPhoneWithNotchVisible()) { | |
| this.viewer._bottomContainer.style.bottom = "20px"; | |
| } | |
| } | |
| /** | |
| * Drawing-buffer pixels per CSS pixel — `1`, `1.25`, `1.5`, `1.75` or | |
| * `native` (`PIXEL_RATIOS`). | |
| * | |
| * `useBrowserRecommendedResolution` is held false throughout, which is what | |
| * makes Cesium multiply by the display's own ratio rather than by a flat 1.0; | |
| * the chosen ratio is then expressed as the `resolutionScale` that lands on | |
| * it. Leaving the flag alone and driving one number keeps the two from | |
| * disagreeing — the same picture used to be reachable two ways. | |
| * | |
| * No explicit render, unlike `msaa`. Both setters only raise Cesium's | |
| * `_forceResize` flag, but `CesiumWidget.resize` runs on every animation frame | |
| * ahead of `render` whatever render-on-demand is doing, and asks for a frame | |
| * itself once it has reconfigured the canvas. | |
| */ | |
| set pixelRatio(ratio: string) { | |
| if (!(PIXEL_RATIOS as readonly string[]).includes(ratio)) { | |
| console.error("Unknown pixel ratio"); | |
| return; | |
| } | |
| this.viewer.useBrowserRecommendedResolution = false; | |
| this.viewer.resolutionScale = resolutionScaleFor(ratio, window.devicePixelRatio); | |
| } | |
| /** | |
| * Multisample antialiasing — `off`, `2` or `4` (`MSAA_RATES`). | |
| * | |
| * Setting the sample count does not itself ask for a frame, so under | |
| * render-on-demand the control would appear to do nothing until something | |
| * else happened to request one — hence the explicit render. | |
| */ | |
| set msaa(rate: string) { | |
| if (!(MSAA_RATES as readonly string[]).includes(rate)) { | |
| console.error("Unknown MSAA rate"); | |
| return; | |
| } | |
| this.viewer.scene.msaaSamples = msaaSamplesFor(rate); | |
| this.viewer.scene.requestRender(); | |
| } | |
| /** | |
| * Render-on-demand. Owned by the store rather than poked on the scene, because | |
| * more than one thing turns it off — the Render menu's switch and the benchmark panel — | |
| * and a plain scene property is not reactive, so a control bound straight to it | |
| * goes on showing the old value after anything else has written it. | |
| */ | |
| set requestRenderMode(value: boolean) { | |
| this.viewer.scene.requestRenderMode = value; | |
| // One render either way. Switching render-on-demand *on* otherwise leaves | |
| // whatever was last drawn on screen until something happens to ask for a | |
| // frame, which makes the change look like a freeze. | |
| this.viewer.scene.requestRender(); | |
| } | |
| set showFps(value: boolean) { | |
| this.viewer.scene.debugShowFramesPerSecond = value; | |
| } | |
| /** | |
| * Put a star map behind the globe — one of `STAR_MAPS`. Resolves once it is | |
| * drawing, and rejects if its faces could not be fetched. | |
| * | |
| * An action rather than a setter, unlike the rest of the scene settings, | |
| * because an optional map has to be in hand before the sky box is built. | |
| * Cesium is willing to take urls and fetch them itself, but it does that | |
| * *inside* the render loop: a face that 404s then throws out of `Scene.render` | |
| * rather than out of here, and with `rethrowRenderErrors` on that takes the app | |
| * down instead of falling back. Which is not hypothetical — `DeepStar2K` is | |
| * built by `pnpm update-starmap` and is absent until someone runs it. | |
| * | |
| * The viewer is constructed with exactly the built-in sky box (CesiumWidget | |
| * calls `SkyBox.createEarthSkyBox()` when it is passed none), so this only ever | |
| * runs to move off that or back to it — hence the watcher in sceneSync is not | |
| * immediate, and a link naming the built-in re-fetches nothing. | |
| */ | |
| async applyStarMap(name: string): Promise<void> { | |
| if (!(STAR_MAPS as readonly string[]).includes(name)) { | |
| console.error("Unknown star map"); | |
| return; | |
| } | |
| // A star map is part of the background, and `background: false` is what took | |
| // the sky box away. Installing one would put the stars back behind a scene | |
| // that asked to be transparent. | |
| if (!useCesiumStore().background) { | |
| return; | |
| } | |
| const generation = ++this.#starMapGeneration; | |
| const sources = starMapSources(name); | |
| const faces = sources === undefined ? undefined : await this.loadStarMapFaces(sources); | |
| // Overtaken while the faces were in flight. Nothing to undo — the swap below | |
| // is the only thing that touches the scene. | |
| if (generation !== this.#starMapGeneration) { | |
| return; | |
| } | |
| const previous = this.viewer.scene.skyBox; | |
| this.viewer.scene.skyBox = faces === undefined ? SkyBox.createEarthSkyBox() : new SkyBox({ sources: faces }); | |
| // Scene destroys its sky box only when the scene itself goes, so a swap | |
| // without this leaks the outgoing cube map — 100 MB of it leaving | |
| // `DeepStar2K`, which Cesium keeps unmipmapped. Safe here because the scene | |
| // no longer holds the reference: the next frame builds its command from the | |
| // new one. Same reasoning in `set background`. | |
| previous?.destroy(); | |
| // Nothing in Cesium asks for a frame when a cube map lands, so under | |
| // render-on-demand with a paused clock the swap would sit invisible. | |
| this.viewer.scene.requestRender(); | |
| } | |
| /** | |
| * The six faces as images, fetched exactly the way Cesium's own `loadCubeMap` | |
| * would. The options are not incidental: `flipY` is what orients a cube map | |
| * face, and dropping it mirrors the sky. | |
| */ | |
| private async loadStarMapFaces(sources: StarMapSources): Promise<Record<keyof StarMapSources, ImageBitmap | HTMLImageElement>> { | |
| const entries = Object.entries(sources) as [keyof StarMapSources, string][]; | |
| const images = await Promise.all( | |
| entries.map(async ([, url]) => { | |
| const image = await Resource.fetchImage({ url, flipY: true, preferImageBitmap: true }); | |
| if (!image) { | |
| throw new Error(`Star map face ${url} did not load`); | |
| } | |
| return image; | |
| }), | |
| ); | |
| return Object.fromEntries(entries.map(([face], index) => [face, images[index]])) as Record<keyof StarMapSources, ImageBitmap | HTMLImageElement>; | |
| } | |
| set background(active: boolean) { | |
| if (!active) { | |
| this.viewer.scene.backgroundColor = Color.TRANSPARENT; | |
| this.viewer.scene.moon = undefined; | |
| this.viewer.scene.skyAtmosphere = undefined; | |
| // Destroyed, not merely dropped, for the reason `applyStarMap` gives: Scene | |
| // lets go of a sky box without freeing its cube map, which is up to 100 MB | |
| // once a 2048 map is the one being taken away. | |
| const skyBox = this.viewer.scene.skyBox; | |
| this.viewer.scene.skyBox = undefined; | |
| skyBox?.destroy(); | |
| this.viewer.scene.sun = undefined; | |
| document.documentElement.style.background = "transparent"; | |
| document.body.style.background = "transparent"; | |
| const container = document.getElementById("cesiumContainer"); | |
| if (container) container.style.background = "transparent"; | |
| } | |
| } | |
| enablePerformanceStats(logContinuously = false): void { | |
| this.performanceStats = new CesiumPerformanceStats(this.viewer.scene, logContinuously); | |
| } | |
| addErrorHandler(): void { | |
| this.viewer.scene.rethrowRenderErrors = true; | |
| this.viewer.scene.renderError.addEventListener((scene: Scene, error: Error) => { | |
| console.error(scene, error); | |
| usePostHog().posthog.captureException(error); | |
| }); | |
| // Cesium answers a render-loop error by drawing its own panel and going no | |
| // further, so the only way these reach PostHog is to wrap the panel itself. | |
| const widget = this.viewer.cesiumWidget; | |
| const proxied = widget.showErrorPanel; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| widget.showErrorPanel = function widgetError(this: unknown, title: string, message: string, error: any) { | |
| proxied.apply(this, [title, message, error]); | |
| usePostHog().posthog.captureException(reportableError(error, title, message)); | |
| }; | |
| } | |
| } | |