Spaces:
Configuration error
Configuration error
| import { | |
| Cartesian3, | |
| ExtrapolationType, | |
| JulianDate, | |
| LagrangePolynomialApproximation, | |
| Matrix3, | |
| ReferenceFrame, | |
| SampledPositionProperty, | |
| TimeInterval, | |
| Transforms, | |
| defined, | |
| } from "@cesium/engine"; | |
| import type { InterpolationAlgorithm } from "@cesium/engine"; | |
| import type { Viewer } from "@cesium/widgets"; | |
| import type Orbit from "./Orbit"; | |
| import "./util/CesiumSampledPositionRawValueAccess"; | |
| import { CesiumCallbackHelper } from "./util/CesiumCallbackHelper"; | |
| import { drawablePositions } from "./util/drawablePositions"; | |
| import { GridPositionProperty } from "./util/GridPositionProperty"; | |
| import type { SampleChunk, TrajectorySampler } from "./util/sampleSource"; | |
| import { trajectoryWindow } from "./util/trajectoryWindow"; | |
| // Cesium 1.143 widened the InterpolationAlgorithm interface (type/interpolate) without | |
| // updating the LagrangePolynomialApproximation namespace declaration; the runtime object | |
| // satisfies the interface, so bridge the upstream typings gap with a cast. | |
| const lagrangeInterpolation = LagrangePolynomialApproximation as unknown as InterpolationAlgorithm; | |
| interface SampledPositionData { | |
| interval: TimeInterval; | |
| /** | |
| * Absent until a path graphic asks for it, exactly like `inertial`. The grid is | |
| * what everything else reads, and a `SampledPositionProperty` over the same | |
| * window is expensive in a way that is easy to miss: a `JulianDate` object per | |
| * sample, and 241 of those per satellite. Measured at 13.2 KB a satellite — 66 MB | |
| * across five thousand. See `requireSampled`. | |
| */ | |
| fixed: SampledPositionProperty | undefined; | |
| /** | |
| * Absent until something asks for it. Only the Orbit component reads the | |
| * inertial frame, and carrying a second full sample set for every satellite in | |
| * a scene that never draws one measured 8.7 KB a satellite — 43 MB across five | |
| * thousand. See `requireInertial`. | |
| */ | |
| inertial: SampledPositionProperty | undefined; | |
| valid: boolean; | |
| } | |
| /** | |
| * The single owner of the sampled position for one satellite: the sliding | |
| * sample window (half an orbit back, 1.5 forward), gap-filling and eviction | |
| * as time advances, and the fixed/inertial frame duality. | |
| * | |
| * Consumers subscribe via `start()` and read positions through the accessors; | |
| * nothing outside this module touches the sample bookkeeping. | |
| */ | |
| export class SampledTrajectory { | |
| #orbit: Orbit; | |
| #data: SampledPositionData | undefined; | |
| /** See requireInertial. */ | |
| #wantsInertial = false; | |
| /** See requireSampled. */ | |
| #wantsSampled = false; | |
| /** | |
| * Where samples come from. Injected rather than reached for, so this class has | |
| * no opinion about whether propagation happens on a worker — see sampleSource. | |
| */ | |
| readonly #sampler: TrajectorySampler; | |
| /** | |
| * The same fixed-frame samples again, on a uniform grid, for entities to read. | |
| * | |
| * Entities are evaluated once each per frame and that evaluation was the largest | |
| * single cost in a large scene — measured at 5,000 satellites, halving | |
| * `dataSourceDisplay.update` from 12.0 ms to 5.1 and taking the frame rate from | |
| * 71 to 97. See GridPositionProperty for why, and for the caveats. | |
| * | |
| * The authoritative store, and the only one most satellites have: `fixed` and | |
| * `inertial` are both built on demand from this. It is also the smaller of the | |
| * two, because it derives sample times from the anchor instead of keeping a | |
| * `JulianDate` object per sample — GridPositionProperty has the figures. | |
| */ | |
| #gridFixed = new GridPositionProperty(); | |
| /** | |
| * False when a chunk arrived with samples the propagator refused. The grid read | |
| * depends on there being no holes in it, so such a satellite falls back to the | |
| * sampled property — correct, merely slower. Measured across the live catalog | |
| * this has never fired. | |
| */ | |
| #gridUsable = true; | |
| /** The fill in flight, if any. At most one — see `ensure`. */ | |
| #filling: Promise<void> | undefined; | |
| /** The time a coalesced tick asked about, to be honoured once the fill lands. */ | |
| #pendingTime: JulianDate | undefined; | |
| /** | |
| * Set once `start`'s teardown has run. | |
| * | |
| * A separate flag rather than `!this.#data`, because the branch that needs it — | |
| * the whole-window fill — is entered precisely when `#data` is already undefined, | |
| * so that test cannot tell "never had a window" from "window taken away". | |
| */ | |
| #stopped = false; | |
| constructor(orbit: Orbit, sampler: TrajectorySampler) { | |
| this.#orbit = orbit; | |
| this.#sampler = sampler; | |
| } | |
| /** Whether samples exist and propagation has not failed. */ | |
| get valid(): boolean { | |
| return this.#data?.valid ?? false; | |
| } | |
| /** | |
| * Fixed-frame samples, irregular-capable. What path graphics need: Cesium's | |
| * PathVisualizer sub-samples a `SampledPositionProperty` at its stored sample | |
| * times and anything else at `resolution`, which would be far coarser. | |
| * | |
| * Call `requireSampled` first — like `inertial`, this returns undefined on a | |
| * trajectory nothing has asked for it on rather than quietly building one. | |
| */ | |
| get fixed(): SampledPositionProperty | undefined { | |
| return this.#data?.fixed; | |
| } | |
| /** | |
| * Declare that the irregular-capable property is needed, and make it so. | |
| * | |
| * The same shape as `requireInertial`: a flag so every later refresh feeds it, | |
| * and a backfill from the grid so a window already up is not re-propagated. The | |
| * backfill is the grid's own samples with times derived from the anchor, so it | |
| * costs no SGP4 and no frame transforms. | |
| */ | |
| requireSampled(): void { | |
| if (this.#wantsSampled) { | |
| return; | |
| } | |
| this.#wantsSampled = true; | |
| this.#backfillSampled(); | |
| } | |
| #backfillSampled(): void { | |
| const data = this.#data; | |
| if (!data || data.fixed) { | |
| return; | |
| } | |
| const fixed = SampledTrajectory.#createProperty(ReferenceFrame.FIXED); | |
| const { times, positions } = this.#windowSamples(); | |
| if (times.length > 0) { | |
| fixed.addSamples(times, positions); | |
| } | |
| data.fixed = fixed; | |
| } | |
| /** | |
| * The window's samples, from whichever store currently owns them. | |
| * | |
| * Normally the grid. Once a gap has made the grid unusable it is abandoned and | |
| * cleared, and the sampled property is the only complete record — reading the | |
| * grid then would hand back the window it happened to stop at, which is a | |
| * position for the wrong time rather than a missing one. | |
| */ | |
| #windowSamples(): { times: JulianDate[]; positions: Cartesian3[] } { | |
| if (this.#gridUsable && this.#gridFixed.length > 0) { | |
| return this.#gridFixed.allSamples(); | |
| } | |
| const fixed = this.#data?.fixed; | |
| if (!fixed) { | |
| return { times: [], positions: [] }; | |
| } | |
| const { times, values } = fixed.getRawSamples(); | |
| return { times, positions: values as Cartesian3[] }; | |
| } | |
| /** | |
| * How many samples the window holds, whichever store owns them. | |
| * | |
| * Exists so a caller can ask about the window without first working out which | |
| * property is live — and because the two stores spell it differently (`length` a | |
| * getter here, `length()` a method on Cesium's). | |
| */ | |
| get sampleCount(): number { | |
| if (this.#gridUsable && this.#gridFixed.length > 0) { | |
| return this.#gridFixed.length; | |
| } | |
| return this.#data?.fixed?.length() ?? 0; | |
| } | |
| /** Positions between two instants, from whichever store owns them. See `#windowSamples`. */ | |
| #positionsBetween(start: JulianDate, end: JulianDate): Cartesian3[] { | |
| if (this.#gridUsable && this.#gridFixed.length > 0) { | |
| return this.#gridFixed.samplesBetween(start, end).positions; | |
| } | |
| const fixed = this.#data?.fixed; | |
| return fixed ? (fixed.getRawValues(start, end) as Cartesian3[]) : []; | |
| } | |
| /** | |
| * What an entity should bind its position to — the grid property where usable. | |
| * | |
| * Everything that only ever asks "where is it now" goes through here. Path | |
| * graphics deliberately do not; see `fixed`. | |
| */ | |
| get entityPosition(): GridPositionProperty | SampledPositionProperty | undefined { | |
| if (!this.#data) { | |
| return undefined; | |
| } | |
| return this.#gridUsable && this.#gridFixed.length > 0 ? this.#gridFixed : this.#data.fixed; | |
| } | |
| /** | |
| * Inertial-frame (ICRF) sampled position for orbit visualization. | |
| * | |
| * Call `requireInertial` first. Reading this without doing so returns undefined | |
| * on a trajectory that has never been asked for the inertial frame, rather than | |
| * quietly building one — the point of the flag is that the cost is opted into. | |
| */ | |
| get inertial(): SampledPositionProperty | undefined { | |
| return this.#data?.inertial; | |
| } | |
| /** | |
| * Declare that the inertial frame is needed, and make it so. | |
| * | |
| * Idempotent, and safe to call before or after `start`: the flag makes every | |
| * later refresh sample both frames, and if a window is already up its inertial | |
| * half is backfilled from the fixed samples already in it. That backfill is a | |
| * frame transform per sample and no SGP4 — the propagation has already been | |
| * paid for, and only the rotation into ICRF is missing. | |
| */ | |
| requireInertial(): void { | |
| if (this.#wantsInertial) { | |
| return; | |
| } | |
| this.#wantsInertial = true; | |
| this.#backfillInertial(); | |
| } | |
| #backfillInertial(): void { | |
| const data = this.#data; | |
| if (!data || data.inertial) { | |
| return; | |
| } | |
| const inertial = SampledTrajectory.#createProperty(ReferenceFrame.INERTIAL); | |
| // From the grid, which always holds the window; the sampled property may not | |
| // exist at all, and when it does it holds the same samples anyway. | |
| const { times, positions: values } = this.#windowSamples(); | |
| const positions: Cartesian3[] = []; | |
| const kept: JulianDate[] = []; | |
| for (const [index, time] of times.entries()) { | |
| const fixedToIcrf = Transforms.computeFixedToIcrfMatrix(time); | |
| if (!defined(fixedToIcrf)) { | |
| continue; | |
| } | |
| kept.push(time); | |
| positions.push(Matrix3.multiplyByVector(fixedToIcrf, values[index] as Cartesian3, new Cartesian3())); | |
| } | |
| if (kept.length > 0) { | |
| inertial.addSamples(kept, positions); | |
| } | |
| data.inertial = inertial; | |
| } | |
| /** The time interval currently covered by samples. */ | |
| get interval(): TimeInterval | undefined { | |
| return this.#data?.interval; | |
| } | |
| /** | |
| * Fixed-frame position at `time`, interpolated from the samples. | |
| * | |
| * Through `entityPosition`, because the callers are the same shape as an entity: | |
| * the sky HUD and the sensor cone's orientation both ask this once per frame. | |
| */ | |
| position(time: JulianDate): Cartesian3 | undefined { | |
| return this.entityPosition?.getValue(time); | |
| } | |
| positionsForNextOrbit(start: JulianDate, reference: "inertial" | "fixed" = "inertial", loop = true): unknown[] { | |
| if (!this.#data) return []; | |
| const end = JulianDate.addSeconds(start, this.#orbit.orbitalPeriod * 60, new JulianDate()); | |
| let positions: unknown[]; | |
| if (reference === "fixed") { | |
| // The grid holds the same samples and always exists, so asking for the | |
| // Earth-relative path does not drag a sampled property into being. | |
| positions = this.#positionsBetween(start, end); | |
| } else { | |
| // Asking for the inertial frame is the declaration itself. | |
| this.requireInertial(); | |
| const inertial = this.#data.inertial; | |
| if (!inertial) return []; | |
| positions = inertial.getRawValues(start, end); | |
| } | |
| if (positions.length === 0) return []; | |
| if (loop) { | |
| // Repeating the first sample is what closes the orbit, rather than leaving | |
| // a gap at the seam. | |
| return [...positions, positions[0]]; | |
| } | |
| return positions; | |
| } | |
| /** | |
| * The Earth-relative path one full orbit ahead of `start`, for the Orbit track. | |
| * | |
| * The raw stored samples rather than a resampling: they are already there, and | |
| * at 120 a revolution they draw a track no coarser than the position the | |
| * satellite is itself interpolated from. Only the head is computed, because | |
| * the first stored sample can sit up to a sampling interval (about 45 s, some | |
| * 350 km) ahead of the satellite, and a gold line that visibly starts in front | |
| * of the point it belongs to is the one artefact of batching that a viewer | |
| * would read as a bug rather than as a level of detail. | |
| */ | |
| positionsForTrack(start: JulianDate): Cartesian3[] { | |
| if (!this.#data) return []; | |
| const end = JulianDate.addSeconds(start, this.#orbit.orbitalPeriod * 60, new JulianDate()); | |
| const head = this.position(start); | |
| const samples = this.#positionsBetween(start, end); | |
| // A head with nothing behind it — a clock jump, or the moment after a gap | |
| // abandoned the grid — is one point, not a track, so the caller's below-two | |
| // check skips the component until the next re-cut, once the refill lands. | |
| // | |
| // Not `[head, head]` to get past `PolylineGeometry`'s below-two-positions | |
| // throw: the duplicate collapses to `undefined` geometry, which costs more | |
| // than the missing component. See `drawablePositions`. | |
| return drawablePositions(head ? [head, ...samples] : samples); | |
| } | |
| groundTrack(julianDate: JulianDate, samplesFwd = 1, samplesBwd = 0, interval = 300): (Cartesian3 | undefined)[] { | |
| const groundTrack: (Cartesian3 | undefined)[] = []; | |
| const startTime = -samplesBwd * interval; | |
| const stopTime = samplesFwd * interval; | |
| for (let time = startTime; time <= stopTime; time += interval) { | |
| const timestamp = JulianDate.addSeconds(julianDate, time, new JulianDate()); | |
| groundTrack.push(this.position(timestamp)); | |
| } | |
| return groundTrack; | |
| } | |
| /** | |
| * Take an opening window fetched before this trajectory existed. | |
| * | |
| * The build fetches it, because a satellite is only worth constructing once its | |
| * samples are in hand — and constructing it is `sgp4init`, which belongs inside | |
| * the build's frame budget rather than in a loop over the whole activation. | |
| * | |
| * The interval is the chunk's own extent rather than the policy window: the | |
| * first `ensure` computes that and tops up the difference. | |
| */ | |
| adopt(chunk: SampleChunk): void { | |
| const sampleCount = Math.floor(chunk.positionsFixed.length / 3); | |
| if (this.#data || sampleCount === 0) { | |
| return; | |
| } | |
| const start = SampledTrajectory.#sampleTime(chunk, 0); | |
| const stop = SampledTrajectory.#sampleTime(chunk, sampleCount - 1); | |
| this.#init(start); | |
| this.#applyChunk(chunk); | |
| this.#setInterval(new TimeInterval({ start, stop })); | |
| } | |
| /** | |
| * Make sure the window covers `time`, requesting whatever is missing. | |
| * | |
| * The single way samples ever enter this class. Awaitable because the samples | |
| * come from somewhere else now: the build awaits the first call so a satellite | |
| * is only shown once it has a position, and the periodic top-up does not await | |
| * at all — the window runs one and a half revolutions ahead of the clock, so a | |
| * reply arriving a few frames late is invisible. | |
| * | |
| * The requested bounds are deliberately approximate. They come from this | |
| * satellite's own period, which differs slightly from the one the sampler | |
| * derives, and it does not matter: the sampler answers on a grid anchored to the | |
| * element set's epoch, so a window a few seconds wider or narrower changes which | |
| * samples come back but never where they sit in time. | |
| * | |
| * At most one request is outstanding at a time. Without that, a tick arriving | |
| * before the previous reply computed the same missing range and asked for it | |
| * again: measured at 5,000 satellites and ×10000, the sampler was producing | |
| * 2.5 million samples a second where the window needs about 1.1 million. | |
| */ | |
| ensure(time: JulianDate): Promise<void> { | |
| if (this.#filling) { | |
| // A tick arrived while a request was already out. Neither queue it — at a | |
| // fast clock the ticks outrun the replies and the queue only grows — nor | |
| // drop it, which would lose a clock that jumped mid-request. Remember the | |
| // latest time and re-run once, when the current fill lands. | |
| this.#pendingTime = time; | |
| return this.#filling; | |
| } | |
| this.#filling = this.#fill(time).finally(() => { | |
| this.#filling = undefined; | |
| const pending = this.#pendingTime; | |
| this.#pendingTime = undefined; | |
| if (pending) { | |
| void this.ensure(pending); | |
| } | |
| }); | |
| return this.#filling; | |
| } | |
| async #fill(time: JulianDate): Promise<void> { | |
| const window = trajectoryWindow(this.#orbit.orbitalPeriod); | |
| if (window.sampleCount === 0) { | |
| return; | |
| } | |
| const request = new TimeInterval({ | |
| start: JulianDate.addSeconds(time, window.offsetSeconds, new JulianDate()), | |
| stop: JulianDate.addSeconds(time, window.offsetSeconds + window.spanSeconds, new JulianDate()), | |
| }); | |
| // Nothing yet, or the clock has jumped clear of what is held: one request for | |
| // the whole window rather than two for its edges. | |
| if (!this.#data || !TimeInterval.contains(this.#data.interval, time)) { | |
| const chunk = await this.#sampler.samples(JulianDate.toDate(request.start).getTime(), JulianDate.toDate(request.stop).getTime()); | |
| // Torn down while the request was in flight — the same check the two-chunk | |
| // path below makes. Without it `#init` rebuilt `#data` after `start`'s | |
| // teardown had cleared it, leaving a disposed trajectory reporting itself | |
| // valid, with a fresh grid buffer and nothing left to refresh it. | |
| if (!chunk || chunk.positionsFixed.length === 0 || this.#stopped) { | |
| return; | |
| } | |
| this.#init(request.start); | |
| this.#applyChunk(chunk); | |
| this.#setInterval(request); | |
| return; | |
| } | |
| const held = this.#data.interval; | |
| const missingBefore = JulianDate.secondsDifference(held.start, request.start) > 0; | |
| const missingAfter = JulianDate.secondsDifference(request.stop, held.stop) > 0; | |
| const chunks = await Promise.all([ | |
| missingBefore ? this.#sampler.samples(JulianDate.toDate(request.start).getTime(), JulianDate.toDate(held.start).getTime()) : undefined, | |
| missingAfter ? this.#sampler.samples(JulianDate.toDate(held.stop).getTime(), JulianDate.toDate(request.stop).getTime()) : undefined, | |
| ]); | |
| // Torn down while the request was in flight. | |
| if (!this.#data) { | |
| return; | |
| } | |
| for (const chunk of chunks) { | |
| if (chunk) this.#applyChunk(chunk); | |
| } | |
| this.#evict(request); | |
| this.#data.interval = request; | |
| } | |
| /** | |
| * File one chunk of fixed-frame samples. | |
| * | |
| * The chunk arrives already rotated — the sampler does that leg, because it needs | |
| * no Cesium (see sgp4Worker and temeToFixed) — so for a satellite with neither | |
| * sampled property this method is a typed-array copy into the grid and nothing | |
| * else: no allocation, no per-sample arithmetic, no Cesium call. That is the case | |
| * almost every satellite is in. | |
| * | |
| * The rest exists for the two properties built on demand. ICRF is still Cesium's, | |
| * because `computeFixedToIcrfMatrix` rests on IAU data that only the main thread | |
| * holds, so it is charged to the trajectories that draw an orbit. | |
| * | |
| * Refused samples are skipped, not re-propagated — retrying would run the same | |
| * propagator on the same instant and fail the same way, and a sampled position | |
| * property interpolates across the gap. | |
| */ | |
| #applyChunk(chunk: SampleChunk): void { | |
| const data = this.#data; | |
| if (!data) { | |
| return; | |
| } | |
| const arrived = chunk.positionsFixed; | |
| const sampleCount = Math.floor(arrived.length / 3); | |
| const refused = chunk.refusedIndices.length > 0 ? new Set(chunk.refusedIndices) : undefined; | |
| const inertialProperty = data.inertial; | |
| // Refusals take the slow path too, because the gap branch below hands this | |
| // chunk to a sampled property it has just created, which needs the pairs. | |
| if (inertialProperty === undefined && data.fixed === undefined && refused === undefined) { | |
| if (sampleCount > 0) { | |
| this.#addToGrid(chunk, arrived, false); | |
| } | |
| return; | |
| } | |
| const anchor = SampledTrajectory.#chunkAnchor(chunk); | |
| const sampledTimes: JulianDate[] = []; | |
| const sampledPositions: Cartesian3[] = []; | |
| const sampledInertial: Cartesian3[] = []; | |
| const keptPositions = new Float64Array(sampleCount * 3); | |
| let kept = 0; | |
| for (let index = 0; index < sampleCount; index += 1) { | |
| if (refused?.has(index)) { | |
| continue; | |
| } | |
| const offset = index * 3; | |
| const time = SampledTrajectory.#sampleTimeFrom(anchor, chunk, index); | |
| const position = new Cartesian3(arrived[offset] as number, arrived[offset + 1] as number, arrived[offset + 2] as number); | |
| if (inertialProperty) { | |
| const fixedToIcrf = Transforms.computeFixedToIcrfMatrix(time); | |
| if (!defined(fixedToIcrf)) { | |
| // Reported once per trajectory rather than once per sample: a window is | |
| // a couple of hundred of these and the cause is the same for all of them. | |
| if (data.valid) { | |
| console.error("Reference frame transformation data failed to load"); | |
| data.valid = false; | |
| } | |
| continue; | |
| } | |
| sampledInertial.push(Matrix3.multiplyByVector(fixedToIcrf, position, new Cartesian3())); | |
| } | |
| sampledTimes.push(time); | |
| sampledPositions.push(position); | |
| const at = kept * 3; | |
| keptPositions[at] = position.x; | |
| keptPositions[at + 1] = position.y; | |
| keptPositions[at + 2] = position.z; | |
| kept += 1; | |
| } | |
| if (kept === 0) { | |
| return; | |
| } | |
| // The grid first, and only then the sampled property: a gap here forces the | |
| // sampled property into being, and its backfill reads the grid as it was | |
| // before this chunk. Any shortfall counts as a gap, not just a refusal — a | |
| // missing ICRF transform drops a sample the same way, and the grid's indices | |
| // only line up with the chunk's when nothing was dropped. | |
| // | |
| // `keptPositions` is handed over whole rather than sliced to `kept`: a | |
| // shortfall is a gap, and the gap branch abandons the grid without reading the | |
| // buffer at all, so the only call that reads it is the one where the two are | |
| // the same length. | |
| this.#addToGrid(chunk, keptPositions, kept !== sampleCount); | |
| // Added at once: a sorted array avoids a search per sample. | |
| this.#data?.fixed?.addSamples(sampledTimes, sampledPositions); | |
| inertialProperty?.addSamples(sampledTimes, sampledInertial); | |
| } | |
| /** | |
| * Mirror a chunk into the grid property. | |
| * | |
| * A gap makes the grid unusable rather than approximated: the grid read assumes | |
| * no holes, and closing one by interpolating across a 45 s gap would put the | |
| * satellite kilometres out at that instant. Such a satellite reads from the | |
| * sampled property instead, which is slower and correct — and unusable is | |
| * permanent, because the samples that would have filled the hole are not coming. | |
| * The next refresh rebinds the entities (see updatedSampledPositionForComponents). | |
| */ | |
| #addToGrid(chunk: SampleChunk, fixedFlat: Float64Array, hadGaps: boolean): void { | |
| if (!this.#gridUsable) { | |
| return; | |
| } | |
| if (hadGaps) { | |
| // Order is the whole of it. The grid is still the authoritative record at | |
| // this instant and the backfill reads whichever store is authoritative, so | |
| // asking for the sampled property has to happen *before* the grid is | |
| // disowned — flipping the flag first made `#windowSamples` skip the grid, | |
| // find a `fixed` that did not exist yet, and hand back nothing, leaving the | |
| // new property holding only this chunk. Then let the buffer go: an abandoned | |
| // grid that keeps its samples is a window frozen where it was abandoned. | |
| this.requireSampled(); | |
| this.#gridUsable = false; | |
| this.#gridFixed.clear(); | |
| return; | |
| } | |
| if (!this.#gridFixed.isOnGrid(chunk.anchorEpochMs, chunk.stepSeconds)) { | |
| this.#gridFixed.reset(chunk.anchorEpochMs, chunk.stepSeconds); | |
| } | |
| if (!this.#gridFixed.add(chunk.firstIndex, fixedFlat)) { | |
| // Not contiguous with what is held — a clock jump landing between windows. | |
| // Start the grid again from this chunk rather than leave a hole in it. | |
| this.#gridFixed.reset(chunk.anchorEpochMs, chunk.stepSeconds); | |
| this.#gridFixed.add(chunk.firstIndex, fixedFlat); | |
| } | |
| } | |
| /** Its own method so the caller's narrowing of `#data` does not reach in here. */ | |
| #setInterval(interval: TimeInterval): void { | |
| if (this.#data) { | |
| this.#data.interval = interval; | |
| } | |
| } | |
| /** | |
| * The chunk's grid origin as a JulianDate. | |
| * | |
| * Hoisted out of the per-sample path deliberately. It is one value for the whole | |
| * chunk, and rebuilding it per sample meant a `Date` and a `JulianDate` allocated | |
| * for each of 241 samples per satellite — 1.2 million of each across a | |
| * 5,000-satellite build, which was the largest single slice of the window | |
| * handling. | |
| */ | |
| static #chunkAnchor(chunk: SampleChunk): JulianDate { | |
| return JulianDate.fromDate(new Date(chunk.anchorEpochMs)); | |
| } | |
| /** | |
| * The instant of one sample, from the grid rather than from the chunk's start. | |
| * | |
| * Every chunk for a satellite carries the same anchor, so the same grid index | |
| * always yields the same JulianDate — which is what stops two chunks from | |
| * placing one grid instant at two times a fraction of a millisecond apart. See | |
| * Sgp4Chunk.anchorEpochMs. | |
| */ | |
| static #sampleTimeFrom(anchor: JulianDate, chunk: SampleChunk, index: number): JulianDate { | |
| return JulianDate.addSeconds(anchor, (chunk.firstIndex + index) * chunk.stepSeconds, new JulianDate()); | |
| } | |
| /** The same instant, for the two callers that want one sample and not a run of them. */ | |
| static #sampleTime(chunk: SampleChunk, index: number): JulianDate { | |
| return SampledTrajectory.#sampleTimeFrom(SampledTrajectory.#chunkAnchor(chunk), chunk, index); | |
| } | |
| #evict(keep: TimeInterval): void { | |
| const data = this.#data; | |
| if (!data) { | |
| return; | |
| } | |
| const before = new TimeInterval({ start: JulianDate.fromIso8601("1957"), stop: keep.start, isStartIncluded: false, isStopIncluded: false }); | |
| const after = new TimeInterval({ start: keep.stop, stop: JulianDate.fromIso8601("2100"), isStartIncluded: false, isStopIncluded: false }); | |
| data.fixed?.removeSamples(before); | |
| data.fixed?.removeSamples(after); | |
| data.inertial?.removeSamples(before); | |
| data.inertial?.removeSamples(after); | |
| if (this.#gridFixed.length > 0) { | |
| this.#gridFixed.dropBefore(this.#gridFixed.indexAtOrAfter(keep.start)); | |
| this.#gridFixed.dropAfter(this.#gridFixed.indexAtOrAfter(keep.stop)); | |
| } | |
| } | |
| /** | |
| * Keep the window fresh, and hand back the teardown. | |
| * | |
| * The opening window is not filled here — the build awaits `ensure` before the | |
| * satellite is shown, so by the time this runs there is one. All this does is | |
| * arrange for the top-ups. | |
| */ | |
| start(viewer: Viewer, callback: () => void): () => void { | |
| callback(); | |
| const samplingRefreshRate = (this.#orbit.orbitalPeriod * 60) / 4; | |
| const removeCallback = CesiumCallbackHelper.createPeriodicTimeCallback(viewer, samplingRefreshRate, (time) => { | |
| void this.ensure(time).then(() => { | |
| // Torn down while the top-up was in flight. | |
| if (this.#data) callback(); | |
| }); | |
| }); | |
| return () => { | |
| removeCallback(); | |
| this.#stopped = true; | |
| this.#data = undefined; | |
| // So a fill still in flight does not schedule another one after teardown. | |
| this.#pendingTime = undefined; | |
| }; | |
| } | |
| /** Both frames want the same extrapolation and interpolation; only the frame differs. */ | |
| static #createProperty(referenceFrame?: ReferenceFrame): SampledPositionProperty { | |
| const property = new SampledPositionProperty(referenceFrame); | |
| property.backwardExtrapolationType = ExtrapolationType.HOLD; | |
| property.forwardExtrapolationType = ExtrapolationType.HOLD; | |
| property.setInterpolationOptions({ | |
| interpolationDegree: 5, | |
| interpolationAlgorithm: lagrangeInterpolation, | |
| }); | |
| return property; | |
| } | |
| #init(currentTime: JulianDate): void { | |
| this.#gridUsable = true; | |
| this.#data = { | |
| interval: new TimeInterval({ | |
| start: currentTime, | |
| stop: currentTime, | |
| isStartIncluded: false, | |
| isStopIncluded: false, | |
| }), | |
| // Both only if something has already asked. A re-init mid-life keeps | |
| // whatever the trajectory was already committed to sampling. | |
| fixed: this.#wantsSampled ? SampledTrajectory.#createProperty() : undefined, | |
| inertial: this.#wantsInertial ? SampledTrajectory.#createProperty(ReferenceFrame.INERTIAL) : undefined, | |
| valid: true, | |
| }; | |
| } | |
| } | |