import { useEffect, useRef, useState } from 'react' import maplibregl from 'maplibre-gl' import { useNMEA } from '../../hooks/useNMEA.js' import { getApi } from '../../mock/index.js' import 'maplibre-gl/dist/maplibre-gl.css' export default function NavigationMap() { const { lat, lon, heading, sog } = useNMEA() const mapContainer = useRef(null) const map = useRef(null) const shipMarkerRef = useRef(null) const trackSourceRef = useRef(false) const [zoom, setZoom] = useState(11) const api = getApi() const snapshot = api.signalk.getSnapshot?.() const waypoints = api.signalk.getWaypoints?.() || [] const mapLat = lat ?? 55.32 const mapLon = lon ?? 15.22 useEffect(() => { if (map.current) return map.current = new maplibregl.Map({ container: mapContainer.current, style: { version: 8, sources: { 'osm': { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors', }, 'seamark': { type: 'raster', tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenSeaMap contributors', }, }, layers: [ { id: 'osm-base', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19, }, { id: 'seamark-overlay', type: 'raster', source: 'seamark', minzoom: 5, maxzoom: 19, paint: { 'raster-opacity': 0.8, }, }, ], glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', }, center: [mapLon, mapLat], zoom: 11, pitch: 0, bearing: 0, antialias: true, }) // Add navigation controls map.current.addControl(new maplibregl.NavigationControl(), 'top-right') // Add ship marker source and layer map.current.on('load', () => { if (!map.current.getSource('ship')) { map.current.addSource('ship', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Point', coordinates: [mapLon, mapLat] }, properties: { heading: 0 }, }, }) map.current.addLayer({ id: 'ship-marker', type: 'symbol', source: 'ship', layout: { 'icon-image': 'marker-blue', 'icon-size': 1.2, 'icon-rotate': ['get', 'heading'], 'icon-allow-overlap': true, 'icon-ignore-placement': true, }, }) } // Add track source for ship trail if (!map.current.getSource('track')) { map.current.addSource('track', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [[mapLon, mapLat]] }, }, }) map.current.addLayer({ id: 'track-line', type: 'line', source: 'track', paint: { 'line-color': '#0ea5e9', 'line-width': 2, 'line-opacity': 0.7, 'line-dasharray': [5, 5], }, }) trackSourceRef.current = true } // Add waypoints if (waypoints.length > 0) { const waypointFeatures = waypoints.map((wp, idx) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [wp.lon, wp.lat] }, properties: { index: idx, isCurrent: idx === snapshot?.currentWaypoint }, })) if (!map.current.getSource('waypoints')) { map.current.addSource('waypoints', { type: 'geojson', data: { type: 'FeatureCollection', features: waypointFeatures, }, }) map.current.addLayer({ id: 'waypoint-circles', type: 'circle', source: 'waypoints', paint: { 'circle-radius': 8, 'circle-color': ['case', ['get', 'isCurrent'], '#0ea5e9', '#f59e0b'], 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff', }, }) map.current.addLayer({ id: 'waypoint-labels', type: 'symbol', source: 'waypoints', layout: { 'text-field': ['to-string', ['+', ['get', 'index'], 1]], 'text-size': 12, 'text-font': ['Open Sans Semibold'], 'text-offset': [0, 0], 'text-allow-overlap': true, }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#000000', 'text-halo-width': 1, }, }) } else { map.current.getSource('waypoints').setData({ type: 'FeatureCollection', features: waypointFeatures, }) } } }) map.current.on('zoom', () => setZoom(map.current.getZoom())) return () => { // Cleanup is handled by React } }, []) // Update ship position useEffect(() => { if (!map.current || lat == null || lon == null) return if (map.current.getSource('ship')) { const trackSource = map.current.getSource('track') if (trackSource && trackSourceRef.current) { const currentData = trackSource._data if (currentData.geometry.coordinates.length > 500) { currentData.geometry.coordinates.shift() // Keep last 500 points } currentData.geometry.coordinates.push([lon, lat]) trackSource.setData(currentData) } map.current.getSource('ship').setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] }, properties: { heading: heading ?? 0 }, }) // Auto-center on ship map.current.flyTo({ center: [lon, lat], zoom: zoom, speed: 0.5, curve: 1, }) } }, [lat, lon, heading, zoom]) return (
{/* Map Controls */}
{/* Info Panel */} {lat != null && lon != null && (
Position {lat.toFixed(5)}°
{lon.toFixed(5)}°
{heading != null && (
Heading {Math.round(heading)}°
)} {sog != null && (
Speed {(sog * 1.943844).toFixed(1)} kn
)} {waypoints.length > 0 && snapshot?.currentWaypoint != null && (
Route WP {snapshot.currentWaypoint + 1} / {waypoints.length}
)} {snapshot?.distanceToWaypoint != null && (
Distance {(snapshot.distanceToWaypoint * 1.852).toFixed(1)} km
)}
)} {/* Map Style Toggle */}
+ SeaMarks
) } const styles = { container: { position: 'relative', flex: 1, overflow: 'hidden', borderRadius: 'var(--radius)', }, mapBox: { width: '100%', height: '100%', }, controls: { position: 'absolute', top: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 6, zIndex: 1000, }, infoPanel: { position: 'absolute', bottom: 12, left: 12, background: 'var(--glass-bg)', backdropFilter: 'blur(var(--glass-blur))', WebkitBackdropFilter: 'blur(var(--glass-blur))', border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius-lg)', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: 'var(--text)', zIndex: 500, maxWidth: 180, animation: 'slideInUp 0.3s ease-out', }, infoSection: { display: 'flex', flexDirection: 'column', gap: 2, }, infoRow: { display: 'flex', justifyContent: 'space-between', gap: 8, }, label: { color: 'var(--muted)', fontWeight: 600, fontSize: 10, }, value: { fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--accent)', textAlign: 'right', }, layerToggle: { position: 'absolute', top: 60, right: 12, background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.3)', borderRadius: 'var(--radius)', padding: '4px 8px', zIndex: 500, }, }