Gpyes integration (#11)

* Introduce core modules: device management, bus communication, and discovery protocol. Adds system device interface, virtual hardware bus, and device discovery logic. Includes tests for all components.

* improve map
- Fix typos in variable and function names (`vessle` to `vessel`).
- Add `update_vessel_data_with_gps` function to enable GPS integration for vessel data updates.
- Integrate real GPS data into vessel systems and UI components (speed, heading, etc.).
- Initialize speed gauge display at 0 kts.
- Include `useEffect` in `MapNext` to log and potentially handle `vesselPosition` changes.

**Add compass heading update system using GPS heading data.**

- Remove `UserLocationMarker` component and related code from `MapNext.tsx`
- Simplify logic for layer selection and navigation within `App.tsx`
- Replace map style 'Bathymetry' with 'OSM' in layer options

improve map

* update image

---------

Co-authored-by: geoffsee <>
This commit is contained in:
Geoff Seemueller
2025-07-20 15:51:33 -04:00
committed by GitHub
parent 2311f43d97
commit e029ef48fc
28 changed files with 4557 additions and 207 deletions

View File

@@ -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<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 [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
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<VesselStatus | null>(null);
const [vesselPosition, setVesselPosition] = useState<VesselStatus | null>(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 */
<Box w="100vw" h="100vh" position="relative" overflow="hidden">
{/* GPS Feed Display — absolutely positioned at bottom-left */}
{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>
)}
{/* Button bar — absolutely positioned inside the wrapper */}
<HStack position="absolute" top={4} right={4} zIndex={1}>
<Box
@@ -194,28 +330,206 @@ function App() {
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>
<Button
colorScheme="blue"
size="sm"
variant="solid"
onClick={handleNavigationClick}
>
Layer
</Button>
<LayerSelector onClick={handleLayerChange} />
</HStack>
<MapNext mapboxPublicKey={atob(key)}/>
<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}*/}

View File

@@ -1,4 +1,4 @@
import {useState, useMemo} from 'react';
import {useState, useMemo, useEffect} from 'react';
import Map, {
Marker,
Popup,
@@ -15,7 +15,18 @@ import PORTS from './test_data/nautical-base-data.json';
import {Box} from "@chakra-ui/react";
export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
export interface Geolocation {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Geolocation/clearWatch) */
clearWatch(watchId: number): void;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Geolocation/getCurrentPosition) */
getCurrentPosition(successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions): void;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Geolocation/watchPosition) */
watchPosition(successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions): number;
}
export default function MapNext(props: any = {mapboxPublicKey: "", geolocation: Geolocation, vesselPosition: undefined, layer: undefined, mapView: undefined} as any) {
const [popupInfo, setPopupInfo] = useState(null);
const pins = useMemo(
@@ -44,22 +55,29 @@ export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
[]
);
useEffect(() => {
console.log("props.vesselPosition", props?.vesselPosition);
// setLocationLock(props.vesselPosition)
}, [props.vesselPosition]);
return (
<Box>
<Map
initialViewState={{
latitude: 40,
longitude: -100,
zoom: 3.5,
latitude: props.mapView?.latitude || 40,
longitude: props.mapView?.longitude || -100,
zoom: props.mapView?.zoom || 3.5,
bearing: 0,
pitch: 0
}}
key={`${props.mapView?.latitude}-${props.mapView?.longitude}-${props.mapView?.zoom}`}
mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y"
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} position="top-left" />
<GeolocateControl showUserHeading={true} showUserLocation={true} geolocation={props.geolocation} position="top-left" />
<FullscreenControl position="top-left" />
<NavigationControl position="top-left" />
<ScaleControl />
@@ -107,6 +125,9 @@ export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
<img width="100%" src={popupInfo.image} />
</Popup>
)}
</Map>
<ControlPanel />

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
/**
* UserLocationMarker
* • size overall diameter in px (default 24)
* • color dot / ring colour (default #1E90FF ⟵ systemblue)
* • pulse adds a subtle accuracyhalo 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 pageload if pulse is ever enabled
React.useEffect(() => {
if (!pulse || document.getElementById('ulmpulsekf')) return;
const styleTag = document.createElement('style');
styleTag.id = 'ulmpulsekf';
styleTag.textContent = `
@keyframes ulmpulse {
0% { r: 0; opacity: .6; }
70% { r: 12px; opacity: 0; }
100% { r: 12px; opacity: 0; }
}`;
document.head.appendChild(styleTag);
}, [pulse]);
return (
<svg
height={size}
width={size}
viewBox="0 0 24 24"
style={{
display: 'block',
transform: 'translate(-50%, -50%)', // center on map coordinate
pointerEvents: 'none' // let clicks pass through
}}
>
{/* accuracy halo (animated when pulse=true) */}
{pulse && (
<circle
cx="12"
cy="12"
r="0"
fill={color}
opacity=".6"
style={{
animation: 'ulmpulse 2s ease-out infinite'
}}
/>
)}
{/* outer ring */}
<circle
cx="12"
cy="12"
r={size / 2 - strokeWidth}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
{/* inner dot */}
<circle
cx="12"
cy="12"
r={size * 0.25} /* ≈ 6px when size = 24 */
fill={color}
/>
</svg>
);
}
export default React.memo(UserLocationMarker);