added board control
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
import BLE from '../ble.js';
|
||||
import UI from '../ui.js';
|
||||
import IK from '../kinematics.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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
</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 class="card" style="display:flex; flex-direction:column; padding: 12px; position:relative;">
|
||||
<div class="card-header"><span class="card-title">Interactive Map</span></div>
|
||||
|
||||
<div style="flex:1; display:flex; align-items:center; justify-content:center; overflow:hidden;">
|
||||
<div id="map-container" style="position: relative; width: 100%; max-width: 800px; aspect-ratio: 300/175; 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%; height: 100%; object-fit: cover; position: absolute; top:0; left:0; border-radius: 8px;" />
|
||||
<div id="markers-layer" style="position: absolute; top:0; left:0; width:100%; height:100%;"></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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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="erratic">Erratic (Coming Soon)</option>
|
||||
<option value="random">Random (Coming Soon)</option>
|
||||
<option value="snaky">Snaky (Coming Soon)</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>
|
||||
|
||||
</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';
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
} else {
|
||||
spots = data.spots || [];
|
||||
if (data.bounds) bounds = data.bounds;
|
||||
}
|
||||
document.getElementById('bg-image').src = `web/assets/backgrounds/${currentBg}/bg.svg?v=${Date.now()}`;
|
||||
renderSpots();
|
||||
} catch(e) {
|
||||
UI.log('Failed to load spots.json', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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.aspectRatio = `${w}/${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) {
|
||||
UI.log('Cannot move: System is not homed! Please click HOME ALL.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = document.getElementById('mode-select').value;
|
||||
if (mode !== 'direct') {
|
||||
UI.log(`Mode '${mode}' not fully implemented yet! Using direct move.`, 'warn');
|
||||
}
|
||||
|
||||
// Calculate IK
|
||||
const wsCheck = IK.checkWorkspace(spot.x, spot.y);
|
||||
if (!wsCheck.ok) {
|
||||
UI.log(`⛔ ${wsCheck.reason}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = IK.solve(spot.x, spot.y);
|
||||
if (!res.reachable) {
|
||||
UI.log(`Target ${spot.label} is unreachable geometrically.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (IK.armsCrossed(res.theta1, res.theta2)) {
|
||||
UI.log(`Target ${spot.label} rejected: elbows crossed!`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newSteps1 = IK.radToSteps(res.theta1);
|
||||
const newSteps2 = IK.radToSteps(res.theta2);
|
||||
const delta1 = newSteps1 - steps1;
|
||||
const delta2 = newSteps2 - steps2;
|
||||
|
||||
steps1 = newSteps1;
|
||||
steps2 = newSteps2;
|
||||
saveSteps();
|
||||
updatePositionReadout();
|
||||
|
||||
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
|
||||
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
|
||||
|
||||
UI.log(`Moving to ${spot.label} (${spot.x}, ${spot.y})`, 'success');
|
||||
|
||||
if (BLE.isConnected()) {
|
||||
try {
|
||||
await BLE.write(cmd1);
|
||||
await BLE.write(cmd2);
|
||||
} catch (e) {
|
||||
UI.log(`BLE error: ${e.message}`, 'error');
|
||||
}
|
||||
} else {
|
||||
UI.log(`[sim] ${cmd1} ${cmd2}`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
mount(container) {
|
||||
syncStateFromStorage();
|
||||
container.innerHTML = buildHTML();
|
||||
|
||||
updateHomingUI();
|
||||
loadSpots();
|
||||
|
||||
// ── 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();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
|
||||
// Sync position on mount if connected
|
||||
if (BLE.isConnected()) {
|
||||
BLE.write('POS').catch(() => {});
|
||||
} else {
|
||||
updatePositionReadout();
|
||||
}
|
||||
|
||||
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
||||
},
|
||||
|
||||
unmount() {
|
||||
eventCleanup.forEach(fn => fn());
|
||||
eventCleanup = [];
|
||||
}
|
||||
};
|
||||
+12
-10
@@ -57,6 +57,18 @@ const HomeSection = {
|
||||
<span class="card-title">Sections</span>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<button class="sidebar-nav-item" data-route="board-control"
|
||||
onclick="document.getElementById('nav-board-control').click()"
|
||||
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">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Board Control</div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Interactive map navigation</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)">
|
||||
@@ -68,16 +80,6 @@ const HomeSection = {
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Manually jog each motor, visualize arm position</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-nav-item" style="padding:14px 16px;background:var(--surface-2);opacity:0.45;cursor:default">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Board Control</div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Coming soon</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,10 +45,23 @@ const PX_L2 = IK.ARM.l2 * SV.SCALE;
|
||||
const PX_D2 = IK.ARM.d2 * SV.SCALE;
|
||||
|
||||
// ── Section state ─────────────────────────────────────────────────
|
||||
let steps1 = IK.ARM.HOME_STEPS.m1;
|
||||
let steps2 = IK.ARM.HOME_STEPS.m2;
|
||||
let steps1 = 0;
|
||||
let steps2 = 0;
|
||||
let eventCleanup = [];
|
||||
|
||||
function syncStateFromStorage() {
|
||||
const 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;
|
||||
}
|
||||
|
||||
function saveSteps() {
|
||||
if (localStorage.getItem('wiji_homed') === 'true') {
|
||||
localStorage.setItem('wiji_steps1', steps1);
|
||||
localStorage.setItem('wiji_steps2', steps2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SVG element refs ──────────────────────────────────────────────
|
||||
let svgEl;
|
||||
// Real arm elements
|
||||
@@ -135,6 +148,7 @@ function updateReadouts(t1, t2, ex, ey) {
|
||||
if (elTheta2) elTheta2.textContent = IK.radToDeg(t2).toFixed(1) + '°';
|
||||
if (elSteps1) elSteps1.textContent = steps1;
|
||||
if (elSteps2) elSteps2.textContent = steps2;
|
||||
saveSteps();
|
||||
}
|
||||
|
||||
// ── Ghost arm show/hide ───────────────────────────────────────────
|
||||
@@ -218,6 +232,7 @@ async function zeroMotor(motor) {
|
||||
if (motor === 'ALL') {
|
||||
steps1 = IK.ARM.HOME_STEPS.m1;
|
||||
steps2 = IK.ARM.HOME_STEPS.m2;
|
||||
localStorage.setItem('wiji_homed', 'true');
|
||||
} else if (motor === 1) {
|
||||
steps1 = IK.ARM.HOME_STEPS.m1;
|
||||
} else {
|
||||
@@ -616,6 +631,7 @@ function buildHTML() {
|
||||
|
||||
const StepperTestSection = {
|
||||
mount(container) {
|
||||
syncStateFromStorage();
|
||||
container.innerHTML = buildHTML();
|
||||
|
||||
// ── Cache refs ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user