Files
ExoMy_Cuno/ExoMy_Software-master/gui/index.html
T
2026-05-23 19:44:12 +02:00

1171 lines
40 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 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="11" width="18" height="10" rx="1" fill="#0088FF" opacity=".85"/>
<rect x="11" y="7" width="10" height="4" rx="1" fill="#0088FF" opacity=".5"/>
<circle cx="9" cy="23" r="3.5" fill="none" stroke="#0088FF" stroke-width="1.5"/>
<circle cx="23" cy="23" r="3.5" fill="none" stroke="#0088FF" stroke-width="1.5"/>
<circle cx="9" cy="23" r="1.3" fill="#0088FF"/>
<circle cx="23" cy="23" r="1.3" fill="#0088FF"/>
<rect x="22" y="8" width="4" height="3" rx=".5" fill="#FF6B00" opacity=".9"/>
<line x1="16" y1="7" x2="16" y2="3" stroke="#0088FF" stroke-width="1.5"/>
<circle cx="16" cy="2.5" r="1" fill="#0088FF"/>
<rect x="3" y="14" width="4" height="2" rx="1" fill="#0088FF" opacity=".6"/>
</svg>
<span class="hdr-title">EXO<em>MY</em>&nbsp;GROUND CTRL</span>
<div class="hdr-div"></div>
<div class="hdr-badge">Mission Ops</div>
<div class="hdr-gap"></div>
<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="mission-state">Bereit</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"><div class="c-rec-dot"></div>LIVE</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>
SAT: <span id="gps-sats">--</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">
<div class="c-bm-box">
<span class="c-bm-lbl">V</span>
<strong id="overlay-speed">0,0</strong>
<span class="c-bm-unit">cm/s</span>
</div>
<div class="c-bm-box">
<span class="c-bm-lbl">S</span>
<strong id="overlay-distance">0,00</strong>
<span class="c-bm-unit">m</span>
</div>
</div>
<svg class="c-track" id="track-overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<path id="track-center" class="track-center" d=""></path>
<path id="track-left" class="track-edge" d=""></path>
<path id="track-right" class="track-edge" d=""></path>
</svg>
<div class="c-xhair">
<svg viewBox="-90 -90 180 180" xmlns="http://www.w3.org/2000/svg" width="180" height="180">
<!-- äußerer rotierender Strichelring -->
<circle class="xh-rot" cx="0" cy="0" r="82" fill="none" stroke="rgba(0,255,145,.2)" stroke-width="1" stroke-dasharray="4 10"/>
<!-- mittlerer Ring, Gegenrichtung -->
<circle class="xh-rot-rev" cx="0" cy="0" r="60" fill="none" stroke="rgba(0,255,145,.15)" stroke-width=".75" stroke-dasharray="2 16"/>
<!-- innerer fester Ring -->
<circle cx="0" cy="0" r="16" fill="none" stroke="rgba(0,255,145,.3)" stroke-width=".75"/>
<!-- Kreuzlinien mit Lücke in der Mitte -->
<line x1="-86" y1="0" x2="-19" y2="0" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<line x1="19" y1="0" x2="86" y2="0" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<line x1="0" y1="-86" x2="0" y2="-19" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<line x1="0" y1="19" x2="0" y2="86" stroke="rgba(0,255,145,.6)" stroke-width="1"/>
<!-- Skalenstriche bei r=60 -->
<line x1="-60" y1="-6" x2="-60" y2="6" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<line x1="60" y1="-6" x2="60" y2="6" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<line x1="-6" y1="-60" x2="6" y2="-60" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<line x1="-6" y1="60" x2="6" y2="60" stroke="rgba(0,255,145,.45)" stroke-width=".75"/>
<!-- Skalenstriche bei r=38 -->
<line x1="-38" y1="-3.5" x2="-38" y2="3.5" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<line x1="38" y1="-3.5" x2="38" y2="3.5" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<line x1="-3.5" y1="-38" x2="3.5" y2="-38" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<line x1="-3.5" y1="38" x2="3.5" y2="38" stroke="rgba(0,255,145,.3)" stroke-width=".5"/>
<!-- Eckklammern -->
<path d="M-74,-58 L-74,-74 L-58,-74" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<path d="M58,-74 L74,-74 L74,-58" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<path d="M-74,58 L-74,74 L-58,74" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<path d="M58,74 L74,74 L74,58" fill="none" stroke="rgba(0,255,145,.75)" stroke-width="1.5"/>
<!-- pulsierender Ring -->
<circle class="xh-pulse" cx="0" cy="0" r="6" fill="none" stroke="rgba(0,255,145,.7)" stroke-width="1"/>
<!-- Mittelpunkt -->
<circle cx="0" cy="0" r="2.5" fill="rgba(0,255,145,.9)"/>
<circle cx="0" cy="0" r="1" fill="rgba(255,255,255,.7)"/>
</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 class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Telemetrie</span><div class="ss-hdr-line"></div></div>
<div class="sr sr-gauge gauge-cpu">
<span class="sr-key">CPU</span>
<div class="gauge"><div class="gauge-fill" id="bar-cpu"></div></div>
<span class="gauge-num" id="val-cpu">--</span>
</div>
<div class="sr sr-gauge gauge-temp">
<span class="sr-key">Temp</span>
<div class="gauge"><div class="gauge-fill" id="bar-temp"></div></div>
<span class="gauge-num" id="val-temp">--</span>
</div>
<div class="sr sr-gauge gauge-mem">
<span class="sr-key">RAM</span>
<div class="gauge"><div class="gauge-fill" id="bar-mem"></div></div>
<span class="gauge-num" id="val-mem">--</span>
</div>
<div class="ss-hdr"><div class="ss-hdr-line"></div><span class="ss-hdr-txt">Netzwerk</span><div class="ss-hdr-line"></div></div>
<div class="sr">
<span class="sr-led led-ok"></span>
<span class="sr-key">WLAN</span>
<span class="sr-val b" id="stat-wifi">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">IP</span>
<span class="sr-val b" id="stat-ips">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Uptime</span>
<span class="sr-val b" id="stat-uptime">--</span>
</div>
<div class="sr">
<span class="sr-led led-blue"></span>
<span class="sr-key">Disk</span>
<span class="sr-val b" id="stat-disk">--</span>
</div>
<div class="sr">
<span class="sr-led led-off"></span>
<span class="sr-key">Spannung</span>
<span class="sr-val b" id="stat-undervoltage">--</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 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 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 trajectoryOverlay = {
steering: 90,
velocity: 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 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() {
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 renderTrajectoryOverlay() {
var overlay = document.getElementById("track-overlay");
if (!overlay) return;
var moving = driveEstimator.motorsEnabled && Math.abs(driveEstimator.speedMps) > 0.005;
overlay.style.opacity = moving ? "1" : "0.22";
var baseX = 50;
var baseY = 92;
var laneHalf = 9;
var topY = 18;
var leftStart = baseX - laneHalf;
var rightStart = baseX + laneHalf;
if (!moving) {
setPath("track-center", "M " + baseX + " " + baseY + " L " + baseX + " " + topY);
setPath("track-left", "M " + leftStart + " " + baseY + " L " + leftStart + " " + topY);
setPath("track-right", "M " + rightStart + " " + baseY + " L " + rightStart + " " + topY);
return;
}
var mode = driveEstimator.locomotionMode;
var steering = Number(trajectoryOverlay.steering || 90);
var direction = driveEstimator.speedMps < -0.005 ? -1 : 1;
if (mode === 3) {
var crabOffset = Math.max(-24, Math.min(24, (steering >= 0 ? steering - 90 : steering + 90) * 0.26));
setPath("track-center", "M " + baseX + " " + baseY + " L " + (baseX + crabOffset) + " " + topY);
setPath("track-left", "M " + (baseX - laneHalf) + " " + baseY + " L " + (baseX - laneHalf + crabOffset) + " " + topY);
setPath("track-right", "M " + (baseX + laneHalf) + " " + baseY + " L " + (baseX + laneHalf + crabOffset) + " " + topY);
return;
}
if (mode === 2) {
var pointTurnRight = steering > 95 || steering < -95;
var sweep = pointTurnRight ? 1 : 0;
var radiusOuter = 34;
var radiusInner = 20;
var centerX = pointTurnRight ? 78 : 22;
var startLeftX = pointTurnRight ? 40 : 24;
var startRightX = pointTurnRight ? 58 : 42;
setPath("track-center", "M " + baseX + " " + baseY + " A 27 27 0 0 " + sweep + " " + centerX + " 42");
setPath("track-left", "M " + startLeftX + " " + baseY + " A " + radiusOuter + " " + radiusOuter + " 0 0 " + sweep + " " + centerX + " 18");
setPath("track-right", "M " + startRightX + " " + baseY + " A " + radiusInner + " " + radiusInner + " 0 0 " + sweep + " " + centerX + " 34");
return;
}
var normalizedTurn;
if (steering > 90) {
normalizedTurn = Math.min(1, (steering - 90) / 90);
} else {
normalizedTurn = -Math.min(1, (90 - steering) / 90);
}
normalizedTurn *= direction;
var endX = baseX + normalizedTurn * 26;
var controlY = 58;
var controlBias = normalizedTurn * 18;
var centerPath = "M " + baseX + " " + baseY + " Q " + (baseX + controlBias * 0.6) + " " + controlY + " " + endX + " " + topY;
var leftPath = "M " + leftStart + " " + baseY + " Q " + (leftStart + controlBias) + " " + controlY + " " + (endX - laneHalf) + " " + topY;
var rightPath = "M " + rightStart + " " + baseY + " Q " + (rightStart + controlBias) + " " + controlY + " " + (endX + laneHalf) + " " + topY;
if (Math.abs(normalizedTurn) < 0.04) {
centerPath = "M " + baseX + " " + baseY + " L " + baseX + " " + topY;
leftPath = "M " + leftStart + " " + baseY + " L " + leftStart + " " + topY;
rightPath = "M " + rightStart + " " + baseY + " L " + rightStart + " " + topY;
}
setPath("track-center", centerPath);
setPath("track-left", leftPath);
setPath("track-right", rightPath);
}
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 updateGpsOverlayFromFix(message) {
if (!message) return;
var hasFix = message.status && message.status.status >= 0;
if (!hasFix) {
setText("gps-lat", "--");
setText("gps-lon", "--");
return;
}
setText("gps-lat", formatNumber(Number(message.latitude || 0), 5));
setText("gps-lon", formatNumber(Number(message.longitude || 0), 5));
}
function updateGpsOverlayFromStatus(text) {
var value = String(text || "Offline");
setText("gps-state", value);
var satMatch = value.match(/(\d+)\s*Sat/i);
setText("gps-sats", satMatch ? satMatch[1] : "--");
}
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);
setText("mission-state", text === "Verbunden" ? "Nominal" : text);
}
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 || {};
setText("stat-wifi", system.wifi || "unbekannt");
setText("stat-ips", system.ips || "unbekannt");
updateUndervoltageDisplay(system.undervoltage || "unbekannt", system.undervoltage_state || "unknown");
setText("stat-uptime", system.uptime || "unbekannt");
setText("stat-disk", system.disk_free || "unbekannt");
setText("val-cpu", system.cpu_usage || "--");
setText("val-temp", system.cpu_temperature || "--");
setText("val-mem", system.memory_usage || "--");
document.getElementById("bar-cpu").style.width = percentTextToWidth(system.cpu_usage);
document.getElementById("bar-mem").style.width = percentTextToWidth(system.memory_usage);
document.getElementById("bar-temp").style.width = temperatureTextToWidth(system.cpu_temperature);
setText("cam-state", services.camera === "active" ? "Online" : services.camera || "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) {
updateServiceState(payload);
}).catch(function () {
setText("stat-wifi", "nicht erreichbar");
});
}
function publishJoy() {
if (!joyListener) {
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) {
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) {
applyDriveMode(mode);
pulseButton(buttonIndex);
}
function toggleMotors() {
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) {
var data = joyMetrics();
var dx = clientX - data.cx;
var dy = clientY - data.cy;
if (driveMode === "turn-on-point" || driveMode === "crab") {
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) {
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) || 1280;
var videoHeight = (video && video.naturalHeight) || 720;
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();
updateAxesDisplay(0, 0);
updateControlLocks();
updateClocks();
window.setInterval(updateClocks, 1000);
fetchSystemStatus();
fetchDriveEstimatorData();
renderDriveEstimatorOverlay();
renderTrajectoryOverlay();
statusTimer = window.setInterval(fetchSystemStatus, 5000);
window.setInterval(renderDriveEstimatorOverlay, 250);
window.setInterval(renderTrajectoryOverlay, 250);
var video = document.getElementById("video_output");
video.src = "//" + hostUrl + ":8083/stream.mjpg";
video.addEventListener("load", function () {
setText("cam-state", "Online");
updateCameraFrameLayout();
});
video.addEventListener("error", function () {
setText("cam-state", "Fehler");
});
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 () {
setRosStatus("Verbunden");
});
ros.on("error", function () {
setRosStatus("Fehler");
});
ros.on("close", function () {
setRosStatus("Getrennt");
});
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) {
applyDriveMode(locomotionModeToUi(message.locomotion_mode), true);
motorsEnabled = !!message.motors_enabled;
driveEstimator.motorsEnabled = !!message.motors_enabled;
driveEstimator.locomotionMode = Number(message.locomotion_mode);
trajectoryOverlay.steering = Number(message.steering || 90);
trajectoryOverlay.velocity = Number(message.vel || 0);
updateMotorDisplay();
renderDriveEstimatorOverlay();
renderTrajectoryOverlay();
});
motorCommandsListener = new ROSLIB.Topic({
ros: ros,
name: "/motor_commands",
messageType: "exomy/MotorCommands"
});
motorCommandsListener.subscribe(function (message) {
driveEstimator.motorSpeeds = Array.isArray(message.motor_speeds) ? message.motor_speeds.map(Number) : [0, 0, 0, 0, 0, 0];
renderDriveEstimatorOverlay();
renderTrajectoryOverlay();
});
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);
});
var joySubscriber = new ROSLIB.Topic({
ros: ros,
name: "/joy",
messageType: "sensor_msgs/Joy"
});
joySubscriber.subscribe(function (message) {
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("motor-toggle").addEventListener("click", toggleMotors);
document.getElementById("mt-joy").addEventListener("click", function () {
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 () {
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) {
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) {
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) {
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>