add a setpoint indicator

This commit is contained in:
2026-04-02 19:13:35 +02:00
parent 80b66be4fc
commit 22d750e3ab
7 changed files with 250 additions and 148 deletions

View File

@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="AntNameTitle">IP rotator</title>
<title>RotRouter</title>
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:300italic,400italic,700italic,400,700,300&subset=latin-ext" rel="stylesheet" type="text/css">
<style>
:root {
--box-size: 600px;
--box-size: 400px;
}
html, body {
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
@@ -16,10 +16,17 @@
margin: 0;
padding: 0;
}
.container {
width: var(--box-size);
margin: 0 auto;
position: relative;
.rotators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
padding: 20px;
}
.rotator-card {
display: flex;
flex-direction: column;
align-items: center;
}
canvas {
display: block;
@@ -27,11 +34,22 @@
}
.info-panel {
width: var(--box-size);
margin-top: 10px;
margin-top: 8px;
text-align: center;
}
.rotator-label {
font-size: 22px;
font-weight: bold;
color: #fff;
margin: 0 0 2px;
}
.rotator-bands {
font-size: 15px;
color: #aaa;
margin: 0 0 6px;
}
.status-bar {
font-size: 25px;
font-size: 20px;
margin: 0;
}
.status-wrapper {
@@ -45,34 +63,24 @@
.text-bold { font-weight: bold; }
.text-red { color: red; }
.text-muted { color: #666; font-size: 73%; }
a:hover { color: #fff; }
a { color: #ccc; text-decoration: underline; }
.turned-by {
font-size: 14px;
color: #888;
margin: 4px 0 0;
}
.turned-by .source {
color: #e0e040;
}
</style>
</head>
<body>
<div class="container">
<canvas id="MainCanvas" width="600" height="600"></canvas>
<div class="info-panel">
<p class="status-bar">
<span class="status-wrapper">
<span id="AntName" class="text-white">Loading...</span> | POE
<span id="ADCValue" class="text-white text-bold">0</span> V |
raw <span id="AZValue" class="text-bold">0</span>&deg; |
<a href="/set" onclick="window.open(this.href, 'setup', 'width=700,height=1350,menubar=no,location=no,status=no'); return false;">SETUP</a>
</span>
<br>
<span id="MacAddress" class="text-muted"></span>
<span id="OnlineStatus" class="text-muted"></span>
</p>
</div>
</div>
<div class="rotators" id="rotators"></div>
<script>
class RotatorState {
constructor() {
this.boxSize = 600;
this.boxSize = 400;
this.center = this.boxSize / 2;
this.azShift = 0;
this.azRange = 360;
@@ -85,42 +93,16 @@
this.antName = "";
this.adc = 0;
this.lastSeen = 0;
}
}
class RotatorAPI {
static async fetchText(endpoint) {
try {
const res = await fetch(`/${endpoint}`);
if (!res.ok) throw new Error('Network response was not ok');
return await res.text();
} catch (e) {
console.warn(`Failed to fetch ${endpoint}`, e);
return null;
}
}
static async sendTarget(azTarget) {
try {
const res = await fetch('/', {
method: 'POST',
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
body: `ROT=${azTarget}`
});
if (res.ok) alert(await res.text());
} catch (e) {
console.error("Failed to send target rotation", e);
}
this.bands = [];
}
}
class RotatorRenderer {
constructor(canvasId, state) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
constructor(canvas, state) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.state = state;
// Create offscreen canvas for caching static background layers
this.bgCanvas = document.createElement('canvas');
this.bgCanvas.width = state.boxSize;
this.bgCanvas.height = state.boxSize;
@@ -154,7 +136,6 @@
ctx.clearRect(0, 0, sz, sz);
// 1. Draw Direction Lines (formerly StaticBot)
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = '#606060';
@@ -167,12 +148,10 @@
}
ctx.stroke();
// 2. Draw Map Image
if (this.mapImage.complete && this.mapImage.naturalHeight !== 0) {
ctx.drawImage(this.mapImage, 0, 0, sz, sz);
}
// 3. Draw Static Overlays (Compass, Dark Zones)
ctx.lineWidth = 8;
ctx.strokeStyle = 'orange';
if (this.state.azRange > 360) {
@@ -188,7 +167,7 @@
ctx.arc(cx, cy, sz / 2 * 0.9, startArc, 2 * Math.PI);
ctx.lineWidth = 2;
ctx.strokeStyle = '#c0c0c0';
ctx.font = "20px Arial";
ctx.font = "16px Arial";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = "#808080";
@@ -219,7 +198,6 @@
}
ctx.stroke();
// Dark zone logic
if (this.state.azRange < 360) {
ctx.beginPath();
ctx.lineWidth = 1;
@@ -251,7 +229,6 @@
}
}
// Instantly trigger a main render after caching
this.render();
}
@@ -262,22 +239,22 @@
const cy = this.state.center;
const { azShift, azRange, azimuth, status, antRadiationAngle, targetAngle } = this.state;
// 1. Clear and composite background
ctx.clearRect(0, 0, sz, sz);
ctx.drawImage(this.bgCanvas, 0, 0);
// 2. Draw Target Line
ctx.beginPath();
const mappedAzimuth = azimuth > 360 ? azimuth - 360 : azimuth;
const mappedTarget = (targetAngle + azShift) > 360 ? (targetAngle + azShift - 360) : (targetAngle + azShift);
if (targetAngle != null && Math.abs(mappedTarget - mappedAzimuth) > antRadiationAngle / 2) {
// Setpoint line (only while turning)
if (targetAngle != null && status !== 4) {
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = 'red';
const pt = this.getPoint(targetAngle, sz / 2 * 0.9);
ctx.moveTo(cx, cy);
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
}
// 3. Draw Pointer
// Pointer
ctx.beginPath();
ctx.lineWidth = 5;
const isOutOfRange = azimuth < 0 || azimuth > azRange;
if (isOutOfRange) ctx.strokeStyle = '#c0c0c0';
@@ -293,7 +270,6 @@
ctx.lineTo(pEnd.x, pEnd.y);
ctx.stroke();
// Arrow for CW/CCW
if (status !== 4) {
ctx.beginPath();
ctx.fillStyle = "red";
@@ -311,8 +287,7 @@
ctx.fill();
}
// 4. Draw Values Text
ctx.font = "bold 100px Arial";
ctx.font = "bold 70px Arial";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
@@ -322,12 +297,12 @@
const textLoc = this.getPoint(displayAngle + 180, sz * 0.2);
if (isOutOfRange) {
ctx.font = "bold 30px Arial";
ctx.font = "bold 22px Arial";
ctx.fillStyle = '#c0c0c0';
ctx.fillText(azimuth < 0 ? "CCW endstop zone" : "CW endstop zone", textLoc.x, textLoc.y);
} else {
ctx.fillStyle = "black";
ctx.fillText(`${textAngle}\u00B0`, textLoc.x + 3, textLoc.y + 3); // shadow
ctx.fillText(`${textAngle}\u00B0`, textLoc.x + 3, textLoc.y + 3);
if (status !== 4) ctx.fillStyle = "red";
else if (azimuth > 359) ctx.fillStyle = "orange";
@@ -336,7 +311,6 @@
ctx.fillText(`${textAngle}\u00B0`, textLoc.x, textLoc.y);
}
// 5. Radiation Beam Cone
ctx.beginPath();
ctx.moveTo(cx, cy);
const ccwAngle = antRadiationAngle / 2;
@@ -355,22 +329,77 @@
}
class RotatorUI {
constructor(state) {
constructor(label, container, state) {
this.state = state;
this.label = label;
const card = document.createElement('div');
card.className = 'rotator-card';
this.nameEl = document.createElement('p');
this.nameEl.className = 'rotator-label';
this.nameEl.textContent = label;
card.appendChild(this.nameEl);
this.bandsEl = document.createElement('p');
this.bandsEl.className = 'rotator-bands';
card.appendChild(this.bandsEl);
const canvas = document.createElement('canvas');
canvas.width = state.boxSize;
canvas.height = state.boxSize;
card.appendChild(canvas);
const infoPanel = document.createElement('div');
infoPanel.className = 'info-panel';
infoPanel.innerHTML = `
<p class="status-bar">
<span class="status-wrapper">
POE <span class="adc text-white text-bold">0</span> V |
raw <span class="az text-bold">0</span>&deg;
<span class="online text-muted"></span>
</span>
</p>
<p class="turned-by">Last turned by: <span class="source">-</span></p>
`;
card.appendChild(infoPanel);
container.appendChild(card);
this.els = {
title: document.getElementById('AntNameTitle'),
name: document.getElementById('AntName'),
mac: document.getElementById('MacAddress'),
adc: document.getElementById('ADCValue'),
az: document.getElementById('AZValue'),
online: document.getElementById('OnlineStatus')
adc: infoPanel.querySelector('.adc'),
az: infoPanel.querySelector('.az'),
online: infoPanel.querySelector('.online'),
turnedBy: infoPanel.querySelector('.turned-by .source'),
};
this.renderer = new RotatorRenderer(canvas, state);
canvas.addEventListener("click", (evt) => {
const rect = canvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
let angle = Math.atan2(state.center - y, x - state.center) * 180 / Math.PI;
angle = Math.round((90 - angle + 360) % 360);
state.targetAngle = angle;
this.renderer.render();
fetch(`/turn/${encodeURIComponent(label)}`, {
method: 'POST',
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
body: `ROT=${angle}`
}).catch(() => {});
});
}
updateStatic() {
this.els.title.innerText = this.state.antName || "IP rotator";
this.els.name.innerText = this.state.antName;
this.els.mac.innerText = this.state.mac;
this.nameEl.textContent = this.state.antName || this.label;
this.bandsEl.textContent = this.state.bands.length
? this.state.bands.map(b => b + " MHz").join(", ")
: "";
this.renderer.updateMap(this.state.mapUrl);
}
updateDynamic() {
@@ -388,86 +417,90 @@
? " | <span class=\"text-red\">&bull;</span> Offline"
: " | <span class=\"text-white\">&bull;</span> Connected";
}
updateTurnedBy(sourceIp, targetAz) {
this.els.turnedBy.textContent = `${sourceIp} \u2192 ${targetAz}\u00B0`;
}
}
class RotatorApp {
constructor() {
this.state = new RotatorState();
this.ui = new RotatorUI(this.state);
this.renderer = new RotatorRenderer('MainCanvas', this.state);
this.rotators = {};
this.container = document.getElementById('rotators');
this.websocket = null;
this.bindEvents();
}
bindEvents() {
this.renderer.canvas.addEventListener("click", (evt) => {
const rect = this.renderer.canvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
let angle = Math.atan2(this.state.center - y, x - this.state.center) * 180 / Math.PI;
angle = Math.round((90 - angle + 360) % 360); // Simplify calculation for canvas math
this.state.targetAngle = angle;
RotatorAPI.sendTarget(angle);
this.renderer.render(); // Instantly show user click target
});
}
async initialize() {
const initData = await this.startWs();
const initState = await this.startWs();
for (const [key, value] of Object.entries(initData)) {
this.state[key] = value;
for (const [label, clientState] of Object.entries(initState)) {
const state = new RotatorState();
if (clientState.initData) {
for (const [key, value] of Object.entries(clientState.initData)) {
state[key] = value;
}
}
state.bands = clientState.bands || [];
const ui = new RotatorUI(label, this.container, state);
ui.updateStatic();
if (clientState.setpoint) {
state.targetAngle = clientState.setpoint.targetAz;
ui.updateTurnedBy(clientState.setpoint.sourceIp, clientState.setpoint.targetAz);
}
this.rotators[label] = { state, ui };
}
this.ui.updateStatic();
this.renderer.updateMap(this.state.mapUrl);
// Start timers
// this.startLoops();
document.title = Object.values(this.rotators)
.map(r => r.state.antName || "Rotator")
.join(" / ");
}
async startWs() {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
this.websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
let resolved = false;
this.websocket.addEventListener("message", (e) => {
if (!e || !e.data)
return;
if (!e || !e.data) return;
const parsed = JSON.parse(e.data);
if (!parsed)
return;
if (!parsed || !parsed.data) return;
const data = parsed.data;
if (!data)
return;
const rotData = data['rot14'];
if (rotData.initData) {
resolve(rotData.initData); // super ugly
if (!resolved) {
resolved = true;
resolve(data);
return;
}
if (rotData.dynamic) {
const numAz = rotData.dynamic.azimuth;
const numStat = rotData.dynamic.status;
for (const [label, rotData] of Object.entries(data)) {
const rot = this.rotators[label];
if (!rot) continue;
let needsRender = false;
if (this.state.azimuth !== numAz || this.state.status !== numStat) {
needsRender = true;
if (rotData.dynamic) {
const { azimuth, status, adc } = rotData.dynamic;
let needsRender = rot.state.azimuth !== azimuth || rot.state.status !== status;
rot.state.adc = adc;
rot.state.azimuth = azimuth;
rot.state.status = status;
rot.state.lastSeen = Date.now();
if (needsRender) rot.ui.renderer.render();
rot.ui.updateDynamic();
}
this.state.adc = rotData.dynamic.adc;
this.state.azimuth = numAz;
this.state.status = numStat;
this.state.lastSeen = Date.now();
if (needsRender) this.renderer.render();
if (rotData.turnedBy) {
rot.state.targetAngle = rotData.targetAz;
rot.ui.renderer.render();
rot.ui.updateTurnedBy(rotData.turnedBy, rotData.targetAz);
}
}
console.log(`RECEIVED: `, data);
});
setInterval(() => {
@@ -477,11 +510,10 @@
}
}
// Bootstrap
document.addEventListener("DOMContentLoaded", () => {
const app = new RotatorApp();
app.initialize();
});
</script>
</body>
</html>
</html>