Remove ais-test-map application, including dependencies, configuration, and source files.

This commit is contained in:
geoffsee
2025-07-21 19:45:35 -04:00
parent 81ca7ab06c
commit b62035b8b4
24 changed files with 738 additions and 7883 deletions

2
Cargo.lock generated
View File

@@ -9426,6 +9426,8 @@ checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
name = "yachtpit"
version = "0.1.0"
dependencies = [
"anyhow",
"base-map",
"bevy",
"bevy_asset_loader",
"bevy_flurx",

View File

@@ -1,228 +0,0 @@
# AIS Test Map
This is a separate test map implementation created to test displaying data from the AIS server. It's located in a separate directory from the main base-map to allow independent testing and development.
## Purpose
This test map was created to:
- Test AIS WebSocket server connectivity
- Display vessel data from the AIS stream
- Provide a simplified environment for debugging AIS data issues
- Serve as a reference implementation for AIS integration
## Issues Fixed
### React StrictMode Double Effect Issues
The primary issue was React's StrictMode in development mode causing double invocation of effects, leading to multiple simultaneous WebSocket connection attempts and immediate disconnections with errors like:
- "WebSocket is closed before the connection is established"
- Connection closed with error code 1006 (abnormal closure)
- "The network connection was lost"
### JSON Parsing Errors
The original implementation had JSON parsing errors when the AIS server sent plain text messages like "Connected to AIS stream". The error was:
```
Error parsing WebSocket message: SyntaxError: JSON Parse error: Unexpected identifier "Connected"
```
**Solution**: Implemented a React StrictMode-safe WebSocket connection with comprehensive state management and race condition prevention in `src/ais-provider.tsx`:
### Key Improvements:
1. **React StrictMode Protection**: Prevents multiple simultaneous connection attempts using `isConnectingRef` flag
2. **Component Mount Tracking**: Uses `isMountedRef` to prevent operations on unmounted components
3. **Connection State Guards**: Comprehensive checks before attempting connections or state updates
4. **Exponential Backoff Reconnection**: Automatic reconnection with increasing delays (1s, 2s, 4s, 8s, etc.)
5. **Connection Timeout Management**: 10-second timeout with proper cleanup to prevent hanging connections
6. **Graceful Message Handling**: Handles both JSON and plain text messages without errors
7. **Resource Cleanup**: Proper cleanup of all timeouts, event handlers, and connections
8. **Race Condition Prevention**: Multiple safeguards to prevent connection race conditions
```typescript
// React StrictMode-safe connection logic
const connectSocket = useCallback(() => {
// Prevent multiple simultaneous connection attempts (React StrictMode protection)
if (isConnectingRef.current) {
console.log('Connection attempt already in progress, skipping...');
return;
}
// Check if component is still mounted
if (!isMountedRef.current) {
console.log('Component unmounted, skipping connection attempt');
return;
}
isConnectingRef.current = true;
const ws = new WebSocket('ws://localhost:3000/ws');
wsRef.current = ws;
// Connection timeout with proper cleanup
connectionTimeoutRef.current = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING && isMountedRef.current) {
console.log('[TIMEOUT] Connection timeout, closing WebSocket');
isConnectingRef.current = false;
ws.close();
}
}, 10000);
ws.onopen = () => {
if (!isMountedRef.current) {
console.log('[OPEN] Component unmounted, closing connection');
ws.close();
return;
}
isConnectingRef.current = false;
// Handle successful connection...
};
// Robust message handling
ws.onmessage = (event) => {
try {
const messageData = event.data;
let data;
try {
data = JSON.parse(messageData);
} catch (parseError) {
console.log('Received plain text message:', messageData);
return;
}
// Handle JSON messages...
} catch (err) {
console.error('Error processing WebSocket message:', err);
}
};
}, []);
// Component cleanup with proper resource management
useEffect(() => {
isMountedRef.current = true;
// Small delay to prevent immediate double connection in StrictMode
const connectTimeout = setTimeout(() => {
if (isMountedRef.current) {
connectSocket();
}
}, 100);
return () => {
isMountedRef.current = false;
clearTimeout(connectTimeout);
// Clean up all resources...
};
}, [connectSocket]);
```
## Project Structure
```
ais-test-map/
├── src/
│ ├── App.tsx # Main application component
│ ├── MapComponent.tsx # Map display component
│ ├── ais-provider.tsx # AIS WebSocket provider (fixed)
│ └── main.tsx # Application entry point
├── public/
├── package.json
├── vite.config.ts
├── test-websocket.cjs # WebSocket connection test
└── README.md # This file
```
## Setup and Usage
### Prerequisites
- Node.js installed
- AIS WebSocket server running on `ws://localhost:3000/ws`
- Mapbox access token
### Installation
```bash
cd ais-test-map
npm install
```
### Running the Test Map
```bash
npm run dev
```
The map will be available at `http://localhost:5173`
### Testing WebSocket Connection
To test the WebSocket connection independently:
```bash
node test-websocket.cjs
```
This will:
- Connect to the AIS WebSocket server
- Send test messages (bounding box, start stream)
- Display received messages (both JSON and plain text)
- Verify the connection works without parsing errors
## Features
- **Real-time AIS Data**: Connects to WebSocket server for live vessel data
- **Interactive Map**: Mapbox-based map with vessel markers
- **Bounding Box Updates**: Automatically updates AIS data based on map viewport
- **Error Handling**: Robust error handling for connection issues
- **Connection Status**: Visual indicators for connection state
## Configuration
The AIS provider connects to `ws://localhost:3000/ws` by default. To change this, modify the WebSocket URL in `src/ais-provider.tsx`:
```typescript
const ws = new WebSocket('ws://your-server:port/ws');
```
## Troubleshooting
### Connection Issues
1. Verify the AIS server is running: `lsof -i :3000`
2. Check WebSocket connection: `node test-websocket.cjs`
3. Review browser console for detailed error messages
### No Vessel Data
1. Ensure the map viewport covers an area with AIS data
2. Check that the AIS stream has started (look for "Started AIS stream" in console)
3. Verify bounding box is being sent correctly
## Development Notes
This test map uses a simplified AIS provider compared to the main base-map implementation. Key differences:
- Simplified vessel data structure
- Direct WebSocket connection without complex reconnection logic
- Focused on testing and debugging rather than production use
## Testing Results
### React StrictMode Connection Test
```
🧪 Testing React StrictMode scenario (rapid double connections)...
Connection 1: ✅ Successful
Connection 2: ✅ Properly skipped (race condition prevented)
🔄 Testing sequential connections...
Sequential connection 1: ✅ Success
Sequential connection 2: ✅ Success
Sequential connection 3: ✅ Success
📈 Final Statistics:
Total connection attempts: 4
Successful connections: 4
Failed connections: 0
Success rate: 100.0%
🎉 All tests passed! React StrictMode fixes are working correctly.
```
### Connection Stability Verification
**React StrictMode Protection**: Double effects properly handled without race conditions
**WebSocket Connection**: Stable connections without immediate disconnections
**Message Handling**: Both JSON and plain text messages processed correctly
**Reconnection Logic**: Exponential backoff working with proper cleanup
**Resource Management**: All timeouts and connections properly cleaned up
**Bounding Box Updates**: Map viewport changes trigger correct AIS data updates
**AIS Stream**: Stream initialization and data flow working correctly

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AIS Test Map</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +0,0 @@
{
"name": "ais-test-map",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint ",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@chakra-ui/react": "^3.21.1",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.29.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"jsdom": "^26.1.0",
"mapbox-gl": "^3.13.0",
"react-map-gl": "^8.0.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.0.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
}

View File

@@ -1,168 +0,0 @@
import 'mapbox-gl/dist/mapbox-gl.css';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import Map, { Marker, Popup, NavigationControl, ScaleControl, type MapRef } from 'react-map-gl/mapbox';
import { useAISProvider, type VesselData } from './ais-provider';
import VesselMarker from './vessel-marker';
// Mapbox token (base64 encoded)
const key = 'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
const MAPBOX_TOKEN = atob(key);
const App: React.FC = () => {
const [boundingBox, setBoundingBox] = useState<{
sw_lat: number;
sw_lon: number;
ne_lat: number;
ne_lon: number;
} | undefined>(undefined);
const [vesselPopup, setVesselPopup] = useState<VesselData | null>(null);
const mapRef = useRef<MapRef | null>(null);
// Use the AIS provider
const { vessels, isConnected, error, connectionStatus, updateBoundingBox } = useAISProvider(boundingBox);
// Update bounding box when map moves
const handleMapMove = useCallback(() => {
if (mapRef.current) {
const map = mapRef.current.getMap();
const bounds = map.getBounds();
if (bounds) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const newBoundingBox = {
sw_lat: sw.lat,
sw_lon: sw.lng,
ne_lat: ne.lat,
ne_lon: ne.lng
};
setBoundingBox(newBoundingBox);
updateBoundingBox(newBoundingBox);
}
}
}, [updateBoundingBox]);
// Initialize bounding box when map loads
const handleMapLoad = useCallback(() => {
handleMapMove();
}, [handleMapMove]);
// Create vessel markers
const vesselMarkers = useMemo(() =>
vessels.map((vessel) => (
<Marker
key={`vessel-${vessel.id}`}
longitude={vessel.longitude}
latitude={vessel.latitude}
anchor="center"
onClick={(e) => {
e.originalEvent.stopPropagation();
setVesselPopup(vessel);
}}
>
<VesselMarker
heading={vessel.heading}
color={getVesselColor(vessel.type)}
size={16}
/>
</Marker>
)),
[vessels]
);
return (
<div style={{ width: '100vw', height: '100vh', position: 'relative' }}>
{/* Status Panel */}
<div style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 1000,
background: 'rgba(255, 255, 255, 0.9)',
padding: '10px',
borderRadius: '5px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
minWidth: '200px'
}}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '16px' }}>AIS Test Map</h3>
<div><strong>Status:</strong> {connectionStatus}</div>
<div><strong>Vessels:</strong> {vessels.length}</div>
{error && <div style={{ color: 'red' }}><strong>Error:</strong> {error}</div>}
{isConnected && (
<div style={{ color: 'green', fontSize: '12px', marginTop: '5px' }}>
Connected to AIS server
</div>
)}
</div>
{/* Map */}
<Map
ref={mapRef}
initialViewState={{
latitude: 40.7128,
longitude: -74.0060,
zoom: 10
}}
style={{ width: '100%', height: '100%' }}
mapStyle="mapbox://styles/mapbox/standard"
mapboxAccessToken={MAPBOX_TOKEN}
onLoad={handleMapLoad}
onMoveEnd={handleMapMove}
>
<NavigationControl position="top-right" />
<ScaleControl />
{vesselMarkers}
{/* Vessel Popup */}
{vesselPopup && (
<Popup
longitude={vesselPopup.longitude}
latitude={vesselPopup.latitude}
anchor="bottom"
onClose={() => setVesselPopup(null)}
closeButton={true}
closeOnClick={false}
>
<div style={{ padding: '10px', minWidth: '200px' }}>
<h4 style={{ margin: '0 0 10px 0' }}>{vesselPopup.name}</h4>
<div><strong>MMSI:</strong> {vesselPopup.mmsi}</div>
<div><strong>Type:</strong> {vesselPopup.type}</div>
<div><strong>Speed:</strong> {vesselPopup.speed.toFixed(1)} knots</div>
<div><strong>Heading:</strong> {vesselPopup.heading}°</div>
<div><strong>Position:</strong> {vesselPopup.latitude.toFixed(4)}, {vesselPopup.longitude.toFixed(4)}</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '5px' }}>
Last update: {vesselPopup.lastUpdate.toLocaleTimeString()}
</div>
</div>
</Popup>
)}
</Map>
</div>
);
};
// Helper function to get vessel color based on type
const getVesselColor = (type: string): string => {
switch (type.toLowerCase()) {
case 'yacht':
case 'pleasure craft':
return '#00cc66';
case 'fishing vessel':
case 'fishing':
return '#ff6600';
case 'cargo':
case 'container':
return '#cc0066';
case 'tanker':
return '#ff0000';
case 'passenger':
return '#6600cc';
default:
return '#0066cc';
}
};
export default App;

View File

@@ -1,9 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,31 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
],
"exclude": ["**/*.test.ts"]
}

View File

@@ -1,8 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from "vite-tsconfig-paths"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
})

View File

@@ -11,7 +11,9 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0"
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@chakra-ui/react": "^3.21.1",
@@ -1712,6 +1714,12 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/core": {
"version": "1.12.14",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.14.tgz",
@@ -3983,6 +3991,66 @@
"dev": true,
"license": "ISC"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -5145,7 +5213,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
@@ -5869,6 +5936,68 @@
"dev": true,
"license": "ISC"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/sort-asc": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
@@ -6732,7 +6861,6 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -6767,6 +6895,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",

View File

@@ -16,7 +16,9 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0"
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@chakra-ui/react": "^3.21.1",

View File

@@ -1,229 +1,24 @@
import 'mapbox-gl/dist/mapbox-gl.css';
import {Box, Button, HStack, Input, Text} from '@chakra-ui/react';
import { useColorMode } from './components/ui/color-mode';
import {Box, Button, HStack, Text} from '@chakra-ui/react';
import {useColorMode} from './components/ui/color-mode';
import {useCallback, useEffect, useState} from "react";
import MapNext, {type Geolocation} from "@/MapNext.tsx";
import { getNeumorphicStyle, getNeumorphicColors } from './theme/neumorphic-theme';
import MapNext from "@/MapNext.tsx";
import {getNeumorphicColors, getNeumorphicStyle} from './theme/neumorphic-theme';
import {layers, LayerSelector} from "@/LayerSelector.tsx";
import {useAISProvider, type VesselData} from './ais-provider';
import type {GpsPosition, VesselStatus} from './types';
import {GpsFeed} from "@/components/map/GpsFeedInfo.tsx";
import {AisFeed} from './components/map/AisFeedInfo';
import {Search} from "@/components/map/Search.tsx";
import {SearchResult} from "@/components/map/SearchResult.tsx";
import {NativeGeolocation} from "@/CustomGeolocate.ts";
// public key
const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
// const vesselLayerStyle: CircleLayerSpecification = {
// id: 'vessel',
// type: 'circle',
// paint: {
// 'circle-radius': 8,
// 'circle-color': '#ff4444',
// 'circle-stroke-width': 2,
// 'circle-stroke-color': '#ffffff'
// },
// source: ''
// };
// Types for bevy_flurx_ipc communication
interface GpsPosition {
latitude: number;
longitude: number;
zoom: number;
}
interface VesselStatus {
latitude: number;
longitude: number;
heading: number;
speed: number;
}
class MyGeolocation implements Geolocation {
constructor({clearWatch, getCurrentPosition, watchPosition}: {
clearWatch: (watchId: number) => void;
getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => void;
watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => number;
}) {
this.clearWatch = clearWatch;
this.watchPosition = watchPosition;
this.getCurrentPosition = getCurrentPosition;
}
clearWatch(_watchId: number): void {
throw new Error('Method not implemented.');
}
getCurrentPosition(_successCallback: PositionCallback, _errorCallback?: PositionErrorCallback | null, _options?: PositionOptions): void {
throw new Error('Method not implemented.');
}
watchPosition(_successCallback: PositionCallback, _errorCallback?: PositionErrorCallback | null, _options?: PositionOptions): number {
throw new Error('Method not implemented.');
}
}
const custom_geolocation = new MyGeolocation({
clearWatch: (watchId: number) => {
if (typeof window !== 'undefined' && (window as any).geolocationWatches) {
const interval = (window as any).geolocationWatches.get(watchId);
if (interval) {
clearInterval(interval);
(window as any).geolocationWatches.delete(watchId);
}
}
},
watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => {
if (typeof window === 'undefined') return 0;
// Initialize watches map if it doesn't exist
if (!(window as any).geolocationWatches) {
(window as any).geolocationWatches = new Map();
}
if (!(window as any).geolocationWatchId) {
(window as any).geolocationWatchId = 0;
}
const watchId = ++(window as any).geolocationWatchId;
const pollPosition = async () => {
if ((window as any).__FLURX__) {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
}
};
// Poll immediately and then at intervals
pollPosition();
const interval = setInterval(pollPosition, options?.timeout || 5000);
(window as any).geolocationWatches.set(watchId, interval);
return watchId;
},
getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, _options?: PositionOptions) => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
(async () => {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
})();
} else if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: '__FLURX__ not available',
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
},
});
// interface MapViewParams {
// latitude: number;
// longitude: number;
// zoom: number;
// }
// interface AuthParams {
// authenticated: boolean;
// token: string | null;
// }
function App() {
const { colorMode } = useColorMode();
const {colorMode} = useColorMode();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState('');
@@ -234,47 +29,174 @@ function App() {
zoom: 14
});
// Map state that can be updated from Rust
// const [mapView, setMapView] = useState({
// longitude: -122.4,
// latitude: 37.8,
// zoom: 14
// });
const custom_geolocation = new NativeGeolocation({
clearWatch: (watchId: number) => {
if (typeof window !== 'undefined' && (window as any).geolocationWatches) {
const interval = (window as any).geolocationWatches.get(watchId);
if (interval) {
clearInterval(interval);
(window as any).geolocationWatches.delete(watchId);
}
}
},
watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => {
if (typeof window === 'undefined') return 0;
// Initialize watches map if it doesn't exist
if (!(window as any).geolocationWatches) {
(window as any).geolocationWatches = new Map();
}
if (!(window as any).geolocationWatchId) {
(window as any).geolocationWatchId = 0;
}
const watchId = ++(window as any).geolocationWatchId;
const pollPosition = async () => {
if ((window as any).__FLURX__) {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
}
};
// Poll immediately and then at intervals
pollPosition();
const interval = setInterval(pollPosition, options?.timeout || 5000);
(window as any).geolocationWatches.set(watchId, interval);
return watchId;
},
getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, _options?: PositionOptions) => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
(async () => {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
})();
} else if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: '__FLURX__ not available',
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
},
});
// Vessel position state
const [vesselPosition, setVesselPosition] = useState<VesselStatus | null>(null);
// Create vessel geojson data
// const vesselGeojson: FeatureCollection = {
// type: 'FeatureCollection',
// features: vesselPosition ? [
// {
// type: 'Feature',
// geometry: {
// type: 'Point',
// coordinates: [vesselPosition.longitude, vesselPosition.latitude]
// },
// properties: {
// title: 'Vessel Position',
// heading: vesselPosition.heading,
// speed: vesselPosition.speed
// }
// }
// ] : []
// };
// AIS state management
const [aisEnabled, setAisEnabled] = useState(false);
const [boundingBox, _setBoundingBox] = useState<{
sw_lat: number;
sw_lon: number;
ne_lat: number;
ne_lon: number;
} | undefined>(undefined);
const [vesselPopup, setVesselPopup] = useState<VesselData | null>(null);
// Button click handlers
// const handleNavigationClick = useCallback(async () => {
// if (typeof window !== 'undefined' && (window as any).__FLURX__) {
// try {
// await (window as any).__FLURX__.invoke("navigation_clicked");
// console.log('Navigation clicked');
// } catch (error) {
// console.error('Failed to invoke navigation_clicked:', error);
// }
// }
// }, []);
// Use the AIS provider when enabled
const {
vessels,
isConnected: aisConnected,
error: aisError,
connectionStatus
} = useAISProvider(aisEnabled ? boundingBox : undefined);
const selectSearchResult = useCallback(async (searchResult: { lat: string, lon: string }) => {
@@ -300,9 +222,9 @@ function App() {
}),
});
const coordinates = await geocode.json();
const { lat, lon } = coordinates;
const {lat, lon} = coordinates;
console.log(`Got geocode coordinates: ${lat}, ${lon}`);
setSearchResults([{ lat, lon }]);
setSearchResults([{lat, lon}]);
} catch (e) {
console.error('Geocoding failed:', e);
// Continue without results
@@ -327,25 +249,6 @@ function App() {
console.log('Layer changed to:', layer.name);
}, []);
// const handleMapViewChange = useCallback(async (evt: any) => {
// const { longitude, latitude, zoom } = evt.viewState;
// setMapView({ longitude, latitude, zoom });
//
// if (typeof window !== 'undefined' && (window as any).__FLURX__) {
// try {
// const mapViewParams: MapViewParams = {
// latitude,
// longitude,
// zoom
// };
// await (window as any).__FLURX__.invoke("map_view_changed", mapViewParams);
// console.log('Map view changed:', mapViewParams);
// } catch (error) {
// console.error('Failed to invoke map_view_changed:', error);
// }
// }
// }, []);
// Poll for vessel status updates
useEffect(() => {
const pollVesselStatus = async () => {
@@ -374,11 +277,6 @@ function App() {
try {
const mapInit: GpsPosition = await (window as any).__FLURX__.invoke("get_map_init");
console.log('Map initialization data:', mapInit);
// setMapView({
// latitude: mapInit.latitude,
// longitude: mapInit.longitude,
// zoom: mapInit.zoom
// });
} catch (error) {
console.error('Failed to get map initialization data:', error);
}
@@ -389,141 +287,103 @@ function App() {
}, []);
return (
/* Full-screen wrapper — fills the viewport and becomes the positioning context */
<Box w="100vw" h="100vh" position="relative" overflow="hidden">
{/* GPS Feed Display — absolutely positioned at top-right */}
<Box
position="absolute"
top={65}
right={4}
maxW="20%"
zIndex={1}
p={4}
>
{vesselPosition && (
<Box
position="absolute"
top={65}
right={4}
zIndex={1}
p={4}
fontSize="sm"
fontFamily="monospace"
minW="220px"
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
<Box fontWeight="bold" mb={3} fontSize="md">GPS Feed</Box>
<Box mb={1}>Lat: {vesselPosition.latitude.toFixed(6)}°</Box>
<Box mb={1}>Lon: {vesselPosition.longitude.toFixed(6)}°</Box>
<Box mb={1}>Heading: {vesselPosition.heading.toFixed(1)}°</Box>
<Box>Speed: {vesselPosition.speed.toFixed(1)} kts</Box>
</Box>
<GpsFeed vesselPosition={vesselPosition} colorMode={colorMode}/>
)}
{/* AIS Status Panel */}
{aisEnabled && (
<AisFeed
vesselPosition={vesselPosition}
colorMode={colorMode}
connectionStatus={connectionStatus}
vesselData={vessels}
aisError={aisError}
aisConnected={aisConnected}
/>
)}
</Box>
{/* Button bar — absolutely positioned inside the wrapper */}
<HStack position="absolute" top={4} right={4} zIndex={1}>
<Box
display="flex"
alignItems="center"
position="relative"
>
<Button
size="sm"
variant="surface"
onClick={handleSearchClick}
mr={2}
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
<Text>Search...</Text>
</Button>
{isSearchOpen && <Box
w="200px"
transform={`translateX(${isSearchOpen ? "0" : "100%"})`}
opacity={isSearchOpen ? 1 : 0}
onKeyDown={(e) => {
console.log(e);
if(e.key === 'Escape') {
setIsSearchOpen(false)
}
}}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark', 'pressed')}
>
<Input
placeholder="Search..."
size="sm"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyPress={async (e) => {
console.log(e);
if (e.key === 'Enter' && searchResults.length === 0 && searchInput.length > 2) {
await handleSearchClick()
<Search
onClick={handleSearchClick}
colorMode={colorMode}
searchOpen={isSearchOpen}
onKeyDown={(e) => {
console.log(e);
if (e.key === 'Escape') {
setIsSearchOpen(false)
}
}}
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyPress={async (e) => {
console.log(e);
if (e.key === 'Enter' && searchResults.length === 0 && searchInput.length > 2) {
await handleSearchClick()
}
}}
searchResults={searchResults}
callbackfn={(result, index) => {
const colors = getNeumorphicColors(colorMode as 'light' | 'dark');
return (
<SearchResult key={index} onKeyPress={async (e) => {
if (e.key === 'Enter' && searchResults.length > 0) {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}
}}
border="none"
{...getNeumorphicStyle(colorMode as 'light' | 'dark', 'pressed')}
/>
{searchResults.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
zIndex={2}
mt={2}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
{searchResults.map((result, index) => {
const colors = getNeumorphicColors(colorMode as 'light' | 'dark');
return (
<Box
key={index}
p={3}
cursor="pointer"
borderRadius="8px"
transition="all 0.2s ease-in-out"
onKeyPress={async (e) => {
console.log(e.key)
if (e.key === 'Enter' && searchResults.length > 0) {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}
}}
_hover={{
bg: colors.accent + '20',
transform: 'translateY(-1px)',
}}
onClick={async () => {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}}
>
{`${result.lat}, ${result.lon}`}
</Box>
);
})}
</Box>
)}
</Box>}
</Box>
<LayerSelector onClick={handleLayerChange} />
colors={colors}
onClick={async () => {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}}
result={result}
/>
);
}}/>
<Button
size="sm"
variant="surface"
onClick={() => setAisEnabled(!aisEnabled)}
mr={2}
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
bg={aisEnabled ? 'green.500' : undefined}
_hover={{
bg: aisEnabled ? 'green.600' : undefined,
}}
>
<Text>AIS {aisEnabled ? 'ON' : 'OFF'}</Text>
</Button>
<LayerSelector onClick={handleLayerChange}/>
</HStack>
<MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} geolocation={window.navigator.geolocation || custom_geolocation}/>
{/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/}
{/* initialViewState={mapView}*/}
{/* onMove={handleMapViewChange}*/}
{/* mapStyle="mapbox://styles/mapbox/dark-v11"*/}
{/* reuseMaps*/}
{/* attributionControl={false}*/}
{/* style={{width: '100%', height: '100%'}} // let the wrapper dictate size*/}
{/*>*/}
{/* /!*{vesselPosition && (*!/*/}
{/* /!* <Source id="vessel-data" type="geojson" data={vesselGeojson}>*!/*/}
{/* /!* <Layer {...vesselLayerStyle} />*!/*/}
{/* /!* </Source>*!/*/}
{/* /!*)}*!/*/}
{/*</Map>*/}
<MapNext
mapboxPublicKey={atob(key)}
vesselPosition={vesselPosition}
layer={selectedLayer}
mapView={mapView}
geolocation={custom_geolocation}
aisVessels={aisEnabled ? vessels : []}
onVesselClick={setVesselPopup}
vesselPopup={vesselPopup}
onVesselPopupClose={() => setVesselPopup(null)}
/>
</Box>
);
}

View File

@@ -0,0 +1,27 @@
import type {Geolocation} from "@/MapNext.tsx";
export class NativeGeolocation implements Geolocation {
constructor({clearWatch, getCurrentPosition, watchPosition}: {
clearWatch: (watchId: number) => void;
getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => void;
watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => number;
}) {
this.clearWatch = clearWatch;
this.watchPosition = watchPosition;
this.getCurrentPosition = getCurrentPosition;
}
clearWatch(_watchId: number): void {
throw new Error('Method not implemented.');
}
getCurrentPosition(_successCallback: PositionCallback, _errorCallback?: PositionErrorCallback | null, _options?: PositionOptions): void {
throw new Error('Method not implemented.');
}
watchPosition(_successCallback: PositionCallback, _errorCallback?: PositionErrorCallback | null, _options?: PositionOptions): number {
throw new Error('Method not implemented.');
}
}

View File

@@ -11,6 +11,8 @@ import Map, {
import ControlPanel from './control-panel.tsx';
import Pin from './pin.tsx';
import VesselMarker from './vessel-marker';
import type { VesselData } from './ais-provider';
import PORTS from './test_data/nautical-base-data.json';
import {Box} from "@chakra-ui/react";
@@ -27,7 +29,19 @@ export interface Geolocation {
export default function MapNext(props: any = {mapboxPublicKey: "", geolocation: Geolocation, vesselPosition: undefined, layer: undefined, mapView: undefined} as any) {
interface MapNextProps {
mapboxPublicKey: string;
geolocation: Geolocation;
vesselPosition?: any;
layer?: any;
mapView?: any;
aisVessels?: VesselData[];
onVesselClick?: (vessel: VesselData) => void;
vesselPopup?: VesselData | null;
onVesselPopupClose?: () => void;
}
export default function MapNext(props: MapNextProps) {
const [popupInfo, setPopupInfo] = useState(null);
const mapRef = useRef<MapRef | null>(null);
@@ -70,6 +84,52 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
[]
);
// Helper function to get vessel color based on type
const getVesselColor = (type: string): string => {
switch (type.toLowerCase()) {
case 'yacht':
case 'pleasure craft':
return '#00cc66';
case 'fishing vessel':
case 'fishing':
return '#ff6600';
case 'cargo':
case 'container':
return '#cc0066';
case 'tanker':
return '#ff0000';
case 'passenger':
return '#6600cc';
default:
return '#0066cc';
}
};
// Create vessel markers
const vesselMarkers = useMemo(() =>
(props.aisVessels || []).map((vessel) => (
<Marker
key={`vessel-${vessel.id}`}
longitude={vessel.longitude}
latitude={vessel.latitude}
anchor="center"
onClick={(e) => {
e.originalEvent.stopPropagation();
if (props.onVesselClick) {
props.onVesselClick(vessel);
}
}}
>
<VesselMarker
heading={vessel.heading}
color={getVesselColor(vessel.type)}
size={16}
/>
</Marker>
)),
[props.aisVessels, props.onVesselClick]
);
return (
<Box>
<Map
@@ -100,6 +160,31 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
<ScaleControl />
{pins}
{vesselMarkers}
{/* Vessel Popup */}
{props.vesselPopup && (
<Popup
longitude={props.vesselPopup.longitude}
latitude={props.vesselPopup.latitude}
anchor="bottom"
onClose={() => props.onVesselPopupClose && props.onVesselPopupClose()}
closeButton={true}
closeOnClick={false}
>
<div style={{ padding: '10px', minWidth: '200px' }}>
<h4 style={{ margin: '0 0 10px 0' }}>{props.vesselPopup.name}</h4>
<div><strong>MMSI:</strong> {props.vesselPopup.mmsi}</div>
<div><strong>Type:</strong> {props.vesselPopup.type}</div>
<div><strong>Speed:</strong> {props.vesselPopup.speed.toFixed(1)} knots</div>
<div><strong>Heading:</strong> {props.vesselPopup.heading}°</div>
<div><strong>Position:</strong> {props.vesselPopup.latitude.toFixed(4)}, {props.vesselPopup.longitude.toFixed(4)}</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '5px' }}>
Last update: {props.vesselPopup.lastUpdate.toLocaleTimeString()}
</div>
</div>
</Popup>
)}
{popupInfo && (
<Popup

View File

@@ -88,9 +88,9 @@ export const useAISProvider = (boundingBox?: BoundingBox) => {
const wsRef = useRef<WebSocket | null>(null);
const vesselMapRef = useRef<Map<string, VesselData>>(new Map());
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectTimeoutRef = useRef<any | null>(null);
const reconnectAttemptsRef = useRef<number>(0);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connectionTimeoutRef = useRef<any | null>(null);
const isConnectingRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const maxReconnectAttempts = 10;

View File

@@ -0,0 +1,34 @@
import type {VesselData} from "@/ais-provider.tsx";
import type { VesselStatus } from "@/types";
import { Box } from "@chakra-ui/react";
import {getNeumorphicStyle} from "@/theme/neumorphic-theme.ts";
export function AisFeed(props: {
vesselPosition: VesselStatus | null,
colorMode: "light" | "dark",
connectionStatus: string,
vesselData: VesselData[],
aisError: string | null,
aisConnected: boolean
}) {
return <Box
position="relative"
zIndex={1}
p={4}
fontSize="sm"
fontFamily="monospace"
maxH="20%"
backdropFilter="blur(10px)"
{...getNeumorphicStyle(props.colorMode as "light" | "dark")}
>
<Box fontWeight="bold" mb={3} fontSize="md">AIS Status</Box>
<Box mb={1}>Status: {props.connectionStatus}</Box>
<Box mb={1}>Vessels: {props.vesselData.length}</Box>
{props.aisError && <Box color="red.500" fontSize="xs">Error: {props.aisError}</Box>}
{props.aisConnected && (
<Box color="green.500" fontSize="xs" mt={2}>
Connected to AIS server
</Box>
)}
</Box>;
}

View File

@@ -0,0 +1,23 @@
import type {VesselStatus} from "@/types.ts";
import {Box} from "@chakra-ui/react";
import {getNeumorphicStyle} from "@/theme/neumorphic-theme.ts";
export function GpsFeed(props: { vesselPosition: VesselStatus, colorMode: 'light' | 'dark' }) {
return <>
<Box
position="relative"
zIndex={1}
fontSize="sm"
maxH="20%"
fontFamily="monospace"
backdropFilter="blur(10px)"
{...getNeumorphicStyle(props.colorMode)}
>
<Box fontWeight="bold" mb={3} fontSize="md">GPS Feed</Box>
<Box mb={1}>Lat: {props.vesselPosition.latitude.toFixed(6)}°</Box>
<Box mb={1}>Lon: {props.vesselPosition.longitude.toFixed(6)}°</Box>
<Box mb={1}>Heading: {props.vesselPosition.heading.toFixed(1)}°</Box>
<Box>Speed: {props.vesselPosition.speed.toFixed(1)} kts</Box>
</Box>
</>;
}

View File

@@ -0,0 +1,63 @@
import {Box, Button, Input, Text} from "@chakra-ui/react";
import {getNeumorphicStyle} from "@/theme/neumorphic-theme.ts";
export function Search(props: {
onClick: () => Promise<void>,
colorMode: "light" | "dark",
searchOpen: boolean,
onKeyDown: (e: any) => void,
value: string,
onChange: (e: any) => void,
onKeyPress: (e: any) => Promise<void>,
searchResults: any[],
callbackfn: (result: any, index: any) => any // JSX.Element
}) {
return <Box
display="flex"
alignItems="center"
position="relative"
>
<Button
size="sm"
variant="surface"
onClick={props.onClick}
mr={2}
{...getNeumorphicStyle(props.colorMode as "light" | "dark")}
>
<Text>Search...</Text>
</Button>
{props.searchOpen && <Box
w="200px"
transform={`translateX(${props.searchOpen ? "0" : "100%"})`}
opacity={props.searchOpen ? 1 : 0}
onKeyDown={props.onKeyDown}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(props.colorMode as "light" | "dark", "pressed")}
>
<Input
placeholder="Search..."
size="sm"
value={props.value}
onChange={props.onChange}
onKeyPress={props.onKeyPress}
border="none"
{...getNeumorphicStyle(props.colorMode as "light" | "dark", "pressed")}
/>
{props.searchResults.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
zIndex={2}
mt={2}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(props.colorMode as "light" | "dark")}
>
{props.searchResults.map(props.callbackfn)}
</Box>
)}
</Box>}
</Box>;
}

View File

@@ -0,0 +1,39 @@
import {Box} from "@chakra-ui/react";
interface SearchResultProps {
onKeyPress: (e: any) => Promise<void>;
colors: {
bg: string;
surface: string;
text: string;
textSecondary: string;
accent: string;
shadow: { dark: string; light: string }
} | {
bg: string;
surface: string;
text: string;
textSecondary: string;
accent: string;
shadow: { dark: string; light: string }
};
onClick: () => Promise<void>;
result: any;
}
export function SearchResult(props: SearchResultProps) {
return <Box
p={3}
cursor="pointer"
borderRadius="8px"
transition="all 0.2s ease-in-out"
onKeyPress={props.onKeyPress}
_hover={{
bg: props.colors.accent + "20",
transform: "translateY(-1px)",
}}
onClick={props.onClick}
>
{`${props.result.lat}, ${props.result.lon}`}
</Box>;
}

View File

@@ -0,0 +1,24 @@
// Types for bevy_flurx_ipc communication
export interface GpsPosition {
latitude: number;
longitude: number;
zoom: number;
}
export interface VesselStatus {
latitude: number;
longitude: number;
heading: number;
speed: number;
}
// interface MapViewParams {
// latitude: number;
// longitude: number;
// zoom: number;
// }
// interface AuthParams {
// authenticated: boolean;
// token: string | null;
// }

View File

@@ -80,6 +80,7 @@ winit = { version = "0.30", default-features = false }
image = { version = "0.25", default-features = false }
## This greatly improves WGPU's performance due to its heavy use of trace! calls
log = { version = "0.4", features = ["max_level_debug", "release_max_level_warn"] }
anyhow = "1.0.98"
# Platform-specific tokio features
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
@@ -100,4 +101,4 @@ console_error_panic_hook = "0.1"
[build-dependencies]
embed-resource = "1"
# base-map = { path = "../base-map" } # Temporarily disabled for testing
base-map = { path = "../base-map" } # Comment to Temporarily disable for testing

View File

@@ -9,12 +9,22 @@ use bevy::DefaultPlugins;
use yachtpit::GamePlugin;
use std::io::Cursor;
use winit::window::Icon;
use tokio::process::Command;
#[cfg(not(target_arch = "wasm32"))]
use bevy_webview_wry::WebviewWryPlugin;
fn main() {
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main(flavor = "multi_thread")]
async fn main() {
launch_bevy();
}
#[cfg(not(target_arch = "wasm32"))]
fn launch_bevy() {
App::new()
.insert_resource(ClearColor(Color::NONE))
.add_plugins(
@@ -22,7 +32,6 @@ fn main() {
.set(WindowPlugin {
primary_window: Some(Window {
// Bind to canvas included in `index.html`
canvas: Some("#yachtpit-canvas".to_owned()),
fit_canvas_to_parent: true,
// Tells wasm not to override default event handling, like F5 and Ctrl+R
prevent_default_event_handling: false,
@@ -37,10 +46,14 @@ fn main() {
)
.add_plugins(GamePlugin)
.add_systems(Startup, set_window_icon)
.add_systems(Update, start_ais_server)
.add_plugins(WebviewWryPlugin::default())
.run();
}
#[cfg(target_arch = "wasm32")]
#[cfg(target_arch = "wasm32")]
fn launch_bevy() {
{
// Add console logging for WASM debugging
console_error_panic_hook::set_once();
@@ -73,9 +86,20 @@ fn main() {
})
.run();
}
}
fn start_ais_server() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
if let Ok(mut cmd) = Command::new("cargo")
.current_dir("../ais-server")
.arg("run").arg("--release").spawn() {
let _ = cmd.wait().await;
}
});
}
// Sets the icon on windows and X11
fn set_window_icon(
windows: NonSend<WinitWindows>,

View File

@@ -148,7 +148,7 @@ fn setup_menu(mut commands: Commands) {
BackgroundColor(secondary_button_colors.normal),
BorderColor(Color::linear_rgb(0.80, 0.82, 0.85)),
BorderRadius::all(Val::Px(12.0)),
secondary_button_colors,
secondary_button_colors.clone(),
OpenLink("https://bevyengine.org"),
))
.with_child((