Sequence editor. Layout fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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).
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user