409 lines
12 KiB
JavaScript
Executable File
409 lines
12 KiB
JavaScript
Executable File
// client.js
|
||
// UI: Buttons, Anzeige von Result als JSON + Baum, Fallback für Commands
|
||
|
||
function appendLog(line) {
|
||
const el = document.getElementById("log");
|
||
if (!el) return;
|
||
|
||
const now = new Date().toISOString();
|
||
el.value += `[${now}] ${line}\n`;
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function clearTextarea(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = "";
|
||
}
|
||
|
||
function clearElement(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.innerHTML = "";
|
||
}
|
||
|
||
function formatScalar(value) {
|
||
if (value === null) return "null";
|
||
if (value === undefined) return "undefined";
|
||
if (typeof value === "string") return JSON.stringify(value);
|
||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||
if (typeof value === "boolean") return String(value);
|
||
return String(value);
|
||
}
|
||
|
||
function renderTree(container, value, key = "result", open = true) {
|
||
if (!container) return;
|
||
|
||
container.innerHTML = "";
|
||
container.appendChild(renderNode(key, value, open));
|
||
}
|
||
|
||
function renderNode(key, value, open = false) {
|
||
const isObject = value !== null && typeof value === "object";
|
||
|
||
if (!isObject) {
|
||
const leaf = document.createElement("div");
|
||
leaf.className = "tree-leaf";
|
||
leaf.textContent = `${key}: ${formatScalar(value)}`;
|
||
return leaf;
|
||
}
|
||
|
||
const details = document.createElement("details");
|
||
details.open = open;
|
||
|
||
const summary = document.createElement("summary");
|
||
summary.textContent = Array.isArray(value)
|
||
? `${key} [${value.length}]`
|
||
: key;
|
||
|
||
details.appendChild(summary);
|
||
|
||
const body = document.createElement("div");
|
||
body.style.marginLeft = "16px";
|
||
|
||
if (Array.isArray(value)) {
|
||
value.forEach((item, idx) => {
|
||
body.appendChild(renderNode(String(idx), item, false));
|
||
});
|
||
} else {
|
||
Object.entries(value).forEach(([childKey, childVal]) => {
|
||
body.appendChild(renderNode(childKey, childVal, false));
|
||
});
|
||
}
|
||
|
||
details.appendChild(body);
|
||
return details;
|
||
}
|
||
|
||
function renderResult(result) {
|
||
const jsonEl = document.getElementById("result-json");
|
||
const treeEl = document.getElementById("result-tree");
|
||
|
||
if (jsonEl) {
|
||
jsonEl.value = JSON.stringify(result, null, 2);
|
||
}
|
||
|
||
renderTree(treeEl, result, "result", true);
|
||
}
|
||
|
||
dataCache = null
|
||
headersCache = null
|
||
rowsCache = null
|
||
timeCache = null
|
||
jsonCache = null
|
||
|
||
async function fetchCSV(){
|
||
if(dataCache == null || headersCache == null || rowsCache == null || timeCache == null){
|
||
({ data: dataCache, headers: headersCache, rows: rowsCache } = await fetchCSV_fromServer());
|
||
timeCache = Date.now();
|
||
}
|
||
if (Date.now() - timeCache > 1000){
|
||
|
||
({ data: dataCache, headers: headersCache, rows: rowsCache } = await fetchCSV_fromServer());
|
||
timeCache = Date.now();
|
||
}
|
||
|
||
return {data: dataCache, headers: headersCache, rows: rowsCache}
|
||
|
||
}
|
||
|
||
async function fetchCSV_fromServer() {
|
||
|
||
const res = await fetch("/api/latest-snapshot");
|
||
if (!res.ok) throw new Error("Fehler beim Laden des Snapshots");
|
||
|
||
|
||
let data;
|
||
if (res.headers.get("content-type")?.includes("application/json")) {
|
||
data = await res.json();
|
||
} else {
|
||
const csvData = await res.text();
|
||
data = {
|
||
filename: "latest.csv",
|
||
mtime: new Date().toISOString(),
|
||
content: csvData
|
||
};
|
||
}
|
||
|
||
if (!jsonCache && data.jsonFile) {
|
||
jsonCache = JSON.parse(data.jsonFile.content);
|
||
}
|
||
|
||
const lines = data.content.trim().split(/\r?\n/).filter(Boolean);
|
||
if (lines.length < 2) {
|
||
throw new Error("Keine oder unvollständige Daten");
|
||
}
|
||
|
||
const headers = lines[0].split(",").map(h => h.trim());
|
||
|
||
const rows = lines.slice(1).map(line => {
|
||
const cells = line.split(",");
|
||
const obj = {};
|
||
headers.forEach((h, i) => {
|
||
const raw = (cells[i] ?? "").trim();
|
||
const numeric = Number(raw);
|
||
obj[h] = raw !== "" && Number.isFinite(numeric) ? numeric : raw;
|
||
});
|
||
return obj;
|
||
});
|
||
|
||
return { data, headers, rows };
|
||
}
|
||
|
||
async function fetchWebcamSnapshotData() {
|
||
const { data, headers, rows } = await fetchCSV();
|
||
return {
|
||
filename: data.filename,
|
||
mtime: data.mtime,
|
||
headers,
|
||
rows,
|
||
robotIntrinsics: jsonCache,
|
||
imageFile: data.imageFile,
|
||
image2: data.image2
|
||
};
|
||
}
|
||
|
||
async function sendToBodyTracker({imageFile, image2, robotIntrinsics}) {
|
||
const response = await fetch('/api/estimate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageFile, image2, robotIntrinsics })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const message = await response.text();
|
||
throw new Error(`BodyTracker fehlgeschlagen (${response.status}): ${message}`);
|
||
}
|
||
|
||
return await response.json();
|
||
}
|
||
|
||
async function renderSnapshot() {
|
||
const table = document.getElementById("snapshot-table");
|
||
const pictureEl = document.getElementById("snapshot-info-picture");
|
||
if (!table) return;
|
||
|
||
try {
|
||
const { data, headers, rows } = await fetchCSV();
|
||
|
||
// Info anzeigen
|
||
const infoEl = document.getElementById("snapshot-info");
|
||
if (infoEl) {
|
||
infoEl.textContent = `Datei: ${data.filename}, Geändert: ${new Date(data.mtime).toLocaleString()}, Zeilen: ${rows.length}`;
|
||
}
|
||
|
||
// Bild anzeigen, falls vorhanden
|
||
if (pictureEl) {
|
||
let imagesHtml = '';
|
||
if (data.imageFile) {
|
||
imagesHtml += `<img src="data:${data.imageFile.mimeType};base64,${data.imageFile.contentBase64}" alt="${data.imageFile.filename}" style="max-width: calc(50% - 2.5px); height: auto;">`;
|
||
}
|
||
if (data.image2) {
|
||
imagesHtml += `<img src="data:${data.image2.mimeType};base64,${data.image2.contentBase64}" alt="${data.image2.filename}" style="max-width: calc(50% - 2.5px); height: auto;">`;
|
||
}
|
||
if (imagesHtml) {
|
||
pictureEl.innerHTML = `<div style="display: flex; gap: 5px;">${imagesHtml}</div>`;
|
||
} else {
|
||
pictureEl.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
// Tabelle leeren
|
||
table.innerHTML = "";
|
||
|
||
// Header
|
||
const thead = document.createElement("thead");
|
||
const headerRow = document.createElement("tr");
|
||
headers.forEach(h => {
|
||
const th = document.createElement("th");
|
||
th.textContent = h;
|
||
headerRow.appendChild(th);
|
||
});
|
||
thead.appendChild(headerRow);
|
||
table.appendChild(thead);
|
||
|
||
// Body
|
||
const tbody = document.createElement("tbody");
|
||
rows.forEach(row => {
|
||
const tr = document.createElement("tr");
|
||
headers.forEach(h => {
|
||
const td = document.createElement("td");
|
||
let value = row[h];
|
||
if (typeof value === 'number') {
|
||
if (h === 'id' || h === 'seen_by') {
|
||
value = Math.round(value);
|
||
} else {
|
||
value = value.toFixed(1);
|
||
}
|
||
}
|
||
td.textContent = value;
|
||
tr.appendChild(td);
|
||
});
|
||
tbody.appendChild(tr);
|
||
});
|
||
table.appendChild(tbody);
|
||
} catch (err) {
|
||
console.error("Fehler beim Rendern des Snapshots:", err);
|
||
}
|
||
}
|
||
|
||
async function onCalculateClick() {
|
||
clearTextarea("analysis-log");
|
||
clearTextarea("result-json");
|
||
clearElement("result-tree");
|
||
clearElement("snapshot-table");
|
||
|
||
appendLog("Starte Berechnung...");
|
||
|
||
try {
|
||
const response = await fetch("robot.json");
|
||
const robot = await response.json();
|
||
const snapshot = await fetchWebcamSnapshotData();
|
||
|
||
appendLog(`Snapshot geladen: ${snapshot.filename} (${snapshot.rows.length} Zeilen)`);
|
||
|
||
let result = null;
|
||
try {
|
||
result = await sendToBodyTracker(snapshot);
|
||
appendLog("BodyTracker wurde aufgerufen.");
|
||
} catch (err) {
|
||
appendLog(`BodyTracker nicht aufgerufen: ${err.message}`);
|
||
}
|
||
|
||
if (!result) {
|
||
appendLog("Fallback: lokale Pose-Berechnung mit calculateAngles.js");
|
||
result = await window.calculate(snapshot.robotIntrinsics, robot);
|
||
}
|
||
|
||
renderResult(result);
|
||
await renderSnapshot();
|
||
appendLog("Result angezeigt.");
|
||
} catch (err) {
|
||
appendLog(`Fehler: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// ── Foto ─────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Kameraliste vom Backend holen und <select id="cam-select"> befüllen.
|
||
* Schlägt das Laden fehl, bleiben die hardkodierten Fallback-Optionen erhalten.
|
||
*/
|
||
async function loadCameras() {
|
||
const select = document.getElementById('cam-select');
|
||
if (!select) return;
|
||
try {
|
||
const res = await fetch('/api/webcam/cameras');
|
||
if (!res.ok) return;
|
||
const { cameras } = await res.json();
|
||
select.innerHTML = cameras
|
||
.map(c => `<option value="${c.id}">${c.id} (${c.position || c.name})</option>`)
|
||
.join('');
|
||
} catch {
|
||
// Fallback: hardkodierte Optionen aus index.html bleiben erhalten
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Foto-Button: holt ein HD-JPEG der gewählten Kamera und zeigt es an.
|
||
* Der Endpoint /api/webcam/snapshot/:id liefert das JPEG direkt (kein base64).
|
||
*/
|
||
async function onFotoClick() {
|
||
const camId = document.getElementById('cam-select')?.value || 'cam0';
|
||
const display = document.getElementById('foto-display');
|
||
if (!display) return;
|
||
|
||
appendLog(`Foto: ${camId} …`);
|
||
|
||
// Cache-Buster über Timestamp-Parameter, damit der Browser nicht aus dem Cache lädt
|
||
const url = `/api/webcam/snapshot/${camId}?t=${Date.now()}`;
|
||
|
||
const img = document.createElement('img');
|
||
img.style.maxWidth = '100%';
|
||
img.style.height = 'auto';
|
||
img.alt = camId;
|
||
img.onload = () => appendLog(`Foto ${camId}: OK (${img.naturalWidth}×${img.naturalHeight}px)`);
|
||
img.onerror = () => appendLog(`Foto ${camId}: Fehler beim Laden`);
|
||
img.src = url;
|
||
|
||
display.innerHTML = '';
|
||
display.appendChild(img);
|
||
}
|
||
|
||
async function onCommandClick(btn) {
|
||
const cmd = btn.dataset.cmd;
|
||
const payloadSelector = btn.dataset.payload;
|
||
const payload = payloadSelector
|
||
? document.querySelector(payloadSelector)?.value ?? ""
|
||
: "";
|
||
|
||
if (typeof window.sendCommand === "function") {
|
||
try {
|
||
await window.sendCommand(cmd, payload);
|
||
appendLog(`Command gesendet: ${cmd}${payload ? " " + payload : ""}`);
|
||
} catch (err) {
|
||
appendLog(`Command-Fehler: ${err.message}`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
appendLog(`Command (kein Transport definiert): ${cmd}${payload ? " " + payload : ""}`);
|
||
}
|
||
|
||
function setupUi() {
|
||
const calculateBtn = document.getElementById("btn-calculate");
|
||
if (calculateBtn) {
|
||
calculateBtn.addEventListener("click", onCalculateClick);
|
||
}
|
||
|
||
const fotoBtn = document.getElementById("btn-foto");
|
||
if (fotoBtn) {
|
||
fotoBtn.addEventListener("click", onFotoClick);
|
||
}
|
||
|
||
document.querySelectorAll("button[data-cmd]").forEach(btn => {
|
||
if (btn.id === "btn-calculate") return;
|
||
btn.addEventListener("click", () => onCommandClick(btn));
|
||
});
|
||
|
||
// Kameraliste vom Backend laden (befüllt #cam-select dynamisch)
|
||
loadCameras();
|
||
|
||
// ===== SECTION COLLAPSE/EXPAND =====
|
||
document.querySelectorAll(".section h2").forEach(heading => {
|
||
heading.addEventListener("click", () => {
|
||
const section = heading.closest(".section");
|
||
if (section) {
|
||
section.classList.toggle("collapsed");
|
||
}
|
||
});
|
||
heading.style.cursor = "pointer";
|
||
});
|
||
|
||
// Auto-expand when content is written to a collapsed section
|
||
const observer = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
if (mutation.type === "childList" || mutation.type === "characterData") {
|
||
let el = mutation.target;
|
||
while (el && el !== document.body) {
|
||
const section = el.closest(".section");
|
||
if (section && section.classList.contains("collapsed")) {
|
||
section.classList.remove("collapsed");
|
||
break;
|
||
}
|
||
el = el.parentElement;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Observer auf alle Elemente in Sections anwenden
|
||
document.querySelectorAll(".section").forEach(section => {
|
||
observer.observe(section, {
|
||
childList: true,
|
||
subtree: true,
|
||
characterData: true,
|
||
characterDataOldValue: false
|
||
});
|
||
});
|
||
}
|
||
|
||
window.addEventListener("DOMContentLoaded", setupUi); |