Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
602 changes: 149 additions & 453 deletions frontend/src/components/EarthTwin.tsx

Large diffs are not rendered by default.

167 changes: 167 additions & 0 deletions frontend/src/components/SatelliteSpotlight/GlowEffect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import * as Cesium from 'cesium';

export type SpotlightVisualState = 'baseline' | 'dimmed' | 'highlighted';

export interface PointBaseline {
color: Cesium.Color;
pixelSize: number;
outlineColor: Cesium.Color;
outlineWidth: number;
}

const DIM_ALPHA = 0.22;
const DIM_LABEL_ALPHA = 0.35;
const HIGHLIGHT_SCALE = 1.7;
const HIGHLIGHT_RISK_SCALE = 2.1;
const TRANSITION_MS = 220;

export function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

/**
* Tiny, dependency-free tween used only during spotlight transitions
* (~200ms), never as continuous per-frame work. Cancelled automatically if
* a newer tween starts on the same entity via the provided token ref.
*/
function animate(
from: number,
to: number,
durationMs: number,
onUpdate: (value: number) => void,
tokenRef: { current: number },
myToken: number
) {
if (prefersReducedMotion() || durationMs <= 0) {
onUpdate(to);
return;
}

const start = performance.now();
const step = (now: number) => {
if (tokenRef.current !== myToken) return; // superseded by a newer transition
const elapsed = now - start;
const t = Math.min(1, elapsed / durationMs);
const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
onUpdate(from + (to - from) * eased);
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}

/** Per-entity animation token so rapid hover changes don't race each other. */
const animationTokens = new WeakMap<Cesium.Entity, { current: number }>();
let tokenCounter = 0;

function getTokenRef(entity: Cesium.Entity): { current: number } {
let ref = animationTokens.get(entity);
if (!ref) {
ref = { current: 0 };
animationTokens.set(entity, ref);
}
return ref;
}

/** Restores an entity's point/label styling to its normal, non-spotlighted state. */
export function applyBaseline(entity: Cesium.Entity, baseline: PointBaseline) {
if (!entity.point) return;
const tokenRef = getTokenRef(entity);
const myToken = ++tokenCounter;
tokenRef.current = myToken;

const point = entity.point;
point.color = new Cesium.ConstantProperty(baseline.color.withAlpha(0.85));
point.outlineColor = new Cesium.ConstantProperty(baseline.outlineColor.withAlpha(0.4));
point.outlineWidth = new Cesium.ConstantProperty(baseline.outlineWidth);

const currentSize = (point.pixelSize?.getValue(Cesium.JulianDate.now()) as number) ?? baseline.pixelSize;
animate(
currentSize,
baseline.pixelSize,
TRANSITION_MS,
(v) => {
point.pixelSize = new Cesium.ConstantProperty(v);
},
tokenRef,
myToken
);

if (entity.label) {
entity.label.show = new Cesium.ConstantProperty(false);
}
}

/** Fades a non-active entity into the background so the spotlighted one stands out. */
export function applyDim(entity: Cesium.Entity, baseline: PointBaseline) {
if (!entity.point) return;
const tokenRef = getTokenRef(entity);
const myToken = ++tokenCounter;
tokenRef.current = myToken;

const point = entity.point;
point.color = new Cesium.ConstantProperty(baseline.color.withAlpha(DIM_ALPHA));
point.outlineColor = new Cesium.ConstantProperty(baseline.outlineColor.withAlpha(DIM_ALPHA * 0.6));
point.outlineWidth = new Cesium.ConstantProperty(baseline.outlineWidth);

const currentSize = (point.pixelSize?.getValue(Cesium.JulianDate.now()) as number) ?? baseline.pixelSize;
animate(
currentSize,
Math.max(baseline.pixelSize * 0.85, 1.5),
TRANSITION_MS,
(v) => {
point.pixelSize = new Cesium.ConstantProperty(v);
},
tokenRef,
myToken
);

if (entity.label) {
entity.label.show = new Cesium.ConstantProperty(false);
}

void DIM_LABEL_ALPHA; // reserved for label-opacity theming if labels are enabled later
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Makes an entity the visual focal point: brighter, larger, glowing outline. */
export function applyHighlight(
entity: Cesium.Entity,
baseline: PointBaseline,
options: { isCollisionRisk?: boolean; labelText?: string } = {}
) {
if (!entity.point) return;
const tokenRef = getTokenRef(entity);
const myToken = ++tokenCounter;
tokenRef.current = myToken;

const point = entity.point;
const glowColor = options.isCollisionRisk ? Cesium.Color.fromCssColorString('#FF3B30') : baseline.color;

point.color = new Cesium.ConstantProperty(glowColor.brighten(0.3, new Cesium.Color()).withAlpha(1));
point.outlineColor = new Cesium.ConstantProperty(glowColor.withAlpha(0.95));
point.outlineWidth = new Cesium.ConstantProperty(baseline.outlineWidth + (options.isCollisionRisk ? 3 : 2));

const targetSize = baseline.pixelSize * (options.isCollisionRisk ? HIGHLIGHT_RISK_SCALE : HIGHLIGHT_SCALE);
const currentSize = (point.pixelSize?.getValue(Cesium.JulianDate.now()) as number) ?? baseline.pixelSize;
animate(
currentSize,
targetSize,
TRANSITION_MS,
(v) => {
point.pixelSize = new Cesium.ConstantProperty(v);
},
tokenRef,
myToken
);

if (entity.label && options.labelText !== undefined) {
entity.label.text = new Cesium.ConstantProperty(options.labelText);
entity.label.show = new Cesium.ConstantProperty(true);
entity.label.font = new Cesium.ConstantProperty('600 13px "IBM Plex Sans", monospace');
entity.label.fillColor = new Cesium.ConstantProperty(Cesium.Color.WHITE);
entity.label.outlineColor = new Cesium.ConstantProperty(Cesium.Color.BLACK.withAlpha(0.8));
entity.label.outlineWidth = new Cesium.ConstantProperty(2);
entity.label.style = new Cesium.ConstantProperty(Cesium.LabelStyle.FILL_AND_OUTLINE);
entity.label.pixelOffset = new Cesium.ConstantProperty(new Cesium.Cartesian2(0, -16));
entity.label.disableDepthTestDistance = new Cesium.ConstantProperty(Number.POSITIVE_INFINITY);
}
}
119 changes: 119 additions & 0 deletions frontend/src/components/SatelliteSpotlight/OrbitHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import * as Cesium from 'cesium';
import type { CatalogObject } from '@/types/satellite';

/**
* Computes a lightweight polyline for a satellite's orbit path so it can be
* emphasized while the satellite is hovered/selected.
*
* This mirrors the position math used to place satellite points (see
* EarthTwin.keplerToLatLonAlt) but sweeps the true anomaly across a full
* revolution while holding "now" fixed, so the drawn ellipse matches the
* satellite's current ground track shape. It intentionally ignores nodal
* regression across a single period — a reasonable simplification for a
* visual highlight rather than a precision propagator.
*/
const EARTH_RADIUS_KM = 6371;
const ORBIT_SAMPLE_STEPS = 90;

export function computeOrbitPositions(obj: CatalogObject): Cesium.Cartesian3[] | null {
if (
obj.semimajor_axis == null ||
obj.inclination == null ||
obj.raan == null ||
obj.arg_of_perigee == null ||
obj.mean_motion == null
) {
return null;
}

const alt = obj.semimajor_axis - EARTH_RADIUS_KM;
if (alt < 0 || alt > 100000) return null;

const ecc = obj.eccentricity ?? 0;
const raanRad = (obj.raan * Math.PI) / 180;
const incRad = (obj.inclination * Math.PI) / 180;
const argPerigeeRad = (obj.arg_of_perigee * Math.PI) / 180;

const now = new Date();
const J2000 = new Date('2000-01-01T12:00:00Z').getTime();
const daysSinceJ2000 = (now.getTime() - J2000) / 86400000;
const GMST = (280.46061837 + 360.98564736629 * daysSinceJ2000) % 360;

const positions: Cesium.Cartesian3[] = [];

for (let i = 0; i <= ORBIT_SAMPLE_STEPS; i++) {
const meanAnomaly = ((i / ORBIT_SAMPLE_STEPS) * 360 * Math.PI) / 180;
const trueAnomaly = meanAnomaly + 2 * ecc * Math.sin(meanAnomaly);
const argLat = argPerigeeRad + trueAnomaly;

// Geocentric distance varies with true anomaly for eccentric orbits:
// r = a(1 - e^2) / (1 + e*cos(ν)). At ecc=0 this reduces to the
// constant-radius circular case.
const radiusKm = (obj.semimajor_axis * (1 - ecc * ecc)) / (1 + ecc * Math.cos(trueAnomaly));
const pointAlt = radiusKm - EARTH_RADIUS_KM;

const lon =
((((Math.atan2(Math.cos(incRad) * Math.sin(argLat), Math.cos(argLat)) * 180) / Math.PI +
(raanRad * 180) / Math.PI -
GMST +
540) %
360) -
180);
const lat = (Math.asin(Math.sin(incRad) * Math.sin(argLat)) * 180) / Math.PI;

positions.push(Cesium.Cartesian3.fromDegrees(lon, lat, pointAlt * 1000));
}

return positions;
}

interface OrbitHighlightOptions {
color: Cesium.Color;
isCollisionRisk?: boolean;
}

const ORBIT_ENTITY_ID = '__spotlight_orbit_highlight__';

/**
* Adds (or replaces) the single orbit-highlight polyline entity on the
* viewer. Only one orbit is ever highlighted at a time, so this removes any
* previous instance first — cheap, since it's only called on
* hover/selection change, never per frame.
*/
export function setOrbitHighlight(
viewer: Cesium.Viewer,
obj: CatalogObject | null,
options: OrbitHighlightOptions
): void {
if (viewer.isDestroyed()) return;

const existing = viewer.entities.getById(ORBIT_ENTITY_ID);
if (existing) viewer.entities.remove(existing);

if (!obj) return;

const positions = computeOrbitPositions(obj);
if (!positions) return;

const width = options.isCollisionRisk ? 3.5 : 2.5;
const glowPower = options.isCollisionRisk ? 0.35 : 0.2;

viewer.entities.add({
id: ORBIT_ENTITY_ID,
polyline: {
positions,
width,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower,
color: options.color.withAlpha(0.9),
}),
clampToGround: false,
},
});
}

export function clearOrbitHighlight(viewer: Cesium.Viewer): void {
if (viewer.isDestroyed()) return;
const existing = viewer.entities.getById(ORBIT_ENTITY_ID);
if (existing) viewer.entities.remove(existing);
}
86 changes: 86 additions & 0 deletions frontend/src/components/SatelliteSpotlight/SpotlightInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { MaterialIcon } from '@/components/MaterialIcon';
import type { CatalogObject } from '@/types/satellite';
import { CATEGORY_COLORS } from '@/lib/satelliteVisuals';

interface SpotlightInfoCardProps {
object: CatalogObject;
isCollisionRisk: boolean;
onClose: () => void;
}

function orbitTypeFromAltitude(altitudeKm: number | null): string {
if (altitudeKm == null) return '—';
if (altitudeKm < 2000) return 'LEO';
if (altitudeKm < 35786) return 'MEO';
if (altitudeKm < 35886) return 'GEO';
return 'HEO';
}

/**
* Small "selected satellite" card: name, NORAD ID, orbit type, altitude,
* and health/risk status. Rendered while a satellite is locked in via
* click or keyboard Enter.
*/
export const SpotlightInfoCard: React.FC<SpotlightInfoCardProps> = ({ object, isCollisionRisk, onClose }) => {
const altitudeKm = object.semimajor_axis != null ? object.semimajor_axis - 6371 : null;
const colorConfig = isCollisionRisk ? CATEGORY_COLORS.COLLISION : CATEGORY_COLORS[object.classification] ?? CATEGORY_COLORS.UNKNOWN;

return (
<div
role="dialog"
aria-label={`Selected satellite: ${object.name}`}
className={`spotlight-info-card ${isCollisionRisk ? 'spotlight-info-card--risk' : ''} bg-bg-deep-space/90 backdrop-blur-xl border p-4 min-w-[240px] max-w-[280px]`}
style={{
borderColor: isCollisionRisk ? 'rgba(255,59,48,0.6)' : `${colorConfig.css}4d`,
boxShadow: `0 0 30px ${isCollisionRisk ? 'rgba(255,59,48,0.25)' : `${colorConfig.css}26`}`,
}}
>
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-2 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: colorConfig.css, boxShadow: `0 0 8px ${colorConfig.css}` }}
/>
<span className="font-technical-data text-xs font-bold text-primary-container truncate">{object.name}</span>
</div>
<button
onClick={onClose}
aria-label="Clear selection"
className="text-on-surface-variant/60 hover:text-on-surface cursor-pointer shrink-0"
>
<MaterialIcon name="close" className="text-xs" />
</button>
</div>

{isCollisionRisk && (
<div className="flex items-center gap-1.5 mb-3 bg-status-emergency/15 border border-status-emergency/50 px-2 py-1">
<MaterialIcon name="warning" className="text-status-emergency text-xs" />
<span className="font-technical-data text-status-emergency text-[10px] font-bold">CONJUNCTION RISK</span>
</div>
)}

<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 font-technical-data text-[10px]">
<span className="text-on-surface-variant/60">NORAD ID</span>
<span className="text-on-surface font-semibold">{object.catalog_number}</span>

<span className="text-on-surface-variant/60">ORBIT TYPE</span>
<span className="text-on-surface font-semibold">{orbitTypeFromAltitude(altitudeKm)}</span>

<span className="text-on-surface-variant/60">ALTITUDE</span>
<span className="text-on-surface font-semibold">{altitudeKm != null ? `${Math.round(altitudeKm).toLocaleString()} km` : '—'}</span>

<span className="text-on-surface-variant/60">HEALTH / RISK</span>
<span className={`font-semibold ${isCollisionRisk ? 'text-status-emergency' : 'text-status-success'}`}>
{isCollisionRisk ? 'AT RISK' : 'NOMINAL'}
</span>
</div>

<div className="mt-3 pt-2 border-t border-primary-container/20 text-[9px] text-primary/50 font-technical-data">
Double-click to focus camera · Esc to clear
</div>
</div>
);
};

export default SpotlightInfoCard;
Loading
Loading