596 lines
23 KiB
JavaScript
596 lines
23 KiB
JavaScript
import BLE from '../ble.js';
|
|
import UI from '../ui.js';
|
|
import IK from '../kinematics.js';
|
|
import Settings from '../settings.js';
|
|
import Motion from '../motion.js';
|
|
import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js';
|
|
import { IdleEffects } from '../idle-effects.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 isManualPickMode = false;
|
|
let isFullscreen = false;
|
|
|
|
function syncStateFromStorage() {
|
|
isHomed = localStorage.getItem('wiji_homed') === 'true';
|
|
steps1 = isHomed ? parseInt(localStorage.getItem('wiji_steps1') || IK.ARM.HOME_STEPS.m1) : IK.ARM.HOME_STEPS.m1;
|
|
steps2 = isHomed ? parseInt(localStorage.getItem('wiji_steps2') || IK.ARM.HOME_STEPS.m2) : IK.ARM.HOME_STEPS.m2;
|
|
currentBg = localStorage.getItem('wiji_bg') || 'default';
|
|
Motion.setSteps(steps1, steps2);
|
|
}
|
|
|
|
function saveSteps() {
|
|
if (isHomed) {
|
|
localStorage.setItem('wiji_steps1', steps1);
|
|
localStorage.setItem('wiji_steps2', steps2);
|
|
}
|
|
}
|
|
|
|
function buildHTML() {
|
|
return `
|
|
<style>
|
|
.board-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 280px;
|
|
gap: 16px;
|
|
height: 100%;
|
|
align-items: stretch;
|
|
}
|
|
@media (max-width: 900px) { .board-grid { grid-template-columns: 1fr; } }
|
|
.spot-marker:hover {
|
|
background: rgba(0, 230, 118, 0.8) !important;
|
|
transform: translate(-50%, -50%) scale(1.3) !important;
|
|
}
|
|
.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);
|
|
}
|
|
.pos-marker {
|
|
position: absolute;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
background: var(--accent-blue);
|
|
border: 2px solid white;
|
|
transform: translate(-50%, -50%);
|
|
transition: left 0.1s linear, top 0.1s linear;
|
|
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
body.board-fullscreen .section-page {
|
|
transform: none !important;
|
|
animation: none !important;
|
|
}
|
|
.fullscreen-card {
|
|
position: fixed !important;
|
|
inset: 0 !important;
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
z-index: 9999 !important;
|
|
border-radius: 0 !important;
|
|
margin: 0 !important;
|
|
background: #111 !important;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.fullscreen-card #map-wrapper {
|
|
align-items: center !important;
|
|
}
|
|
.fullscreen-card #map-container {
|
|
max-width: 100% !important;
|
|
width: min(100%, calc((100vh - 90px) * (var(--w) / var(--h)))) !important;
|
|
height: auto !important;
|
|
aspect-ratio: calc(var(--w) / var(--h)) !important;
|
|
display: block !important;
|
|
}
|
|
.fullscreen-card #bg-image {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
object-fit: fill !important;
|
|
max-width: none !important;
|
|
max-height: none !important;
|
|
}
|
|
</style>
|
|
|
|
<div class="section-page fade-in" style="display:flex; flex-direction:column; height: 100%;">
|
|
<div class="section-header">
|
|
<h2>Board Control</h2>
|
|
<p>Click on the map or select from the list to move the pointer.</p>
|
|
</div>
|
|
|
|
<div class="board-grid">
|
|
<!-- ── Map Area ─────────────────────────────────────── -->
|
|
<div id="map-card" class="card" style="display:flex; flex-direction:column; padding: 12px; position:relative;">
|
|
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
|
<span class="card-title">Interactive Map</span>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button id="btn-manual-pick" class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem; display:flex; align-items:center; gap:4px; border-radius: var(--radius-sm);">
|
|
<span style="font-size: 1.1rem;">⌖</span> Manual Pick
|
|
</button>
|
|
<button id="btn-fullscreen" class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem; display:flex; align-items:center; gap:4px; border-radius: var(--radius-sm);">
|
|
<span id="fullscreen-icon" style="font-size: 1.1rem;">⛶</span> <span id="fullscreen-text">Full Screen</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="map-wrapper" style="flex:1; display:flex; align-items:flex-start; justify-content:center; overflow:hidden;">
|
|
<div id="map-container" style="position: relative; width: 100%; max-width: 800px; background: #111; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.5);">
|
|
<img id="bg-image" src="web/assets/backgrounds/${currentBg}/bg.svg" style="width: 100%; display: block; height: auto; border-radius: 8px;" />
|
|
<div id="markers-layer" style="position: absolute; top:0; left:0; width:100%; height:100%;"></div>
|
|
<div id="pos-marker" class="pos-marker" style="display:none;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Control Panel ──────────────────────────────── -->
|
|
<div style="display:flex; flex-direction:column; gap: 12px;">
|
|
|
|
<!-- Homing Card -->
|
|
<div class="card">
|
|
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
|
<span class="card-title">System Status</span>
|
|
<div style="display:flex; gap: 12px;">
|
|
<div style="text-align:right"><div style="font-size:0.6rem; color:var(--text-muted); text-transform:uppercase;">X (mm)</div><div id="pos-x" style="font-weight:bold; font-size:1rem; color:var(--accent-green);">0.0</div></div>
|
|
<div style="text-align:right"><div style="font-size:0.6rem; color:var(--text-muted); text-transform:uppercase;">Y (mm)</div><div id="pos-y" style="font-weight:bold; font-size:1rem; color:var(--accent-green);">0.0</div></div>
|
|
</div>
|
|
</div>
|
|
<div id="homing-banner" class="homing-banner ${isHomed ? 'homed' : 'not-homed'}">
|
|
${isHomed ? '✓ HOMED' : '⚠ NOT HOMED! Movement Locked.'}
|
|
</div>
|
|
<div style="display:flex; gap: 8px;">
|
|
<button id="btn-force-home" class="btn btn-danger" style="flex:1; padding: 10px; font-weight:bold;">
|
|
${isHomed ? 'FORCE HOME' : 'HOME ALL'}
|
|
</button>
|
|
<button id="btn-park" class="btn btn-secondary" style="flex:1; padding: 10px; font-weight:bold;">
|
|
GO TO PARK
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls Card -->
|
|
<div class="card" style="flex:1; display:flex; flex-direction:column;">
|
|
<div class="card-header"><span class="card-title">Navigation</span></div>
|
|
|
|
<div class="form-group" style="margin-bottom: 12px;">
|
|
<label class="form-label">Background Map</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>
|
|
|
|
<!-- Settings -->
|
|
<div style="background: var(--surface-2); padding: 10px; border-radius: var(--radius-sm); margin-bottom: 12px;">
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
<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="bc-slider-speed" min="100" max="1200" step="100" value="${Settings.speed}" style="width:100%; margin-bottom: 8px;">
|
|
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
<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="bc-slider-accel" min="50" max="500" step="50" value="${Settings.accel}" style="width:100%;">
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom: 12px;">
|
|
<label class="form-label">Movement Mode</label>
|
|
<select id="mode-select" class="form-input" style="width:100%;">
|
|
<option value="direct">Direct (Normal)</option>
|
|
<option value="snaky">Snaky</option>
|
|
<option value="erratic">Erratic</option>
|
|
<option value="jumpy">Jumpy</option>
|
|
<option value="hesitant">Hesitant</option>
|
|
<option value="overshoot">Overshoot</option>
|
|
<option value="random">Random</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom: 12px;">
|
|
<label class="form-label">Idle Animation</label>
|
|
<select id="bc-idle-mode-select" class="form-input idle-mode-select" style="width:100%;">
|
|
<option value="none">None</option>
|
|
<option value="circles">Circles</option>
|
|
<option value="erratic">Erratic</option>
|
|
<option value="park-bounce">Park Bounce</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group" style="flex:1; display:flex; flex-direction:column;">
|
|
<label class="form-label">Available Spots</label>
|
|
<select id="spot-list" size="10" class="form-input" style="flex:1; width: 100%; margin-bottom: 12px; font-family: monospace; font-size: 1rem; padding: 8px;">
|
|
<!-- Populated dynamically -->
|
|
</select>
|
|
</div>
|
|
|
|
<button id="btn-go" class="btn btn-success btn-full" style="padding: 12px; font-weight:bold; font-size: 1.1rem;" disabled>
|
|
GO TO SELECTION
|
|
</button>
|
|
</div>
|
|
|
|
${buildQuickSequencesHTML()}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updatePosMarker() {
|
|
const marker = document.getElementById('pos-marker');
|
|
if (!marker) return;
|
|
|
|
const t1 = IK.stepsToRad(steps1);
|
|
const t2 = IK.stepsToRad(steps2);
|
|
const { endX, endY, valid } = IK.forward(t1, t2);
|
|
|
|
if (valid && isFinite(endX) && isFinite(endY)) {
|
|
const px = document.getElementById('pos-x');
|
|
const py = document.getElementById('pos-y');
|
|
if (px) px.textContent = endX.toFixed(1);
|
|
if (py) py.textContent = endY.toFixed(1);
|
|
|
|
const w = bounds.xMax - bounds.xMin;
|
|
const h = bounds.yMax - bounds.yMin;
|
|
|
|
// Bounds check to avoid marker flying off screen entirely
|
|
if (endX >= bounds.xMin && endX <= bounds.xMax && endY >= bounds.yMin && endY <= bounds.yMax) {
|
|
const xPct = ((endX - bounds.xMin) / w) * 100;
|
|
const yPct = ((bounds.yMax - endY) / h) * 100;
|
|
|
|
marker.style.left = `${xPct}%`;
|
|
marker.style.top = `${yPct}%`;
|
|
marker.style.display = 'block';
|
|
} else {
|
|
marker.style.display = 'none'; // Out of bounds visually
|
|
}
|
|
} else {
|
|
marker.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
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';
|
|
} else {
|
|
banner.className = 'homing-banner not-homed';
|
|
banner.textContent = '⚠ NOT HOMED! Movement Locked.';
|
|
btn.textContent = 'HOME ALL';
|
|
}
|
|
}
|
|
|
|
async function loadSpots() {
|
|
try {
|
|
const res = await fetch(`web/assets/backgrounds/${currentBg}/spots.json?v=${Date.now()}`);
|
|
const data = await res.json();
|
|
let parsedSpots = [];
|
|
let parkPosition = { x: 0, y: 80 };
|
|
|
|
if (Array.isArray(data)) {
|
|
parsedSpots = data;
|
|
} else {
|
|
parsedSpots = data.spots || [];
|
|
if (data.bounds) bounds = data.bounds;
|
|
if (data.parkPosition) parkPosition = data.parkPosition;
|
|
}
|
|
|
|
if (!parsedSpots.find(s => s.id === 'PARK')) {
|
|
parsedSpots.push({ id: 'PARK', label: 'Park Position', x: parkPosition.x, y: parkPosition.y });
|
|
}
|
|
IdleEffects.setParkPosition(parkPosition.x, parkPosition.y);
|
|
spots = parsedSpots;
|
|
document.getElementById('bg-image').src = `web/assets/backgrounds/${currentBg}/bg.svg?v=${Date.now()}`;
|
|
renderSpots();
|
|
updatePosMarker();
|
|
} catch(e) {
|
|
UI.log('Failed to load spots.json', 'error');
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function renderSpots() {
|
|
const listEl = document.getElementById('spot-list');
|
|
const markersEl = document.getElementById('markers-layer');
|
|
const mapContainer = document.getElementById('map-container');
|
|
if (!listEl || !markersEl || !mapContainer) return;
|
|
|
|
const w = bounds.xMax - bounds.xMin;
|
|
const h = bounds.yMax - bounds.yMin;
|
|
mapContainer.style.setProperty('--w', w);
|
|
mapContainer.style.setProperty('--h', h);
|
|
|
|
listEl.innerHTML = '';
|
|
markersEl.innerHTML = '';
|
|
|
|
spots.forEach(spot => {
|
|
// List item
|
|
const opt = document.createElement('option');
|
|
opt.value = spot.id;
|
|
opt.textContent = `${spot.label.padEnd(10, ' ')} [${spot.x}, ${spot.y}]`;
|
|
listEl.appendChild(opt);
|
|
|
|
// Map marker
|
|
const marker = document.createElement('div');
|
|
const xPct = ((spot.x - bounds.xMin) / w) * 100;
|
|
const yPct = ((bounds.yMax - spot.y) / h) * 100;
|
|
|
|
marker.className = 'spot-marker';
|
|
marker.style = `position: absolute; left: ${xPct}%; top: ${yPct}%; transform: translate(-50%, -50%); width: 18px; height: 18px; border-radius: 50%; background: rgba(0, 230, 118, 0.4); border: 2px solid var(--accent-green); cursor: pointer; transition: all 0.2s;`;
|
|
marker.title = spot.label;
|
|
|
|
marker.addEventListener('click', () => {
|
|
listEl.value = spot.id;
|
|
document.getElementById('btn-go').disabled = false;
|
|
executeMove(spot);
|
|
});
|
|
|
|
markersEl.appendChild(marker);
|
|
});
|
|
}
|
|
|
|
async function executeMove(spot) {
|
|
if (!isHomed) {
|
|
const msg = 'Cannot move: System is not homed! Please click FORCE HOME in the control panel first.';
|
|
UI.log(msg, 'error');
|
|
alert(msg);
|
|
return;
|
|
}
|
|
|
|
const mode = document.getElementById('mode-select').value;
|
|
UI.log(`Moving to ${spot.label} (${spot.x}, ${spot.y}) [${mode}]`, 'success');
|
|
await Motion.goto(spot.x, spot.y, mode);
|
|
}
|
|
|
|
export default {
|
|
mount(container) {
|
|
syncStateFromStorage();
|
|
container.innerHTML = buildHTML();
|
|
|
|
updateHomingUI();
|
|
loadSpots();
|
|
|
|
// ── Idle Effects Selection ──────────────────────────────────
|
|
const idleSelect = document.getElementById('bc-idle-mode-select');
|
|
if (idleSelect) {
|
|
idleSelect.value = IdleEffects.getMode();
|
|
idleSelect.addEventListener('change', () => {
|
|
IdleEffects.setMode(idleSelect.value);
|
|
});
|
|
}
|
|
|
|
// ── 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();
|
|
initQuickSequences(
|
|
currentBg,
|
|
() => spots,
|
|
() => ({ steps1, steps2 }),
|
|
(s1, s2) => {
|
|
steps1 = s1; steps2 = s2;
|
|
updatePosMarker();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Settings Sliders ──────────────────────────────────────
|
|
const sSpeed = document.getElementById('bc-slider-speed');
|
|
const sAccel = document.getElementById('bc-slider-accel');
|
|
|
|
if (sSpeed) {
|
|
sSpeed.addEventListener('input', (e) => {
|
|
Settings.speed = parseInt(e.target.value);
|
|
document.getElementById('speed-val').textContent = Settings.speed;
|
|
sendSettings();
|
|
});
|
|
}
|
|
|
|
if (sAccel) {
|
|
sAccel.addEventListener('input', (e) => {
|
|
Settings.accel = parseInt(e.target.value);
|
|
document.getElementById('accel-val').textContent = Settings.accel;
|
|
sendSettings();
|
|
});
|
|
}
|
|
|
|
// ── Homing Logic ──────────────────────────────────────────
|
|
document.getElementById('btn-force-home').addEventListener('click', async () => {
|
|
isHomed = true;
|
|
localStorage.setItem('wiji_homed', 'true');
|
|
updateHomingUI();
|
|
|
|
UI.log('Homing all motors...', 'info');
|
|
if (BLE.isConnected()) {
|
|
try {
|
|
await BLE.write('HOMEALL');
|
|
} catch (e) {
|
|
UI.log(e.message, 'error');
|
|
}
|
|
} else {
|
|
steps1 = IK.ARM.HOME_STEPS.m1;
|
|
steps2 = IK.ARM.HOME_STEPS.m2;
|
|
Motion.setSteps(steps1, steps2);
|
|
saveSteps();
|
|
updatePosMarker();
|
|
UI.log('[sim] HOMEALL', 'info');
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-park').addEventListener('click', () => {
|
|
const parkSpot = spots.find(s => s.id === 'PARK');
|
|
if (parkSpot) executeMove(parkSpot);
|
|
});
|
|
|
|
// ── List Selection ────────────────────────────────────────
|
|
const listEl = document.getElementById('spot-list');
|
|
const goBtn = document.getElementById('btn-go');
|
|
|
|
listEl.addEventListener('change', () => {
|
|
goBtn.disabled = !listEl.value;
|
|
});
|
|
|
|
goBtn.addEventListener('click', () => {
|
|
const selectedId = listEl.value;
|
|
const spot = spots.find(s => s.id === selectedId);
|
|
if (spot) {
|
|
executeMove(spot);
|
|
}
|
|
});
|
|
|
|
// ── Manual Pick Mode ──────────────────────────────────────
|
|
const btnManualPick = document.getElementById('btn-manual-pick');
|
|
const mapContainer = document.getElementById('map-container');
|
|
|
|
if (btnManualPick && mapContainer) {
|
|
btnManualPick.addEventListener('click', () => {
|
|
isManualPickMode = !isManualPickMode;
|
|
if (isManualPickMode) {
|
|
btnManualPick.classList.add('btn-success');
|
|
btnManualPick.classList.remove('btn-secondary');
|
|
mapContainer.style.cursor = 'crosshair';
|
|
} else {
|
|
btnManualPick.classList.remove('btn-success');
|
|
btnManualPick.classList.add('btn-secondary');
|
|
mapContainer.style.cursor = 'default';
|
|
}
|
|
});
|
|
|
|
mapContainer.addEventListener('click', (e) => {
|
|
if (!isManualPickMode) return;
|
|
// Ignore clicks on existing markers
|
|
if (e.target.classList.contains('spot-marker')) return;
|
|
|
|
const rect = mapContainer.getBoundingClientRect();
|
|
const xClick = e.clientX - rect.left;
|
|
const yClick = e.clientY - rect.top;
|
|
|
|
const w = bounds.xMax - bounds.xMin;
|
|
const h = bounds.yMax - bounds.yMin;
|
|
|
|
const x = bounds.xMin + (xClick / rect.width) * w;
|
|
const y = bounds.yMax - (yClick / rect.height) * h;
|
|
|
|
const check = IK.checkWorkspace(x, y);
|
|
if (!check.ok) {
|
|
UI.log(check.reason, 'warning');
|
|
return;
|
|
}
|
|
|
|
// Execute move
|
|
if (listEl) listEl.value = ''; // Deselect predefined spots
|
|
goBtn.disabled = true;
|
|
|
|
executeMove({ id: 'manual', label: `Manual [${x.toFixed(1)}, ${y.toFixed(1)}]`, x, y });
|
|
|
|
// Turn off mode automatically (one-shot)
|
|
isManualPickMode = false;
|
|
btnManualPick.classList.remove('btn-success');
|
|
btnManualPick.classList.add('btn-secondary');
|
|
mapContainer.style.cursor = 'default';
|
|
});
|
|
}
|
|
|
|
// ── Full Screen Mode ──────────────────────────────────────
|
|
const btnFullscreen = document.getElementById('btn-fullscreen');
|
|
const mapCard = document.getElementById('map-card');
|
|
const fullscreenIcon = document.getElementById('fullscreen-icon');
|
|
const fullscreenText = document.getElementById('fullscreen-text');
|
|
|
|
if (btnFullscreen && mapCard) {
|
|
btnFullscreen.addEventListener('click', () => {
|
|
isFullscreen = !isFullscreen;
|
|
if (isFullscreen) {
|
|
document.body.classList.add('board-fullscreen');
|
|
mapCard.classList.add('fullscreen-card');
|
|
fullscreenIcon.textContent = '✖';
|
|
fullscreenText.textContent = 'Exit';
|
|
} else {
|
|
document.body.classList.remove('board-fullscreen');
|
|
mapCard.classList.remove('fullscreen-card');
|
|
fullscreenIcon.textContent = '⛶';
|
|
fullscreenText.textContent = 'Full Screen';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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();
|
|
updatePosMarker();
|
|
}
|
|
};
|
|
document.addEventListener('ble:status', onBLEStatus);
|
|
|
|
// Sync position on mount if connected
|
|
if (BLE.isConnected()) {
|
|
BLE.write('POS').catch(() => {});
|
|
sendSettings();
|
|
} else {
|
|
updatePosMarker();
|
|
}
|
|
|
|
initQuickSequences(
|
|
currentBg,
|
|
() => spots,
|
|
() => ({ steps1, steps2 }),
|
|
(s1, s2) => {
|
|
steps1 = s1; steps2 = s2;
|
|
updatePosMarker();
|
|
}
|
|
);
|
|
|
|
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
|
},
|
|
|
|
unmount() {
|
|
document.body.classList.remove('board-fullscreen');
|
|
eventCleanup.forEach(fn => fn());
|
|
eventCleanup = [];
|
|
}
|
|
};
|