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 (
+
+ Enable Orbital Tracking
+
+ );
+}
+```
+
+#### 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
+
+ ×
+
+
+
+
+
+ )}
+
+ );
+}
\ 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) => (
+
+ {country.capital}
+
+ )),
+ [countries]
+ );
return (
- {countries.map((country, index) => (
-
- {country.capital}
-
- ))}
+ {countryOptions}
}>
{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',