Files
boWave/dashboard/src/components/nav/NavigationMap.jsx
denshooter 0b70891bca Phase 2: MapLibre GL with OpenSeaMap navigation
- Integrated MapLibre GL for professional maritime mapping
- Combined OSM base maps with OpenSeaMap layer for nautical overlays
  * Seamarks, buoys, channels, and depth information
  * Fully styled with MapLibre-compatible tiles

- Created NavigationMap component with:
  * Real-time ship position marker with heading indicator
  * Automatic centering and smooth flyTo animations
  * Waypoint display with current waypoint highlighting
  * Ship track visualization (last 500 points, dashed line)
  * Route polyline showing waypoints

- Professional map controls:
  * Zoom in/out buttons with smooth animations
  * Center-on-ship button for quick navigation
  * Info panel showing current position, heading, speed, distance
  * Glassmorphic info panel with dark/light mode support

- Enhanced SignalK mock:
  * Added trackPoints array to record ship movement
  * Automatically maintains last 500 points for performance
  * Integrated with map for visual track history

- Updated Navigation page to use new map
- Build: 68 modules, 1.24 MB (343 KB gzipped)
- Hot module reloading working smoothly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 15:00:17 +01:00

388 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div style={styles.container}>
<div ref={mapContainer} style={styles.mapBox} />
{/* Map Controls */}
<div style={styles.controls}>
<button
className="icon ghost"
onClick={() => map.current?.zoomIn()}
title="Zoom in"
style={{ fontSize: 18 }}
>
+
</button>
<button
className="icon ghost"
onClick={() => map.current?.zoomOut()}
title="Zoom out"
style={{ fontSize: 18 }}
>
</button>
<button
className="icon ghost"
onClick={() => {
if (lat != null && lon != null) {
map.current?.flyTo({
center: [lon, lat],
zoom: 12,
duration: 1000,
})
}
}}
title="Center on ship"
style={{ fontSize: 16 }}
>
</button>
</div>
{/* Info Panel */}
{lat != null && lon != null && (
<div style={styles.infoPanel}>
<div style={styles.infoSection}>
<div style={styles.infoRow}>
<span style={styles.label}>Position</span>
<span style={styles.value}>{lat.toFixed(5)}°</span>
</div>
<div style={styles.infoRow}>
<span style={styles.label}></span>
<span style={styles.value}>{lon.toFixed(5)}°</span>
</div>
</div>
{heading != null && (
<div style={styles.infoSection}>
<div style={styles.infoRow}>
<span style={styles.label}>Heading</span>
<span style={styles.value}>{Math.round(heading)}°</span>
</div>
</div>
)}
{sog != null && (
<div style={styles.infoSection}>
<div style={styles.infoRow}>
<span style={styles.label}>Speed</span>
<span style={styles.value}>{(sog * 1.943844).toFixed(1)} kn</span>
</div>
</div>
)}
{waypoints.length > 0 && snapshot?.currentWaypoint != null && (
<div style={styles.infoSection}>
<div style={styles.infoRow}>
<span style={styles.label}>Route</span>
<span style={styles.value}>WP {snapshot.currentWaypoint + 1} / {waypoints.length}</span>
</div>
</div>
)}
{snapshot?.distanceToWaypoint != null && (
<div style={styles.infoSection}>
<div style={styles.infoRow}>
<span style={styles.label}>Distance</span>
<span style={styles.value}>{(snapshot.distanceToWaypoint * 1.852).toFixed(1)} km</span>
</div>
</div>
)}
</div>
)}
{/* Map Style Toggle */}
<div style={styles.layerToggle}>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>+ SeaMarks</span>
</div>
</div>
)
}
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,
},
}