Files
RotRouter/public/index.html
2026-03-19 18:55:00 +01:00

504 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="AntNameTitle">IP rotator</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;
}
html, body {
font-family: 'Roboto Condensed', sans-serif, Arial, Tahoma, Verdana;
background-color: #000;
color: #ccc;
margin: 0;
padding: 0;
}
.container {
width: var(--box-size);
margin: 0 auto;
position: relative;
}
canvas {
display: block;
cursor: crosshair;
}
.info-panel {
width: var(--box-size);
margin-top: 10px;
text-align: center;
}
.status-bar {
font-size: 25px;
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%; }
a:hover { color: #fff; }
a { color: #ccc; text-decoration: underline; }
</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>
<script>
class RotatorState {
constructor() {
this.boxSize = 600;
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.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);
}
}
}
class RotatorRenderer {
constructor(canvasId, state) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.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;
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);
// 1. Draw Direction Lines (formerly StaticBot)
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();
// 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) {
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 = "20px 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();
// Dark zone logic
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();
}
}
// Instantly trigger a main render after caching
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;
// 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) {
const pt = this.getPoint(targetAngle, sz / 2 * 0.9);
ctx.moveTo(cx, cy);
ctx.lineTo(pt.x, pt.y);
}
// 3. Draw Pointer
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();
// Arrow for CW/CCW
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();
}
// 4. Draw Values Text
ctx.font = "bold 100px 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 30px 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
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);
}
// 5. Radiation Beam Cone
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(state) {
this.state = state;
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')
};
}
updateStatic() {
this.els.title.innerText = this.state.antName || "IP rotator";
this.els.name.innerText = this.state.antName;
this.els.mac.innerText = this.state.mac;
}
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;
const isOffline = (Date.now() - this.state.lastSeen) > 1500;
this.els.online.innerHTML = isOffline
? " | <span class=\"text-red\">&bull;</span> Offline"
: " | <span class=\"text-white\">&bull;</span> Connected";
}
}
class RotatorApp {
constructor() {
this.state = new RotatorState();
this.ui = new RotatorUI(this.state);
this.renderer = new RotatorRenderer('MainCanvas', this.state);
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() {
// Sequential fetch to protect embedded web servers from connection exhaustion
const endpoints = [
{ key: 'azShift', url: 'readStart', isNum: true },
{ key: 'azRange', url: 'readMax', isNum: true },
{ key: 'antRadiationAngle', url: 'readAnt', isNum: true },
{ key: 'antName', url: 'readAntName', isNum: false },
{ key: 'mapUrl', url: 'readMapUrl', isNum: false },
{ key: 'mac', url: 'readMAC', isNum: false },
{ key: 'elevation', url: 'readElevation', isNum: true }
];
for (let conf of endpoints) {
const val = await RotatorAPI.fetchText(conf.url);
if (val !== null) {
this.state[conf.key] = conf.isNum ? Number(val) : val;
}
}
this.ui.updateStatic();
this.renderer.updateMap(this.state.mapUrl);
// Start timers
// this.startLoops();
this.startWs();
}
startWs() {
const websocket = new WebSocket(document.location.href.replace(/\/?$/, '/') + "ws");
websocket.addEventListener("message", (e) => {
if (!e || !e.data)
return;
const data = JSON.parse(e.data);
if (!data)
return;
console.log(`RECEIVED: `, data);
});
setInterval(() => {
websocket.send(JSON.stringify({ ping: 1 }));
}, 2000);
}
async pollData() {
const adc = await RotatorAPI.fetchText('readADC');
const az = await RotatorAPI.fetchText('readAZ');
const stat = await RotatorAPI.fetchText('readStat');
if (adc !== null && az !== null && stat !== null) {
const numAz = Number(az);
const numStat = Number(stat);
let needsRender = false;
if (this.state.azimuth !== numAz || this.state.status !== numStat) {
needsRender = true;
}
this.state.adc = Number(adc);
this.state.azimuth = numAz;
this.state.status = numStat;
this.state.lastSeen = Date.now();
if (needsRender) this.renderer.render();
}
this.ui.updateDynamic();
}
startLoops() {
// Data polling
setInterval(() => this.pollData(), 500);
// Force UI status check (offline state handling)
setInterval(() => this.ui.updateDynamic(), 2000);
// Map refresh
setInterval(() => this.renderer.updateMap(this.state.mapUrl), 600000);
// Trigger first poll instantly
this.pollData();
}
}
// Bootstrap
document.addEventListener("DOMContentLoaded", () => {
const app = new RotatorApp();
app.initialize();
});
</script>
</body>
</html>