-
Notifications
You must be signed in to change notification settings - Fork 41
Feature/procedural space background #180
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
Changes from all commits
6e8c854
5ee1046
6de27f0
d0e205c
1e8ddfa
f80ccfc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,9 @@ | ||
| import React, { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react'; | ||
| import { prefersReducedMotion } from './SatelliteSpotlight/GlowEffect'; | ||
| import { useSatelliteSelection } from '@/hooks/useSatelliteSelection'; | ||
| import { useSpotlightEffect } from '@/hooks/useSpotlightEffect'; | ||
| import { useCinematicCamera } from '@/hooks/useCinematicCamera'; | ||
| import { ProceduralSpaceBackground } from '@/components/ui/ProceduralSpaceBackground'; | ||
| import { keplerToLatLonAlt } from '@/utils/orbitCalc'; | ||
| import { useUIStore } from '@/store/uiStore'; | ||
| import { logEvent } from '@/store/logbookStore'; | ||
|
|
@@ -140,7 +144,7 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => { | |
| const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null); | ||
| const [datasetVersion, setDatasetVersion] = useState(0); | ||
| const activeSector = useActiveSector(); | ||
| const setSelectedSatelliteId = useUIStore((s) => s.setSelectedSatelliteId); | ||
| const { setSelectedSatelliteId, selectedSatelliteId } = useUIStore(); | ||
| const [useFallback, setUseFallback] = useState(true); | ||
| // Set by flyToSatellite(), consumed by SpotlightManager to lock its | ||
| // selection/info-card onto a satellite chosen from outside the globe | ||
|
|
@@ -411,6 +415,17 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => { | |
| const [selectedBookmarkId, setSelectedBookmarkId] = | ||
| useState(''); | ||
|
|
||
| useSpotlightEffect({ | ||
| viewer: viewerInstance, | ||
| selectedId: selectedSatelliteId, | ||
| entitiesRef, | ||
| catalogMapRef, | ||
| collisionSetRef, | ||
| datasetVersion, | ||
| hoveredId: hoveredObject?.catalog_number ?? null, | ||
| }); | ||
|
|
||
| useCinematicCamera(viewerInstance, entitiesRef, catalogMapRef); | ||
|
|
||
| const saveCurrentView = useCallback(() => { | ||
| const viewer = viewerRef.current; | ||
|
|
@@ -612,6 +627,20 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => { | |
| navigationInstructionsInitiallyVisible: false, | ||
| scene3DOnly: true, | ||
| shouldAnimate: true, | ||
| contextOptions: { | ||
| webgl: { | ||
| alpha: true, | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| // Add offline Natural Earth imagery layer as fallback | ||
| Cesium.TileMapServiceImageryProvider.fromUrl( | ||
| Cesium.buildModuleUrl('Assets/Textures/NaturalEarthII') | ||
| ).then((provider) => { | ||
| if (viewerRef.current && !viewerRef.current.isDestroyed()) { | ||
| viewerRef.current.imageryLayers.addImageryProvider(provider); | ||
| } | ||
|
Comment on lines
+637
to
+643
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -nP -C2 '<(?:React\.)?StrictMode\b' frontend
rg -n -C4 'TileMapServiceImageryProvider\.fromUrl|addImageryProvider' frontend/src/components/EarthTwin.tsxRepository: 7-Blocks/Kepler Length of output: 884 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the Cesium viewer creation effect scope and cleanup.
sed -n '520,590p' frontend/src/components/EarthTwin.tsx | cat -n
# Check whether another provider add exists nearby and whether the request has any catch/finally handling.
sed -n '540,575p' frontend/src/components/EarthTwin.tsx | cat -nRepository: 7-Blocks/Kepler Length of output: 4292 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Read the full effect cleanup and any later viewer assignment/additions involving Natural Earth.
sed -n '590,660p' frontend/src/components/EarthTwin.tsx | cat -n
rg -n -C3 'NaturalEarthII|TileMapServiceImageryProvider|viewerRef\.current\s*=|addImageryProvider' frontend/src/components/EarthTwin.tsxRepository: 7-Blocks/Kepler Length of output: 3790 Scope the imagery request to the created viewer and handle rejection.
Keep the closed-over 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| viewerRef.current = viewer; | ||
|
|
@@ -624,7 +653,11 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => { | |
|
|
||
|
|
||
| viewer.scene.globe.enableLighting = true; // Enable real-time day/night lighting | ||
| viewer.scene.backgroundColor = Cesium.Color.fromCssColorString('#050710'); | ||
| viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString('#1a1a24'); // Visible fallback color | ||
| viewer.scene.backgroundColor = Cesium.Color.TRANSPARENT; | ||
| viewer.scene.skyBox.show = false; | ||
| viewer.scene.sun.show = false; | ||
| viewer.scene.moon.show = false; | ||
| viewer.scene.fog.enabled = false; | ||
| if (viewer.scene.skyAtmosphere) { | ||
| viewer.scene.skyAtmosphere.show = true; | ||
|
|
@@ -805,10 +838,15 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => { | |
| const hoveredCategoryLabel = OBJECT_CATEGORY_INFO[hoveredCategory].label; | ||
|
|
||
| return ( | ||
| <div className="relative w-full h-full overflow-hidden bg-bg-deep-space border-b border-border-panel"> | ||
| {/* 3D Cesium Container */} | ||
| <div ref={containerRef} className="absolute inset-0 w-full h-full z-0" /> | ||
|
|
||
| <div | ||
| className="relative w-full h-full overflow-hidden bg-bg-deep-space border-b border-border-panel" | ||
| > | ||
| <ProceduralSpaceBackground viewer={viewerInstance} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The procedural canvas is mounted inside an EarthTwin container with an opaque Severity Level: Major
|
||
| {/* The Cesium container must sit cleanly in the background */} | ||
| <div | ||
| ref={containerRef} | ||
| className="absolute inset-0 z-10 bg-transparent" | ||
| /> | ||
| {/* High-Fidelity SVG Fallback */} | ||
| {useFallback && ( | ||
| <div className="absolute inset-0 z-0 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(5,7,10,0.8)_0%,#000000_100%)]"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { DynamicBackground } from '@/components/DynamicBackground/DynamicBackgro | |
| import { LogbookPanel } from '@/components/Logbook/LogbookPanel'; | ||
| import { useLogbookStore, logEvent } from '@/store/logbookStore'; | ||
| import { NotificationCenter } from '@/components/ui/NotificationCenter'; | ||
| import { CameraControls } from '@/components/ui/CameraControls'; | ||
|
|
||
| /** Ensures the mission-init System log fires once per browser tab session. */ | ||
| let hasLoggedMissionInit = false; | ||
|
|
@@ -416,6 +417,7 @@ export const MainLayout: React.FC = () => { | |
| </div> | ||
|
|
||
| <NotificationCenter /> | ||
| <CameraControls /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Mounting Severity Level: Major
|
||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import React from 'react'; | ||
| import { motion, AnimatePresence } from 'framer-motion'; | ||
| import { useUIStore } from '@/store/uiStore'; | ||
| import type { CameraMode } from '@/store/uiStore'; | ||
| import { MaterialIcon } from '../MaterialIcon'; | ||
|
|
||
| export const CameraControls: React.FC = () => { | ||
| const { selectedSatelliteId, cameraMode, setCameraMode } = useUIStore(); | ||
|
|
||
| const modes: { id: CameraMode; icon: string; label: string }[] = [ | ||
| { id: 'FREE', icon: 'pan_tool', label: 'FREE' }, | ||
| { id: 'CHASE', icon: 'flight_takeoff', label: 'CHASE' }, | ||
| { id: 'COCKPIT', icon: 'visibility', label: 'COCKPIT' }, | ||
| { id: 'EARTH_OBSERVER', icon: 'public', label: 'EARTH' }, | ||
| { id: 'ORBITAL', icon: 'satellite_alt', label: 'ORBITAL' }, | ||
| ]; | ||
|
|
||
| return ( | ||
| <AnimatePresence> | ||
| {selectedSatelliteId && ( | ||
| <motion.div | ||
| initial={{ opacity: 0, y: 20 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| exit={{ opacity: 0, y: 20 }} | ||
| className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 p-1.5 bg-bg-deep-space/90 backdrop-blur-md border border-primary-container/30 rounded-full shadow-[0_4px_24px_rgba(0,0,0,0.8)] pointer-events-auto" | ||
| > | ||
| {modes.map((mode) => { | ||
| const isActive = cameraMode === mode.id; | ||
| return ( | ||
| <button | ||
| key={mode.id} | ||
| onClick={() => setCameraMode(mode.id)} | ||
| className={`flex items-center gap-1.5 px-3 sm:px-4 py-1.5 sm:py-2 rounded-full transition-ui font-label-caps text-[9px] sm:text-[10px] tracking-widest ${ | ||
| isActive | ||
| ? 'bg-primary-container text-bg-deep-space font-bold' | ||
| : 'text-on-surface-variant hover:text-primary-container hover:bg-surface-container-high' | ||
| }`} | ||
| title={mode.label} | ||
| > | ||
| <MaterialIcon name={mode.icon} className="text-sm" /> | ||
| <span className="hidden sm:inline">{mode.label}</span> | ||
| </button> | ||
| ); | ||
| })} | ||
| </motion.div> | ||
| )} | ||
| </AnimatePresence> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import React, { useEffect, useRef } from 'react'; | ||
| import * as Cesium from 'cesium'; | ||
|
|
||
| interface Props { | ||
| viewer: Cesium.Viewer | null; | ||
| } | ||
|
|
||
| const TILE_SIZE = 2048; | ||
|
|
||
| function createOffscreenCanvas(width: number, height: number): HTMLCanvasElement { | ||
| const canvas = document.createElement('canvas'); | ||
| canvas.width = width; | ||
| canvas.height = height; | ||
| return canvas; | ||
| } | ||
|
|
||
| function drawStars(ctx: CanvasRenderingContext2D, count: number, maxRadius: number, colors: string[]) { | ||
| for (let i = 0; i < count; i++) { | ||
| const x = Math.random() * TILE_SIZE; | ||
| const y = Math.random() * TILE_SIZE; | ||
| const radius = Math.random() * maxRadius + 0.1; | ||
| const color = colors[Math.floor(Math.random() * colors.length)]; | ||
|
|
||
| ctx.beginPath(); | ||
| ctx.arc(x, y, radius, 0, Math.PI * 2); | ||
| ctx.fillStyle = color; | ||
| ctx.fill(); | ||
|
|
||
| // Add subtle glow for larger stars | ||
| if (radius > 1) { | ||
| ctx.shadowBlur = 10; | ||
| ctx.shadowColor = color; | ||
| ctx.fill(); | ||
| ctx.shadowBlur = 0; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function drawNebula(ctx: CanvasRenderingContext2D, count: number) { | ||
| for (let i = 0; i < count; i++) { | ||
| const x = Math.random() * TILE_SIZE; | ||
| const y = Math.random() * TILE_SIZE; | ||
| const radius = Math.random() * 400 + 100; | ||
|
|
||
| // Soft subtle colors | ||
| const r = Math.floor(Math.random() * 30 + 10); | ||
| const g = Math.floor(Math.random() * 20 + 5); | ||
| const b = Math.floor(Math.random() * 50 + 30); | ||
|
|
||
| const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); | ||
| gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.15)`); | ||
| gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); | ||
|
|
||
| ctx.beginPath(); | ||
| ctx.arc(x, y, radius, 0, Math.PI * 2); | ||
| ctx.fillStyle = gradient; | ||
| ctx.fill(); | ||
| } | ||
| } | ||
|
|
||
| export const ProceduralSpaceBackground: React.FC<Props> = ({ viewer }) => { | ||
| const canvasRef = useRef<HTMLCanvasElement>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (!viewer || !canvasRef.current) return; | ||
|
|
||
| const canvas = canvasRef.current; | ||
| const ctx = canvas.getContext('2d', { alpha: false }); | ||
| if (!ctx) return; | ||
|
|
||
| // Resize observer to keep main canvas full screen | ||
| const resizeObserver = new ResizeObserver(() => { | ||
| canvas.width = window.innerWidth; | ||
| canvas.height = window.innerHeight; | ||
| }); | ||
| resizeObserver.observe(document.body); | ||
|
|
||
| // Pre-generate layers | ||
| const layer1 = createOffscreenCanvas(TILE_SIZE, TILE_SIZE); | ||
| const ctx1 = layer1.getContext('2d')!; | ||
| ctx1.fillStyle = '#030508'; | ||
| ctx1.fillRect(0, 0, TILE_SIZE, TILE_SIZE); | ||
| drawNebula(ctx1, 8); | ||
| drawStars(ctx1, 2000, 0.8, ['#ffffff', '#aaddff', '#ffddaa', '#444455']); // Background faint stars | ||
|
|
||
| const layer2 = createOffscreenCanvas(TILE_SIZE, TILE_SIZE); | ||
| const ctx2 = layer2.getContext('2d')!; | ||
| drawStars(ctx2, 800, 1.2, ['#ffffff', '#aaddff', '#ffcc88']); // Midground stars | ||
|
|
||
| const layer3 = createOffscreenCanvas(TILE_SIZE, TILE_SIZE); | ||
| const ctx3 = layer3.getContext('2d')!; | ||
| drawStars(ctx3, 200, 2.0, ['#ffffff', '#00e5ff', '#ffaa00']); // Foreground bright stars | ||
|
|
||
| const drawTiled = (source: HTMLCanvasElement, xOffset: number, yOffset: number) => { | ||
| // Wrap offsets | ||
| let ox = xOffset % TILE_SIZE; | ||
| if (ox < 0) ox += TILE_SIZE; | ||
| let oy = yOffset % TILE_SIZE; | ||
| if (oy < 0) oy += TILE_SIZE; | ||
|
|
||
| // Calculate how many tiles we need to cover the screen | ||
| const startX = -ox; | ||
| const startY = -oy; | ||
|
|
||
| for (let x = startX; x < canvas.width; x += TILE_SIZE) { | ||
| for (let y = startY; y < canvas.height; y += TILE_SIZE) { | ||
| ctx.drawImage(source, x, y); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const onPreRender = () => { | ||
| // Extract camera state | ||
| const heading = viewer.camera.heading; // 0 to 2PI | ||
| const pitch = viewer.camera.pitch; // -PI/2 to PI/2 | ||
|
|
||
| // We also use altitude to slightly scale or shift things | ||
| const alt = viewer.camera.positionCartographic.height; | ||
| // Normal map altitude between 1000km and 50000km | ||
| const zoomFactor = Math.max(0, Math.min(1, (alt - 1000000) / 40000000)); | ||
|
|
||
| // 360 degrees = 4000 pixels of scrolling | ||
| const radToPixels = 4000 / (Math.PI * 2); | ||
|
|
||
| const baseX = heading * radToPixels; | ||
| const baseY = pitch * radToPixels; | ||
|
|
||
| ctx.fillStyle = '#030508'; | ||
| ctx.fillRect(0, 0, canvas.width, canvas.height); | ||
|
|
||
| // Draw layers with parallax | ||
| // Layer 1 (slowest, background) | ||
| drawTiled(layer1, baseX * 0.1, baseY * 0.1); | ||
|
|
||
| // Layer 2 (mid) | ||
| drawTiled(layer2, baseX * 0.3, baseY * 0.3 + (zoomFactor * 50)); | ||
|
|
||
| // Layer 3 (fastest, foreground) | ||
| drawTiled(layer3, baseX * 0.6, baseY * 0.6 + (zoomFactor * 150)); | ||
| }; | ||
|
|
||
| viewer.scene.preRender.addEventListener(onPreRender); | ||
|
|
||
| return () => { | ||
| resizeObserver.disconnect(); | ||
| if (viewer && !viewer.isDestroyed()) { | ||
| viewer.scene.preRender.removeEventListener(onPreRender); | ||
| } | ||
| }; | ||
| }, [viewer]); | ||
|
|
||
| return ( | ||
| <canvas | ||
| ref={canvasRef} | ||
| className="absolute inset-0 pointer-events-none z-0" | ||
| style={{ background: '#030508' }} | ||
| /> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: 7-Blocks/Kepler
Length of output: 180
🏁 Script executed:
Repository: 7-Blocks/Kepler
Length of output: 7005
🏁 Script executed:
Repository: 7-Blocks/Kepler
Length of output: 4937
Reconsider ignoring
alembic.ini.alembic.inicontains shared migration configuration, includingscript_location. If the repo has no tracked Alembic files other than this ignore rule, contributors cannot run migrations without recreating the file. Commit the file with a placeholder URL and override the database URL throughDATABASE_URL.🤖 Prompt for AI Agents