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
1 change: 1 addition & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:

- name: Run tests
env:
PYTHONPATH: .
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbital_guardian_test
SECRET_KEY: ci_test_secret_key_not_for_production
ALLOWED_ORIGINS: http://localhost:5173
Expand Down
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,14 @@ coverage/
.tmp/
*storybook.log
storybook-static

# ──── Python ───────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
.env
.pytest_cache/
alembic.ini

Copy link
Copy Markdown

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:

#!/bin/bash
# Check whether alembic.ini and Python caches are currently tracked.
git ls-files | rg -n 'alembic\.ini|\.pyc$|__pycache__' || echo "No matching tracked files."

Repository: 7-Blocks/Kepler

Length of output: 180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== .gitignore relevant entries =="
nl -ba .gitignore | sed -n '1,160p' | rg -n 'alembic|alembic\.ini|__pycache__|\.pyc' || true

echo
echo "== repo files with alembic =="
git ls-files | rg -n 'alembic|alembic\.ini|alembic_versions|versions' || true

echo
echo "== all tracked migration/config references =="
git ls-files | rg -n '\.(ini|py|toml|yml|yaml)$' | xargs -r rg -n 'script_location|sqlalchemy\.url|db\.url|alembic|ALEMBIC|DATABASE_URL' || true

echo
echo "== Alembic files in repo, excluding tracked =="
git ls-files --others --exclude-standard | rg -n 'alembic|alembic\.ini|versions' || true

echo
echo "== environment variable references =="
rg -n 'DATABASE_URL|ALEMBIC\w*|sqlalchemy\.url|script_location' -S . --glob '!**/.git/**' --glob '!**/node_modules/**' || true

Repository: 7-Blocks/Kepler

Length of output: 7005


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== .gitignore first 160 lines =="
awk '{printf "%6d\t%s\n", NR, $0}' .gitignore | sed -n '1,160p'

echo
echo "== repo files with alembic or versions =="
git ls-files | rg -n '(^|/)alembic\.ini$|alembic|versions' || echo "No matching tracked files."

echo
echo "== untracked files matching alembic/version/cache =="
git ls-files --others --exclude-standard | rg -n '(^|/)alembic\.ini$|alembic|versions|__pycache__|\.pyc' || echo "No untracked matching files."

echo
echo "== migration/config references in tracked files =="
git ls-files | rg '(\.ini|\.py|\.toml|\.yml|\.yaml)$' || true | xargs -r -n1000 sh -c 'rm -f ".tmp_rg_batch" ; rg -n "script_location|sqlalchemy\.url|db\.url|alembic|ALEMBIC|DATABASE_URL|env\.config" "$@" && touch .tmp_batch' sh {} + || true

Repository: 7-Blocks/Kepler

Length of output: 4937


Reconsider ignoring alembic.ini.

alembic.ini contains shared migration configuration, including script_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 through DATABASE_URL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore at line 100, Remove alembic.ini from the ignore rules and add the
shared configuration file to version control with a placeholder database URL,
preserving the script_location setting. Configure migrations to use DATABASE_URL
as the runtime override.

Binary file removed backend/api/v1/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed backend/app/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/__pycache__/main.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file removed backend/app/core/__pycache__/config.cpython-313.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed backend/database/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file removed backend/database/__pycache__/session.cpython-313.pyc
Binary file not shown.
Binary file removed backend/models/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file removed backend/models/__pycache__/db_models.cpython-313.pyc
Binary file not shown.
Binary file removed backend/orbital/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
50 changes: 44 additions & 6 deletions frontend/src/components/EarthTwin.tsx
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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.tsx

Repository: 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 -n

Repository: 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.tsx

Repository: 7-Blocks/Kepler

Length of output: 3790


Scope the imagery request to the created viewer and handle rejection.

viewerRef.current can be updated by a later effect run before fromUrl() resolves, so this callback may add the Natural Earth provider to the replacement viewer. The app mounts under StrictMode, which can duplicate this during reinitialization when the first load is fast. Also, the request is unhandled if the imagery fails to load.

Keep the closed-over viewer for this provider load, dispose on cleanup, add the provider only while that viewer remains active, and attach a .catch() handler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/EarthTwin.tsx` around lines 567 - 573, Update the
Natural Earth imagery load in the viewer initialization effect to use the
closed-over viewer instance rather than viewerRef.current, and add the provider
only if that viewer is still active and not destroyed. Track the provider-load
promise for cleanup, dispose the provider or cancel pending work when the effect
is cleaned up, and attach a catch handler for load failures.

});

viewerRef.current = viewer;
Expand All @@ -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;
Expand Down Expand Up @@ -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} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 bg-bg-deep-space background, while the canvas implementation places it at a negative stacking level. This can put the canvas behind its parent's background and make the new procedural stars and nebula invisible, leaving only the opaque panel visible. Keep the background canvas in the visible stacking context or remove the negative z-index. [css layout issue]

Severity Level: Major ⚠️
- ❌ Dashboard globe can show only a flat dark background.
- ⚠️ Procedural stars and nebulae disappear behind opaque layers.
- ⚠️ The advertised animated space environment is unavailable.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** frontend/src/components/EarthTwin.tsx
**Line:** 733:733
**Comment:**
	*Css Layout Issue: The procedural canvas is mounted inside an EarthTwin container with an opaque `bg-bg-deep-space` background, while the canvas implementation places it at a negative stacking level. This can put the canvas behind its parent's background and make the new procedural stars and nebula invisible, leaving only the opaque panel visible. Keep the background canvas in the visible stacking context or remove the negative z-index.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

{/* 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%)]">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -416,6 +417,7 @@ export const MainLayout: React.FC = () => {
</div>

<NotificationCenter />
<CameraControls />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Mounting CameraControls at the layout level makes the toolbar appear on every route whenever the global selectedSatelliteId is set. The Satellites page sets that same global selection, so users can see camera-mode controls on a page without a Cesium viewer, and the selected mode remains active when later opening the globe. Render this control only in the EarthTwin/dashboard context or clear/namespace the globe camera state on route changes. [api mismatch]

Severity Level: Major ⚠️
- ⚠️ Camera toolbar appears on non-globe pages.
- ⚠️ Satellites page controls can modify globe-only state.
- ⚠️ Dashboard may open with stale cinematic camera mode.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** frontend/src/components/layouts/MainLayout.tsx
**Line:** 417:417
**Comment:**
	*Api Mismatch: Mounting `CameraControls` at the layout level makes the toolbar appear on every route whenever the global `selectedSatelliteId` is set. The Satellites page sets that same global selection, so users can see camera-mode controls on a page without a Cesium viewer, and the selected mode remains active when later opening the globe. Render this control only in the EarthTwin/dashboard context or clear/namespace the globe camera state on route changes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +419 to +420

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

NotificationCenter requests geolocation on every dashboard load.

NotificationCenter calls useFlybyEngine on line 11 of frontend/src/components/ui/NotificationCenter.tsx. That hook calls navigator.geolocation.getCurrentPosition in a mount effect. Mounting the component here means the browser shows the location permission prompt as soon as the dashboard opens, before the user asks for any flyby feature. Request the location only after the user enables flyby alerts or selects a satellite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/layouts/MainLayout.tsx` around lines 416 - 417,
Update NotificationCenter and its useFlybyEngine integration so mounting the
dashboard does not call navigator.geolocation.getCurrentPosition. Defer the
location request until the user enables flyby alerts or selects a satellite,
while preserving the existing flyby behavior after either interaction.

</div>
</div>
);
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/ui/CameraControls.tsx
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>
);
};
159 changes: 159 additions & 0 deletions frontend/src/components/ui/ProceduralSpaceBackground.tsx
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' }}
/>
);
};
Loading
Loading