- 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>
388 lines
10 KiB
JavaScript
388 lines
10 KiB
JavaScript
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,
|
||
},
|
||
}
|