UNPKG

signalk-polar-performance-plugin

Version:

A plugin that calculates performance information based on a (CSV) polar diagram.

515 lines (448 loc) 15.1 kB
<!DOCTYPE html> <html> <head> <script type='text/javascript' src="./Chart.min.js"></script> <script type='text/javascript' src="./jquery-3.7.1-min.js"></script> <title>Polar performance</title> <style> body { background-color: #020202; margin: 0; padding: 0; color: white; font-family: Arial, sans-serif; } .chart-container { width: 100%; max-width: 1200px; margin: 0 auto; /* Center the whole thing */ } /* New Toolbar Styling - sits below the chart */ .config-toolbar { display: flex; justify-content: flex-end; /* Aligns content to the right */ align-items: center; padding: 10px 20px; background-color: #0a0a0a; /* Slightly lighter than background */ border-top: 1px solid #222; } .config-toolbar label { font-size: 14px; color: #ccc; margin-right: 8px; } .config-toolbar input { width: 50px; background: rgba(0, 0, 0, 0.5); border: 1px solid #555; color: white; text-align: center; border-radius: 2px; padding: 4px; font-size: 14px; } #error-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(220, 60, 60, 0.95); color: white; text-align: center; padding: 14px 20px; font-size: 15px; z-index: 1000; box-shadow: 0 -2px 8px rgba(0,0,0,0.3); display: none; } #error-banner.show { display: block; } </style> </head> <body> <div class="chart-container"> <canvas id="myChart"></canvas> <!-- Configuration Toolbar (Now below the chart) --> <div class="config-toolbar"> <label for="labelPos">Place polar wind speed label at TWA:</label> <input type="number" id="labelPos" value="135" min="0" max="180" onchange="updateLabelPos(this.value)"> </div> <div id="error-banner"></div> </div> <script> var myChart, chartData let ws let polarSpeed = 0, boatSpeed = 0, TWA = 0, TWS = 0 let dataReceived = { polar: false, boatSpeed: false, twa: false } let dataTimeout // GLOBAL CONFIG: Load from storage or default to 135 window.labelTWA = localStorage.getItem('polarLabelTWA') || 135; document.getElementById('labelPos').value = window.labelTWA; function updateLabelPos(val) { window.labelTWA = val; localStorage.setItem('polarLabelTWA', val); if (myChart) myChart.update(); } // --- CUSTOM PLUGIN: DRAW LABELS --- Chart.plugins.register({ afterDraw: function(chartInstance) { try { var ctx = chartInstance.chart.ctx; var targetAngle = parseFloat(window.labelTWA) || 135; // 1. DRAW LABELS chartInstance.data.datasets.forEach(function(dataset, i) { if (i < 2) return; var meta = chartInstance.getDatasetMeta(i); if (meta.hidden) return; var p1 = null, p2 = null; var p1Meta = null, p2Meta = null; // Find interpolation bracket for (var j = 0; j < dataset.data.length - 1; j++) { if (dataset.data[j].x <= targetAngle && dataset.data[j+1].x >= targetAngle) { p1 = dataset.data[j]; p2 = dataset.data[j+1]; p1Meta = meta.data[j]._model; p2Meta = meta.data[j+1]._model; break; } } var x, y; if (p1 && p2) { var ratio = (p2.x - p1.x === 0) ? 0 : (targetAngle - p1.x) / (p2.x - p1.x); x = p1Meta.x + (p2Meta.x - p1Meta.x) * ratio; y = p1Meta.y + (p2Meta.y - p1Meta.y) * ratio; } else { // Fallback var closest = null, minDiff = 100; dataset.data.forEach(function(d, idx) { var diff = Math.abs(d.x - targetAngle); if (diff < minDiff) { minDiff = diff; closest = meta.data[idx]._model; } }); if (closest) { x = closest.x; y = closest.y; } } // Draw only if on screen and reasonably close to request if (x > 0 && y > 0) { ctx.save(); ctx.font = "bold 14px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; var labelText = String(dataset.label); // Box var textWidth = ctx.measureText(labelText).width; ctx.fillStyle = "rgba(2,2,2,0.9)"; ctx.fillRect(x - (textWidth/2) - 3, y - 9, textWidth + 6, 18); // Text ctx.fillStyle = dataset.borderColor || '#ffffff'; ctx.fillText(labelText, x, y); ctx.restore(); } }); // 2. REDRAW DOTS (Datasets 0 and 1) [0, 1].forEach(function(i) { var meta = chartInstance.getDatasetMeta(i); if (!meta.hidden) { meta.data.forEach(function(point) { point.draw(); }); } }); } catch (err) { console.log("Label draw error: " + err); } } }); $.getJSON("/plugins/signalk-polar-performance-plugin/chartData") .done(function(json) { if (!json || !json.datasets || json.datasets.length < 3) { showError("No polar diagram data available.<br><br>Make sure you have entered a valid polar CSV in the plugin settings."); return; } // Add actual values dots (Polar Speed + Boat Speed) json.datasets.unshift({ label: 'Polar Speed', backgroundColor: 'rgba(0,255,0,0.7)', borderColor: 'rgba(0,255,0,0.2)', borderWidth: 1, radius: 10, pointStyle: 'circle', data: [{ y: 0, x: 0, r: 10 }] }, { label: 'Boat Speed', backgroundColor: 'rgba(0,150,255,0.7)', borderColor: 'rgba(0,150,255,0.2)', borderWidth: 1, radius: 10, pointStyle: 'circle', data: [{ y: 0, x: 0, r: 10 }] }); // Backup original colors for (var i = 2; i < json.datasets.length; i++) { if (!json.datasets[i].borderColor) { json.datasets[i].borderColor = 'rgba(100, 100, 100, 0.5)'; } json.datasets[i]._originalColor = json.datasets[i].borderColor; json.datasets[i].borderWidth = 1; } chartData = json; showChart(chartData); }) .fail(function(jqXHR, textStatus, errorThrown) { let reason = "Unknown error"; if (jqXHR.status === 404) { reason = "Plugin endpoint not found — is the plugin enabled?"; } else if (jqXHR.status === 500) { reason = "Server error — check plugin configuration (invalid CSV polar?)"; } else if (textStatus === "parsererror") { reason = "Invalid data received from server"; } showError(`Failed to load polar data.<br><br>Reason: ${reason}`); }); // New error display function function showError(message) { let banner = document.getElementById('error-banner'); if (!banner) { // Fallback: create banner dynamically if missing banner = document.createElement('div'); banner.id = 'error-banner'; document.body.appendChild(banner); } banner.innerHTML = ` ⚠️ ${message} &nbsp;&nbsp; <button onclick="location.reload()" style="background:white; color:#c33; border:none; padding:4px 10px; border-radius:4px; cursor:pointer;"> Reload </button> `; banner.classList.add('show'); } function showChart (data) { console.log(data) myChart = new Chart("myChart", { type: "scatter", data: data, options: { legend: { display: true, labels: { fontSize: 16, fontColor: 'white' } }, spanGaps: true, scales: { xAxes: [{ type: 'linear', scaleLabel: { display: true, labelString: 'True wind angle (TWA)', fontColor: 'white', fontSize: 16 }, ticks: { display: true, fontColor: 'white', fontSize: 16, }, gridLines: { color: 'rgba(180,180,180,0.7)', lineWidth: 1 } }], yAxes: [{ type: 'linear', scaleLabel: { display: true, labelString: 'Target boat speed (POL SPD)', fontColor: 'white', fontSize: 14 }, ticks: { display: true, fontColor: 'white', fontSize: 14, }, gridLines: { color: 'rgba(180,180,180,0.7)', lineWidth: 1 } }], }, tooltips: { enabled: true, callbacks: { label: function (tooltipItems, data) { let label = tooltipItems.xLabel + "° " + tooltipItems.yLabel + " kts" return label } }, backgroundColor: '#666', titleFontSize: 18, titleFontColor: '#0066ff', bodyFontColor: '#ddd', bodyFontSize: 18, displayColors: false } } }) var ctx = document.getElementById("myChart") ctx.style.backgroundColor = 'rgba(0,0,0,0.8)' ctx.style.fontColor = 'white' } function connect () { console.log("connect()") ws = new WebSocket((window.location.protocol === 'https:' ? 'wss' : 'ws') + "://" + window.location.host + "/signalk/v1/stream?subscribe=none"); ws.onopen = function() { startListeners(); ws.onmessage = function(event) { if (event.data.includes('signalk-server')) { console.log("Skipping welcome message") } else { handleData(JSON.parse(event.data)); } } ws.onclose = function() { setTimeout(connect, 500) } ws.onerror = function(err) { setTimeout(connect, 500) } } } window.addEventListener('focus', function () { if (ws.readyState == 0) { connect() } }) function startListeners () { var paths = [ {'path': 'environment.wind.angleTrueWaterDamped'}, {'path': 'performance.polarSpeed'}, {'path': 'performance.boatSpeedDamped'}, {'path': 'environment.wind.speedTrue'} ] var subscriptionObject = { "context": "vessels.self", "policy" : "ideal", "minPeriod": 1000, "subscribe": paths } ws.send(JSON.stringify(subscriptionObject)); // Start timeout to detect missing data dataReceived = { polar: false, boatSpeed: false, twa: false, tws: false }; clearTimeout(dataTimeout); dataTimeout = setTimeout(checkDataReceived, 20000); } function handleData (data) { if (typeof data.updates[0].meta != 'undefined') return var path = data.updates[0].values[0].path var value = data.updates[0].values[0].value if (path == 'performance.polarSpeed') { polarSpeed = roundDec(msToKts(value), 1) chartData.datasets[0].data[0].y = polarSpeed dataReceived.polar = true; } else if (path === 'environment.wind.angleTrueWaterDamped') { TWA = roundDec(Math.abs(radToDeg(value)),1) chartData.datasets[0].data[0].x = TWA chartData.datasets[1].data[0].x = TWA dataReceived.twa = true; } else if (path == 'performance.boatSpeedDamped') { boatSpeed = roundDec(msToKts(value),1) chartData.datasets[1].data[0].y = boatSpeed dataReceived.boatSpeed = true; } else if (path == 'environment.wind.speedTrue') { TWS = msToKts(value); dataReceived.tws = true; } } function checkDataReceived() { let missing = []; if (!dataReceived.polar) missing.push("Polar Speed (performance.polarSpeed)"); if (!dataReceived.boatSpeed) missing.push("Boat Speed (performance.boatSpeedDamped)"); if (!dataReceived.twa) missing.push("True Wind Angle (environment.wind.angleTrueWater)"); if (!dataReceived.tws) missing.push("True Wind Speed (environment.wind.speedTrue)"); if (missing.length > 0) { showError(`Missing data: ${missing.join(', ')}. Check your instruments and polar settings.`); } } // Enhanced error display function showError(message) { const container = document.querySelector('.chart-container'); container.innerHTML = ` <div style="padding: 40px 20px; text-align: center; color: #ff7777;"> <h2 style="color: #ff6666;">⚠️ Cannot display Polar Performance</h2> <div style="margin: 25px 0; line-height: 1.6; font-size: 17px;"> ${message} </div> <button onclick="location.reload()" style="padding: 12px 24px; font-size: 16px; background: #333; color: white; border: 1px solid #666; border-radius: 6px; cursor: pointer;"> Reload Page </button> </div> `; } connect() setInterval(updateChart, 300) function updateChart () { if (myChart) { interpolateColors(TWS); myChart.update(); } } // --- Interpolate Colors --- function interpolateColors(tws) { if (!tws || !chartData) return; for (var i = 2; i < chartData.datasets.length; i++) { var dataset = chartData.datasets[i]; var lineSpeed = parseFloat(dataset.label); if (isNaN(lineSpeed)) continue; var originalColor = dataset._originalColor; if (!originalColor) { originalColor = "rgba(100,100,100,0.5)"; dataset._originalColor = originalColor; } var diff = Math.abs(tws - lineSpeed); var influenceRange = 2.5; var weight = Math.max(0, 1 - (diff / influenceRange)); if (weight < 0.05) { dataset.borderColor = originalColor; dataset.borderWidth = 1; dataset.pointBorderColor = originalColor; dataset.pointBackgroundColor = originalColor; } else { var blended = blendColors(originalColor, "#FFFFFF", weight); dataset.borderColor = blended; dataset.borderWidth = 1 + (3 * weight); dataset.pointBorderColor = blended; dataset.pointBackgroundColor = blended; } } } function blendColors(color1, color2, weight) { var c1 = parseColor(color1); var c2 = parseColor(color2); var r = Math.round(c1[0] + (c2[0] - c1[0]) * weight); var g = Math.round(c1[1] + (c2[1] - c1[1]) * weight); var b = Math.round(c1[2] + (c2[2] - c1[2]) * weight); return "rgb(" + r + "," + g + "," + b + ")"; } function parseColor(input) { if (!input) return [100,100,100]; if (input.substr(0,1) == "#") { var col = input.slice(1); if (col.length == 3) col = col[0]+col[0]+col[1]+col[1]+col[2]+col[2]; var int = parseInt(col, 16); return [(int >> 16) & 255, (int >> 8) & 255, int & 255]; } else if (input.substr(0,3) == "rgb") { var parts = input.match(/(\d+)/g); if (parts && parts.length >= 3) { return [parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2])]; } } return [100,100,100]; } function radToDeg(radians) { return radians * 180 / Math.PI } function msToKts(ms) { return ms * 1.94384 } function roundDec (value, decimals) { if (typeof value == 'undefined') { return undefined } else { value = Number(value.toFixed(decimals)) return value } } </script> </body> </html>