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
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>