540 lines
19 KiB
HTML
540 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<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: 400px;
|
|
}
|
|
html, body {
|
|
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
|
|
background-color: #000;
|
|
color: #ccc;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.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;
|
|
cursor: crosshair;
|
|
}
|
|
.info-panel {
|
|
width: var(--box-size);
|
|
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: 20px;
|
|
margin: 0;
|
|
}
|
|
.status-wrapper {
|
|
color: #000;
|
|
background: #666;
|
|
padding: 4px 6px;
|
|
border-radius: 5px;
|
|
display: inline-block;
|
|
}
|
|
.text-white { color: #fff; }
|
|
.text-bold { font-weight: bold; }
|
|
.text-red { color: red; }
|
|
.text-muted { color: #666; font-size: 73%; }
|
|
.turned-by {
|
|
font-size: 14px;
|
|
color: #888;
|
|
margin: 4px 0 0;
|
|
}
|
|
.turned-by .source {
|
|
color: #e0e040;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="rotators" id="rotators"></div>
|
|
|
|
<script>
|
|
class RotatorState {
|
|
constructor() {
|
|
this.boxSize = 400;
|
|
this.center = this.boxSize / 2;
|
|
this.azShift = 0;
|
|
this.azRange = 360;
|
|
this.azimuth = 0;
|
|
this.antRadiationAngle = 0;
|
|
this.status = 4;
|
|
this.mapUrl = "";
|
|
this.elevation = 0;
|
|
this.mac = "";
|
|
this.antName = "";
|
|
this.adc = 0;
|
|
this.online = false;
|
|
this.bands = [];
|
|
}
|
|
}
|
|
|
|
class RotatorRenderer {
|
|
constructor(canvas, state) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.state = state;
|
|
|
|
this.bgCanvas = document.createElement('canvas');
|
|
this.bgCanvas.width = state.boxSize;
|
|
this.bgCanvas.height = state.boxSize;
|
|
this.bgCtx = this.bgCanvas.getContext('2d');
|
|
|
|
this.mapImage = new Image();
|
|
this.mapImage.onload = () => this.drawBackgroundCache();
|
|
}
|
|
|
|
getPoint(angle, radius) {
|
|
const rad = angle * Math.PI / 180;
|
|
return {
|
|
x: this.state.center + Math.sin(rad) * radius,
|
|
y: this.state.center - Math.cos(rad) * radius
|
|
};
|
|
}
|
|
|
|
updateMap(url) {
|
|
if (url && this.mapImage.src !== new URL(url, document.baseURI).href) {
|
|
this.mapImage.src = url;
|
|
} else {
|
|
this.drawBackgroundCache();
|
|
}
|
|
}
|
|
|
|
drawBackgroundCache() {
|
|
const ctx = this.bgCtx;
|
|
const sz = this.state.boxSize;
|
|
const cx = this.state.center;
|
|
const cy = this.state.center;
|
|
|
|
ctx.clearRect(0, 0, sz, sz);
|
|
|
|
ctx.beginPath();
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = '#606060';
|
|
for (let i = 0; i < 24; i++) {
|
|
const startRadius = (i % 2 === 0) ? sz / 2 * 0.1 : sz / 2 * 0.3;
|
|
const pStart = this.getPoint(i * 15, startRadius);
|
|
const pEnd = this.getPoint(i * 15, sz / 2 * 0.9);
|
|
ctx.moveTo(pStart.x, pStart.y);
|
|
ctx.lineTo(pEnd.x, pEnd.y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
if (this.mapImage.complete && this.mapImage.naturalHeight !== 0) {
|
|
ctx.drawImage(this.mapImage, 0, 0, sz, sz);
|
|
}
|
|
|
|
ctx.lineWidth = 8;
|
|
ctx.strokeStyle = 'orange';
|
|
if (this.state.azRange > 360) {
|
|
const startRad = (270 + this.state.azShift) * Math.PI / 180;
|
|
const stopRad = (270 + this.state.azShift + this.state.azRange - 360) * Math.PI / 180;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, sz / 2 * 0.9 - 8, startRad, stopRad);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.beginPath();
|
|
const startArc = this.state.elevation === 0 ? 0 : Math.PI;
|
|
ctx.arc(cx, cy, sz / 2 * 0.9, startArc, 2 * Math.PI);
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeStyle = '#c0c0c0';
|
|
ctx.font = "16px Arial";
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = "#808080";
|
|
|
|
const maxTick = this.state.elevation === 0 ? 35 : 18;
|
|
const offset = this.state.elevation === 0 ? 0 : 270;
|
|
|
|
for (let i = 0; i <= maxTick; i++) {
|
|
const angle = i * 10 + offset;
|
|
if (i % 9 !== 0) {
|
|
const p1 = this.getPoint(angle, sz / 2 * 0.95);
|
|
const p2 = this.getPoint(angle, sz / 2 * 0.9);
|
|
ctx.moveTo(p1.x, p1.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
}
|
|
if (i % 3 === 0 && i % 9 !== 0) {
|
|
const pt = this.getPoint(angle, sz / 2);
|
|
ctx.fillText(i * 10, pt.x, pt.y);
|
|
}
|
|
}
|
|
|
|
if (this.state.elevation === 0) {
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText("N", cx, sz * 0.025);
|
|
ctx.fillText("E", sz * 0.975, cy);
|
|
ctx.fillText("S", cx, sz * 0.975);
|
|
ctx.fillText("W", sz * 0.025, cy);
|
|
}
|
|
ctx.stroke();
|
|
|
|
if (this.state.azRange < 360) {
|
|
ctx.beginPath();
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = 'white';
|
|
const p1 = this.getPoint(this.state.azShift, sz / 2 * 0.9);
|
|
const p2 = this.getPoint(this.state.azShift + this.state.azRange, sz / 2 * 0.9);
|
|
ctx.moveTo(cx, cy); ctx.lineTo(p1.x, p1.y);
|
|
ctx.moveTo(cx, cy); ctx.lineTo(p2.x, p2.y);
|
|
ctx.stroke();
|
|
|
|
if (this.state.azRange + this.state.antRadiationAngle < 360) {
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
|
const darkRange = 360 - this.state.azRange - this.state.antRadiationAngle;
|
|
const steps = (darkRange - 20) / 10;
|
|
const startAngle = this.state.azShift + this.state.azRange + this.state.antRadiationAngle / 2;
|
|
|
|
const startPt = this.getPoint(startAngle, sz / 2 * 0.9);
|
|
ctx.moveTo(cx, cy);
|
|
ctx.lineTo(startPt.x, startPt.y);
|
|
|
|
for (let i = 0; i < steps; i++) {
|
|
const pt = this.getPoint(startAngle + 10 * (i + 1), sz / 2 * 0.9);
|
|
ctx.lineTo(pt.x, pt.y);
|
|
}
|
|
const endPt = this.getPoint(this.state.azShift - this.state.antRadiationAngle / 2, sz / 2 * 0.9);
|
|
ctx.lineTo(endPt.x, endPt.y);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
|
|
render() {
|
|
const ctx = this.ctx;
|
|
const sz = this.state.boxSize;
|
|
const cx = this.state.center;
|
|
const cy = this.state.center;
|
|
const { azShift, azRange, azimuth, status, antRadiationAngle, targetAngle } = this.state;
|
|
|
|
ctx.clearRect(0, 0, sz, sz);
|
|
ctx.drawImage(this.bgCanvas, 0, 0);
|
|
|
|
const isOffline = !this.state.online;
|
|
|
|
if (isOffline) {
|
|
// Grey overlay
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, sz / 2 * 0.9, 0, 2 * Math.PI);
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
ctx.fill();
|
|
|
|
// OFFLINE label
|
|
ctx.font = 'bold 50px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = '#666';
|
|
ctx.fillText('OFFLINE', cx, cy);
|
|
return;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Pointer
|
|
ctx.beginPath();
|
|
ctx.lineWidth = 5;
|
|
const isOutOfRange = azimuth < 0 || azimuth > azRange;
|
|
if (isOutOfRange) ctx.strokeStyle = '#c0c0c0';
|
|
else if (status !== 4) ctx.strokeStyle = "red";
|
|
else if (azimuth > 359) ctx.strokeStyle = "orange";
|
|
else ctx.strokeStyle = '#00aa00';
|
|
|
|
const displayAngle = azimuth + azShift;
|
|
const pStart = this.getPoint(displayAngle, sz / 2 * 0.9);
|
|
const pEnd = this.getPoint(displayAngle, sz / 2 * 0.7);
|
|
|
|
ctx.moveTo(pStart.x, pStart.y);
|
|
ctx.lineTo(pEnd.x, pEnd.y);
|
|
ctx.stroke();
|
|
|
|
if (status !== 4) {
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "red";
|
|
const offset = status < 4 ? -6 : 6;
|
|
|
|
const a1 = this.getPoint(displayAngle + offset / 2, sz / 2 * 0.85);
|
|
const a2 = this.getPoint(displayAngle + offset / 2, sz / 2 * 0.75);
|
|
const a3 = this.getPoint(displayAngle + offset * 1.3, sz / 2 * 0.8);
|
|
ctx.moveTo(a1.x, a1.y); ctx.lineTo(a2.x, a2.y); ctx.lineTo(a3.x, a3.y);
|
|
|
|
const b1 = this.getPoint(displayAngle + offset * 1.5, sz / 2 * 0.83);
|
|
const b2 = this.getPoint(displayAngle + offset * 1.5, sz / 2 * 0.77);
|
|
const b3 = this.getPoint(displayAngle + offset * 2, sz / 2 * 0.8);
|
|
ctx.moveTo(b1.x, b1.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(b3.x, b3.y);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.font = "bold 70px Arial";
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
let textAngle = this.state.elevation === 0 ? azimuth + azShift : azimuth;
|
|
if (textAngle > 359) textAngle -= 360;
|
|
|
|
const textLoc = this.getPoint(displayAngle + 180, sz * 0.2);
|
|
|
|
if (isOutOfRange) {
|
|
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);
|
|
|
|
if (status !== 4) ctx.fillStyle = "red";
|
|
else if (azimuth > 359) ctx.fillStyle = "orange";
|
|
else ctx.fillStyle = "green";
|
|
|
|
ctx.fillText(`${textAngle}\u00B0`, textLoc.x, textLoc.y);
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy);
|
|
const ccwAngle = antRadiationAngle / 2;
|
|
for (let i = 0; i <= 16; i++) {
|
|
const pt = this.getPoint(displayAngle - ccwAngle + (antRadiationAngle / 16) * i, sz / 2 * 0.9);
|
|
ctx.lineTo(pt.x, pt.y);
|
|
}
|
|
|
|
if (isOutOfRange) ctx.fillStyle = "rgba(255, 255, 255, 0.10)";
|
|
else if (status !== 4) ctx.fillStyle = "rgba(255, 0, 0, 0.25)";
|
|
else if (azimuth > 359) ctx.fillStyle = "rgba(255, 165, 0, 0.25)";
|
|
else ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
|
|
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
class RotatorUI {
|
|
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>°
|
|
<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 = {
|
|
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.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() {
|
|
const adcFormatted = Math.round(this.state.adc * 10) / 10;
|
|
if (this.state.adc < 11.5) {
|
|
this.els.adc.innerHTML = `<span class="text-red">${adcFormatted}</span>`;
|
|
} else {
|
|
this.els.adc.innerText = adcFormatted;
|
|
}
|
|
|
|
this.els.az.innerText = this.state.azimuth;
|
|
|
|
this.els.online.innerHTML = this.state.online
|
|
? " | <span class=\"text-white\">•</span> Connected"
|
|
: " | <span class=\"text-red\">•</span> Offline";
|
|
}
|
|
|
|
updateTurnedBy(sourceIp, targetAz) {
|
|
this.els.turnedBy.textContent = `${sourceIp} \u2192 ${targetAz}\u00B0`;
|
|
}
|
|
}
|
|
|
|
class RotatorApp {
|
|
constructor() {
|
|
this.rotators = {};
|
|
this.container = document.getElementById('rotators');
|
|
this.websocket = null;
|
|
}
|
|
|
|
async initialize() {
|
|
const initState = await this.startWs();
|
|
|
|
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 };
|
|
}
|
|
|
|
document.title = Object.values(this.rotators)
|
|
.map(r => r.state.antName || "Rotator")
|
|
.join(" / ");
|
|
|
|
}
|
|
|
|
async startWs() {
|
|
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;
|
|
|
|
const parsed = JSON.parse(e.data);
|
|
if (!parsed || !parsed.data) return;
|
|
|
|
const data = parsed.data;
|
|
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve(data);
|
|
return;
|
|
}
|
|
|
|
for (const [label, rotData] of Object.entries(data)) {
|
|
const rot = this.rotators[label];
|
|
if (!rot) continue;
|
|
|
|
if (rotData.dynamic) {
|
|
const { azimuth, status, adc, online } = rotData.dynamic;
|
|
let needsRender = rot.state.azimuth !== azimuth
|
|
|| rot.state.status !== status
|
|
|| rot.state.online !== online;
|
|
|
|
rot.state.adc = adc;
|
|
rot.state.azimuth = azimuth;
|
|
rot.state.status = status;
|
|
rot.state.online = online;
|
|
|
|
if (needsRender) rot.ui.renderer.render();
|
|
rot.ui.updateDynamic();
|
|
}
|
|
|
|
if (rotData.turnedBy) {
|
|
rot.state.targetAngle = rotData.targetAz;
|
|
rot.ui.renderer.render();
|
|
rot.ui.updateTurnedBy(rotData.turnedBy, rotData.targetAz);
|
|
}
|
|
}
|
|
});
|
|
|
|
setInterval(() => {
|
|
this.websocket.send(JSON.stringify({ ping: 1 }));
|
|
}, 2000);
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const app = new RotatorApp();
|
|
app.initialize();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|