UNPKG

polar-recorder

Version:

A SignalK plugin to record boat polars based on sailing performance

490 lines (397 loc) 16.8 kB
// main.js import { initChart, updateChart, updateLivePoint } from './chart.js'; const API_BASE = '/signalk/v1/api/polar-recorder'; let latestPolarData = {}; let polarFiles = []; let selectedPolarFile; let showFullChart = true; let ws; let reconnectInterval; function connectWebSocket() { ws = new WebSocket(`ws://${location.host}/plugins/polar-recorder/ws`); ws.onopen = () => { console.log("[WebSocket] Connected to backend"); clearInterval(reconnectInterval); // Stop reconnection attempts }; ws.onmessage = (event) => { const message = JSON.parse(event.data); console.log("[WebSocket] Message received:", message); switch (message.event) { case 'updateLivePerformance': updateLivePerformance(message.twa, message.tws, message.stw); break; case 'changeMotoringStatus': document.getElementById('motoringOverlay').style.display = message.engineOn ? 'flex' : 'none'; break; case 'changeRecordStatus': var recordingControls = document.getElementById('recordControls'); if (recordingControls) { recordingControls.style.display = message.status ? 'block' : 'none'; } document.getElementById('recordingOverlay').style.display = message.status ? 'block' : 'none'; break; case 'polarUpdated': if (message.filePath != undefined) { const updatedFile = message.filePath.split('/').pop(); // Extract filename const select = document.getElementById('polarFileSelect'); if (select && Array.from(select.options).some(opt => opt.value === updatedFile)) { select.value = updatedFile; fetchPolarData(updatedFile); } else { // If the file is new, re-fetch the list and then select the new one fetchPolarFiles(updatedFile); } } break; default: console.warn("[WebSocket] Unknown event:", message); } }; ws.onclose = () => { console.warn("[WebSocket] Disconnected. Attempting reconnect every 10s..."); if (!reconnectInterval) { reconnectInterval = setInterval(() => { console.log("[WebSocket] Trying to reconnect..."); connectWebSocket(); }, 10000); } }; ws.onerror = (err) => { console.error("[WebSocket] Error:", err); ws.close(); // Trigger reconnect flow }; } async function fetchPolarData(polarFile) { try { selectedPolarFile = polarFile; const url = `${API_BASE}/polar-data${polarFile ? `?fileName=${encodeURIComponent(polarFile)}` : ''}`; const response = await fetch(url); if (response.ok) { latestPolarData = await response.json(); generateTable(latestPolarData); updateChart(latestPolarData, showFullChart); updateTimestamp(); } else { console.error('Failed to fetch polar data'); } } catch (error) { console.error('Error fetching polar data:', error); } } async function fetchPolarFiles(selectedFileName) { try { const response = await fetch(`${API_BASE}/get-polar-files`); if (response.ok) { polarFiles = await response.json(); const select = document.getElementById('polarFileSelect'); select.innerHTML = polarFiles.map(file => `<option value="${file}">${file}</option>`).join(''); if (polarFiles.length > 0) { const selectedFile = selectedFileName || polarFiles[0]; select.value = selectedFile; await fetchPolarData(selectedFile); } } else { console.error('Failed to fetch polar files'); } } catch (error) { console.error('Error fetching polar files:', error); } } function updateTimestamp() { const now = new Date(); document.getElementById('updateTime').textContent = `Updated at: ${now.toLocaleTimeString()}`; } function generateTable(polarData) { const tableHeader = document.querySelector('#polarTable thead'); const tableBody = document.querySelector('#polarTableBody'); let windAngles = Object.keys(polarData).map(Number).sort((a, b) => a - b).filter(a => a !== 0); let windSpeeds = [...new Set(Object.values(polarData).flatMap(obj => Object.keys(obj).map(Number)))].sort((a, b) => a - b).filter(s => s !== 0); let headerRow = '<tr><th>TWA/TWS</th>' + windSpeeds.map(s => `<th>${s} kt</th>`).join('') + '</tr>'; tableHeader.innerHTML = headerRow; tableBody.innerHTML = windAngles.map(angle => { let row = `<tr><td>${angle}°</td>`; windSpeeds.forEach(speed => { const boatSpeed = polarData[angle]?.[speed]?.boatSpeed; row += `<td>${boatSpeed != null ? boatSpeed.toFixed(1) : '-'}</td>`; }); return row + '</tr>'; }).join(''); } function findClosestPolarPoint(twa, tws, polarData) { let closestTWA = null; let closestTWS = null; let expectedBoatSpeed = 0; let minDistance = Infinity; Math.abs(twa) const windAngles = Object.keys(polarData).map(Number); const windSpeeds = [...new Set(Object.values(polarData).flatMap(obj => Object.keys(obj).map(Number)))] windAngles.forEach(angle => { windSpeeds.forEach(speed => { if (polarData[angle]?.[speed] != null) { const dist = Math.sqrt((angle - twa) ** 2 + (speed - tws) ** 2); if (dist < minDistance) { minDistance = dist; closestTWA = angle; closestTWS = speed; expectedBoatSpeed = polarData[angle][speed].boatSpeed; } } }); }); return { closestTWA, closestTWS, expectedBoatSpeed }; } // async function fetchLivePerformance() { // try { // const response = await fetch(`${API_BASE}/live-data`); // if (!response.ok) throw new Error('Failed to fetch live performance data'); // const data = await response.json(); // const twa = parseFloat(data.twa); // const tws = parseFloat(data.tws); // const stw = parseFloat(data.stw); // updateLivePerformance(twa, tws, stw); // } catch (error) { // console.error("Error fetching live performance data:", error); // } // } function updateLivePerformance(twa, tws, stw) { updateLivePoint(twa, stw); if (twa != undefined && tws != undefined && stw != undefined) { document.getElementById("windAngle").textContent = `Wind Angle: ${twa.toFixed(0)}°`; document.getElementById("windSpeed").textContent = `Wind Speed: ${tws.toFixed(1)} kt`; document.getElementById("boatSpeed").textContent = `Boat Speed: ${stw.toFixed(1)} kt`; if (Object.keys(latestPolarData).length > 0) { const { closestTWA, closestTWS, expectedBoatSpeed } = findClosestPolarPoint(twa, tws, latestPolarData); const delta = (stw - expectedBoatSpeed).toFixed(2); const deltaPct = expectedBoatSpeed > 0 ? ((delta / expectedBoatSpeed) * 100).toFixed(1) : "--"; document.getElementById("closestPolar").textContent = `Closest Polar: ${closestTWS} kt TWS / ${closestTWA}° TWA`; document.getElementById("speedDifference").textContent = `Difference: ${delta} kt (${deltaPct}%)`; } else { document.getElementById("closestPolar").textContent = `Closest Polar: -- kt TWS / --° TWA`; document.getElementById("speedDifference").textContent = `Difference: -- kt (--%)`; } } } async function fetchMotoringStatus() { const response = await fetch(`${API_BASE}/motoring`); if (!response.ok) throw new Error('Failed to fetch live performance data'); const data = await response.json(); const motoring = data.motoring; console.log(`Front motoring: ${motoring}`); const overlay = document.getElementById('motoringOverlay'); if (motoring) { overlay.style.display = 'flex'; } else { overlay.style.display = 'none'; } } async function fetchRecordingStatus() { const response = await fetch(`${API_BASE}/recording`); if (!response.ok) throw new Error('Failed to fetch live performance data'); const data = await response.json(); const recording = data.recording; console.log(`Front recording: ${recording}`); const overlay = document.getElementById('recordingOverlay'); if (recording) { overlay.style.display = 'flex'; } else { overlay.style.display = 'none'; } } function exportCurrentPolarToCSV() { if (!latestPolarData || Object.keys(latestPolarData).length === 0) { alert("No polar data loaded."); return; } const angles = Object.keys(latestPolarData).map(Number).sort((a, b) => a - b); const speeds = [...new Set( Object.values(latestPolarData) .flatMap(row => Object.keys(row).map(Number)) )].sort((a, b) => a - b); // Build CSV header let csv = "TWA/TWS," + speeds.join(",") + "\n"; // Build rows for (const angle of angles) { const row = [angle]; for (const tws of speeds) { const point = latestPolarData[angle]?.[tws]; row.push(point?.boatSpeed?.toFixed(2) ?? ""); } csv += row.join(",") + "\n"; } // Trigger download const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const a = document.createElement("a"); const filename = (selectedPolarFile || "polar").replace(/\.json$/i, ".csv"); a.href = URL.createObjectURL(blob); a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } function triggerFileImport() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.csv,.pol'; input.addEventListener('change', () => { if (input.files?.length) { importPolarFile(input.files[0]); } }); input.click(); } async function importPolarFile(file) { const reader = new FileReader(); reader.onload = async () => { const text = reader.result; const lines = text.trim().split(/\r?\n/); const delimiter = lines[0].includes(',') ? ',' : '\t'; const headers = lines[0].split(delimiter).slice(1).map(Number); if (headers.some(isNaN)) { alert("Invalid header: some TWS values are not numbers"); return; } const polar = {}; let count = 0; for (let i = 1; i < lines.length; i++) { const cols = lines[i].split(delimiter); const angle = parseInt(cols[0]); if (isNaN(angle)) { alert(`Invalid TWA at line ${i + 1}`); return; } if (!polar[angle]) polar[angle] = {}; cols.slice(1).forEach((val, j) => { const boatSpeed = parseFloat(val); if (!isNaN(boatSpeed)) { polar[angle][headers[j]] = { boatSpeed, timestamp: new Date().toISOString() }; count++; } }); } const originalName = file.name.replace(/\.[^.]+$/, ''); const filename = `${originalName}.json`; try { const response = await fetch(`${API_BASE}/import-polar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: filename, data: polar }) }); if (!response.ok) throw new Error("Failed to import file"); alert(`Import successful: ${count} points saved.`); await fetchPolarFiles(filename); } catch (error) { alert("Import failed: " + error.message); } }; reader.readAsText(file); } async function startRecording(polarFile) { const response = await fetch(`${API_BASE}/start-recording`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ polarFile }) }); const result = await response.json(); if (result.success) { document.getElementById('recordControls').style.display = 'block'; } else if (result.message) { alert(result.message); } } async function stopRecording(save) { try { const response = await fetch(`${API_BASE}/stop-recording`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ save }) }); const result = await response.json(); if (!response.ok || !result.success) { alert(result.message || 'Failed to stop recording.'); } else { alert(result.message); if (selectedPolarFile) { fetchPolarData(selectedPolarFile); // 👈 recarga para mostrar los nuevos datos } } document.getElementById('recordControls').style.display = 'none'; } catch (err) { console.error('Error stopping recording:', err); alert('Unexpected error stopping recording'); } } // Init chart initChart(showFullChart); // Init fetchPolarFiles(); // Initial connection connectWebSocket(); //setInterval(fetchLivePerformance, 1000); document.addEventListener("DOMContentLoaded", () => { const toggleTableBtn = document.getElementById("toggleTableBtn"); toggleTableBtn.addEventListener("click", () => { const container = document.getElementById("toggleTableContainer"); const isExpanded = container.classList.contains("expanded"); if (isExpanded) { container.classList.remove("expanded"); toggleTableBtn.textContent = "Show Table"; } else { container.classList.add("expanded"); toggleTableBtn.textContent = "Hide Table"; } }); document.getElementById('exportPolarBtn').addEventListener('click', exportCurrentPolarToCSV); document.getElementById('importPolarBtn').addEventListener('click', triggerFileImport); const toggleFullChartBtn = document.getElementById("toggleFullChartBtn"); toggleFullChartBtn.addEventListener("click", () => { showFullChart = !showFullChart; if (showFullChart) { toggleFullChartBtn.textContent = "Half polar"; } else { toggleFullChartBtn.textContent = "Mirror polar"; } initChart(showFullChart); fetchPolarData(selectedPolarFile); }); document.getElementById('recordPolarBtn').addEventListener('click', () => { console.log(`Start recording in ${selectedPolarFile}`); startRecording(selectedPolarFile); }); document.getElementById('stopSaveBtn').addEventListener('click', () => stopRecording(true)); // document.getElementById('stopCancelBtn').addEventListener('click', () => stopRecording(false)); document.getElementById('newPolarBtn').addEventListener('click', async () => { let filename = prompt("Enter new polar file name:"); if (!filename || filename.trim() === "") { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); const h = String(now.getHours()).padStart(2, '0'); const min = String(now.getMinutes()).padStart(2, '0'); filename = `Polar-${y}${m}${d}_${h}${min}.json`; } else if (!filename.endsWith(".json")) { filename += ".json"; } try { const response = await fetch(`${API_BASE}/create-polar-file`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: filename }) }); if (!response.ok) throw new Error("Failed to create file"); await fetchPolarFiles(filename); } catch (error) { alert("Error creating polar file: " + error.message); } }); fetchPolarFiles().then(() => { const select = document.getElementById('polarFileSelect'); select.addEventListener('change', (event) => { fetchPolarData(event.target.value); }); }); });