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:
Geoff Seemueller
2025-07-21 21:12:29 -04:00
committed by GitHub
parent e029ef48fc
commit a4337cae3c
36 changed files with 5911 additions and 533 deletions

1308
crates/ais/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

30
crates/ais/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "ais"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "ais"
path = "src/main.rs"
[profile.dev]
debug = false
[dependencies]
tokio = { version = "1.0", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
futures-util = "0.3"
url = "2.4"
axum = { version = "0.7", features = ["ws"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
base64 = "0.22.1"
tokio-util = "0.7.15"
[dev-dependencies]
tokio-test = "0.4"
axum-test = "14.0"
mockito = "1.0"
serde_json = "1.0"

91
crates/ais/error.md Normal file
View File

@@ -0,0 +1,91 @@
/Users/williamseemueller/.cargo/bin/cargo run --color=always --package ais --bin ais --profile dev
warning: function `start_ais_stream_with_callbacks` is never used
--> src/ais.rs:78:8
|
78 | pub fn start_ais_stream_with_callbacks() {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(dead_code)]` on by default
warning: `ais` (bin "ais") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/ais`
thread 'main' panicked at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/js-sys-0.3.77/src/lib.rs:6063:9:
cannot access imported statics on non-wasm targets
stack backtrace:
0: __rustc::rust_begin_unwind
at /rustc/6b00bc3880198600130e1cf62b8f8a93494488cc/library/std/src/panicking.rs:697:5
1: core::panicking::panic_fmt
at /rustc/6b00bc3880198600130e1cf62b8f8a93494488cc/library/core/src/panicking.rs:75:14
2: js_sys::global::get_global_object::SELF::init::__wbg_static_accessor_SELF_37c5d418e4bf5819
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/js-sys-0.3.77/src/lib.rs:6063:9
3: js_sys::global::get_global_object::SELF::init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/js-sys-0.3.77/src/lib.rs:6063:9
4: core::ops::function::FnOnce::call_once
at /Users/williamseemueller/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
5: once_cell::unsync::Lazy<T,F>::force::{{closure}}
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:775:28
6: once_cell::unsync::OnceCell<T>::get_or_init::{{closure}}
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:591:57
7: once_cell::unsync::OnceCell<T>::get_or_try_init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:629:23
8: once_cell::unsync::OnceCell<T>::get_or_init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:591:19
9: once_cell::unsync::Lazy<T,F>::force
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:774:13
10: <wasm_bindgen::__rt::LazyCell<T> as core::ops::deref::Deref>::deref
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-0.2.100/src/rt/mod.rs:56:9
11: wasm_bindgen::JsThreadLocal<T>::with
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-0.2.100/src/lib.rs:1271:18
12: js_sys::global::get_global_object
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/js-sys-0.3.77/src/lib.rs:6082:29
13: core::ops::function::FnOnce::call_once
at /Users/williamseemueller/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
14: once_cell::unsync::Lazy<T,F>::force::{{closure}}
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:775:28
15: once_cell::unsync::OnceCell<T>::get_or_init::{{closure}}
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:591:57
16: once_cell::unsync::OnceCell<T>::get_or_try_init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:629:23
17: once_cell::unsync::OnceCell<T>::get_or_init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:591:19
18: once_cell::unsync::Lazy<T,F>::force
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:774:13
19: <once_cell::unsync::Lazy<T,F> as core::ops::deref::Deref>::deref
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:843:13
20: js_sys::global
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/js-sys-0.3.77/src/lib.rs:6051:12
21: wasm_bindgen_futures::queue::Queue::new
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-futures-0.4.50/src/queue.rs:89:35
22: core::ops::function::FnOnce::call_once
at /Users/williamseemueller/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
23: once_cell::unsync::Lazy<T,F>::force::{{closure}}
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:775:28
24: once_cell::unsync::OnceCell<T>::get_or_init::{{closure}}
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:591:57
25: once_cell::unsync::OnceCell<T>::get_or_try_init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:629:23
26: once_cell::unsync::OnceCell<T>::get_or_init
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:591:19
27: once_cell::unsync::Lazy<T,F>::force
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:774:13
28: <once_cell::unsync::Lazy<T,F> as core::ops::deref::Deref>::deref
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/once_cell-1.21.3/src/lib.rs:843:13
29: wasm_bindgen_futures::queue::Queue::with
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-futures-0.4.50/src/queue.rs:124:11
30: wasm_bindgen_futures::task::singlethread::Task::spawn
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-futures-0.4.50/src/task/singlethread.rs:36:9
31: wasm_bindgen_futures::spawn_local
at /Users/williamseemueller/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-bindgen-futures-0.4.50/src/lib.rs:93:5
32: ais::ais::start_ais_stream
at ./src/ais.rs:22:5
33: ais::main
at ./src/main.rs:7:5
34: core::ops::function::FnOnce::call_once
at /Users/williamseemueller/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Process finished with exit code 101

1007
crates/ais/src/ais.rs Normal file

File diff suppressed because it is too large Load Diff

36
crates/ais/src/main.rs Normal file
View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use axum::Router;
use axum::routing::get;
use tower_http::cors::CorsLayer;
use crate::ais::{AisStreamManager, AppState};
mod ais;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create the shared state with the AIS stream manager
let state = AppState {
ais_stream_manager: Arc::new(AisStreamManager::new()),
};
// Create and start the Axum HTTP server
let app = create_router(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("AIS server running on http://0.0.0.0:3000");
axum::serve(listener, app)
.with_graceful_shutdown(ais::shutdown_signal())
.await?;
Ok(())
}
// Create the Axum router
fn create_router(state: AppState) -> Router {
Router::new()
.route("/ais", get(crate::ais::get_ais_data))
.route("/ws", get(crate::ais::websocket_handler))
.layer(CorsLayer::permissive())
.with_state(state)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>
);
}

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

@@ -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>
);
}

View File

@@ -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

View 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
};
};

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,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];
};

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

@@ -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;

View 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(),
};

View File

@@ -27,5 +27,6 @@
},
"include": [
"src"
]
],
"exclude": ["**/*.test.ts"]
}

View File

@@ -4,4 +4,5 @@
{ "path": "./tsconfig.app.json"},
{ "path": "./tsconfig.node.json"}
],
}

View 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,
},
});

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,5 @@ console_error_panic_hook = "0.1"
[build-dependencies]
embed-resource = "1"
base-map = { path = "../base-map" }
base-map = { path = "../base-map" } # Comment to Temporarily disable for testing
ais = { path = "../ais" } # Comment to Temporarily disable for testing

View File

@@ -2,19 +2,39 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use bevy::asset::AssetMetaCheck;
use bevy::ecs::spawn::SpawnableList;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use bevy::winit::WinitWindows;
use bevy::DefaultPlugins;
use yachtpit::GamePlugin;
use std::io::Cursor;
use tokio::process::Command;
use winit::window::Icon;
use yachtpit::GamePlugin;
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() {
// Start AIS server in background
tokio::spawn(async {
info!("Starting AIS server...");
let mut cmd = Command::new("target/release/ais").spawn().unwrap();
match cmd.wait().await {
Ok(status) => info!("AIS server process exited with status: {}", status),
Err(e) => error!("Error waiting for AIS server process: {}", e),
}
});
launch_bevy();
}
#[cfg(not(target_arch = "wasm32"))]
use bevy_webview_wry::WebviewWryPlugin;
fn main() {
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(target_arch = "wasm32"))]
fn launch_bevy() {
App::new()
.insert_resource(ClearColor(Color::NONE))
.add_plugins(
@@ -22,7 +42,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,
@@ -36,11 +55,18 @@ fn main() {
}),
)
.add_plugins(GamePlugin)
.add_systems(Startup, set_window_icon)
.add_systems(Startup, set_window_icon) // Changed here
.add_plugins(WebviewWryPlugin::default())
.run();
}
#[cfg(target_arch = "wasm32")]
#[cfg(target_arch = "wasm32")]
async fn main() {
launch_bevy();
}
#[cfg(target_arch = "wasm32")]
fn launch_bevy() {
{
// Add console logging for WASM debugging
console_error_panic_hook::set_once();
@@ -73,8 +99,54 @@ fn main() {
})
.run();
}
}
//
// fn start_ais_server() {
// static mut SERVER_STARTED: bool = false;
//
// unsafe {
// if SERVER_STARTED {
// return;
// }
// SERVER_STARTED = true;
// }
//
// let rt = tokio::runtime::Runtime::new().unwrap();
// rt.block_on(async {
// info!("Starting AIS server...");
// if let Ok(mut cmd) = Command::new("cargo")
// .current_dir("../ais")
// .arg("run").arg("--release")
// .spawn() {
// info!("AIS server process spawned");
// let status = cmd.wait().await;
// match status {
// Ok(exit_status) => match exit_status.code() {
// Some(code) => info!("AIS server exited with status code: {}", code),
// None => info!("AIS server terminated by signal"),
// },
// Err(e) => error!("AIS server failed: {}", e),
// }
// } else {
// error!("Failed to start AIS server - unable to spawn process");
// }
// });
// }
// fn start_ais_server() {
// // This task will run on the Tokio runtime's thread pool without blocking Bevy
// tokio::spawn(async {
// info!("Starting AIS server in the background...");
//
//
//
// // This now waits on the background task, not the main Bevy thread
// match cmd.wait().await {
// Ok(status) => info!("AIS server process exited with status: {}", status),
// Err(e) => error!("Error waiting for AIS server process: {}", e),
// }
// });
// }
// Sets the icon on windows and X11
fn set_window_icon(

View File

@@ -13,17 +13,42 @@ impl Plugin for MenuPlugin {
}
}
#[derive(Component)]
#[derive(Component, Clone)]
struct ButtonColors {
normal: Color,
hovered: Color,
pressed: Color,
}
// Neumorphic color palette for luxury design
struct NeumorphicColors;
impl NeumorphicColors {
// Base surface color - soft gray with warm undertones
const SURFACE: Color = Color::linear_rgb(0.88, 0.90, 0.92);
// Primary button colors with depth
const PRIMARY_NORMAL: Color = Color::linear_rgb(0.85, 0.87, 0.90);
const PRIMARY_HOVERED: Color = Color::linear_rgb(0.90, 0.92, 0.95);
const PRIMARY_PRESSED: Color = Color::linear_rgb(0.80, 0.82, 0.85);
// Secondary button colors (more subtle)
const SECONDARY_NORMAL: Color = Color::linear_rgb(0.86, 0.88, 0.91);
const SECONDARY_HOVERED: Color = Color::linear_rgb(0.88, 0.90, 0.93);
const SECONDARY_PRESSED: Color = Color::linear_rgb(0.82, 0.84, 0.87);
// Text colors for contrast
const TEXT_PRIMARY: Color = Color::linear_rgb(0.25, 0.30, 0.35);
const TEXT_SECONDARY: Color = Color::linear_rgb(0.45, 0.50, 0.55);
const TEXT_ACCENT: Color = Color::linear_rgb(0.20, 0.45, 0.75);
}
impl Default for ButtonColors {
fn default() -> Self {
ButtonColors {
normal: Color::linear_rgb(0.15, 0.15, 0.15),
hovered: Color::linear_rgb(0.25, 0.25, 0.25),
normal: NeumorphicColors::PRIMARY_NORMAL,
hovered: NeumorphicColors::PRIMARY_HOVERED,
pressed: NeumorphicColors::PRIMARY_PRESSED,
}
}
}
@@ -34,6 +59,18 @@ struct Menu;
fn setup_menu(mut commands: Commands) {
info!("menu");
commands.spawn((Camera2d, Msaa::Off));
// Set neumorphic background
commands.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
..default()
},
BackgroundColor(NeumorphicColors::SURFACE),
));
commands
.spawn((
Node {
@@ -52,23 +89,27 @@ fn setup_menu(mut commands: Commands) {
.spawn((
Button,
Node {
width: Val::Px(140.0),
height: Val::Px(50.0),
width: Val::Px(180.0),
height: Val::Px(65.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(2.0)),
margin: UiRect::all(Val::Px(8.0)),
..Default::default()
},
BackgroundColor(button_colors.normal),
BorderColor(Color::linear_rgb(0.82, 0.84, 0.87)),
BorderRadius::all(Val::Px(16.0)),
button_colors,
ChangeState(GameState::Playing),
))
.with_child((
Text::new("Play"),
Text::new("▶ PLAY"),
TextFont {
font_size: 40.0,
font_size: 28.0,
..default()
},
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)),
TextColor(NeumorphicColors::TEXT_PRIMARY),
));
});
commands
@@ -85,74 +126,71 @@ fn setup_menu(mut commands: Commands) {
Menu,
))
.with_children(|children| {
let secondary_button_colors = ButtonColors {
normal: NeumorphicColors::SECONDARY_NORMAL,
hovered: NeumorphicColors::SECONDARY_HOVERED,
pressed: NeumorphicColors::SECONDARY_PRESSED,
};
children
.spawn((
Button,
Node {
width: Val::Px(170.0),
height: Val::Px(50.0),
justify_content: JustifyContent::SpaceAround,
width: Val::Px(180.0),
height: Val::Px(45.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(5.)),
padding: UiRect::all(Val::Px(8.)),
border: UiRect::all(Val::Px(1.0)),
margin: UiRect::horizontal(Val::Px(8.0)),
..Default::default()
},
BackgroundColor(Color::NONE),
ButtonColors {
normal: Color::NONE,
..default()
},
BackgroundColor(secondary_button_colors.normal),
BorderColor(Color::linear_rgb(0.80, 0.82, 0.85)),
BorderRadius::all(Val::Px(12.0)),
secondary_button_colors.clone(),
OpenLink("https://bevyengine.org"),
))
.with_children(|parent| {
parent.spawn((
Text::new("Made with Bevy"),
TextFont {
font_size: 15.0,
..default()
},
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)),
));
parent.spawn((
Node {
width: Val::Px(32.),
..default()
},
));
});
.with_child((
Text::new("🚀 Made with Bevy"),
TextFont {
font_size: 14.0,
..default()
},
TextColor(NeumorphicColors::TEXT_SECONDARY),
));
children
.spawn((
Button,
Node {
width: Val::Px(170.0),
height: Val::Px(50.0),
justify_content: JustifyContent::SpaceAround,
width: Val::Px(180.0),
height: Val::Px(45.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(5.)),
padding: UiRect::all(Val::Px(8.)),
border: UiRect::all(Val::Px(1.0)),
margin: UiRect::horizontal(Val::Px(8.0)),
..default()
},
BackgroundColor(Color::NONE),
BackgroundColor(secondary_button_colors.normal),
BorderColor(Color::linear_rgb(0.80, 0.82, 0.85)),
BorderRadius::all(Val::Px(12.0)),
ButtonColors {
normal: Color::NONE,
hovered: Color::linear_rgb(0.25, 0.25, 0.25),
normal: NeumorphicColors::SECONDARY_NORMAL,
hovered: NeumorphicColors::SECONDARY_HOVERED,
pressed: NeumorphicColors::SECONDARY_PRESSED,
},
OpenLink("https://github.com/NiklasEi/bevy_game_template"),
))
.with_children(|parent| {
parent.spawn((
Text::new("Open source"),
TextFont {
font_size: 15.0,
..default()
},
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)),
));
parent.spawn((
Node {
width: Val::Px(32.),
..default()
},
));
});
.with_child((
Text::new("📖 Open Source"),
TextFont {
font_size: 14.0,
..default()
},
TextColor(NeumorphicColors::TEXT_SECONDARY),
));
});
}
@@ -178,6 +216,10 @@ fn click_play_button(
for (interaction, mut color, button_colors, change_state, open_link) in &mut interaction_query {
match *interaction {
Interaction::Pressed => {
// Apply pressed state visual feedback
*color = button_colors.pressed.into();
// Handle button actions
if let Some(state) = change_state {
next_state.set(state.0.clone());
} else if let Some(link) = open_link {
@@ -187,9 +229,11 @@ fn click_play_button(
}
}
Interaction::Hovered => {
// Smooth transition to hovered state
*color = button_colors.hovered.into();
}
Interaction::None => {
// Return to normal state
*color = button_colors.normal.into();
}
}