diff --git a/.aiignore b/.aiignore deleted file mode 100644 index de7901f..0000000 --- a/.aiignore +++ /dev/null @@ -1,14 +0,0 @@ -# An .aiignore file follows the same syntax as a .gitignore file. -# .gitignore documentation: https://git-scm.com/docs/gitignore -# Junie will ask for explicit approval before view or edit the file or file within a directory listed in .aiignore. -# Only files contents is protected, Junie is still allowed to view file names even if they are listed in .aiignore. -# Be aware that the files you included in .aiignore can still be accessed by Junie in two cases: -# - If Brave Mode is turned on. -# - If a command has been added to the Allowlist — Junie will not ask for confirmation, even if it accesses - files and folders listed in .aiignore. -target -**/**/dist -.idea -.github -Cargo.lock -LICENSE -yachtpit.png \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3dbe847..02ce09e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json /crates/yachtpit/assets/ui/assets/ /crates/yachtpit/assets/ui/packages/base-map/dist/ +/crates/base-map/map/src/map-upgrade/ diff --git a/Cargo.lock b/Cargo.lock index 830c279..7b77d43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4274,6 +4274,26 @@ dependencies = [ "redox_syscall 0.5.13", ] +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -6573,6 +6593,7 @@ dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", "io-kit-sys", + "libudev", "mach2", "nix 0.26.4", "scopeguard", @@ -9088,6 +9109,7 @@ checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" name = "yachtpit" version = "0.1.0" dependencies = [ + "base-map", "bevy", "bevy_asset_loader", "bevy_flurx", @@ -9102,6 +9124,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", + "serialport", "systems", "tokio", "wasm-bindgen", diff --git a/README.md b/README.md index f19612e..5254b9a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > Warning: Experimental, incomplete, and unfunded.

- +

diff --git a/crates/base-map/map/src/App.tsx b/crates/base-map/map/src/App.tsx index c0887f8..6ef2801 100644 --- a/crates/base-map/map/src/App.tsx +++ b/crates/base-map/map/src/App.tsx @@ -1,16 +1,17 @@ -// import Map from 'react-map-gl/mapbox'; -// import {Source, Layer} from 'react-map-gl/maplibre'; import 'mapbox-gl/dist/mapbox-gl.css'; import {Box, Button, HStack, Input} from '@chakra-ui/react'; import {useCallback, useEffect, useState} from "react"; -import MapNext from "@/MapNext.tsx"; -// import type {FeatureCollection} from 'geojson'; -// import type {CircleLayerSpecification} from "mapbox-gl"; +import MapNext, {type Geolocation} from "@/MapNext.tsx"; // 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 = { @@ -39,6 +40,31 @@ interface VesselStatus { 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; @@ -50,9 +76,58 @@ interface VesselStatus { // token: string | null; // } +function LayerSelector(props: { onClick: (e: any) => Promise }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + {isOpen && ( + + {layers.map(layer => ( + { + setIsOpen(false); + await props.onClick(e); + }} + > + {layer.name} + + ))} + + )} + + ); +} + function App() { const [isSearchOpen, setIsSearchOpen] = useState(false); + const [selectedLayer, setSelectedLayer] = useState(layers[0]); + const [searchInput, setSearchInput] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [mapView, setMapView] = useState({ + longitude: -122.4, + latitude: 37.8, + zoom: 14 + }); // Map state that can be updated from Rust // const [mapView, setMapView] = useState({ @@ -62,7 +137,7 @@ function App() { // }); // Vessel position state - // const [vesselPosition, setVesselPosition] = useState(null); + const [vesselPosition, setVesselPosition] = useState(null); // Create vessel geojson data // const vesselGeojson: FeatureCollection = { @@ -85,20 +160,51 @@ function App() { // 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); - } - } + // 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); + // } + // } + // }, []); + + + const selectSearchResult = useCallback(async (searchResult: { lat: string, lon: string }) => { + // Navigate to the selected location with zoom + console.log(`Navigating to: ${searchResult.lat}, ${searchResult.lon}`); + setMapView({ + longitude: parseFloat(searchResult.lon), + latitude: parseFloat(searchResult.lat), + zoom: 15 + }); }, []); - const handleSearchClick = useCallback(async () => { - setIsSearchOpen(!isSearchOpen); + if (isSearchOpen && searchInput.length > 1) { + try { + console.log(`Trying to geocode: ${searchInput}`); + const geocode = await fetch('https://geocode.geoffsee.com', { + method: 'POST', + mode: 'cors', + body: JSON.stringify({ + location: searchInput, + }), + }); + const coordinates = await geocode.json(); + const { lat, lon } = coordinates; + console.log(`Got geocode coordinates: ${lat}, ${lon}`); + setSearchResults([{ lat, lon }]); + } catch (e) { + console.error('Geocoding failed:', e); + // Continue without results + } + } else { + setIsSearchOpen(!isSearchOpen); + } + if (typeof window !== 'undefined' && (window as any).__FLURX__) { try { await (window as any).__FLURX__.invoke("search_clicked"); @@ -107,6 +213,14 @@ function App() { console.error('Failed to invoke search_clicked:', error); } } + }, [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 handleMapViewChange = useCallback(async (evt: any) => { @@ -135,7 +249,7 @@ function App() { try { const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status"); console.log('Vessel status:', vesselStatus); - // setVesselPosition(vesselStatus); + setVesselPosition(vesselStatus); } catch (error) { console.error('Failed to get vessel status:', error); } @@ -175,6 +289,28 @@ function App() { return ( /* Full-screen wrapper — fills the viewport and becomes the positioning context */ + {/* GPS Feed Display — absolutely positioned at bottom-left */} + {vesselPosition && ( + + GPS Feed + Lat: {vesselPosition.latitude.toFixed(6)}° + Lon: {vesselPosition.longitude.toFixed(6)}° + Heading: {vesselPosition.heading.toFixed(1)}° + Speed: {vesselPosition.speed.toFixed(1)} kts + + )} {/* Button bar — absolutely positioned inside the wrapper */} 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 && ( + + {searchResults.map((result, index) => ( + { + console.log(`Selecting result ${result.lat}, ${result.lon}`); + await selectSearchResult(result); + setSearchResults([]); + setIsSearchOpen(false); + }} + > + {`${result.lat}, ${result.lon}`} + + ))} + + )} } - + - + { + 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); + } + }, + })}/> {/* { + console.log("props.vesselPosition", props?.vesselPosition); + // setLocationLock(props.vesselPosition) + }, [props.vesselPosition]); + return ( - + @@ -107,6 +125,9 @@ export default function MapNext(props: any = {mapboxPublicKey: ""} as any) { )} + + + diff --git a/crates/base-map/map/src/user-location-marker.tsx b/crates/base-map/map/src/user-location-marker.tsx new file mode 100644 index 0000000..1717592 --- /dev/null +++ b/crates/base-map/map/src/user-location-marker.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +/** + * UserLocationMarker + * • size – overall diameter in px (default 24) + * • color – dot / ring colour (default #1E90FF ⟵ system‑blue) + * • pulse – adds a subtle accuracy‑halo animation when true + */ +function UserLocationMarker({ + size = 24, + color = '#1E90FF', + pulse = false + }) { + // stroke width scales with size so the ring stays proportionate + const strokeWidth = size * 0.083; // ≈ 2px when size = 24 + + // keyframes are injected once per page‑load if pulse is ever enabled + React.useEffect(() => { + if (!pulse || document.getElementById('ulm‑pulse‑kf')) return; + const styleTag = document.createElement('style'); + styleTag.id = 'ulm‑pulse‑kf'; + styleTag.textContent = ` + @keyframes ulm‑pulse { + 0% { r: 0; opacity: .6; } + 70% { r: 12px; opacity: 0; } + 100% { r: 12px; opacity: 0; } + }`; + document.head.appendChild(styleTag); + }, [pulse]); + + return ( + + {/* accuracy halo (animated when pulse=true) */} + {pulse && ( + + )} + + {/* outer ring */} + + + {/* inner dot */} + + + ); +} + +export default React.memo(UserLocationMarker); diff --git a/crates/components/src/instrument_cluster.rs b/crates/components/src/instrument_cluster.rs index 16fa54b..f392d18 100644 --- a/crates/components/src/instrument_cluster.rs +++ b/crates/components/src/instrument_cluster.rs @@ -39,7 +39,7 @@ pub fn setup_instrument_cluster(mut commands: Commands) { )) .with_children(|gauge| { gauge.spawn(create_text("SPEED", FONT_SIZE_SMALL, TEXT_COLOR_PRIMARY)); - gauge.spawn(create_text("12.5", FONT_SIZE_LARGE, TEXT_COLOR_SUCCESS)); + gauge.spawn(create_text("0.0", FONT_SIZE_LARGE, TEXT_COLOR_SUCCESS)); gauge.spawn(create_text("KTS", FONT_SIZE_SMALL, TEXT_COLOR_SECONDARY)); }); diff --git a/crates/components/src/vessel_data.rs b/crates/components/src/vessel_data.rs index d5a7313..9726ba2 100644 --- a/crates/components/src/vessel_data.rs +++ b/crates/components/src/vessel_data.rs @@ -31,14 +31,31 @@ impl Default for VesselData { } } -/// Updates yacht data with simulated sensor readings +/// Updates yacht data with sensor readings, using real GPS data when available pub fn update_vessel_data(mut vessel_data: ResMut, time: Res