signalk-polar-performance-plugin
Version:
A plugin that calculates performance information based on a (CSV) polar diagram.
515 lines (448 loc) • 15.1 kB
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}
<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>