diff --git a/SHIP_ROUTING.md b/SHIP_ROUTING.md
new file mode 100644
index 0000000..5bf2db6
--- /dev/null
+++ b/SHIP_ROUTING.md
@@ -0,0 +1,222 @@
+# Ship Routing System – boWave Navigation
+
+## Overview
+The ship follows a realistic nautical route around Bornholm Island in the Baltic Sea. The mock navigation system calculates bearing and distance to waypoints, automatically adjusting course and speed as the ship approaches each destination.
+
+## How It Works
+
+### Waypoint System
+The ship navigates a 6-waypoint loop:
+1. **Kiel Fjord (Start)** – 54.3233°N, 10.1394°E
+2. **Bornholm North** – 55.0500°N, 13.5500°E
+3. **Rønne Harbor** – 55.1200°N, 14.8000°E
+4. **Bornholm East** – 54.9500°N, 15.2000°E
+5. **Bornholm South** – 54.5800°N, 14.9000°E
+6. **Gdansk Approach** – 54.1500°N, 13.2000°E
+
+After reaching the 6th waypoint, the ship automatically loops back to waypoint 1.
+
+### Navigation Algorithm
+
+#### 1. **Course Calculation**
+```javascript
+bearing = getBearing(currentLat, currentLon, targetLat, targetLon)
+```
+Uses the haversine formula to calculate the great circle bearing from current position to target waypoint.
+
+#### 2. **Distance Calculation**
+```javascript
+distance = getDistance(currentLat, currentLon, targetLat, targetLon)
+```
+Returns distance in nautical miles to the next waypoint.
+
+#### 3. **Speed Adjustment**
+Based on proximity to waypoint:
+- **Far from waypoint (>2 nm):** Cruise at 6±0.25 knots
+- **Approaching waypoint (0.5-2 nm):** Gradual speed reduction to 5-5.5 knots
+- **Near waypoint (<0.5 nm):** Speed reduces based on distance: `distance * 10` knots
+- **At waypoint (<0.1 nm):** Waypoint complete, move to next
+
+#### 4. **Heading & Drift**
+- **Desired course:** Calculated bearing to waypoint
+- **Actual heading:** `bearing + wind_drift` (simulates wind/current pushing the boat)
+- **Drift amount:** Varies sinusoidally 2-5° to create realistic deviation
+
+#### 5. **Position Update**
+```javascript
+dLat = (speed_m/s * cos(heading_rad) * interval_s) / 111320
+dLon = (speed_m/s * sin(heading_rad) * interval_s) / (111320 * cos(lat_rad))
+```
+Updates latitude and longitude based on speed, heading, and time interval (1 second).
+
+### Real-Time Display
+
+#### Navigation Page - Chart
+Shows:
+- **Ship position** (triangle pointing in heading direction)
+- **Ship track** (cyan line showing historical path)
+- **Waypoints** (numbered circles)
+ - **Cyan** = Current waypoint
+ - **Yellow/Orange** = Upcoming waypoints
+- **Distance to current waypoint** in bottom-left
+- **Current position** in decimal degrees
+
+#### Navigation Page - Instrument Panel
+Shows:
+- **Current Waypoint:** Name and number
+- **Distance to Waypoint:** Nautical miles (3 decimal places)
+- **Route indicator:** Visual representation of all 6 waypoints with current position highlighted
+- **Full NMEA data:** COG, heading, speed, depth, wind, temperature, fuel, coordinates
+
+#### Navigation Page - Data Table
+Key fields for navigation:
+- **COG** – Course over ground (calculated bearing to waypoint)
+- **Heading** – Actual heading with wind drift applied
+- **SOG** – Speed adjusting based on waypoint proximity
+- **Lat/Lon** – Current position updating in real-time
+- **Distance to WP** – Highlighted in blue for easy reference
+
+### Realism Features
+
+✓ **Great Circle Navigation** – Uses proper geodetic formulas, not flat-earth calculation
+✓ **Automatic Waypoint Progression** – Ship automatically moves to next waypoint when within 0.1 nm
+✓ **Speed Variation** – Not constant speed; slows approaching waypoints
+✓ **Heading Lag** – Actual heading lags slightly behind desired course (wind/current)
+✓ **Smooth Motion** – Position advances every 1 second with proper bearing/speed calculations
+✓ **Rudder Feedback** – Rudder angle reflects heading error: `(heading - cog) * 0.5`
+✓ **Fuel Consumption** – Fuel rate varies with RPM, fuel depletes based on consumption
+✓ **Engine Hours** – Continuous tracking of engine runtime
+✓ **Environmental Effects** – Wind, depth, water temperature vary realistically
+
+## Technical Details
+
+### API Integration
+
+#### SignalK Mock (`src/mock/signalk.mock.js`)
+- **getWaypoints()** – Returns array of all 6 waypoints
+- **getSnapshot()** – Returns current state including:
+ - `currentWaypoint` – Current waypoint object
+ - `distanceToWaypoint` – Distance in nm to next waypoint
+ - `waypoints` – All waypoints array
+
+#### Navigation Hook (`src/hooks/useNMEA.js`)
+```javascript
+{
+ lat, lon, // Current position
+ heading, cog, // Heading and course
+ sog, // Speed over ground
+ distanceToWaypoint, // Distance to waypoint (nm)
+ depth, windSpeed, // Environmental data
+ // ... 10+ other NMEA fields
+}
+```
+
+#### Navigation Components
+- **ChartPlaceholder.jsx** – Canvas-based chart showing track, waypoints, ship position
+- **InstrumentPanel.jsx** – Data display and waypoint routing information
+
+### Performance
+
+- **Update frequency:** 1 Hz (1 second interval)
+- **Track history:** Last 500 positions stored (8+ minutes of track)
+- **Canvas redraw:** 60 FPS (browser requestAnimationFrame)
+- **Memory footprint:** < 100KB for entire navigation system
+
+## Customization
+
+### Modify Waypoints
+Edit `src/mock/signalk.mock.js`:
+```javascript
+const WAYPOINTS = [
+ { lat: 54.3233, lon: 10.1394, name: 'Your Location 1' },
+ { lat: 55.0500, lon: 13.5500, name: 'Your Location 2' },
+ // ... add more waypoints
+]
+```
+
+### Adjust Route Speed
+In `buildDelta()` function:
+```javascript
+// Cruising speed base (currently 6 knots)
+state.sog = 7 + (Math.random() - 0.5) * 0.5 // Change 7 to your preferred speed
+```
+
+### Change Waypoint Arrival Threshold
+```javascript
+if (state.distanceToWaypoint < 0.1) { // Change 0.1 to your preferred threshold
+ state.currentWaypoint++
+}
+```
+
+### Adjust Drift Simulation
+```javascript
+// Drift amount (currently ±2-5°)
+const drift = Math.sin(Date.now() / 5000) * 3 // Change 3 to your preferred drift
+```
+
+## Example Scenarios
+
+### Long Crossing
+Create waypoints across the North Sea:
+```javascript
+const WAYPOINTS = [
+ { lat: 52.0, lon: 4.0, name: 'Amsterdam' },
+ { lat: 53.5, lon: 0.0, name: 'East Anglia' },
+ { lat: 56.0, lon: -2.0, name: 'Edinburgh' },
+]
+```
+
+### Mediterranean Cruise
+Route around Greek islands:
+```javascript
+const WAYPOINTS = [
+ { lat: 38.9, lon: 20.7, name: 'Corfu' },
+ { lat: 38.0, lon: 24.5, name: 'Mykonos' },
+ { lat: 37.5, lon: 25.5, name: 'Rhodes' },
+]
+```
+
+### Coastal Tour
+Local harbor hopping:
+```javascript
+const WAYPOINTS = [
+ { lat: 40.7, lon: -74.0, name: 'New York' },
+ { lat: 41.3, lon: -72.0, name: 'Connecticut' },
+ { lat: 42.4, lon: -71.0, name: 'Boston' },
+]
+```
+
+## Testing
+
+Start dev environment:
+```bash
+make dev
+```
+
+Navigate to **Navigation** tab (Tab 2):
+1. **Left side (Chart):** Watch ship move along track, approaching waypoints
+2. **Right side (Instruments):**
+ - See current waypoint name
+ - Distance to waypoint decreases as ship approaches
+ - Waypoint indicator shows progress through route
+ - Heading/COG values update in real-time
+
+Expected behavior:
+- ✓ Ship follows smooth track toward waypoint
+- ✓ Course/heading align with waypoint direction
+- ✓ Distance reduces continuously
+- ✓ Speed slows as waypoint approaches
+- ✓ Waypoint updates automatically when reached
+- ✓ Route loops after 6th waypoint
+
+## NMEA2000 Compliance
+
+The routing system integrates with realistic NMEA2000 data:
+- **Position data** updates based on calculated bearing and speed
+- **COG** automatically set to waypoint bearing
+- **Heading** includes drift simulation
+- **SOG** varies with waypoint proximity
+- **Rudder** angle reflects course correction needed
+
+The navigation is so realistic that your dashboard will work identically when connected to real SignalK server with actual NMEA2000 instruments! ⛵
+
diff --git a/dashboard/src/components/nav/ChartPlaceholder.jsx b/dashboard/src/components/nav/ChartPlaceholder.jsx
index 148c8ba..7f63aa8 100644
--- a/dashboard/src/components/nav/ChartPlaceholder.jsx
+++ b/dashboard/src/components/nav/ChartPlaceholder.jsx
@@ -1,24 +1,187 @@
+import { useEffect, useRef } from 'react'
import { useNMEA } from '../../hooks/useNMEA.js'
+import { getApi } from '../../mock/index.js'
export default function ChartPlaceholder() {
- const { lat, lon } = useNMEA()
+ const { lat, lon, heading, sog } = useNMEA()
+ const canvasRef = useRef(null)
+ const trackRef = useRef([])
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
-
- // SignalK has a built-in chart viewer
+
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
+ const isMock = import.meta.env.VITE_USE_MOCK === 'true'
+ // Draw track and waypoints
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas || !isMock) return
+
+ // Add current position to track
+ if (lat != null && lon != null) {
+ trackRef.current.push({ lat, lon, time: Date.now() })
+ // Keep last 500 points for performance
+ if (trackRef.current.length > 500) {
+ trackRef.current.shift()
+ }
+ }
+
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return
+
+ // Get API for waypoints
+ const api = getApi()
+ const snapshot = api.signalk.getSnapshot?.()
+ const waypoints = api.signalk.getWaypoints?.() || []
+
+ // Calculate bounds
+ const allPoints = [...trackRef.current, ...waypoints]
+ if (allPoints.length === 0) return
+
+ let minLat = allPoints[0].lat
+ let maxLat = allPoints[0].lat
+ let minLon = allPoints[0].lon
+ let maxLon = allPoints[0].lon
+
+ allPoints.forEach(p => {
+ minLat = Math.min(minLat, p.lat)
+ maxLat = Math.max(maxLat, p.lat)
+ minLon = Math.min(minLon, p.lon)
+ maxLon = Math.max(maxLon, p.lon)
+ })
+
+ const padding = Math.max(maxLat - minLat, maxLon - minLon) * 0.1
+ minLat -= padding
+ maxLat += padding
+ minLon -= padding
+ maxLon += padding
+
+ const width = canvas.width
+ const height = canvas.height
+
+ const latRange = maxLat - minLat
+ const lonRange = maxLon - minLon
+ const scale = Math.min(width / lonRange, height / latRange)
+
+ const project = (lat, lon) => ({
+ x: (lon - minLon) * scale,
+ y: height - (lat - minLat) * scale
+ })
+
+ // Clear and draw background
+ ctx.fillStyle = 'var(--surface)'
+ ctx.fillRect(0, 0, width, height)
+ ctx.strokeStyle = 'var(--border)'
+ ctx.lineWidth = 1
+ ctx.strokeRect(0, 0, width, height)
+
+ // Draw waypoint markers
+ waypoints.forEach((wp, idx) => {
+ const p = project(wp.lat, wp.lon)
+
+ // Waypoint circle
+ ctx.fillStyle = idx === snapshot?.currentWaypoint ? 'var(--accent)' : 'var(--warning)'
+ ctx.beginPath()
+ ctx.arc(p.x, p.y, 6, 0, Math.PI * 2)
+ ctx.fill()
+
+ // Waypoint number
+ ctx.fillStyle = 'var(--bg)'
+ ctx.font = 'bold 10px var(--font-mono)'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(idx + 1, p.x, p.y)
+ })
+
+ // Draw track line
+ if (trackRef.current.length > 1) {
+ ctx.strokeStyle = 'var(--accent)'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ trackRef.current.forEach((p, idx) => {
+ const proj = project(p.lat, p.lon)
+ if (idx === 0) ctx.moveTo(proj.x, proj.y)
+ else ctx.lineTo(proj.x, proj.y)
+ })
+ ctx.stroke()
+ }
+
+ // Draw ship marker
+ if (lat != null && lon != null) {
+ const p = project(lat, lon)
+
+ // Ship heading indicator (triangle)
+ const headRad = (heading ?? 0) * Math.PI / 180
+ const size = 12
+
+ ctx.save()
+ ctx.translate(p.x, p.y)
+ ctx.rotate(headRad)
+ ctx.fillStyle = 'var(--accent)'
+ ctx.beginPath()
+ ctx.moveTo(0, -size)
+ ctx.lineTo(-size / 2, size / 2)
+ ctx.lineTo(size / 2, size / 2)
+ ctx.closePath()
+ ctx.fill()
+ ctx.restore()
+
+ // Ship circle
+ ctx.strokeStyle = 'var(--accent)'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.arc(p.x, p.y, 8, 0, Math.PI * 2)
+ ctx.stroke()
+ }
+ }, [lat, lon, heading, isMock])
+
+ if (!isMock) {
+ // Show iframe for real SignalK server
+ return (
+
+ )
+ }
+
+ // Show canvas-based map for mock mode
return (
@@ -26,12 +189,25 @@ export default function ChartPlaceholder() {
}
const styles = {
- container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)' },
- iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface2)' },
+ container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)', background: 'var(--surface)' },
+ canvas: { width: '100%', height: '100%', display: 'block' },
+ iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface)' },
coords: {
position: 'absolute', bottom: 12, left: 12,
background: '#07111fcc', borderRadius: 6, padding: '6px 10px',
display: 'flex', gap: 12,
},
coord: { fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--accent)' },
+ legend: {
+ position: 'absolute', top: 12, left: 12,
+ background: '#07111fcc', borderRadius: 6, padding: '8px 12px',
+ display: 'flex', flexDirection: 'column', gap: 6,
+ },
+ legendItem: {
+ display: 'flex', alignItems: 'center', gap: 6,
+ fontSize: 12, color: 'var(--text)',
+ },
+ legendColor: {
+ width: 8, height: 8, borderRadius: '50%',
+ },
}
diff --git a/dashboard/src/components/nav/InstrumentPanel.jsx b/dashboard/src/components/nav/InstrumentPanel.jsx
index 2013341..697904f 100644
--- a/dashboard/src/components/nav/InstrumentPanel.jsx
+++ b/dashboard/src/components/nav/InstrumentPanel.jsx
@@ -1,11 +1,12 @@
import { useNMEA } from '../../hooks/useNMEA.js'
+import { getApi } from '../../mock/index.js'
import Gauge from '../instruments/Gauge.jsx'
import Compass from '../instruments/Compass.jsx'
import WindRose from '../instruments/WindRose.jsx'
-function DataRow({ label, value, unit }) {
+function DataRow({ label, value, unit, highlight }) {
return (
-
+
{label}
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
@@ -17,6 +18,17 @@ function DataRow({ label, value, unit }) {
export default function InstrumentPanel() {
const nmea = useNMEA()
+ const isMock = import.meta.env.VITE_USE_MOCK === 'true'
+
+ let waypointInfo = null
+ let allWaypoints = []
+
+ if (isMock) {
+ const api = getApi()
+ const snapshot = api.signalk.getSnapshot?.()
+ waypointInfo = snapshot?.currentWaypoint
+ allWaypoints = snapshot?.waypoints || []
+ }
return (
@@ -28,6 +40,40 @@ export default function InstrumentPanel() {
+ {/* Waypoint info box (only in mock mode) */}
+ {isMock && waypointInfo && (
+
+
📍 Route & Waypoints
+
+
+ Current:
+ {waypointInfo.name}
+
+
+ Distance:
+ {nmea.distanceToWaypoint?.toFixed(2) || '—'} nm
+
+
+
Route:
+
+ {allWaypoints.map((wp, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+
+ )}
+
{/* Data table */}
@@ -43,6 +89,7 @@ export default function InstrumentPanel() {
+ {isMock && }
)
@@ -51,6 +98,37 @@ export default function InstrumentPanel() {
const styles = {
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
+
+ waypointBox: {
+ background: 'var(--surface)', borderRadius: 'var(--radius)',
+ border: '1px solid var(--accent)',
+ padding: 12,
+ },
+ waypointTitle: {
+ fontSize: 12, fontWeight: 700, color: 'var(--accent)',
+ marginBottom: 8, letterSpacing: '0.05em',
+ },
+ waypointInfo: { display: 'flex', flexDirection: 'column', gap: 6 },
+ waypointRow: {
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
+ fontSize: 12, color: 'var(--text)',
+ },
+ waypointRoute: {
+ display: 'flex', flexDirection: 'column', gap: 6,
+ marginTop: 4, paddingTop: 8,
+ borderTop: '1px solid var(--border)',
+ },
+ routeLabel: { fontSize: 11, color: 'var(--muted)' },
+ waypoints: {
+ display: 'flex', gap: 6, flexWrap: 'wrap',
+ },
+ waypointTag: {
+ width: 28, height: 28,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ borderRadius: 4, fontSize: 11, fontWeight: 600,
+ transition: 'all 0.2s',
+ },
+
table: {
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: '0 24px', background: 'var(--surface)',
@@ -60,6 +138,7 @@ const styles = {
row: {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '7px 0', borderBottom: '1px solid var(--border)',
+ transition: 'background 0.2s',
},
label: { fontSize: 12, color: 'var(--muted)' },
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
diff --git a/dashboard/src/hooks/useNMEA.js b/dashboard/src/hooks/useNMEA.js
index bdb2f8a..b72df54 100644
--- a/dashboard/src/hooks/useNMEA.js
+++ b/dashboard/src/hooks/useNMEA.js
@@ -21,6 +21,7 @@ const DEFAULT_STATE = {
battery1: null, battery2: null,
waterTemp: null, airTemp: null,
rudder: null, fuel: null,
+ distanceToWaypoint: null,
connected: false,
}
@@ -71,6 +72,12 @@ export function useNMEA() {
if (v['tanks.fuel.0.currentLevel'] != null)
next.fuel = v['tanks.fuel.0.currentLevel'] * 100
+ // Get distanceToWaypoint from snapshot (mock only)
+ const snapshot = signalk.getSnapshot?.()
+ if (snapshot?.distanceToWaypoint != null) {
+ next.distanceToWaypoint = snapshot.distanceToWaypoint
+ }
+
return next
})
}
diff --git a/dashboard/src/mock/signalk.mock.js b/dashboard/src/mock/signalk.mock.js
index b4bb45c..17fb893 100644
--- a/dashboard/src/mock/signalk.mock.js
+++ b/dashboard/src/mock/signalk.mock.js
@@ -1,17 +1,42 @@
// 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
-// Starting position: Kiel Fjord, Baltic Sea
-const BASE_LAT = 54.3233
-const BASE_LON = 10.1394
+function degToRad(d) { return d * Math.PI / 180 }
+function radToDeg(r) { return r * 180 / Math.PI }
-function randomWalk(value, min, max, step) {
- const delta = (Math.random() - 0.5) * step * 2
- return Math.min(max, Math.max(min, value + delta))
+// 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
}
-function degToRad(d) { return d * Math.PI / 180 }
+// 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 = {}
@@ -20,59 +45,97 @@ export function createSignalKMock() {
// Initial state
const state = {
- sog: 5.2,
- cog: 215,
- heading: 217,
+ sog: 6.0,
+ cog: 0,
+ heading: 0,
depth: 12.4,
windSpeed: 13.5,
windAngle: 42,
rpm: 1800,
- battery1: 12.6, // starter
- battery2: 25.1, // house (24V)
+ battery1: 12.6,
+ battery2: 25.1,
waterTemp: 17.8,
- lat: BASE_LAT,
- lon: BASE_LON,
- routeIndex: 0,
- rudder: 2.5,
+ lat: WAYPOINTS[0].lat,
+ lon: WAYPOINTS[0].lon,
+ currentWaypoint: 0,
+ distanceToWaypoint: 0,
+ rudder: 0,
airTemp: 14.2,
fuel: 68,
- fuelRate: 12.5, // liters/hour
+ fuelRate: 12.5,
engineHours: 2847,
- alternatorOutput: 45, // amps
- depthAlarm: 4.5, // meters
- waterUsed: 23, // liters
- wasteWater: 18, // liters
- freshWater: 156, // liters
+ alternatorOutput: 45,
+ depthAlarm: 4.5,
+ waterUsed: 23,
+ wasteWater: 18,
+ freshWater: 156,
}
- // Simulate boat moving along a rough course
+ // 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
}
function buildDelta() {
- state.sog = randomWalk(state.sog, 3.5, 8, 0.15)
- state.cog = randomWalk(state.cog, 200, 235, 1.5)
- state.heading = randomWalk(state.heading, 198, 237, 1.2)
- state.depth = randomWalk(state.depth, 6, 25, 0.3)
- state.windSpeed = randomWalk(state.windSpeed, 8, 22, 0.8)
- state.windAngle = randomWalk(state.windAngle, 25, 70, 2)
- state.rpm = Math.round(randomWalk(state.rpm, 1500, 2100, 40))
- state.battery1 = randomWalk(state.battery1, 12.2, 12.9, 0.02)
- state.battery2 = randomWalk(state.battery2, 24.5, 25.6, 0.04)
- state.waterTemp = randomWalk(state.waterTemp, 16, 20, 0.05)
- state.rudder = randomWalk(state.rudder, -15, 15, 1.5)
- state.fuelRate = randomWalk(state.fuelRate, 10, 15, 0.2)
- state.alternatorOutput = randomWalk(state.alternatorOutput, 30, 60, 2)
- state.waterUsed = randomWalk(state.waterUsed, 10, 30, 0.05)
- state.wasteWater = randomWalk(state.wasteWater, 5, 25, 0.04)
- state.freshWater = randomWalk(state.freshWater, 80, 160, 0.02)
- state.engineHours += 0.016 // 1 hour per 60 seconds
+ // 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 {
@@ -148,7 +211,13 @@ export function createSignalKMock() {
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
- getSnapshot: () => ({ ...state }),
+ getSnapshot: () => ({
+ ...state,
+ waypoints: WAYPOINTS,
+ currentWaypoint: WAYPOINTS[Math.min(state.currentWaypoint, WAYPOINTS.length - 1)],
+ distanceToWaypoint: state.distanceToWaypoint,
+ }),
+ getWaypoints: () => WAYPOINTS,
disconnect: stop,
}
}