- 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>
231 lines
8.1 KiB
JavaScript
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,
|
|
}
|
|
}
|