mirror of
https://github.com/seemueller-io/yachtpit.git
synced 2025-09-08 22:46:45 +00:00
Refactor MapNext
to GeoMap
, streamline code, and integrate optimized GeoJSON-based port rendering.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -9426,6 +9426,7 @@ checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
|
|||||||
name = "yachtpit"
|
name = "yachtpit"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ais",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base-map",
|
"base-map",
|
||||||
"bevy",
|
"bevy",
|
||||||
|
@@ -2,7 +2,7 @@ import 'mapbox-gl/dist/mapbox-gl.css';
|
|||||||
import {Box, Button, HStack, Text} from '@chakra-ui/react';
|
import {Box, Button, HStack, Text} from '@chakra-ui/react';
|
||||||
import {useColorMode} from './components/ui/color-mode';
|
import {useColorMode} from './components/ui/color-mode';
|
||||||
import {useCallback, useEffect, useState} from "react";
|
import {useCallback, useEffect, useState} from "react";
|
||||||
import MapNext from "@/MapNext.tsx";
|
import GeoMap from "@/GeoMap";
|
||||||
import {getNeumorphicColors, getNeumorphicStyle} from './theme/neumorphic-theme';
|
import {getNeumorphicColors, getNeumorphicStyle} from './theme/neumorphic-theme';
|
||||||
import {layers, LayerSelector} from "@/LayerSelector.tsx";
|
import {layers, LayerSelector} from "@/LayerSelector.tsx";
|
||||||
import {useAISProvider, type VesselData} from './ais-provider';
|
import {useAISProvider, type VesselData} from './ais-provider';
|
||||||
@@ -373,7 +373,7 @@ function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
<LayerSelector onClick={handleLayerChange}/>
|
<LayerSelector onClick={handleLayerChange}/>
|
||||||
</HStack>
|
</HStack>
|
||||||
<MapNext
|
<GeoMap
|
||||||
mapboxPublicKey={atob(key)}
|
mapboxPublicKey={atob(key)}
|
||||||
vesselPosition={vesselPosition}
|
vesselPosition={vesselPosition}
|
||||||
layer={selectedLayer}
|
layer={selectedLayer}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import type {Geolocation} from "@/MapNext.tsx";
|
import type {Geolocation} from "@/Map.tsx";
|
||||||
|
|
||||||
|
|
||||||
export class NativeGeolocation implements Geolocation {
|
export class NativeGeolocation implements Geolocation {
|
||||||
|
96
crates/base-map/map/src/GeoMap.tsx
Normal file
96
crates/base-map/map/src/GeoMap.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {useMemo, useCallback, useRef} from 'react';
|
||||||
|
import Map, {
|
||||||
|
Source, Layer,
|
||||||
|
NavigationControl, FullscreenControl, ScaleControl, GeolocateControl,
|
||||||
|
type MapRef
|
||||||
|
} from 'react-map-gl/mapbox';
|
||||||
|
import {Box} from '@chakra-ui/react';
|
||||||
|
import type {Feature, FeatureCollection, Point} from 'geojson';
|
||||||
|
import PORTS from './test_data/nautical-base-data.json';
|
||||||
|
import type {VesselData} from './ais-provider';
|
||||||
|
|
||||||
|
export interface Geolocation {
|
||||||
|
clearWatch(watchId: number): void;
|
||||||
|
getCurrentPosition(
|
||||||
|
successCallback: PositionCallback,
|
||||||
|
errorCallback?: PositionErrorCallback | null,
|
||||||
|
options?: PositionOptions
|
||||||
|
): void;
|
||||||
|
watchPosition(
|
||||||
|
successCallback: PositionCallback,
|
||||||
|
errorCallback?: PositionErrorCallback | null,
|
||||||
|
options?: PositionOptions
|
||||||
|
): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 GeoMap(props: MapNextProps) {
|
||||||
|
const mapRef = useRef<MapRef | null>(null);
|
||||||
|
|
||||||
|
const portsGeoJSON = useMemo<FeatureCollection<Point>>(() => ({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: PORTS.map(port => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {type: 'Point', coordinates: [port.longitude, port.latitude]},
|
||||||
|
properties: {city: port.city, state: port.state}
|
||||||
|
} as Feature<Point>))
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const handleGeolocate = useCallback((pos: GeolocationPosition) => {
|
||||||
|
console.log('User location loaded:', pos);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Map
|
||||||
|
ref={mapRef}
|
||||||
|
initialViewState={{
|
||||||
|
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={props.layer?.value ?? 'mapbox://styles/mapbox/standard'}
|
||||||
|
mapboxAccessToken={props.mapboxPublicKey}
|
||||||
|
style={{ position: 'fixed', inset: 0 }}
|
||||||
|
>
|
||||||
|
<Source id="ports" type="geojson" data={portsGeoJSON}>
|
||||||
|
<Layer
|
||||||
|
id="ports-layer"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': 6,
|
||||||
|
'circle-color': '#007bff',
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
|
||||||
|
<GeolocateControl
|
||||||
|
showUserHeading={true}
|
||||||
|
showUserLocation={true}
|
||||||
|
geolocation={props.geolocation}
|
||||||
|
position="top-left"
|
||||||
|
onGeolocate={handleGeolocate}
|
||||||
|
/>
|
||||||
|
<FullscreenControl position="top-left" />
|
||||||
|
<NavigationControl position="top-left" />
|
||||||
|
<ScaleControl />
|
||||||
|
</Map>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,238 +0,0 @@
|
|||||||
import {useState, useMemo, useCallback, useRef} from 'react';
|
|
||||||
import Map, {
|
|
||||||
Marker,
|
|
||||||
Popup,
|
|
||||||
NavigationControl,
|
|
||||||
FullscreenControl,
|
|
||||||
ScaleControl,
|
|
||||||
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";
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
|
||||||
() =>
|
|
||||||
PORTS.map((city, index) => (
|
|
||||||
<Marker
|
|
||||||
key={`marker-${index}`}
|
|
||||||
longitude={city.longitude}
|
|
||||||
latitude={city.latitude}
|
|
||||||
anchor="bottom"
|
|
||||||
onClick={e => {
|
|
||||||
// If we let the click event propagates to the map, it will immediately close the popup
|
|
||||||
// with `closeOnClick: true`
|
|
||||||
e.originalEvent.stopPropagation();
|
|
||||||
/*
|
|
||||||
src/MapNext.tsx:34:38 - error TS2345: Argument of type '{ city: string; population: string; image: string; state: string; latitude: number; longitude: number; }' is not assignable to parameter of type 'SetStateAction<null>'.
|
|
||||||
Type '{ city: string; population: string; image: string; state: string; latitude: number; longitude: number; }' provides no match for the signature '(prevState: null): null'.
|
|
||||||
*/
|
|
||||||
// @ts-ignore
|
|
||||||
setPopupInfo(city);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pin />
|
|
||||||
</Marker>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper function to get vessel color based on type
|
|
||||||
const getVesselColor = (type: string): string => {
|
|
||||||
switch (type.toLowerCase()) {
|
|
||||||
case 'yacht':
|
|
||||||
case 'pleasure craft':
|
|
||||||
return '#00cc66';
|
|
||||||
case 'fishing vessel':
|
|
||||||
case 'fishing':
|
|
||||||
return '#ff6600';
|
|
||||||
case 'cargo':
|
|
||||||
case 'container':
|
|
||||||
return '#cc0066';
|
|
||||||
case 'tanker':
|
|
||||||
return '#ff0000';
|
|
||||||
case 'passenger':
|
|
||||||
return '#6600cc';
|
|
||||||
default:
|
|
||||||
return '#0066cc';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create vessel markers
|
|
||||||
const vesselMarkers = useMemo(() =>
|
|
||||||
(props.aisVessels || []).map((vessel) => (
|
|
||||||
<Marker
|
|
||||||
key={`vessel-${vessel.id}`}
|
|
||||||
longitude={vessel.longitude}
|
|
||||||
latitude={vessel.latitude}
|
|
||||||
anchor="center"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.originalEvent.stopPropagation();
|
|
||||||
if (props.onVesselClick) {
|
|
||||||
props.onVesselClick(vessel);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VesselMarker
|
|
||||||
heading={vessel.heading}
|
|
||||||
color={getVesselColor(vessel.type)}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
</Marker>
|
|
||||||
)),
|
|
||||||
[props.aisVessels, props.onVesselClick]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Map
|
|
||||||
ref={mapRef}
|
|
||||||
initialViewState={{
|
|
||||||
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={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"
|
|
||||||
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
|
|
||||||
anchor="top"
|
|
||||||
/*
|
|
||||||
src/MapNext.tsx:66:53 - error TS2339: Property 'longitude' does not exist on type 'never'.
|
|
||||||
|
|
||||||
66 longitude={Number(popupInfo.longitude)}
|
|
||||||
*/
|
|
||||||
// @ts-ignore
|
|
||||||
longitude={Number(popupInfo.longitude)}
|
|
||||||
/*
|
|
||||||
src/MapNext.tsx:67:52 - error TS2339: Property 'latitude' does not exist on type 'never'.
|
|
||||||
|
|
||||||
67 latitude={Number(popupInfo.latitude)}
|
|
||||||
~~~~~~~~
|
|
||||||
*/
|
|
||||||
// @ts-ignore
|
|
||||||
latitude={Number(popupInfo.latitude)}
|
|
||||||
onClose={() => setPopupInfo(null)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{/*src/MapNext.tsx:71:40 - error TS2339: Property 'city' does not exist on type 'never'.
|
|
||||||
|
|
||||||
71 {popupInfo.city}, {popupInfo.state} |{' '}
|
|
||||||
~~~~*/}
|
|
||||||
|
|
||||||
{/*@ts-ignore*/}{/*@ts-ignore*/}
|
|
||||||
{popupInfo.city},{popupInfo.state}
|
|
||||||
{/*@ts-ignore*/}
|
|
||||||
<a
|
|
||||||
target="_new"
|
|
||||||
|
|
||||||
href={`http://en.wikipedia.org/w/index.php?title=Special:Search&search=${(popupInfo as any).city}, ${(popupInfo as any).state}`}
|
|
||||||
>
|
|
||||||
Wikipedia
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/*@ts-ignore*/}
|
|
||||||
<img width="100%" src={popupInfo.image} />
|
|
||||||
</Popup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</Map>
|
|
||||||
|
|
||||||
<ControlPanel />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
Reference in New Issue
Block a user