mirror of
https://github.com/seemueller-io/yachtpit.git
synced 2025-09-08 22:46:45 +00:00
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:
@@ -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}*/}
|
||||
|
@@ -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 />
|
||||
|
77
crates/base-map/map/src/user-location-marker.tsx
Normal file
77
crates/base-map/map/src/user-location-marker.tsx
Normal file
@@ -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 (
|
||||
<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: 'ulm‑pulse 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);
|
Reference in New Issue
Block a user