Style auf Driver anpassen

This commit is contained in:
ChK
2026-04-05 05:28:47 +02:00
parent 909b79b08d
commit 74ae3f2bf8
8 changed files with 1100 additions and 492 deletions

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
public/snapshots
# Builds & Coverage
coverage/
dist/

View File

@@ -374,6 +374,7 @@ async function calculate() {
const row242 = getRow(242, 3);
const row200 = getRow(200, 3);
const row204 = getRow(204, 3);
const row222 = getRow(222, 3);
const angleYCandidates = [];
@@ -516,10 +517,14 @@ async function calculate() {
}
// Ellbow / Zusatzschätzung
if (row226 || row229 || row198 || row243 || row242 || row200 || row204) {
if (row222 || row226 || row229 || row198 || row243 || row242 || row200 || row204) {
let x226 = 0;
let xCount = 0;
if(row222){
x226 += row222.x_mm * 5;
xCount += 5;
}
if (row226) {
x226 += row226.x_mm * 5;
xCount += 5;

View File

@@ -4,132 +4,85 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>appRobotHoming</title>
<link rel="stylesheet" href="/styles.css" />
<style>
.result-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel textarea,
.panel pre {
width: 100%;
min-height: 240px;
box-sizing: border-box;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1220;
color: #e2e8f0;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: auto;
}
#result-tree {
min-height: 240px;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1220;
color: #e2e8f0;
padding: 12px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
#result-tree details {
margin-left: 14px;
}
#result-tree summary {
cursor: pointer;
user-select: none;
color: #93c5fd;
}
#result-tree .tree-leaf {
margin-left: 18px;
white-space: pre-wrap;
}
#result-tree .tree-kv {
white-space: pre-wrap;
}
@media (max-width: 1000px) {
.result-layout {
grid-template-columns: 1fr;
}
}
</style>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
<h1>appRobotHoming</h1>
<div id="status">Status: <span class="badge" id="conn"></span></div>
</header>
<main>
<section class="controls">
<button data-cmd="HOME">HOME</button>
<button data-cmd="STOP">STOP</button>
<button data-cmd="STATUS">STATUS</button>
<button data-cmd="RESET">RESET</button>
<button data-cmd="PING">PING</button>
<button id="btn-calculate">Calculate Actions</button>
<!-- Optionaler Titel (bewusst neutral, leicht ausblendbar) -->
<!--<h1 class="app-title">appRobotHoming</h1>-->
<input
id="gcodePayload"
type="text"
placeholder="G-Code / Motorbefehl"
style="width: 220px; padding: 10px; border-radius: 8px; border: 1px solid #334155; background: #0b1220; color: #e2e8f0;"
/>
<button data-cmd="GCODEMOTOR" data-payload="#gcodePayload">GCodeMotor</button>
</section>
<div class="sections">
<section class="log">
<label for="log">Ausgabe</label>
<textarea id="log" readonly></textarea>
</section>
<!-- ACTIONS -->
<div class="section full">
<h2>Aktionen</h2>
<section class="analysis">
<label for="analysis-log">Analysis & Reasoning</label>
<textarea id="analysis-log" readonly></textarea>
</section>
<div class="controls">
<button data-cmd="HOME">HOME</button>
<button data-cmd="STOP">STOP</button>
<button data-cmd="STATUS">STATUS</button>
<button data-cmd="RESET">RESET</button>
<button data-cmd="PING">PING</button>
<section class="result">
<label>Berechnetes Result</label>
<div class="result-layout">
<div class="panel">
<label for="result-json">Raw JSON</label>
<textarea id="result-json" readonly></textarea>
</div>
<div class="panel">
<label>Tree View</label>
<div id="result-tree"></div>
</div>
<input
id="gcodePayload"
type="text"
placeholder="G-Code / Motorbefehl"
/>
<button data-cmd="GCODEMOTOR" data-payload="#gcodePayload">
GCodeMotor
</button>
<button id="btn-calculate">Calculate Actions</button>
</div>
</section>
</div>
<section class="snapshot">
<label for="snapshot-content">Neuester Snapshot</label>
<!-- AUSGABE / LOG (default collapsed) -->
<div class="section full">
<h2>Ausgabe</h2>
<textarea id="log" readonly></textarea>
</div>
<!-- ANALYSE -->
<div class="section full">
<h2>Analysis &amp; Reasoning</h2>
<textarea id="analysis-log" readonly></textarea>
</div>
<!-- RESULT RAW JSON -->
<div class="section half">
<h2>Result Raw JSON</h2>
<div class="panel">
<label>Raw JSON</label>
<textarea id="result-json" readonly></textarea>
</div>
</div>
<!-- RESULT TREE VIEW -->
<div class="section half">
<h2>Result Tree View</h2>
<!-- bewusst alte Struktur beibehalten -->
<div class="panel">
<label>Tree View</label>
<div id="result-tree"></div>
</div>
</div>
<!-- SNAPSHOT (vorbereitet, aber leer) -->
<div class="section full">
<h2>Neuester Snapshot</h2>
<div id="snapshot-info"></div>
<table id="snapshot-table"></table>
</section>
</main>
</div>
<footer>
<small>HTTPS + WSS Relay • ©</small>
</footer>
</div>
<script src="/calculateActions.js"></script>
<script src="/client.js"></script>
</body>
</html>

View File

@@ -1,262 +1,655 @@
// calculateActions.js
// Funktionen zum Berechnen von Vorschlägen basierend auf den neuesten CSV-Daten
// Berechnung + nachvollziehbare Result-Struktur + Live-Logs in analysis-log
const analysisLogEl = document.getElementById('analysis-log');
function getAnalysisLogEl() {
if (typeof document === "undefined") return null;
return document.getElementById("analysis-log");
}
function appendToAnalysis(line) {
const el = getAnalysisLogEl();
if (!el) return;
const now = new Date().toISOString();
analysisLogEl.value += `[${now}] ${line}\n`;
analysisLogEl.scrollTop = analysisLogEl.scrollHeight;
el.value += `[${now}] ${line}\n`;
el.scrollTop = el.scrollHeight;
}
function createAnalysisResult(meta = {}) {
return {
meta: {
timestamp: new Date().toISOString(),
...meta
},
inputs: {
source: null,
headers: [],
rowCount: 0
},
observations: [],
calculations: [],
features: {},
commands: [],
logs: [],
errors: [],
summary: {
observationCount: 0,
calculationCount: 0,
featureCount: 0,
commandCount: 0
},
status: "ok"
};
}
function rowSnapshot(row) {
return {
id: row?.id,
seen_by: row?.seen_by,
x_mm: row?.x_mm,
y_mm: row?.y_mm,
z_mm: row?.z_mm,
roll_deg: row?.roll_deg,
pitch_deg: row?.pitch_deg
};
}
function addLog(result, message, level = "info") {
const entry = {
timestamp: new Date().toISOString(),
level,
message
};
result.logs.push(entry);
appendToAnalysis(message);
}
function addError(result, message, error = null) {
result.status = "error";
result.errors.push({
timestamp: new Date().toISOString(),
message,
details: error ? String(error) : null
});
addLog(result, `Fehler: ${message}`, "error");
}
function addObservation(result, key, row, confidence = 1.0, notes = []) {
result.observations.push({
key,
source: {
rowId: row?.id,
seenBy: row?.seen_by
},
values: rowSnapshot(row),
confidence,
notes
});
}
function addCalculation(result, calc) {
result.calculations.push({
timestamp: new Date().toISOString(),
...calc
});
}
function addFeature(result, key, feature) {
result.features[key] = feature;
}
function addCommand(result, id, command, basedOn = [], confidence = 1.0, format = "gcode") {
result.commands.push({
id,
command,
basedOn,
confidence,
format
});
}
function weightedAverage(candidates) {
const weightSum = candidates.reduce((sum, c) => sum + c.weight, 0);
if (!weightSum) return null;
const valueRad = candidates.reduce((sum, c) => sum + (c.valueRad * c.weight), 0) / weightSum;
const spreadRad = candidates.length > 1
? Math.sqrt(
candidates.reduce(
(sum, c) => sum + c.weight * Math.pow(c.valueRad - valueRad, 2),
0
) / weightSum
)
: 0;
const confidence = Math.max(
0.2,
Math.min(
0.98,
0.55 +
Math.min(candidates.length * 0.1, 0.25) -
Math.min(spreadRad / 1.0, 0.2)
)
);
return { valueRad, weightSum, spreadRad, confidence };
}
async function fetchCSV() {
console.log('Lade und verarbeite CSV-Daten...');
const res = await fetch('/api/latest-snapshot');
if (!res.ok) throw new Error('Fehler beim Laden des Snapshots');
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 };
}
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
};
}
// CSV parsen
const lines = data.content.trim().split('\n');
if (lines.length < 2) {
throw new Error('Keine oder unvollständige Daten');
}
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(',');
let obj = {};
headers.forEach((h, i) => {
const val = cells[i]?.trim();
obj[h] = isNaN(val) ? val : parseFloat(val);
});
return obj;
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;
});
appendToAnalysis(`CSV-Daten geladen: ${rows.length} Zeilen, ${headers.length} Spalten.`);
return { data, headers, rows };
return obj;
});
return { data, headers, rows };
}
async function readValues( data, headers, rows ){
console.log('Geladene Daten:', data);
console.log('Headers:', headers);
console.log('Parsed rows:', rows);
function calculateAngleFromPosition(result, row, axisY, axisZ, deltaYangle0, deltaZangle0 = 0, label = "angle") {
const y = parseFloat(row.y_mm);
const z = parseFloat(row.z_mm);
}
const dy = -(y - axisY);
const dz = z - axisZ;
function calculateAngleFromPosition(row, axisY, axisZ, deltaYangle0, deltaZangle0 = 0) {
let y = parseFloat(row.y_mm);
let z = parseFloat(row.z_mm);
let angle0Rad = 0;
if (deltaZangle0 !== 0 && deltaYangle0 !== 0) {
angle0Rad = Math.atan(deltaZangle0 / deltaYangle0);
}
let dy = -(y - axisY);
let dz = z - axisZ;
const angleRad = Math.atan2(dz, dy) - angle0Rad;
const angleDeg = angleRad * 180 / Math.PI;
let angle0Rad = 0;
if(deltaZangle0 !== 0){
angle0Rad = Math.atan(deltaZangle0/deltaYangle0);
const message = `(${label} = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad) aus Position von ID = ${row.id}`;
addLog(result, message);
addCalculation(result, {
type: "angleFromPosition",
label,
source: {
rowId: row.id,
seenBy: row.seen_by
},
input: {
axisY,
axisZ,
deltaYangle0,
deltaZangle0,
y_mm: y,
z_mm: z,
dy,
dz,
angle0Rad
},
output: {
angleRad,
angleDeg
}
});
angleRad = Math.atan(dz/dy) - angle0Rad;
angleDeg = angleRad * (180 / Math.PI);
appendToAnalysis(`(yMotor = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad ) aus Position von ID = ${row.id}`);
return angleRad;
return angleRad;
}
function calculateAngleFromRollColumn(row, roll0 = 0, pitch0 = 0, yaw0 = 0, strMotor = "yMotor") {
let roll = -parseFloat(row.roll_deg) + roll0;
appendToAnalysis(`(${strMotor} = ${roll.toFixed(2)}° = ${(roll * Math.PI / 180).toFixed(4)} rad) aus roll_deg von ID = ${row.id}`);
return roll*Math.PI / 180;
function calculateAngleFromRollColumn(result, row, roll0 = 0, pitch0 = 0, yaw0 = 0, strMotor = "motor") {
const rollDeg = -parseFloat(row.roll_deg) + roll0;
const rollRad = rollDeg * Math.PI / 180;
const message = `(${strMotor} = ${rollDeg.toFixed(2)}° = ${rollRad.toFixed(4)} rad) aus roll_deg von ID = ${row.id}`;
addLog(result, message);
addCalculation(result, {
type: "angleFromRollColumn",
label: strMotor,
source: {
rowId: row.id,
seenBy: row.seen_by
},
input: {
roll0,
pitch0,
yaw0,
roll_deg: row.roll_deg
},
output: {
angleRad: rollRad,
angleDeg: rollDeg
}
});
return rollRad;
}
function calculateAngleFromRelativePosition(row1, row2, strMotor = "zMotor") {
let y1 = parseFloat(row1.y_mm);
let z1 = parseFloat(row1.z_mm);
let y2 = parseFloat(row2.y_mm);
let z2 = parseFloat(row2.z_mm);
let dy = y1 - y2;
let dz = z1 - z2;
let angleRad = -Math.atan(dz/dy);
let angleDeg = angleRad * (180 / Math.PI);
appendToAnalysis(`(${strMotor} = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad) aus relativer Position von ID = ${row1.id} und ID = ${row2.id}`);
return angleRad;
function calculateAngleFromRelativePosition(result, row1, row2, strMotor = "motor") {
const y1 = parseFloat(row1.y_mm);
const z1 = parseFloat(row1.z_mm);
const y2 = parseFloat(row2.y_mm);
const z2 = parseFloat(row2.z_mm);
const dy = y1 - y2;
const dz = z1 - z2;
const angleRad = -Math.atan2(dz, dy);
const angleDeg = angleRad * 180 / Math.PI;
const message = `(${strMotor} = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad) aus relativer Position von ID = ${row1.id} und ID = ${row2.id}`;
addLog(result, message);
addCalculation(result, {
type: "angleFromRelativePosition",
label: strMotor,
source: {
rowIds: [row1.id, row2.id],
seenBy: [row1.seen_by, row2.seen_by]
},
input: {
y1_mm: y1,
z1_mm: z1,
y2_mm: y2,
z2_mm: z2,
dy,
dz
},
output: {
angleRad,
angleDeg
}
});
return angleRad;
}
function buildFeatureFromCandidates(result, key, title, method, candidates) {
const summary = weightedAverage(candidates);
if (!summary) return null;
const feature = {
title,
method,
valueRad: summary.valueRad,
valueDeg: summary.valueRad * 180 / Math.PI,
confidence: summary.confidence,
spreadRad: summary.spreadRad,
weightSum: summary.weightSum,
evidence: candidates.map(c => c.source),
parts: candidates
};
addFeature(result, key, feature);
addCalculation(result, {
type: "featureSummary",
key,
title,
method,
input: candidates,
output: {
valueRad: feature.valueRad,
valueDeg: feature.valueDeg,
confidence: feature.confidence,
spreadRad: feature.spreadRad,
weightSum: feature.weightSum
}
});
return feature;
}
async function calculate() {
let shoulderAxisY = 115;
let shoulderAxisZ = 61;
const result = createAnalysisResult({
sourceScript: "calculateActions.js"
});
let rows = null;
let headers = null;
try {
addLog(result, "Starte Berechnung...");
try {
appendToAnalysis('Starte Berechnung...');
const result = await fetchCSV();
rows = result.rows;
headers = result.headers;
const data = result.data;
await readValues( data, headers, rows );
} catch (err) {
appendToAnalysis('Fehler in calculate: ' + err.message);
}
const { data, headers, rows } = await fetchCSV();
// Oberarm:
var angleY = 0;
var angleZ = 0;
var angleYcount = 0;
var angleZcount = 0;
// 243 damit 35mm weiter außen (250+35) und 0mm höher als Schulterachse
const row243 = rows.find(r => r.id == 243 && r.seen_by == 3)
if(row243){
angleY += await calculateAngleFromPosition(row243, shoulderAxisY, shoulderAxisZ, 250+35, 0);
angleY += await calculateAngleFromRollColumn(row243, 90, 0, 0);
angleYcount+=2;
}
const row229 = rows.find(r => r.id == 229 && r.seen_by == 3)
if(row229){
angleY += await calculateAngleFromPosition(row229, shoulderAxisY, shoulderAxisZ, 250, 35);
//angleY +=calculateAngleFromRollColumn(row229, 0, 0, 0); // Roll ist extrem unzuverlässig
angleYcount+=1;
}
console.log(angleY, angleYcount);
const row198 = rows.find(r => r.id == 198 && r.seen_by == 3)
if(row198){
angleY += await calculateAngleFromPosition(row198, shoulderAxisY, shoulderAxisZ, 165, 35);
//angleY +=calculateAngleFromRollColumn(row198, 180, 0, 0); // ist ungenau
angleYcount++;
}
if(row198 && row229){
angleY += 3*(calculateAngleFromRelativePosition(row198, row229, "yMotor"));
angleYcount += 3;
}
const row197 = rows.find(r => r.id == 197)
if(row197){
var angleDeg = 90 - row197.pitch_deg;
var angleRad = angleDeg * Math.PI / 180;
appendToAnalysis(`(yMotor = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad ) aus Pitch von ${row197.id}`);
}
// Unterarm:
// 218 und 219, wenn die sichtbar sind, ist auch die Schulter eindeutig definiert
const row218 = rows.find(r => r.id == 218 && r.seen_by == 3)
const row219 = rows.find(r => r.id == 219 && r.seen_by == 3)
if(row218 && row219){
const lowerArmAngle = calculateAngleFromRelativePosition(row218, row219, "zMotor");
angleZ += lowerArmAngle;
angleZcount++;
}
console.log("z", angleZ);
const row226 = rows.find(r => r.id == 226 && r.seen_by == 3)
if(row226){
angleZ += calculateAngleFromRollColumn(row226, 0, 0, 0, "zMotor");
angleZcount++;
}
console.log("z", angleZ);
if(angleYcount > 0 && angleYcount > 0){
strActionOptionA = `G92 y${(angleY*180/(angleYcount*Math.PI)).toFixed(1)} z${(angleZ*180/(Math.PI*angleZcount)).toFixed(1)} (Set Coord. Angles in deg)`;
strActionOptionB = `M92 y${(angleY/(angleYcount)).toFixed(3)} z${(angleZ/(angleZcount)).toFixed(3)} (Set Coord. Angles in rad)`;
appendToAnalysis(`Suggestion: ${strActionOptionA}`);
appendToAnalysis(`Suggestion: ${strActionOptionB}`);
// ToDo: Change
}
// Ellbow
const row242 = rows.find(r => r.id == 242 && r.seen_by == 3)
const row200 = rows.find(r => r.id == 200 && r.seen_by == 3)
const row204 = rows.find(r => r.id == 204 && r.seen_by == 3)
// Ellenbogen-Rotation aus x-Position 218 219
// dazu brauche ich genaue x-Position von 226
if(row226 || row229 || row198 || row243 || row242 || row200 || row200){
var x226 = 0;
var xCount = 0;
if(row226){
x226 += row226.x_mm * 5;
xCount += 5;
}
if(row229){
x226 += row229.x_mm + 90;
xCount += 1;
}
if(row198){
x226 += row198.x_mm + 90;
xCount += 1;
}
if(row243){
x226 += row243.x_mm + 90;
xCount += 1;
}
if(row242){
x226 += row242.x_mm + 90;
xCount += 1;
}
if(row200){
x226 += row200.x_mm + 154;
xCount += 1;
}
if(row204){
x226 += row204.x_mm + 160;
xCount += 1;
}
x226 = x226 / xCount;
appendToAnalysis(`Ellebogen x226=${x226}`)
// Wenn 218 und/oder 219 sicher gesehen wird, kann ich daraus x-pos bestimmen
if(row218 || row219){
var x219 = 0;
xCount = 0;
if(row218){
x219 += row218.x_mm;
xCount += 1;
}
if(row219){
x219 += row219.x_mm;
xCount += 1;
}
x219 = x219 / xCount;
appendToAnalysis(`Ellebogen x219=${x219}`)
var xDelta = x219 - x226;
if(Math.abs(xDelta) < 35){
appendToAnalysis(`Ellebogen xDelta / 35=${xDelta / 35}`)
var angleRad = Math.asin(xDelta / 35)
var angleDeg = 90 -angleRad*180/Math.PI;
appendToAnalysis(`(xEllbow = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad ) aus x von 218 bzw. 219`);
}
}
}
}
if (typeof module !== 'undefined') {
module.exports = {
calculateAngleFromPosition,
calculateAngleFromRollColumn,
calculateAngleFromRelativePosition,
calculate
result.inputs = {
source: {
filename: data.filename,
mtime: data.mtime
},
headers,
rowCount: rows.length
};
addLog(result, `CSV-Daten geladen: ${rows.length} Zeilen, ${headers.length} Spalten.`);
const getRow = (id, seenBy = null) => {
const row = rows.find(r => r.id == id && (seenBy === null || r.seen_by == seenBy));
if (row) {
addObservation(
result,
`row-${id}${seenBy !== null ? `-seenBy-${seenBy}` : ""}`,
row,
seenBy === 3 ? 0.95 : 0.75,
[
`id=${id}`,
seenBy !== null ? `seen_by=${seenBy}` : "seen_by=any"
]
);
} else {
addLog(result, `Zeile nicht gefunden: id=${id}${seenBy !== null ? `, seen_by=${seenBy}` : ""}`, "warn");
}
return row;
};
const shoulderAxisY = 115;
const shoulderAxisZ = 61;
// Oberarm / shoulder
const row243 = getRow(243, 3);
const row229 = getRow(229, 3);
const row198 = getRow(198, 3);
const row197 = getRow(197);
const row218 = getRow(218, 3);
const row219 = getRow(219, 3);
const row226 = getRow(226, 3);
const row242 = getRow(242, 3);
const row200 = getRow(200, 3);
const row204 = getRow(204, 3);
const row222 = getRow(222, 3);
const angleYCandidates = [];
if (row243) {
const a1 = calculateAngleFromPosition(result, row243, shoulderAxisY, shoulderAxisZ, 285, 0, "yMotor");
const a2 = calculateAngleFromRollColumn(result, row243, 90, 0, 0, "yMotor");
angleYCandidates.push({
source: "row243.position",
valueRad: a1,
weight: 1
});
angleYCandidates.push({
source: "row243.roll_deg",
valueRad: a2,
weight: 1
});
}
if (row229) {
const a = calculateAngleFromPosition(result, row229, shoulderAxisY, shoulderAxisZ, 250, 35, "yMotor");
angleYCandidates.push({
source: "row229.position",
valueRad: a,
weight: 1
});
}
if (row198) {
const a = calculateAngleFromPosition(result, row198, shoulderAxisY, shoulderAxisZ, 165, 35, "yMotor");
angleYCandidates.push({
source: "row198.position",
valueRad: a,
weight: 1
});
}
if (row198 && row229) {
const a = calculateAngleFromRelativePosition(result, row198, row229, "yMotor");
angleYCandidates.push({
source: "row198-row229.relative",
valueRad: a,
weight: 3
});
}
if (row197) {
const angleDeg = 90 - parseFloat(row197.pitch_deg);
const angleRad = angleDeg * Math.PI / 180;
addLog(result, `(yMotor = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad ) aus Pitch von ${row197.id}`);
addCalculation(result, {
type: "angleFromPitch",
label: "yMotor",
source: {
rowId: row197.id,
seenBy: row197.seen_by
},
input: {
pitch_deg: row197.pitch_deg
},
output: {
angleRad,
angleDeg
}
});
angleYCandidates.push({
source: "row197.pitch_deg",
valueRad: angleRad,
weight: 1
});
}
if (angleYCandidates.length > 0) {
buildFeatureFromCandidates(
result,
"shoulder.angleY",
"Schulter Y",
"weighted-average",
angleYCandidates
);
}
// Unterarm / z
const angleZCandidates = [];
if (row218 && row219) {
const lowerArmAngle = calculateAngleFromRelativePosition(result, row218, row219, "zMotor");
angleZCandidates.push({
source: "row218-row219.relative",
valueRad: lowerArmAngle,
weight: 200 // Hohes gewicht, da ich sicher bin, dass es korrekt ist
});
}
if (row226) {
const a = calculateAngleFromRollColumn(result, row226, 0, 0, 0, "zMotor");
angleZCandidates.push({
source: "row226.roll_deg",
valueRad: a,
weight: 1
});
}
if (angleZCandidates.length > 0) {
buildFeatureFromCandidates(
result,
"forearm.angleZ",
"Unterarm Z",
"combined",
angleZCandidates
);
}
// Commands
const angleY = result.features["shoulder.angleY"];
const angleZ = result.features["forearm.angleZ"];
if (angleY && angleZ) {
const cmdConfidence = Math.min(angleY.confidence, angleZ.confidence);
addCommand(
result,
"set-coord-angles-deg",
`G92 y${angleY.valueDeg.toFixed(1)} z${angleZ.valueDeg.toFixed(1)} (Set Coord. Angles in deg)`,
["shoulder.angleY", "forearm.angleZ"],
cmdConfidence
);
addCommand(
result,
"set-coord-angles-rad",
`M92 y${angleY.valueRad.toFixed(3)} z${angleZ.valueRad.toFixed(3)} (Set Coord. Angles in rad)`,
["shoulder.angleY", "forearm.angleZ"],
cmdConfidence
);
}
// Ellbow / Zusatzschätzung
if (row222 || row226 || row229 || row198 || row243 || row242 || row200 || row204) {
let x226 = 0;
let xCount = 0;
if(row222){
x226 += row222.x_mm * 5;
xCount += 5;
}
if (row226) {
x226 += row226.x_mm * 5;
xCount += 5;
}
if (row229) {
x226 += row229.x_mm + 90;
xCount += 1;
}
if (row198) {
x226 += row198.x_mm + 90;
xCount += 1;
}
if (row243) {
x226 += row243.x_mm + 90;
xCount += 1;
}
if (row242) {
x226 += row242.x_mm + 90;
xCount += 1;
}
if (row200) {
x226 += row200.x_mm + 154;
xCount += 1;
}
if (row204) {
x226 += row204.x_mm + 160;
xCount += 1;
}
if (xCount > 0) {
x226 = x226 / xCount;
addLog(result, `Ellbogen x226=${x226}`);
addCalculation(result, {
type: "elbowXEstimate",
source: {
rowIds: [row226, row229, row198, row243, row242, row200, row204].filter(Boolean).map(r => r.id)
},
output: {
x226
}
});
}
if (row218 || row219) {
let x219 = 0;
let x219Count = 0;
if (row218) {
x219 += row218.x_mm;
x219Count += 1;
}
if (row219) {
x219 += row219.x_mm;
x219Count += 1;
}
if (x219Count > 0) {
x219 = x219 / x219Count;
addLog(result, `Ellbogen x219=${x219}`);
const xDelta = x219 - x226;
addCalculation(result, {
type: "elbowXComparison",
input: {
x226,
x219,
xDelta
}
});
if (Math.abs(xDelta) < 35) {
addLog(result, `Ellbogen xDelta / 35=${xDelta / 35}`);
const angleRad = Math.asin(xDelta / 35);
const angleDeg = 90 - angleRad * 180 / Math.PI;
addLog(result, `(xEllbow = ${angleDeg.toFixed(2)}° = ${angleRad.toFixed(4)} rad ) aus x von 218 bzw. 219`);
addCalculation(result, {
type: "elbowAngleFromX",
input: {
x226,
x219,
xDelta
},
output: {
angleRad,
angleDeg
}
});
}
}
}
}
result.summary = {
observationCount: result.observations.length,
calculationCount: result.calculations.length,
featureCount: Object.keys(result.features).length,
commandCount: result.commands.length
};
addLog(
result,
`Berechnung fertig: ${result.summary.featureCount} Features, ${result.summary.commandCount} Commands.`
);
return result;
} catch (err) {
addError(result, err.message, err);
return result;
}
}
if (typeof window !== "undefined") {
window.calculate = calculate;
window.createAnalysisResult = createAnalysisResult;
}
// Export für Module, falls benötigt
// export { fetchCSV, calculate };
if (typeof module !== "undefined") {
module.exports = {
calculate,
createAnalysisResult,
calculateAngleFromPosition,
calculateAngleFromRollColumn,
calculateAngleFromRelativePosition
};
}

View File

@@ -1,136 +1,135 @@
(function(){
const logEl = document.getElementById('log');
const connEl = document.getElementById('conn');
// client.js
// UI: Buttons, Anzeige von Result als JSON + Baum, Fallback für Commands
function append(line){
const now = new Date().toISOString();
logEl.value += `[${now}] ${line}
`;
logEl.scrollTop = logEl.scrollHeight;
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;
}
async function refreshStatus(){
try{
const res = await fetch('/api/status');
const st = await res.json();
if (st.connected){ connEl.textContent = 'verbunden'; connEl.className = 'badge ok'; }
else if (st.lastError){ connEl.textContent = 'fehler'; connEl.className = 'badge err'; }
else { connEl.textContent = 'getrennt'; connEl.className = 'badge warn'; }
}catch(e){ connEl.textContent = 'unbekannt'; connEl.className = 'badge'; }
}
const details = document.createElement("details");
details.open = open;
function processDataShortenPosition(data){
if(data?.text){
try{
let obj = JSON.parse(data.text);
if(obj?.position){
obj.position.x = parseFloat(obj.position.x.toFixed(3));
obj.position.y = parseFloat(obj.position.y.toFixed(3));
obj.position.z = parseFloat(obj.position.z.toFixed(3));
obj.position.a = parseFloat(obj.position.a.toFixed(3));
obj.position.b = parseFloat(obj.position.b.toFixed(3));
obj.position.c = parseFloat(obj.position.c.toFixed(3));
}
if(obj?.motorCounts){
obj.motorCounts.x = parseFloat(obj.motorCounts.x.toFixed(3));
obj.motorCounts.y = parseFloat(obj.motorCounts.y.toFixed(3));
obj.motorCounts.z = parseFloat(obj.motorCounts.z.toFixed(3));
obj.motorCounts.a = parseFloat(obj.motorCounts.a.toFixed(3));
obj.motorCounts.b = parseFloat(obj.motorCounts.b.toFixed(3));
obj.motorCounts.c = parseFloat(obj.motorCounts.c.toFixed(3));
if(obj.motorCounts.e !== undefined) obj.motorCounts.e = parseFloat(obj.motorCounts.e.toFixed(3));
}
return "text: " + JSON.stringify(obj);
}catch(e){
return "text: " + data.text;
}
}
return "";
}
const summary = document.createElement("summary");
summary.textContent = Array.isArray(value)
? `${key} [${value.length}]`
: key;
function connectSSE(){
const es = new EventSource('/api/events');
es.onmessage = (ev)=>{
try{
const p = JSON.parse(ev.data);
if (p.level === 'msg' && p.data?.text !== 'Ping') append(`WSS → ${processDataShortenPosition(p.data)}`);
//if (p.level === 'msg') append(`WSS → ${processDataShortenPosition(p.data)}`);
else if (p.level === 'tx') append(`TX → ${JSON.stringify(p.data)}`);
else append(`${p.level?.toUpperCase?.()}: ${p.message}`);
}catch{ append(ev.data); }
};
es.onerror = ()=>{
append('SSE Fehler/unterbrochen. Versuche neu zu verbinden…');
setTimeout(connectSSE, 2000);
};
}
details.appendChild(summary);
function bindButtons(){
document.querySelectorAll('button[data-cmd]').forEach(btn =>{
btn.addEventListener('click', async () =>{
const cmd = btn.getAttribute('data-cmd');
const body = document.createElement("div");
body.style.marginLeft = "16px";
let payload = null;
const payloadSelector = btn.getAttribute('data-payload');
if (payloadSelector) {
const field = document.querySelector(payloadSelector);
if (field) payload = field.value;
}
try{
const res = await fetch('/api/send', {
method:'POST', headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ cmd, payload })
});
const data = await res.json();
if(!res.ok){ append(`FEHLER ${res.status}: ${data.error || 'Unbekannt'}`); }
else { append(`Sende: ${cmd}`); }
}catch(err){ append('FEHLER: ' + (err?.message || err)); }
});
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));
});
}
async function loadLatestSnapshot() {
try {
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();
// Fallback: filename aus dem Pfad oder unbekannt, mtime jetzt
data = { filename: 'latest.csv', mtime: new Date().toISOString(), content: csvData };
}
const infoEl = document.getElementById('snapshot-info');
const tableEl = document.getElementById('snapshot-table');
// Info anzeigen
const mtime = new Date(data.mtime).toLocaleString();
infoEl.textContent = `Datei: ${data.filename} | Erstellt: ${mtime}`;
// CSV parsen und Tabelle bauen
const lines = data.content.trim().split('\n');
if (lines.length === 0) {
tableEl.innerHTML = '<tr><td>Keine Daten</td></tr>';
return;
}
const headers = lines[0].split(',');
let html = '<thead><tr>' + headers.map(h => `<th>${h.trim()}</th>`).join('') + '</tr></thead><tbody>';
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',');
html += '<tr>' + cells.map(c => `<td>${c.trim()}</td>`).join('') + '</tr>';
}
html += '</tbody>';
tableEl.innerHTML = html;
} catch (err) {
document.getElementById('snapshot-info').textContent = 'Fehler: ' + err.message;
document.getElementById('snapshot-table').innerHTML = '';
}
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);
}
bindButtons();
connectSSE();
refreshStatus();
loadLatestSnapshot();
})();
renderTree(treeEl, result, "result", true);
}
async function onCalculateClick() {
clearTextarea("analysis-log");
clearTextarea("result-json");
clearElement("result-tree");
appendLog("Starte Berechnung...");
try {
const result = await window.calculate();
renderResult(result);
appendLog("Result angezeigt.");
} catch (err) {
appendLog(`Fehler: ${err.message}`);
}
}
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);
}
document.querySelectorAll("button[data-cmd]").forEach(btn => {
if (btn.id === "btn-calculate") return;
btn.addEventListener("click", () => onCommandClick(btn));
});
}
window.addEventListener("DOMContentLoaded", setupUi);

View File

@@ -5,6 +5,70 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>appRobotHoming</title>
<link rel="stylesheet" href="/styles.css" />
<style>
.result-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
.panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel textarea,
.panel pre {
width: 100%;
min-height: 240px;
box-sizing: border-box;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1220;
color: #e2e8f0;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: auto;
}
#result-tree {
min-height: 240px;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1220;
color: #e2e8f0;
padding: 12px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
#result-tree details {
margin-left: 14px;
}
#result-tree summary {
cursor: pointer;
user-select: none;
color: #93c5fd;
}
#result-tree .tree-leaf {
margin-left: 18px;
white-space: pre-wrap;
}
#result-tree .tree-kv {
white-space: pre-wrap;
}
@media (max-width: 1000px) {
.result-layout {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
@@ -19,7 +83,8 @@
<button data-cmd="STATUS">STATUS</button>
<button data-cmd="RESET">RESET</button>
<button data-cmd="PING">PING</button>
<button onclick="calculate()">Calculate Actions</button>
<button id="btn-calculate">Calculate Actions</button>
<input
id="gcodePayload"
type="text"
@@ -27,7 +92,6 @@
style="width: 220px; padding: 10px; border-radius: 8px; border: 1px solid #334155; background: #0b1220; color: #e2e8f0;"
/>
<button data-cmd="GCODEMOTOR" data-payload="#gcodePayload">GCodeMotor</button>
</section>
<section class="log">
@@ -36,10 +100,24 @@
</section>
<section class="analysis">
<label for="analysis">Analysis & Reasoning</label>
<label for="analysis-log">Analysis & Reasoning</label>
<textarea id="analysis-log" readonly></textarea>
</section>
<section class="result">
<label>Berechnetes Result</label>
<div class="result-layout">
<div class="panel">
<label for="result-json">Raw JSON</label>
<textarea id="result-json" readonly></textarea>
</div>
<div class="panel">
<label>Tree View</label>
<div id="result-tree"></div>
</div>
</div>
</section>
<section class="snapshot">
<label for="snapshot-content">Neuester Snapshot</label>
<div id="snapshot-info"></div>
@@ -51,7 +129,7 @@
<small>HTTPS + WSS Relay • ©</small>
</footer>
<script src="/client.js"></script>
<script src="/calculateActions.js"></script>
<script src="/client.js"></script>
</body>
</html>
</html>

23
public/o/styles.css Executable file
View File

@@ -0,0 +1,23 @@
:root{ --bg:#0f172a; --fg:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8; --ok:#22c55e; --warn:#f59e0b; --err:#ef4444; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); }
header{ display:flex; justify-content:space-between; align-items:center; padding:16px 24px; border-bottom:1px solid #1f2937; }
h1{ margin:0; font-size:20px; }
.badge{ padding:2px 8px; border-radius:999px; background:#334155; }
.badge.ok{ background: #064e3b; color:#a7f3d0; }
.badge.warn{ background:#3f1b00; color:#fdba74; }
.badge.err{ background:#3f0d0d; color:#fecaca; }
main{ display:grid; grid-template-columns:1fr; gap:16px; padding:16px; max-width:1400px; margin:0 auto; }
.controls{ display:flex; gap:12px; flex-wrap:wrap; }
.controls button{ background:#1e293b; color:var(--fg); border:1px solid #334155; padding:10px 16px; border-radius:8px; cursor:pointer; }
.controls button:hover{ border-color: var(--accent); }
.log{ display:flex; flex-direction:column; gap:8px; }
#log{ width:100%; height:360px; background:#0b1220; color:var(--fg); border:1px solid #1f2937; border-radius:8px; padding:8px; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; }
.analysis{ display:flex; flex-direction:column; gap:8px; }
#analysis-log{ width:100%; height:200px; background:#0b1220; color:var(--fg); border:1px solid #1f2937; border-radius:8px; padding:8px; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; }
.snapshot{ display:flex; flex-direction:column; gap:8px; }
#snapshot-info{ font-size:14px; color:var(--muted); }
#snapshot-table{ width:100%; border-collapse:collapse; background:#0b1220; color:var(--fg); border:1px solid #1f2937; border-radius:8px; overflow:hidden; }
#snapshot-table th, #snapshot-table td{ padding:4px 8px; border:1px solid #334155; text-align:left; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; }
#snapshot-table th{ background:#1e293b; }
footer{ padding:12px 24px; border-top:1px solid #1f2937; color:var(--muted); }

View File

@@ -1,23 +1,178 @@
:root{ --bg:#0f172a; --fg:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8; --ok:#22c55e; --warn:#f59e0b; --err:#ef4444; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); }
header{ display:flex; justify-content:space-between; align-items:center; padding:16px 24px; border-bottom:1px solid #1f2937; }
h1{ margin:0; font-size:20px; }
.badge{ padding:2px 8px; border-radius:999px; background:#334155; }
.badge.ok{ background: #064e3b; color:#a7f3d0; }
.badge.warn{ background:#3f1b00; color:#fdba74; }
.badge.err{ background:#3f0d0d; color:#fecaca; }
main{ display:grid; grid-template-columns:1fr; gap:16px; padding:16px; max-width:1400px; margin:0 auto; }
.controls{ display:flex; gap:12px; flex-wrap:wrap; }
.controls button{ background:#1e293b; color:var(--fg); border:1px solid #334155; padding:10px 16px; border-radius:8px; cursor:pointer; }
.controls button:hover{ border-color: var(--accent); }
.log{ display:flex; flex-direction:column; gap:8px; }
#log{ width:100%; height:360px; background:#0b1220; color:var(--fg); border:1px solid #1f2937; border-radius:8px; padding:8px; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; }
.analysis{ display:flex; flex-direction:column; gap:8px; }
#analysis-log{ width:100%; height:200px; background:#0b1220; color:var(--fg); border:1px solid #1f2937; border-radius:8px; padding:8px; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; }
.snapshot{ display:flex; flex-direction:column; gap:8px; }
#snapshot-info{ font-size:14px; color:var(--muted); }
#snapshot-table{ width:100%; border-collapse:collapse; background:#0b1220; color:var(--fg); border:1px solid #1f2937; border-radius:8px; overflow:hidden; }
#snapshot-table th, #snapshot-table td{ padding:4px 8px; border:1px solid #334155; text-align:left; font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px; }
#snapshot-table th{ background:#1e293b; }
footer{ padding:12px 24px; border-top:1px solid #1f2937; color:var(--muted); }
:root {
--bg: #0b1220;
--panel: #132c44;
--border: #0e1822;
--text: #e0e6ed;
--muted: #9aa6b2;
--accent: #a4bbd4;
}
* {
box-sizing: border-box;
}
html, body {
min-height: 100%;
}
body {
font-family: Arial, sans-serif;
margin: 16px;
background: linear-gradient(
to bottom,
#dddddd -20%,
var(--bg) 130%
);
color: var(--text);
font-size: 14px;
}
/* ===== Titel ===== */
.app-title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 500;
color: var(--accent);
}
/* ===== GRID ===== */
.sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 16px;
}
.section {
grid-column: span 2;
}
.section.half {
grid-column: span 1;
}
.section.full {
grid-column: span 2;
}
/* ===== SECTION CARD ===== */
.section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 22px 20px;
}
.section h2 {
margin: 0;
font-size: 15px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--accent);
}
/* Collapse Pfeil */
.section h2::after {
content: "▼";
font-size: 12px;
transition: transform 0.2s ease;
}
.section.collapsed h2::after {
transform: rotate(-90deg);
}
.section.collapsed > :not(h2) {
display: none;
}
/* ===== ACTIONS ===== */
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
}
.controls button {
background: #1e293b;
color: var(--text);
border: 1px solid #334155;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
}
.controls button:hover {
border-color: var(--accent);
}
.controls input {
padding: 10px;
border-radius: 8px;
border: 1px solid #334155;
background: #0b1220;
color: var(--text);
width: 220px;
}
/* ===== TEXTAREAS ===== */
textarea {
width: 100%;
min-height: 200px;
margin-top: 12px;
background: #0b1220;
color: var(--text);
border: 1px solid #1f2937;
border-radius: 8px;
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
/* ===== PANEL (alte Struktur wiederhergestellt) ===== */
.panel {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 12px;
margin-top: 12px;
}
.panel label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
/* ===== TREE VIEW (JS-kompatibel) ===== */
#result-tree {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
#result-tree details {
margin-left: 14px;
}
#result-tree summary {
cursor: pointer;
user-select: none;
color: #93c5fd;
}
#result-tree .tree-leaf {
margin-left: 18px;
white-space: pre-wrap;
}