// 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 += `
`;
}
if (data.image2) {
imagesHtml += `
`;
}
if (imagesHtml) {
pictureEl.innerHTML = `
${imagesHtml}
`;
} 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 ─────────────────────────────────────────────────────────────────────
/**
* Foto-Button: holt HD-JPEGs aller 3 Kameras parallel
* und zeigt sie nebeneinander im Snapshot-Bereich an.
*/
async function onFotoClick() {
const display = document.getElementById('snapshot-info-picture');
if (!display) return;
const camIds = ['cam0', 'cam1', 'cam2'];
const labels = { cam0: 'front', cam1: 'left', cam2: 'right' };
appendLog('Foto: alle Kameras …');
const t = Date.now();
// Wrapper mit 3 Spalten
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display:flex; gap:6px; align-items:flex-start;';
camIds.forEach(id => {
const col = document.createElement('div');
col.style.cssText = 'flex:1 1 0; min-width:0;';
const caption = document.createElement('div');
caption.style.cssText = 'font-size:0.8em; margin-bottom:3px; color:#aaa;';
caption.textContent = `${id} (${labels[id]})`;
const img = document.createElement('img');
img.style.cssText = 'width:100%; height:auto; display:block;';
img.alt = id;
img.onload = () => appendLog(` ${id}: ${img.naturalWidth}×${img.naturalHeight}px`);
img.onerror = () => appendLog(` ${id}: Fehler`);
img.src = `/api/webcam/snapshot/${id}?t=${t}`;
col.appendChild(caption);
col.appendChild(img);
wrapper.appendChild(col);
});
display.innerHTML = '';
display.appendChild(wrapper);
}
// ── Homing ────────────────────────────────────────────────────────────────────
let _homingState = null;
function setHomingStatus(label, cls) {
const el = document.getElementById('homing-status');
if (!el) return;
el.textContent = label;
el.className = `status-badge ${cls}`;
}
function setHomingProgress(step, total, text) {
const wrap = document.getElementById('homing-progress');
const bar = document.getElementById('homing-progress-bar');
const txt = document.getElementById('homing-progress-text');
if (!wrap) return;
wrap.style.display = 'block';
if (bar) bar.style.width = (total > 0 ? Math.round(step / total * 100) : 0) + '%';
if (txt) txt.textContent = text || `Schritt ${step} / ${total}`;
}
function writePartialGCode(state) {
const axisMap = { x: 'X', y: 'Y', z: 'Z', a: 'A', b: 'B', c: 'C', e: 'E' };
const parts = [];
for (const [key, axis] of Object.entries(axisMap)) {
if (state[key] != null) parts.push(`${axis}${Number(state[key]).toFixed(2)}`);
}
if (!parts.length) return;
const el = document.getElementById('gcodePayload');
if (el) el.value = `G92 ${parts.join(' ')}`;
}
function showHomingResult(state) {
// Raw JSON
const jsonEl = document.getElementById('result-json');
if (jsonEl) jsonEl.value = JSON.stringify(state, null, 2);
// Tree View: Labels + Einheiten statt generischem renderTree
const treeEl = document.getElementById('result-tree');
if (treeEl) {
const LABELS = {
x_mm: 'x (Slider)', y_deg: 'y (Arm1)', z_deg: 'z (Ellbow)',
a_deg: 'a (Arm2)', b_deg: 'b (Hand)', c_deg: 'c (Palm)',
e_mm: 'e (Greifer)',
};
const UNITS = {
x_mm: 'mm', y_deg: '°', z_deg: '°',
a_deg: '°', b_deg: '°', c_deg: '°', e_mm: 'mm',
};
let html = '';
for (const [key, val] of Object.entries(state)) {
const label = LABELS[key] ?? key;
const unit = UNITS[key] ?? '';
const valStr = typeof val === 'number' ? val.toFixed(2) : String(val ?? '–');
html += `
${label}
${valStr} ${unit}
`;
}
treeEl.innerHTML = html || '–';
}
}
async function loadHomingImages(runDir) {
if (!runDir) return;
try {
const res = await fetch(`/api/homing/run-data?run=${encodeURIComponent(runDir)}`);
if (!res.ok) return;
const data = await res.json();
// ── Debug-Bilder ──────────────────────────────────────────────────────────
const display = document.getElementById('snapshot-info-picture');
if (display) {
const debugImages = (data.images ?? []).filter(img => /debug/i.test(img.filename));
let html = '';
for (const img of debugImages) {
html += `
${img.filename}
`;
}
html += '
';
display.innerHTML = html;
}
// ── Marker-CSV-Tabelle ────────────────────────────────────────────────────
if (data.csvContent) renderHomingCsv(data.csvContent, runDir);
} catch { /* nicht kritisch */ }
}
function renderHomingCsv(csvContent, runDir) {
const table = document.getElementById('snapshot-table');
const infoEl = document.getElementById('snapshot-info');
if (!table) return;
const lines = csvContent.trim().split(/\r?\n/).filter(Boolean);
if (lines.length < 2) return;
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 num = Number(raw);
obj[h] = raw !== '' && Number.isFinite(num) ? num : raw;
});
return obj;
});
if (infoEl) infoEl.textContent = `aruco_marker_poses.csv · ${runDir} · ${rows.length} Marker`;
table.innerHTML = '';
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);
const tbody = document.createElement('tbody');
rows.forEach(row => {
const tr = document.createElement('tr');
headers.forEach(h => {
const td = document.createElement('td');
let v = row[h];
if (typeof v === 'number') {
v = /^(marker_id|id|num_cameras|seen_by)$/.test(h) ? Math.round(v) : v.toFixed(1);
}
td.textContent = v ?? '';
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
}
async function runHoming() {
// UI zurücksetzen
clearTextarea('log');
clearTextarea('analysis-log');
clearTextarea('result-json');
clearElement('result-tree');
clearElement('snapshot-table');
clearElement('snapshot-info-picture');
const btnRun = document.getElementById('btn-homing-run');
const btnSend = document.getElementById('btn-homing-send');
_homingState = null;
if (btnRun) btnRun.disabled = true;
if (btnSend) {
btnSend.disabled = true;
btnSend.style.opacity = '.4';
btnSend.style.cursor = 'not-allowed';
}
setHomingStatus('● Läuft …', 'wip');
setHomingProgress(0, 6, 'Verbinde …');
try {
const response = await fetch('/api/homing/run', { method: 'POST' });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let evt;
try { evt = JSON.parse(line.slice(6)); } catch { continue; }
switch (evt.type) {
case 'log':
appendLog(evt.text ?? '');
break;
case 'step':
setHomingProgress(evt.step, evt.total, evt.text);
appendLog(`[${evt.step}/${evt.total}] ${evt.text || ''}`);
break;
case 'analysis': {
const el = document.getElementById('analysis-log');
if (el) {
el.value += `${evt.key}:\n${JSON.stringify(evt.value, null, 2)}\n\n`;
el.scrollTop = el.scrollHeight;
}
// Progressiver Skeleton-Update: accumulated_state nach jedem Gelenk
if (evt.key?.startsWith('state_') && evt.value) {
const frame = document.getElementById('board-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'homing-state', state: evt.value }, '*');
}
writePartialGCode(evt.value);
}
break;
}
case 'error':
appendLog(evt.text ?? '');
setHomingStatus('✗ Fehler', 'open');
break;
case 'done':
if (evt.state) {
_homingState = evt.state;
showHomingResult(evt.state);
if (btnSend) {
btnSend.disabled = false;
btnSend.style.opacity = '';
btnSend.style.cursor = '';
}
setHomingStatus('✓ Fertig', 'done');
setHomingProgress(6, 6, 'Homing abgeschlossen');
} else {
setHomingStatus('✗ Fehler', 'open');
setHomingProgress(6, 6, 'Fehler aufgetreten');
}
if (evt.runDir) await loadHomingImages(evt.runDir);
const frame = document.getElementById('board-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'load-homing-run', runDir: evt.runDir }, '*');
if (evt.state) {
frame.contentWindow.postMessage({ type: 'homing-state', state: evt.state }, '*');
}
}
break;
}
}
}
} catch (err) {
appendLog(`❌ Verbindungsfehler: ${err.message}`);
setHomingStatus('✗ Fehler', 'open');
} finally {
if (btnRun) btnRun.disabled = false;
}
}
async function sendHomingToRobot() {
if (!_homingState) return;
const btnSend = document.getElementById('btn-homing-send');
if (btnSend) btnSend.disabled = true;
try {
const res = await fetch('/api/homing/send-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: _homingState }),
});
const data = await res.json();
if (res.ok) {
appendLog('✅ State erfolgreich an Roboter gesendet');
setHomingStatus('✓ Gesendet', 'done');
} else {
appendLog(`❌ Fehler beim Senden: ${data.error ?? JSON.stringify(data)}`);
if (btnSend) btnSend.disabled = false;
}
} catch (err) {
appendLog(`❌ Netzwerkfehler: ${err.message}`);
if (btnSend) btnSend.disabled = false;
}
}
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() {
// Homing-Buttons
const homingRunBtn = document.getElementById('btn-homing-run');
if (homingRunBtn) homingRunBtn.addEventListener('click', runHoming);
const homingSendBtn = document.getElementById('btn-homing-send');
if (homingSendBtn) homingSendBtn.addEventListener('click', sendHomingToRobot);
// Ältere Buttons (Fallback / Debug)
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));
});
// ===== 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);