mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
595 lines
21 KiB
JavaScript
595 lines
21 KiB
JavaScript
import { rgbaFromCss } from '../shared/color.js';
|
|
import * as geo from '../../geo/index.js';
|
|
import { appClock } from '../../time/clock.js';
|
|
import {
|
|
TRAJECTORY_STAGE_COLORS,
|
|
REPLAY_ORBIT_DURATION_SEC,
|
|
REPLAY_TILE_SETTLE_DELAY_SEC,
|
|
STAGE_REENTRY_ALTITUDE_M,
|
|
} from './policy.js';
|
|
|
|
export function createRendering({
|
|
state: layerState,
|
|
services,
|
|
parts,
|
|
source,
|
|
getRenderer,
|
|
}) {
|
|
const { getSatelliteOrbitTrack, findSatelliteOrbitTrackInTle } =
|
|
services.satellites;
|
|
|
|
function setGraphicVisibility(graphic, visible, time) {
|
|
if (!graphic) return;
|
|
const next = Boolean(visible);
|
|
const current = graphic.show?.getValue?.(time);
|
|
if (current !== next) graphic.show = next;
|
|
}
|
|
|
|
function updateMissionFrame() {
|
|
if (
|
|
!layerState._enabled ||
|
|
!layerState._viewer ||
|
|
!layerState._dataSource?.show
|
|
) {
|
|
parts.overlays.hideReplayVehicleOverlay();
|
|
return;
|
|
}
|
|
parts.panel.updateMissionTelemetry();
|
|
// The app clock's now, as the JulianDate Cesium's entity properties take.
|
|
const time = getRenderer().time(appClock.now());
|
|
layerState._declutterTime = time;
|
|
layerState._declutterOccluder.cameraPosition =
|
|
layerState._viewer.scene.camera.positionWC;
|
|
parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder);
|
|
parts.overlays.refreshSelectedMissionOverlayText();
|
|
for (const entity of layerState._dataSource.entities.values) {
|
|
if (!entity.show) continue;
|
|
if (!entity.position) continue;
|
|
const id = parts.selection.entityLaunchId(entity);
|
|
if (!id) continue;
|
|
const position = entity.position.getValue(time);
|
|
let horizonVisible = Boolean(
|
|
position && layerState._declutterOccluder.isPointVisible(position),
|
|
);
|
|
const launchAnchor = entity.id.startsWith('rocket-launch:');
|
|
if (launchAnchor) {
|
|
horizonVisible = parts.overlays.missionAnchorVisible(
|
|
layerState._viewer.scene.camera.positionWC,
|
|
position,
|
|
id,
|
|
layerState._selectedLaunchId,
|
|
);
|
|
// Keep entity.show reserved for mission-selection isolation. Horizon
|
|
// state is applied to the individual graphics below so a rear-side
|
|
// anchor remains eligible for reevaluation as the camera moves.
|
|
}
|
|
if (entity.point) {
|
|
setGraphicVisibility(entity.point, horizonVisible, time);
|
|
}
|
|
if (launchAnchor && entity.billboard) {
|
|
setGraphicVisibility(
|
|
entity.billboard,
|
|
horizonVisible && id === layerState._hoveredRosterLaunchId,
|
|
time,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The source snapshot owns its ephemeris epoch. A renderer attached later
|
|
// must reconstruct the same ascent and orbit, including a paused replay.
|
|
const orbitSolutions = new WeakMap();
|
|
function orbitForLaunch(launch, activeTleText, orbitAllowed) {
|
|
const known = orbitSolutions.get(launch);
|
|
if (known && known.activeTleText === activeTleText) return known;
|
|
const coreTrack =
|
|
orbitAllowed && launch.satelliteQuery
|
|
? getSatelliteOrbitTrack(launch.satelliteQuery, {
|
|
launchTime: launch.launchTime,
|
|
})
|
|
: null;
|
|
const satelliteTrack =
|
|
coreTrack ||
|
|
(orbitAllowed && activeTleText && launch.satelliteQuery
|
|
? findSatelliteOrbitTrackInTle(activeTleText, launch.satelliteQuery, {
|
|
launchTime: launch.launchTime,
|
|
})
|
|
: null);
|
|
const orbitPath = !orbitAllowed
|
|
? null
|
|
: satelliteTrack?.orbitPath?.length > 1
|
|
? satelliteTrack.orbitPath
|
|
: parts.paths.approximateOrbitPath(launch);
|
|
const solution = { activeTleText, satelliteTrack, orbitPath };
|
|
orbitSolutions.set(launch, solution);
|
|
return solution;
|
|
}
|
|
|
|
function addLaunchEntity(launch, activeTleText = layerState._activeTleText) {
|
|
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
|
const overlayRecord = {
|
|
launch,
|
|
anchorPosition: position,
|
|
elementEntryFactories: [],
|
|
liveEventTime: null,
|
|
};
|
|
layerState._missionOverlayRecords.set(launch.id, overlayRecord);
|
|
const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit(
|
|
launch.status,
|
|
);
|
|
const { satelliteTrack, orbitPath } = orbitForLaunch(
|
|
launch,
|
|
activeTleText,
|
|
orbitAllowed,
|
|
);
|
|
launch.recoveryStages.forEach((stage) => {
|
|
stage.endpoint = parts.paths.landingEndpoint(
|
|
stage,
|
|
launch,
|
|
orbitPath?.[0] || null,
|
|
);
|
|
});
|
|
if (satelliteTrack) layerState._orbitMatches++;
|
|
const entity = getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-launch:${launch.id}`,
|
|
position,
|
|
point: {
|
|
pixelSize: 5,
|
|
color: parts.model.missionMarkerColor(launch),
|
|
outlineColor: parts.model.missionMarkerColor(launch),
|
|
outlineWidth: 0,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
billboard: {
|
|
image: parts.overlays.missionHoverReticleImage(),
|
|
width: 24,
|
|
height: 24,
|
|
show: false,
|
|
horizontalOrigin: 0,
|
|
verticalOrigin: 0,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
launchName: launch.name,
|
|
launchTime: launch.launchTime,
|
|
launchSite: launch.launchSite,
|
|
provider: launch.provider,
|
|
mission: launch.mission,
|
|
status: launch.status,
|
|
source: launch.source,
|
|
orbit: JSON.stringify(launch.orbit || {}),
|
|
satelliteName: satelliteTrack?.name || '',
|
|
noradId: satelliteTrack?.noradId || '',
|
|
},
|
|
});
|
|
// Publish the same anchor representation held by the active renderer.
|
|
overlayRecord.anchorPosition = entity.position.getValue(
|
|
getRenderer().time(appClock.now()),
|
|
);
|
|
const points = launch.trajectory
|
|
.filter(
|
|
(point) =>
|
|
Number.isFinite(Number(point.latitude)) &&
|
|
Number.isFinite(Number(point.longitude)),
|
|
)
|
|
.map((point) => ({
|
|
stage: String(
|
|
point.stage ||
|
|
point.stage_name ||
|
|
point.phase ||
|
|
point.stageName ||
|
|
'trajectory',
|
|
),
|
|
position: geo.cartesian3.fromDegrees(
|
|
Number(point.longitude),
|
|
Number(point.latitude),
|
|
Number(point.altitude || 0),
|
|
),
|
|
}));
|
|
if (points.length > 1) {
|
|
const segments = [];
|
|
points.forEach((point) => {
|
|
const previous = segments.at(-1);
|
|
if (!previous || previous.stage !== point.stage)
|
|
segments.push({ stage: point.stage, positions: [] });
|
|
segments.at(-1).positions.push(point.position);
|
|
});
|
|
segments.forEach((segment, index) => {
|
|
if (segment.positions.length < 2) return;
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-trajectory:${launch.id}:${index}`,
|
|
polyline: {
|
|
positions: parts.paths.surfaceSafePath(segment.positions),
|
|
width: 2,
|
|
material: rgbaFromCss(
|
|
TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length],
|
|
).withAlpha(0.8),
|
|
clampToGround: false,
|
|
arc: 'none',
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
stage: segment.stage,
|
|
source: launch.source,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
if (orbitPath?.length > 1) {
|
|
const orbitCurrent = satelliteTrack?.current || {
|
|
longitude: launch.lon,
|
|
latitude: launch.lat,
|
|
altitude: 0,
|
|
};
|
|
const orbitPeriodSec =
|
|
satelliteTrack?.periodSec ||
|
|
parts.paths.estimatedOrbitPeriodSeconds(orbitPath);
|
|
const launchEpochMs = Date.parse(launch.launchTime);
|
|
const disclosedInsertionOffsetSec =
|
|
parts.paths.orbitInsertionOffsetSeconds(launch);
|
|
const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec)
|
|
? Math.max(0, disclosedInsertionOffsetSec)
|
|
: 600;
|
|
const insertionEpochMs = Number.isFinite(launchEpochMs)
|
|
? launchEpochMs + insertionDurationSec * 1000
|
|
: Number.NaN;
|
|
let insertionReference = points.at(-1)?.position || null;
|
|
if (
|
|
!insertionReference &&
|
|
satelliteTrack?.positionAt &&
|
|
Number.isFinite(insertionEpochMs)
|
|
) {
|
|
const propagatedInsertion = satelliteTrack.positionAt(
|
|
new Date(insertionEpochMs),
|
|
);
|
|
if (propagatedInsertion) {
|
|
insertionReference = geo.cartesian3.fromDegrees(
|
|
propagatedInsertion.longitude,
|
|
propagatedInsertion.latitude,
|
|
propagatedInsertion.altitude,
|
|
);
|
|
}
|
|
}
|
|
if (!insertionReference && !satelliteTrack) {
|
|
// A projected orbit has no authoritative historical phase. Start its
|
|
// plane over the launch site, advance only by the estimated powered
|
|
// ascent duration, and join the forward orbit tangent. Using the UTC
|
|
// epoch as phase sent some ascents toward the far side of Earth and
|
|
// forced a visible 180-degree corrective hook before insertion.
|
|
const insertionProgress = geo.math.clamp(
|
|
insertionDurationSec / orbitPeriodSec,
|
|
1 / 96,
|
|
0.16,
|
|
);
|
|
insertionReference = parts.paths.samplePath(
|
|
orbitPath,
|
|
insertionProgress,
|
|
);
|
|
}
|
|
const { ascentPath, animatedOrbitPath } = parts.paths.buildMissionPaths(
|
|
position,
|
|
points.map((point) => point.position),
|
|
orbitPath,
|
|
insertionReference,
|
|
);
|
|
const ascentDurationSec = parts.replay.replayAscentDurationSeconds(
|
|
launch,
|
|
ascentPath,
|
|
);
|
|
// Cesium can evaluate CallbackProperty values multiple times while it
|
|
// traverses one frame. Camera tracking and the HTML replay overlay must
|
|
// use the exact same replay epoch as those callbacks; sampling appClock.now()
|
|
// independently made the vehicle advance relative to the camera and then
|
|
// snap back on the next frame.
|
|
let replayStateForFrame = null;
|
|
const sampleReplayState = () =>
|
|
parts.replay.replayState(
|
|
launch,
|
|
layerState._animationStarts.get(launch.id) || appClock.now(),
|
|
ascentDurationSec,
|
|
REPLAY_ORBIT_DURATION_SEC,
|
|
orbitPeriodSec,
|
|
layerState._replaySpeed,
|
|
parts.replay.replayClockNow(launch.id),
|
|
REPLAY_TILE_SETTLE_DELAY_SEC,
|
|
layerState._replayCameraLaunchId !== launch.id,
|
|
);
|
|
// preUpdate runs before Cesium evaluates entity CallbackProperties. Seed
|
|
// one state there and retain it through postRender so every consumer in
|
|
// that traversal sees the same timestamp. scene.frameState.frameNumber is
|
|
// advanced between some of those phases, so it is not a reliable cache key.
|
|
const beginReplayFrame = () => {
|
|
replayStateForFrame = sampleReplayState();
|
|
return replayStateForFrame;
|
|
};
|
|
const getReplayState = () => {
|
|
if (!replayStateForFrame) {
|
|
replayStateForFrame = sampleReplayState();
|
|
}
|
|
return replayStateForFrame;
|
|
};
|
|
launch.recoveryStages.forEach((stage, stageIndex) => {
|
|
stage.endpoint = parts.paths.landingEndpoint(
|
|
stage,
|
|
launch,
|
|
ascentPath.at(-1),
|
|
);
|
|
const path = parts.paths.stageReentryRecoveryPath(
|
|
ascentPath,
|
|
stage.endpoint,
|
|
stageIndex,
|
|
launch.recoveryStages.length,
|
|
);
|
|
if (path.length < 2) return;
|
|
const reentryIndex = parts.paths.atmosphericReentryIndex(path);
|
|
const reentryPath = path.slice(reentryIndex);
|
|
const color = rgbaFromCss(
|
|
TRAJECTORY_STAGE_COLORS[
|
|
(stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length
|
|
],
|
|
);
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`,
|
|
polyline: {
|
|
positions: path,
|
|
width: 2,
|
|
material: getRenderer().dash({
|
|
color: color.withAlpha(0.82),
|
|
dashLength: 12,
|
|
dashPattern: 0xaaaa,
|
|
}),
|
|
arc: 'none',
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
stageId: stage.id,
|
|
phase: 'STAGE_REENTRY_RECOVERY',
|
|
accuracy: stage.endpoint.accuracy,
|
|
source: launch.source,
|
|
},
|
|
});
|
|
if (reentryPath.length > 1) {
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-reentry:${launch.id}:${stageIndex}`,
|
|
polyline: {
|
|
positions: reentryPath,
|
|
width: 2.5,
|
|
material: getRenderer().dash({
|
|
color: rgbaFromCss('#ffd166').withAlpha(0.9),
|
|
dashLength: 8,
|
|
dashPattern: 0xf0f0,
|
|
}),
|
|
arc: 'none',
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
stageId: stage.id,
|
|
phase: 'ATMOSPHERIC_REENTRY',
|
|
altitudeM: STAGE_REENTRY_ALTITUDE_M,
|
|
source: 'Estimated 100 km atmospheric interface',
|
|
},
|
|
});
|
|
const reentryPosition = path[reentryIndex];
|
|
overlayRecord.elementEntryFactories.push(() =>
|
|
parts.overlays.createRocketMissionElementOverlayEntry({
|
|
id: `reentry:${launch.id}:${stageIndex}`,
|
|
position: reentryPosition,
|
|
text: 'STAGE RE-ENTRY',
|
|
accent: '#ffd166',
|
|
priority: 700_000 - stageIndex,
|
|
gapPx: 8,
|
|
}),
|
|
);
|
|
}
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-recovery-end:${launch.id}:${stageIndex}`,
|
|
position: path.at(-1),
|
|
point: {
|
|
pixelSize: 4,
|
|
color,
|
|
outlineWidth: 0,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
stageId: stage.id,
|
|
phase: 'RECOVERY_ENDPOINT',
|
|
source: launch.source,
|
|
},
|
|
});
|
|
});
|
|
layerState._replayTracks.set(launch.id, {
|
|
ascentPath,
|
|
animatedOrbitPath,
|
|
orbitFrameSphere:
|
|
parts.camera.replayOrbitFrameSphere(animatedOrbitPath),
|
|
ascentDurationSec,
|
|
beginReplayFrame,
|
|
getReplayState,
|
|
lastCameraHeading: Math.PI,
|
|
orbitCameraWorldFrame: false,
|
|
lastVehicleRotation: 0,
|
|
lastOverlayWindowPosition: null,
|
|
lastOverlayMode: null,
|
|
});
|
|
let livePosition = geo.cartesian3.fromDegrees(
|
|
orbitCurrent.longitude,
|
|
orbitCurrent.latitude,
|
|
orbitCurrent.altitude,
|
|
);
|
|
const liveTime = new Date(appClock.now());
|
|
const liveTelemetry = { speedMps: null };
|
|
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
|
|
let liveFrameNumber = -1;
|
|
const fallbackPeriodSec =
|
|
parts.paths.estimatedOrbitPeriodSeconds(orbitPath);
|
|
const updateLiveState = () => {
|
|
const frameNumber =
|
|
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
|
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
|
|
liveFrameNumber = frameNumber;
|
|
const nowMs = appClock.now();
|
|
liveTime.setTime(nowMs);
|
|
if (satelliteTrack) {
|
|
const propagated =
|
|
satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current;
|
|
if (propagated) {
|
|
geo.cartesian3.fromDegrees(
|
|
propagated.longitude,
|
|
propagated.latitude,
|
|
propagated.altitude,
|
|
undefined,
|
|
livePosition,
|
|
);
|
|
liveTelemetry.speedMps = Number.isFinite(propagated.speedMps)
|
|
? propagated.speedMps
|
|
: null;
|
|
}
|
|
} else {
|
|
// In-place write keeps one Cartesian3 for the mission's lifetime —
|
|
// the estimated branch previously reallocated per propagation.
|
|
livePosition =
|
|
parts.paths.samplePath(
|
|
orbitPath,
|
|
parts.paths.orbitProgressAtTime(nowMs, fallbackPeriodSec),
|
|
livePosition,
|
|
) || livePosition;
|
|
}
|
|
};
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-satellite:${launch.id}`,
|
|
position: getRenderer().callback(() => {
|
|
updateLiveState();
|
|
return livePosition;
|
|
}, false),
|
|
point: {
|
|
pixelSize: 6,
|
|
color: rgbaFromCss(satelliteTrack ? '#7bed9f' : '#ffd166'),
|
|
outlineWidth: 0,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
noradId: satelliteTrack?.noradId || '',
|
|
phase: satelliteTrack
|
|
? 'CURRENT_SATELLITE_POSITION'
|
|
: 'ESTIMATED_ORBIT_POSITION',
|
|
source: satelliteTrack
|
|
? 'Satellites layer'
|
|
: 'Approximate mission orbit',
|
|
},
|
|
});
|
|
overlayRecord.liveEventTime = () => liveTime;
|
|
overlayRecord.elementEntryFactories.push(() => {
|
|
const name = satelliteTrack
|
|
? parts.overlays
|
|
.shortMissionLabel(satelliteTrack.name, 22)
|
|
.toUpperCase()
|
|
: 'EST. ORBIT POSITION';
|
|
return parts.overlays.createRocketMissionElementOverlayEntry({
|
|
id: `payload-position:${launch.id}`,
|
|
position: () => livePosition,
|
|
text: `${name}\n${parts.policyHelpers.formatMissionEventTime(liveTime)}`,
|
|
accent: satelliteTrack ? '#7bed9f' : '#ffd166',
|
|
priority: 900_000,
|
|
gapPx: 12,
|
|
});
|
|
});
|
|
// Live satellite rings use a primitive collection so the core Satellite
|
|
// GMST transform can rotate their baked ECEF geometry without rebuilding
|
|
// it. Estimated launch-site-relative rings remain ordinary entities.
|
|
const primitiveOrbitAdded = parts.orbitRendering.addMissionOrbitPrimitive(
|
|
launch,
|
|
orbitPath,
|
|
satelliteTrack,
|
|
);
|
|
if (!primitiveOrbitAdded) {
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-orbit:${launch.id}`,
|
|
polyline: {
|
|
positions: orbitPath,
|
|
// The 3 px canvas gives the periodic dot room to read as round; the
|
|
// shader masks the ten intervening dashes down to a thin center stroke.
|
|
width: 3,
|
|
material: getRenderer().patternMaterial(
|
|
rgbaFromCss('#c084fc').withAlpha(0.95),
|
|
),
|
|
arc: 'none',
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
satelliteName: '',
|
|
noradId: '',
|
|
source: 'Approximate mission orbit',
|
|
},
|
|
});
|
|
}
|
|
const orbitLabelBakePosition = ascentPath.at(-1);
|
|
let orbitLabelPosition = orbitLabelBakePosition;
|
|
const primitivePath = layerState._missionOrbitPrimitives.get(launch.id);
|
|
if (primitivePath) {
|
|
primitivePath.labelBakePosition = orbitLabelBakePosition;
|
|
primitivePath.labelPosition = geo.matrix4.multiplyByPoint(
|
|
primitivePath.primitive.modelMatrix,
|
|
orbitLabelBakePosition,
|
|
geo.cartesian3.create(),
|
|
);
|
|
orbitLabelPosition = () => primitivePath.labelPosition;
|
|
}
|
|
overlayRecord.elementEntryFactories.push(() =>
|
|
parts.overlays.createRocketMissionElementOverlayEntry({
|
|
id: `orbit:${launch.id}`,
|
|
position: orbitLabelPosition,
|
|
text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT',
|
|
accent: satelliteTrack ? '#22e6e6' : '#c084fc',
|
|
priority: 800_000,
|
|
gapPx: 8,
|
|
}),
|
|
);
|
|
const replayPosition = getRenderer().callback(() => {
|
|
const state = getReplayState();
|
|
return state.ascending
|
|
? parts.paths.samplePath(ascentPath, state.phaseProgress)
|
|
: parts.paths.samplePath(animatedOrbitPath, state.phaseProgress);
|
|
}, false);
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-vehicle:${launch.id}`,
|
|
position: replayPosition,
|
|
properties: {
|
|
launchId: launch.id,
|
|
noradId: satelliteTrack?.noradId || '',
|
|
phase: 'ASCENT_THEN_ORBIT',
|
|
source: satelliteTrack
|
|
? 'Satellite trajectory animation'
|
|
: 'Approximate trajectory animation',
|
|
},
|
|
});
|
|
getRenderer().addEntity(layerState._dataSource, {
|
|
id: `rocket-transfer:${launch.id}`,
|
|
polyline: {
|
|
positions: ascentPath,
|
|
width: 1.5,
|
|
// The animated vehicle uses Cartesian interpolation between these
|
|
// exact samples. Prevent Cesium from replacing each segment with a
|
|
// geodesic arc, which would visually separate the dot from the line.
|
|
arc: 'none',
|
|
material: getRenderer().dash({
|
|
color: rgbaFromCss('#7bed9f').withAlpha(0.8),
|
|
dashLength: 24,
|
|
dashPattern: 0xf0f0,
|
|
}),
|
|
},
|
|
properties: {
|
|
launchId: launch.id,
|
|
phase: 'APPROXIMATE_TRANSFER',
|
|
source: satelliteTrack
|
|
? 'Launch site to propagated insertion'
|
|
: 'Launch site to forward projected insertion',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return { setGraphicVisibility, updateMissionFrame, addLaunchEntity };
|
|
}
|