UNPKG

signalk-racer

Version:

Signalk plugin to calculate values of interest to sail racers, such as: Time to Start; Time of Start, Time to Burn; Distance to Line; Next leg TWA.

406 lines (358 loc) 14.9 kB
<!DOCTYPE html> <!-- public/index.html --> <html lang="en"> <head> <meta charset="UTF-8"> <title>SignalK Racer Control</title> <style> body { background-color: black; color: white; /* optional for visibility */ } .button-timer-command { background-color: #041b8e; color: white; font-size: 1em; font-weight: bold; } .button-timer-adjust { background-color: #2196F3; color: white; } button { padding: 1em 1em; font-size: 1em; cursor: pointer; border: none; border-radius: 4px; } body { font-family: sans-serif; max-width: 100%; margin: 1em auto; text-align: center; } input[readonly] { background: #eee; border: 1px solid #ccc; padding: 0.3em; } #status { margin-top: 1em; font-size: 1em; color: green; } .port-set { background-color: #f88; color: black; font-size: 1em;} .port-move { background-color: #a00; color: white; } .port-rotate { background-color: #a00; color: white; } .stb-set { background-color: #8f8; color: black; font-size: 1em; } .stb-move { background-color: #080; color: white;} .stb-rotate { background-color: #080; color: white; } #startLineSelect { font-size: 1em; padding: 0.3em; margin-left: 0.5em; } </style> </head> <body> <script> let socket; let statusTimer; let startTime; function updateStatus(message) { const status = document.getElementById('status'); if (!status) return; status.textContent = message; if (statusTimer) clearTimeout(statusTimer); statusTimer = setTimeout(() => { if (status.textContent === message) { status.textContent = '-'; } }, 10000); } function toRadians(degrees) { return degrees ? degrees * (Math.PI / 180) : null; } function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const socketUrl = protocol + '//' + window.location.host + '/signalk/v1/stream?subscribe=none'; socket = new WebSocket(socketUrl); socket.onopen = () => { updateStatus('✅ Connected to Signal K server'); socket.send(JSON.stringify({ context: "vessels.self", subscribe: [ { path: "navigation.racing.*", policy: "instant" } ] })); }; socket.onerror = (err) => updateStatus('WebSocket error: ' + err.message); socket.onclose = () => { updateStatus('Disconnected. Retrying in 5s...'); setTimeout(connectWebSocket, 5000); }; socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.updates) { data.updates.forEach(update => { if (update.values) { update.values.forEach(value => { switch (value.path) { case "navigation.racing.startLineLength": // document.getElementById("lineLength").value = value.value.toFixed(1); break; case "navigation.racing.stbLineBias": // document.getElementById("lineBias").value = value.value.toFixed(1); break; case "navigation.racing.timeToStart": let timer = document.getElementById('timeToStart'); if (timer) timer.textContent = formatTimeToMMSS(value.value); break; case "navigation.racing.startTime": { if (!value.value) { document.getElementById('startTime').value = "HH:MM:SS"; } else { startTime = new Date(value.value); // handles ISO 8601 format document.getElementById('startTime').value = startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } break; } default: console.log("Received value: " + value.path + " = " + JSON.stringify(value.value)); break; } }); } }); } }; } function sendSetStartLine(end) { if (!socket || socket.readyState !== WebSocket.OPEN) { updateStatus('WebSocket not connected.'); return; } let message = JSON.stringify({ context: 'vessels.self', put: { path: 'navigation.racing.setStartLine', value: { end, position : 'bow'} } }); console.log("sendSetStartLine: " + message); socket.send(message); updateStatus(`Sent request to set ${end}`); } function sendAdjustStartLine(end, delta, rotate) { if (!socket || socket.readyState !== WebSocket.OPEN) { updateStatus('WebSocket not connected.'); return; } const message = JSON.stringify({ context: 'vessels.self', put: { path: 'navigation.racing.setStartLine', value: { end, delta, rotate : rotate ? toRadians(rotate) : null } } }); console.log("sendAdjustStartLine: " + message); socket.send(message); updateStatus(`Sent ${delta}m ${rotate}° request for ${end}`); } function loadStartLines() { const url = window.location.origin + '/signalk/v1/api/vessels/self/navigation/racing/lines'; fetch(url) .then(res => { if (!res.ok) { throw new Error('HTTP ' + res.status); } return res.json(); }) .then(data => { const select = document.getElementById('startLineSelect'); if (!select) return; // Clear existing options select.innerHTML = ''; // Default option = "no named line" const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = 'Default line'; select.appendChild(defaultOpt); const payload = data && data.value ? data.value : { lines: [], startLineName: null }; if (Array.isArray(payload.lines)) { payload.lines.forEach(line => { const opt = document.createElement('option'); opt.value = line.startLineName; opt.textContent = line.startLineDescription ? `${line.startLineName}${line.startLineDescription}` : line.startLineName; select.appendChild(opt); }); } // Preselect active if (!payload.startLineName) { select.value = ''; } else { select.value = payload.startLineName; if (select.value !== payload.startLineName) { // In case active name not in the list select.value = ''; } } updateStatus('Loaded start lines: ' + payload.lines.length + ' lines'); }) .catch(err => { console.error('Failed to load start lines:', err); updateStatus('Failed to load start lines: ' + err.message); }); } function setStartLineName(startLineName) { const url = window.location.origin + '/signalk/v1/api/vessels/self/navigation/racing/setStartLineName'; const body = { value: { startLineName: startLineName === '' ? null : startLineName } }; fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(res => { if (!res.ok) { throw new Error('HTTP ' + res.status); } return res.json().catch(() => ({})); }) .then(() => { updateStatus( startLineName ? `Active line set to: ${startLineName}` : 'Active line set to Default' ); }) .catch(err => { console.error('Failed to set active line:', err); updateStatus('Failed to set active line: ' + err.message); }); } // connect WS and load lines once the page is running connectWebSocket(); loadStartLines(); function formatTimeToMMSS(seconds) { const mm = Math.floor(seconds / 60); const ss = Math.floor(seconds % 60); return `${mm.toString().padStart(2, '0')}:${ss.toString().padStart(2, '0')}`; } function adjustStartTime(deltaSeconds) { if (!socket || socket.readyState !== WebSocket.OPEN) { updateStatus('WebSocket not connected.'); return; } socket.send(JSON.stringify({ context: 'vessels.self', put: { path: 'navigation.racing.setStartTime', value: { command: 'adjust', delta: deltaSeconds } } })); updateStatus(`Sent delta timeToStart: ${deltaSeconds} sec`); } function setStartTime() { if (!socket || socket.readyState !== WebSocket.OPEN) { updateStatus('WebSocket not connected.'); return; } try { const now = new Date(); const parts = document.getElementById('startTime').value.split(':').map(Number); const hours = parts[0]; const minutes = parts[1]; const seconds = parts.length >= 3 ? parts[2] : 0; const date = new Date(now); date.setHours(hours, minutes, seconds, 0); if (date <= now) date.setDate(date.getDate() + 1); socket.send(JSON.stringify({ context: 'vessels.self', put: { path: 'navigation.racing.setStartTime', value: { command: 'set', startTime: date.toISOString(), } } })); updateStatus(`Sent start time: ${date.toLocaleTimeString()}`); } catch (err) { console.log('Could not set start at ', err); } } function sendTimerCommand(command) { if (!socket || socket.readyState !== WebSocket.OPEN) { updateStatus('WebSocket not connected.'); return; } socket.send(JSON.stringify({ context: 'vessels.self', put: { path: 'navigation.racing.setStartTime', value: { command } } })); updateStatus(`Sent timer command: ${command}`); } </script> <div style="display: flex; justify-content: center; align-items: center; gap: 3em; flex-wrap: wrap;"> <div style="display: grid; grid-template-columns: repeat(6, auto); grid-template-rows: repeat(3, auto); gap: 0.5em;"> <div></div> <button class="port-rotate" onclick="sendAdjustStartLine('port', 0, 1)">+1°</button> <div></div> <div></div> <button class="stb-rotate" onclick="sendAdjustStartLine('stb', 0, -1)">+1°</button> <div></div> <!---------> <button class="port-move" onclick="sendAdjustStartLine('port', 5, 0)">+5m</button> <button class="port-set" onclick="sendSetStartLine('port')">Port</button> <button class="port-move" onclick="sendAdjustStartLine('port', -5, 0)">-5m</button> <button class="stb-move" onclick="sendAdjustStartLine('stb', -5, 0)">-5m</button> <button class="stb-set" onclick="sendSetStartLine('stb')">Stbd</button> <button class="stb-move" onclick="sendAdjustStartLine('stb', 5, 0)">+5m</button> <!---------> <div></div> <button class="port-rotate" onclick="sendAdjustStartLine('port', 0, -1)">-1°</button> <div></div> <div></div> <button class="stb-rotate" onclick="sendAdjustStartLine('stb', 0, 1)">-1°</button> <div></div> </div> </div> <!-- Start-line selector --> <div style="margin-top: 1em;"> <label for="startLineSelect">Start line:</label> <select id="startLineSelect" onchange="setStartLineName(this.value)"></select> </div> <!-- Start Time Row --> <div style="margin-top: 1em; display: flex; justify-content: center; align-items: center; gap: 0.5em;"> <button class="button-timer-adjust" onclick="adjustStartTime(-60)">-1min</button> <button class="button-timer-adjust" onclick="adjustStartTime(-1)">-1s</button> <label for="startTime">At: <input id="startTime" style="font-size: 1.5em; width: 6em; text-align: center;"></label> <button class="button-timer-adjust" onclick="adjustStartTime(1)">+1s</button> <button class="button-timer-adjust" onclick="adjustStartTime(60)">+1min</button> </div> <!-- Timer Control Buttons --> <div style="margin-top: 0.5em;"> <button class="button-timer-command" onclick="sendTimerCommand('start')">Start</button> <button class="button-timer-command" onclick="setStartTime()">At</button> <button class="button-timer-command" onclick="sendTimerCommand('sync')">Sync</button> <button class="button-timer-command" onclick="sendTimerCommand('reset')">Reset</button> </div> <div id="status">Connecting...</div> </body> </html>