/**
* rotator.js (frontend) — rotator control panel for the big ROTOR tab.
* - Clickable SVG compass
* - Type a 4/6-char locator, degrees, or a callsign → compute azimuth → go
* - Preset directions N/NE/E...
* - Direction HINT: typing into this tab's own target field (without
* hitting go) draws a dotted suggestion line on THIS dial, separate from
* the real target/needle, cleared once the rotor actually starts moving
* (any command) - see updateCard. The RADIO tab's quick-log bar and the
* FT8 quick-log show the SAME kind of hint, but on the separate mini
* compass next to the VFO (rotormini.js's own setHint/clearHint, not
* this file's) - that's the dial actually visible alongside them.
*/
(function() {
'use strict';
let rotators = [];
// rot.name is admin-typed free text (KONFIGURACJA, already escaped on the
// edit side in admin.js) but was shown here, to every operator, unescaped -
// same injection class as ui.js's rig-name fix.
function _esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>');
}
// ── Maidenhead locator → coordinates ─────────────────────────────────────────
function locatorToLatLon(loc) {
loc = loc.toUpperCase().trim();
if (loc.length < 4) return null;
const A = loc.charCodeAt(0) - 65; // A=0
const B = loc.charCodeAt(1) - 65;
const C = parseInt(loc[2]);
const D = parseInt(loc[3]);
if (A<0||A>17||B<0||B>17||isNaN(C)||isNaN(D)) return null;
let lon = A * 20 - 180 + C * 2 + 1;
let lat = B * 10 - 90 + D * 1 + 0.5;
if (loc.length >= 6) {
const E = loc.charCodeAt(4) - 65;
const F = loc.charCodeAt(5) - 65;
if (!isNaN(E) && !isNaN(F)) {
lon += E * (2/24) + (2/24)/2;
lat += F * (1/24) + (1/24)/2;
}
}
return { lat, lon };
}
// ── Compute the bearing between two points ────────────────────────────────────
function bearingTo(fromLat, fromLon, toLat, toLon) {
const φ1 = fromLat * Math.PI / 180;
const φ2 = toLat * Math.PI / 180;
const Δλ = (toLon - fromLon) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
const θ = Math.atan2(y, x);
return ((θ * 180 / Math.PI) + 360) % 360;
}
// ── Get the station's location from the config ────────────────────────────────
function getMyLocator() {
const cfgLoc = window.AppState?.stationLocator || '';
return cfgLoc || 'KO02'; // fallback: central Poland
}
// ── Compute azimuth from a locator or degrees ─────────────────────────────────
function resolveTarget(input) {
input = (input || '').trim();
if (!input) return null;
// Try as a number (degrees)
const deg = parseFloat(input);
if (!isNaN(deg) && /^-?\d/.test(input)) {
return { az: ((deg % 360) + 360) % 360, label: `${deg}°` };
}
// Try as a Maidenhead locator
if (/^[A-Ra-r]{2}\d{2}/.test(input)) {
const myLoc = getMyLocator();
const myPos = locatorToLatLon(myLoc);
const dxPos = locatorToLatLon(input);
if (!myPos || !dxPos) return null;
const az = bearingTo(myPos.lat, myPos.lon, dxPos.lat, dxPos.lon);
const dist = calcDist(myPos.lat, myPos.lon, dxPos.lat, dxPos.lon);
return { az: Math.round(az * 10) / 10, label: `${input} → ${Math.round(az)}° (${Math.round(dist)} km)` };
}
// Otherwise treat it as a callsign - reuse beamheading.js's own
// locator-or-prefix resolution (same logic the FT8 quick-log's SP/LP
// already relies on) instead of duplicating a prefix table here.
const h = window.BeamHeading?.headingFor(input, '');
if (h) {
return { az: h.azimuth, label: `${input.toUpperCase()} → ${h.azimuth}° (${h.distance}km, ${h.source === 'grid' ? 'grid' : 'prefix'})` };
}
return null;
}
// Distance in km (haversine)
function calcDist(lat1, lon1, lat2, lon2) {
const R = 6371;
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
const Δφ = (lat2-lat1) * Math.PI/180, Δλ = (lon2-lon1) * Math.PI/180;
const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
// ── Load state ───────────────────────────────────────────────────────────────
async function load() {
try {
const r = await fetch('/api/rotator');
if (!r.ok) throw new Error('HTTP ' + r.status);
rotators = await r.json();
render();
} catch(e) {
const el = document.getElementById('rotators-container');
if (el) el.innerHTML = `
Brak rotatorów lub błąd połączenia. Skonfiguruj w Ustawienia → Admin.
`;
}
}
// ── WS handler ────────────────────────────────────────────────────────────────
function handleWS(msg) {
if (msg.type !== 'rotator_update') return;
const idx = rotators.findIndex(r => r.id === msg.rotator.id);
if (idx >= 0) { rotators[idx] = msg.rotator; updateCard(msg.rotator); }
}
// ── Render ────────────────────────────────────────────────────────────────────
function render() {
const el = document.getElementById('rotators-container');
if (!el) return;
if (!rotators.length) {
el.innerHTML = `
Brak skonfigurowanych rotatorów. Dodaj w Ustawienia → Admin.
`;
}
// ── Preview the target while typing ───────────────────────────────────────────
function previewTarget(id) {
const inp = document.getElementById(`rot-target-${id}`);
const prv = document.getElementById(`rot-target-preview-${id}`);
if (!inp || !prv) return;
const result = resolveTarget(inp.value);
prv.textContent = result ? `→ Azymut: ${result.az}° ${result.label || ''}` : (inp.value ? '⚠ Nieprawidłowy locator / kąt' : '');
// Empty/invalid input -> result is null -> setHint(id, null) clears it,
// same as deleting a callsign in the FT8 quick-log (see updateBeamRow).
setHint(id, result ? result.az : null);
}
// ── Go to a locator/degrees ────────────────────────────────────────────────────
function goTarget(id) {
const inp = document.getElementById(`rot-target-${id}`);
if (!inp || !inp.value.trim()) return;
const result = resolveTarget(inp.value);
if (!result) { window.UI?.showToast('⚠ Nieprawidłowy locator lub kąt', 'error'); return; }
// Fill in the az field and go
const azInp = document.getElementById(`rot-az-input-${id}`);
if (azInp) azInp.value = Math.round(result.az);
setPos(id);
window.UI?.showToast(`▶ Rotator #${id} → ${result.az}° ${result.label||''}`);
}
// ── Update the card ─────────────────────────────────────────────────────────
// Azimuth interpolation: the server sends the position every ~0.5s, we
// smoothly animate the needle between readings (60fps) so the motion
// looks continuous. We keep the currently displayed azimuth (_displayAz)
// and the target (_targetDisplayAz) per rotator.
const _displayAz = {}; // currently displayed angle (animated)
const _serverAz = {}; // last angle from the server (animation target)
const _rotMeta = {}; // {target_az, moving} per rotator
// FOUND 2026-10-18: the needle used to just ease 25%/frame toward the last
// server reading - at 60fps that closes the gap in ~0.25s, far faster than
// the ~0.5-1.5s between actual position updates from the rotor, so the
// needle visibly snapped-then-waited in lockstep with the jumpy numeric
// readout instead of looking continuous. Ported the speed-based
// extrapolation rotormini.js (the compact widget next to the VFO) already
// used successfully: estimate °/s from consecutive updates, then keep
// projecting the needle forward at that speed between updates (capped at
// the commanded target, so it can't overshoot).
const _azPrevSrv = {}; // previous angle from the server, per id
const _tPrevSrv = {}; // time of the last server update, per id
const _speed = {}; // smoothed °/s per id
let _animRaf = null;
// Direction HINT per rotator - a suggestion only (typed into this tab's own
// target input, or into the FT8 quick-log via Rotator.setHint from
// wsjtx.js's updateBeamRow), separate from _rotMeta's real target_az. Drawn
// on the dial as its own distinct marker (see drawCompass) and cleared as
// soon as the rotor actually starts moving (updateCard below) so it can
// never linger on the dial looking like a still-pending suggestion once
// the rotor is already headed somewhere (possibly from a DIFFERENT
// command - manual az, preset button, etc).
const _hintAz = {};
function setHint(id, az) {
_hintAz[id] = (az == null || isNaN(az)) ? null : az;
_redrawHint(id);
}
function clearHint(id) {
_hintAz[id] = null;
_redrawHint(id);
}
function _redrawHint(id) {
const rot = rotators.find(r => r.id === id);
const az = _displayAz[id] ?? rot?.azimuth ?? 0;
const meta = _rotMeta[id] || {};
drawCompass(id, az, meta.target_az ?? rot?.target_az ?? az, meta.moving ?? rot?.moving ?? false);
}
function _shortestAngleDiff(from, to) {
// Shortest angular difference (-180..180), so the needle doesn't loop 350°->10°
let diff = (to - from) % 360;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return diff;
}
function _animateNeedles() {
const now = performance.now();
let anyMoving = false;
for (const id in _serverAz) {
const meta = _rotMeta[id] || {};
const cur = _displayAz[id] ?? _serverAz[id];
const base = _azPrevSrv[id] ?? _serverAz[id];
if (meta.moving && (_speed[id] || 0) > 0.05) {
// Actively turning - project forward from the last server reading at
// the estimated speed, capped so it never runs past the commanded
// target even if a status update is late.
const dt = Math.min((now - (_tPrevSrv[id] || now)) / 1000, 2.0);
const toTarget = _shortestAngleDiff(base, meta.target_az ?? base);
if (Math.abs(toTarget) > 1) {
const dir = Math.sign(toTarget);
const stepMag = Math.min(_speed[id] * dt, Math.abs(toTarget));
_displayAz[id] = (base + dir * stepMag + 360) % 360;
} else {
_displayAz[id] = _serverAz[id];
}
anyMoving = true;
} else {
// Stopped - gentle ease to the exact position
const diff = _shortestAngleDiff(cur, _serverAz[id]);
if (Math.abs(diff) > 0.05) {
_displayAz[id] = (cur + diff * 0.15 + 360) % 360;
anyMoving = true;
} else {
_displayAz[id] = _serverAz[id];
}
}
drawCompass(id, _displayAz[id], meta.target_az ?? _serverAz[id], meta.moving);
// Also update the azimuth text smoothly
const azD = document.getElementById(`rot-az-disp-${id}`);
if (azD) azD.textContent = `${_displayAz[id].toFixed(1)}°`;
}
if (anyMoving) {
_animRaf = requestAnimationFrame(_animateNeedles);
} else {
_animRaf = null;
}
}
function _startNeedleAnim() {
if (!_animRaf) _animRaf = requestAnimationFrame(_animateNeedles);
}
function updateCard(rot) {
const elD = document.getElementById(`rot-el-disp-${rot.id}`);
const movD = document.getElementById(`rot-moving-${rot.id}`);
if (elD) elD.textContent = `EL: ${rot.elevation.toFixed(1)}°`;
if (movD) movD.innerHTML = rot.moving
? `● OBRACA → ${rot.target_az}°`
: `● W POZYCJI`;
// The rotor is actually moving now (from ANY command - this update, a
// preset button, another operator) - a lingering hint would read as "go
// here next" when it's really just stale input. Clear it unconditionally
// rather than only for the command that started this exact move.
if (rot.moving && _hintAz[rot.id] != null) _hintAz[rot.id] = null;
// Estimate rotation speed (°/s) from consecutive server readings while
// moving - see _animateNeedles for why this matters. Smoothed (60/40)
// like rotormini.js's own speed estimate, so one glitchy read doesn't
// make the needle lurch.
const now = performance.now();
if (rot.moving && _tPrevSrv[rot.id] && now > _tPrevSrv[rot.id]) {
const dt = (now - _tPrevSrv[rot.id]) / 1000;
if (dt > 0.05 && dt < 3.0) {
const rawSpeed = Math.abs(_shortestAngleDiff(_serverAz[rot.id] ?? rot.azimuth, rot.azimuth)) / dt;
if (rawSpeed < 20) _speed[rot.id] = (_speed[rot.id] ?? 0) * 0.6 + rawSpeed * 0.4;
}
} else if (!rot.moving) {
_speed[rot.id] = 0;
}
_azPrevSrv[rot.id] = _serverAz[rot.id] ?? rot.azimuth;
_tPrevSrv[rot.id] = now;
// Save the target from the server and start a smooth needle animation to that position
_serverAz[rot.id] = rot.azimuth;
_rotMeta[rot.id] = { target_az: rot.target_az, moving: rot.moving };
if (_displayAz[rot.id] === undefined) {
// First reading — set immediately, no animation
_displayAz[rot.id] = rot.azimuth;
drawCompass(rot.id, rot.azimuth, rot.target_az, rot.moving);
const azD = document.getElementById(`rot-az-disp-${rot.id}`);
if (azD) azD.textContent = `${rot.azimuth.toFixed(1)}°`;
} else {
_startNeedleAnim();
}
}
// ── Compass canvas ───────────────────────────────────────────────────────────
function drawCompass(id, azimuth, target, moving) {
const canvas = document.getElementById(`compass-${id}`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const cx = W/2, cy = H/2, R = W/2 - 5;
ctx.clearRect(0, 0, W, H);
// Circle background
ctx.beginPath(); ctx.arc(cx,cy,R,0,Math.PI*2);
ctx.fillStyle = '#090c09'; ctx.fill();
ctx.strokeStyle = 'rgba(76,219,106,0.18)'; ctx.lineWidth = 1; ctx.stroke();
// Inner rings
[0.35,0.65,0.88].forEach(f => {
ctx.beginPath(); ctx.arc(cx,cy,R*f,0,Math.PI*2);
ctx.strokeStyle = 'rgba(76,219,106,0.07)'; ctx.lineWidth = 0.5; ctx.stroke();
});
// Lines every 10° — longer every 30°, cardinal every 90°
for (let d = 0; d < 360; d += 10) {
const rad = (d-90)*Math.PI/180;
const inner = d % 90 === 0 ? 0.78 : (d % 30 === 0 ? 0.83 : 0.89);
ctx.beginPath();
ctx.moveTo(cx + R*inner*Math.cos(rad), cy + R*inner*Math.sin(rad));
ctx.lineTo(cx + R*0.96*Math.cos(rad), cy + R*0.96*Math.sin(rad));
ctx.strokeStyle = d%90===0 ? 'rgba(76,219,106,0.55)' : (d%30===0 ? 'rgba(76,219,106,0.22)' : 'rgba(76,219,106,0.1)');
ctx.lineWidth = d%90===0 ? 1.5 : 0.7;
ctx.stroke();
}
// Labels every 30°
const lbls = {0:'N',30:'30',60:'60',90:'E',120:'120',150:'150',180:'S',210:'210',240:'240',270:'W',300:'300',330:'330'};
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
Object.entries(lbls).forEach(([d,l]) => {
const rad = (parseInt(d)-90)*Math.PI/180;
const dist = d==='0'||d==='90'||d==='180'||d==='270' ? 0.68 : 0.71;
ctx.font = ['0','90','180','270'].includes(d) ? 'bold 11px Share Tech Mono,monospace' : '9px Share Tech Mono,monospace';
ctx.fillStyle = ['0','90','180','270'].includes(d) ? 'rgba(76,219,106,0.8)' : 'rgba(76,219,106,0.45)';
ctx.fillText(l, cx + R*dist*Math.cos(rad), cy + R*dist*Math.sin(rad));
});
// Direction HINT (dotted blue) - a typed-in suggestion, not a real
// command (see _hintAz above). Drawn before the target/needle so it
// never visually competes with them - it's a suggestion, not a state.
const hint = _hintAz[id];
if (hint != null) {
const hRad = (hint-90)*Math.PI/180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + R*0.82*Math.cos(hRad), cy + R*0.82*Math.sin(hRad));
ctx.strokeStyle = 'rgba(100,180,255,0.6)';
ctx.lineWidth = 1.5; ctx.setLineDash([2,3]); ctx.stroke(); ctx.setLineDash([]);
const hx = cx + R*0.82*Math.cos(hRad), hy = cy + R*0.82*Math.sin(hRad);
ctx.beginPath();
ctx.arc(hx, hy, 3.5, 0, Math.PI*2);
ctx.fillStyle = 'rgba(100,180,255,0.8)'; ctx.fill();
}
// Target line (dashed yellow)
if (Math.abs(target - azimuth) > 1) {
const tRad = (target-90)*Math.PI/180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + R*0.82*Math.cos(tRad), cy + R*0.82*Math.sin(tRad));
ctx.strokeStyle = moving ? 'rgba(240,180,41,0.7)' : 'rgba(240,180,41,0.35)';
ctx.lineWidth = 1.5; ctx.setLineDash([5,4]); ctx.stroke(); ctx.setLineDash([]);
// Target dot
const tx = cx + R*0.82*Math.cos(tRad), ty = cy + R*0.82*Math.sin(tRad);
ctx.beginPath();
ctx.arc(tx, ty, 4, 0, Math.PI*2);
ctx.fillStyle = 'rgba(240,180,41,0.7)'; ctx.fill();
}
// Azimuth needle (green)
const aRad = (azimuth-90)*Math.PI/180;
// Needle shadow
ctx.shadowBlur = 8; ctx.shadowColor = 'rgba(76,219,106,0.5)';
ctx.beginPath();
ctx.moveTo(cx - R*0.28*Math.cos(aRad), cy - R*0.28*Math.sin(aRad));
ctx.lineTo(cx + R*0.80*Math.cos(aRad), cy + R*0.80*Math.sin(aRad));
ctx.strokeStyle = '#4cdb6a'; ctx.lineWidth = 2.5; ctx.stroke();
ctx.shadowBlur = 0;
// Arrowhead
const ax = cx+R*0.80*Math.cos(aRad), ay = cy+R*0.80*Math.sin(aRad);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(ax - 11*Math.cos(aRad) + 5*Math.sin(aRad), ay - 11*Math.sin(aRad) - 5*Math.cos(aRad));
ctx.lineTo(ax - 11*Math.cos(aRad) - 5*Math.sin(aRad), ay - 11*Math.sin(aRad) + 5*Math.cos(aRad));
ctx.closePath();
ctx.fillStyle = '#4cdb6a'; ctx.fill();
// Tail
ctx.beginPath();
ctx.moveTo(cx - R*0.28*Math.cos(aRad), cy - R*0.28*Math.sin(aRad));
ctx.lineTo(cx - R*0.28*Math.cos(aRad) + 6*Math.sin(aRad), cy - R*0.28*Math.sin(aRad) - 6*Math.cos(aRad));
ctx.lineTo(cx - R*0.28*Math.cos(aRad) - 6*Math.sin(aRad), cy - R*0.28*Math.sin(aRad) + 6*Math.cos(aRad));
ctx.closePath(); ctx.fillStyle = '#2a8a3a'; ctx.fill();
// Center
ctx.beginPath(); ctx.arc(cx,cy,5,0,Math.PI*2);
ctx.fillStyle = '#4cdb6a'; ctx.fill();
// Value in the center
ctx.font = 'bold 14px Orbitron,sans-serif';
ctx.fillStyle = 'rgba(76,219,106,0.85)';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(`${Math.round(azimuth)}°`, cx, cy + R*0.47);
}
// ── Compass click ────────────────────────────────────────────────────────────
function compassClick(event, id) {
const canvas = event.target;
const rect = canvas.getBoundingClientRect();
const cx = canvas.width/2, cy = canvas.height/2;
const x = (event.clientX - rect.left) * (canvas.width / rect.width) - cx;
const y = (event.clientY - rect.top) * (canvas.height / rect.height) - cy;
const az = Math.round(((Math.atan2(y,x)*180/Math.PI) + 90 + 360) % 360);
const azInp = document.getElementById(`rot-az-input-${id}`);
if (azInp) azInp.value = az;
// Show the preview
const targetInp = document.getElementById(`rot-target-${id}`);
if (targetInp) { targetInp.value = String(az); previewTarget(id); }
setPos(id);
}
// ── Actions ───────────────────────────────────────────────────────────────────
async function setPos(id) {
const az = parseFloat(document.getElementById(`rot-az-input-${id}`)?.value) || 0;
const elI = document.getElementById(`rot-el-input-${id}`);
const el = elI ? (parseFloat(elI.value)||0) : 0;
await apiPost(`/api/rotator/${id}/position`, {az, el});
}
async function stop(id) { await apiPost(`/api/rotator/${id}/stop`); }
// Was posting to /api/rotator//park - a backend route that never
// existed (only /position, /stop, /test are implemented), so PARK 404'd
// silently on every click. "Park" is just "go to 0/0" - reuse the real
// /position endpoint instead of inventing a dedicated backend route for it.
async function park(id) {
const azInp = document.getElementById(`rot-az-input-${id}`);
if (azInp) azInp.value = 0;
const elInp = document.getElementById(`rot-el-input-${id}`);
if (elInp) elInp.value = 0;
const targetInp = document.getElementById(`rot-target-${id}`);
if (targetInp) { targetInp.value = '0'; previewTarget(id); }
await apiPost(`/api/rotator/${id}/position`, {az: 0, el: 0});
}
function goDir(id, az) {
const azInp = document.getElementById(`rot-az-input-${id}`);
if (azInp) azInp.value = az;
const tInp = document.getElementById(`rot-target-${id}`);
if (tInp) { tInp.value = String(az); previewTarget(id); }
setPos(id);
}
async function apiPost(url, body) {
try {
const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: body ? JSON.stringify(body) : undefined });
const res = await r.json();
// The backend now rejects /position and /stop for a viewer or someone
// without the radio claimed (403 {error}) — without this check a click
// simply did nothing, with no feedback at all (same endpoint as rotormini.js).
if (res && res.error) window.UI?.showToast?.('✗ ' + res.error, 'error');
return res;
} catch(e) {
console.error('[rotator]', e);
window.UI?.showToast?.('✗ ' + e.message, 'error');
}
}
window.Rotator = { load, render, handleWS, compassClick, previewTarget, goTarget, setPos, stop, park, goDir, setHint, clearHint };
})();