// 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, } }