Sequence editor. Layout fixes

This commit is contained in:
PROFERIS - Mi³osz Stocki
2026-07-07 11:54:50 +02:00
parent 366de88ee4
commit 228cbf2329
14 changed files with 1162 additions and 75 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input)
- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input). Fully stateless
Both of these should also have effects like: Normal movement, erratic, jumpy, random
- No Disable steppers but
- Idle timeout
+11 -4
View File
@@ -374,6 +374,11 @@ The `?v=Date.now()` on imports handles this automatically on page load.
- Text input to spell words automatically
- Delay, Speed, and Acceleration sliders
- Position overlay marker on background
- [x] Sequence Editor section (`sequence-editor.js`) & Runner:
- Vertical list builder for custom sequences (Spots or X/Y).
- JSON export to persist sequences to `sequences.json`.
- Quick Sequences UI in `stepper-test`, `board-control`, and `input-text`.
- Shared `sequence-runner.js` to handle asynchronous execution and delays.
- [x] Firmware (`src/main.cpp`) — AccelStepper + BLE command parser + position NOTIFY
---
@@ -393,10 +398,11 @@ The router currently only has three routes. These sections need to be created/ex
- Requires background `spots.json` to have `"hasAlphabet": true`.
- Persisted Speed, Acceleration, and Delay settings.
### `sequence-editor` section (not started)
- Record and replay a sequence of moves
- Save/load sequences to localStorage
- Simple list UI: add step, delete step, reorder, run
### `sequence-editor` section (implemented)
- Record and replay a sequence of moves.
- Draft sequences auto-save to `localStorage`.
- Simple list UI: add Spot step, add XY step, delete step, reorder, play sequence.
- Export as JSON to paste into `web/assets/backgrounds/<bg>/sequences.json`.
To add a new section:
1. Create `web/js/sections/<name>.js` exporting `{ mount(el), unmount() }`
@@ -421,6 +427,7 @@ To add a new section:
| **`fivebarIKGame.js` is reference only** | The original site's game uses a different angle convention (absolute degrees, different home, with `ikElbowSigns` tracking). We use the C++ IK formula instead because it directly produces step deltas from home. `fivebarIKGame.js` is kept in the repo for reference and understanding, not imported. |
| **Hash-based routing** | Keeps the SPA working from `file://` and simple static servers without needing a history API setup |
| **Persistent Homing State** | The UI uses `localStorage.getItem('wiji_homed')` to track if the arm has been homed during the user's ongoing interaction. This avoids forcing the user to re-home every time they switch tabs or pages. Sending `HOMEALL` sets this to `true`. |
| **Stateless Sequence Storage** | To remain serverless, sequences are stored per-background in `web/assets/backgrounds/<bg>/sequences.json`. The Sequence Editor generates the JSON payload, which the user manually copy-pastes into the file to persist. Sequences marked `"favorite": true` appear automatically in Quick Sequences cards across the app. |
---
+10 -9
View File
@@ -46,28 +46,27 @@
Input Text
</a>
<a class="sidebar-nav-item" id="nav-sequence-editor" href="#sequence-editor" data-route="sequence-editor" role="button"
aria-label="Go to Sequence Editor">
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-sequence"></use></svg>
Sequence Editor
</a>
<a class="sidebar-nav-item" id="nav-stepper-test" href="#stepper-test" data-route="stepper-test" role="button"
aria-label="Go to Stepper Test">
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-stepper"></use></svg>
Stepper Test
</a>
<!-- Spacer + coming soon -->
<!-- Spacer -->
<div style="flex:1"></div>
<div class="divider"></div>
<span class="sidebar-section-label">Coming Soon</span>
<div class="sidebar-nav-item" style="opacity:0.35;cursor:default" aria-disabled="true">
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-sequence"></use></svg>
Sequence Editor
</div>
</nav>
<!-- ── Main area ──────────────────────────────────────────────── -->
<div style="display:flex;flex-direction:column;flex:1;overflow:hidden">
<!-- Top bar -->
<header class="topbar" role="banner">
<header class="top-bar" style="display:flex; justify-content:space-between; align-items:center; padding: 12px 10px; background: var(--surface-1); border-bottom: 1px solid var(--border);" role="banner">
<!-- Hamburger -->
<button class="hamburger" id="hamburger-btn" aria-label="Toggle sidebar" aria-expanded="true"
aria-controls="sidebar">
@@ -117,12 +116,14 @@
import StepperSection from './web/js/sections/stepper-test.js';
import BoardControlSection from './web/js/sections/board-control.js';
import InputTextSection from './web/js/sections/input-text.js';
import SequenceEditorSection from './web/js/sections/sequence-editor.js';
// ── Register routes ────────────────────────────────────────────
Router.register('home', HomeSection);
Router.register('stepper-test', StepperSection);
Router.register('board-control', BoardControlSection);
Router.register('input-text', InputTextSection);
Router.register('sequence-editor', SequenceEditorSection);
// ── Sidebar toggle logic ───────────────────────────────────────
const sidebar = document.getElementById('sidebar');
@@ -0,0 +1,35 @@
[
{
"id": "seq_alphabet_default",
"name": "Spell Alphabet",
"favorite": true,
"steps": [
{ "type": "spot", "id": "A" },
{ "type": "spot", "id": "B" },
{ "type": "spot", "id": "C" },
{ "type": "spot", "id": "D" },
{ "type": "spot", "id": "E" }
]
},
{
"id": "seq_yes_no",
"name": "Yes then No",
"favorite": true,
"steps": [
{ "type": "spot", "id": "YES" },
{ "type": "spot", "id": "NO" }
]
},
{
"id": "seq_corners",
"name": "Four Corners",
"favorite": true,
"steps": [
{ "type": "xy", "x": 130, "y": 120, "delay": 1500 },
{ "type": "xy", "x": -130, "y": 120, "delay": 1500 },
{ "type": "xy", "x": -130, "y": -15, "delay": 1500 },
{ "type": "xy", "x": 130, "y": -15, "delay": 1500 },
{ "type": "xy", "x": 0, "y": 50, "delay": 1500 }
]
}
]
@@ -0,0 +1,14 @@
[
{
"id": "seq_alphabet_portrait",
"name": "Spell Alphabet (Portrait)",
"favorite": true,
"steps": [
{ "type": "spot", "id": "A" },
{ "type": "spot", "id": "B" },
{ "type": "spot", "id": "C", "delay": 1000 },
{ "type": "spot", "id": "D" },
{ "type": "spot", "id": "E" }
]
}
]
+54
View File
@@ -0,0 +1,54 @@
# Sequence Configuration Specification (`sequences.json`)
Sequences allow predefined orchestration of movements. They are stored per-background in `web/assets/backgrounds/<bg_name>/sequences.json`.
## File Structure
The file should contain a JSON array of sequence objects.
```json
[
{
"id": "demo_sequence",
"name": "Demo Mix",
"favorite": true,
"steps": [
{
"type": "spot",
"id": "A"
},
{
"type": "spot",
"id": "B",
"delay": 1500
},
{
"type": "xy",
"x": 50,
"y": 120,
"delay": 500
}
]
}
]
```
## Sequence Object Properties
- `id` (string): Unique identifier for the sequence.
- `name` (string): Human-readable name displayed in the UI.
- `favorite` (boolean): If `true`, this sequence will appear in the "Quick Sequences" box across relevant sections (`board-control`, `input-text`, `stepper-test`).
- `steps` (array): A list of step objects executed in order.
## Step Object Properties
All steps share an optional `delay` property. If omitted, the global delay setting is used.
- `delay` (number, optional): Delay in milliseconds to wait *after* reaching the target before moving to the next step.
### Type: "spot"
Moves to a predefined spot in the background's `spots.json`.
- `type`: Must be `"spot"`.
- `id`: The spot ID string (e.g., `"YES"`, `"A"`).
### Type: "xy"
Moves to a raw X/Y coordinate in mm.
- `type`: Must be `"xy"`.
- `x`: X coordinate (number).
- `y`: Y coordinate (number).
+66
View File
@@ -0,0 +1,66 @@
import SequenceRunner from './sequence-runner.js';
import UI from './ui.js';
export function buildQuickSequencesHTML() {
return `
<div class="card" id="quick-sequences-card" style="margin-bottom: 16px;">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<span class="card-title">Quick Sequences</span>
<button id="btn-stop-sequence" class="btn btn-sm btn-danger" style="display:none;">STOP</button>
</div>
<div id="quick-sequences-list" style="display:flex; flex-direction:column; gap:8px;">
<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 0.9rem;">
Loading sequences...
</div>
</div>
</div>
`;
}
export async function initQuickSequences(bgName, getSpots, getSteps, onStepComplete) {
const listEl = document.getElementById('quick-sequences-list');
const stopBtn = document.getElementById('btn-stop-sequence');
if (!listEl) return;
try {
const res = await fetch(`web/assets/backgrounds/${bgName}/sequences.json?v=${Date.now()}`);
if (!res.ok) throw new Error('Not found');
const sequences = await res.json();
const favorites = sequences.filter(s => s.favorite);
if (favorites.length === 0) {
listEl.innerHTML = `<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 0.9rem;">No favorite sequences found.</div>`;
return;
}
listEl.innerHTML = favorites.map(seq => `
<div class="sequence-item" style="display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border);">
<span style="font-weight:bold; font-size:1rem;">${seq.name}</span>
<button class="btn btn-sm btn-success btn-play-seq" data-seq-id="${seq.id}" title="Play Sequence" style="border-radius: 50%; width: 32px; height: 32px; padding: 0; display:flex; align-items:center; justify-content:center;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
`).join('');
// Attach listeners
listEl.querySelectorAll('.btn-play-seq').forEach(btn => {
btn.addEventListener('click', async () => {
const seq = favorites.find(s => s.id === btn.dataset.seqId);
if (seq) {
stopBtn.style.display = 'block';
const { steps1, steps2 } = getSteps();
await SequenceRunner.run(seq, getSpots(), steps1, steps2, onStepComplete);
stopBtn.style.display = 'none';
}
});
});
stopBtn.addEventListener('click', () => {
SequenceRunner.stop();
stopBtn.style.display = 'none';
});
} catch (e) {
listEl.innerHTML = `<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 0.9rem;">No sequences.json found for this background.</div>`;
}
}
+22
View File
@@ -2,6 +2,7 @@ import BLE from '../ble.js';
import UI from '../ui.js';
import IK from '../kinematics.js';
import Settings from '../settings.js';
import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js';
let eventCleanup = [];
let isHomed = false;
@@ -147,6 +148,8 @@ function buildHTML() {
</button>
</div>
${buildQuickSequencesHTML()}
</div>
</div>
</div>
@@ -317,6 +320,15 @@ export default {
currentBg = bgSelect.value;
localStorage.setItem('wiji_bg', currentBg);
loadSpots();
initQuickSequences(
currentBg,
() => spots,
() => ({ steps1, steps2 }),
(s1, s2) => {
steps1 = s1; steps2 = s2;
updatePositionReadout();
}
);
});
}
@@ -399,6 +411,16 @@ export default {
updatePositionReadout();
}
initQuickSequences(
currentBg,
() => spots,
() => ({ steps1, steps2 }),
(s1, s2) => {
steps1 = s1; steps2 = s2;
updatePositionReadout();
}
);
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
},
+11 -1
View File
@@ -81,9 +81,19 @@ const HomeSection = {
</div>
</button>
<button class="sidebar-nav-item" data-route="sequence-editor"
onclick="document.getElementById('nav-sequence-editor').click()"
style="padding:14px 16px;background:var(--surface-2);text-align:left;border:none;cursor:pointer;width:100%;">
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-sequence"></use></svg>
<div>
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Sequence Editor</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Build and test custom sequences</div>
</div>
</button>
<button class="sidebar-nav-item" data-route="stepper-test"
onclick="document.getElementById('nav-stepper-test').click()"
style="padding:14px 16px;background:var(--surface-2)">
style="padding:14px 16px;background:var(--surface-2);text-align:left;border:none;cursor:pointer;width:100%;">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
+26 -4
View File
@@ -2,6 +2,7 @@ import BLE from '../ble.js';
import UI from '../ui.js';
import IK from '../kinematics.js';
import Settings from '../settings.js';
import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js';
let eventCleanup = [];
let isHomed = false;
@@ -141,10 +142,10 @@ function buildHTML() {
<input type="range" id="slider-accel" min="50" max="1000" step="50" value="${Settings.accel}" style="width:100%; margin-bottom: 8px;">
<div class="setting-row">
<label class="form-label" style="margin:0;">Delay (s)</label>
<span id="delay-val" style="font-size: 0.8rem; color: var(--text-muted);">${(Settings.delay/1000).toFixed(1)}</span>
<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="500" value="${Settings.delay}" style="width:100%;">
<input type="range" id="slider-delay" min="500" max="5000" step="100" value="${Settings.delay}" style="width:100%;">
</div>
<div class="form-group" style="margin-bottom: 12px;">
@@ -160,6 +161,8 @@ function buildHTML() {
</button>
</div>
${buildQuickSequencesHTML()}
</div>
</div>
</div>
@@ -362,6 +365,15 @@ export default {
currentBg = bgSelect.value;
localStorage.setItem('wiji_input_bg', currentBg);
loadSpots();
initQuickSequences(
currentBg,
() => spots,
() => ({ steps1, steps2 }),
(s1, s2) => {
steps1 = s1; steps2 = s2;
updatePosMarker();
}
);
});
}
@@ -384,7 +396,7 @@ export default {
sDelay.addEventListener('input', (e) => {
Settings.delay = parseInt(e.target.value);
document.getElementById('delay-val').textContent = (Settings.delay/1000).toFixed(1);
document.getElementById('delay-val').textContent = Settings.delay;
});
// ── Homing Logic ──────────────────────────────────────────
@@ -450,6 +462,16 @@ export default {
updatePosMarker();
}
initQuickSequences(
currentBg,
() => spots,
() => ({ steps1, steps2 }),
(s1, s2) => {
steps1 = s1; steps2 = s2;
updatePosMarker();
}
);
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
},
+738
View File
@@ -0,0 +1,738 @@
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';
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;
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>
<button id="btn-force-home" class="btn btn-danger btn-full" style="padding: 10px; font-weight:bold;">
${isHomed ? 'FORCE HOME ALL' : 'HOME ALL MOTORS'}
</button>
</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 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="1000" 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();
if (Array.isArray(data)) {
spots = data;
hasAlphabet = false;
} else {
spots = data.spots || [];
if (data.bounds) bounds = data.bounds;
hasAlphabet = !!data.hasAlphabet;
}
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';
await sendSettings();
await SequenceRunner.run(sequence, spots, steps1, steps2, (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();
steps1 = IK.ARM.HOME_STEPS.m1;
steps2 = IK.ARM.HOME_STEPS.m2;
saveSteps();
updatePositionReadout();
UI.log('Homing all motors...', 'info');
if (BLE.isConnected()) {
try {
await BLE.write('HOMEALL');
} catch (e) {
UI.log(e.message, 'error');
}
} else {
UI.log('[sim] HOMEALL', 'info');
}
});
// ── 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 = [];
}
};
+15 -13
View File
@@ -288,7 +288,7 @@ function buildZones() {
fill="rgba(255,82,82,0.12)"
stroke="rgba(255,82,82,0.55)" stroke-width="1.5"
rx="3"/>
<text x="${SV.OX}" y="${ey + eh/2 + 4}" text-anchor="middle"
<text x="${SV.OX}" y="${ey + eh / 2 + 4}" text-anchor="middle"
font-size="8" fill="rgba(255,82,82,0.7)" font-family="monospace">
NO-GO ZONE
</text>
@@ -310,7 +310,7 @@ function buildZones() {
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<line x1="${r1x}" y1="${r0y - 4}" x2="${r1x}" y2="${r0y + 4}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<text x="${(r0x + r1x)/2}" y="${r0y + 12}" text-anchor="middle"
<text x="${(r0x + r1x) / 2}" y="${r0y + 12}" text-anchor="middle"
font-size="8" fill="rgba(255,255,255,0.3)" font-family="monospace">50 mm</text>
<!-- Motor markers -->
@@ -372,7 +372,9 @@ function buildHTML() {
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
border-radius:var(--radius-xs); }
@media (max-width: 600px) {
.jog-compact { grid-template-columns: 1fr; }
}
</style>
<div class="section-page fade-in">
@@ -586,8 +588,8 @@ function buildHTML() {
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-cw">CW ▶</button>
</div>
<div class="quick-steps">
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="${n}">+${n}</button>`).join('')}
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="-${n}">-${n}</button>`).join('')}
${[10, 50, 100, 512].map(n => `<button class="btn btn-ghost" data-motor="1" data-steps="${n}">+${n}</button>`).join('')}
${[10, 50, 100, 512].map(n => `<button class="btn btn-ghost" data-motor="1" data-steps="-${n}">-${n}</button>`).join('')}
</div>
<button class="btn btn-danger btn-sm btn-full" id="s1-zero">Home M1</button>
</div>
@@ -602,8 +604,8 @@ function buildHTML() {
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-cw">CW ▶</button>
</div>
<div class="quick-steps">
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="${n}">+${n}</button>`).join('')}
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="-${n}">-${n}</button>`).join('')}
${[10, 50, 100, 512].map(n => `<button class="btn btn-ghost" data-motor="2" data-steps="${n}">+${n}</button>`).join('')}
${[10, 50, 100, 512].map(n => `<button class="btn btn-ghost" data-motor="2" data-steps="-${n}">-${n}</button>`).join('')}
</div>
<button class="btn btn-danger btn-sm btn-full" id="s2-zero">Home M2</button>
</div>
@@ -643,7 +645,7 @@ const StepperTestSection = {
elbow1 = document.getElementById('elbow1');
elbow2 = document.getElementById('elbow2');
endEff = document.getElementById('end-eff');
coordLabel= document.getElementById('coord-label');
coordLabel = document.getElementById('coord-label');
xhH = document.getElementById('xh-h');
xhV = document.getElementById('xh-v');
@@ -655,7 +657,7 @@ const StepperTestSection = {
ghostElbow2 = document.getElementById('ghost-e2');
ghostEnd = document.getElementById('ghost-e1');
ghostLabel = document.getElementById('ghost-label');
targetMarker= document.getElementById('target-marker');
targetMarker = document.getElementById('target-marker');
elCurrX = document.getElementById('curr-x');
elCurrY = document.getElementById('curr-y');
@@ -717,9 +719,9 @@ const StepperTestSection = {
// ── Jog CW / CCW ────────────────────────────────────────────
const gs = (n) => parseInt(document.getElementById(`jog-steps-${n}`)?.value ?? '50', 10) || 50;
document.getElementById('s1-cw') .addEventListener('click', () => jogMotor(1, +gs(1)));
document.getElementById('s1-cw').addEventListener('click', () => jogMotor(1, +gs(1)));
document.getElementById('s1-ccw').addEventListener('click', () => jogMotor(1, -gs(1)));
document.getElementById('s2-cw') .addEventListener('click', () => jogMotor(2, +gs(2)));
document.getElementById('s2-cw').addEventListener('click', () => jogMotor(2, +gs(2)));
document.getElementById('s2-ccw').addEventListener('click', () => jogMotor(2, -gs(2)));
document.getElementById('s1-zero').addEventListener('click', () => zeroMotor(1));
document.getElementById('s2-zero').addEventListener('click', () => zeroMotor(2));
@@ -738,7 +740,7 @@ const StepperTestSection = {
clearTimeout(spdT);
spdT = setTimeout(() => {
const cmd = `SPD:${e.target.value}`;
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
BLE.isConnected() ? BLE.write(cmd).catch(() => { }) : UI.log(`[sim] ${cmd}`, 'info');
}, 400);
});
document.getElementById('accel-slider').addEventListener('input', (e) => {
@@ -746,7 +748,7 @@ const StepperTestSection = {
clearTimeout(accT);
accT = setTimeout(() => {
const cmd = `ACC:${e.target.value}`;
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
BLE.isConnected() ? BLE.write(cmd).catch(() => { }) : UI.log(`[sim] ${cmd}`, 'info');
}, 400);
});
+106
View File
@@ -0,0 +1,106 @@
import BLE from './ble.js';
import UI from './ui.js';
import IK from './kinematics.js';
import Settings from './settings.js';
let isRunning = false;
function delayMs(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export default {
get isRunning() { return isRunning; },
stop() { isRunning = false; },
async run(sequence, spotsData, currentSteps1, currentSteps2, onStepComplete) {
if (isRunning) {
UI.log('A sequence is already running.', 'warn');
return false;
}
isRunning = true;
UI.log(`Running sequence: ${sequence.name}`, 'info');
let steps1 = currentSteps1;
let steps2 = currentSteps2;
for (let i = 0; i < sequence.steps.length; i++) {
if (!isRunning) {
UI.log('Sequence stopped.', 'info');
break;
}
const step = sequence.steps[i];
let targetX, targetY, label;
if (step.type === 'spot') {
const spot = spotsData.find(s => s.id === step.id);
if (!spot) {
UI.log(`Sequence error: Spot '${step.id}' not found. Skipping.`, 'error');
continue;
}
targetX = spot.x;
targetY = spot.y;
label = spot.label || spot.id;
} else if (step.type === 'xy') {
targetX = step.x;
targetY = step.y;
label = `(${targetX}, ${targetY})`;
} else {
UI.log(`Unknown step type: ${step.type}`, 'error');
continue;
}
const wsCheck = IK.checkWorkspace(targetX, targetY);
if (!wsCheck.ok) {
UI.log(`${wsCheck.reason} [${label}]`, 'error');
continue; // or break? let's continue
}
const res = IK.solve(targetX, targetY);
if (!res.reachable || IK.armsCrossed(res.theta1, res.theta2)) {
UI.log(`Target ${label} is unreachable or crosses arms.`, 'error');
continue;
}
const newSteps1 = IK.radToSteps(res.theta1);
const newSteps2 = IK.radToSteps(res.theta2);
const delta1 = newSteps1 - steps1;
const delta2 = newSteps2 - steps2;
steps1 = newSteps1;
steps2 = newSteps2;
if (onStepComplete) {
onStepComplete(steps1, steps2);
}
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
UI.log(`Seq step: ${label}`, 'info');
if (BLE.isConnected()) {
try {
await BLE.write(cmd1);
await BLE.write(cmd2);
} catch (e) {
UI.log(`BLE error: ${e.message}`, 'error');
isRunning = false;
return false;
}
} else {
console.log(`[sim] ${cmd1} ${cmd2}`);
}
// Wait for delay
const waitTime = typeof step.delay === 'number' ? step.delay : Settings.delay;
await delayMs(waitTime);
}
isRunning = false;
UI.log(`Sequence completed.`, 'success');
return true;
}
};
+10
View File
@@ -58,6 +58,14 @@
gap: 10px;
}
@media (max-width: 480px) {
.topbar { padding: 0 10px; gap: 8px; }
.topbar-brand { gap: 6px; font-size: 0.9rem; }
.topbar-actions { gap: 6px; }
#ble-label { display: none; }
.topbar-brand .brand-icon { width: 24px; height: 24px; }
}
/* ── Hamburger button ────────────────────────────────────────── */
.hamburger {
display: flex;
@@ -447,6 +455,8 @@
letter-spacing: 0.08em;
}
.form-input {
width: 100%;
min-width: 0;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);