Spaces:
Configuration error
Configuration error
| import { | |
| ArcType, | |
| BoundingSphere, | |
| CallbackProperty, | |
| Cartesian2, | |
| Cartesian3, | |
| Color, | |
| ColorGeometryInstanceAttribute, | |
| CornerType, | |
| CorridorGraphics, | |
| DistanceDisplayCondition, | |
| Entity, | |
| EntityView, | |
| GeometryInstance, | |
| HeadingPitchRange, | |
| HeightReference, | |
| HorizontalOrigin, | |
| JulianDate, | |
| LabelGraphics, | |
| LabelStyle, | |
| Math as CesiumMath, | |
| ModelGraphics, | |
| NearFarScalar, | |
| PathGraphics, | |
| PointGraphics, | |
| PolylineColorAppearance, | |
| PolylineGeometry, | |
| PolylineGlowMaterialProperty, | |
| PolylineGraphics, | |
| SceneMode, | |
| VelocityOrientationProperty, | |
| } from "@cesium/engine"; | |
| import type { Viewer } from "@cesium/widgets"; | |
| import CesiumSensorVolumes from "cesium-sensor-volumes"; | |
| import { clearPassHighlights, setPassHighlights } from "../composables/usePassHighlights"; | |
| import { SATELLITE_COMPONENTS } from "../config/components"; | |
| import { ORBIT_CLASS_COLOR, type OrbitClass } from "../config/orbitClass"; | |
| import type { GroundStation } from "./PassPredictor"; | |
| import type { CatalogEntry } from "./SatelliteCatalog"; | |
| import { coneDescription, coneOrientation, groundTrackDescription, modelUri, orbitPathTimes, orbitTrackTimes, orbitUsesPathGraphic } from "./satelliteGraphics"; | |
| import { SatelliteProperties } from "./SatelliteProperties"; | |
| import { drawablePositions } from "./util/drawablePositions"; | |
| import type { PassPredictorSource } from "./util/passSource"; | |
| import type { PolylineBatch } from "./util/PolylineBatch"; | |
| import type { TrajectorySampler } from "./util/sampleSource"; | |
| type SatelliteComponentName = string; | |
| /** The shared polyline batches a satellite draws its orbit lines into. */ | |
| export interface SatelliteBatches { | |
| /** Inertial: the Orbit component's closed ellipse. */ | |
| orbits: PolylineBatch; | |
| /** Fixed: the Orbit track component's Earth-relative path. */ | |
| tracks: PolylineBatch; | |
| } | |
| /** | |
| * The link drawn to a ground station during a pass. Not in SATELLITE_COMPONENTS | |
| * and not switchable: the ground-station setter makes it when there is a station | |
| * to draw to, so it is a component this class creates for itself. | |
| */ | |
| const GROUND_STATION_LINK = "Ground station link"; | |
| // The palette converted once, not per satellite: with ~10,000 points on screen | |
| // these are shared instances, the same way Cesium shares its own Color constants. | |
| const POINT_COLOR = Object.fromEntries(Object.entries(ORBIT_CLASS_COLOR).map(([orbitClass, hex]) => [orbitClass, Color.fromCssColorString(hex)])) as Record<OrbitClass, Color>; | |
| /** | |
| * `BoundingSphereState.PENDING`. Written out rather than imported: the enum is | |
| * exported from the engine's JavaScript but not from its type declarations. See | |
| * groundTrackSettled. | |
| */ | |
| const BOUNDING_SPHERE_PENDING = 1; | |
| /** | |
| * How each component is made. Keyed against the config list rather than written | |
| * out as a switch, so adding a component there without a creator here is a | |
| * compile error instead of a "Unknown component" at runtime. | |
| */ | |
| const CREATORS: Record<(typeof SATELLITE_COMPONENTS)[number] | typeof GROUND_STATION_LINK, (sat: SatelliteComponentCollection) => void> = { | |
| Point: (sat) => sat.createPoint(), | |
| Label: (sat) => sat.createLabel(), | |
| Orbit: (sat) => sat.createOrbit(), | |
| "Orbit track": (sat) => sat.createOrbitTrack(), | |
| "Ground track": (sat) => sat.createGroundTrack(), | |
| "Sensor cone": (sat) => sat.createCone(), | |
| "3D model": (sat) => sat.createModel(), | |
| [GROUND_STATION_LINK]: (sat) => sat.createGroundStationLink(), | |
| }; | |
| /** | |
| * Below this a polyline is not a polyline. `PolylineGeometry`'s constructor throws | |
| * rather than declining, and a throw raised while geometry is rebuilt escapes | |
| * through `clock.tick` into Cesium's render loop, which turns | |
| * `_useDefaultRenderLoop` off and stops the app for the rest of the session. | |
| */ | |
| const MIN_POLYLINE_POSITIONS = 2; | |
| /** | |
| * An Entity when the component is drawn on its own, a GeometryInstance when it is | |
| * merged into the shared orbit batch. Never a Primitive: the one creator that made | |
| * one was dead code, and the branch checking for it could never be true. | |
| */ | |
| type Component = Entity | GeometryInstance; | |
| /** One satellite's Cesium objects, created on demand and dropped on disable. */ | |
| export class SatelliteComponentCollection { | |
| /** Written into by `getBoundingSphere` and never read. See groundTrackSettled. */ | |
| static readonly #sphereScratch = new BoundingSphere(); | |
| /** So a broken assumption is reported once rather than on every tick. */ | |
| static #reportedMissingBoundingSphere = false; | |
| static #reportMissingBoundingSphere(): void { | |
| if (SatelliteComponentCollection.#reportedMissingBoundingSphere) { | |
| return; | |
| } | |
| SatelliteComponentCollection.#reportedMissingBoundingSphere = true; | |
| console.error("Cesium DataSourceDisplay has no getBoundingSphere; pacing ground tracks on a fixed schedule instead. Cesium internals have moved — see groundTrackSettled."); | |
| } | |
| readonly viewer: Viewer; | |
| readonly props: SatelliteProperties; | |
| /** | |
| * The batches every untracked orbit and orbit track are drawn into. Passed in | |
| * rather than reached for: they are shared by every satellite, and one owner | |
| * beats a static. | |
| */ | |
| readonly #orbits: PolylineBatch; | |
| readonly #tracks: PolylineBatch; | |
| #components: Record<string, Component> = {}; | |
| /** What a click or a track acts on — the first Entity to be created. */ | |
| defaultEntity: Entity | undefined; | |
| eventListeners: Record<string, () => void> = {}; | |
| constructor(viewer: Viewer, entry: CatalogEntry, batches: SatelliteBatches, sampler: TrajectorySampler, passes: PassPredictorSource) { | |
| this.viewer = viewer; | |
| this.props = new SatelliteProperties(entry, sampler, passes); | |
| this.#orbits = batches.orbits; | |
| this.#tracks = batches.tracks; | |
| } | |
| /** | |
| * Put this satellite's passes on the clock deck's ruler, once they exist. | |
| * | |
| * Prediction is off-thread, so the list a read returns may be the previous one | |
| * or nothing at all. Publishing what is known now and again when the answer | |
| * lands is the whole adaptation: the first call paints a stale or empty band and | |
| * costs nothing, the second paints the real one. | |
| */ | |
| #highlightPasses(): void { | |
| const predictor = this.props.passPredictor; | |
| const time = this.viewer.clock.currentTime; | |
| if (this.isSelected) { | |
| setPassHighlights(predictor.passes(time)); | |
| } else { | |
| // Not selected: nothing to paint, but the tracked satellite still wants the | |
| // window computed for its ground-station link. | |
| predictor.passes(time); | |
| } | |
| } | |
| #batchFor(name: SatelliteComponentName): PolylineBatch { | |
| return name === "Orbit track" ? this.#tracks : this.#orbits; | |
| } | |
| get components(): Record<string, Component> { | |
| return this.#components; | |
| } | |
| get componentNames(): string[] { | |
| return Object.keys(this.#components); | |
| } | |
| get created(): boolean { | |
| return this.componentNames.length > 0; | |
| } | |
| get isSelected(): boolean { | |
| return Object.values(this.#components).some((component) => this.viewer.selectedEntity === component); | |
| } | |
| get isTracked(): boolean { | |
| return Object.values(this.#components).some((component) => this.viewer.trackedEntity === component); | |
| } | |
| show(componentNames: string[] = this.componentNames): void { | |
| componentNames.forEach((name) => this.enableComponent(name)); | |
| } | |
| hide(componentNames: string[] = this.componentNames): void { | |
| componentNames.forEach((name) => this.disableComponent(name)); | |
| } | |
| track(animate = false): void { | |
| if (!this.defaultEntity) { | |
| return; | |
| } | |
| if (!animate) { | |
| this.viewer.trackedEntity = this.defaultEntity; | |
| return; | |
| } | |
| this.viewer.trackedEntity = undefined; | |
| const clockRunning = this.viewer.clock.shouldAnimate; | |
| this.viewer.clock.shouldAnimate = false; | |
| void this.viewer.flyTo(this.defaultEntity, { offset: new HeadingPitchRange(0, -CesiumMath.PI_OVER_FOUR, 1580000) }).then((result: boolean) => { | |
| if (result) { | |
| this.viewer.trackedEntity = this.defaultEntity; | |
| this.viewer.clock.shouldAnimate = clockRunning; | |
| } | |
| }); | |
| } | |
| /** | |
| * Drive the camera from the entity's own position while it is tracked, and put | |
| * it back to a sensible angle when tracking stops. | |
| */ | |
| artificiallyTrack(): void { | |
| const entity = this.defaultEntity; | |
| if (!entity) { | |
| return; | |
| } | |
| const cameraTracker = new EntityView(entity, this.viewer.scene, this.viewer.scene.globe.ellipsoid); | |
| const removeTick = this.viewer.clock.onTick.addEventListener((clock) => { | |
| cameraTracker.update(clock.currentTime); | |
| }); | |
| const removeTracked = this.viewer.trackedEntityChanged.addEventListener(() => { | |
| removeTick(); | |
| removeTracked(); | |
| if (typeof this.viewer.trackedEntity === "undefined") { | |
| void this.viewer.flyTo(entity, { offset: new HeadingPitchRange(0, CesiumMath.toRadians(-90.0), 2000000) }); | |
| } | |
| }); | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| createCesiumEntity(componentName: string, entityKey: string, entityValue: any, name: string, position: any, moving: boolean): void { | |
| const entity = new Entity({ | |
| name, | |
| position, | |
| viewFrom: new Cartesian3(0, -3600000, 4200000), | |
| }); | |
| if (moving) { | |
| entity.orientation = new VelocityOrientationProperty(position); | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| (entity as any)[entityKey] = entityValue; | |
| this.#components[componentName] = entity; | |
| } | |
| enableComponent(name: SatelliteComponentName): void { | |
| if (!this.created) { | |
| this.init(); | |
| } | |
| if (!this.props.trajectory.valid) { | |
| console.error(`No valid position data available for ${this.props.name}`); | |
| return; | |
| } | |
| if (!(name in this.#components)) { | |
| this.createComponent(name); | |
| this.updatedSampledPositionForComponents(); | |
| } | |
| const component = this.#components[name]; | |
| if (component instanceof Entity) { | |
| if (!this.viewer.entities.contains(component)) { | |
| this.viewer.entities.add(component); | |
| } | |
| this.defaultEntity ??= component; | |
| } else if (component instanceof GeometryInstance) { | |
| this.#batchFor(name).add(component); | |
| } | |
| if (name === "3D model") { | |
| // So the model does not cover the label. | |
| this.#setLabelOffset(20); | |
| } | |
| } | |
| disableComponent(name: SatelliteComponentName): void { | |
| if (name === "3D model") { | |
| this.#setLabelOffset(10); | |
| } | |
| const component = this.#components[name]; | |
| if (component instanceof Entity) { | |
| this.viewer.entities.remove(component); | |
| } else if (component instanceof GeometryInstance) { | |
| this.#batchFor(name).remove(component); | |
| } | |
| delete this.#components[name]; | |
| if (this.defaultEntity === component) { | |
| // Hand the role to whatever is still drawn. It used to be kept pointing at | |
| // the removed entity, so a click target and the camera's tracked entity | |
| // could both outlive what they referred to. | |
| this.defaultEntity = Object.values(this.#components).find((remaining) => remaining instanceof Entity); | |
| } | |
| if (this.componentNames.length === 0) { | |
| this.deinit(); | |
| } | |
| } | |
| #setLabelOffset(x: number): void { | |
| const labelEntity = this.#components.Label as Entity | undefined; | |
| if (labelEntity?.label) { | |
| labelEntity.label.pixelOffset = new Cartesian2(x, 0) as unknown as typeof labelEntity.label.pixelOffset; | |
| } | |
| } | |
| init(): void { | |
| this.eventListeners.sampledPosition = this.props.trajectory.start(this.viewer, () => { | |
| this.updatedSampledPositionForComponents(true); | |
| }); | |
| // Pass prediction answers late now, so the things derived from a pass list | |
| // have to be told rather than to ask. The ground-station link reads | |
| // passIntervals through a CallbackProperty and needs nothing; the timeline | |
| // bands are painted once and do. | |
| this.eventListeners.passesChanged = this.props.passPredictor.onChanged(() => { | |
| if (this.isSelected) { | |
| setPassHighlights(this.props.passPredictor.passes(this.viewer.clock.currentTime)); | |
| } | |
| }); | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| this.eventListeners.selectedEntity = this.viewer.selectedEntityChanged.addEventListener((entity: any) => { | |
| if (!entity || entity?.name === "Ground station") { | |
| clearPassHighlights(); | |
| return; | |
| } | |
| if (this.isSelected) { | |
| this.#highlightPasses(); | |
| } | |
| }); | |
| this.eventListeners.trackedEntity = this.viewer.trackedEntityChanged.addEventListener(() => { | |
| if (this.isTracked) { | |
| this.artificiallyTrack(); | |
| } | |
| if ("Orbit" in this.components && !this.isCorrectOrbitComponent()) { | |
| // Rebuilt rather than adjusted: a geometry cannot change visualisation | |
| // type in place. | |
| this.disableComponent("Orbit"); | |
| this.enableComponent("Orbit"); | |
| } | |
| if ("Orbit track" in this.components && !this.isCorrectOrbitTrackComponent()) { | |
| // Same swap as the Orbit above: the satellite the camera is on gets the | |
| // exact per-frame path, everything else gets the batch. | |
| this.disableComponent("Orbit track"); | |
| this.enableComponent("Orbit track"); | |
| } | |
| }); | |
| } | |
| deinit(): void { | |
| // Every one of them, by iterating rather than by naming: the pass listener was | |
| // added to `init` and missed here, and an enable/disable cycle then left one | |
| // more subscriber on the predictor's list every time round. | |
| Object.values(this.eventListeners).forEach((remove) => remove?.()); | |
| this.eventListeners = {}; | |
| } | |
| /** | |
| * Fully tear down this collection so it can be dropped from the active set. | |
| * | |
| * `hide()` disables every created component; removing the last one triggers | |
| * `deinit()` (see disableComponent), which detaches the sampledPosition and | |
| * viewer listeners and tears down the sampledPosition. For a collection whose | |
| * components were never created this is a no-op (empty componentNames), so | |
| * dispose is safe to call unconditionally and is idempotent. | |
| */ | |
| dispose(): void { | |
| this.hide(); | |
| } | |
| updatedSampledPositionForComponents(update = false): void { | |
| const { entityPosition } = this.props.trajectory; | |
| // Neither the inertial frame nor the sampled property: both are absent unless | |
| // a component asked for them, and requiring either here would have stopped | |
| // every other component updating in exactly the scenes the laziness is for. | |
| if (!entityPosition) return; | |
| Object.entries(this.components).forEach(([type, component]) => { | |
| if (type === "Orbit") { | |
| if (component instanceof Entity) { | |
| // An Orbit exists, so createOrbit has already required the frame. | |
| this.props.trajectory.requireInertial(); | |
| component.position = this.props.trajectory.inertial; | |
| } else if (update && component instanceof GeometryInstance) { | |
| // A geometry cannot be edited in place; it has to be rebuilt | |
| this.disableComponent("Orbit"); | |
| this.enableComponent("Orbit"); | |
| } | |
| } else if (type === "Orbit track") { | |
| if (component instanceof Entity) { | |
| // The sampled property, not the grid — this one is a path, and an Orbit | |
| // track Entity exists only because createOrbitTrackPath asked for it. | |
| this.props.trajectory.requireSampled(); | |
| component.position = this.props.trajectory.fixed; | |
| } else if (update) { | |
| // The window it was cut from has just moved, so the samples behind the | |
| // batched geometry are the old ones. Re-cut rather than rebuild the | |
| // membership: replace() leaves the batch the same size. | |
| this.refreshOrbitTrack(this.viewer.clock.currentTime); | |
| } | |
| } else if (component instanceof Entity) { | |
| if (type === "Sensor cone") { | |
| component.position = entityPosition; | |
| component.orientation = new CallbackProperty((time?: JulianDate) => coneOrientation(this.props.trajectory.position(time as JulianDate)), false); | |
| } else { | |
| component.position = entityPosition; | |
| component.orientation = new VelocityOrientationProperty(entityPosition); | |
| } | |
| } | |
| }); | |
| // Request a single frame after satellite position updates when the clock is paused | |
| if (!this.viewer.clock.shouldAnimate) { | |
| const removeCallback = this.viewer.clock.onTick.addEventListener(() => { | |
| this.viewer.scene.requestRender(); | |
| removeCallback(); | |
| }); | |
| } | |
| } | |
| createComponent(name: SatelliteComponentName): void { | |
| // A plain string, because that is what the store and the url carry. The | |
| // table's own type is the const union, so a component added to the config | |
| // without a creator here is still a compile error. | |
| const create = (CREATORS as Record<string, ((sat: SatelliteComponentCollection) => void) | undefined>)[name]; | |
| if (!create) { | |
| console.error(`Unknown component ${name}`); | |
| return; | |
| } | |
| create(this); | |
| } | |
| /** | |
| * An entity at the satellite, positioned by the grid property. | |
| * | |
| * Everything that only asks where the satellite is right now belongs here. A | |
| * path graphic does not — it sub-samples the property it is given, and Cesium | |
| * only knows how to do that densely for its own `SampledPositionProperty` — so | |
| * `createOrbitTrackPath` and `createOrbitPath` build their entities directly. | |
| */ | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| createCesiumSatelliteEntity(entityName: string, entityKey: string, entityValue: any): void { | |
| this.createCesiumEntity(entityName, entityKey, entityValue, this.props.name, this.props.trajectory.entityPosition, true); | |
| } | |
| // Coloured by orbit regime, matching the badge the satellite browser shows on | |
| // the same satellite's row — so the menu reads as the legend for the globe. | |
| // | |
| // Small on purpose: a whole constellation at 6 px merges into a sheet that | |
| // hides the globe under it. 5 px still leaves the globe legible under a full | |
| // Starlink activation, and the outline is what keeps a point visible against | |
| // bright imagery rather than its size. | |
| createPoint(): void { | |
| const point = new PointGraphics({ | |
| pixelSize: 5, | |
| color: POINT_COLOR[this.props.orbitClass], | |
| outlineColor: Color.DIMGREY, | |
| outlineWidth: 1, | |
| }); | |
| this.createCesiumSatelliteEntity("Point", "point", point); | |
| } | |
| createModel(): void { | |
| const model = new ModelGraphics({ | |
| uri: modelUri(this.props.name, this.props.entry.metadata.modelUrl), | |
| minimumPixelSize: 50, | |
| maximumScale: 10000, | |
| }); | |
| this.createCesiumSatelliteEntity("3D model", "model", model); | |
| } | |
| // Drawn in the neutral the LEO point uses, not white: a label is chrome next | |
| // to the marker it names, and at white it outshouted the very points it was | |
| // meant to identify. | |
| createLabel(): void { | |
| const label = new LabelGraphics({ | |
| text: this.props.name, | |
| font: "13px Arial", | |
| fillColor: POINT_COLOR.LEO, | |
| style: LabelStyle.FILL_AND_OUTLINE, | |
| outlineColor: Color.DIMGREY, | |
| outlineWidth: 2, | |
| horizontalOrigin: HorizontalOrigin.LEFT, | |
| pixelOffset: new Cartesian2(10, 0), | |
| distanceDisplayCondition: new DistanceDisplayCondition(2000, 8e7), | |
| translucencyByDistance: new NearFarScalar(6e7, 1.0, 8e7, 0.0), | |
| }); | |
| this.createCesiumSatelliteEntity("Label", "label", label); | |
| } | |
| createOrbit(): void { | |
| // The Orbit is the only component drawn in the inertial frame, so it is the | |
| // only thing that makes the second sample set worth keeping. Declared here, | |
| // once, rather than at each of the two places below that go on to read it. | |
| this.props.trajectory.requireInertial(); | |
| if (this.usePathGraphicForOrbit) { | |
| this.createOrbitPath(); | |
| } else { | |
| this.createOrbitPolylineGeometry(); | |
| } | |
| } | |
| /** | |
| * Whether the Orbit component matches how it should currently be drawn. | |
| * | |
| * The non-path branch used to be checked against `Primitive`, which is what | |
| * the never-called `createOrbitPolylinePrimitive` would have stored — | |
| * `createOrbitPolylineGeometry` stores a GeometryInstance, so the check was | |
| * permanently false and every track change tore down and rebuilt the orbit of | |
| * every untracked satellite in 3D, each rebuild costing a full batch rebuild. | |
| */ | |
| isCorrectOrbitComponent(): boolean { | |
| return this.usePathGraphicForOrbit ? this.components.Orbit instanceof Entity : this.components.Orbit instanceof GeometryInstance; | |
| } | |
| get usePathGraphicForOrbit(): boolean { | |
| return orbitUsesPathGraphic(this.isTracked, this.viewer.scene.mode === SceneMode.SCENE3D); | |
| } | |
| createOrbitPath(): void { | |
| const path = new PathGraphics({ | |
| ...orbitPathTimes(this.props.orbit.orbitalPeriod), | |
| material: Color.WHITE.withAlpha(0.15), | |
| resolution: 600, | |
| width: 2, | |
| }); | |
| this.createCesiumEntity("Orbit", "path", path, this.props.name, this.props.trajectory.inertial, true); | |
| } | |
| /** The orbit as a geometry for the shared batch — how every untracked orbit is drawn in 3D. */ | |
| createOrbitPolylineGeometry(): void { | |
| const positions = this.props.trajectory.positionsForNextOrbit(this.viewer.clock.currentTime); | |
| if (positions.length < MIN_POLYLINE_POSITIONS) { | |
| return; | |
| } | |
| const geometryInstance = new GeometryInstance({ | |
| geometry: new PolylineGeometry({ | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| positions: positions as any, | |
| width: 2, | |
| arcType: ArcType.NONE, | |
| vertexFormat: PolylineColorAppearance.VERTEX_FORMAT, | |
| }), | |
| attributes: { | |
| color: ColorGeometryInstanceAttribute.fromColor(new Color(1.0, 1.0, 1.0, 0.15)), | |
| }, | |
| id: this.props.name, | |
| }); | |
| this.components.Orbit = geometryInstance; | |
| } | |
| createOrbitTrack(): void { | |
| if (this.usePathGraphicForOrbitTrack) { | |
| this.createOrbitTrackPath(); | |
| } else { | |
| this.createOrbitTrackPolylineGeometry(); | |
| } | |
| } | |
| /** | |
| * Whether the Orbit track is currently drawn the way it should be — the same | |
| * question `isCorrectOrbitComponent` asks of the Orbit, and for the same | |
| * reason: tracking a satellite changes the answer, so the track has to be torn | |
| * down and rebuilt when it does. | |
| */ | |
| isCorrectOrbitTrackComponent(): boolean { | |
| return this.usePathGraphicForOrbitTrack ? this.components["Orbit track"] instanceof Entity : this.components["Orbit track"] instanceof GeometryInstance; | |
| } | |
| get usePathGraphicForOrbitTrack(): boolean { | |
| return orbitUsesPathGraphic(this.isTracked, this.viewer.scene.mode === SceneMode.SCENE3D); | |
| } | |
| /** | |
| * The exact track, resampled every frame by Cesium's PathVisualizer. | |
| * | |
| * Reserved for the tracked satellite, which is the one the camera is sitting | |
| * on and the only one whose head anyone can see move. It costs about 60 µs a | |
| * frame — irrelevant for one satellite, and 300 ms at five thousand, which is | |
| * what the batch below exists to avoid. | |
| */ | |
| createOrbitTrackPath(): void { | |
| const path = new PathGraphics({ | |
| ...orbitTrackTimes(this.props.orbit.orbitalPeriod), | |
| material: Color.GOLD.withAlpha(0.15), | |
| resolution: 600, | |
| width: 2, | |
| }); | |
| // The sampled property, so PathVisualizer sub-samples at the stored sample | |
| // times rather than at `resolution`. Asking is what brings it into being — | |
| // only the tracked satellite and the non-3D scene modes draw a path. | |
| this.props.trajectory.requireSampled(); | |
| this.createCesiumEntity("Orbit track", "path", path, this.props.name, this.props.trajectory.fixed, true); | |
| } | |
| /** The track as a geometry for the shared batch — how every untracked track is drawn in 3D. */ | |
| createOrbitTrackPolylineGeometry(): void { | |
| const geometry = this.#orbitTrackGeometry(this.viewer.clock.currentTime); | |
| if (geometry) { | |
| this.components["Orbit track"] = geometry; | |
| } | |
| } | |
| #orbitTrackGeometry(time: JulianDate): GeometryInstance | undefined { | |
| const positions = this.props.trajectory.positionsForTrack(time); | |
| if (positions.length < MIN_POLYLINE_POSITIONS) { | |
| return undefined; | |
| } | |
| return new GeometryInstance({ | |
| geometry: new PolylineGeometry({ | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| positions: positions as any, | |
| width: 2, | |
| arcType: ArcType.NONE, | |
| vertexFormat: PolylineColorAppearance.VERTEX_FORMAT, | |
| }), | |
| attributes: { | |
| color: ColorGeometryInstanceAttribute.fromColor(Color.GOLD.withAlpha(0.15)), | |
| }, | |
| id: this.props.name, | |
| }); | |
| } | |
| /** | |
| * Re-cut the batched track so its head sits back on the satellite. | |
| * | |
| * A fixed-frame track goes stale as the clock runs — the satellite advances | |
| * along a line that does not move with it — so unlike the inertial orbit there | |
| * is no model matrix that keeps it current and the geometry has to be rebuilt. | |
| * Cheap enough to do on a timer because the batch coalesces: five thousand | |
| * calls to `replace` cost one primitive rebuild, not five thousand. | |
| * | |
| * A no-op for the tracked satellite, whose track is a PathGraphic that Cesium | |
| * already keeps exact, and for anything not currently in the batch. | |
| */ | |
| refreshOrbitTrack(time: JulianDate): void { | |
| const current = this.#components["Orbit track"]; | |
| if (!(current instanceof GeometryInstance)) { | |
| return; | |
| } | |
| const next = this.#orbitTrackGeometry(time); | |
| if (next && this.#tracks.replace(current, next)) { | |
| this.#components["Orbit track"] = next; | |
| } | |
| } | |
| /** | |
| * The swath corridor under the satellite, as *constant* positions re-assigned | |
| * on a timer rather than a CallbackProperty read every frame. | |
| * | |
| * A CallbackProperty that reports itself non-constant puts the corridor on | |
| * Cesium's dynamic-geometry path, and that path re-tessellates the geometry | |
| * and recreates its ground primitive every single frame — for every satellite | |
| * that has one. Measured at about 90 µs per drawn corridor per frame, which is | |
| * 414 ms of main thread at five thousand satellites, and it bought nothing: | |
| * the callback returns two positions 300 s apart, so the shape it was rebuilt | |
| * from barely moved between one frame and the next. | |
| * | |
| * Constant positions put it back on the static path, where the geometry is | |
| * only rebuilt when the property actually changes — which is now `refreshGroundTrack`, | |
| * on the same schedule as the batched orbit tracks. | |
| */ | |
| createGroundTrack(): void { | |
| const description = groundTrackDescription(this.props.orbitClass, this.props.swath); | |
| if (!description) { | |
| return; | |
| } | |
| const positions = this.#groundTrackPositions(this.viewer.clock.currentTime); | |
| // The same check `refreshGroundTrack` makes, and for the same reason: a | |
| // corridor Cesium cannot build geometry from takes the render loop down with | |
| // it. | |
| if (positions.length < 2) { | |
| return; | |
| } | |
| const corridor = new CorridorGraphics({ | |
| cornerType: CornerType.MITERED, | |
| height: 1000, | |
| heightReference: HeightReference.CLAMP_TO_GROUND, | |
| material: Color.DARKRED.withAlpha(0.25), | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| positions: positions as any, | |
| width: description.widthMeters, | |
| }); | |
| this.createCesiumSatelliteEntity("Ground track", "corridor", corridor); | |
| } | |
| /** | |
| * The ground track as positions a corridor can be built from. | |
| * | |
| * Holes are dropped because the sampled position has no value outside its | |
| * window and a corridor handed one throws from inside Cesium's geometry worker. | |
| * Duplicates are dropped because the corridor collapses them itself and then | |
| * declines to build anything, which costs more — see `drawablePositions`. | |
| * | |
| * Duplicates are the case that arrives. Outside the sample window | |
| * `GridPositionProperty` clamps its stencil to the window's edge by design, so | |
| * every time beyond it reads back the same edge sample: two instants 300 s apart | |
| * answer with one point. Not `ExtrapolationType` — that governs the sampled | |
| * property, which most satellites never build. | |
| */ | |
| #groundTrackPositions(time: JulianDate): Cartesian3[] { | |
| return drawablePositions(this.props.trajectory.groundTrack(time)); | |
| } | |
| /** See createGroundTrack. */ | |
| refreshGroundTrack(time: JulianDate): void { | |
| const entity = this.#components["Ground track"]; | |
| if (!(entity instanceof Entity) || !entity.corridor) { | |
| return; | |
| } | |
| const positions = this.#groundTrackPositions(time); | |
| if (positions.length < 2) { | |
| return; | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| entity.corridor.positions = positions as any; | |
| } | |
| /** | |
| * Whether the corridor Cesium is drawing has caught up with the positions it | |
| * was last handed, or undefined when there is nothing to ask — no ground track | |
| * on this satellite, or no way to ask about one. | |
| * | |
| * Worth asking because the rebuild takes a number of frames that varies with | |
| * the size of the batch, so any fixed schedule is either slower than it needs | |
| * to be or fast enough to discard an unfinished rebuild. See SatelliteManager's | |
| * GROUND_TRACK_REFRESH_FRAMES for what the second of those does. | |
| * | |
| * `getBoundingSphere` is what Cesium itself calls once a frame to decide | |
| * whether the tracked entity can be followed yet, and it reports PENDING for | |
| * as long as the batch primitive behind the entity is unfinished — measured as | |
| * landing one frame before the new corridor is drawn. Cesium marks it private | |
| * and leaves it out of its type declarations, hence the cast and the check: if | |
| * it goes away the caller falls back to a fixed schedule rather than silently | |
| * never waiting again. | |
| */ | |
| groundTrackSettled(): boolean | undefined { | |
| const entity = this.#components["Ground track"]; | |
| if (!(entity instanceof Entity) || !entity.corridor) { | |
| return undefined; | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const display = this.viewer.dataSourceDisplay as any; | |
| if (typeof display?.getBoundingSphere !== "function") { | |
| SatelliteComponentCollection.#reportMissingBoundingSphere(); | |
| return undefined; | |
| } | |
| return display.getBoundingSphere(entity, false, SatelliteComponentCollection.#sphereScratch) !== BOUNDING_SPHERE_PENDING; | |
| } | |
| createCone(fov = this.props.coneFovDeg): void { | |
| const description = coneDescription(this.props.orbitClass, fov); | |
| if (!description) { | |
| return; | |
| } | |
| const entity = new Entity(); | |
| entity.addProperty("conicSensor"); | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| (entity as any).conicSensor = new CesiumSensorVolumes.ConicSensorGraphics({ | |
| radius: description.radiusMeters, | |
| innerHalfAngle: description.innerHalfAngleRad, | |
| outerHalfAngle: description.outerHalfAngleRad, | |
| lateralSurfaceMaterial: Color.GOLD.withAlpha(0.15), | |
| intersectionColor: Color.GOLD.withAlpha(0.3), | |
| intersectionWidth: 1, | |
| }); | |
| this.components["Sensor cone"] = entity; | |
| } | |
| createGroundStationLink(): void { | |
| if (!this.props.passPredictor.groundStationAvailable) { | |
| return; | |
| } | |
| const polyline = new PolylineGraphics({ | |
| material: new PolylineGlowMaterialProperty({ | |
| glowPower: 0.5, | |
| color: Color.FORESTGREEN, | |
| }), | |
| positions: new CallbackProperty((time?: JulianDate) => { | |
| const satPosition = this.props.trajectory.position(time as JulianDate); | |
| const groundPosition = this.activeGroundStationCartesian(time as JulianDate); | |
| return [satPosition, groundPosition]; | |
| }, false), | |
| show: new CallbackProperty((time?: JulianDate) => this.props.passPredictor.passIntervals.contains(time as JulianDate), false), | |
| width: 5, | |
| }); | |
| this.createCesiumSatelliteEntity("Ground station link", "polyline", polyline); | |
| } | |
| /** | |
| * Resolve the cartesian endpoint for the ground-station link at the given time. | |
| * | |
| * The polyline is only shown during a pass (see `show` callback), so we find the | |
| * pass that contains `time` and look up the ground station that recorded it. | |
| * Falls back to the first ground station if no active pass is found (e.g. when | |
| * Cesium evaluates the positions callback outside of any pass interval). | |
| */ | |
| private activeGroundStationCartesian(time: JulianDate): Cartesian3 | undefined { | |
| const groundStations = this.props.passPredictor.groundStations; | |
| if (groundStations.length === 0) { | |
| return undefined; | |
| } | |
| const timeMs = JulianDate.toDate(time).getTime(); | |
| const activePass = this.props.passPredictor.passes(time).find((pass) => timeMs >= pass.start && timeMs <= pass.end); | |
| const target = (activePass && groundStations.find((gs) => gs.name === activePass.groundStationName)) ?? groundStations[0]; | |
| if (!target) { | |
| return undefined; | |
| } | |
| return Cartesian3.fromDegrees(target.position.longitude, target.position.latitude, target.position.height); | |
| } | |
| set groundStations(groundStations: GroundStation[]) { | |
| // No groundstation calculation for GEO satellites | |
| if (this.props.orbit.orbitalPeriod > 60 * 12) { | |
| return; | |
| } | |
| // The setter clears the predictor's window; ask for the new one now so | |
| // pass-dependent visuals update without waiting for a read. The answer is | |
| // off-thread, so it arrives via the listener rather than here. | |
| this.props.passPredictor.groundStations = groundStations; | |
| if (this.isSelected || this.isTracked) { | |
| this.#highlightPasses(); | |
| } | |
| if (this.created) { | |
| this.createGroundStationLink(); | |
| } | |
| } | |
| } | |