-
Notifications
You must be signed in to change notification settings - Fork 29
feat(satellites): add interactive spotlight effect for hover/select (#95) #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
krishkhinchi
merged 3 commits into
7-Blocks:main
from
SankeerthNara:Interactive-Satellite-Spotlight-Effect
Jul 26, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
167 changes: 167 additions & 0 deletions
167
frontend/src/components/SatelliteSpotlight/GlowEffect.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| /** 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
119
frontend/src/components/SatelliteSpotlight/OrbitHighlight.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
86
frontend/src/components/SatelliteSpotlight/SpotlightInfoCard.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.