Files
boWave/dashboard/src/mock/signalk.mock.js
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

231 lines
8.1 KiB
JavaScript

// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
// The ship navigates a realistic route around Bornholm Island, Baltic Sea.
const INTERVAL_MS = 1000
function degToRad(d) { return d * Math.PI / 180 }
function radToDeg(r) { return r * 180 / Math.PI }
// Realistic sailing route around Bornholm Island, Baltic Sea
const WAYPOINTS = [
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Start)' },
{ lat: 55.0500, lon: 13.5500, name: 'Bornholm North' },
{ lat: 55.1200, lon: 14.8000, name: 'Rønne Harbor' },
{ lat: 54.9500, lon: 15.2000, name: 'Bornholm East' },
{ lat: 54.5800, lon: 14.9000, name: 'Bornholm South' },
{ lat: 54.1500, lon: 13.2000, name: 'Gdansk Approach' },
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Loop)' },
]
// Calculate distance between two coordinates in nautical miles
function getDistance(lat1, lon1, lat2, lon2) {
const R = 3440.06 // Earth's radius in nautical miles
const dLat = degToRad(lat2 - lat1)
const dLon = degToRad(lon2 - lon1)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
// Calculate bearing from point 1 to point 2 (0-360 degrees)
function getBearing(lat1, lon1, lat2, lon2) {
const dLon = degToRad(lon2 - lon1)
const y = Math.sin(dLon) * Math.cos(degToRad(lat2))
const x = Math.cos(degToRad(lat1)) * Math.sin(degToRad(lat2)) -
Math.sin(degToRad(lat1)) * Math.cos(degToRad(lat2)) * Math.cos(dLon)
return (radToDeg(Math.atan2(y, x)) + 360) % 360
}
export function createSignalKMock() {
const listeners = {}
let timer = null
let running = false
// Initial state
const state = {
sog: 6.0,
cog: 0,
heading: 0,
depth: 12.4,
windSpeed: 13.5,
windAngle: 42,
rpm: 1800,
battery1: 12.6,
battery2: 25.1,
waterTemp: 17.8,
lat: WAYPOINTS[0].lat,
lon: WAYPOINTS[0].lon,
currentWaypoint: 0,
distanceToWaypoint: 0,
rudder: 0,
airTemp: 14.2,
fuel: 68,
fuelRate: 12.5,
engineHours: 2847,
alternatorOutput: 45,
depthAlarm: 4.5,
waterUsed: 23,
wasteWater: 18,
freshWater: 156,
trackPoints: [{ lat: WAYPOINTS[0].lat, lon: WAYPOINTS[0].lon }],
}
// Navigate to next waypoint
function updateCourseToWaypoint() {
if (state.currentWaypoint >= WAYPOINTS.length) {
state.currentWaypoint = 0 // Loop back to start
}
const target = WAYPOINTS[state.currentWaypoint]
const bearing = getBearing(state.lat, state.lon, target.lat, target.lon)
state.distanceToWaypoint = getDistance(state.lat, state.lon, target.lat, target.lon)
// Update desired course
state.cog = bearing
// Heading lags slightly behind (2-5° drift due to wind/current)
const drift = Math.sin(Date.now() / 5000) * 3
state.heading = (bearing + drift + 360) % 360
// Vary speed based on proximity to waypoint
if (state.distanceToWaypoint < 0.5) {
// Approaching waypoint
state.sog = Math.max(2, state.distanceToWaypoint * 10)
if (state.distanceToWaypoint < 0.1) {
state.currentWaypoint++ // Move to next waypoint
}
} else if (state.distanceToWaypoint < 2) {
// In final approach
state.sog = 5 + (Math.random() - 0.5) * 1
} else {
// Cruising
state.sog = 6 + (Math.random() - 0.5) * 0.5
}
}
// Advance position based on course and speed
function advancePosition() {
const speedMs = state.sog * 0.514 // knots to m/s
const headRad = degToRad(state.heading)
// Calculate lat/lon displacement
const dLat = (speedMs * Math.cos(headRad) * INTERVAL_MS / 1000) / 111320
const dLon = (speedMs * Math.sin(headRad) * INTERVAL_MS / 1000) / (111320 * Math.cos(degToRad(state.lat)))
state.lat += dLat
state.lon += dLon
// Record track point (keep last 500)
state.trackPoints.push({ lat: state.lat, lon: state.lon })
if (state.trackPoints.length > 500) {
state.trackPoints.shift()
}
}
function buildDelta() {
// Update navigation
updateCourseToWaypoint()
// Update sensors with subtle variations
state.depth = Math.max(3, state.depth + (Math.random() - 0.5) * 0.3)
state.windSpeed = Math.max(5, state.windSpeed + (Math.random() - 0.5) * 0.8)
state.windAngle = (state.windAngle + (Math.random() - 0.5) * 2 + 360) % 360
state.rpm = 1600 + (Math.random() - 0.5) * 200
state.battery1 = 12.4 + (Math.random() - 0.5) * 0.3
state.battery2 = 25.0 + (Math.random() - 0.5) * 0.4
state.waterTemp = 17.8 + (Math.random() - 0.5) * 0.2
state.rudder = (state.heading - state.cog) * 0.5 // Rudder angle follows heading error
state.fuelRate = 12.5 + (state.rpm - 1800) * 0.01
state.fuel = Math.max(5, state.fuel - state.fuelRate / 3600)
state.engineHours += 0.016 / 3600
state.alternatorOutput = 40 + (Math.random() - 0.5) * 10
// Advance position
advancePosition()
return {
updates: [{
source: { label: 'mock', type: 'NMEA2000' },
timestamp: new Date().toISOString(),
values: [
// Navigation
{ path: 'navigation.speedOverGround', value: state.sog * 0.514 },
{ path: 'navigation.courseOverGroundTrue', value: degToRad(state.cog) },
{ path: 'navigation.headingTrue', value: degToRad(state.heading) },
{ path: 'navigation.position', value: { latitude: state.lat, longitude: state.lon } },
// Depth & water
{ path: 'environment.depth.belowKeel', value: state.depth },
{ path: 'environment.water.temperature', value: state.waterTemp + 273.15 },
// Wind
{ path: 'environment.wind.speedApparent', value: state.windSpeed * 0.514 },
{ path: 'environment.wind.angleApparent', value: degToRad(state.windAngle) },
// Air
{ path: 'environment.outside.temperature', value: state.airTemp + 273.15 },
// Engine
{ path: 'propulsion.main.revolutions', value: state.rpm / 60 },
{ path: 'propulsion.main.fuelRate', value: state.fuelRate / 3600 }, // kg/s (approximated)
{ path: 'propulsion.main.alternatorOutput', value: state.alternatorOutput },
// Electrical
{ path: 'electrical.batteries.starter.voltage', value: state.battery1 },
{ path: 'electrical.batteries.house.voltage', value: state.battery2 },
// Steering
{ path: 'steering.rudderAngle', value: degToRad(state.rudder) },
// Tanks (as ratio 0-1)
{ path: 'tanks.fuel.0.currentLevel', value: state.fuel / 100 },
{ path: 'tanks.freshWater.0.currentLevel', value: state.freshWater / 200 },
{ path: 'tanks.wasteWater.0.currentLevel', value: state.wasteWater / 50 },
{ path: 'tanks.wasteWater.0.pumpOverride', value: state.wasteWater > 30 },
// Engine hours (seconds)
{ path: 'propulsion.main.engineHours', value: state.engineHours * 3600 },
]
}]
}
}
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function start() {
if (running) return
running = true
// Send initial delta immediately
emit('delta', buildDelta())
timer = setInterval(() => emit('delta', buildDelta()), INTERVAL_MS)
}
function stop() {
if (timer) clearInterval(timer)
running = false
}
return {
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
if (event === 'delta' && !running) start()
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
getSnapshot: () => ({
...state,
waypoints: WAYPOINTS,
currentWaypoint: WAYPOINTS[Math.min(state.currentWaypoint, WAYPOINTS.length - 1)],
distanceToWaypoint: state.distanceToWaypoint,
}),
getWaypoints: () => WAYPOINTS,
disconnect: stop,
}
}