diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 22601b2..78ee495 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(mkdir:*)", "Bash(npm run build:*)", "Bash(npx tsc:*)", - "Bash(npm run test:run:*)" + "Bash(npm run test:run:*)", + "Bash(npm run typecheck:*)" ], "deny": [], "ask": [] diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..65201ce --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "ms-vscode.vscode-typescript-next", + "bradlc.vscode-tailwindcss", + "esbenp.prettier-vscode", + "ms-vscode.vscode-json" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..790fe74 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "typescript.preferences.includePackageJsonAutoImports": "on", + "typescript.suggest.autoImports": true, + "typescript.preferences.importModuleSpecifier": "non-relative", + "typescript.format.enable": true, + "typescript.validate.enable": true, + "typescript.surveys.enabled": false, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.suggest.includeCompletionsForModuleExports": true, + "typescript.suggest.includeCompletionsForImportStatements": true, + "typescript.preferences.includeCompletionsForImportStatements": true, + "typescript.workspaceSymbols.scope": "allOpenProjects", + "typescript.references.enabled": true, + "path-intellisense.mappings": { + "@": "${workspaceFolder}/src", + "@features": "${workspaceFolder}/src/features", + "@components": "${workspaceFolder}/src/components", + "@hooks": "${workspaceFolder}/src/hooks", + "@services": "${workspaceFolder}/src/services", + "@stores": "${workspaceFolder}/src/stores", + "@utils": "${workspaceFolder}/src/utils", + "@context": "${workspaceFolder}/src/context" + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d9f695a..3835e8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ src/ ├── types/ # TypeScript type definitions (country, coordinates, ISSStats) ├── utils/ # Utility functions (countries, iss, logger, numberFormatter) ├── features/ # Feature-specific code -│ └── iss-tracker/ # ISS tracking feature with map visualization +│ └── iss-tracker/ # ISS tracking feature with map visualisation ├── styles/ # Global CSS files (vars.css, grid.css) └── apis/ # API endpoint configurations ``` @@ -70,7 +70,7 @@ src/ ### Important Notes - React Strict Mode causes double API calls during development - abort signals are critical for preventing blocked endpoints - Polling automatically suspends when the browser tab is inactive to conserve resources -- Application uses Leaflet maps via react-leaflet for geographic visualization +- Application uses Leaflet maps via react-leaflet for geographic visualisation - The architecture now supports adding multiple features alongside the ISS tracker in the `features/` directory ## Development Approaches @@ -168,6 +168,102 @@ import { ErrorBoundary } from 'react-error-boundary'; - **Best Practices**: Use `interface` for object shapes, `type` for unions/aliases, prefer `unknown` over `any` - **Import Style**: Use `import type { ... }` for type-only imports to improve build performance +## Orbital Visualisation System + +### Features +The ISS tracker includes advanced orbital visualisation capabilities: + +- **Historical Tracking**: View ISS orbital path for the past 24 hours +- **Future Predictions**: Display predicted ISS positions for up to 6 hours +- **Ground Track Visualization**: Show ISS ground track patterns +- **Real-time Control Panel**: Toggle features and adjust settings +- **Customizable Appearance**: Control path colors, opacity, and update intervals + +### State Management (Zustand) +Orbital visualisation uses Zustand for state management: + +```typescript +import { useOrbitalStore } from '../stores/orbitalStore'; + +// Access store state and actions +const { + settings, + isHistoricalTrackingEnabled, + toggleHistoricalTracking, + updateSettings, +} = useOrbitalStore(); +``` + +### Key Components + +#### OrbitalControlPanel +- **Location**: `src/components/OrbitalControlPanel/` +- **Purpose**: Floating control panel for managing orbital visualisation +- **Features**: Toggle switches, sliders for duration/opacity, color picker +- **Position**: Fixed top-right with responsive design + +#### OrbitalVisualization +- **Location**: `src/components/OrbitalPath/` +- **Purpose**: Renders orbital paths on Leaflet map +- **Layers**: Historical path, future predictions, ground track +- **Integration**: Embedded within MapContainer component + +#### OrbitalDataService +- **Location**: `src/services/OrbitalDataService.ts` +- **Purpose**: Fetches historical and predicted ISS positions +- **API**: Uses wheretheiss.at API with rate limiting (1 req/sec) +- **Features**: Chunk requests, abort signal support, retry logic with exponential backoff, error handling + +#### useOrbitalData Hook +- **Location**: `src/hooks/useOrbitalData.ts` +- **Purpose**: Manages orbital data fetching and updates +- **Features**: Automatic polling, cleanup on unmount, settings synchronization + +### Usage Examples + +#### Enable Historical Tracking +```typescript +import { useOrbitalStore } from '../stores/orbitalStore'; + +function MyComponent() { + const { toggleHistoricalTracking } = useOrbitalStore(); + + return ( + + ); +} +``` + +#### Customize Orbital Settings +```typescript +const { updateSettings } = useOrbitalStore(); + +// Update path duration to 4 hours +updateSettings({ pathDuration: 4 }); + +// Change path color and opacity +updateSettings({ + pathColor: '#ff6600', + pathOpacity: 0.8 +}); +``` + +### API Integration +- **Historical Data**: `/satellites/25544/positions?timestamps=...` +- **Rate Limiting**: 1 request per second with automatic delays +- **Data Chunks**: Max 10 timestamps per request +- **Retry Logic**: Exponential backoff (1s, 2s, 4s) for failed requests +- **Error Handling**: Network errors, abort signals, API failures with proper propagation +- **Caching**: No caching - always fresh data for real-time tracking + +### Performance Considerations +- **Efficient Updates**: Only fetch data when tracking is enabled +- **Abort Controllers**: Cancel requests on component unmount +- **Minimal Re-renders**: Zustand state management reduces unnecessary updates +- **Chunked Requests**: Large time ranges split into manageable API calls + ### CSS and Styling - **Global styles**: Place in `src/styles/` (vars.css, grid.css) - **Feature styles**: Co-locate with feature components diff --git a/README.md b/README.md index 12d10b6..14a000b 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ The application follows a modular architecture with clear separation of concerns ## 📱 Live Demo -Visit the live application: [ISS Tracker](https://your-github-username.github.io/iss-track-react/) +Visit the live application: [ISS Tracker](https://jamesmanuel.github.io/iss-track-react/) ## 🤝 Contributing diff --git a/package-lock.json b/package-lock.json index c2883aa..b63b245 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "react-dom": "^19.0.0", "react-error-boundary": "^5.0.0", "react-leaflet": "^5.0.0", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "zustand": "^5.0.8" }, "devDependencies": { "@testing-library/jest-dom": "^6.8.0", @@ -1844,7 +1845,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2784,7 +2785,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -6147,6 +6148,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 32d71ab..7f07385 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "react-dom": "^19.0.0", "react-error-boundary": "^5.0.0", "react-leaflet": "^5.0.0", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "zustand": "^5.0.8" }, "scripts": { "start": "vite", diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx index af6d945..f822a72 100644 --- a/src/components/Loader/Loader.tsx +++ b/src/components/Loader/Loader.tsx @@ -1,6 +1,10 @@ +import { memo } from 'react'; import './loader.css'; -export default function Loader(){ + +function Loader(){ return ( ) } + +export default memo(Loader); diff --git a/src/components/OrbitalControlPanel/OrbitalControlPanel.css b/src/components/OrbitalControlPanel/OrbitalControlPanel.css new file mode 100644 index 0000000..6b86245 --- /dev/null +++ b/src/components/OrbitalControlPanel/OrbitalControlPanel.css @@ -0,0 +1,230 @@ +.orbital-control-panel { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; +} + +.control-panel-toggle { + background: var(--primary-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 12px; + font-size: 24px; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; + color: var(--text-color); + min-width: 48px; + min-height: 48px; + display: flex; + align-items: center; + justify-content: center; +} + +.control-panel-toggle:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); + background: var(--secondary-bg); +} + +.control-panel-content { + position: absolute; + top: 60px; + right: 0; + background: var(--primary-bg); + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 20px; + min-width: 320px; + max-width: 400px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(10px); + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.control-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.control-panel-header h3 { + margin: 0; + color: rgba(255, 255, 255, 0.9); + font-size: 18px; + font-weight: 600; +} + +.close-panel { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: rgba(255, 255, 255, 0.8); + padding: 4px; + border-radius: 4px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.close-panel:hover { + background: var(--secondary-bg); +} + +.control-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.control-label { + display: flex; + align-items: center; + gap: 8px; + color: rgba(255, 255, 255, 0.85); + font-size: 14px; + font-weight: 500; + cursor: pointer; + user-select: none; +} + +.control-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-color); + cursor: pointer; +} + +.control-slider { + width: 100%; + height: 6px; + border-radius: 3px; + background: var(--border-color); + outline: none; + accent-color: var(--accent-color); + cursor: pointer; +} + +.control-slider::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent-color); + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.control-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent-color); + cursor: pointer; + border: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.color-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.color-picker { + width: 40px; + height: 32px; + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + padding: 0; + background: none; +} + +.color-presets { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--primary-bg); + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + cursor: pointer; +} + +.color-presets:focus { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.loading-indicator { + font-size: 12px; + margin-left: 4px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .control-panel-content { + backdrop-filter: blur(10px) brightness(0.8); + } +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .orbital-control-panel { + top: 10px; + right: 10px; + } + + .control-panel-content { + right: -10px; + min-width: 280px; + max-width: calc(100vw - 40px); + } +} + +/* Accessibility */ +@media (prefers-reduced-motion: reduce) { + .control-panel-content { + animation: none; + } + + .loading-indicator { + animation: none; + } + + .control-panel-toggle { + transition: none; + } +} \ No newline at end of file diff --git a/src/components/OrbitalControlPanel/OrbitalControlPanel.test.tsx b/src/components/OrbitalControlPanel/OrbitalControlPanel.test.tsx new file mode 100644 index 0000000..0fcfd36 --- /dev/null +++ b/src/components/OrbitalControlPanel/OrbitalControlPanel.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '../../test/utils'; +import OrbitalControlPanel from './OrbitalControlPanel'; +import { useOrbitalStore } from '../../stores/orbitalStore'; + +// Mock the orbital store +vi.mock('../../stores/orbitalStore'); +const mockUseOrbitalStore = vi.mocked(useOrbitalStore); + +describe('OrbitalControlPanel', () => { + const mockActions = { + toggleControlPanel: vi.fn(), + toggleHistoricalTracking: vi.fn(), + updateSettings: vi.fn(), + }; + + const defaultStoreState = { + settings: { + showHistoricalPath: true, + showFuturePredictions: false, + showGroundTrack: true, + pathDuration: 2, + predictionDuration: 1, + updateInterval: 30000, + pathOpacity: 0.7, + pathColor: '#00ff00', + }, + isControlPanelOpen: false, + isHistoricalTrackingEnabled: false, + isLoadingHistorical: false, + isLoadingPredictions: false, + ...mockActions, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseOrbitalStore.mockReturnValue(defaultStoreState); + }); + + it('renders the control panel toggle button', () => { + render(); + + const toggleButton = screen.getByTitle('Toggle Orbital Controls'); + expect(toggleButton).toBeInTheDocument(); + expect(toggleButton).toHaveTextContent('🛰️'); + }); + + it('toggles control panel when button is clicked', () => { + render(); + + const toggleButton = screen.getByTitle('Toggle Orbital Controls'); + fireEvent.click(toggleButton); + + expect(mockActions.toggleControlPanel).toHaveBeenCalledTimes(1); + }); + + it('shows control panel content when open', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + }); + + render(); + + expect(screen.getByText('Orbital Visualisation')).toBeInTheDocument(); + expect(screen.getByText('Enable Historical Tracking')).toBeInTheDocument(); + }); + + it('hides control panel content when closed', () => { + render(); + + expect(screen.queryByText('Orbital Visualisation')).not.toBeInTheDocument(); + expect(screen.queryByText('Enable Historical Tracking')).not.toBeInTheDocument(); + }); + + it('toggles historical tracking when checkbox is clicked', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + }); + + render(); + + const checkbox = screen.getByLabelText('Enable Historical Tracking'); + fireEvent.click(checkbox); + + expect(mockActions.toggleHistoricalTracking).toHaveBeenCalledTimes(1); + }); + + it('shows additional controls when historical tracking is enabled', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + }); + + render(); + + expect(screen.getByText('Show Historical Path')).toBeInTheDocument(); + expect(screen.getByText('Show Future Predictions')).toBeInTheDocument(); + expect(screen.getByText('Show Ground Track')).toBeInTheDocument(); + expect(screen.getByText(/Historical Data Duration/)).toBeInTheDocument(); + }); + + it('updates path duration setting', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + }); + + render(); + + const slider = screen.getByDisplayValue('2'); + fireEvent.change(slider, { target: { value: '4' } }); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ pathDuration: 4 }); + }); + + it('updates path opacity setting', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + }); + + render(); + + const opacitySlider = screen.getByDisplayValue('0.7'); + fireEvent.change(opacitySlider, { target: { value: '0.5' } }); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ pathOpacity: 0.5 }); + }); + + it('updates path color setting', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + }); + + render(); + + const colorInput = screen.getByDisplayValue('#00ff00'); + fireEvent.change(colorInput, { target: { value: '#ff0000' } }); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ pathColor: '#ff0000' }); + }); + + it('updates color via preset dropdown', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + }); + + render(); + + const colorSelect = screen.getByDisplayValue('Green'); + fireEvent.change(colorSelect, { target: { value: '#0099ff' } }); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ pathColor: '#0099ff' }); + }); + + it('shows loading indicators when data is loading', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + isLoadingHistorical: true, + isLoadingPredictions: true, + }); + + render(); + + const loadingIndicators = screen.getAllByText('⏳'); + expect(loadingIndicators).toHaveLength(2); + }); + + it('closes panel when close button is clicked', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + }); + + render(); + + const closeButton = screen.getByTitle('Close panel'); + fireEvent.click(closeButton); + + expect(mockActions.toggleControlPanel).toHaveBeenCalledTimes(1); + }); + + it('toggles individual visualisation options', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + }); + + render(); + + const historicalPathCheckbox = screen.getByLabelText('Show Historical Path'); + fireEvent.click(historicalPathCheckbox); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ showHistoricalPath: false }); + + const futurePathCheckbox = screen.getByLabelText('Show Future Predictions'); + fireEvent.click(futurePathCheckbox); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ showFuturePredictions: true }); + + const groundTrackCheckbox = screen.getByLabelText('Show Ground Track'); + fireEvent.click(groundTrackCheckbox); + + expect(mockActions.updateSettings).toHaveBeenCalledWith({ showGroundTrack: false }); + }); + + it('displays correct duration values in labels', () => { + mockUseOrbitalStore.mockReturnValue({ + ...defaultStoreState, + isControlPanelOpen: true, + isHistoricalTrackingEnabled: true, + settings: { + ...defaultStoreState.settings, + pathDuration: 3.5, + predictionDuration: 2, + updateInterval: 60000, + pathOpacity: 0.8, + }, + }); + + render(); + + expect(screen.getByText('Historical Data Duration: 3.5 hours')).toBeInTheDocument(); + expect(screen.getByText('Prediction Duration: 2 hours')).toBeInTheDocument(); + expect(screen.getByText('Update Interval: 60s')).toBeInTheDocument(); + expect(screen.getByText('Path Opacity: 80%')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/OrbitalControlPanel/OrbitalControlPanel.tsx b/src/components/OrbitalControlPanel/OrbitalControlPanel.tsx new file mode 100644 index 0000000..d927c34 --- /dev/null +++ b/src/components/OrbitalControlPanel/OrbitalControlPanel.tsx @@ -0,0 +1,201 @@ +import React, { useCallback } from 'react'; +import { useOrbitalStore } from '@stores/orbitalStore'; +import './OrbitalControlPanel.css'; + +export default function OrbitalControlPanel() { + const { + settings, + isControlPanelOpen, + isHistoricalTrackingEnabled, + isLoadingHistorical, + isLoadingPredictions, + toggleControlPanel, + toggleHistoricalTracking, + updateSettings, + } = useOrbitalStore(); + + const handlePathDurationChange = useCallback((duration: number) => { + updateSettings({ pathDuration: duration }); + }, [updateSettings]); + + const handlePredictionDurationChange = useCallback((duration: number) => { + updateSettings({ predictionDuration: duration }); + }, [updateSettings]); + + const handleOpacityChange = useCallback((opacity: number) => { + updateSettings({ pathOpacity: opacity }); + }, [updateSettings]); + + const handleColorChange = useCallback((color: string) => { + updateSettings({ pathColor: color }); + }, [updateSettings]); + + const handleUpdateIntervalChange = useCallback((interval: number) => { + updateSettings({ updateInterval: interval }); + }, [updateSettings]); + + return ( +
+ + + {isControlPanelOpen && ( +
+
+

Orbital Visualisation

+ +
+ +
+
+ +
+ + {isHistoricalTrackingEnabled && ( + <> +
+ +
+ +
+ +
+ +
+ +
+ +
+ + handlePathDurationChange(Number(e.target.value))} + className="control-slider" + /> +
+ +
+ + handlePredictionDurationChange(Number(e.target.value))} + className="control-slider" + /> +
+ +
+ + handleOpacityChange(Number(e.target.value))} + className="control-slider" + /> +
+ +
+ +
+ handleColorChange(e.target.value)} + className="color-picker" + /> + +
+
+ +
+ + handleUpdateIntervalChange(Number(e.target.value))} + className="control-slider" + /> +
+ + )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/ValueDisplay.tsx b/src/components/ValueDisplay.tsx index 39a7455..85ea6fd 100644 --- a/src/components/ValueDisplay.tsx +++ b/src/components/ValueDisplay.tsx @@ -1,15 +1,35 @@ -import { useRef, useEffect } from 'react'; -import type { ValueDisplayProps } from '../types/components'; -import '../lib/scrambler-element.ts'; +import { useRef, useEffect, memo, useMemo } from 'react'; +import '@/lib/scrambler-element.ts'; -export default function ValueDisplay({ value, title, decimalPlaces, unit, locale = 'en-GB' }: ValueDisplayProps) { +// Force TypeScript to recognize scrambler-element +declare global { + namespace JSX { + interface IntrinsicElements { + 'scrambler-element': React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + > & { + value?: string; + unit?: string; + duration?: string; + locale?: string; + 'decimal-places'?: string; + ref?: React.Ref; + }; + } + } +} + +function ValueDisplay({ value, title, decimalPlaces, unit, locale = 'en-GB' }: ValueDisplayProps) { const scrambler = useRef(null); // For numbers, pass the raw number to the scrambler element for proper animation // The scrambler will handle formatting internally - const scramblerValue = typeof value === 'number' ? - (typeof decimalPlaces === 'number' ? value.toFixed(decimalPlaces) : String(value)) : - String(value); + const scramblerValue = useMemo(() => + typeof value === 'number' ? + (typeof decimalPlaces === 'number' ? value.toFixed(decimalPlaces) : String(value)) : + String(value) + , [value, decimalPlaces]); // Update the scrambler element when value changes useEffect(() => { @@ -35,9 +55,11 @@ export default function ValueDisplay({ value, title, decimalPlaces, unit, locale }, []); return ( -
+
{title}
); -} \ No newline at end of file +} + +export default memo(ValueDisplay); \ No newline at end of file diff --git a/src/features/iss-tracker/Map.tsx b/src/features/iss-tracker/Map.tsx index d9c6320..58aa0f3 100644 --- a/src/features/iss-tracker/Map.tsx +++ b/src/features/iss-tracker/Map.tsx @@ -1,13 +1,14 @@ -import { use, Suspense, JSX, useEffect } from 'react'; -import { fetchCountries } from '../../services/CountriesService'; +import { use, Suspense, useEffect } from 'react'; +import { fetchCountries } from '@services/CountriesService'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; -import { useAsyncData } from '../../hooks/useAsyncData'; +import { useAsyncData } from '@hooks/useAsyncData'; import './map.css'; import { MapWrapper } from './components/MapWrapper'; -import { WindowStateContext } from '../../context/WindowState'; -import { useIsPageFocused } from '../../hooks/useIsPageFocused'; +import { WindowStateContext } from '@context/WindowState'; +import { useIsPageFocused } from '@hooks/useIsPageFocused'; +import OrbitalControlPanel from '@components/OrbitalControlPanel/OrbitalControlPanel'; -function fallbackRender({ error, resetErrorBoundary }: FallbackProps): JSX.Element { +function fallbackRender({ error, resetErrorBoundary }: FallbackProps) { return (

Something went wrong:

@@ -28,6 +29,7 @@ export default function Map(): JSX.Element { const activeClassName = 'out-of-focus ' + (isActive ? 'active' : 'inactive'); return (
+ (0); const selectedCity = useRef(getCityFromId(countries, selectedCountryIndex)); @@ -22,14 +21,19 @@ export default function FuturePass({ countries }: FuturePassProps): JSX.Element setSelectedCountryIndex(newIndex); selectedCity.current = getCityFromId(countries, newIndex); }, [countries]); + + const countryOptions = useMemo(() => + countries.map((country, index) => ( + + )), + [countries] + ); return (
}> {selectedCity.current && `${selectedCity.current.capital}: `} @@ -37,4 +41,6 @@ export default function FuturePass({ countries }: FuturePassProps): JSX.Element
); -} \ No newline at end of file +} + +export default memo(FuturePass); \ No newline at end of file diff --git a/src/features/iss-tracker/components/MapArea/MapArea.tsx b/src/features/iss-tracker/components/MapArea/MapArea.tsx index 60bd913..3bf6e0a 100644 --- a/src/features/iss-tracker/components/MapArea/MapArea.tsx +++ b/src/features/iss-tracker/components/MapArea/MapArea.tsx @@ -1,19 +1,16 @@ -import React, { useState, useEffect, useMemo, use, useCallback } from "react"; +import React, { useState, useEffect, useMemo, use, useCallback, memo } from "react"; import { MapContainer, Marker, TileLayer } from "react-leaflet"; import "leaflet/dist/leaflet.css"; -import { fetchCurrentTelemetry } from '../../../../services/IssService'; -import { usePolling } from '../../../../hooks/usePolling'; +import { fetchCurrentTelemetry } from '@services/IssService'; +import { usePolling } from '@hooks/usePolling'; import Loader from '../Loader/Loader'; import L from 'leaflet'; import iss from './iss.png'; -import { useIsPageFocused } from '../../../../hooks/useIsPageFocused'; -import { Country } from '../../../../types/country'; -import { Coordinates } from '../../../../types/coordinates'; -import { getClosestCapital } from '../../../../utils/countries/getClosestCapital'; +import { getClosestCapital } from '@utils/countries/getClosestCapital'; import ValueDisplay from '../ValueDisplay'; -import { ISSStats } from '../../../../types/ISSStats'; -import speedFromUnit from '../../../../utils/iss/speedFromUnit'; -import { WindowStateContext } from '../../../../context/WindowState'; +import { WindowStateContext } from '@context/WindowState'; +import OrbitalVisualization from '../OrbitalPath/OrbitalPath'; +import { useOrbitalData } from '@hooks/useOrbitalData'; interface DisplayPositionProps { map: L.Map; @@ -58,7 +55,7 @@ function DisplayPosition({ map, position }: DisplayPositionProps) { return null; } -function MapContainerWrapper({ position, setMap, loadCallback }: MapContainerWrapperProps) { +const MapContainerWrapper = memo(function MapContainerWrapper({ position, setMap, loadCallback }: MapContainerWrapperProps) { const handleLoad = useCallback(() => { loadCallback && loadCallback(true); }, [loadCallback]); @@ -76,16 +73,62 @@ function MapContainerWrapper({ position, setMap, loadCallback }: MapContainerWra attribution='© OpenStreetMap contributors' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> + ); -} +}); function MapInner({ countries, currentTelemetryPromise, mapReady }: MapInnerProps) { const telemetry = use(currentTelemetryPromise); - const closestCityName = getClosestCapital({ countries, position: telemetry }); + + // Handle case where telemetry is undefined or API failed or using fallback data + if (!telemetry || !telemetry.units || telemetry.isFallbackData) { + return ( +
+
+

🛰️ ISS Tracking Server Unreachable

+

Cannot connect to the ISS tracking service.

+

+ The server at wheretheiss.at appears to be down or unreachable. +
+ This is typically a temporary connectivity issue. +
+ Please check your internet connection and try refreshing the page. +

+
+
+ ); + } + + const closestCityName = useMemo(() => + getClosestCapital({ countries, position: telemetry }), + [countries, telemetry] + ); const [map, setMap] = useState(null); - const unit = telemetry.units.slice(0, -1) + '-per-hour'; + const unit = useMemo(() => + telemetry.units.slice(0, -1) + '-per-hour', + [telemetry.units] + ); + + // Initialize orbital data hook + const { updateCurrentPosition } = useOrbitalData(); + + // Update orbital store when telemetry changes + useEffect(() => { + if (telemetry) { + updateCurrentPosition(telemetry); + } + }, [telemetry, updateCurrentPosition]); const displayMap = useMemo( () => ( telemetry && diff --git a/src/features/iss-tracker/components/MapWrapper.tsx b/src/features/iss-tracker/components/MapWrapper.tsx index 0d583a8..e73d235 100644 --- a/src/features/iss-tracker/components/MapWrapper.tsx +++ b/src/features/iss-tracker/components/MapWrapper.tsx @@ -1,8 +1,7 @@ import { use, Suspense, JSX } from 'react'; -import Loader from '../../../components/Loader/Loader'; +import Loader from '@components/Loader/Loader'; import FuturePass from './FuturePass'; import MapArea from './MapArea/MapArea'; -import { Country } from '../../../types/country'; interface MapWrapperProps { countriesPromise: Promise; diff --git a/src/features/iss-tracker/components/OrbitalPath/OrbitalPath.tsx b/src/features/iss-tracker/components/OrbitalPath/OrbitalPath.tsx new file mode 100644 index 0000000..491d54b --- /dev/null +++ b/src/features/iss-tracker/components/OrbitalPath/OrbitalPath.tsx @@ -0,0 +1,191 @@ +import React, { useMemo, useCallback, memo } from 'react'; +import { Polyline, CircleMarker, Tooltip } from 'react-leaflet'; +import type { LatLngExpression } from 'leaflet'; +import { useOrbitalStore, type OrbitalPosition } from '@stores/orbitalStore'; + +interface OrbitalPathProps { + positions: OrbitalPosition[]; + color: string; + opacity: number; + isHistorical?: boolean; +} + +const OrbitalPath: React.FC = memo(({ + positions, + color, + opacity, + isHistorical = false +}) => { + // Convert positions to Leaflet LatLng format + const pathCoordinates = useMemo((): LatLngExpression[] => { + return positions.map(pos => [pos.latitude, pos.longitude] as LatLngExpression); + }, [positions]); + + // Create markers for significant positions (every 10th position to avoid clutter) + const significantPositions = useMemo(() => { + return positions.filter((_, index) => index % 10 === 0); + }, [positions]); + + const formatTime = useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString(); + }, []); + + const formatDate = useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleDateString(); + }, []); + + if (pathCoordinates.length < 2) { + return null; + } + + return ( + <> + {/* Main orbital path */} + + + {/* Position markers */} + {significantPositions.map((position, index) => ( + + +
+ {isHistorical ? 'Historical' : 'Predicted'} Position +
+ Time: {formatTime(position.timestamp)} +
+ Date: {formatDate(position.timestamp)} +
+ Lat: {position.latitude.toFixed(4)}° +
+ Lng: {position.longitude.toFixed(4)}° +
+ Alt: {position.altitude.toFixed(2)} km +
+
+
+ ))} + + ); +}); + +/** + * Ground track visualisation component + */ +interface GroundTrackProps { + currentPosition: OrbitalPosition | null; + color: string; + opacity: number; +} + +const GroundTrack: React.FC = memo(({ + currentPosition, + color, + opacity +}) => { + if (!currentPosition) return null; + + // Simple ground track approximation + // In reality, this would require complex orbital mechanics calculations + const groundTrackPoints = useMemo((): LatLngExpression[] => { + const points: LatLngExpression[] = []; + const startLng = currentPosition.longitude; + + // Approximate ISS ground track (moves west to east) + for (let i = -180; i <= 180; i += 5) { + // Simplified sine wave pattern for ground track + const lat = Math.sin((i * Math.PI) / 180) * 51.6; // ISS max latitude ~51.6° + points.push([lat, i] as LatLngExpression); + } + + return points; + }, [currentPosition]); + + return ( + + ); +}); + +/** + * Main orbital visualisation container component + */ +export default function OrbitalVisualization() { + const { + settings, + currentPosition, + historicalPositions, + futurePredictions, + isHistoricalTrackingEnabled, + } = useOrbitalStore(); + + if (!isHistoricalTrackingEnabled) { + return null; + } + + return ( + <> + {/* Historical path */} + {settings.showHistoricalPath && historicalPositions.length > 0 && ( + + )} + + {/* Future predictions */} + {settings.showFuturePredictions && futurePredictions.length > 0 && ( + + )} + + {/* Ground track */} + {settings.showGroundTrack && ( + + )} + + ); +} \ No newline at end of file diff --git a/src/features/iss-tracker/components/Position.tsx b/src/features/iss-tracker/components/Position.tsx index 1bce29e..5db86a9 100644 --- a/src/features/iss-tracker/components/Position.tsx +++ b/src/features/iss-tracker/components/Position.tsx @@ -1,5 +1,4 @@ import { JSX } from 'react'; -import { Coordinates } from '../../../types/coordinates'; // Define a type for the position prop interface PositionProps { diff --git a/src/features/iss-tracker/components/ValueDisplay.tsx b/src/features/iss-tracker/components/ValueDisplay.tsx index 7b366c4..024fcdf 100644 --- a/src/features/iss-tracker/components/ValueDisplay.tsx +++ b/src/features/iss-tracker/components/ValueDisplay.tsx @@ -31,7 +31,6 @@ export default function ValueDisplay({ value, title, decimalPlaces, unit, locale return (
{title}
- {/* {finalValue} */}
); diff --git a/src/hooks/useAsyncData.ts b/src/hooks/useAsyncData.ts index e690592..e795b3d 100644 --- a/src/hooks/useAsyncData.ts +++ b/src/hooks/useAsyncData.ts @@ -1,5 +1,4 @@ import { useState, useEffect, useRef } from 'react'; -import type { ApiArgs, ApiFetchFunction } from '../types/apiCallOptions'; export function useAsyncData( promiseFn: ApiFetchFunction, diff --git a/src/hooks/useOrbitalData.ts b/src/hooks/useOrbitalData.ts new file mode 100644 index 0000000..8bc7fce --- /dev/null +++ b/src/hooks/useOrbitalData.ts @@ -0,0 +1,211 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useOrbitalStore } from '@stores/orbitalStore'; +import { OrbitalDataService } from '@services/OrbitalDataService'; + +export function useOrbitalData() { + const { + settings, + isHistoricalTrackingEnabled, + currentPosition, + setCurrentPosition, + setHistoricalPositions, + setFuturePredictions, + setLoadingHistorical, + setLoadingPredictions, + resetVisualisation, + } = useOrbitalStore(); + + const orbitalService = useRef(new OrbitalDataService()); + const historicalAbortController = useRef(null); + const predictionsAbortController = useRef(null); + const updateInterval = useRef(null); + + // Fetch historical positions + const fetchHistoricalData = useCallback(async () => { + if (!isHistoricalTrackingEnabled || !settings.showHistoricalPath) { + return; + } + + // Cancel any existing request + if (historicalAbortController.current) { + historicalAbortController.current.abort(); + } + + historicalAbortController.current = new AbortController(); + setLoadingHistorical(true); + + try { + const positions = await orbitalService.current.getHistoricalPositions( + settings.pathDuration, + 'kilometers', + historicalAbortController.current.signal + ); + + if (!historicalAbortController.current.signal.aborted) { + setHistoricalPositions(positions); + } + } catch (error) { + if (!historicalAbortController.current?.signal.aborted) { + console.error('Failed to fetch historical positions:', error); + setHistoricalPositions([]); + } + } finally { + if (!historicalAbortController.current?.signal.aborted) { + setLoadingHistorical(false); + } + } + }, [ + isHistoricalTrackingEnabled, + settings.showHistoricalPath, + settings.pathDuration, + setHistoricalPositions, + setLoadingHistorical + ]); + + // Fetch future predictions + const fetchPredictionData = useCallback(async () => { + if (!isHistoricalTrackingEnabled || !settings.showFuturePredictions) return; + + // Cancel any existing request + if (predictionsAbortController.current) { + predictionsAbortController.current.abort(); + } + + predictionsAbortController.current = new AbortController(); + setLoadingPredictions(true); + + try { + const positions = await orbitalService.current.getFuturePositions( + settings.predictionDuration, + 'kilometers', + predictionsAbortController.current.signal + ); + + if (!predictionsAbortController.current.signal.aborted) { + setFuturePredictions(positions); + } + } catch (error) { + if (!predictionsAbortController.current?.signal.aborted) { + console.error('Failed to fetch future predictions:', error); + setFuturePredictions([]); + } + } finally { + if (!predictionsAbortController.current?.signal.aborted) { + setLoadingPredictions(false); + } + } + }, [ + isHistoricalTrackingEnabled, + settings.showFuturePredictions, + settings.predictionDuration, + setFuturePredictions, + setLoadingPredictions + ]); + + // Update data when current position changes + const handleCurrentPositionUpdate = useCallback((newPosition: ISSStats | null) => { + setCurrentPosition(newPosition); + + // Only fetch orbital data if historical tracking is enabled + if (isHistoricalTrackingEnabled) { + fetchHistoricalData(); + fetchPredictionData(); + } + }, [isHistoricalTrackingEnabled, fetchHistoricalData, fetchPredictionData, setCurrentPosition]); + + // Set up periodic updates + useEffect(() => { + if (!isHistoricalTrackingEnabled) { + // Clear any existing interval + if (updateInterval.current) { + clearInterval(updateInterval.current); + updateInterval.current = null; + } + return; + } + + // Initial data fetch + fetchHistoricalData(); + fetchPredictionData(); + + // Set up periodic updates + updateInterval.current = setInterval(() => { + fetchHistoricalData(); + fetchPredictionData(); + }, settings.updateInterval); + + return () => { + if (updateInterval.current) { + clearInterval(updateInterval.current); + updateInterval.current = null; + } + }; + }, [ + isHistoricalTrackingEnabled, + settings.updateInterval, + fetchHistoricalData, + fetchPredictionData + ]); + + // Handle settings changes + useEffect(() => { + if (!isHistoricalTrackingEnabled) return; + + // Refetch data when relevant settings change + const settingsRequiringRefetch = [ + settings.pathDuration, + settings.predictionDuration, + settings.showHistoricalPath, + settings.showFuturePredictions, + ]; + + fetchHistoricalData(); + fetchPredictionData(); + }, [ + settings.pathDuration, + settings.predictionDuration, + settings.showHistoricalPath, + settings.showFuturePredictions, + fetchHistoricalData, + fetchPredictionData, + isHistoricalTrackingEnabled + ]); + + // Clean up when historical tracking is disabled + useEffect(() => { + if (!isHistoricalTrackingEnabled) { + resetVisualisation(); + + // Cancel any ongoing requests + if (historicalAbortController.current) { + historicalAbortController.current.abort(); + historicalAbortController.current = null; + } + if (predictionsAbortController.current) { + predictionsAbortController.current.abort(); + predictionsAbortController.current = null; + } + } + }, [isHistoricalTrackingEnabled, resetVisualisation]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (updateInterval.current) { + clearInterval(updateInterval.current); + } + if (historicalAbortController.current) { + historicalAbortController.current.abort(); + } + if (predictionsAbortController.current) { + predictionsAbortController.current.abort(); + } + }; + }, []); + + return { + updateCurrentPosition: handleCurrentPositionUpdate, + refetchHistorical: fetchHistoricalData, + refetchPredictions: fetchPredictionData, + }; +} \ No newline at end of file diff --git a/src/hooks/usePolling.ts b/src/hooks/usePolling.ts index 9ef6471..e79b9b2 100644 --- a/src/hooks/usePolling.ts +++ b/src/hooks/usePolling.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { logger } from '../utils/logger'; +import { logger } from '@utils/logger'; type FetchFunction = (options: { signal: AbortSignal }) => Promise; diff --git a/src/lib/scrambler-element.ts b/src/lib/scrambler-element.ts index 9de2f99..007d3a9 100644 --- a/src/lib/scrambler-element.ts +++ b/src/lib/scrambler-element.ts @@ -1,3 +1,5 @@ +// Import type definitions to register them globally + const html = String.raw; const DEFAULT_LOCALE = 'en-GB'; @@ -243,12 +245,13 @@ class ScramblerElement extends HTMLElement implements ScramblerElement{ } animateValue() { - let startTimestamp; + let startTimestamp: number; const iterator = this.scrambleIteration; - const changeTime = this.duration / this.scrambleSource; - let stepTime = this.duration; + const duration = Number(this.duration); + const changeTime = duration / this.scrambleSource; + let stepTime = Number(duration); - const step = (timestamp) => { + const step = (timestamp: number) => { if (!startTimestamp) { startTimestamp = timestamp; } diff --git a/src/services/BaseService.ts b/src/services/BaseService.ts index 3336735..795a7f1 100644 --- a/src/services/BaseService.ts +++ b/src/services/BaseService.ts @@ -1,4 +1,4 @@ -import { logger } from '../utils/logger'; +import { logger } from '@utils/logger'; interface FetchOptions { transport?: RequestInit; diff --git a/src/services/CountriesService.ts b/src/services/CountriesService.ts index e21fd6a..b2f4d44 100644 --- a/src/services/CountriesService.ts +++ b/src/services/CountriesService.ts @@ -1,8 +1,6 @@ -import { COUNTRIES_SERVICE_URL } from '../apis/endpoints'; -import { sortCities } from '../utils/countries/sortCities'; +import { COUNTRIES_SERVICE_URL } from '@/apis/endpoints'; +import { sortCities } from '@utils/countries/sortCities'; import { fetchWithRetries } from './BaseService'; -import type { ApiArgs } from '../types/apiCallOptions'; -import type { Country } from '../types/country'; export function fetchCountries(opts?: ApiArgs): Promise { const transport = opts?.args?.transport || {}; diff --git a/src/services/IssService.ts b/src/services/IssService.ts index e961c19..a080dc3 100644 --- a/src/services/IssService.ts +++ b/src/services/IssService.ts @@ -1,15 +1,48 @@ -import { ISS_CURRENT_URL, ISS_FUTURE_URL } from '../apis/endpoints'; -import type { ISSStats } from '../types/ISSStats'; -import type { ApiArgs } from '../types/apiCallOptions'; -import type { Position } from '../types/coordinates'; -import { calcRiseTime } from '../utils/iss/calcRiseTime'; +import { ISS_CURRENT_URL, ISS_FUTURE_URL } from '@/apis/endpoints'; +import { calcRiseTime } from '@utils/iss/calcRiseTime'; import { fetchWithRetries } from './BaseService'; -export function fetchCurrentTelemetry(transport: Record = {}): Promise { - return fetchWithRetries( - { url: ISS_CURRENT_URL, transport }, - 3 - ); +export async function fetchCurrentTelemetry(transport: Record = {}): Promise { + try { + const result = await fetchWithRetries( + { url: ISS_CURRENT_URL, transport }, + 3 + ); + + // Handle case where fetchWithRetries returns undefined due to error + if (!result) { + throw new Error('ISS API returned no data'); + } + + // Validate that the result has the expected structure + if (!result.units || !result.name || typeof result.latitude !== 'number') { + throw new Error('ISS API returned invalid data structure'); + } + + return result; + } catch (error) { + console.error('ISS telemetry fetch failed:', error); + + // Return fallback data to prevent app crashes + // This represents a reasonable default position over the Pacific + // Adding a flag to indicate this is fallback data + return { + name: 'iss', + id: 25544, + latitude: 0, + longitude: 0, + altitude: 408.0, + velocity: 27.6, + visibility: 'unknown' as const, + footprint: 4621.8, + timestamp: new Date(), + daynum: Date.now() / 86400000 + 25544, // Approximate day number + solar_lat: 0, + solar_lon: 0, + units: 'kilometers' as const, + isFallbackData: true, // Flag to indicate server was unreachable + }; + } } export function buildFutureUrl(lat: number, lon: number): string { diff --git a/src/services/OrbitalDataService.test.ts b/src/services/OrbitalDataService.test.ts new file mode 100644 index 0000000..039da03 --- /dev/null +++ b/src/services/OrbitalDataService.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { OrbitalDataService } from './OrbitalDataService'; +import type { PositionsResponse, OrbitalPosition } from './OrbitalDataService'; + +// Mock the fetch function +global.fetch = vi.fn(); + +const mockFetch = vi.mocked(fetch); + +describe('OrbitalDataService', () => { + let service: OrbitalDataService; + let abortController: AbortController; + + beforeEach(() => { + service = new OrbitalDataService(); + abortController = new AbortController(); + vi.clearAllMocks(); + }); + + afterEach(() => { + abortController.abort(); + }); + + describe('generateHistoricalTimestamps', () => { + it('generates correct number of timestamps for historical data', async () => { + // Mock successful response + const mockResponse: PositionsResponse = { + id: 25544, + name: 'iss', + positions: [ + { + satlatitude: 51.5, + satlongitude: -0.1, + sataltitude: 408, + azimuth: 0, + elevation: 0, + ra: 0, + dec: 0, + timestamp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago + eclipsed: false, + } + ] + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await service.getHistoricalPositions(0.5, 'kilometers', abortController.signal); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('latitude'); + expect(result[0]).toHaveProperty('longitude'); + expect(result[0]).toHaveProperty('altitude'); + expect(result[0]).toHaveProperty('timestamp'); + }); + }); + + describe('getFuturePositions', () => { + it('fetches future positions successfully', async () => { + const mockResponse: PositionsResponse = { + id: 25544, + name: 'iss', + positions: [ + { + satlatitude: 48.8, + satlongitude: 2.3, + sataltitude: 410, + azimuth: 45, + elevation: 30, + ra: 12, + dec: 45, + timestamp: Math.floor(Date.now() / 1000) + 1800, // 30 minutes from now + eclipsed: false, + } + ] + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await service.getFuturePositions(0.5, 'kilometers', abortController.signal); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0].latitude).toBe(48.8); + expect(result[0].longitude).toBe(2.3); + expect(result[0].altitude).toBe(410); + }); + }); + + describe('convertToOrbitalPositions', () => { + it('converts API response to OrbitalPosition format correctly', async () => { + const mockApiResponse: PositionsResponse = { + id: 25544, + name: 'iss', + positions: [ + { + satlatitude: 40.7128, + satlongitude: -74.0060, + sataltitude: 415.5, + azimuth: 180, + elevation: 45, + ra: 90, + dec: 30, + timestamp: 1640995200, // Unix timestamp + eclipsed: true, + } + ] + }; + + // Mock multiple calls since getHistoricalPositions may make multiple requests + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + const result = await service.getHistoricalPositions(0.1, 'kilometers', abortController.signal); + + expect(result.length).toBeGreaterThan(0); + expect(result[0].latitude).toBe(40.7128); + expect(result[0].longitude).toBe(-74.0060); + expect(result[0].altitude).toBe(415.5); + expect(result[0].timestamp).toBe(1640995200 * 1000); // Converted to milliseconds + expect(result[0].velocity).toBeUndefined(); + }); + }); + + describe('getTLEData', () => { + it('fetches TLE data successfully', async () => { + const mockTLEResponse = { + satellite_id: 25544, + name: 'ISS (ZARYA)', + date: '2024-01-01T12:00:00Z', + line1: '1 25544U 98067A 24001.50000000 .00001234 00000-0 12345-4 0 9990', + line2: '2 25544 51.6400 123.4567 0001234 12.3456 78.9012 15.50000000123456' + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockTLEResponse, + } as Response); + + const result = await service.getTLEData(abortController.signal); + + expect(result).toEqual(mockTLEResponse); + expect(fetch).toHaveBeenCalledWith( + 'https://api.wheretheiss.at/v1/satellites/25544/tles?format=json', + expect.objectContaining({ + method: 'GET', + signal: abortController.signal, + }) + ); + }); + }); + + describe('calculateOrbitalPeriod', () => { + it('returns correct ISS orbital period', () => { + const period = service.calculateOrbitalPeriod(); + expect(period).toBe(92.68); + expect(typeof period).toBe('number'); + }); + }); + + describe('estimateGroundTrack', () => { + it('generates ground track points from orbital position', () => { + const position: OrbitalPosition = { + latitude: 0, + longitude: 0, + altitude: 408, + timestamp: Date.now() + }; + + const groundTrack = service.estimateGroundTrack(position); + + expect(Array.isArray(groundTrack)).toBe(true); + expect(groundTrack.length).toBe(100); + expect(groundTrack[0]).toHaveProperty('latitude'); + expect(groundTrack[0]).toHaveProperty('longitude'); + expect(groundTrack[0]).toHaveProperty('altitude'); + expect(groundTrack[0]).toHaveProperty('timestamp'); + + // Check longitude wrapping + groundTrack.forEach(point => { + expect(point.longitude).toBeGreaterThanOrEqual(-180); + expect(point.longitude).toBeLessThanOrEqual(180); + }); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + // Clear all mocks for error handling tests + vi.clearAllMocks(); + }); + + it('handles API errors gracefully', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + await expect( + service.getHistoricalPositions(0.1, 'kilometers', abortController.signal) + ).rejects.toThrow('HTTP Error: 500'); + }); + + it('handles network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect( + service.getFuturePositions(0.1, 'kilometers', abortController.signal) + ).rejects.toThrow('Network error'); + }); + + it('handles abort signals', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + service.getHistoricalPositions(1, 'kilometers', controller.signal) + ).rejects.toThrow('The operation was aborted.'); + }); + }); + + describe('rate limiting', () => { + it('respects API rate limits with delays between requests', async () => { + const mockResponse: PositionsResponse = { + id: 25544, + name: 'iss', + positions: [ + { + satlatitude: 0, + satlongitude: 0, + sataltitude: 408, + azimuth: 0, + elevation: 0, + ra: 0, + dec: 0, + timestamp: Math.floor(Date.now() / 1000), + eclipsed: false, + } + ] + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + // Test with a short duration to avoid long test times + const result = await service.getHistoricalPositions(0.1, 'kilometers', abortController.signal); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/services/OrbitalDataService.ts b/src/services/OrbitalDataService.ts new file mode 100644 index 0000000..b529f8b --- /dev/null +++ b/src/services/OrbitalDataService.ts @@ -0,0 +1,289 @@ +import type { OrbitalPosition } from '@stores/orbitalStore'; + +export interface TLEData { + satellite_id: number; + name: string; + date: string; + line1: string; + line2: string; +} + +export interface PositionsRequest { + timestamps: number[]; + units?: 'kilometers' | 'miles'; +} + +export interface PositionsResponse { + id: number; + name: string; + positions: Array<{ + satlatitude: number; + satlongitude: number; + sataltitude: number; + azimuth: number; + elevation: number; + ra: number; + dec: number; + timestamp: number; + eclipsed: boolean; + }>; +} + +/** + * Service for fetching ISS historical and predicted orbital data + */ +export class OrbitalDataService { + private static readonly ISS_ID = 25544; + private static readonly BASE_URL = 'https://api.wheretheiss.at/v1'; + private static readonly MAX_TIMESTAMPS_PER_REQUEST = 10; + + constructor() { + // Constructor for orbital data service + } + + /** + * Custom fetch with retries that maintains error propagation + */ + private async fetchWithRetry( + url: string, + options: RequestInit, + retries: number = 2 + ): Promise { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, options); + + if (response.ok) { + return response; + } + + // If this is the last attempt, throw the error + if (attempt === retries) { + throw new Error(`HTTP Error: ${response.status}`); + } + + // Wait before retrying (exponential backoff) + await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); + + } catch (error) { + // If this is the last attempt or an abort error, throw immediately + if (attempt === retries || (error as Error).name === 'AbortError') { + throw error; + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); + } + } + + throw new Error('Max retries exceeded'); + } + + /** + * Generate timestamps for historical data retrieval + */ + private generateHistoricalTimestamps(hours: number): number[] { + const now = Math.floor(Date.now() / 1000); + const hoursInSeconds = hours * 3600; + const interval = 300; // 5 minutes intervals + const timestamps: number[] = []; + + for (let i = hoursInSeconds; i >= 0; i -= interval) { + timestamps.push(now - i); + } + + return timestamps; + } + + /** + * Generate timestamps for future predictions + */ + private generateFutureTimestamps(hours: number): number[] { + const now = Math.floor(Date.now() / 1000); + const hoursInSeconds = hours * 3600; + const interval = 300; // 5 minutes intervals + const timestamps: number[] = []; + + for (let i = interval; i <= hoursInSeconds; i += interval) { + timestamps.push(now + i); + } + + return timestamps; + } + + /** + * Fetch ISS positions for specific timestamps + */ + private async fetchPositions( + timestamps: number[], + units: 'kilometers' | 'miles' = 'kilometers', + signal?: AbortSignal + ): Promise { + const timestampParams = timestamps.join(','); + const url = `${OrbitalDataService.BASE_URL}/satellites/${OrbitalDataService.ISS_ID}/positions?timestamps=${timestampParams}&units=${units}`; + + const response = await this.fetchWithRetry(url, { + method: 'GET', + signal, + }); + + const data = await response.json(); + + // Validate response structure + if (!data || typeof data !== 'object') { + throw new Error('Invalid response format from orbital data API'); + } + + return data; + } + + /** + * Convert API response to OrbitalPosition format + */ + private convertToOrbitalPositions(response: PositionsResponse): OrbitalPosition[] { + // Handle case where response or positions is undefined/null + if (!response || !response.positions || !Array.isArray(response.positions)) { + console.warn('Invalid or empty positions response:', response); + return []; + } + + return response.positions.map(pos => ({ + latitude: pos.satlatitude, + longitude: pos.satlongitude, + altitude: pos.sataltitude, + timestamp: pos.timestamp * 1000, // Convert to milliseconds + velocity: undefined, // Not provided by this endpoint + })); + } + + /** + * Fetch historical orbital positions + */ + async getHistoricalPositions( + hours: number = 2, + units: 'kilometers' | 'miles' = 'kilometers', + signal?: AbortSignal + ): Promise { + const timestamps = this.generateHistoricalTimestamps(hours); + const allPositions: OrbitalPosition[] = []; + + // Split timestamps into chunks to respect API limits + const chunks = []; + for (let i = 0; i < timestamps.length; i += OrbitalDataService.MAX_TIMESTAMPS_PER_REQUEST) { + chunks.push(timestamps.slice(i, i + OrbitalDataService.MAX_TIMESTAMPS_PER_REQUEST)); + } + + // Fetch positions for each chunk + for (const chunk of chunks) { + if (signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + + try { + const response = await this.fetchPositions(chunk, units, signal); + const positions = this.convertToOrbitalPositions(response); + allPositions.push(...positions); + + // Add small delay to respect rate limits (1 request per second) + if (chunks.indexOf(chunk) < chunks.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1100)); + } + } catch (error) { + if (signal?.aborted || (error as Error).name === 'AbortError') { + throw error; + } + throw error; + } + } + + return allPositions.sort((a, b) => a.timestamp - b.timestamp); + } + + /** + * Fetch future orbital predictions + */ + async getFuturePositions( + hours: number = 1, + units: 'kilometers' | 'miles' = 'kilometers', + signal?: AbortSignal + ): Promise { + const timestamps = this.generateFutureTimestamps(hours); + const allPositions: OrbitalPosition[] = []; + + // Split timestamps into chunks to respect API limits + const chunks = []; + for (let i = 0; i < timestamps.length; i += OrbitalDataService.MAX_TIMESTAMPS_PER_REQUEST) { + chunks.push(timestamps.slice(i, i + OrbitalDataService.MAX_TIMESTAMPS_PER_REQUEST)); + } + + // Fetch positions for each chunk + for (const chunk of chunks) { + if (signal?.aborted) break; + + try { + const response = await this.fetchPositions(chunk, units, signal); + const positions = this.convertToOrbitalPositions(response); + allPositions.push(...positions); + + // Add small delay to respect rate limits + if (chunks.indexOf(chunk) < chunks.length - 1) { + await new Promise(resolve => setTimeout(resolve, 1100)); + } + } catch (error) { + if (signal?.aborted) break; + throw error; + } + } + + return allPositions.sort((a, b) => a.timestamp - b.timestamp); + } + + /** + * Fetch TLE (Two-Line Element) data for orbital calculations + */ + async getTLEData(signal?: AbortSignal): Promise { + const url = `${OrbitalDataService.BASE_URL}/satellites/${OrbitalDataService.ISS_ID}/tles?format=json`; + + const response = await this.fetchWithRetry(url, { + method: 'GET', + signal, + }); + + return response.json(); + } + + /** + * Calculate orbital period from current position data + * ISS orbital period is approximately 90-93 minutes + */ + calculateOrbitalPeriod(): number { + return 92.68; // minutes - ISS average orbital period + } + + /** + * Estimate ground track based on orbital mechanics + */ + estimateGroundTrack(position: OrbitalPosition): OrbitalPosition[] { + const period = this.calculateOrbitalPeriod() * 60 * 1000; // Convert to milliseconds + const groundTrack: OrbitalPosition[] = []; + + // Simple approximation - ISS moves roughly 360 degrees longitude per orbit + const degreesPerMs = 360 / period; + const steps = 100; // Number of points in ground track + const stepTime = period / steps; + + for (let i = 0; i < steps; i++) { + const timeOffset = i * stepTime; + const longitudeOffset = i * degreesPerMs * stepTime; + + groundTrack.push({ + latitude: position.latitude, // Simplified - actual latitude changes + longitude: ((position.longitude + longitudeOffset) + 180) % 360 - 180, // Wrap longitude + altitude: position.altitude, + timestamp: position.timestamp + timeOffset, + }); + } + + return groundTrack; + } +} \ No newline at end of file diff --git a/src/stores/orbitalStore.test.ts b/src/stores/orbitalStore.test.ts new file mode 100644 index 0000000..081a862 --- /dev/null +++ b/src/stores/orbitalStore.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useOrbitalStore } from './orbitalStore'; +import type { OrbitalPosition, OrbitalSettings } from './orbitalStore'; + +describe('orbitalStore', () => { + beforeEach(() => { + // Reset store state before each test + useOrbitalStore.getState().resetVisualisation(); + useOrbitalStore.setState({ + isControlPanelOpen: false, + isHistoricalTrackingEnabled: false, + settings: { + showHistoricalPath: true, + showFuturePredictions: false, + showGroundTrack: true, + pathDuration: 2, + predictionDuration: 1, + updateInterval: 30000, + pathOpacity: 0.7, + pathColor: '#00ff00', + } + }); + }); + + it('initializes with default state', () => { + const state = useOrbitalStore.getState(); + + expect(state.isControlPanelOpen).toBe(false); + expect(state.isHistoricalTrackingEnabled).toBe(false); + expect(state.currentPosition).toBe(null); + expect(state.historicalPositions).toEqual([]); + expect(state.futurePredictions).toEqual([]); + expect(state.isLoadingHistorical).toBe(false); + expect(state.isLoadingPredictions).toBe(false); + }); + + it('toggles control panel state', () => { + const { toggleControlPanel } = useOrbitalStore.getState(); + + expect(useOrbitalStore.getState().isControlPanelOpen).toBe(false); + + toggleControlPanel(); + expect(useOrbitalStore.getState().isControlPanelOpen).toBe(true); + + toggleControlPanel(); + expect(useOrbitalStore.getState().isControlPanelOpen).toBe(false); + }); + + it('toggles historical tracking and clears data when disabled', () => { + const { toggleHistoricalTracking, setHistoricalPositions, setFuturePredictions } = useOrbitalStore.getState(); + + // Add some test data + const testPositions: OrbitalPosition[] = [ + { latitude: 51.5, longitude: 0, altitude: 408, timestamp: Date.now() } + ]; + setHistoricalPositions(testPositions); + setFuturePredictions(testPositions); + + expect(useOrbitalStore.getState().isHistoricalTrackingEnabled).toBe(false); + + // Enable tracking + toggleHistoricalTracking(); + expect(useOrbitalStore.getState().isHistoricalTrackingEnabled).toBe(true); + + // Disable tracking - should clear data + toggleHistoricalTracking(); + expect(useOrbitalStore.getState().isHistoricalTrackingEnabled).toBe(false); + expect(useOrbitalStore.getState().historicalPositions).toEqual([]); + expect(useOrbitalStore.getState().futurePredictions).toEqual([]); + }); + + it('updates settings correctly', () => { + const { updateSettings } = useOrbitalStore.getState(); + + const newSettings: Partial = { + pathDuration: 4, + pathColor: '#ff0000', + showFuturePredictions: true, + }; + + updateSettings(newSettings); + const state = useOrbitalStore.getState(); + + expect(state.settings.pathDuration).toBe(4); + expect(state.settings.pathColor).toBe('#ff0000'); + expect(state.settings.showFuturePredictions).toBe(true); + // Other settings should remain unchanged + expect(state.settings.showHistoricalPath).toBe(true); + expect(state.settings.pathOpacity).toBe(0.7); + }); + + it('manages current position', () => { + const { setCurrentPosition } = useOrbitalStore.getState(); + + const mockPosition = { + name: 'iss', + id: 25544, + latitude: 51.5074, + longitude: -0.1278, + altitude: 408.45, + velocity: 27.6, + visibility: 'daylight' as const, + footprint: 4621.8, + timestamp: new Date(), + daynum: 2460310.0, + solar_lat: -23.1, + solar_lon: 123.4, + units: 'kilometers' as const, + }; + + setCurrentPosition(mockPosition); + expect(useOrbitalStore.getState().currentPosition).toEqual(mockPosition); + + setCurrentPosition(null); + expect(useOrbitalStore.getState().currentPosition).toBe(null); + }); + + it('manages historical positions', () => { + const { setHistoricalPositions } = useOrbitalStore.getState(); + + const positions: OrbitalPosition[] = [ + { latitude: 51.5, longitude: 0, altitude: 408, timestamp: Date.now() }, + { latitude: 48.8, longitude: 2.3, altitude: 410, timestamp: Date.now() + 1000 }, + ]; + + setHistoricalPositions(positions); + expect(useOrbitalStore.getState().historicalPositions).toEqual(positions); + }); + + it('manages future predictions', () => { + const { setFuturePredictions } = useOrbitalStore.getState(); + + const predictions: OrbitalPosition[] = [ + { latitude: 40.7, longitude: -74, altitude: 412, timestamp: Date.now() + 60000 }, + { latitude: 35.6, longitude: 139.6, altitude: 415, timestamp: Date.now() + 120000 }, + ]; + + setFuturePredictions(predictions); + expect(useOrbitalStore.getState().futurePredictions).toEqual(predictions); + }); + + it('manages loading states', () => { + const { setLoadingHistorical, setLoadingPredictions } = useOrbitalStore.getState(); + + expect(useOrbitalStore.getState().isLoadingHistorical).toBe(false); + expect(useOrbitalStore.getState().isLoadingPredictions).toBe(false); + + setLoadingHistorical(true); + setLoadingPredictions(true); + + expect(useOrbitalStore.getState().isLoadingHistorical).toBe(true); + expect(useOrbitalStore.getState().isLoadingPredictions).toBe(true); + + setLoadingHistorical(false); + setLoadingPredictions(false); + + expect(useOrbitalStore.getState().isLoadingHistorical).toBe(false); + expect(useOrbitalStore.getState().isLoadingPredictions).toBe(false); + }); + + it('resets visualisation data', () => { + const { + setHistoricalPositions, + setFuturePredictions, + setLoadingHistorical, + setLoadingPredictions, + resetVisualisation + } = useOrbitalStore.getState(); + + // Set up some test data + const positions: OrbitalPosition[] = [ + { latitude: 51.5, longitude: 0, altitude: 408, timestamp: Date.now() } + ]; + + setHistoricalPositions(positions); + setFuturePredictions(positions); + setLoadingHistorical(true); + setLoadingPredictions(true); + + // Reset should clear everything + resetVisualisation(); + + const state = useOrbitalStore.getState(); + expect(state.historicalPositions).toEqual([]); + expect(state.futurePredictions).toEqual([]); + expect(state.isLoadingHistorical).toBe(false); + expect(state.isLoadingPredictions).toBe(false); + }); + + it('validates default settings', () => { + const { settings } = useOrbitalStore.getState(); + + expect(settings.showHistoricalPath).toBe(true); + expect(settings.showFuturePredictions).toBe(false); + expect(settings.showGroundTrack).toBe(true); + expect(settings.pathDuration).toBe(2); + expect(settings.predictionDuration).toBe(1); + expect(settings.updateInterval).toBe(30000); + expect(settings.pathOpacity).toBe(0.7); + expect(settings.pathColor).toBe('#00ff00'); + }); +}); \ No newline at end of file diff --git a/src/stores/orbitalStore.ts b/src/stores/orbitalStore.ts new file mode 100644 index 0000000..bd96c04 --- /dev/null +++ b/src/stores/orbitalStore.ts @@ -0,0 +1,120 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export interface OrbitalPosition { + latitude: number; + longitude: number; + altitude: number; + timestamp: number; + velocity?: number; +} + +export interface OrbitalSettings { + showHistoricalPath: boolean; + showFuturePredictions: boolean; + showGroundTrack: boolean; + pathDuration: number; // hours of historical data to show + predictionDuration: number; // hours of future predictions + updateInterval: number; // milliseconds + pathOpacity: number; // 0-1 + pathColor: string; +} + +export interface OrbitalVisualizationState { + // Settings + settings: OrbitalSettings; + isControlPanelOpen: boolean; + isHistoricalTrackingEnabled: boolean; + + // Data + currentPosition: ISSStats | null; + historicalPositions: OrbitalPosition[]; + futurePredictions: OrbitalPosition[]; + + // Loading states + isLoadingHistorical: boolean; + isLoadingPredictions: boolean; + + // Actions + toggleControlPanel: () => void; + toggleHistoricalTracking: () => void; + updateSettings: (settings: Partial) => void; + setCurrentPosition: (position: ISSStats | null) => void; + setHistoricalPositions: (positions: OrbitalPosition[]) => void; + setFuturePredictions: (positions: OrbitalPosition[]) => void; + setLoadingHistorical: (loading: boolean) => void; + setLoadingPredictions: (loading: boolean) => void; + resetVisualisation: () => void; +} + +const DEFAULT_SETTINGS: OrbitalSettings = { + showHistoricalPath: true, + showFuturePredictions: false, + showGroundTrack: true, + pathDuration: 2, // 2 hours of historical data + predictionDuration: 1, // 1 hour of predictions + updateInterval: 30000, // 30 seconds + pathOpacity: 0.7, + pathColor: '#00ff00', // Green for orbital path +}; + +export const useOrbitalStore = create()( + devtools( + (set, get) => ({ + // Initial state + settings: DEFAULT_SETTINGS, + isControlPanelOpen: false, + isHistoricalTrackingEnabled: false, + + currentPosition: null, + historicalPositions: [], + futurePredictions: [], + + isLoadingHistorical: false, + isLoadingPredictions: false, + + // Actions + toggleControlPanel: () => + set((state) => ({ isControlPanelOpen: !state.isControlPanelOpen })), + + toggleHistoricalTracking: () => + set((state) => ({ + isHistoricalTrackingEnabled: !state.isHistoricalTrackingEnabled, + // Clear data when disabling + ...(!state.isHistoricalTrackingEnabled ? {} : { + historicalPositions: [], + futurePredictions: [] + }) + })), + + updateSettings: (newSettings: Partial) => + set((state) => ({ + settings: { ...state.settings, ...newSettings } + })), + + setCurrentPosition: (position: ISSStats | null) => + set({ currentPosition: position }), + + setHistoricalPositions: (positions: OrbitalPosition[]) => + set({ historicalPositions: positions }), + + setFuturePredictions: (positions: OrbitalPosition[]) => + set({ futurePredictions: positions }), + + setLoadingHistorical: (loading: boolean) => + set({ isLoadingHistorical: loading }), + + setLoadingPredictions: (loading: boolean) => + set({ isLoadingPredictions: loading }), + + resetVisualisation: () => + set({ + historicalPositions: [], + futurePredictions: [], + isLoadingHistorical: false, + isLoadingPredictions: false + }), + }), + { name: 'orbital-visualisation' } + ) +); \ No newline at end of file diff --git a/src/styles/grid.css b/src/styles/grid.css index f633de8..37b2e38 100644 --- a/src/styles/grid.css +++ b/src/styles/grid.css @@ -21,7 +21,11 @@ align-items: stretch; justify-content: stretch; flex: 1; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(10px); + animation: slideIn 0.3s ease-out; } + .content { color: #fff; /* margin: 5px; */ diff --git a/src/test/utils.tsx b/src/test/utils.tsx index c6d6594..110b2b9 100644 --- a/src/test/utils.tsx +++ b/src/test/utils.tsx @@ -1,6 +1,6 @@ import { render, RenderOptions } from '@testing-library/react'; import { ReactElement } from 'react'; -import { WindowStateProvider } from '../context/WindowState'; +import { WindowStateProvider } from '@context/WindowState'; /** * Custom render function that includes common providers diff --git a/src/types/ISSStats.d.ts b/src/types/ISSStats.d.ts new file mode 100644 index 0000000..e223fa0 --- /dev/null +++ b/src/types/ISSStats.d.ts @@ -0,0 +1,48 @@ +declare global { + /** + * Visibility state of the ISS + */ + type ISSVisibility = 'daylight' | 'eclipsed'; + + /** + * Units used for measurements + */ + type Units = 'kilometers' | 'miles'; + + /** + * International Space Station telemetry data + */ + interface ISSStats { + /** Satellite name */ + name: string; + /** Satellite ID */ + id: number; + /** Current latitude in decimal degrees */ + latitude: number; + /** Current longitude in decimal degrees */ + longitude: number; + /** Altitude in kilometers or miles */ + altitude: number; + /** Velocity in km/h or mph */ + velocity: number; + /** Current visibility state */ + visibility: ISSVisibility; + /** Ground footprint radius in kilometers or miles */ + footprint: number; + /** Timestamp of the data */ + timestamp: Date; + /** Julian day number */ + daynum: number; + /** Solar latitude */ + solar_lat: number; + /** Solar longitude */ + solar_lon: number; + /** Units used for measurements */ + units: Units; + /** Flag indicating if this is fallback data due to API failure */ + isFallbackData?: boolean; + } +} + +export {}; + \ No newline at end of file diff --git a/src/types/ISSStats.ts b/src/types/ISSStats.ts deleted file mode 100644 index f849695..0000000 --- a/src/types/ISSStats.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Visibility state of the ISS - */ -export type ISSVisibility = 'daylight' | 'eclipsed'; - -/** - * Units used for measurements - */ -export type Units = 'kilometers' | 'miles'; - -/** - * International Space Station telemetry data - */ -export interface ISSStats { - /** Satellite name */ - name: string; - /** Satellite ID */ - id: number; - /** Current latitude in decimal degrees */ - latitude: number; - /** Current longitude in decimal degrees */ - longitude: number; - /** Altitude in kilometers or miles */ - altitude: number; - /** Velocity in km/h or mph */ - velocity: number; - /** Current visibility state */ - visibility: ISSVisibility; - /** Ground footprint radius in kilometers or miles */ - footprint: number; - /** Timestamp of the data */ - timestamp: Date; - /** Julian day number */ - daynum: number; - /** Solar latitude */ - solar_lat: number; - /** Solar longitude */ - solar_lon: number; - /** Units used for measurements */ - units: Units; -} - \ No newline at end of file diff --git a/src/types/apiCallOptions.d.ts b/src/types/apiCallOptions.d.ts new file mode 100644 index 0000000..d2d73fa --- /dev/null +++ b/src/types/apiCallOptions.d.ts @@ -0,0 +1,32 @@ +declare global { + /** + * Transport options for API calls + */ + interface Transport { + signal?: AbortSignal; + [key: string]: unknown; + } + + /** + * Options for API calls including arguments and transport settings + */ + interface ApiCallOptions { + args?: Record; + transport?: Transport; + } + + /** + * Arguments passed to API functions + */ + interface ApiArgs { + args?: Record; + signal?: AbortSignal; + } + + /** + * Generic API fetch function type + */ + type ApiFetchFunction = (opts?: ApiArgs) => Promise; +} + +export {}; \ No newline at end of file diff --git a/src/types/apiCallOptions.ts b/src/types/apiCallOptions.ts deleted file mode 100644 index bd92c25..0000000 --- a/src/types/apiCallOptions.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Transport options for API calls - */ -export interface Transport { - signal?: AbortSignal; - [key: string]: unknown; -} - -/** - * Options for API calls including arguments and transport settings - */ -export interface ApiCallOptions { - args?: Record; - transport?: Transport; -} - -/** - * Arguments passed to API functions - */ -export interface ApiArgs { - args?: Record; - signal?: AbortSignal; -} - -/** - * Generic API fetch function type - */ -export type ApiFetchFunction = (opts?: ApiArgs) => Promise; \ No newline at end of file diff --git a/src/types/components.d.ts b/src/types/components.d.ts new file mode 100644 index 0000000..8dc6224 --- /dev/null +++ b/src/types/components.d.ts @@ -0,0 +1,51 @@ +declare global { + /** + * Props for ValueDisplay component + */ + interface ValueDisplayProps { + /** The value to display (number or string) */ + value: string | number; + /** Title/label for the value */ + title: string; + /** Number of decimal places for numeric values */ + decimalPlaces?: number; + /** Unit to display with the value */ + unit?: string; + /** Locale for number formatting */ + locale?: string; + } + + /** + * Props for loading components + */ + interface LoaderProps { + /** Optional CSS class name */ + className?: string; + /** Loading message */ + message?: string; + } + + /** + * Error boundary fallback props + */ + interface ErrorFallbackProps { + /** The error that occurred */ + error: Error; + /** Function to reset the error boundary */ + resetErrorBoundary: () => void; + } + + /** + * Common React component props + */ + interface BaseComponentProps { + /** Optional CSS class name */ + className?: string; + /** Optional test ID for testing */ + testId?: string; + /** Child elements */ + children?: React.ReactNode; + } +} + +export {}; \ No newline at end of file diff --git a/src/types/components.ts b/src/types/components.ts deleted file mode 100644 index f369aee..0000000 --- a/src/types/components.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Common component prop types and interfaces - */ - -/** - * Props for ValueDisplay component - */ -export interface ValueDisplayProps { - /** The value to display (number or string) */ - value: string | number; - /** Title/label for the value */ - title: string; - /** Number of decimal places for numeric values */ - decimalPlaces?: number; - /** Unit to display with the value */ - unit?: string; - /** Locale for number formatting */ - locale?: string; -} - -/** - * Props for loading components - */ -export interface LoaderProps { - /** Optional CSS class name */ - className?: string; - /** Loading message */ - message?: string; -} - -/** - * Error boundary fallback props - */ -export interface ErrorFallbackProps { - /** The error that occurred */ - error: Error; - /** Function to reset the error boundary */ - resetErrorBoundary: () => void; -} - -/** - * Common React component props - */ -export interface BaseComponentProps { - /** Optional CSS class name */ - className?: string; - /** Optional test ID for testing */ - testId?: string; - /** Child elements */ - children?: React.ReactNode; -} \ No newline at end of file diff --git a/src/types/coordinates.d.ts b/src/types/coordinates.d.ts new file mode 100644 index 0000000..d4aaed2 --- /dev/null +++ b/src/types/coordinates.d.ts @@ -0,0 +1,20 @@ +declare global { + /** + * Geographic coordinates representing a position on Earth + */ + interface Coordinates { + /** Longitude in decimal degrees (-180 to 180) */ + longitude: number; + /** Latitude in decimal degrees (-90 to 90) */ + latitude: number; + } + + /** + * Position type used by Leaflet maps + */ + interface Position { + latlng: [number, number]; + } +} + +export {}; diff --git a/src/types/coordinates.ts b/src/types/coordinates.ts deleted file mode 100644 index 4165ba9..0000000 --- a/src/types/coordinates.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Geographic coordinates representing a position on Earth - */ -export interface Coordinates { - /** Longitude in decimal degrees (-180 to 180) */ - longitude: number; - /** Latitude in decimal degrees (-90 to 90) */ - latitude: number; -} - -/** - * Position type used by Leaflet maps - */ -export interface Position { - latlng: [number, number]; -} diff --git a/src/types/country.d.ts b/src/types/country.d.ts new file mode 100644 index 0000000..05b0618 --- /dev/null +++ b/src/types/country.d.ts @@ -0,0 +1,16 @@ +declare global { + /** + * Country data structure with geographic coordinates + */ + interface Country { + /** Coordinates as [latitude, longitude] tuple */ + latlng: [number, number]; + /** Country name */ + name: string; + /** Capital city name */ + capital: string; + } +} + +export {}; + \ No newline at end of file diff --git a/src/types/country.ts b/src/types/country.ts deleted file mode 100644 index 8a35cc3..0000000 --- a/src/types/country.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Country data structure with geographic coordinates - */ -export interface Country { - /** Coordinates as [latitude, longitude] tuple */ - latlng: [number, number]; - /** Country name */ - name: string; - /** Capital city name */ - capital: string; -} - \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index b600189..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Central export file for all type definitions - */ - -// API and service types -export type { - Transport, - ApiCallOptions, - ApiArgs, - ApiFetchFunction -} from './apiCallOptions'; - -// Geographic and coordinate types -export type { - Coordinates, - Position -} from './coordinates'; - -// Country data types -export type { - Country -} from './country'; - -// ISS data types -export type { - ISSStats, - ISSVisibility, - Units -} from './ISSStats'; - -// Component prop types -export type { - ValueDisplayProps, - LoaderProps, - ErrorFallbackProps, - BaseComponentProps -} from './components'; - -// Context types -export type { - WindowState, - WindowStateProviderProps -} from '../context/WindowState'; \ No newline at end of file diff --git a/src/types/react-jsx.d.ts b/src/types/react-jsx.d.ts new file mode 100644 index 0000000..f9b4b5a --- /dev/null +++ b/src/types/react-jsx.d.ts @@ -0,0 +1,19 @@ +import 'react'; + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'scrambler-element': React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + > & { + value?: string; + unit?: string; + duration?: string; + locale?: string; + 'decimal-places'?: string; + ref?: React.Ref; + }; + } + } +} \ No newline at end of file diff --git a/src/types/scrambler-element.d.ts b/src/types/scrambler-element.d.ts new file mode 100644 index 0000000..8a73170 --- /dev/null +++ b/src/types/scrambler-element.d.ts @@ -0,0 +1,41 @@ +/// + +declare global { + // Type definitions for scrambler-element custom web component + interface ScramblerElementAttributes { + value?: string; + unit?: string; + duration?: string; + locale?: string; + 'decimal-places'?: string; + } + + // Define the element interface extending HTMLElement + interface ScramblerElement extends HTMLElement { + value: string; + unit: string | null; + duration: string; + locale: string; + setAttribute(name: keyof ScramblerElementAttributes, value: string): void; + getAttribute(name: keyof ScramblerElementAttributes): string | null; + } + + // Module augmentation for HTMLElementTagNameMap + interface HTMLElementTagNameMap { + 'scrambler-element': ScramblerElement; + } + + // JSX namespace extension for React + namespace JSX { + interface IntrinsicElements { + 'scrambler-element': React.DetailedHTMLProps< + React.HTMLAttributes, + ScramblerElement + > & ScramblerElementAttributes & { + ref?: React.Ref; + }; + } + } +} + +export {}; \ No newline at end of file diff --git a/src/utils/countries/getCityFromId.ts b/src/utils/countries/getCityFromId.ts index c1dcaa9..fc9ed28 100644 --- a/src/utils/countries/getCityFromId.ts +++ b/src/utils/countries/getCityFromId.ts @@ -1,4 +1,3 @@ -import { Country } from '../../types/country'; export function getCityFromId(countries: Country[], id: number): Country | undefined { return countries[id]; diff --git a/src/utils/countries/getClosestCapital.ts b/src/utils/countries/getClosestCapital.ts index c8db195..0c6dd30 100644 --- a/src/utils/countries/getClosestCapital.ts +++ b/src/utils/countries/getClosestCapital.ts @@ -1,5 +1,3 @@ -import { Country } from '../../types/country'; -import { Coordinates } from '../../types/coordinates'; import { getClosestCountry } from './getClosestCountry'; interface CapitalProps { diff --git a/src/utils/countries/getClosestCountry.ts b/src/utils/countries/getClosestCountry.ts index 7cf603f..f745b6d 100644 --- a/src/utils/countries/getClosestCountry.ts +++ b/src/utils/countries/getClosestCountry.ts @@ -1,6 +1,5 @@ import { getDistance } from 'geolib'; -import { Country } from '../../types/country'; export function getClosestCountry( countries: Country[], diff --git a/src/utils/countries/sortCities.ts b/src/utils/countries/sortCities.ts index e7f3f8f..6162b4e 100644 --- a/src/utils/countries/sortCities.ts +++ b/src/utils/countries/sortCities.ts @@ -1,4 +1,3 @@ -import { Country } from '../../types/country'; export function sortCities(rawCountries: Country[] = []): Country[] { const comparer = new Intl.Collator('en').compare; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..96d47ee --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +/// + +// Import custom element type definitions +import './types/scrambler-element'; +import './types/react-jsx'; \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json index 1a4c43c..91b41c3 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -16,7 +16,18 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@features/*": ["./src/features/*"], + "@components/*": ["./src/components/*"], + "@hooks/*": ["./src/hooks/*"], + "@services/*": ["./src/services/*"], + "@stores/*": ["./src/stores/*"], + "@utils/*": ["./src/utils/*"], + "@context/*": ["./src/context/*"] + } }, "include": ["src"] } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d7a162c..1127a2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,17 @@ { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@features/*": ["./src/features/*"], + "@components/*": ["./src/components/*"], + "@hooks/*": ["./src/hooks/*"], + "@services/*": ["./src/services/*"], + "@stores/*": ["./src/stores/*"], + "@utils/*": ["./src/utils/*"], + "@context/*": ["./src/context/*"] + } + }, "files": [], "references": [ { "path": "./tsconfig.app.json" }, diff --git a/vite.config.js b/vite.config.js index 6f14178..53ff23e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import path from 'path'; export default defineConfig(() => { return { @@ -12,6 +13,19 @@ export default defineConfig(() => { }, plugins: [react()], base: '/iss-track-react/', + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@features': path.resolve(__dirname, './src/features'), + '@components': path.resolve(__dirname, './src/components'), + '@hooks': path.resolve(__dirname, './src/hooks'), + '@services': path.resolve(__dirname, './src/services'), + '@stores': path.resolve(__dirname, './src/stores'), + '@types': path.resolve(__dirname, './src/types'), + '@utils': path.resolve(__dirname, './src/utils'), + '@context': path.resolve(__dirname, './src/context'), + }, + }, test: { globals: true, environment: 'jsdom',