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; /** * `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 = {}; /** What a click or a track acts on — the first Entity to be created. */ defaultEntity: Entity | undefined; eventListeners: Record 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 { 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 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(); } } }