mirror of
https://github.com/seemueller-io/yachtpit.git
synced 2025-09-08 22:46:45 +00:00
AIS (Automatic identification system) Integration: Maritime (#12)
* WIP: Enable dynamic AIS stream handling based on user location and map focus. - Prevent AIS stream from starting immediately; start upon user interaction. - Add `ais_stream_started` state for WebSocket management. - Extend `useRealAISProvider` with `userLocationLoaded` and `mapFocused` to control stream. - Update frontend components to handle geolocation and map focus. - Exclude test files from compilation Introduce WebSocket integration for AIS services - Added WebSocket-based `useRealAISProvider` React hook for real-time AIS vessel data. - Created various tests including unit, integration, and browser tests to validate WebSocket functionality. - Added `ws` dependency to enable WebSocket communication. - Implemented vessel data mapping and bounding box handling for dynamic updates. * **Introduce Neumorphic UI design with new themes and styles** - Added NeumorphicTheme implementation for light and dark modes. - Refactored `LayerSelector` and `MapNext` components to use the neumorphic style and color utilities. - Updated `menu.rs` with neumorphic-inspired button and background styling. - Enhanced GPS feed and vessel popups with neumorphic visuals, improving clarity and aesthetics. - Temporarily disabled base-map dependency in `yachtpit` for isolation testing. * update names in layer selector * Update search button text to "Search..." for better clarity. * Add key event handlers for search and result selection in App.tsx * Implement AIS Test Map application with WebSocket-based vessel tracking and Mapbox integration. * Refactor AIS server to use Axum framework with shared stream manager and state handling. Fix metadata key mismatch in frontend vessel mapper. * Remove AIS provider integration and related vessel markers * Remove `ais-test-map` application, including dependencies, configuration, and source files. * ais data feed functional, bb query is overshot, performance degraded * Add AIS module as a build dependency --------- Co-authored-by: geoffsee <>
This commit is contained in:
1356
crates/base-map/map/package-lock.json
generated
1356
crates/base-map/map/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,35 +8,43 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint ",
|
||||
"preview": "vite preview",
|
||||
"background-server": "(cd ../ && cargo run &)"
|
||||
"background-server": "(cd ../ && cargo run &)",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"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",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@tauri-apps/plugin-geolocation": "^2.3.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"bevy_flurx_api": "^0.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"geojson": "^0.5.0",
|
||||
"globals": "^16.2.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"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",
|
||||
"@tauri-apps/plugin-geolocation": "^2.3.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"bevy_flurx_api": "^0.1.0",
|
||||
"geojson": "^0.5.0"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
@@ -1,124 +1,24 @@
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import {Box, Button, HStack, Input} from '@chakra-ui/react';
|
||||
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 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 layers = [
|
||||
{ name: 'OSM', value: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
|
||||
];
|
||||
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export type Layer = { name: string; value: string };
|
||||
export type Layers = Layer[];
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// interface MapViewParams {
|
||||
// latitude: number;
|
||||
// longitude: number;
|
||||
// zoom: number;
|
||||
// }
|
||||
|
||||
// interface AuthParams {
|
||||
// authenticated: boolean;
|
||||
// token: string | null;
|
||||
// }
|
||||
|
||||
function LayerSelector(props: { onClick: (e: any) => Promise<void> }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Button colorScheme="blue" size="sm" variant="solid" onClick={() => setIsOpen(!isOpen)}>
|
||||
Layer
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
w="200px"
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
boxShadow="md"
|
||||
zIndex={2}
|
||||
>
|
||||
{layers.map(layer => (
|
||||
<Box
|
||||
key={layer.value}
|
||||
id={layer.value}
|
||||
p={2}
|
||||
cursor="pointer"
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
onClick={async e => {
|
||||
setIsOpen(false);
|
||||
await props.onClick(e);
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
const {colorMode} = useColorMode();
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -129,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 }) => {
|
||||
@@ -183,6 +210,7 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const handleSearchClick = useCallback(async () => {
|
||||
console.log("calling hsc")
|
||||
if (isSearchOpen && searchInput.length > 1) {
|
||||
try {
|
||||
console.log(`Trying to geocode: ${searchInput}`);
|
||||
@@ -194,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
|
||||
@@ -204,7 +232,7 @@ function App() {
|
||||
} else {
|
||||
setIsSearchOpen(!isSearchOpen);
|
||||
}
|
||||
|
||||
|
||||
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
|
||||
try {
|
||||
await (window as any).__FLURX__.invoke("search_clicked");
|
||||
@@ -215,33 +243,12 @@ function App() {
|
||||
}
|
||||
}, [isSearchOpen, searchInput]);
|
||||
|
||||
const handleLayerChange = useCallback(async (e: any) => {
|
||||
const newLayer = layers.find(layer => layer.value === e.target.id);
|
||||
if (newLayer) {
|
||||
setSelectedLayer(newLayer);
|
||||
console.log('Layer changed to:', newLayer.name);
|
||||
}
|
||||
const handleLayerChange = useCallback(async (layer: any) => {
|
||||
console.log('Layer change requested:', layer);
|
||||
setSelectedLayer(layer);
|
||||
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 () => {
|
||||
@@ -270,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);
|
||||
}
|
||||
@@ -285,266 +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 bottom-left */}
|
||||
{/* 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}
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
color="white"
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
fontFamily="monospace"
|
||||
minW="200px"
|
||||
>
|
||||
<Box fontWeight="bold" mb={2}>GPS Feed</Box>
|
||||
<Box>Lat: {vesselPosition.latitude.toFixed(6)}°</Box>
|
||||
<Box>Lon: {vesselPosition.longitude.toFixed(6)}°</Box>
|
||||
<Box>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"
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={handleSearchClick}
|
||||
mr={2}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
{isSearchOpen && <Box
|
||||
w="200px"
|
||||
transition="all 0.3s"
|
||||
transform={`translateX(${isSearchOpen ? "0" : "100%"})`}
|
||||
background="rgba(0, 0, 0, 0.8)"
|
||||
opacity={isSearchOpen ? 1 : 0}
|
||||
color="white"
|
||||
>
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
size="sm"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
color="white"
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
border="none"
|
||||
borderRadius="0"
|
||||
_focus={{
|
||||
outline: 'none',
|
||||
}}
|
||||
_placeholder={{
|
||||
color: "#d1cfcf"
|
||||
}}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="100%"
|
||||
left={0}
|
||||
w="200px"
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
boxShadow="md"
|
||||
zIndex={2}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
p={2}
|
||||
cursor="pointer"
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
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} />
|
||||
<Text>AIS {aisEnabled ? 'ON' : 'OFF'}</Text>
|
||||
</Button>
|
||||
<LayerSelector onClick={handleLayerChange}/>
|
||||
</HStack>
|
||||
<MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} 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);
|
||||
}
|
||||
},
|
||||
})}/>
|
||||
{/*<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>
|
||||
);
|
||||
}
|
||||
|
27
crates/base-map/map/src/CustomGeolocate.ts
Normal file
27
crates/base-map/map/src/CustomGeolocate.ts
Normal 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.');
|
||||
}
|
||||
|
||||
}
|
91
crates/base-map/map/src/LayerSelector.tsx
Normal file
91
crates/base-map/map/src/LayerSelector.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import {Button, Menu, Portal} from '@chakra-ui/react';
|
||||
import {useColorMode} from './components/ui/color-mode';
|
||||
import {useState} from "react";
|
||||
import {getNeumorphicColors, getNeumorphicStyle} from './theme/neumorphic-theme';
|
||||
|
||||
export const layers = [
|
||||
{ name: 'Standard', value: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
|
||||
];
|
||||
|
||||
|
||||
|
||||
// const vesselLayerStyle: CircleLayerSpecification = {
|
||||
// id: 'vessel',
|
||||
// type: 'circle',
|
||||
// paint: {
|
||||
// 'circle-radius': 8,
|
||||
// 'circle-color': '#ff4444',
|
||||
// 'circle-stroke-width': 2,
|
||||
// 'circle-stroke-color': '#ffffff'
|
||||
// },
|
||||
// source: ''
|
||||
// };
|
||||
|
||||
|
||||
export type Layer = { name: string; value: string };
|
||||
export type Layers = Layer[];
|
||||
|
||||
// interface MapViewParams {
|
||||
// latitude: number;
|
||||
// longitude: number;
|
||||
// zoom: number;
|
||||
// }
|
||||
|
||||
// interface AuthParams {
|
||||
// authenticated: boolean;
|
||||
// token: string | null;
|
||||
// }
|
||||
|
||||
export function LayerSelector(props: { onClick: (layer: Layer) => Promise<void> }) {
|
||||
const { colorMode } = useColorMode();
|
||||
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
|
||||
const neumorphicStyle = getNeumorphicStyle(colorMode as 'light' | 'dark');
|
||||
const colors = getNeumorphicColors(colorMode as 'light' | 'dark');
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="surface"
|
||||
{...neumorphicStyle}
|
||||
>
|
||||
{selectedLayer?.name || 'Layer'}
|
||||
</Button>
|
||||
</Menu.Trigger>
|
||||
<Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content
|
||||
minW="200px"
|
||||
py={2}
|
||||
{...neumorphicStyle}
|
||||
>
|
||||
{layers.map(layer => (
|
||||
<Menu.Item
|
||||
key={layer.value}
|
||||
id={layer.value}
|
||||
value={layer.value}
|
||||
borderRadius={6}
|
||||
transition="all 0.2s ease-in-out"
|
||||
_hover={{
|
||||
bg: colors.accent + '20',
|
||||
transform: 'translateY(-1px)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// @ts-ignore
|
||||
console.log(e.target.id)
|
||||
setSelectedLayer(layer);
|
||||
props.onClick(layer);
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Portal>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
@@ -1,15 +1,18 @@
|
||||
import {useState, useMemo, useEffect} from 'react';
|
||||
import {useState, useMemo, useCallback, useRef} from 'react';
|
||||
import Map, {
|
||||
Marker,
|
||||
Popup,
|
||||
NavigationControl,
|
||||
FullscreenControl,
|
||||
ScaleControl,
|
||||
GeolocateControl
|
||||
GeolocateControl,
|
||||
type MapRef
|
||||
} from 'react-map-gl/mapbox';
|
||||
|
||||
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";
|
||||
@@ -26,8 +29,34 @@ 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);
|
||||
|
||||
// Handle user location events
|
||||
const handleGeolocate = useCallback((position: GeolocationPosition) => {
|
||||
console.log('User location loaded:', position);
|
||||
}, []);
|
||||
|
||||
const handleTrackUserLocationStart = useCallback(() => {
|
||||
console.log('Started tracking user location');
|
||||
}, []);
|
||||
|
||||
const handleTrackUserLocationEnd = useCallback(() => {
|
||||
console.log('Stopped tracking user location');
|
||||
}, []);
|
||||
|
||||
const pins = useMemo(
|
||||
() =>
|
||||
@@ -55,15 +84,56 @@ 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';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log("props.vesselPosition", props?.vesselPosition);
|
||||
// setLocationLock(props.vesselPosition)
|
||||
}, [props.vesselPosition]);
|
||||
// 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
|
||||
ref={mapRef}
|
||||
initialViewState={{
|
||||
latitude: props.mapView?.latitude || 40,
|
||||
longitude: props.mapView?.longitude || -100,
|
||||
@@ -72,17 +142,49 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
|
||||
pitch: 0
|
||||
}}
|
||||
key={`${props.mapView?.latitude}-${props.mapView?.longitude}-${props.mapView?.zoom}`}
|
||||
|
||||
mapStyle={props.layer?.value || "mapbox://styles/mapbox/standard"}
|
||||
mapboxAccessToken={props.mapboxPublicKey}
|
||||
style={{position: "fixed", width: '100%', height: '100%', bottom: 0, top: 0, left: 0, right: 0}}
|
||||
>
|
||||
<GeolocateControl showUserHeading={true} showUserLocation={true} geolocation={props.geolocation} position="top-left" />
|
||||
<GeolocateControl
|
||||
showUserHeading={true}
|
||||
showUserLocation={true}
|
||||
geolocation={props.geolocation}
|
||||
position="top-left"
|
||||
onGeolocate={handleGeolocate}
|
||||
onTrackUserLocationStart={handleTrackUserLocationStart}
|
||||
onTrackUserLocationEnd={handleTrackUserLocationEnd}
|
||||
/>
|
||||
<FullscreenControl position="top-left" />
|
||||
<NavigationControl position="top-left" />
|
||||
<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
|
||||
|
404
crates/base-map/map/src/ais-provider.tsx
Normal file
404
crates/base-map/map/src/ais-provider.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
// Vessel data interface
|
||||
export interface VesselData {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
heading: number; // degrees 0-359
|
||||
speed: number; // knots
|
||||
length: number; // meters
|
||||
width: number; // meters
|
||||
mmsi: string; // Maritime Mobile Service Identity
|
||||
callSign: string;
|
||||
destination?: string;
|
||||
eta?: string;
|
||||
lastUpdate: Date;
|
||||
}
|
||||
|
||||
// AIS service response structure (matching Rust AisResponse)
|
||||
interface AisResponse {
|
||||
message_type?: string;
|
||||
mmsi?: string;
|
||||
ship_name?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
timestamp?: string;
|
||||
speed_over_ground?: number;
|
||||
course_over_ground?: number;
|
||||
heading?: number;
|
||||
navigation_status?: string;
|
||||
ship_type?: string;
|
||||
raw_message: any;
|
||||
}
|
||||
|
||||
// Bounding box for AIS queries
|
||||
interface BoundingBox {
|
||||
sw_lat: number;
|
||||
sw_lon: number;
|
||||
ne_lat: number;
|
||||
ne_lon: number;
|
||||
}
|
||||
|
||||
// WebSocket message types for communication with the backend
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
bounding_box?: BoundingBox;
|
||||
}
|
||||
|
||||
// Convert AIS service response to VesselData format
|
||||
const convertAisResponseToVesselData = (aisResponse: AisResponse): VesselData | null => {
|
||||
if ((!aisResponse.raw_message?.MetaData?.MMSI) || !aisResponse.latitude || !aisResponse.longitude) {
|
||||
console.log('Skipping vessel with missing data:', {
|
||||
mmsi: aisResponse.mmsi,
|
||||
metadataMSSI: aisResponse.raw_message?.MetaData?.MSSI,
|
||||
latitude: aisResponse.latitude,
|
||||
longitude: aisResponse.longitude,
|
||||
raw: aisResponse.raw_message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: aisResponse.mmsi ?? aisResponse.raw_message?.MetaData?.MMSI,
|
||||
name: aisResponse.ship_name || `Vessel ${aisResponse.mmsi}`,
|
||||
type: aisResponse.ship_type || 'Unknown',
|
||||
latitude: aisResponse.latitude,
|
||||
longitude: aisResponse.longitude,
|
||||
heading: aisResponse.heading || 0,
|
||||
speed: aisResponse.speed_over_ground || 0,
|
||||
length: 100, // Default length
|
||||
width: 20, // Default width
|
||||
mmsi: aisResponse.mmsi ?? aisResponse.raw_message?.MetaData?.MMSI,
|
||||
callSign: '',
|
||||
destination: '',
|
||||
eta: '',
|
||||
lastUpdate: new Date()
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified AIS provider hook for testing
|
||||
export const useAISProvider = (boundingBox?: BoundingBox) => {
|
||||
const [vessels, setVessels] = useState<VesselData[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<string>('Disconnected');
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const vesselMapRef = useRef<Map<string, VesselData>>(new Map());
|
||||
const reconnectTimeoutRef = useRef<any | null>(null);
|
||||
const reconnectAttemptsRef = useRef<number>(0);
|
||||
const connectionTimeoutRef = useRef<any | null>(null);
|
||||
const isConnectingRef = useRef<boolean>(false);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
const maxReconnectAttempts = 10;
|
||||
const baseReconnectDelay = 1000; // 1 second
|
||||
|
||||
// Calculate exponential backoff delay
|
||||
const getReconnectDelay = useCallback(() => {
|
||||
const delay = baseReconnectDelay * Math.pow(2, reconnectAttemptsRef.current);
|
||||
return Math.min(delay, 30000); // Cap at 30 seconds
|
||||
}, []);
|
||||
|
||||
// Connect to WebSocket with React StrictMode-safe 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;
|
||||
}
|
||||
|
||||
// Clear any existing reconnection timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any existing connection timeout
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Check if already connected or connecting
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||
console.log('WebSocket already connecting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check reconnection attempts
|
||||
if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
setError('Failed to connect after multiple attempts');
|
||||
setConnectionStatus('Failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set connecting flag to prevent race conditions
|
||||
isConnectingRef.current = true;
|
||||
|
||||
setConnectionStatus(reconnectAttemptsRef.current > 0 ?
|
||||
`Reconnecting... (${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})` :
|
||||
'Connecting...');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log(`[CONNECT] Attempting WebSocket connection (attempt ${reconnectAttemptsRef.current + 1})`);
|
||||
|
||||
// Close any existing connection properly
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
wsRef.current = ws;
|
||||
|
||||
// Set 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); // 10 second timeout
|
||||
|
||||
ws.onopen = () => {
|
||||
// Clear connection timeout
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Check if component is still mounted
|
||||
if (!isMountedRef.current) {
|
||||
console.log('[OPEN] Component unmounted, closing connection');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[OPEN] Connected to AIS WebSocket');
|
||||
isConnectingRef.current = false; // Clear connecting flag
|
||||
setIsConnected(true);
|
||||
setConnectionStatus('Connected');
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0; // Reset reconnection attempts
|
||||
|
||||
// Send bounding box if available
|
||||
if (boundingBox && isMountedRef.current) {
|
||||
const message: WebSocketMessage = {
|
||||
type: 'set_bounding_box',
|
||||
bounding_box: boundingBox
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
console.log('[OPEN] Sent bounding box:', boundingBox);
|
||||
}
|
||||
|
||||
// Start AIS stream
|
||||
if (isMountedRef.current) {
|
||||
const startMessage: WebSocketMessage = {
|
||||
type: 'start_ais_stream'
|
||||
};
|
||||
ws.send(JSON.stringify(startMessage));
|
||||
console.log('[OPEN] Started AIS stream');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const messageData = event.data;
|
||||
|
||||
// Try to parse as JSON, but handle plain text messages gracefully
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(messageData);
|
||||
} catch (parseError) {
|
||||
console.log('Received plain text message:', messageData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle JSON status messages
|
||||
if (typeof data === 'string' || data.type) {
|
||||
console.log('Received message:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process vessel data
|
||||
const vesselData = convertAisResponseToVesselData(data);
|
||||
if (vesselData) {
|
||||
console.log('Received vessel data:', vesselData);
|
||||
vesselMapRef.current.set(vesselData.mmsi, vesselData);
|
||||
setVessels(Array.from(vesselMapRef.current.values()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
// Clear connection timeout
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
console.error('[ERROR] WebSocket error:', error);
|
||||
isConnectingRef.current = false; // Clear connecting flag
|
||||
|
||||
// Only update state if component is still mounted
|
||||
if (isMountedRef.current) {
|
||||
setError('WebSocket connection error');
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// Clear connection timeout
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
console.log(`[CLOSE] WebSocket connection closed: ${event.code} - ${event.reason}`);
|
||||
isConnectingRef.current = false; // Clear connecting flag
|
||||
|
||||
// Only update state if component is still mounted
|
||||
if (isMountedRef.current) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
|
||||
// Only attempt reconnection if component is mounted, wasn't a clean close, and we haven't exceeded max attempts
|
||||
if (isMountedRef.current && !event.wasClean && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay = getReconnectDelay();
|
||||
|
||||
console.log(`[CLOSE] Scheduling reconnection in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts})`);
|
||||
setError(`Connection lost, reconnecting in ${Math.round(delay/1000)}s...`);
|
||||
setConnectionStatus('Reconnecting...');
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
connectSocket();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
if (isMountedRef.current) {
|
||||
if (event.wasClean) {
|
||||
setConnectionStatus('Disconnected');
|
||||
setError(null);
|
||||
} else {
|
||||
setConnectionStatus('Failed');
|
||||
setError('Connection failed after multiple attempts');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error creating WebSocket connection:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown WebSocket error');
|
||||
setConnectionStatus('Error');
|
||||
|
||||
// Schedule reconnection attempt
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay = getReconnectDelay();
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectSocket();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}, [boundingBox, getReconnectDelay]);
|
||||
|
||||
// Update bounding box
|
||||
const updateBoundingBox = useCallback((bbox: BoundingBox) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const message: WebSocketMessage = {
|
||||
type: 'set_bounding_box',
|
||||
bounding_box: bbox
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
console.log('Updated bounding box:', bbox);
|
||||
|
||||
// Clear existing vessels when bounding box changes
|
||||
// vesselMapRef.current.clear();
|
||||
// setVessels([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Connect on mount with React StrictMode protection
|
||||
useEffect(() => {
|
||||
// Set mounted flag
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Small delay to prevent immediate double connection in StrictMode
|
||||
const connectTimeout = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
connectSocket();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
// Mark component as unmounted
|
||||
isMountedRef.current = false;
|
||||
|
||||
// Clear connect timeout
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
// Clear reconnection timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear connection timeout
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Reset connection flags
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Close WebSocket connection properly
|
||||
if (wsRef.current) {
|
||||
console.log('[CLEANUP] Closing WebSocket connection');
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
// Reset reconnection attempts
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
}, [connectSocket]);
|
||||
|
||||
return {
|
||||
vessels,
|
||||
isConnected,
|
||||
error,
|
||||
connectionStatus,
|
||||
connectSocket,
|
||||
updateBoundingBox
|
||||
};
|
||||
};
|
34
crates/base-map/map/src/components/map/AisFeedInfo.tsx
Normal file
34
crates/base-map/map/src/components/map/AisFeedInfo.tsx
Normal 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>;
|
||||
}
|
23
crates/base-map/map/src/components/map/GpsFeedInfo.tsx
Normal file
23
crates/base-map/map/src/components/map/GpsFeedInfo.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
63
crates/base-map/map/src/components/map/Search.tsx
Normal file
63
crates/base-map/map/src/components/map/Search.tsx
Normal 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>;
|
||||
}
|
39
crates/base-map/map/src/components/map/SearchResult.tsx
Normal file
39
crates/base-map/map/src/components/map/SearchResult.tsx
Normal 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>;
|
||||
}
|
75
crates/base-map/map/src/theme/neumorphic-theme.ts
Normal file
75
crates/base-map/map/src/theme/neumorphic-theme.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { defineConfig } from "@chakra-ui/react";
|
||||
|
||||
// Neumorphic color palette
|
||||
const neumorphicColors = {
|
||||
light: {
|
||||
bg: '#e0e5ec',
|
||||
surface: '#e0e5ec',
|
||||
text: '#2d3748',
|
||||
textSecondary: '#4a5568',
|
||||
accent: '#3182ce',
|
||||
shadow: {
|
||||
dark: '#a3b1c6',
|
||||
light: '#ffffff',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
bg: '#2d3748',
|
||||
surface: '#ffffff',
|
||||
text: '#f7fafc',
|
||||
textSecondary: '#e2e8f0',
|
||||
accent: '#63b3ed',
|
||||
shadow: {
|
||||
dark: '#1a202c',
|
||||
light: '#4a5568',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Neumorphic shadow mixins
|
||||
const neumorphicShadows = {
|
||||
light: {
|
||||
raised: '1px 1px 2px #a3b1c6, -1px -1px 2px #ffffff',
|
||||
pressed: 'inset 2px 2px 4px #a3b1c6, inset -2px -2px 4px #ffffff',
|
||||
subtle: '1px 1px 2px #a3b1c6, -1px -1px 2px #ffffff',
|
||||
subtlePressed: 'inset 1px 1px 2px #a3b1c6, inset -1px -1px 2px #ffffff',
|
||||
floating: '6px 6px 12px #a3b1c6, -6px -6px 12px #ffffff',
|
||||
},
|
||||
dark: {
|
||||
raised: '2px 2px 4px #1a202c, -2px -2px 4px #4a5568',
|
||||
pressed: 'inset 2px 2px 4px #1a202c, inset -2px -2px 4px #4a5568',
|
||||
subtle: '2px 2px 2px #1a202c, -2px -2px 2px #4a5568',
|
||||
subtlePressed: 'inset 2px 2px 2px #1a202c, inset -2px -2px 2px #4a5568',
|
||||
floating: '6px 6px 12px #1a202c, -6px -6px 12px #4a5568',
|
||||
},
|
||||
};
|
||||
|
||||
// Simplified theme configuration to avoid TypeScript errors
|
||||
// The utility functions below provide the neumorphic styling functionality
|
||||
export const neumorphicTheme = defineConfig({
|
||||
theme: {
|
||||
// Theme configuration simplified to avoid type errors
|
||||
},
|
||||
});
|
||||
|
||||
// Utility functions for neumorphic styling
|
||||
export const getNeumorphicStyle = (colorMode: 'light' | 'dark', variant: 'raised' | 'pressed' | 'subtle' | 'floating' = 'raised') => {
|
||||
const colors = neumorphicColors[colorMode];
|
||||
const shadows = neumorphicShadows[colorMode];
|
||||
|
||||
return {
|
||||
bg: colors.surface,
|
||||
color: colors.text,
|
||||
borderRadius: 6,
|
||||
boxShadow: shadows[variant] || shadows.raised,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
};
|
||||
};
|
||||
|
||||
export const getNeumorphicColors = (colorMode: 'light' | 'dark') => {
|
||||
return neumorphicColors[colorMode];
|
||||
};
|
||||
|
||||
export const getNeumorphicShadows = (colorMode: 'light' | 'dark') => {
|
||||
return neumorphicShadows[colorMode];
|
||||
};
|
24
crates/base-map/map/src/types.ts
Normal file
24
crates/base-map/map/src/types.ts
Normal 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;
|
||||
// }
|
42
crates/base-map/map/src/vessel-marker.tsx
Normal file
42
crates/base-map/map/src/vessel-marker.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
interface VesselMarkerProps {
|
||||
heading: number;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const VesselMarker: React.FC<VesselMarkerProps> = ({
|
||||
heading,
|
||||
color = '#0066cc',
|
||||
size = 16
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
transform: `rotate(${heading}deg)`,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={color}
|
||||
style={{
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
|
||||
}}
|
||||
>
|
||||
{/* Simple vessel shape - triangle pointing up (north) */}
|
||||
<path d="M12 2 L20 20 L12 16 L4 20 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VesselMarker;
|
10
crates/base-map/map/test/test-setup.ts
Normal file
10
crates/base-map/map/test/test-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock console methods to reduce noise in tests
|
||||
global.console = {
|
||||
...console,
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
@@ -27,5 +27,6 @@
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
@@ -4,4 +4,5 @@
|
||||
{ "path": "./tsconfig.app.json"},
|
||||
{ "path": "./tsconfig.node.json"}
|
||||
],
|
||||
|
||||
}
|
||||
|
11
crates/base-map/map/vitest.config.ts
Normal file
11
crates/base-map/map/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./test/test-setup.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user