Files
ExoMy_Cuno/gui/index.html
T
2026-05-28 19:58:15 +02:00

1837 lines
63 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ExoMy // Ground Control</title>
<script type="text/javascript" src="./vendor/roslib.min.js"></script>
<link rel="stylesheet" href="./app.css?v=20260522105000">
</head>
<body class="page-index">
<header>
<svg class="hdr-icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="14" cy="51" rx="6" ry="7" fill="#FFB21F"/>
<ellipse cx="28" cy="50" rx="6" ry="8" fill="#FFB21F"/>
<ellipse cx="43" cy="50" rx="6" ry="8" fill="#FFB21F"/>
<ellipse cx="55" cy="49" rx="5" ry="7" fill="#FFB21F"/>
<path d="M11 36L23 30H50L58 32L47 37H14L11 36Z" fill="#1F43C9"/>
<rect x="24" y="14" width="7" height="23" rx="3.5" fill="#1B120F"/>
<path d="M18 38L25 36L41 36L51 40L51 46L42 49L38 59L31 59L29 50L19 47L18 38Z" fill="#1B120F"/>
<rect x="21" y="38" width="5" height="10" rx="2.5" fill="#1B120F"/>
<rect x="43" y="39" width="5" height="11" rx="2.5" fill="#1B120F"/>
<path d="M15 41L9 44L8 49" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
<path d="M49 41L57 43L57 47" stroke="#1B120F" stroke-width="4" stroke-linecap="round"/>
<path d="M14 16C14 12 17 9 22 9H32C38 9 41 12 41 16C41 22 36 26 28 26H20C16 26 14 23 14 16Z" fill="#FFB21F"/>
<circle cx="21" cy="15.5" r="3.2" fill="white"/>
<circle cx="30.5" cy="14.5" r="3.6" fill="white"/>
<circle cx="21.5" cy="17" r="1.8" fill="#000"/>
<circle cx="30.8" cy="16" r="2.1" fill="#000"/>
<path d="M25 18C25.8 19.2 27.2 19.2 28 18V20.2C28 21.8 26.8 22.7 25.5 22.7C24.2 22.7 23 21.8 23 20.2V18C23.6 18.8 24.4 19.1 25 18Z" fill="#1B120F"/>
<path d="M12 12C13.5 9.2 17.8 8.2 21 10" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
<path d="M29 7.5C31.8 6.2 35.6 7.1 37.6 9.7" stroke="#1B120F" stroke-width="2.8" stroke-linecap="round"/>
</svg>
<span class="hdr-title" title="CUNO = Celestian Unified Navigation and Observation">CU<em>NO</em>&nbsp;GROUND CTRL</span>
<div class="hdr-div"></div>
<div class="hdr-badge">Mission Ops</div>
<div class="hdr-gap"></div>
<button class="header-icon-btn" id="save-frame-button" type="button" aria-label="Screenshot speichern" title="Screenshot speichern">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 4h7l2 2h3a2 2 0 0 1 2 2v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8a2 2 0 0 1 2-2h2V4z"></path>
<path d="M12 9.2a4.3 4.3 0 1 0 0 8.6a4.3 4.3 0 0 0 0-8.6z"></path>
<circle cx="18" cy="9" r="1.2"></circle>
</svg>
</button>
<a class="admin-link" href="./admin.html">Admin</a>
<div class="hdr-div"></div>
<div class="mission-ok"><div class="pulse-dot"></div><span id="cuno-connection-status">Cuno offline</span></div>
<div class="hdr-div"></div>
<div class="hdr-clocks">
<div class="hdr-clk">
<div class="hdr-clk-lbl">Session</div>
<div class="hdr-clk-val ora" id="met">00:00:00</div>
</div>
<div class="hdr-div"></div>
<div class="hdr-clk">
<div class="hdr-clk-lbl">UTC</div>
<div class="hdr-clk-val" id="utc">--:--:--</div>
</div>
</div>
</header>
<div class="main">
<div class="col-l">
<div class="panel" id="cam-panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Kamera - CAM01</span>
<span class="p-tag">Live Stream</span>
</div>
<div class="cam-port">
<div class="cam-frame" id="video_frame">
<img id="video_output" alt="Kamerabild vom ExoMy" />
<div class="c-rec" id="cam-live-indicator"><div class="c-rec-dot"></div><span id="cam-live-text">LIVE</span></div>
<div class="c-tl">
CAM: <span id="cam-state">Verbinde…</span><br>
MODE: <span id="overlay-mode">Ackermann</span>
</div>
<div class="c-tr">
ROS: <span id="ros-state">Offline</span><br>
CTRL: <span id="input-state">Joystick</span>
</div>
<div class="c-bl">
GPS: <span id="gps-state">Offline</span><br>
POS: <span id="gps-accuracy">--</span><br>
LAT: <span id="gps-lat">--</span><br>
LON: <span id="gps-lon">--</span>
</div>
<div class="c-br">
HOST: <span id="cam-host">--</span><br>
STREAM: 8083
</div>
<div class="c-bm">
V: <span id="overlay-speed">0,0</span> cm/s<br>
S: <span id="overlay-distance">0,00</span> m
</div>
<div class="c-xhair">
<svg viewBox="-110 -110 220 220" xmlns="http://www.w3.org/2000/svg" width="220" height="220">
<!-- outer rotating dashed ring -->
<circle class="xh-rot" cx="0" cy="0" r="103" fill="none" stroke="rgba(0,255,145,.14)" stroke-width="1" stroke-dasharray="4 13"/>
<!-- roll arc (static) r=96, ±60° from top -->
<path d="M -83.1 -48 A 96 96 0 0 1 83.1 -48" fill="none" stroke="rgba(0,255,145,.32)" stroke-width="1.25"/>
<!-- roll tick marks via rotate() -->
<g transform="rotate(0)"> <line x1="0" y1="-96" x2="0" y2="-84" stroke="rgba(0,255,145,.72)" stroke-width="1.5"/></g>
<g transform="rotate(10)"> <line x1="0" y1="-96" x2="0" y2="-89" stroke="rgba(0,255,145,.38)" stroke-width="1"/></g>
<g transform="rotate(-10)"><line x1="0" y1="-96" x2="0" y2="-89" stroke="rgba(0,255,145,.38)" stroke-width="1"/></g>
<g transform="rotate(20)"> <line x1="0" y1="-96" x2="0" y2="-89" stroke="rgba(0,255,145,.38)" stroke-width="1"/></g>
<g transform="rotate(-20)"><line x1="0" y1="-96" x2="0" y2="-89" stroke="rgba(0,255,145,.38)" stroke-width="1"/></g>
<g transform="rotate(30)"> <line x1="0" y1="-96" x2="0" y2="-86" stroke="rgba(0,255,145,.58)" stroke-width="1.25"/></g>
<g transform="rotate(-30)"><line x1="0" y1="-96" x2="0" y2="-86" stroke="rgba(0,255,145,.58)" stroke-width="1.25"/></g>
<g transform="rotate(45)"> <line x1="0" y1="-96" x2="0" y2="-89" stroke="rgba(0,255,145,.32)" stroke-width="1"/></g>
<g transform="rotate(-45)"><line x1="0" y1="-96" x2="0" y2="-89" stroke="rgba(0,255,145,.32)" stroke-width="1"/></g>
<!-- counter-rotating middle ring -->
<circle class="xh-rot-rev" cx="0" cy="0" r="72" fill="none" stroke="rgba(0,255,145,.12)" stroke-width=".75" stroke-dasharray="2 20"/>
<!-- inner fixed ring -->
<circle cx="0" cy="0" r="20" fill="none" stroke="rgba(0,255,145,.25)" stroke-width=".75"/>
<!-- cross lines with center gap -->
<line x1="-107" y1="0" x2="-23" y2="0" stroke="rgba(0,255,145,.55)" stroke-width="1"/>
<line x1="23" y1="0" x2="107" y2="0" stroke="rgba(0,255,145,.55)" stroke-width="1"/>
<line x1="0" y1="-107" x2="0" y2="-23" stroke="rgba(0,255,145,.55)" stroke-width="1"/>
<line x1="0" y1="23" x2="0" y2="107" stroke="rgba(0,255,145,.55)" stroke-width="1"/>
<!-- scale ticks r=72 -->
<line x1="-72" y1="-7" x2="-72" y2="7" stroke="rgba(0,255,145,.38)" stroke-width=".75"/>
<line x1="72" y1="-7" x2="72" y2="7" stroke="rgba(0,255,145,.38)" stroke-width=".75"/>
<line x1="-7" y1="-72" x2="7" y2="-72" stroke="rgba(0,255,145,.38)" stroke-width=".75"/>
<line x1="-7" y1="72" x2="7" y2="72" stroke="rgba(0,255,145,.38)" stroke-width=".75"/>
<!-- scale ticks r=46 -->
<line x1="-46" y1="-4" x2="-46" y2="4" stroke="rgba(0,255,145,.22)" stroke-width=".5"/>
<line x1="46" y1="-4" x2="46" y2="4" stroke="rgba(0,255,145,.22)" stroke-width=".5"/>
<line x1="-4" y1="-46" x2="4" y2="-46" stroke="rgba(0,255,145,.22)" stroke-width=".5"/>
<line x1="-4" y1="46" x2="4" y2="46" stroke="rgba(0,255,145,.22)" stroke-width=".5"/>
<!-- corner brackets -->
<path d="M-90,-72 L-90,-90 L-72,-90" fill="none" stroke="rgba(0,255,145,.72)" stroke-width="1.5"/>
<path d="M72,-90 L90,-90 L90,-72" fill="none" stroke="rgba(0,255,145,.72)" stroke-width="1.5"/>
<path d="M-90,72 L-90,90 L-72,90" fill="none" stroke="rgba(0,255,145,.72)" stroke-width="1.5"/>
<path d="M72,90 L90,90 L90,72" fill="none" stroke="rgba(0,255,145,.72)" stroke-width="1.5"/>
<!-- pulsing center ring -->
<circle class="xh-pulse" cx="0" cy="0" r="7" fill="none" stroke="rgba(0,255,145,.65)" stroke-width="1"/>
<!-- IMU group -->
<g id="imu-horizon-group" opacity="0">
<!-- roll pointer triangle (only rotates, no pitch offset) -->
<g id="imu-roll-pointer">
<polygon points="0,-90 -5.5,-79 5.5,-79" fill="rgba(0,255,145,.88)"/>
</g>
<!-- horizon + pitch ladder (rotates AND translates with pitch) -->
<g id="imu-horizon-frame">
<line x1="-11" y1="-33" x2="11" y2="-33" stroke="rgba(0,255,145,.40)" stroke-width=".9"/>
<line x1="-11" y1="33" x2="11" y2="33" stroke="rgba(0,255,145,.40)" stroke-width=".9"/>
<line x1="-17" y1="-22" x2="17" y2="-22" stroke="rgba(0,255,145,.58)" stroke-width=".9"/>
<line x1="-17" y1="22" x2="17" y2="22" stroke="rgba(0,255,145,.58)" stroke-width=".9"/>
<line x1="-10" y1="-11" x2="10" y2="-11" stroke="rgba(0,255,145,.42)" stroke-width=".85"/>
<line x1="-10" y1="11" x2="10" y2="11" stroke="rgba(0,255,145,.42)" stroke-width=".85"/>
<line id="imu-horizon-main" x1="-40" y1="0" x2="40" y2="0" stroke="rgba(0,255,145,.95)" stroke-width="2.8"/>
<line x1="-40" y1="0" x2="-32" y2="-5" stroke="rgba(0,255,145,.95)" stroke-width="2.8"/>
<line x1="40" y1="0" x2="32" y2="-5" stroke="rgba(0,255,145,.95)" stroke-width="2.8"/>
</g>
<!-- HDG readout with background box (wider for compass direction) -->
<rect x="-42" y="-72" width="84" height="15" fill="rgba(0,15,8,.65)" rx="1"/>
<line x1="-42" y1="-72" x2="42" y2="-72" stroke="rgba(0,255,145,.35)" stroke-width=".5"/>
<text id="imu-heading-text" x="0" y="-62.5" fill="rgba(0,255,145,.92)" font-size="9" text-anchor="middle" letter-spacing="1">HDG 000° N</text>
<!-- roll / pitch readouts -->
<text id="imu-roll-text" x="-73" y="76" fill="rgba(0,255,145,.82)" font-size="7.5" text-anchor="middle">R +0.0°</text>
<text id="imu-pitch-text" x="73" y="76" fill="rgba(0,255,145,.82)" font-size="7.5" text-anchor="middle">P +0.0°</text>
</g>
<!-- center dot -->
<circle cx="0" cy="0" r="3" fill="rgba(0,255,145,.92)"/>
<circle cx="0" cy="0" r="1.2" fill="rgba(255,255,255,.8)"/>
</svg>
</div>
<div class="c-scanlines"></div>
<div class="c-vignette"></div>
</div>
</div>
</div>
</div>
<div class="col-r">
<div class="panel collapsed" id="stat-panel">
<div class="p-hdr stat-panel-toggle" id="stat-panel-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="stat-panel-body">
<div class="p-dot"></div>
<span class="p-lbl">Systemstatus</span>
<span class="p-tag">Web Control</span>
<span class="stat-toggle-indicator" aria-hidden="true">+</span>
</div>
<div class="stat-list" id="stat-panel-body">
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Status</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">Link</span>
<span class="sr-val sval-conn"><span class="pulse-dot"></span><span id="status">Verbinde…</span></span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Modus</span>
<span class="sr-val b" id="stat-mode">Ackermann</span>
</div>
<div class="sr">
<span class="sr-led led-warn"></span>
<span class="sr-key">Motoren</span>
<span class="sr-val o" id="stat-motors">Unbekannt</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Eingabe</span>
<span class="sr-val b" id="stat-input">Joystick</span>
</div>
</div>
</div>
<div class="panel" id="joy-panel">
<div class="p-hdr">
<div class="p-dot"></div>
<span class="p-lbl">Lokomotion</span>
<div class="mode-toggle">
<button class="mtbtn active" id="mt-joy">Joystick</button>
<button class="mtbtn" id="mt-dpad">D-Pad</button>
</div>
</div>
<div class="drive-inline">
<button class="dbtn active" data-mode="ackermann" data-button="1">ACK</button>
<button class="dbtn" data-mode="turn-on-point" data-button="0">TOP</button>
<button class="dbtn" data-mode="crab" data-button="3">CRAB</button>
<button class="dbtn" id="motor-toggle">MOT</button>
</div>
<div class="joy-area" id="joy-area">
<div class="joy-base" id="jbase">
<div class="jg-h"></div><div class="jg-v"></div>
<div class="jring jr1"></div>
<div class="jring jr2"></div>
<div class="joy-thumb" id="jthumb"></div>
</div>
<div class="joy-vals">
<div class="joy-ax">
<div class="joy-ax-n" id="joy-x-label">X-Achse</div>
<div class="joy-ax-v" id="jx">+0.00</div>
</div>
<div class="joy-ax">
<div class="joy-ax-n">Y-Achse</div>
<div class="joy-ax-v" id="jy">+0.00</div>
</div>
</div>
</div>
<div class="dpad-wrap" id="dpad-area">
<div class="dpad-grid">
<button class="dpad-btn dpad-diag" data-x="-0.71" data-y="0.71">↖</button>
<button class="dpad-btn" data-x="0" data-y="1">▲</button>
<button class="dpad-btn dpad-diag" data-x="0.71" data-y="0.71">↗</button>
<button class="dpad-btn" data-x="-1" data-y="0">◄</button>
<div class="dpad-mid"></div>
<button class="dpad-btn" data-x="1" data-y="0">►</button>
<button class="dpad-btn dpad-diag" data-x="-0.71" data-y="-0.71">↙</button>
<button class="dpad-btn" data-x="0" data-y="-1">▼</button>
<button class="dpad-btn dpad-diag" data-x="0.71" data-y="-0.71">↘</button>
</div>
<div class="joy-vals">
<div class="joy-ax">
<div class="joy-ax-n" id="dpad-x-label">X-Achse</div>
<div class="joy-ax-v" id="dpx">+0.00</div>
</div>
<div class="joy-ax">
<div class="joy-ax-n">Y-Achse</div>
<div class="joy-ax-v" id="dpy">+0.00</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
var hostUrl = window.location.hostname;
var ros = null;
var joyListener = null;
var roverCommandListener = null;
var motorCommandsListener = null;
var gpsFixListener = null;
var gpsStatusListener = null;
var gpsDiagnosticsListener = null;
var imuDataListener = null;
var imuStatusListener = null;
var rosReconnectTimer = null;
var rosDisconnectTimer = null;
var cameraReconnectTimer = null;
var cameraRecoveryTimers = [];
var lastCameraServiceState = "";
var lastCameraSettingsVersion = null;
var publishTimer = null;
var statusTimer = null;
var axes = [0, 0, 0, 0, 0, 0];
var buttons = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
var driveMode = "ackermann";
var motorsEnabled = true;
var inputMode = "joystick";
var metBase = Date.now();
var dragging = false;
var lastGpsDiagnostics = null;
var REQUIRED_CALIBRATION_POINTS = [0, 20, 30, 40, 50, 60, 80, 100];
var driveEstimator = {
drivePwmNeutralAverage: 307,
drivePwmRange: 100,
pwmFrequencyHz: 50,
speedMps: 0,
distanceM: 0,
lastTickMs: Date.now(),
motorsEnabled: false,
locomotionMode: 1,
motorSpeeds: [0, 0, 0, 0, 0, 0],
calibrationPoints: []
};
var pageConnection = {
apiOk: false,
apiLastAt: 0,
rosOk: false,
rosLastAt: 0,
gpsLastAt: 0,
state: "offline"
};
var imuOverlayState = {
status: "Offline",
rollDeg: 0,
pitchDeg: 0,
yawDeg: 0,
yawOffsetDeg: 0,
lastUpdateAt: 0
};
function setText(id, text) {
var el = document.getElementById(id);
if (el) {
el.textContent = text;
}
}
function formatNumber(value, digits) {
return Number(value || 0).toLocaleString("de-DE", {
minimumFractionDigits: digits,
maximumFractionDigits: digits
});
}
function formatSpeedCms(speedMps, digits) {
return formatNumber(Number(speedMps || 0) * 100, digits);
}
function formatSignedNumber(value, digits) {
var number = Number(value || 0);
var text = number.toLocaleString("de-DE", {
minimumFractionDigits: digits,
maximumFractionDigits: digits
});
return (number >= 0 ? "+" : "") + text;
}
function headingToCompass(deg) {
var dirs = ["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];
return dirs[Math.round(((deg % 360) + 360) % 360 / 22.5) % 16];
}
function normalizeHeadingDeg(value) {
var heading = Number(value || 0) % 360;
if (heading < 0) heading += 360;
return heading;
}
function applyImuHeadingOffset(yawDeg) {
return normalizeHeadingDeg(Number(yawDeg || 0) + Number(imuOverlayState.yawOffsetDeg || 0));
}
function updateImuOverlaySvg() {
var group = document.getElementById("imu-horizon-group");
var frame = document.getElementById("imu-horizon-frame");
var rollPointer = document.getElementById("imu-roll-pointer");
var headingText = document.getElementById("imu-heading-text");
var rollText = document.getElementById("imu-roll-text");
var pitchText = document.getElementById("imu-pitch-text");
if (!group || !frame || !headingText || !rollText || !pitchText) return;
var ageMs = Date.now() - Number(imuOverlayState.lastUpdateAt || 0);
if (!imuOverlayState.lastUpdateAt || ageMs > 3000) {
group.setAttribute("opacity", "0");
return;
}
var rollDeg = Number(imuOverlayState.rollDeg || 0);
var pitchDeg = Number(imuOverlayState.pitchDeg || 0);
var headingDeg = applyImuHeadingOffset(imuOverlayState.yawDeg);
var pitchOffset = Math.max(-55, Math.min(55, pitchDeg * 2.2));
group.setAttribute("opacity", "1");
frame.setAttribute("transform", "rotate(" + (-rollDeg).toFixed(2) + ") translate(0 " + pitchOffset.toFixed(2) + ")");
if (rollPointer) {
rollPointer.setAttribute("transform", "rotate(" + (-rollDeg).toFixed(2) + ")");
}
headingText.textContent = "HDG " + String(Math.round(headingDeg)).padStart(3, "0") + "° " + headingToCompass(headingDeg);
rollText.textContent = "R " + formatSignedNumber(rollDeg, 1) + "°";
pitchText.textContent = "P " + formatSignedNumber(pitchDeg, 1) + "°";
}
function quaternionToEulerDeg(quaternion) {
if (!quaternion || quaternion.length < 4) {
return { rollDeg: 0, pitchDeg: 0, yawDeg: 0 };
}
var x = Number(quaternion[0] || 0);
var y = Number(quaternion[1] || 0);
var z = Number(quaternion[2] || 0);
var w = Number(quaternion[3] || 1);
var sinrCosp = 2 * (w * x + y * z);
var cosrCosp = 1 - 2 * (x * x + y * y);
var roll = Math.atan2(sinrCosp, cosrCosp);
var sinp = 2 * (w * y - z * x);
var pitch = Math.abs(sinp) >= 1 ? Math.sign(sinp) * (Math.PI / 2) : Math.asin(sinp);
var sinyCosp = 2 * (w * z + x * y);
var cosyCosp = 1 - 2 * (y * y + z * z);
var yaw = Math.atan2(sinyCosp, cosyCosp);
return {
rollDeg: roll * 180 / Math.PI,
pitchDeg: pitch * 180 / Math.PI,
yawDeg: yaw * 180 / Math.PI
};
}
function cloneCalibrationPoints(points) {
return (points || []).map(function (item) {
return {
percent: Number(item.percent),
speed_mps: Number(item.speed_mps)
};
});
}
function normalizeCalibrationPoints(points) {
var source = cloneCalibrationPoints(points).sort(function (a, b) {
return a.percent - b.percent;
});
if (!source.length) {
source = [{ percent: 0, speed_mps: 0 }];
}
return REQUIRED_CALIBRATION_POINTS.map(function (targetPercent) {
var exact = source.find(function (point) {
return Number(point.percent) === Number(targetPercent);
});
if (exact) {
return {
percent: targetPercent,
speed_mps: Number(exact.speed_mps || 0)
};
}
var lower = null;
var upper = null;
source.forEach(function (point) {
if (point.percent < targetPercent) lower = point;
if (!upper && point.percent > targetPercent) upper = point;
});
if (!lower && upper) {
return {
percent: targetPercent,
speed_mps: Number(upper.speed_mps || 0)
};
}
if (lower && !upper) {
return {
percent: targetPercent,
speed_mps: Number(lower.speed_mps || 0)
};
}
if (lower && upper) {
var span = Number(upper.percent) - Number(lower.percent);
var ratio = span <= 0 ? 0 : (targetPercent - Number(lower.percent)) / span;
return {
percent: targetPercent,
speed_mps: Number(lower.speed_mps || 0) + (Number(upper.speed_mps || 0) - Number(lower.speed_mps || 0)) * ratio
};
}
return {
percent: targetPercent,
speed_mps: 0
};
});
}
function interpolateCalibrationSpeed(percentMagnitude) {
var points = driveEstimator.calibrationPoints;
if (!points || !points.length) return 0;
var p = Math.max(0, Math.min(100, Math.abs(percentMagnitude)));
if (p <= points[0].percent) return Number(points[0].speed_mps || 0);
for (var i = 1; i < points.length; i += 1) {
var left = points[i - 1];
var right = points[i];
if (p <= right.percent) {
var span = right.percent - left.percent;
if (span <= 0) return Number(right.speed_mps || 0);
var ratio = (p - left.percent) / span;
return Number(left.speed_mps || 0) + (Number(right.speed_mps || 0) - Number(left.speed_mps || 0)) * ratio;
}
}
return Number(points[points.length - 1].speed_mps || 0);
}
function getAverageMotorSpeedPercent() {
if (!driveEstimator.motorSpeeds || !driveEstimator.motorSpeeds.length) return 0;
var sum = 0;
var count = 0;
driveEstimator.motorSpeeds.forEach(function (value) {
if (typeof value === "number" && !isNaN(value)) {
sum += value;
count += 1;
}
});
return count ? (sum / count) : 0;
}
function isTranslationMode() {
return driveEstimator.locomotionMode !== 2;
}
function updateDriveEstimatorModel() {
var nowMs = Date.now();
var dt = Math.max(0, (nowMs - driveEstimator.lastTickMs) / 1000.0);
driveEstimator.lastTickMs = nowMs;
var avgSpeedPercent = getAverageMotorSpeedPercent();
var direction = avgSpeedPercent > 0 ? 1 : (avgSpeedPercent < 0 ? -1 : 0);
var speedMagnitude = isTranslationMode() && driveEstimator.motorsEnabled
? interpolateCalibrationSpeed(avgSpeedPercent)
: 0;
var speedMps = direction === 0 ? 0 : speedMagnitude * direction;
if (Math.abs(speedMps) < 0.0005) speedMps = 0;
if (dt > 0 && dt < 2.5) driveEstimator.distanceM += Math.abs(speedMps) * dt;
driveEstimator.speedMps = speedMps;
}
function renderDriveEstimatorOverlay() {
if (!pageConnection.rosOk) {
setText("overlay-speed", "--");
setText("overlay-distance", "--");
return;
}
updateDriveEstimatorModel();
setText("overlay-speed", formatSpeedCms(driveEstimator.speedMps, 1));
setText("overlay-distance", formatNumber(driveEstimator.distanceM, 2));
}
function setPath(id, d) {
var node = document.getElementById(id);
if (node) node.setAttribute("d", d || "");
}
function fetchDriveEstimatorData() {
fetch(window.location.protocol + "//" + hostUrl + ":8082/api/drive-estimator/config", {
cache: "no-store"
}).then(function (response) {
if (!response.ok) throw new Error("config");
return response.json();
}).then(function (payload) {
var estimator = payload.estimator || {};
driveEstimator.drivePwmNeutralAverage = Number(estimator.drive_pwm_neutral_average || driveEstimator.drivePwmNeutralAverage);
driveEstimator.drivePwmRange = Number(estimator.drive_pwm_range || driveEstimator.drivePwmRange);
driveEstimator.pwmFrequencyHz = Number(estimator.pwm_frequency_hz || driveEstimator.pwmFrequencyHz);
}).catch(function () {});
fetch(window.location.protocol + "//" + hostUrl + ":8082/api/drive-estimator/calibration", {
cache: "no-store"
}).then(function (response) {
if (!response.ok) throw new Error("calibration");
return response.json();
}).then(function (payload) {
var calibration = payload.calibration || {};
var points = Array.isArray(calibration.points) ? calibration.points : [];
driveEstimator.calibrationPoints = normalizeCalibrationPoints(points);
renderDriveEstimatorOverlay();
}).catch(function () {});
}
function formatAxis(value) {
return (value >= 0 ? "+" : "") + value.toFixed(2);
}
function axisBarWidth(value) {
return Math.max(0, Math.min(100, ((value + 1) / 2) * 100)).toFixed(0) + "%";
}
function percentTextToWidth(value) {
if (!value || typeof value !== "string") {
return "0%";
}
var numeric = parseInt(value.replace("%", ""), 10);
if (isNaN(numeric)) {
return "0%";
}
return Math.max(0, Math.min(100, numeric)) + "%";
}
function temperatureTextToWidth(value) {
if (!value || typeof value !== "string") {
return "0%";
}
var numeric = parseFloat(value.replace("°C", "").replace(",", "."));
if (isNaN(numeric)) {
return "0%";
}
return Math.max(0, Math.min(100, ((numeric - 20) / 60) * 100)).toFixed(0) + "%";
}
function updateAxesDisplay(x, y) {
setText("jx", formatAxis(x));
setText("jy", formatAxis(y));
setText("dpx", formatAxis(x));
setText("dpy", formatAxis(y));
}
function setGpsAccuracyDisplay(label) {
var node = document.getElementById("gps-accuracy");
if (!node) return;
node.classList.remove("gps-quality-good", "gps-quality-mid", "gps-quality-bad");
if (typeof label !== "string" || !label) {
node.textContent = "--";
return;
}
node.textContent = label;
}
function setGpsAccuracyState(label, tone) {
var node = document.getElementById("gps-accuracy");
if (!node) return;
node.classList.remove("gps-quality-good", "gps-quality-mid", "gps-quality-bad");
node.textContent = label || "--";
if (tone === "good") {
node.classList.add("gps-quality-good");
} else if (tone === "mid") {
node.classList.add("gps-quality-mid");
} else if (tone === "bad") {
node.classList.add("gps-quality-bad");
}
}
function setGpsSatelliteSummary(value, connected) {
if (connected && typeof value === "number" && isFinite(value) && value >= 0) {
setText("gps-state", String(Math.round(value)) + " Sat");
return;
}
setText("gps-state", "Offline");
}
function updateGpsAccuracyFromDiagnostics() {
if (!lastGpsDiagnostics || !lastGpsDiagnostics.connected || !lastGpsDiagnostics.valid) {
setGpsAccuracyState("--", "");
return;
}
var fixQuality = Number(lastGpsDiagnostics.fix_quality || 0);
var hdop = Number(lastGpsDiagnostics.hdop);
var pdop = Number(lastGpsDiagnostics.pdop);
var satellitesUsed = Number(lastGpsDiagnostics.satellites_used || 0);
var lastFixAge = Number(lastGpsDiagnostics.last_fix_age_s || 0);
if (fixQuality <= 0 || isNaN(hdop)) {
setGpsAccuracyState("schlecht", "bad");
return;
}
var label = "schlecht";
var tone = "bad";
if (hdop <= 1.2 && (isNaN(pdop) || pdop <= 2.0) && satellitesUsed >= 10 && lastFixAge <= 2.0) {
label = "sehr gut";
tone = "good";
} else if (hdop <= 2.5 && (isNaN(pdop) || pdop <= 4.0) && satellitesUsed >= 6 && lastFixAge <= 4.0) {
label = "gut";
tone = "good";
} else if (hdop <= 4.5 && (isNaN(pdop) || pdop <= 7.0) && satellitesUsed >= 4 && lastFixAge <= 8.0) {
label = "mäßig";
tone = "mid";
}
if (satellitesUsed < 5 || lastFixAge > 8.0) {
label = "schlecht";
tone = "bad";
}
setGpsAccuracyState(label, tone);
}
function updateGpsOverlayFromFix(message) {
if (!message) return;
markRosFresh();
markGpsFresh();
var hasFix = message.status && message.status.status >= 0;
if (!hasFix) {
setGpsAccuracyState("--", "");
setText("gps-lat", "--");
setText("gps-lon", "--");
return;
}
updateGpsAccuracyFromDiagnostics();
if (lastGpsDiagnostics && lastGpsDiagnostics.connected) {
setGpsSatelliteSummary(Number(lastGpsDiagnostics.satellites_used || lastGpsDiagnostics.satellites_visible || 0), true);
}
setText("gps-lat", formatNumber(Number(message.latitude || 0), 5));
setText("gps-lon", formatNumber(Number(message.longitude || 0), 5));
}
function updateGpsOverlayFromStatus(text) {
markRosFresh();
markGpsFresh();
var match = String(text || "").match(/(\d+)\s*Sat/i);
if (match) {
setGpsSatelliteSummary(Number(match[1]), true);
} else if (String(text || "").trim()) {
setGpsSatelliteSummary(0, true);
}
}
function updateGpsOverlayFromDiagnostics(text) {
markRosFresh();
markGpsFresh();
try {
lastGpsDiagnostics = JSON.parse(String(text || "{}"));
} catch (error) {
lastGpsDiagnostics = null;
}
if (lastGpsDiagnostics && lastGpsDiagnostics.connected) {
setGpsSatelliteSummary(Number(lastGpsDiagnostics.satellites_used || lastGpsDiagnostics.satellites_visible || 0), true);
}
updateGpsAccuracyFromDiagnostics();
}
function updateModeDisplay() {
var label = "Ackermann";
if (driveMode === "turn-on-point") {
label = "Turn on Point";
} else if (driveMode === "crab") {
label = "Crab";
}
setText("overlay-mode", label);
setText("stat-mode", label);
}
function applyDriveMode(mode, flash) {
var changed = mode !== driveMode;
driveMode = mode;
document.querySelectorAll(".dbtn[data-mode]").forEach(function (btn) {
var isActive = btn.dataset.mode === mode;
btn.classList.toggle("active", isActive);
if (isActive && changed && flash) {
btn.classList.remove("flash");
void btn.offsetWidth;
btn.classList.add("flash");
}
});
updateModeDisplay();
updateControlLocks();
}
function mirrorJoyThumb(x, y) {
if (dragging || inputMode !== "joystick") return;
var data = joyMetrics();
var dx = x * data.maxD;
var dy = -y * data.maxD;
var thumb = document.getElementById("jthumb");
thumb.style.transition = "left .05s, top .05s";
thumb.style.left = "calc(50% + " + dx + "px)";
thumb.style.top = "calc(50% + " + dy + "px)";
thumb.style.transform = "translate(-50%,-50%)";
updateAxesDisplay(x, y);
}
function locomotionModeToUi(modeValue) {
if (modeValue === 2) {
return "turn-on-point";
}
if (modeValue === 3) {
return "crab";
}
return "ackermann";
}
function updateMotorDisplay() {
setText("stat-motors", motorsEnabled ? "Aktiv" : "Aus");
}
function updateInputDisplay() {
var label = inputMode === "joystick" ? "Joystick" : "D-Pad";
setText("input-state", label);
setText("stat-input", label);
}
function setRosStatus(text) {
setText("status", text);
setText("ros-state", text);
}
function controlsAreBlocked() {
return pageConnection.state === "offline";
}
function updateControlAvailability() {
var panel = document.getElementById("joy-panel");
if (panel) {
panel.classList.toggle("is-disabled", controlsAreBlocked());
}
}
function clearSystemStatusDisplay() {
updateUndervoltageDisplay("--");
}
function clearRosLiveDisplay() {
setText("gps-state", "Offline");
setGpsAccuracyState("--", "");
setText("gps-lat", "--");
setText("gps-lon", "--");
setText("overlay-speed", "--");
setText("overlay-distance", "--");
}
function setPageConnectionState() {
var now = Date.now();
var apiFresh = pageConnection.apiOk && (now - pageConnection.apiLastAt) < 15000;
var rosSocketOpen = !!(ros && ros.socket && ros.socket.readyState === 1);
var rosConnected = rosSocketOpen || !!pageConnection.rosOk;
var state = "offline";
var cunoTitle = "Cuno offline";
if (apiFresh && rosConnected) {
state = "online";
} else if (apiFresh || rosConnected) {
state = "degraded";
}
cunoTitle = state === "online" ? "Cuno online" : (state === "degraded" ? "Cuno eingeschränkt" : "Cuno offline");
pageConnection.state = state;
document.body.classList.toggle("page-connection-online", state === "online");
document.body.classList.toggle("page-connection-degraded", state === "degraded");
document.body.classList.toggle("page-connection-offline", state === "offline");
setText("cuno-connection-status", cunoTitle);
updateControlAvailability();
if (!apiFresh) {
clearSystemStatusDisplay();
}
if (!rosConnected) {
clearRosLiveDisplay();
}
}
function markApiConnection(ok) {
pageConnection.apiOk = !!ok;
if (ok) {
pageConnection.apiLastAt = Date.now();
}
setPageConnectionState();
}
function markRosConnection(ok) {
pageConnection.rosOk = !!ok;
if (ok) {
pageConnection.rosLastAt = Date.now();
}
setPageConnectionState();
}
function markRosFresh() {
pageConnection.rosOk = true;
pageConnection.rosLastAt = Date.now();
setPageConnectionState();
}
function markGpsFresh() {
pageConnection.gpsLastAt = Date.now();
setPageConnectionState();
}
function scheduleRosReconnect() {
if (rosReconnectTimer) return;
rosReconnectTimer = window.setTimeout(function () {
rosReconnectTimer = null;
if (!ros || typeof ros.connect !== "function") return;
setRosStatus("Verbinde...");
try {
ros.connect("ws://" + hostUrl + ":9090");
} catch (error) {}
}, 3000);
}
function clearPendingRosDisconnect() {
if (!rosDisconnectTimer) return;
window.clearTimeout(rosDisconnectTimer);
rosDisconnectTimer = null;
}
function scheduleRosDisconnect() {
if (rosDisconnectTimer) return;
rosDisconnectTimer = window.setTimeout(function () {
rosDisconnectTimer = null;
var rosSocketOpen = !!(ros && ros.socket && ros.socket.readyState === 1);
if (rosSocketOpen) {
markRosConnection(true);
setRosStatus("Verbunden");
return;
}
markRosConnection(false);
setRosStatus("Getrennt");
}, 8000);
}
function refreshRosSocketState() {
if (!ros || !ros.socket) return;
if (ros.socket.readyState === 1) {
clearPendingRosDisconnect();
markRosConnection(true);
}
}
function clearCameraReconnect() {
if (!cameraReconnectTimer) return;
window.clearTimeout(cameraReconnectTimer);
cameraReconnectTimer = null;
}
function clearCameraRecoveryTimers() {
cameraRecoveryTimers.forEach(function (timerId) {
window.clearTimeout(timerId);
});
cameraRecoveryTimers = [];
}
function reloadCameraStream() {
var video = document.getElementById("video_output");
if (!video) return;
video.src = "//" + hostUrl + ":8083/stream.mjpg";
}
function scheduleCameraReconnect(delayMs) {
if (cameraReconnectTimer) return;
cameraReconnectTimer = window.setTimeout(function () {
cameraReconnectTimer = null;
reloadCameraStream();
}, delayMs || 2500);
}
function triggerCameraRecovery() {
clearCameraReconnect();
clearCameraRecoveryTimers();
[1200, 2600, 4200, 6500].forEach(function (delayMs) {
cameraRecoveryTimers.push(window.setTimeout(function () {
reloadCameraStream();
}, delayMs));
});
}
function updateUndervoltageDisplay(text, state) {
var el = document.getElementById("stat-undervoltage");
if (!el) {
return;
}
el.textContent = text || "--";
el.classList.remove("b", "g", "o", "alert", "warntext");
if (state === "active") {
el.classList.add("alert");
return;
}
if (state === "past") {
el.classList.add("warntext");
return;
}
if (state === "clear") {
el.classList.add("g");
return;
}
el.classList.add("b");
}
function updateServiceState(payload) {
var system = payload.system || {};
var services = payload.services || {};
var imuHeading = payload.imu_heading || {};
var cameraServiceState = String(services.camera || "");
var cameraSettingsVersion = Number(services.camera_settings_version || 0);
if (typeof imuHeading.offset_deg === "number" && !isNaN(imuHeading.offset_deg)) {
imuOverlayState.yawOffsetDeg = Number(imuHeading.offset_deg);
updateImuOverlaySvg();
}
updateUndervoltageDisplay(system.undervoltage || "unbekannt", system.undervoltage_state || "unknown");
if (cameraServiceState === "active" && lastCameraServiceState && lastCameraServiceState !== "active") {
setText("cam-state", "Verbinde...");
triggerCameraRecovery();
}
if (lastCameraSettingsVersion !== null && cameraSettingsVersion !== lastCameraSettingsVersion) {
setText("cam-state", "Verbinde...");
triggerCameraRecovery();
}
lastCameraServiceState = cameraServiceState;
lastCameraSettingsVersion = cameraSettingsVersion;
setText("cam-state", cameraServiceState === "active" ? "Online" : cameraServiceState || "unbekannt");
}
function fetchSystemStatus() {
fetch(window.location.protocol + "//" + hostUrl + ":8082/api/status", {
cache: "no-store"
}).then(function (response) {
if (!response.ok) {
throw new Error("status");
}
return response.json();
}).then(function (payload) {
markApiConnection(true);
updateServiceState(payload);
}).catch(function () {
markApiConnection(false);
});
}
function publishJoy() {
if (!joyListener || controlsAreBlocked()) {
return;
}
joyListener.publish(new ROSLIB.Message({
header: {
frame_id: "webgui"
},
axes: axes,
buttons: buttons
}));
}
function startPublishing() {
if (publishTimer) {
return;
}
publishJoy();
publishTimer = window.setInterval(publishJoy, 50);
}
function stopPublishing() {
if (publishTimer) {
clearInterval(publishTimer);
publishTimer = null;
}
}
function setAxes(x, y) {
axes = [x, y, 0, 0, 0, 0];
updateAxesDisplay(x, y);
}
function resetAxes() {
setAxes(0, 0);
publishJoy();
}
function pulseButton(buttonIndex) {
if (controlsAreBlocked()) {
return;
}
axes = [0, 0, 0, 0, 0, 0];
updateAxesDisplay(0, 0);
buttons[buttonIndex] = 1;
publishJoy();
window.setTimeout(function () {
buttons[buttonIndex] = 0;
publishJoy();
}, 120);
}
function selectDriveMode(mode, buttonIndex) {
if (controlsAreBlocked()) {
return;
}
applyDriveMode(mode);
pulseButton(buttonIndex);
}
function loadImageFromBlob(blob) {
return new Promise(function (resolve, reject) {
var image = new Image();
var objectUrl = URL.createObjectURL(blob);
image.onload = function () {
URL.revokeObjectURL(objectUrl);
resolve(image);
};
image.onerror = function () {
URL.revokeObjectURL(objectUrl);
reject(new Error("image"));
};
image.src = objectUrl;
});
}
function drawOverlayTextBlock(context, lines, x, y, align, lineHeight, color) {
context.save();
context.textAlign = align || "left";
context.textBaseline = "top";
context.fillStyle = color;
for (var i = 0; i < lines.length; i += 1) {
context.fillText(lines[i], x, y + (i * lineHeight));
}
context.restore();
}
function drawImuHorizonOverlay(context, canvasWidth, canvasHeight, size) {
var ageMs = Date.now() - Number(imuOverlayState.lastUpdateAt || 0);
if (!imuOverlayState.lastUpdateAt || ageMs > 3000) return;
var cx = canvasWidth / 2;
var cy = canvasHeight / 2;
var unit = size / 220;
var rollRad = Number(imuOverlayState.rollDeg || 0) * Math.PI / 180;
var pitchOffsetPx = Math.max(-55, Math.min(55, Number(imuOverlayState.pitchDeg || 0) * 2.2)) * unit;
var headingDeg = applyImuHeadingOffset(imuOverlayState.yawDeg);
var rollDeg = Number(imuOverlayState.rollDeg || 0);
var pitchDeg = Number(imuOverlayState.pitchDeg || 0);
/* roll pointer triangle (only roll, no pitch) */
context.save();
context.translate(cx, cy);
context.rotate(-rollRad);
context.beginPath();
context.moveTo(0, -90 * unit);
context.lineTo(-5.5 * unit, -79 * unit);
context.lineTo(5.5 * unit, -79 * unit);
context.closePath();
context.fillStyle = "rgba(0,255,145,.88)";
context.fill();
context.restore();
/* horizon + pitch ladder */
context.save();
context.translate(cx, cy);
context.rotate(-rollRad);
context.translate(0, pitchOffsetPx);
[
{ y: -33, span: 11, op: .28 }, { y: 33, span: 11, op: .28 },
{ y: -22, span: 17, op: .44 }, { y: 22, span: 17, op: .44 },
{ y: -11, span: 10, op: .3 }, { y: 11, span: 10, op: .3 }
].forEach(function(pl) {
context.beginPath();
context.moveTo(-pl.span * unit, pl.y * unit);
context.lineTo(pl.span * unit, pl.y * unit);
context.strokeStyle = "rgba(0,255,145," + pl.op + ")";
context.lineWidth = 0.9 * unit;
context.stroke();
});
context.strokeStyle = "rgba(0,255,145,.95)";
context.lineWidth = 2.8 * unit;
context.beginPath(); context.moveTo(-40 * unit, 0); context.lineTo(40 * unit, 0); context.stroke();
context.beginPath(); context.moveTo(-40 * unit, 0); context.lineTo(-32 * unit, -5 * unit); context.stroke();
context.beginPath(); context.moveTo(40 * unit, 0); context.lineTo(32 * unit, -5 * unit); context.stroke();
context.restore();
/* HDG box + text labels */
context.save();
var bw = 84 * unit, bh = 15 * unit;
context.fillStyle = "rgba(0,15,8,.65)";
context.fillRect(cx - bw / 2, cy - 72 * unit, bw, bh);
context.font = (9 * unit) + 'px "Consolas","Cascadia Mono",monospace';
context.fillStyle = "rgba(0,255,145,.92)";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("HDG " + String(Math.round(headingDeg)).padStart(3, "0") + "° " + headingToCompass(headingDeg), cx, cy - 62.5 * unit);
context.font = (7.5 * unit) + 'px "Consolas","Cascadia Mono",monospace';
context.fillStyle = "rgba(0,255,145,.72)";
context.fillText("R " + formatSignedNumber(rollDeg, 1) + "°", cx - 73 * unit, cy + 76 * unit);
context.fillText("P " + formatSignedNumber(pitchDeg, 1) + "°", cx + 73 * unit, cy + 76 * unit);
context.restore();
}
function drawCrosshairOverlayToCanvas(context, canvasWidth, canvasHeight, size) {
var cx = canvasWidth / 2;
var cy = canvasHeight / 2;
var unit = size / 180;
function circle(radius, stroke, lineWidth, dash) {
context.save();
context.beginPath();
context.arc(cx, cy, radius * unit, 0, Math.PI * 2);
if (dash) context.setLineDash(dash.map(function (value) { return value * unit; }));
context.strokeStyle = stroke;
context.lineWidth = lineWidth * unit;
context.stroke();
context.restore();
}
function line(x1, y1, x2, y2, stroke, lineWidth) {
context.save();
context.beginPath();
context.moveTo(cx + (x1 * unit), cy + (y1 * unit));
context.lineTo(cx + (x2 * unit), cy + (y2 * unit));
context.strokeStyle = stroke;
context.lineWidth = lineWidth * unit;
context.stroke();
context.restore();
}
function path(points, stroke, lineWidth) {
context.save();
context.beginPath();
context.moveTo(cx + (points[0][0] * unit), cy + (points[0][1] * unit));
for (var i = 1; i < points.length; i += 1) {
context.lineTo(cx + (points[i][0] * unit), cy + (points[i][1] * unit));
}
context.strokeStyle = stroke;
context.lineWidth = lineWidth * unit;
context.stroke();
context.restore();
}
circle(82, "rgba(0,255,145,.2)", 1, [4, 10]);
circle(60, "rgba(0,255,145,.15)", 0.75, [2, 16]);
circle(16, "rgba(0,255,145,.3)", 0.75);
line(-86, 0, -19, 0, "rgba(0,255,145,.6)", 1);
line(19, 0, 86, 0, "rgba(0,255,145,.6)", 1);
line(0, -86, 0, -19, "rgba(0,255,145,.6)", 1);
line(0, 19, 0, 86, "rgba(0,255,145,.6)", 1);
line(-60, -6, -60, 6, "rgba(0,255,145,.45)", 0.75);
line(60, -6, 60, 6, "rgba(0,255,145,.45)", 0.75);
line(-6, -60, 6, -60, "rgba(0,255,145,.45)", 0.75);
line(-6, 60, 6, 60, "rgba(0,255,145,.45)", 0.75);
line(-38, -3.5, -38, 3.5, "rgba(0,255,145,.3)", 0.5);
line(38, -3.5, 38, 3.5, "rgba(0,255,145,.3)", 0.5);
line(-3.5, -38, 3.5, -38, "rgba(0,255,145,.3)", 0.5);
line(-3.5, 38, 3.5, 38, "rgba(0,255,145,.3)", 0.5);
path([[-74, -58], [-74, -74], [-58, -74]], "rgba(0,255,145,.75)", 1.5);
path([[58, -74], [74, -74], [74, -58]], "rgba(0,255,145,.75)", 1.5);
path([[-74, 58], [-74, 74], [-58, 74]], "rgba(0,255,145,.75)", 1.5);
path([[58, 74], [74, 74], [74, 58]], "rgba(0,255,145,.75)", 1.5);
circle(6, "rgba(0,255,145,.7)", 1);
context.save();
context.beginPath();
context.arc(cx, cy, 2.5 * unit, 0, Math.PI * 2);
context.fillStyle = "rgba(0,255,145,.9)";
context.fill();
context.beginPath();
context.arc(cx, cy, 1 * unit, 0, Math.PI * 2);
context.fillStyle = "rgba(255,255,255,.7)";
context.fill();
context.restore();
drawImuHorizonOverlay(context, canvasWidth, canvasHeight, size);
}
function drawVignetteAndScanlines(context, canvasWidth, canvasHeight) {
context.save();
for (var y = 0; y < canvasHeight; y += 4) {
context.fillStyle = "rgba(0,255,145,.012)";
context.fillRect(0, y, canvasWidth, 1);
}
var vignette = context.createRadialGradient(
canvasWidth * 0.5, canvasHeight * 0.5, canvasWidth * 0.25,
canvasWidth * 0.5, canvasHeight * 0.5, canvasWidth * 0.75
);
vignette.addColorStop(0, "rgba(0,0,0,0)");
vignette.addColorStop(1, "rgba(0,0,0,.58)");
context.fillStyle = vignette;
context.fillRect(0, 0, canvasWidth, canvasHeight);
context.restore();
}
function drawHudOverlayToCanvas(context, canvasWidth, canvasHeight) {
var frame = document.getElementById("video_frame");
var scaleValue = frame ? parseFloat(getComputedStyle(frame).getPropertyValue("--cam-scale")) : 1;
var camScale = isFinite(scaleValue) && scaleValue > 0 ? scaleValue : 1;
var frameWidth = frame ? frame.clientWidth : canvasWidth;
var scaleToCanvas = frameWidth ? (canvasWidth / frameWidth) : 1;
var padding = 13 * camScale * scaleToCanvas;
var fontSize = 9 * camScale * scaleToCanvas;
var lineHeight = fontSize * 1.9;
var hudColor = "rgba(0,255,145,.72)";
context.save();
context.font = fontSize + 'px "IBM Plex Mono", "Roboto Mono", monospace';
context.fillStyle = hudColor;
drawOverlayTextBlock(context, [
"CAM: " + document.getElementById("cam-state").textContent,
"MODE: " + document.getElementById("overlay-mode").textContent
], padding, padding, "left", lineHeight, hudColor);
drawOverlayTextBlock(context, [
"ROS: " + document.getElementById("ros-state").textContent,
"CTRL: " + document.getElementById("input-state").textContent
], canvasWidth - padding, padding, "right", lineHeight, hudColor);
drawOverlayTextBlock(context, [
"GPS: " + document.getElementById("gps-state").textContent,
"POS: " + document.getElementById("gps-accuracy").textContent,
"LAT: " + document.getElementById("gps-lat").textContent,
"LON: " + document.getElementById("gps-lon").textContent
], padding, canvasHeight - padding - (lineHeight * 4), "left", lineHeight, hudColor);
drawOverlayTextBlock(context, [
"HOST: " + document.getElementById("cam-host").textContent,
"STREAM: 8083"
], canvasWidth - padding, canvasHeight - padding - (lineHeight * 2), "right", lineHeight, hudColor);
drawOverlayTextBlock(context, [
"V: " + document.getElementById("overlay-speed").textContent + " cm/s",
"S: " + document.getElementById("overlay-distance").textContent + " m"
], canvasWidth / 2, canvasHeight - padding - (lineHeight * 2), "center", lineHeight, hudColor);
var recY = padding + (fontSize * 0.55);
context.beginPath();
context.arc((canvasWidth / 2) - (18 * camScale * scaleToCanvas), recY, 3 * camScale * scaleToCanvas, 0, Math.PI * 2);
context.fillStyle = document.getElementById("cam-live-indicator").classList.contains("is-offline")
? "rgba(170,255,220,.72)"
: "#FF3333";
context.fill();
context.fillStyle = hudColor;
context.textAlign = "center";
context.fillText(document.getElementById("cam-live-text").textContent, canvasWidth / 2 + (6 * camScale * scaleToCanvas), padding);
context.restore();
drawCrosshairOverlayToCanvas(context, canvasWidth, canvasHeight, 270 * camScale * scaleToCanvas);
drawVignetteAndScanlines(context, canvasWidth, canvasHeight);
}
async function saveCurrentFrame() {
try {
var response = await fetch("//" + hostUrl + ":8083/snapshot.jpg?t=" + Date.now(), {
cache: "no-store"
});
if (!response.ok) {
throw new Error("snapshot");
}
var blob = await response.blob();
var image = await loadImageFromBlob(blob);
var canvas = document.createElement("canvas");
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
var context = canvas.getContext("2d");
if (!context) {
throw new Error("canvas");
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
drawHudOverlayToCanvas(context, canvas.width, canvas.height);
var link = document.createElement("a");
var timestamp = new Date().toISOString().replace(/[:.]/g, "-");
var objectUrl = canvas.toDataURL("image/jpeg", 0.95);
link.href = objectUrl;
link.download = "exomy-" + timestamp + ".jpg";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {}
}
function toggleMotors() {
if (controlsAreBlocked()) {
return;
}
motorsEnabled = !motorsEnabled;
updateMotorDisplay();
pulseButton(9);
}
function updateControlLocks() {
var xOnly = driveMode === "turn-on-point";
setText("joy-x-label", driveMode === "turn-on-point" ? "Rotation" : "X-Achse");
setText("dpad-x-label", driveMode === "turn-on-point" ? "Rotation" : "X-Achse");
document.querySelectorAll("#joy-area .joy-ax")[1].style.opacity = xOnly ? "0.25" : "1";
document.querySelectorAll("#dpad-area .joy-ax")[1].style.opacity = xOnly ? "0.25" : "1";
document.querySelectorAll(".dpad-btn").forEach(function (btn) {
var x = parseFloat(btn.dataset.x || "0");
var y = parseFloat(btn.dataset.y || "0");
var disable = xOnly && y !== 0;
btn.disabled = disable;
btn.style.opacity = disable ? "0.15" : "";
btn.style.pointerEvents = disable ? "none" : "";
});
}
function updateClocks() {
var now = new Date();
setText("utc", now.toUTCString().split(" ")[4]);
var s = Math.floor((Date.now() - metBase) / 1000);
var h = String(Math.floor(s / 3600)).padStart(2, "0");
var m = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
var sec = String(s % 60).padStart(2, "0");
setText("met", h + ":" + m + ":" + sec);
}
function joyMetrics() {
var rect = document.getElementById("jbase").getBoundingClientRect();
var baseR = Math.min(rect.width, rect.height) / 2;
var thumbR = baseR * 0.33;
return {
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
maxD: baseR - thumbR
};
}
function moveThumb(clientX, clientY) {
if (controlsAreBlocked()) {
return;
}
var data = joyMetrics();
var dx = clientX - data.cx;
var dy = clientY - data.cy;
if (driveMode === "turn-on-point") {
dy = 0;
}
var distance = Math.sqrt(dx * dx + dy * dy);
if (distance > data.maxD && distance > 0) {
dx = dx / distance * data.maxD;
dy = dy / distance * data.maxD;
}
var thumb = document.getElementById("jthumb");
thumb.style.left = "calc(50% + " + dx + "px)";
thumb.style.top = "calc(50% + " + dy + "px)";
thumb.style.transform = "translate(-50%,-50%)";
thumb.style.transition = "none";
var nx = dx / data.maxD;
var ny = -dy / data.maxD;
setAxes(nx, ny);
publishJoy();
startPublishing();
}
function resetThumb() {
var thumb = document.getElementById("jthumb");
thumb.style.transition = "left .22s cubic-bezier(.25,.46,.45,.94), top .22s cubic-bezier(.25,.46,.45,.94)";
thumb.style.left = "50%";
thumb.style.top = "50%";
thumb.style.transform = "translate(-50%,-50%)";
stopPublishing();
resetAxes();
}
function setDpadPressed(button, x, y) {
if (controlsAreBlocked()) {
return;
}
document.querySelectorAll(".dpad-btn").forEach(function (btn) {
btn.classList.remove("pressed");
});
button.classList.add("pressed");
setAxes(x, y);
startPublishing();
}
function clearDpad() {
document.querySelectorAll(".dpad-btn").forEach(function (btn) {
btn.classList.remove("pressed");
});
stopPublishing();
resetAxes();
}
function updateCameraFrameLayout() {
var port = document.querySelector(".cam-port");
var frame = document.getElementById("video_frame");
var video = document.getElementById("video_output");
if (!port || !frame) return;
var portWidth = port.clientWidth;
var portHeight = port.clientHeight;
if (!portWidth || !portHeight) return;
var videoWidth = (video && video.naturalWidth) || 1296;
var videoHeight = (video && video.naturalHeight) || 972;
var aspectRatio = videoWidth / videoHeight;
var frameWidth = portWidth;
var frameHeight = frameWidth / aspectRatio;
if (frameHeight > portHeight) {
frameHeight = portHeight;
frameWidth = frameHeight * aspectRatio;
}
frame.style.width = frameWidth + "px";
frame.style.height = frameHeight + "px";
frame.style.left = ((portWidth - frameWidth) / 2) + "px";
frame.style.top = ((portHeight - frameHeight) / 2) + "px";
frame.style.setProperty("--cam-scale", Math.max(0.55, Math.min(1.1, frameWidth / 640)).toFixed(3));
}
function setStatPanelCollapsed(collapsed, immediate) {
var panel = document.getElementById("stat-panel");
var toggle = document.getElementById("stat-panel-toggle");
var body = document.getElementById("stat-panel-body");
if (!panel || !toggle || !body) return;
var isCollapsed = panel.classList.contains("collapsed");
if (isCollapsed === collapsed && !immediate) return;
body.style.overflow = "hidden";
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
var indicator = toggle.querySelector(".stat-toggle-indicator");
if (indicator) {
indicator.textContent = collapsed ? "+" : "-";
}
if (immediate) {
panel.classList.toggle("collapsed", collapsed);
body.style.height = collapsed ? "0px" : "auto";
return;
}
if (collapsed) {
body.style.height = body.scrollHeight + "px";
body.offsetHeight;
panel.classList.add("collapsed");
body.style.height = "0px";
} else {
panel.classList.remove("collapsed");
body.style.height = "0px";
body.offsetHeight;
body.style.height = body.scrollHeight + "px";
}
}
function toggleStatPanel() {
var panel = document.getElementById("stat-panel");
if (!panel) return;
setStatPanelCollapsed(!panel.classList.contains("collapsed"));
}
window.addEventListener("load", function () {
setText("cam-host", hostUrl);
updateModeDisplay();
updateMotorDisplay();
updateInputDisplay();
updateImuOverlaySvg();
updateAxesDisplay(0, 0);
updateControlLocks();
updateClocks();
window.setInterval(updateClocks, 1000);
window.setInterval(updateImuOverlaySvg, 750);
fetchSystemStatus();
refreshRosSocketState();
setPageConnectionState();
fetchDriveEstimatorData();
renderDriveEstimatorOverlay();
statusTimer = window.setInterval(fetchSystemStatus, 5000);
window.setInterval(refreshRosSocketState, 1000);
window.setInterval(setPageConnectionState, 1000);
window.setInterval(renderDriveEstimatorOverlay, 250);
var video = document.getElementById("video_output");
reloadCameraStream();
video.addEventListener("load", function () {
clearCameraReconnect();
clearCameraRecoveryTimers();
setText("cam-state", "Online");
setText("cam-host", hostUrl);
setText("cam-live-text", "LIVE");
var liveIndicator = document.getElementById("cam-live-indicator");
if (liveIndicator) {
liveIndicator.classList.remove("is-offline");
}
updateCameraFrameLayout();
});
video.addEventListener("error", function () {
setText("cam-state", "Fehler");
setText("cam-live-text", "OFFLINE");
var liveIndicator = document.getElementById("cam-live-indicator");
if (liveIndicator) {
liveIndicator.classList.add("is-offline");
}
scheduleCameraReconnect(2500);
});
updateCameraFrameLayout();
window.addEventListener("resize", updateCameraFrameLayout);
window.addEventListener("orientationchange", function () {
window.setTimeout(updateCameraFrameLayout, 80);
});
var statBody = document.getElementById("stat-panel-body");
if (statBody) {
statBody.addEventListener("transitionend", function (event) {
if (event.propertyName !== "height") return;
var panel = document.getElementById("stat-panel");
if (!panel) return;
statBody.style.height = panel.classList.contains("collapsed") ? "0px" : "auto";
});
}
setStatPanelCollapsed(true, true);
var statToggle = document.getElementById("stat-panel-toggle");
if (statToggle) {
statToggle.addEventListener("click", toggleStatPanel);
statToggle.addEventListener("keydown", function (event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleStatPanel();
}
});
}
ros = new ROSLIB.Ros({
url: "ws://" + hostUrl + ":9090"
});
ros.on("connection", function () {
if (rosReconnectTimer) {
window.clearTimeout(rosReconnectTimer);
rosReconnectTimer = null;
}
clearPendingRosDisconnect();
markRosConnection(true);
setRosStatus("Verbunden");
});
ros.on("error", function () {
setRosStatus("Verbinde...");
scheduleRosDisconnect();
scheduleRosReconnect();
});
ros.on("close", function () {
setRosStatus("Verbinde...");
scheduleRosDisconnect();
scheduleRosReconnect();
});
joyListener = new ROSLIB.Topic({
ros: ros,
name: "/joy",
messageType: "sensor_msgs/Joy"
});
roverCommandListener = new ROSLIB.Topic({
ros: ros,
name: "/rover_command",
messageType: "exomy/RoverCommand"
});
roverCommandListener.subscribe(function (message) {
clearPendingRosDisconnect();
markRosFresh();
setRosStatus("Verbunden");
applyDriveMode(locomotionModeToUi(message.locomotion_mode), true);
motorsEnabled = !!message.motors_enabled;
driveEstimator.motorsEnabled = !!message.motors_enabled;
driveEstimator.locomotionMode = Number(message.locomotion_mode);
updateMotorDisplay();
renderDriveEstimatorOverlay();
});
motorCommandsListener = new ROSLIB.Topic({
ros: ros,
name: "/motor_commands",
messageType: "exomy/MotorCommands"
});
motorCommandsListener.subscribe(function (message) {
clearPendingRosDisconnect();
markRosFresh();
setRosStatus("Verbunden");
driveEstimator.motorSpeeds = Array.isArray(message.motor_speeds) ? message.motor_speeds.map(Number) : [0, 0, 0, 0, 0, 0];
renderDriveEstimatorOverlay();
});
gpsFixListener = new ROSLIB.Topic({
ros: ros,
name: "/fix",
messageType: "sensor_msgs/NavSatFix"
});
gpsFixListener.subscribe(updateGpsOverlayFromFix);
gpsStatusListener = new ROSLIB.Topic({
ros: ros,
name: "/gps/status_text",
messageType: "std_msgs/String"
});
gpsStatusListener.subscribe(function (message) {
updateGpsOverlayFromStatus(message.data);
});
gpsDiagnosticsListener = new ROSLIB.Topic({
ros: ros,
name: "/gps/diagnostics_json",
messageType: "std_msgs/String"
});
gpsDiagnosticsListener.subscribe(function (message) {
updateGpsOverlayFromDiagnostics(message.data);
});
imuDataListener = new ROSLIB.Topic({
ros: ros,
name: "/imu/data",
messageType: "sensor_msgs/Imu"
});
imuDataListener.subscribe(function (message) {
clearPendingRosDisconnect();
markRosFresh();
setRosStatus("Verbunden");
var orientation = message.orientation || {};
var euler = quaternionToEulerDeg([
Number(orientation.x || 0),
Number(orientation.y || 0),
Number(orientation.z || 0),
Number(orientation.w || 1)
]);
imuOverlayState.rollDeg = euler.rollDeg;
imuOverlayState.pitchDeg = euler.pitchDeg;
imuOverlayState.yawDeg = euler.yawDeg;
imuOverlayState.lastUpdateAt = Date.now();
updateImuOverlaySvg();
});
imuStatusListener = new ROSLIB.Topic({
ros: ros,
name: "/imu/status",
messageType: "std_msgs/String"
});
imuStatusListener.subscribe(function (message) {
imuOverlayState.status = String((message && message.data) || "").trim() || "Verbunden";
});
var joySubscriber = new ROSLIB.Topic({
ros: ros,
name: "/joy",
messageType: "sensor_msgs/Joy"
});
joySubscriber.subscribe(function (message) {
clearPendingRosDisconnect();
markRosConnection(true);
if (message.header.frame_id === "webgui") return;
var x = (message.axes[0] || 0);
var y = -(message.axes[1] || 0);
mirrorJoyThumb(x, y);
});
document.querySelectorAll(".dbtn[data-mode]").forEach(function (button) {
button.addEventListener("click", function () {
selectDriveMode(button.dataset.mode, parseInt(button.dataset.button, 10));
});
});
document.getElementById("save-frame-button").addEventListener("click", saveCurrentFrame);
document.getElementById("motor-toggle").addEventListener("click", toggleMotors);
document.getElementById("mt-joy").addEventListener("click", function () {
if (controlsAreBlocked()) return;
inputMode = "joystick";
updateInputDisplay();
document.getElementById("mt-joy").classList.add("active");
document.getElementById("mt-dpad").classList.remove("active");
document.getElementById("joy-area").style.display = "";
document.getElementById("dpad-area").classList.remove("visible");
clearDpad();
resetThumb();
});
document.getElementById("mt-dpad").addEventListener("click", function () {
if (controlsAreBlocked()) return;
inputMode = "dpad";
updateInputDisplay();
document.getElementById("mt-dpad").classList.add("active");
document.getElementById("mt-joy").classList.remove("active");
document.getElementById("joy-area").style.display = "none";
document.getElementById("dpad-area").classList.add("visible");
resetThumb();
clearDpad();
});
var joyBase = document.getElementById("jbase");
joyBase.addEventListener("mousedown", function (event) {
if (controlsAreBlocked()) return;
dragging = true;
moveThumb(event.clientX, event.clientY);
event.preventDefault();
});
document.addEventListener("mousemove", function (event) {
if (dragging) {
moveThumb(event.clientX, event.clientY);
}
});
document.addEventListener("mouseup", function () {
if (dragging) {
dragging = false;
resetThumb();
}
});
joyBase.addEventListener("touchstart", function (event) {
if (controlsAreBlocked()) return;
dragging = true;
moveThumb(event.touches[0].clientX, event.touches[0].clientY);
event.preventDefault();
}, { passive: false });
document.addEventListener("touchmove", function (event) {
if (dragging) {
moveThumb(event.touches[0].clientX, event.touches[0].clientY);
event.preventDefault();
}
}, { passive: false });
document.addEventListener("touchend", function () {
if (dragging) {
dragging = false;
resetThumb();
}
});
document.querySelectorAll(".dpad-btn").forEach(function (button) {
function activate(event) {
if (controlsAreBlocked()) return;
var x = parseFloat(button.dataset.x);
var y = parseFloat(button.dataset.y);
setDpadPressed(button, x, y);
if (event) {
event.preventDefault();
}
}
function deactivate() {
clearDpad();
}
button.addEventListener("mousedown", activate);
button.addEventListener("mouseup", deactivate);
button.addEventListener("mouseleave", deactivate);
button.addEventListener("touchstart", activate, { passive: false });
button.addEventListener("touchend", deactivate);
});
});
</script>
</body>
</html>