Files
ESP32-WijiBoard/web/js/sections/sequence-editor.js
T
2026-07-08 22:09:56 +02:00

784 lines
30 KiB
JavaScript

import BLE from '../ble.js';
import UI from '../ui.js';
import IK from '../kinematics.js';
import Settings from '../settings.js';
import SequenceRunner from '../sequence-runner.js';
import Motion from '../motion.js';
let eventCleanup = [];
let isHomed = false;
let spots = [];
let bounds = { xMin: -150, xMax: 150, yMin: -30, yMax: 145 };
let currentBg = localStorage.getItem('wiji_bg') || 'default';
let steps1 = 0;
let steps2 = 0;
let hasAlphabet = false;
let savedBrowserSequences = [];
let bgSequences = [];
let sequence = {
id: "my_custom_sequence",
name: "My Sequence",
favorite: false,
steps: []
};
function syncStateFromStorage() {
isHomed = localStorage.getItem('wiji_homed') === 'true';
steps1 = isHomed ? parseInt(localStorage.getItem('wiji_steps1') || IK.ARM.HOME_STEPS.m1) : IK.ARM.HOME_STEPS.m1;
steps2 = isHomed ? parseInt(localStorage.getItem('wiji_steps2') || IK.ARM.HOME_STEPS.m2) : IK.ARM.HOME_STEPS.m2;
currentBg = localStorage.getItem('wiji_bg') || 'default';
const savedSeq = localStorage.getItem('wiji_sequence_draft');
if (savedSeq) {
try {
sequence = JSON.parse(savedSeq);
} catch(e) {}
}
const savedSeqs = localStorage.getItem('wiji_saved_sequences');
if (savedSeqs) {
try {
savedBrowserSequences = JSON.parse(savedSeqs);
} catch(e) {}
}
}
function saveSteps() {
if (isHomed) {
localStorage.setItem('wiji_steps1', steps1);
localStorage.setItem('wiji_steps2', steps2);
}
}
function saveSequenceDraft() {
localStorage.setItem('wiji_sequence_draft', JSON.stringify(sequence));
updateJSONPreview();
}
function updateJSONPreview() {
const el = document.getElementById('json-preview');
if (el) {
el.value = JSON.stringify([sequence], null, 2);
}
}
function saveToBrowser() {
const existingIdx = savedBrowserSequences.findIndex(s => s.id === sequence.id);
if (existingIdx >= 0) {
savedBrowserSequences[existingIdx] = JSON.parse(JSON.stringify(sequence));
} else {
savedBrowserSequences.push(JSON.parse(JSON.stringify(sequence)));
}
localStorage.setItem('wiji_saved_sequences', JSON.stringify(savedBrowserSequences));
renderSavedSequences();
UI.log(`Saved '${sequence.name}' to browser storage`, 'success');
}
function buildHTML() {
return `
<style>
.board-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 16px;
height: 100%;
align-items: start;
}
@media (max-width: 900px) { .board-grid { grid-template-columns: 1fr; } }
.homing-banner {
padding: 10px;
border-radius: var(--radius-sm);
font-weight: bold;
text-align: center;
margin-bottom: 12px;
transition: all 0.3s;
}
.homing-banner.homed {
background: rgba(0, 230, 118, 0.1);
color: var(--accent-green);
border: 1px solid rgba(0, 230, 118, 0.3);
}
.homing-banner.not-homed {
background: rgba(255, 82, 82, 0.1);
color: var(--accent-red, #ff5252);
border: 1px solid rgba(255, 82, 82, 0.3);
}
.step-box {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.step-controls { display: flex; gap: 6px; }
.btn-icon {
padding: 4px; border-radius: var(--radius-xs);
background: transparent; color: var(--text-muted); border: none; cursor: pointer;
}
.btn-icon:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
.setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.meta-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.step-input-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 6px;
margin-bottom: 8px;
align-items: center;
}
.step-input-row.four-cols {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
.mobile-separator { display: none; }
@media (max-width: 480px) {
.meta-row { grid-template-columns: 1fr auto; }
.meta-row .name-input { grid-column: 1 / -1; order: 3; }
.step-input-row { grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.step-input-row .full-w-mobile { grid-column: 1 / -1; }
.step-input-row.four-cols { grid-template-columns: 1fr 1fr; }
.mobile-separator { display: block; height: 1px; background: rgba(255,255,255,0.05); margin: 16px 0; }
}
</style>
<div class="section-page fade-in" style="display:flex; flex-direction:column; height: 100%;">
<div class="section-header">
<h2>Sequence Editor</h2>
<p>Build and test custom sequences. Export JSON to save them permanently.</p>
</div>
<div class="board-grid">
<!-- ── Editor Area ─────────────────────────────────────── -->
<div style="display:flex; flex-direction:column; gap: 12px;">
<div class="card" style="flex:1;">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
<span class="card-title">Sequence Steps</span>
<div style="display:flex; gap:4px; flex-wrap:wrap;">
<button id="btn-play-editor" class="btn btn-sm btn-success">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"><path d="M8 5v14l11-7z"/></svg>PLAY
</button>
<button id="btn-stop-editor" class="btn btn-sm btn-danger" style="display:none;">STOP</button>
<button id="btn-clear-seq" class="btn btn-sm btn-ghost">CLEAR</button>
</div>
</div>
<!-- Sequence Meta Info -->
<div class="meta-row">
<input type="text" id="seq-id" class="form-input" placeholder="Sequence ID" value="${sequence.id}" />
<input type="text" id="seq-name" class="form-input name-input" placeholder="Display Name" value="${sequence.name}" />
<label style="display:flex; align-items:center; gap:4px; font-size: 0.9rem; cursor:pointer; white-space: nowrap;">
<input type="checkbox" id="seq-favorite" ${sequence.favorite ? 'checked' : ''} /> Favorite
</label>
</div>
<!-- Steps List -->
<div id="steps-list" style="min-height: 150px; margin-bottom: 16px;">
<!-- Populated dynamically -->
</div>
<!-- Add Step Controls -->
<div style="background: var(--surface-2); border: 1px solid var(--border); padding: 12px; border-radius: var(--radius-sm);">
<div style="margin-bottom: 8px; font-weight: bold; font-size: 0.9rem;">Add Step</div>
<div class="step-input-row">
<select id="editor-spot-select" class="form-input full-w-mobile">
<option value="">-- Select Spot --</option>
<!-- Populated dynamically -->
</select>
<input type="number" id="editor-spot-delay" class="form-input" placeholder="Delay (ms)" step="100" title="Optional delay" />
<button id="btn-add-spot" class="btn btn-primary">+ Spot</button>
</div>
<div class="mobile-separator"></div>
<div class="step-input-row four-cols">
<input type="number" id="editor-x" class="form-input" placeholder="X" />
<input type="number" id="editor-y" class="form-input" placeholder="Y" />
<input type="number" id="editor-xy-delay" class="form-input" placeholder="Delay (ms)" step="100" title="Optional delay" />
<button id="btn-add-xy" class="btn btn-primary">+ XY</button>
</div>
<div id="text-input-divider" class="mobile-separator" style="display:none;"></div>
<div id="text-input-row" class="step-input-row" style="display:none;">
<input type="text" id="editor-text" class="form-input full-w-mobile" placeholder="Text (e.g. HELLO)" />
<input type="number" id="editor-text-delay" class="form-input" placeholder="Delay (ms)" step="100" title="Delay between letters" />
<button id="btn-add-text" class="btn btn-primary">+ Text</button>
</div>
</div>
</div>
<!-- JSON Export -->
<div class="card">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
<span class="card-title">JSON Export</span>
<button id="btn-save-browser" class="btn btn-sm btn-primary">SAVE TO BROWSER</button>
</div>
<textarea id="json-preview" class="form-input" style="width: 100%; height: 200px; font-family: monospace; font-size: 0.85rem; resize: vertical;" readonly></textarea>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 8px;">
Copy this JSON array into <code>web/assets/backgrounds/${currentBg}/sequences.json</code> to persist it.
</div>
</div>
</div>
<!-- ── Control Panel ──────────────────────────────── -->
<div style="display:flex; flex-direction:column; gap: 12px;">
<!-- Homing Card -->
<div class="card">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<span class="card-title">System Status</span>
<div style="display:flex; gap: 12px;">
<div style="text-align:right"><div style="font-size:0.6rem; color:var(--text-muted); text-transform:uppercase;">X (mm)</div><div id="pos-x" style="font-weight:bold; font-size:1rem; color:var(--accent-green);">0.0</div></div>
<div style="text-align:right"><div style="font-size:0.6rem; color:var(--text-muted); text-transform:uppercase;">Y (mm)</div><div id="pos-y" style="font-weight:bold; font-size:1rem; color:var(--accent-green);">0.0</div></div>
</div>
</div>
<div id="homing-banner" class="homing-banner ${isHomed ? 'homed' : 'not-homed'}">
${isHomed ? '✓ HOMED' : '⚠ NOT HOMED! Movement Locked.'}
</div>
<div style="display:flex; gap: 8px;">
<button id="btn-force-home" class="btn btn-danger" style="flex:1; padding: 10px; font-weight:bold;">
${isHomed ? 'FORCE HOME ALL' : 'HOME ALL MOTORS'}
</button>
<button id="btn-park" class="btn btn-secondary" style="flex:1; padding: 10px; font-weight:bold;">
GO TO PARK
</button>
</div>
</div>
<!-- Settings Card -->
<div class="card">
<div class="card-header"><span class="card-title">Motion Settings</span></div>
<div class="form-group" style="margin-bottom: 12px;">
<label class="form-label">Background Context</label>
<select id="bg-select" class="form-input" style="width:100%;">
<option value="default">Default Spirit Board (Landscape)</option>
<option value="portrait-right">Right Side Test (Portrait)</option>
</select>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">Provides available spots for the editor.</div>
</div>
<div class="form-group" style="margin-bottom: 12px;">
<label class="form-label">Global Movement Mode</label>
<select id="seq-mode-select" class="form-input" style="width:100%;">
<option value="direct">Direct (Normal)</option>
<option value="snaky">Snaky</option>
<option value="erratic">Erratic</option>
<option value="jumpy">Jumpy</option>
<option value="hesitant">Hesitant</option>
<option value="overshoot">Overshoot</option>
<option value="random">Random</option>
</select>
</div>
<div style="background: var(--surface-2); padding: 10px; border-radius: var(--radius-sm);">
<div class="setting-row">
<label class="form-label" style="margin:0;">Speed</label>
<span id="speed-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.speed}</span>
</div>
<input type="range" id="slider-speed" min="100" max="2000" step="100" value="${Settings.speed}" style="width:100%; margin-bottom: 8px;">
<div class="setting-row">
<label class="form-label" style="margin:0;">Accel</label>
<span id="accel-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.accel}</span>
</div>
<input type="range" id="slider-accel" min="50" max="2000" step="50" value="${Settings.accel}" style="width:100%; margin-bottom: 8px;">
<div class="setting-row">
<label class="form-label" style="margin:0;">Global Delay (ms)</label>
<span id="delay-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.delay}</span>
</div>
<input type="range" id="slider-delay" min="500" max="5000" step="100" value="${Settings.delay}" style="width:100%;">
</div>
</div>
<!-- Saved Sequences Card -->
<div class="card">
<div class="card-header"><span class="card-title">Saved Sequences</span></div>
<div id="saved-seq-list" style="display:flex; flex-direction:column; gap:8px;">
<!-- Populated dynamically -->
</div>
</div>
</div>
</div>
</div>
`;
}
function updatePositionReadout() {
const t1 = IK.stepsToRad(steps1);
const t2 = IK.stepsToRad(steps2);
const { endX, endY } = IK.forward(t1, t2);
const px = document.getElementById('pos-x');
const py = document.getElementById('pos-y');
if (px) px.textContent = isFinite(endX) ? endX.toFixed(1) : '---';
if (py) py.textContent = isFinite(endY) ? endY.toFixed(1) : '---';
}
function updateHomingUI() {
const banner = document.getElementById('homing-banner');
const btn = document.getElementById('btn-force-home');
if (!banner || !btn) return;
if (isHomed) {
banner.className = 'homing-banner homed';
banner.textContent = '✓ HOMED';
btn.textContent = 'FORCE HOME ALL';
} else {
banner.className = 'homing-banner not-homed';
banner.textContent = '⚠ NOT HOMED! Movement Locked.';
btn.textContent = 'HOME ALL MOTORS';
}
}
function renderStepsList() {
const container = document.getElementById('steps-list');
if (!container) return;
if (sequence.steps.length === 0) {
container.innerHTML = `<div style="text-align: center; color: var(--text-muted); padding: 20px;">No steps added yet.</div>`;
return;
}
container.innerHTML = sequence.steps.map((step, idx) => {
let label = '';
let details = '';
if (step.type === 'spot') {
label = `Spot: <b>${step.id}</b>`;
} else {
label = `XY: <b>(${step.x}, ${step.y})</b>`;
}
if (step.delay !== undefined) {
details = `<span style="font-size: 0.8rem; color: var(--accent-amber); margin-left: 8px;">Delay: ${step.delay}ms</span>`;
}
return `
<div class="step-box">
<div>
<span style="font-family: monospace; font-size: 0.9rem; color: var(--text-muted); margin-right: 8px;">#${idx+1}</span>
${label} ${details}
</div>
<div class="step-controls">
<button class="btn-icon btn-move-up" data-idx="${idx}" title="Move Up">↑</button>
<button class="btn-icon btn-move-down" data-idx="${idx}" title="Move Down">↓</button>
<button class="btn-icon btn-delete-step" data-idx="${idx}" title="Delete" style="color: var(--accent-red);">✕</button>
</div>
</div>
`;
}).join('');
// Attach listeners
container.querySelectorAll('.btn-move-up').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
if (idx > 0) {
[sequence.steps[idx - 1], sequence.steps[idx]] = [sequence.steps[idx], sequence.steps[idx - 1]];
saveSequenceDraft();
renderStepsList();
}
});
});
container.querySelectorAll('.btn-move-down').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
if (idx < sequence.steps.length - 1) {
[sequence.steps[idx + 1], sequence.steps[idx]] = [sequence.steps[idx], sequence.steps[idx + 1]];
saveSequenceDraft();
renderStepsList();
}
});
});
container.querySelectorAll('.btn-delete-step').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
sequence.steps.splice(idx, 1);
saveSequenceDraft();
renderStepsList();
});
});
}
async function loadSpots() {
try {
const res = await fetch(`web/assets/backgrounds/${currentBg}/spots.json?v=${Date.now()}`);
const data = await res.json();
let parsedSpots = [];
let parkPosition = { x: 0, y: 80 };
if (Array.isArray(data)) {
parsedSpots = data;
hasAlphabet = false;
} else {
parsedSpots = data.spots || [];
if (data.bounds) bounds = data.bounds;
if (data.parkPosition) parkPosition = data.parkPosition;
hasAlphabet = !!data.hasAlphabet;
}
if (!parsedSpots.find(s => s.id === 'PARK')) {
parsedSpots.push({ id: 'PARK', label: 'Park Position', x: parkPosition.x, y: parkPosition.y });
}
spots = parsedSpots;
document.getElementById('text-input-row').style.display = hasAlphabet ? 'grid' : 'none';
document.getElementById('text-input-divider').style.display = hasAlphabet ? 'block' : 'none';
// Populate spot dropdown
const select = document.getElementById('editor-spot-select');
if (select) {
select.innerHTML = '<option value="">-- Select Spot --</option>' + spots.map(s =>
`<option value="${s.id}">${s.label || s.id} [${s.x}, ${s.y}]</option>`
).join('');
}
updateJSONPreview();
await loadBgSequences();
} catch(e) {
UI.log('Failed to load spots.json', 'error');
}
}
async function loadBgSequences() {
try {
const res = await fetch(`web/assets/backgrounds/${currentBg}/sequences.json?v=${Date.now()}`);
if (res.ok) {
bgSequences = await res.json();
} else {
bgSequences = [];
}
} catch (e) {
bgSequences = [];
}
renderSavedSequences();
}
function renderSavedSequences() {
const container = document.getElementById('saved-seq-list');
if (!container) return;
let html = '';
if (bgSequences.length > 0) {
html += `<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; margin-top:4px;">From sequences.json</div>`;
html += bgSequences.map((s, idx) => `
<div style="display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); padding:8px; border-radius:var(--radius-sm); border:1px solid var(--border);">
<span style="font-size:0.9rem;">${s.name}</span>
<button class="btn btn-sm btn-ghost btn-load-seq" data-source="bg" data-idx="${idx}">Load</button>
</div>
`).join('');
}
if (savedBrowserSequences.length > 0) {
html += `<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; margin-top:8px;">From Browser Storage</div>`;
html += savedBrowserSequences.map((s, idx) => `
<div style="display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); padding:8px; border-radius:var(--radius-sm); border:1px solid var(--border);">
<span style="font-size:0.9rem;">${s.name}</span>
<div style="display:flex; gap:4px;">
<button class="btn btn-sm btn-ghost btn-load-seq" data-source="browser" data-idx="${idx}">Load</button>
<button class="btn btn-sm btn-ghost btn-delete-seq" data-idx="${idx}" style="color:var(--accent-red);" title="Delete from Browser Storage">✕</button>
</div>
</div>
`).join('');
}
if (!html) {
html = `<div style="text-align: center; color: var(--text-muted); font-size: 0.85rem; padding: 12px;">No saved sequences</div>`;
}
container.innerHTML = html;
container.querySelectorAll('.btn-load-seq').forEach(btn => {
btn.addEventListener('click', () => {
const source = btn.dataset.source;
const idx = parseInt(btn.dataset.idx);
const srcObj = source === 'bg' ? bgSequences[idx] : savedBrowserSequences[idx];
if (srcObj) {
sequence = JSON.parse(JSON.stringify(srcObj));
document.getElementById('seq-id').value = sequence.id;
document.getElementById('seq-name').value = sequence.name;
const favCheckbox = document.getElementById('seq-favorite');
if (favCheckbox) favCheckbox.checked = !!sequence.favorite;
saveSequenceDraft();
renderStepsList();
UI.log(`Loaded sequence '${sequence.name}'`, 'info');
}
});
});
container.querySelectorAll('.btn-delete-seq').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
if (confirm('Delete this sequence from browser storage?')) {
savedBrowserSequences.splice(idx, 1);
localStorage.setItem('wiji_saved_sequences', JSON.stringify(savedBrowserSequences));
renderSavedSequences();
}
});
});
}
async function sendSettings() {
if (!BLE.isConnected()) return;
try {
await BLE.write(`SPD:${Settings.speed}`);
await BLE.write(`ACC:${Settings.accel}`);
} catch (e) {
console.error('Failed to send settings', e);
}
}
export default {
mount(container) {
syncStateFromStorage();
container.innerHTML = buildHTML();
updateHomingUI();
loadSpots();
renderStepsList();
// ── Input bindings for metadata ──────────────────────────────
document.getElementById('seq-id').addEventListener('input', (e) => {
sequence.id = e.target.value;
saveSequenceDraft();
});
document.getElementById('seq-name').addEventListener('input', (e) => {
sequence.name = e.target.value;
saveSequenceDraft();
});
const favCheckbox = document.getElementById('seq-favorite');
if (favCheckbox) {
favCheckbox.addEventListener('change', (e) => {
sequence.favorite = e.target.checked;
saveSequenceDraft();
});
}
// ── Background Selection ──────────────────────────────────
const bgSelect = document.getElementById('bg-select');
if (bgSelect) {
bgSelect.value = currentBg;
bgSelect.addEventListener('change', () => {
currentBg = bgSelect.value;
localStorage.setItem('wiji_bg', currentBg);
loadSpots();
});
}
// ── Settings Sliders ──────────────────────────────────────
const sSpeed = document.getElementById('slider-speed');
const sAccel = document.getElementById('slider-accel');
const sDelay = document.getElementById('slider-delay');
sSpeed.addEventListener('input', (e) => {
Settings.speed = parseInt(e.target.value);
document.getElementById('speed-val').textContent = Settings.speed;
sendSettings();
});
sAccel.addEventListener('input', (e) => {
Settings.accel = parseInt(e.target.value);
document.getElementById('accel-val').textContent = Settings.accel;
sendSettings();
});
sDelay.addEventListener('input', (e) => {
Settings.delay = parseInt(e.target.value);
document.getElementById('delay-val').textContent = Settings.delay;
});
// ── Add Steps Logic ───────────────────────────────────────
document.getElementById('btn-add-spot').addEventListener('click', () => {
const select = document.getElementById('editor-spot-select');
const delayInput = document.getElementById('editor-spot-delay');
const spotId = select.value;
const delay = parseInt(delayInput.value);
if (spotId) {
const step = { type: 'spot', id: spotId };
if (!isNaN(delay) && delay >= 0) step.delay = delay;
sequence.steps.push(step);
saveSequenceDraft();
renderStepsList();
select.value = "";
delayInput.value = "";
}
});
document.getElementById('btn-add-xy').addEventListener('click', () => {
const xInput = document.getElementById('editor-x');
const yInput = document.getElementById('editor-y');
const delayInput = document.getElementById('editor-xy-delay');
const x = parseFloat(xInput.value);
const y = parseFloat(yInput.value);
const delay = parseInt(delayInput.value);
if (!isNaN(x) && !isNaN(y)) {
const step = { type: 'xy', x, y };
if (!isNaN(delay) && delay >= 0) {
step.delay = delay;
}
sequence.steps.push(step);
saveSequenceDraft();
renderStepsList();
xInput.value = "";
yInput.value = "";
delayInput.value = "";
}
});
document.getElementById('btn-add-text').addEventListener('click', () => {
const textInput = document.getElementById('editor-text');
const delayInput = document.getElementById('editor-text-delay');
const text = textInput.value.trim().toUpperCase();
const delay = parseInt(delayInput.value);
if (text) {
const chars = Array.from(text);
let addedCount = 0;
for (const char of chars) {
const spot = spots.find(s =>
(s.label && s.label.toUpperCase() === char) ||
(s.id && s.id.toUpperCase() === char)
);
if (spot) {
const step = { type: 'spot', id: spot.id };
if (!isNaN(delay) && delay >= 0) step.delay = delay;
sequence.steps.push(step);
addedCount++;
}
}
if (addedCount > 0) {
saveSequenceDraft();
renderStepsList();
UI.log(`Added ${addedCount} letter spots`, 'success');
} else {
UI.log('No spots found for entered text', 'warn');
}
textInput.value = "";
delayInput.value = "";
}
});
document.getElementById('btn-clear-seq').addEventListener('click', () => {
if (confirm('Are you sure you want to clear this sequence?')) {
sequence.steps = [];
saveSequenceDraft();
renderStepsList();
}
});
document.getElementById('btn-save-browser').addEventListener('click', () => {
saveToBrowser();
});
// ── Sequence Execution ────────────────────────────────────
const btnPlay = document.getElementById('btn-play-editor');
const btnStop = document.getElementById('btn-stop-editor');
btnPlay.addEventListener('click', async () => {
if (!isHomed) {
UI.log('Cannot move: System is not homed!', 'error');
return;
}
if (sequence.steps.length === 0) return;
btnPlay.style.display = 'none';
btnStop.style.display = 'inline-block';
const modeEl = document.getElementById('seq-mode-select');
const mode = modeEl ? modeEl.value : 'direct';
await sendSettings();
await SequenceRunner.run(sequence, spots, steps1, steps2, mode, (s1, s2) => {
steps1 = s1; steps2 = s2;
updatePositionReadout();
});
btnPlay.style.display = 'inline-block';
btnStop.style.display = 'none';
});
btnStop.addEventListener('click', () => {
SequenceRunner.stop();
});
// ── Homing Logic ──────────────────────────────────────────
document.getElementById('btn-force-home').addEventListener('click', async () => {
isHomed = true;
localStorage.setItem('wiji_homed', 'true');
updateHomingUI();
UI.log('Homing all motors...', 'info');
if (BLE.isConnected()) {
try {
await BLE.write('HOMEALL');
} catch (e) {
UI.log(e.message, 'error');
}
} else {
steps1 = IK.ARM.HOME_STEPS.m1;
steps2 = IK.ARM.HOME_STEPS.m2;
saveSteps();
updatePositionReadout();
UI.log('[sim] HOMEALL', 'info');
}
});
document.getElementById('btn-park').addEventListener('click', async () => {
const parkSpot = spots.find(s => s.id === 'PARK');
if (parkSpot) {
if (!isHomed) {
UI.log('Cannot move: System is not homed!', 'error');
return;
}
const modeEl = document.getElementById('seq-mode-select');
const mode = modeEl ? modeEl.value : 'direct';
UI.log(`Moving to Park Position (${parkSpot.x}, ${parkSpot.y})`, 'info');
await Motion.goto(parkSpot.x, parkSpot.y, mode);
}
});
// ── Sync Steps from BLE ───────────────────────────────────
const onBLEStatus = (e) => {
const msg = e.detail;
if (msg.startsWith('P:')) {
const [s1, s2] = msg.slice(2).split(',').map(Number);
steps1 = s1; steps2 = s2;
saveSteps();
updatePositionReadout();
}
};
document.addEventListener('ble:status', onBLEStatus);
if (BLE.isConnected()) {
BLE.write('POS').catch(() => {});
sendSettings();
} else {
updatePositionReadout();
}
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
},
unmount() {
SequenceRunner.stop();
eventCleanup.forEach(fn => fn());
eventCleanup = [];
}
};