Files
appRobotHoming/public/calibration.js
2026-06-10 18:30:16 +02:00

660 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* calibration.js Kalibrierungs-Frontend */
// ── Panel-Loading (lazy) ───────────────────────────────────────────────────────
const _panelLoaded = new Set();
async function loadPanel(tab, src) {
if (_panelLoaded.has(tab)) return;
try {
const r = await fetch(src);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
document.getElementById('tab-' + tab).innerHTML = await r.text();
_panelLoaded.add(tab);
// h2-Klick → Section ein-/ausklappen
document.getElementById('tab-' + tab).querySelectorAll('.section h2').forEach(h2 => {
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
});
// Tab-spezifische Initialisierung
if (tab === 'camera-npz') initCameraNpz();
else if (tab === 'board') initBoard();
else if (tab === 'robot-x-axis') initXAxis();
} catch (err) {
document.getElementById('tab-' + tab).innerHTML =
`<p style="color:#f87171;margin:16px">Panel konnte nicht geladen werden: ${err}</p>`;
}
}
// ── Tab-Switching ─────────────────────────────────────────────────────────────
document.getElementById('tabSidebar').addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
loadPanel(btn.dataset.tab, btn.dataset.src);
});
// Erstes Tab sofort laden
(function () {
const first = document.querySelector('.tab-btn.active');
if (first) loadPanel(first.dataset.tab, first.dataset.src);
})();
// ── Shared: SSE-Stream lesen ──────────────────────────────────────────────────
async function readSseStream(response, logFn, onDone) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop(); // unvollständiges letztes Fragment behalten
for (const part of parts) {
for (const line of part.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const evt = JSON.parse(line.slice(6));
if (evt.type === 'log') { if (evt.text !== '') logFn(evt.text); }
else if (evt.type === 'done') { onDone(evt); }
} catch { /* ungültiges JSON überspringen */ }
}
}
}
}
// ── Camera NPZ ────────────────────────────────────────────────────────────────
function initCameraNpz() {
const logCamera = document.getElementById('log-camera');
function logC(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logCamera.value += `[${ts}] ${msg}\n`;
logCamera.scrollTop = logCamera.scrollHeight;
}
function formatDate(isoString) {
if (!isoString) return '';
return new Date(isoString).toLocaleString('de-CH');
}
function updateCalibInfo(meta) {
const sel = document.getElementById('cam-select-calib');
if (!meta) {
document.getElementById('info-timestamp').textContent = '(keine Session vorhanden)';
document.getElementById('info-created').textContent = '';
document.getElementById('info-images').textContent = '';
sel.innerHTML = '<option value=""> Kamera </option>';
return;
}
document.getElementById('info-timestamp').textContent = meta.timestamp ?? '';
document.getElementById('info-created').textContent = formatDate(meta.createdAt);
const cameras = meta.cameras ?? [];
const imgTxt = meta.imageCount != null
? `${meta.imageCount} Bilder total. ${cameras.length} Kamera(s) verwendet.`
: '';
document.getElementById('info-images').textContent = imgTxt;
const prev = sel.value;
sel.innerHTML = '<option value=""> Kamera </option>';
for (const cam of cameras) {
const opt = document.createElement('option');
opt.value = cam; opt.textContent = cam;
if (cam === prev) opt.selected = true;
sel.appendChild(opt);
}
if (cameras.length === 1) sel.value = cameras[0];
}
async function loadCalibCurrent() {
try {
const r = await fetch('/api/calibration/current');
const d = await r.json();
updateCalibInfo(d.meta);
if (d.session) logC(`Session geladen: ${d.session}`);
else logC('Noch keine Kalibrierungs-Session vorhanden.');
} catch (err) {
logC(`Fehler beim Laden: ${err}`);
}
}
async function safeJson(r) {
const raw = await r.text().catch(() => '');
try { return { ok: r.ok, status: r.status, data: JSON.parse(raw) }; }
catch { return { ok: r.ok, status: r.status, data: null, raw: raw.slice(0, 300) }; }
}
// Aktuelle Session laden
loadCalibCurrent();
// "Neue Kalibrierung anlegen"
document.getElementById('btn-new-calib').addEventListener('click', async () => {
logC('Neue Kalibrierung wird angelegt …');
try {
const r = await fetch('/api/calibration/new', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
if (d.warning) logC(`Warnung: ${d.warning}`);
else logC(`Session angelegt: ${d.session} | Fotos: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Foto aufnehmen"
document.getElementById('btn-foto-calib').addEventListener('click', async () => {
logC('Fotos werden aufgenommen …');
try {
const r = await fetch('/api/calibration/foto', { method: 'POST' });
const d = await r.json();
if (d.error) { logC(`Fehler: ${d.error}`); return; }
updateCalibInfo(d.meta);
logC(`Gespeichert: ${(d.savedFiles ?? []).join(', ')}`);
} catch (err) {
logC(`Fehler: ${err}`);
}
});
// "Kalibrierung berechnen" SSE
document.getElementById('btn-compute-calib').addEventListener('click', async () => {
const camera = document.getElementById('cam-select-calib').value;
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
logC(`Starte Kalibrierung für ${camera}`);
const btn = document.getElementById('btn-compute-calib');
btn.disabled = true;
try {
const response = await fetch('/api/calibration/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || response.statusText; }
logC(`❌ HTTP ${response.status}: ${msg || '(kein Fehlertext evtl. Server neu starten?)'}`);
return;
}
await readSseStream(response, logC, (evt) => {
if (evt.exitCode === 0) logC('✅ Kalibrierung abgeschlossen.');
else logC(`❌ Script beendet mit Exit-Code ${evt.exitCode}`);
});
} catch (err) {
logC(`Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// "NPZ speichern" → an Webcam-Service übertragen
document.getElementById('btn-upload-npz').addEventListener('click', async () => {
const camera = document.getElementById('cam-select-calib').value;
if (!camera) { logC('⚠ Bitte zuerst eine Kamera auswählen.'); return; }
logC(`NPZ wird an Webcam-Service übertragen (${camera}) …`);
try {
const { ok, status, data, raw } = await safeJson(await fetch('/api/calibration/upload-npz', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera }),
}));
if (!ok || data?.error) {
logC(`❌ HTTP ${status}: ${data?.error ?? raw ?? '(kein Fehlertext)'}`);
return;
}
logC(`✅ Gespeichert: ${data.webcam?.saved} (${data.size} Bytes)`);
logC(` calibrationUrl: ${data.webcam?.calibrationUrl}`);
} catch (err) {
logC(`❌ Fehler: ${err}`);
}
});
}
// ── Board: Marker-Tabelle ─────────────────────────────────────────────────────
async function loadBoardTable() {
const wrap = document.getElementById('board-marker-table-wrap');
if (!wrap) return;
const th = (a) => `style="text-align:${a};padding:3px 8px;border-bottom:1px solid #2a2d35;white-space:nowrap;background:#1e293b;color:#555b6e;font-weight:normal"`;
const td = (a, x = '') => `style="padding:2px 8px;border-bottom:1px solid #111418;text-align:${a};white-space:nowrap;${x}"`;
try {
const r = await fetch('/api/board/latest');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (!data.runDir) {
wrap.innerHTML = '<p style="font-size:11px;color:#555b6e;padding:4px 0">Noch kein Board-Run vorhanden.</p>';
return;
}
const meas = data.measuredMarkers;
if (!meas?.markers?.length) {
wrap.innerHTML = `<p style="font-size:11px;color:#555b6e;padding:4px 0">Run: ${data.runDir} kein 3b-Output (≥2 Kameras erforderlich).</p>`;
return;
}
// Modell-Info aus robot.json
const boardMarkers = data.robot?.links?.Board?.markers ?? [];
const modelMap = {};
for (const m of boardMarkers) modelMap[m.id] = m;
const f1 = v => (v == null ? '' : Number(v).toFixed(1));
const f2 = v => (v == null ? '' : Number(v).toFixed(2));
const f4 = v => (v == null ? '' : Number(v).toFixed(4));
const markers = [...meas.markers].sort((a, b) => {
if (a.link !== b.link) return a.link === 'Board' ? -1 : 1;
return a.marker_id - b.marker_id;
});
let html = `<p style="font-size:10px;color:#555b6e;margin-bottom:4px">
Run: ${data.runDir} · ${markers.length} Marker trianguliert
</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:100%">
<thead><tr>
<th ${th('left')}>ID</th>
<th ${th('left')}>Set</th>
<th ${th('left')}>Link</th>
<th ${th('right')}>Kam.</th>
<th ${th('right')}>x mm</th>
<th ${th('right')}>y mm</th>
<th ${th('right')}>z mm</th>
<th ${th('right')}>nx</th>
<th ${th('right')}>ny</th>
<th ${th('right')}>nz</th>
<th ${th('right')}>dist mm</th>
<th ${th('right')}>Δz mm</th>
<th ${th('right')}>Kante mm</th>
</tr></thead>
<tbody>`;
for (const m of markers) {
const model = modelMap[m.marker_id];
const set = m.set ?? model?.set ?? '';
const [px, py, pz] = m.position_mm;
const [mnx, mny, mnz] = m.normal;
let dist = '', dz = '';
if (model && m.link === 'Board') {
const [mx, my, mz] = model.position;
const ddx = px - mx, ddy = py - my, ddz = pz - mz;
dist = Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz).toFixed(2);
dz = ddz.toFixed(2);
}
const col = m.link === 'Board' ? '' : 'color:#3b82f6;';
html += `<tr>
<td ${td('left', col)}>${m.marker_id}</td>
<td ${td('left', col)}>${set}</td>
<td ${td('left', col)}>${m.link}</td>
<td ${td('right', col)}>${m.num_cameras}</td>
<td ${td('right', col)}>${f1(px)}</td>
<td ${td('right', col)}>${f1(py)}</td>
<td ${td('right', col)}>${f1(pz)}</td>
<td ${td('right', col)}>${f4(mnx)}</td>
<td ${td('right', col)}>${f4(mny)}</td>
<td ${td('right', col)}>${f4(mnz)}</td>
<td ${td('right', col)}>${dist}</td>
<td ${td('right', col)}>${dz}</td>
<td ${td('right', col)}>${f1(m.edge_length_mm)}</td>
</tr>`;
}
html += `</tbody></table>`;
// Kamera-Tabelle
const cams = meas.cameras ?? [];
if (cams.length > 0) {
html += `<p style="font-size:10px;color:#555b6e;margin-top:12px;margin-bottom:4px">KAMERAS</p>
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:auto">
<thead><tr>
<th ${th('left')}>ID</th>
<th ${th('right')}>x mm</th>
<th ${th('right')}>y mm</th>
<th ${th('right')}>z mm</th>
<th ${th('right')}>dir_x</th>
<th ${th('right')}>dir_y</th>
<th ${th('right')}>dir_z</th>
</tr></thead>
<tbody>`;
for (const c of cams) {
const [cx, cy, cz] = c.position_mm ?? [null, null, null];
const [dx, dy, dz] = c.direction ?? [null, null, null];
html += `<tr>
<td ${td('left', 'color:#4a9eff;')}>${c.camera_id}</td>
<td ${td('right')}>${f1(cx)}</td>
<td ${td('right')}>${f1(cy)}</td>
<td ${td('right')}>${f1(cz)}</td>
<td ${td('right')}>${f4(dx)}</td>
<td ${td('right')}>${f4(dy)}</td>
<td ${td('right')}>${f4(dz)}</td>
</tr>`;
}
html += `</tbody></table>`;
}
wrap.innerHTML = html;
} catch (err) {
if (wrap) wrap.innerHTML = `<p style="color:#f87171;font-size:11px">Fehler: ${err}</p>`;
}
}
// ── Board ─────────────────────────────────────────────────────────────────────
// ── Tab: Robot X Axis ─────────────────────────────────────────────────────────
async function populateXAxisSetDropdowns() {
let sets = [];
try {
const r = await fetch('/api/robot/board-sets');
if (r.ok) sets = (await r.json()).sets ?? [];
} catch {}
const sel = document.getElementById('xaxis-ref-set');
if (sel) {
sel.innerHTML = '<option value="">alle</option>' +
sets.map(s => `<option value="${s}">${s}</option>`).join('');
}
}
function initXAxis() {
const logEl = document.getElementById('log-xaxis');
function logX(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logEl.value += `[${ts}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
populateXAxisSetDropdowns();
document.getElementById('btn-xaxis-run').addEventListener('click', async () => {
const refSet = document.getElementById('xaxis-ref-set')?.value ?? '';
logX(`Board-Erkennung … Referenz: ${refSet || 'alle'}`);
const btn = document.getElementById('btn-xaxis-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refSet: refSet || undefined }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
logX(`❌ HTTP ${response.status}: ${msg}`);
return;
}
await readSseStream(response, logX, (evt) => {
if (evt.exitCode === 0) {
logX('✅ Board-Run abgeschlossen.');
if (evt.runDir) {
document.getElementById('xaxis-last-run').textContent = evt.runDir;
const frame = document.getElementById('xaxis-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'reload' }, '*');
}
}
} else {
logX(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logX(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
}
// ── Tab: Board (shared helpers) ───────────────────────────────────────────────
/** Befüllt alle Set-Dropdowns aus /api/robot/board-sets */
async function populateBoardSetDropdowns() {
let sets = [];
try {
const r = await fetch('/api/robot/board-sets');
if (r.ok) sets = (await r.json()).sets ?? [];
} catch { /* kein Server / noch keine Sets → leere Dropdowns */ }
// Hilfsfunktion: <select> mit Optionen füllen
function fill(selId, placeholder, selected = '') {
const sel = document.getElementById(selId);
if (!sel) return;
sel.innerHTML = `<option value="">${placeholder}</option>` +
sets.map(s => `<option value="${s}"${s === selected ? ' selected' : ''}>${s}</option>`).join('');
}
fill('board-ref-set', 'alle');
fill('act-align-fixed', ' bleibt ');
fill('act-align-move', ' verschoben ');
}
function initBoard() {
const logBoard = document.getElementById('log-board');
function logB(msg) {
const ts = new Date().toLocaleTimeString('de-CH');
logBoard.value += `[${ts}] ${msg}\n`;
logBoard.scrollTop = logBoard.scrollHeight;
}
// Dropdowns mit Sets aus robot.json befüllen
populateBoardSetDropdowns();
// Tabelle beim ersten Öffnen des Tabs befüllen
loadBoardTable();
document.getElementById('btn-board-run').addEventListener('click', async () => {
const refSet = document.getElementById('board-ref-set')?.value ?? '';
logB(`Board-Erkennung wird gestartet … Referenz: ${refSet || 'alle'}`);
const btn = document.getElementById('btn-board-run');
btn.disabled = true;
try {
const response = await fetch('/api/board/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refSet: refSet || undefined }),
});
if (!response.ok) {
const raw = await response.text().catch(() => '');
let msg;
try { msg = JSON.parse(raw).error || raw; }
catch { msg = raw.slice(0, 300) || `HTTP ${response.status}`; }
logB(`❌ HTTP ${response.status}: ${msg}`);
return;
}
await readSseStream(response, logB, (evt) => {
if (evt.exitCode === 0) {
logB('✅ Board-Run abgeschlossen.');
if (evt.runDir) {
document.getElementById('board-last-run').textContent = evt.runDir;
// Board-Viewer im iframe nach dem Run neu laden
const frame = document.getElementById('board-viewer-frame');
if (frame?.contentWindow) {
frame.contentWindow.postMessage({ type: 'reload' }, '*');
}
// Marker-Tabelle + Set-Dropdowns aktualisieren
loadBoardTable();
populateBoardSetDropdowns();
}
} else {
logB(`❌ Beendet mit Exit-Code ${evt.exitCode}`);
}
});
} catch (err) {
logB(`❌ Fehler: ${err}`);
} finally {
btn.disabled = false;
}
});
// ── Aktion 1: Z-Bereich Zuordnung ───────────────────────────────────────────
document.getElementById('btn-act-assign').addEventListener('click', async () => {
const zMin = document.getElementById('act-z-min').value;
const zMax = document.getElementById('act-z-max').value;
const set = document.getElementById('act-set').value.trim();
const link = document.getElementById('act-link').value.trim();
const result = document.getElementById('act-result');
if (zMin === '' || zMax === '') {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte z-Bereich eingeben.</span>'; return;
}
if (!set && !link) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte mindestens Set oder Link angeben.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Zuordnung läuft …</span>';
try {
const r = await fetch('/api/robot/assign-by-z', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zMin: parseFloat(zMin), zMax: parseFloat(zMax), set, link }),
});
const data = await r.json();
if (!r.ok || data.error) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
if (data.numChanged === 0) {
result.innerHTML = '<span style="color:#555b6e">Keine Marker im Z-Bereich gefunden (weder in robot.json noch im letzten 3b-Run).</span>'; return;
}
const added = data.changes.filter(c => c.action === 'added').length;
const moved = data.changes.filter(c => c.action === 'updated' && c.oldLink !== c.newLink).length;
const updated = data.changes.filter(c => c.action === 'updated').length - moved;
const parts = [];
if (added) parts.push(`${added} neu hinzugefügt`);
if (moved) parts.push(`${moved} verschoben`);
if (updated) parts.push(`${updated} aktualisiert`);
result.innerHTML = `<span style="color:#22c55e">✅ ${data.numChanged} Marker: ${parts.join(', ')}.</span>` +
` IDs: ${data.changes.map(c => c.markerId).join(', ')}`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
// ── Aktion 2: Zuordnung entfernen ───────────────────────────────────────────
document.getElementById('btn-act-remove').addEventListener('click', async () => {
const markerId = document.getElementById('act-rm-id').value.trim();
const removeFrom = document.querySelector('input[name="act-rm-from"]:checked')?.value;
const result = document.getElementById('act-result');
if (!markerId) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Marker-ID eingeben.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Verarbeite …</span>';
try {
const r = await fetch('/api/robot/remove-marker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markerId: parseInt(markerId, 10), removeFrom }),
});
const data = await r.json();
if (!r.ok) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
if (!data.changed) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error}</span>`; return;
}
const info = data.action === 'set-removed'
? `Set "${data.oldSet}" entfernt (bleibt in Link "${data.link}")`
: `Aus Link "${data.link}" entfernt`;
result.innerHTML = `<span style="color:#22c55e">✅ Marker ${markerId}: ${info}</span>`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
// ── Aktion 3: Sets justieren (Kabsch 2D+Z) ─────────────────────────────────
document.getElementById('btn-act-align').addEventListener('click', async () => {
const setFixed = document.getElementById('act-align-fixed').value;
const setToMove = document.getElementById('act-align-move').value;
const result = document.getElementById('act-result');
if (!setToMove) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Set auswählen, das verschoben werden soll.</span>'; return;
}
if (setFixed && setFixed === setToMove) {
result.innerHTML = '<span style="color:#f87171">⚠ "Bleibt" und "verschoben" dürfen nicht dasselbe Set sein.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Justierung läuft …</span>';
try {
const r = await fetch('/api/robot/align-sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setFixed: setFixed || undefined, setToMove }),
});
const data = await r.json();
if (!r.ok || data.error) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
const t = data.transform;
result.innerHTML =
`<span style="color:#22c55e">✅ Set "${setToMove}": ${data.numChanged} Marker verschoben` +
` (${data.numMatchingPts} Messpunkte)${setFixed ? ` "${setFixed}" bleibt` : ''}</span>` +
` &ensp;Δx=${t.tx}&thinsp;mm&ensp;Δy=${t.ty}&thinsp;mm&ensp;Δz=${t.tz}&thinsp;mm` +
`&ensp;θ=${t.thetaDeg}°`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
// ── Aktion 4: Zuordnung hinzufügen ─────────────────────────────────────────
document.getElementById('btn-act-add').addEventListener('click', async () => {
const markerId = document.getElementById('act-add-id').value.trim();
const set = document.getElementById('act-add-set').value.trim();
const link = document.getElementById('act-add-link').value.trim();
const result = document.getElementById('act-result');
if (!markerId) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Marker-ID eingeben.</span>'; return;
}
if (!set && !link) {
result.innerHTML = '<span style="color:#f87171">⚠ Bitte mindestens Set oder Link angeben.</span>'; return;
}
result.innerHTML = '<span style="color:#555b6e">Hinzufügen …</span>';
try {
const r = await fetch('/api/robot/assign-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markerId: parseInt(markerId, 10), set, link }),
});
const data = await r.json();
if (!r.ok) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`; return;
}
if (!data.changed) {
result.innerHTML = `<span style="color:#f87171">❌ ${data.error}</span>`; return;
}
const c = data.change;
const info = c.action === 'added'
? `neu in Link "${c.newLink}"${c.newSet ? ` Set "${c.newSet}"` : ''}`
: `aktualisiert → Link "${c.newLink}"${c.newSet ? ` Set "${c.newSet}"` : ''}`;
result.innerHTML = `<span style="color:#22c55e">✅ Marker ${markerId}: ${info}</span>`;
loadBoardTable();
} catch (err) {
result.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
}
});
}