Initial commit
This commit is contained in:
File diff suppressed because one or more lines are too long
+204
@@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WijiBoard – Control Dashboard</title>
|
||||
<meta name="description" content="WijiBoard SCARA arm control dashboard. Web Bluetooth interface for the ESP32-S3 stepper controller." />
|
||||
|
||||
<!-- Shared styles -->
|
||||
<link rel="stylesheet" href="web/styles/base.css" />
|
||||
<link rel="stylesheet" href="web/styles/components.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
|
||||
<!-- ── Sidebar overlay (click to close) ──────────────────────── -->
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- ── Sidebar ───────────────────────────────────────────────── -->
|
||||
<nav class="sidebar" id="sidebar" aria-label="Main navigation">
|
||||
|
||||
<span class="sidebar-section-label">Navigation</span>
|
||||
|
||||
<a class="sidebar-nav-item active" id="nav-home"
|
||||
href="#home" data-route="home"
|
||||
role="button" aria-label="Go to Home">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Home
|
||||
</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" 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>
|
||||
Stepper Test
|
||||
</a>
|
||||
|
||||
<!-- Spacer + coming soon -->
|
||||
<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" 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>
|
||||
Board Control
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav-item" style="opacity:0.35;cursor:default" aria-disabled="true">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/>
|
||||
</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">
|
||||
<!-- Hamburger -->
|
||||
<button class="hamburger" id="hamburger-btn"
|
||||
aria-label="Toggle sidebar" aria-expanded="true" aria-controls="sidebar">
|
||||
<div class="hamburger-bar"></div>
|
||||
<div class="hamburger-bar"></div>
|
||||
<div class="hamburger-bar"></div>
|
||||
</button>
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="topbar-brand">
|
||||
<div class="brand-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v10l9 5 9-5V7L12 2Z"
|
||||
stroke="#448aff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="12" r="2.5" fill="#448aff" opacity="0.7"/>
|
||||
</svg>
|
||||
</div>
|
||||
WijiBoard
|
||||
</div>
|
||||
|
||||
<!-- BLE actions -->
|
||||
<div class="topbar-actions">
|
||||
<!-- BLE status pill -->
|
||||
<div class="ble-status" id="ble-status" aria-live="polite">
|
||||
<div class="ble-dot" id="ble-dot"></div>
|
||||
<span class="ble-label" id="ble-label">Disconnected</span>
|
||||
</div>
|
||||
|
||||
<!-- Connect / Disconnect button -->
|
||||
<button class="btn btn-sm btn-primary" id="btn-ble-connect"
|
||||
aria-label="Connect to WijiBoard via Bluetooth">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6.5 6.5l11 11M17.5 6.5l-5 5 5 5"/>
|
||||
<path d="M6 17.5l5-5-5-5"/>
|
||||
</svg>
|
||||
<span class="btn-text">Connect</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Router outlet -->
|
||||
<main class="main-content" id="view" role="main" aria-label="Page content">
|
||||
<!-- Sections are injected here by router.js -->
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── App bootstrap (ES module entry point) ─────────────────── -->
|
||||
<script type="module">
|
||||
const V = Date.now(); // cache-buster – ensures fresh module load on every page refresh
|
||||
const { default: Router } = await import(`./web/js/router.js?v=${V}`);
|
||||
const { default: BLE } = await import(`./web/js/ble.js?v=${V}`);
|
||||
const { default: UI } = await import(`./web/js/ui.js?v=${V}`);
|
||||
const { default: HomeSection } = await import(`./web/js/sections/home.js?v=${V}`);
|
||||
const { default: StepperSection } = await import(`./web/js/sections/stepper-test.js?v=${V}`);
|
||||
|
||||
// ── Register routes ────────────────────────────────────────────
|
||||
Router.register('home', HomeSection);
|
||||
Router.register('stepper-test', StepperSection);
|
||||
|
||||
// ── Sidebar toggle logic ───────────────────────────────────────
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const hamburger = document.getElementById('hamburger-btn');
|
||||
const mainContent = document.getElementById('view');
|
||||
|
||||
let sidebarOpen = true;
|
||||
|
||||
function setSidebarOpen(open) {
|
||||
sidebarOpen = open;
|
||||
sidebar.classList.toggle('collapsed', !open);
|
||||
overlay.classList.toggle('visible', open && window.innerWidth < 900);
|
||||
hamburger.classList.toggle('open', open);
|
||||
hamburger.setAttribute('aria-expanded', open);
|
||||
|
||||
// Shift main content on desktop
|
||||
const mc = hamburger.closest('.app-shell')?.querySelector('.main-content');
|
||||
if (mc) mc.classList.toggle('sidebar-collapsed', !open);
|
||||
}
|
||||
|
||||
hamburger.addEventListener('click', () => setSidebarOpen(!sidebarOpen));
|
||||
overlay.addEventListener('click', () => setSidebarOpen(false));
|
||||
|
||||
// Close sidebar on small screens when nav link clicked
|
||||
document.querySelectorAll('.sidebar-nav-item[data-route]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const route = link.dataset.route;
|
||||
Router.navigate('#' + route);
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Collapse sidebar by default on small screens
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
|
||||
// ── Wire BLE events to shared UI ──────────────────────────────
|
||||
UI.initBLEListeners();
|
||||
|
||||
// Connect button handler (initial state)
|
||||
document.getElementById('btn-ble-connect').addEventListener('click', () => {
|
||||
if (BLE.isConnected()) {
|
||||
BLE.disconnect();
|
||||
} else {
|
||||
BLE.connect().catch(e => {
|
||||
if (!e.message?.includes('cancelled') && !e.name?.includes('NotFound')) {
|
||||
UI.log(`Connect error: ${e.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Init router (last step) ────────────────────────────────────
|
||||
Router.init('#view', '#home');
|
||||
|
||||
// ── Check Web Bluetooth availability ──────────────────────────
|
||||
if (!navigator.bluetooth) {
|
||||
setTimeout(() => {
|
||||
UI.log('⚠ Web Bluetooth not available. Use Chrome or Edge on localhost/HTTPS.', 'error');
|
||||
}, 600);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
; PlatformIO Project Configuration
|
||||
; Board: Waveshare ESP32-S3-Zero (or compatible ESP32-S3)
|
||||
; Project: WijiBoard – BLE SCARA Stepper Controller
|
||||
|
||||
[env:esp32-s3-devkitc-1]
|
||||
platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
|
||||
; USB CDC on boot – Serial Monitor works over native USB
|
||||
build_flags =
|
||||
-D ARDUINO_USB_MODE=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
|
||||
; Adjust to your COM port
|
||||
monitor_speed = 115200
|
||||
upload_protocol = esptool
|
||||
|
||||
lib_deps =
|
||||
; AccelStepper for smooth, non-blocking stepper control
|
||||
waspinator/AccelStepper @ ^1.64
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* WijiBoard – BLE Stepper Controller Firmware
|
||||
* src/main.cpp
|
||||
*
|
||||
* Board : Waveshare ESP32-S3-Zero (or compatible ESP32-S3)
|
||||
* Steppers: Two 28BYJ-48 via ULN2003 (AccelStepper, FULL4WIRE mode)
|
||||
*
|
||||
* BLE Command Characteristic (WRITE_NR):
|
||||
* S1+<n> → Step motor 1 CW n steps
|
||||
* S1-<n> → Step motor 1 CCW n steps
|
||||
* S2+<n> → Step motor 2 CW n steps
|
||||
* S2-<n> → Step motor 2 CCW n steps
|
||||
* SPD:<n> → Set max speed for both motors (steps/sec)
|
||||
* ACC:<n> → Set acceleration for both motors (steps/sec²)
|
||||
* HOME1 → Zero motor 1 position
|
||||
* HOME2 → Zero motor 2 position
|
||||
* POS → Request current positions (triggers NOTIFY)
|
||||
*
|
||||
* BLE Status Characteristic (NOTIFY):
|
||||
* P:<s1>,<s2> → Current step positions for motor 1 & 2
|
||||
*
|
||||
* UUIDs – must match web/js/ble.js exactly:
|
||||
* Service : a0b1c2d3-e4f5-6789-abcd-ef0123456700
|
||||
* Command : a0b1c2d3-e4f5-6789-abcd-ef0123456701
|
||||
* Status : a0b1c2d3-e4f5-6789-abcd-ef0123456702
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <AccelStepper.h>
|
||||
#include <BLEDevice.h>
|
||||
#include <BLEServer.h>
|
||||
#include <BLEUtils.h>
|
||||
#include <BLE2902.h>
|
||||
|
||||
// ─── Motor pin mapping (28BYJ-48 / ULN2003) ──────────────────────
|
||||
// Motor 1 – Shoulder
|
||||
#define M1_IN1 4
|
||||
#define M1_IN2 5
|
||||
#define M1_IN3 6
|
||||
#define M1_IN4 7
|
||||
|
||||
// Motor 2 – Elbow
|
||||
#define M2_IN1 8
|
||||
#define M2_IN2 9
|
||||
#define M2_IN3 10
|
||||
#define M2_IN4 11
|
||||
|
||||
// ─── Stepper constants ────────────────────────────────────────────
|
||||
#define STEPS_PER_REV 2048
|
||||
#define DEFAULT_SPEED 600.0f // steps/sec
|
||||
#define DEFAULT_ACCEL 100.0f // steps/sec²
|
||||
|
||||
// ─── BLE UUIDs ────────────────────────────────────────────────────
|
||||
#define SERVICE_UUID "a0b1c2d3-e4f5-6789-abcd-ef0123456700"
|
||||
#define CMD_UUID "a0b1c2d3-e4f5-6789-abcd-ef0123456701"
|
||||
#define STATUS_UUID "a0b1c2d3-e4f5-6789-abcd-ef0123456702"
|
||||
#define DEVICE_NAME "WijiBoard"
|
||||
|
||||
// ─── Stepper objects ──────────────────────────────────────────────
|
||||
AccelStepper stepper1(AccelStepper::FULL4WIRE, M1_IN1, M1_IN2, M1_IN3, M1_IN4);
|
||||
AccelStepper stepper2(AccelStepper::FULL4WIRE, M2_IN1, M2_IN2, M2_IN3, M2_IN4);
|
||||
|
||||
// ─── BLE globals ─────────────────────────────────────────────────
|
||||
BLEServer* pServer = nullptr;
|
||||
BLECharacteristic* pCmdChar = nullptr;
|
||||
BLECharacteristic* pStatusChar = nullptr;
|
||||
bool bleConnected = false;
|
||||
|
||||
// ─── Status notify throttle ───────────────────────────────────────
|
||||
unsigned long lastNotify = 0;
|
||||
const unsigned long NOTIFY_INTERVAL_MS = 200;
|
||||
|
||||
// ─── Forward declarations ─────────────────────────────────────────
|
||||
void parseCommand(const String& cmd);
|
||||
void sendPosition();
|
||||
|
||||
// ─── BLE Server Callbacks ─────────────────────────────────────────
|
||||
class ServerCallbacks : public BLEServerCallbacks {
|
||||
void onConnect(BLEServer*) override {
|
||||
bleConnected = true;
|
||||
Serial.println("[BLE] Client connected");
|
||||
}
|
||||
void onDisconnect(BLEServer*) override {
|
||||
bleConnected = false;
|
||||
Serial.println("[BLE] Client disconnected – restarting advertising");
|
||||
BLEDevice::startAdvertising();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Command Characteristic Callbacks ────────────────────────────
|
||||
class CmdCallbacks : public BLECharacteristicCallbacks {
|
||||
void onWrite(BLECharacteristic* pChar) override {
|
||||
String val = pChar->getValue().c_str();
|
||||
val.trim();
|
||||
if (val.length() == 0) return;
|
||||
Serial.printf("[CMD] Received: '%s'\n", val.c_str());
|
||||
parseCommand(val);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Command parser ───────────────────────────────────────────────
|
||||
void parseCommand(const String& cmd) {
|
||||
// S1+<n> or S1-<n>
|
||||
if (cmd.startsWith("S1") && cmd.length() > 2) {
|
||||
long steps = cmd.substring(2).toInt(); // '+' or '-' prefix handled by toInt()
|
||||
stepper1.move(steps);
|
||||
Serial.printf("[S1] Move %+ld steps\n", steps);
|
||||
return;
|
||||
}
|
||||
// S2+<n> or S2-<n>
|
||||
if (cmd.startsWith("S2") && cmd.length() > 2) {
|
||||
long steps = cmd.substring(2).toInt();
|
||||
stepper2.move(steps);
|
||||
Serial.printf("[S2] Move %+ld steps\n", steps);
|
||||
return;
|
||||
}
|
||||
// SPD:<n>
|
||||
if (cmd.startsWith("SPD:")) {
|
||||
float spd = cmd.substring(4).toFloat();
|
||||
stepper1.setMaxSpeed(spd);
|
||||
stepper2.setMaxSpeed(spd);
|
||||
Serial.printf("[CFG] Max speed → %.0f steps/sec\n", spd);
|
||||
return;
|
||||
}
|
||||
// ACC:<n>
|
||||
if (cmd.startsWith("ACC:")) {
|
||||
float acc = cmd.substring(4).toFloat();
|
||||
stepper1.setAcceleration(acc);
|
||||
stepper2.setAcceleration(acc);
|
||||
Serial.printf("[CFG] Acceleration → %.0f steps/sec²\n", acc);
|
||||
return;
|
||||
}
|
||||
// HOME1
|
||||
if (cmd == "HOME1") {
|
||||
stepper1.setCurrentPosition(0);
|
||||
Serial.println("[S1] Zeroed");
|
||||
sendPosition();
|
||||
return;
|
||||
}
|
||||
// HOME2
|
||||
if (cmd == "HOME2") {
|
||||
stepper2.setCurrentPosition(0);
|
||||
Serial.println("[S2] Zeroed");
|
||||
sendPosition();
|
||||
return;
|
||||
}
|
||||
// POS – explicit position request
|
||||
if (cmd == "POS") {
|
||||
sendPosition();
|
||||
return;
|
||||
}
|
||||
Serial.printf("[CMD] Unknown command: '%s'\n", cmd.c_str());
|
||||
}
|
||||
|
||||
// ─── Send current positions via BLE NOTIFY ────────────────────────
|
||||
void sendPosition() {
|
||||
if (!bleConnected || !pStatusChar) return;
|
||||
String pos = "P:" + String(stepper1.currentPosition()) +
|
||||
"," + String(stepper2.currentPosition());
|
||||
pStatusChar->setValue(pos.c_str());
|
||||
pStatusChar->notify();
|
||||
Serial.printf("[POS] %s\n", pos.c_str());
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────
|
||||
void setup() {
|
||||
// Fix: zero TX buffer to eliminate CDC post-write stall
|
||||
Serial.setTxBufferSize(0);
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println("\n[BOOT] WijiBoard Stepper Controller");
|
||||
|
||||
// ── Init steppers ──────────────────────────────────────────
|
||||
stepper1.setMaxSpeed(DEFAULT_SPEED);
|
||||
stepper1.setAcceleration(DEFAULT_ACCEL);
|
||||
stepper1.setCurrentPosition(0);
|
||||
|
||||
stepper2.setMaxSpeed(DEFAULT_SPEED);
|
||||
stepper2.setAcceleration(DEFAULT_ACCEL);
|
||||
stepper2.setCurrentPosition(0);
|
||||
|
||||
Serial.println("[STEP] Steppers initialized");
|
||||
|
||||
// ── Init BLE ───────────────────────────────────────────────
|
||||
BLEDevice::init(DEVICE_NAME);
|
||||
|
||||
pServer = BLEDevice::createServer();
|
||||
pServer->setCallbacks(new ServerCallbacks());
|
||||
|
||||
// Service
|
||||
BLEService* pService = pServer->createService(SERVICE_UUID);
|
||||
|
||||
// Command characteristic (Write without response)
|
||||
pCmdChar = pService->createCharacteristic(
|
||||
CMD_UUID,
|
||||
BLECharacteristic::PROPERTY_WRITE |
|
||||
BLECharacteristic::PROPERTY_WRITE_NR
|
||||
);
|
||||
pCmdChar->setCallbacks(new CmdCallbacks());
|
||||
|
||||
// Status characteristic (Notify)
|
||||
pStatusChar = pService->createCharacteristic(
|
||||
STATUS_UUID,
|
||||
BLECharacteristic::PROPERTY_NOTIFY
|
||||
);
|
||||
pStatusChar->addDescriptor(new BLE2902());
|
||||
|
||||
pService->start();
|
||||
|
||||
// Advertising
|
||||
BLEAdvertising* pAdv = BLEDevice::getAdvertising();
|
||||
pAdv->addServiceUUID(SERVICE_UUID);
|
||||
pAdv->setScanResponse(true);
|
||||
pAdv->setMinPreferred(0x06);
|
||||
BLEDevice::startAdvertising();
|
||||
|
||||
Serial.printf("[BLE] Advertising as '%s' – ready!\n", DEVICE_NAME);
|
||||
}
|
||||
|
||||
// ─── Loop ─────────────────────────────────────────────────────────
|
||||
void loop() {
|
||||
// Run steppers (non-blocking AccelStepper)
|
||||
stepper1.run();
|
||||
stepper2.run();
|
||||
|
||||
// Periodic position notify while motors are moving
|
||||
if (bleConnected) {
|
||||
unsigned long now = millis();
|
||||
bool moving = stepper1.isRunning() || stepper2.isRunning();
|
||||
if (moving && (now - lastNotify >= NOTIFY_INTERVAL_MS)) {
|
||||
lastNotify = now;
|
||||
sendPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* WijiBoard – BLE Singleton
|
||||
* web/js/ble.js
|
||||
*
|
||||
* Provides a singleton BLE object with:
|
||||
* BLE.connect() → connects to device, gets characteristics
|
||||
* BLE.disconnect() → disconnects
|
||||
* BLE.write(cmd) → writes ASCII command (write-without-response)
|
||||
* BLE.isConnected() → boolean
|
||||
* BLE.on(event, cb) → subscribe to 'connected' | 'disconnected' | 'status'
|
||||
* BLE.off(event, cb) → unsubscribe
|
||||
*
|
||||
* Command characteristic (WRITE_NR):
|
||||
* S1+<n> → step motor 1 clockwise n steps
|
||||
* S1-<n> → step motor 1 counter-clockwise n steps
|
||||
* S2+<n> → step motor 2 clockwise n steps
|
||||
* S2-<n> → step motor 2 counter-clockwise n steps
|
||||
* SPD:<n> → set max speed (steps/sec)
|
||||
* ACC:<n> → set acceleration (steps/sec²)
|
||||
* HOME → zero both steppers
|
||||
* POS → request position report
|
||||
*
|
||||
* Status characteristic (NOTIFY):
|
||||
* P:<s1>,<s2> → current step positions for motor 1 & 2
|
||||
*/
|
||||
|
||||
// ── UUIDs – must match src/main.cpp exactly ──────────────────────
|
||||
const SERVICE_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456700';
|
||||
const CMD_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456701';
|
||||
const STATUS_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456702';
|
||||
const DEVICE_NAME = 'WijiBoard';
|
||||
|
||||
const BLE = (() => {
|
||||
// ── Internal state ──────────────────────────────────────────
|
||||
let device = null;
|
||||
let server = null;
|
||||
let cmdChar = null;
|
||||
let statusChar = null;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// ── Tiny event emitter ──────────────────────────────────────
|
||||
const listeners = {};
|
||||
|
||||
function emit(event, ...args) {
|
||||
(listeners[event] || []).forEach(cb => {
|
||||
try { cb(...args); } catch (e) { console.error('[BLE] listener error', e); }
|
||||
});
|
||||
}
|
||||
|
||||
function on(event, cb) {
|
||||
if (!listeners[event]) listeners[event] = [];
|
||||
listeners[event].push(cb);
|
||||
}
|
||||
|
||||
function off(event, cb) {
|
||||
if (!listeners[event]) return;
|
||||
listeners[event] = listeners[event].filter(fn => fn !== cb);
|
||||
}
|
||||
|
||||
// ── Disconnect handler ──────────────────────────────────────
|
||||
function onDisconnected() {
|
||||
console.log('[BLE] Device disconnected');
|
||||
device = null;
|
||||
server = null;
|
||||
cmdChar = null;
|
||||
statusChar = null;
|
||||
emit('disconnected');
|
||||
}
|
||||
|
||||
// ── Status notification handler ─────────────────────────────
|
||||
function onStatusNotification(event) {
|
||||
const value = new TextDecoder().decode(event.target.value);
|
||||
emit('status', value.trim());
|
||||
}
|
||||
|
||||
// ── connect() ───────────────────────────────────────────────
|
||||
async function connect() {
|
||||
if (!navigator.bluetooth) {
|
||||
throw new Error('Web Bluetooth API not available. Use Chrome/Edge over HTTPS or localhost.');
|
||||
}
|
||||
|
||||
emit('connecting');
|
||||
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ name: DEVICE_NAME }],
|
||||
optionalServices: [SERVICE_UUID],
|
||||
});
|
||||
|
||||
device.addEventListener('gattserverdisconnected', onDisconnected);
|
||||
|
||||
server = await device.gatt.connect();
|
||||
|
||||
const service = await server.getPrimaryService(SERVICE_UUID);
|
||||
|
||||
cmdChar = await service.getCharacteristic(CMD_UUID);
|
||||
|
||||
// Status notifications (optional – firmware may not have this char yet)
|
||||
try {
|
||||
statusChar = await service.getCharacteristic(STATUS_UUID);
|
||||
await statusChar.startNotifications();
|
||||
statusChar.addEventListener('characteristicvaluechanged', onStatusNotification);
|
||||
} catch {
|
||||
console.warn('[BLE] Status characteristic not available – notifications disabled');
|
||||
}
|
||||
|
||||
emit('connected', device.name);
|
||||
}
|
||||
|
||||
// ── disconnect() ────────────────────────────────────────────
|
||||
async function disconnect() {
|
||||
if (device?.gatt?.connected) {
|
||||
device.gatt.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── write() ─────────────────────────────────────────────────
|
||||
async function write(cmd) {
|
||||
if (!cmdChar) throw new Error('BLE not connected');
|
||||
await cmdChar.writeValueWithoutResponse(encoder.encode(cmd));
|
||||
emit('sent', cmd);
|
||||
}
|
||||
|
||||
// ── isConnected() ───────────────────────────────────────────
|
||||
function isConnected() {
|
||||
return !!(device?.gatt?.connected && cmdChar);
|
||||
}
|
||||
|
||||
// ── getDeviceName() ─────────────────────────────────────────
|
||||
function getDeviceName() {
|
||||
return device?.name ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
on, off, connect, disconnect, write, isConnected, getDeviceName,
|
||||
SERVICE_UUID, CMD_UUID, STATUS_UUID, DEVICE_NAME,
|
||||
};
|
||||
})();
|
||||
|
||||
export default BLE;
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* WijiBoard – Kinematics (exact port of PositionControl.cpp)
|
||||
* web/js/kinematics.js
|
||||
*
|
||||
* ── Mechanism: symmetric 5-bar parallel linkage ─────────────────
|
||||
*
|
||||
* END EFFECTOR (x, y)
|
||||
* / \
|
||||
* L2 (110) L2 (110)
|
||||
* / \
|
||||
* ELBOW1 ELBOW2
|
||||
* \ /
|
||||
* L1 (85) L1 (85)
|
||||
* \ /
|
||||
* MOTOR1 (-d2,0) MOTOR2 (+d2,0)
|
||||
* | |
|
||||
* [===BASE===] (d=25.8 mm wide)
|
||||
* d2=12.9 mm
|
||||
*
|
||||
* Both motors are mounted in the centre mechanism box.
|
||||
* Motor 1 is at (-d2, 0), Motor 2 is at (+d2, 0).
|
||||
* Each motor drives a proximal arm (l1). The distal arms (l2)
|
||||
* connect the elbows to the shared end-effector.
|
||||
*
|
||||
* IK: given target (x, y), solve θ1 and θ2 independently:
|
||||
* Motor 1 sees the target at (x - d2, y) from its pivot.
|
||||
* Motor 2 sees the target at (x + d2, y) from its pivot.
|
||||
* Each uses the standard 2-link IK (law of cosines).
|
||||
*
|
||||
* Source: lib/Position/PositionControl.cpp (nerd-sniped/WijiBoard)
|
||||
*/
|
||||
|
||||
// ── Exact constants from PositionControl.cpp ─────────────────────
|
||||
const ARM = {
|
||||
d: 25.8, // full motor separation (mm)
|
||||
d2: 12.9, // half motor separation — motor 1 at (-d2,0), motor 2 at (+d2,0)
|
||||
l1: 85.0, // proximal link length (mm)
|
||||
l2: 110.0, // distal link length (mm)
|
||||
STEPS_PER_REV: 2048,
|
||||
STEP_ANGLE_DEG: 360 / 2048, // ≈ 0.17578125°
|
||||
};
|
||||
|
||||
// ── Workspace limits ──────────────────────────────────────────────
|
||||
const LIMITS = {
|
||||
// Outer bounding box (the board surface)
|
||||
X_MIN: -150,
|
||||
X_MAX: 150,
|
||||
Y_MIN: -30, // numbers sit slightly below Y=0
|
||||
Y_MAX: 145,
|
||||
|
||||
// Centre exclusion zone (the motor/mechanism box)
|
||||
// Motors are at ±12.9 mm; box is a bit larger to account for the housing
|
||||
BOX_HALF_W: 22, // ±22 mm in X (tune to real hardware)
|
||||
BOX_HALF_H: 22, // 0..22 mm in Y (box sits above the base line)
|
||||
BOX_Y_MIN: -5,
|
||||
BOX_Y_MAX: 22,
|
||||
};
|
||||
|
||||
// ── Letter / number position lookup table ────────────────────────
|
||||
// Directly ported from PositionControl.cpp
|
||||
const LOOKUP_TABLE = {
|
||||
'Q': { x: -66.5, y: 91.6 },
|
||||
'W': { x: 70.8, y: 92.0 },
|
||||
'E': { x: -41.4, y: 125.5 },
|
||||
'R': { x: -44.0, y: 96.0 },
|
||||
'T': { x: 1.3, y: 97.8 },
|
||||
'Y': { x: 117.8, y: 91.7 },
|
||||
'U': { x: 22.5, y: 98.3 },
|
||||
'I': { x: 53.5, y: 124.5 },
|
||||
'O': { x: -112.0, y: 82.0 },
|
||||
'P': { x: -90.0, y: 89.0 },
|
||||
'A': { x: -143.0, y: 99.0 },
|
||||
'S': { x: -19.8, y: 98.0 },
|
||||
'D': { x: -68.5, y: 121.7 },
|
||||
'F': { x: -19.5, y: 127.0 },
|
||||
'G': { x: 4.0, y: 128.4 },
|
||||
'H': { x: 31.3, y: 127.3 },
|
||||
'J': { x: 72.5, y: 121.0 },
|
||||
'K': { x: 93.0, y: 116.8 },
|
||||
'L': { x: 114.3, y: 110.2 },
|
||||
'Z': { x: 130.0, y: 75.0 },
|
||||
'X': { x: 95.9, y: 87.3 },
|
||||
'C': { x: -94.0, y: 116.0 },
|
||||
'V': { x: 44.5, y: 97.0 },
|
||||
'B': { x: -119.0, y: 109.0 },
|
||||
'N': { x: -135.4, y: 75.1 },
|
||||
'M': { x: 132.0, y: 100.5 },
|
||||
|
||||
'+': { x: -110.0, y: 1.0 },
|
||||
'-': { x: 110.0, y: 1.0 },
|
||||
'*': { x: 110.0, y: -24.0 },
|
||||
',': { x: -110.0, y: -24.0 },
|
||||
|
||||
'0': { x: -130.4, y: 44.2 },
|
||||
'1': { x: -101.1, y: 53.3 },
|
||||
'2': { x: -71.5, y: 60.4 },
|
||||
'3': { x: -41.7, y: 65.2 },
|
||||
'4': { x: -13.0, y: 66.5 },
|
||||
'5': { x: 16.3, y: 68.0 },
|
||||
'6': { x: 45.8, y: 64.0 },
|
||||
'7': { x: 75.7, y: 61.1 },
|
||||
'8': { x: 103.6, y: 53.8 },
|
||||
'9': { x: 132.1, y: 45.0 },
|
||||
};
|
||||
|
||||
// ── IK solver — exact port of calculateInverseKinematics() ───────
|
||||
/**
|
||||
* Compute motor angles for a given target end-effector position.
|
||||
* Exactly mirrors the C++ implementation in PositionControl.cpp.
|
||||
*
|
||||
* @param {number} x Target X in mm (origin = midpoint between motors)
|
||||
* @param {number} y Target Y in mm
|
||||
* @returns {{ theta1: number, theta2: number, reachable: boolean }}
|
||||
* theta1 / theta2 in RADIANS (motor 1 = left, motor 2 = right)
|
||||
*/
|
||||
function solve(x, y) {
|
||||
const { d2, l1, l2 } = ARM;
|
||||
|
||||
// ── Motor 1 (left pivot at -d2, 0) ───────────────────────────
|
||||
const xmd = x - d2; // target X relative to left motor
|
||||
const s = Math.sqrt(xmd * xmd + y * y); // distance: left motor → target
|
||||
const cosW1 = (l2 * l2 - s * s - l1 * l1) / (-2 * l1 * s);
|
||||
if (cosW1 < -1 || cosW1 > 1) return { theta1: 0, theta2: 0, reachable: false };
|
||||
const q = Math.atan2(y, xmd);
|
||||
const w1 = Math.acos(cosW1);
|
||||
const theta1 = q - w1;
|
||||
|
||||
// ── Motor 2 (right pivot at +d2, 0) ──────────────────────────
|
||||
const xpd = x + d2; // target X relative to right motor
|
||||
const t = Math.sqrt(xpd * xpd + y * y); // distance: right motor → target
|
||||
const cosW2 = (l2 * l2 - t * t - l1 * l1) / (-2 * l1 * t);
|
||||
if (cosW2 < -1 || cosW2 > 1) return { theta1: 0, theta2: 0, reachable: false };
|
||||
const r = Math.atan2(y, xpd);
|
||||
const w2 = Math.acos(cosW2);
|
||||
const theta2 = r + w2;
|
||||
|
||||
return { theta1, theta2, reachable: true };
|
||||
}
|
||||
|
||||
// ── Forward kinematics ───────────────────────────────────────────
|
||||
/**
|
||||
* Given motor angles, compute elbow and end-effector positions.
|
||||
* The end-effector is found as the intersection of the two distal
|
||||
* link circles — this is the FK complement to the 5-bar IK above.
|
||||
*
|
||||
* In practice for visualisation we just re-derive the elbow
|
||||
* positions from each motor.
|
||||
*
|
||||
* @param {number} theta1 Motor 1 angle (radians)
|
||||
* @param {number} theta2 Motor 2 angle (radians)
|
||||
* @returns {{
|
||||
* elbow1: {x,y}, elbow2: {x,y},
|
||||
* endX: number, endY: number
|
||||
* }}
|
||||
*/
|
||||
function forward(theta1, theta2) {
|
||||
const { d2, l1, l2 } = ARM;
|
||||
|
||||
// Elbow 1 (tip of motor 1's proximal link)
|
||||
const e1x = -d2 + l1 * Math.cos(theta1);
|
||||
const e1y = l1 * Math.sin(theta1);
|
||||
|
||||
// Elbow 2 (tip of motor 2's proximal link)
|
||||
const e2x = d2 + l1 * Math.cos(theta2);
|
||||
const e2y = l1 * Math.sin(theta2);
|
||||
|
||||
// End-effector: intersection of circle(elbow1, l2) and circle(elbow2, l2)
|
||||
// Use the same approach as the IK: each distal link points from its elbow to the EE.
|
||||
// For visualisation accuracy, reconstruct EE by reversing the IK:
|
||||
// From IK: theta1 = q - w1 → q = atan2(y, x - d2)
|
||||
// We know theta1 and the elbow position, so EE is at l2 along some direction.
|
||||
// Simplest: use circle-circle intersection of the two elbow-radius-l2 circles.
|
||||
const dx = e2x - e1x;
|
||||
const dy = e2y - e1y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 1e-6 || dist > 2 * l2) {
|
||||
// Degenerate / unreachable — just average the two elbows
|
||||
return {
|
||||
elbow1: { x: e1x, y: e1y },
|
||||
elbow2: { x: e2x, y: e2y },
|
||||
endX: (e1x + e2x) / 2,
|
||||
endY: (e1y + e2y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
const a = dist / 2;
|
||||
const h = Math.sqrt(l2 * l2 - a * a);
|
||||
const mx = (e1x + e2x) / 2;
|
||||
const my = (e1y + e2y) / 2;
|
||||
|
||||
// Two intersection candidates — pick the one with higher Y (the "up" configuration)
|
||||
const px1 = mx + h * (dy / dist);
|
||||
const py1 = my - h * (dx / dist);
|
||||
const px2 = mx - h * (dy / dist);
|
||||
const py2 = my + h * (dx / dist);
|
||||
|
||||
const { endX, endY } = py1 > py2
|
||||
? { endX: px1, endY: py1 }
|
||||
: { endX: px2, endY: py2 };
|
||||
|
||||
return {
|
||||
elbow1: { x: e1x, y: e1y },
|
||||
elbow2: { x: e2x, y: e2y },
|
||||
endX, endY,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Workspace check ───────────────────────────────────────────────
|
||||
/**
|
||||
* Check whether a point is inside the valid workspace.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {{ ok: boolean, reason: string }}
|
||||
*/
|
||||
function checkWorkspace(x, y) {
|
||||
// Outer bounding box
|
||||
if (x < LIMITS.X_MIN || x > LIMITS.X_MAX)
|
||||
return { ok: false, reason: `X=${x.toFixed(1)} outside board limits [${LIMITS.X_MIN}, ${LIMITS.X_MAX}]` };
|
||||
if (y < LIMITS.Y_MIN || y > LIMITS.Y_MAX)
|
||||
return { ok: false, reason: `Y=${y.toFixed(1)} outside board limits [${LIMITS.Y_MIN}, ${LIMITS.Y_MAX}]` };
|
||||
|
||||
// Centre exclusion zone (mechanism box)
|
||||
if (
|
||||
x > -LIMITS.BOX_HALF_W && x < LIMITS.BOX_HALF_W &&
|
||||
y > LIMITS.BOX_Y_MIN && y < LIMITS.BOX_Y_MAX
|
||||
) {
|
||||
return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is inside the mechanism exclusion zone` };
|
||||
}
|
||||
|
||||
// IK reachability
|
||||
const { reachable } = solve(x, y);
|
||||
if (!reachable) return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is outside arm reach` };
|
||||
|
||||
return { ok: true, reason: '' };
|
||||
}
|
||||
|
||||
// ── Lookup ────────────────────────────────────────────────────────
|
||||
function lookup(char) {
|
||||
return LOOKUP_TABLE[char.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
// ── Unit converters ───────────────────────────────────────────────
|
||||
function stepsToRad(steps) {
|
||||
return (steps / ARM.STEPS_PER_REV) * 2 * Math.PI;
|
||||
}
|
||||
function radToSteps(rad) {
|
||||
return Math.round((rad / (2 * Math.PI)) * ARM.STEPS_PER_REV);
|
||||
}
|
||||
function stepsToDeg(steps) {
|
||||
return steps * ARM.STEP_ANGLE_DEG;
|
||||
}
|
||||
function radToDeg(rad) {
|
||||
return rad * 180 / Math.PI;
|
||||
}
|
||||
|
||||
export default {
|
||||
ARM, LIMITS, LOOKUP_TABLE,
|
||||
solve, forward, checkWorkspace, lookup,
|
||||
stepsToRad, radToSteps, stepsToDeg, radToDeg,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* WijiBoard – Hash-based SPA Router
|
||||
* web/js/router.js
|
||||
*
|
||||
* Usage:
|
||||
* Router.register('home', homeModule);
|
||||
* Router.register('stepper-test', stepperModule);
|
||||
* Router.init('#view', '#home'); // outlet element id, default route
|
||||
*
|
||||
* Each section module must export:
|
||||
* { mount(containerEl), unmount() }
|
||||
*/
|
||||
const Router = (() => {
|
||||
const routes = new Map(); // hash → module
|
||||
let outlet = null; // DOM element to render into
|
||||
let current = null; // currently active route key
|
||||
let currentMod = null; // currently mounted module
|
||||
|
||||
// ── Register a route ─────────────────────────────────────────
|
||||
function register(hash, module) {
|
||||
routes.set(hash, module);
|
||||
}
|
||||
|
||||
// ── Navigate to a route ──────────────────────────────────────
|
||||
async function navigate(hash) {
|
||||
const key = hash.replace(/^#/, '');
|
||||
|
||||
if (!routes.has(key)) {
|
||||
console.warn(`[Router] Unknown route: "${key}"`);
|
||||
return;
|
||||
}
|
||||
if (key === current) return; // already there
|
||||
|
||||
// Unmount old section
|
||||
if (currentMod?.unmount) {
|
||||
try { await currentMod.unmount(); } catch (e) { console.error('[Router] unmount error', e); }
|
||||
}
|
||||
|
||||
// Clear outlet
|
||||
outlet.innerHTML = '';
|
||||
|
||||
// Mount new section
|
||||
const mod = routes.get(key);
|
||||
currentMod = mod;
|
||||
current = key;
|
||||
|
||||
try {
|
||||
await mod.mount(outlet);
|
||||
} catch (e) {
|
||||
console.error('[Router] mount error', e);
|
||||
outlet.innerHTML = `<p style="color:var(--accent-red);padding:24px">
|
||||
Failed to load section: ${e.message}</p>`;
|
||||
}
|
||||
|
||||
// Update sidebar active state
|
||||
document.querySelectorAll('.sidebar-nav-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === key);
|
||||
});
|
||||
|
||||
// Update URL hash (without re-triggering hashchange)
|
||||
if (location.hash.replace('#','') !== key) {
|
||||
history.replaceState(null, '', `#${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init: wire up outlet + default route ─────────────────────
|
||||
function init(outletSelector, defaultHash = '#home') {
|
||||
outlet = document.querySelector(outletSelector);
|
||||
if (!outlet) throw new Error(`[Router] Outlet "${outletSelector}" not found`);
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', () => {
|
||||
navigate(location.hash || defaultHash);
|
||||
});
|
||||
|
||||
// Handle initial load
|
||||
const initial = location.hash && location.hash.length > 1
|
||||
? location.hash
|
||||
: defaultHash;
|
||||
|
||||
// Small defer to let the page fully render first
|
||||
requestAnimationFrame(() => navigate(initial));
|
||||
}
|
||||
|
||||
// ── Active route getter ───────────────────────────────────────
|
||||
function getActive() { return current; }
|
||||
|
||||
return { register, navigate, init, getActive };
|
||||
})();
|
||||
|
||||
export default Router;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* WijiBoard – Home Section
|
||||
* web/js/sections/home.js
|
||||
*
|
||||
* A minimal placeholder home section.
|
||||
*/
|
||||
|
||||
import BLE from '../ble.js';
|
||||
|
||||
const HomeSection = {
|
||||
_cleanup: [],
|
||||
|
||||
mount(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-page fade-in">
|
||||
<div class="section-header">
|
||||
<h2>WijiBoard</h2>
|
||||
<p>WiFi Spirit Board — SCARA arm controller. Select a section from the sidebar to get started.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="margin-bottom:16px">
|
||||
<!-- Status card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Connection</span>
|
||||
</div>
|
||||
<div class="status-row" style="margin-bottom:10px">
|
||||
<span class="status-label">BLE</span>
|
||||
<span class="status-value" id="home-ble-status">
|
||||
${BLE.isConnected() ? `✓ ${BLE.getDeviceName()}` : 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;color:var(--text-muted)">
|
||||
Use the <strong style="color:var(--text-secondary)">Connect</strong> button
|
||||
in the top bar to pair with the WijiBoard via Bluetooth.
|
||||
The device name is <code style="color:var(--accent-blue)">WijiBoard</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- About card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">About</span>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;line-height:1.8;color:var(--text-secondary)">
|
||||
This dashboard controls a two-arm SCARA robot that moves a planchette
|
||||
across a spirit board. It uses <strong style="color:var(--text-primary)">Web Bluetooth</strong>
|
||||
to communicate with an ESP32-S3 running two 28BYJ-48 stepper motors
|
||||
with AccelStepper.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections overview -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Sections</span>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<button class="sidebar-nav-item" data-route="stepper-test"
|
||||
onclick="document.getElementById('nav-stepper-test').click()"
|
||||
style="padding:14px 16px;background:var(--surface-2)">
|
||||
<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>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Stepper Test</div>
|
||||
<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>
|
||||
`;
|
||||
|
||||
// Keep home BLE status label in sync
|
||||
const statusEl = container.querySelector('#home-ble-status');
|
||||
const update = () => {
|
||||
if (statusEl) statusEl.textContent = BLE.isConnected()
|
||||
? `✓ ${BLE.getDeviceName()}`
|
||||
: 'Not connected';
|
||||
};
|
||||
BLE.on('connected', update);
|
||||
BLE.on('disconnected', update);
|
||||
this._cleanup = [
|
||||
() => BLE.off('connected', update),
|
||||
() => BLE.off('disconnected', update),
|
||||
];
|
||||
},
|
||||
|
||||
unmount() {
|
||||
this._cleanup.forEach(fn => fn());
|
||||
this._cleanup = [];
|
||||
},
|
||||
};
|
||||
|
||||
export default HomeSection;
|
||||
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* WijiBoard – Stepper Test Section
|
||||
* web/js/sections/stepper-test.js
|
||||
*
|
||||
* 5-bar parallel linkage visualiser + IK-based click-to-move.
|
||||
*
|
||||
* Mechanism geometry (from PositionControl.cpp):
|
||||
* Motor 1 at (-12.9, 0) mm Motor 2 at (+12.9, 0) mm
|
||||
* Proximal links: l1 = 85 mm Distal links: l2 = 110 mm
|
||||
*
|
||||
* IK flow: click XY → IK.solve(x,y) → {θ1, θ2} → steps → BLE
|
||||
* FK flow: steps → radians → IK.forward(θ1, θ2) → draw SVG
|
||||
*/
|
||||
|
||||
import BLE from '../ble.js';
|
||||
import IK from '../kinematics.js';
|
||||
import UI from '../ui.js';
|
||||
|
||||
// ── SVG viewport ──────────────────────────────────────────────────
|
||||
// We map the real workspace onto the SVG canvas.
|
||||
// Real workspace: X ∈ [-155, 155], Y ∈ [-35, 150]
|
||||
// We add some margin and invert Y (SVG y grows downward).
|
||||
|
||||
const SV = {
|
||||
W: 480, H: 380,
|
||||
|
||||
// World→SVG transform: scale + offset so the board fits nicely
|
||||
SCALE: 1.35, // px per mm
|
||||
// Origin (world 0,0) maps to this SVG point:
|
||||
OX: 240, // ≈ centre horizontally
|
||||
OY: 295, // base line — motors sit here
|
||||
|
||||
// Convenience: world mm → SVG px
|
||||
wx(worldX) { return this.OX + worldX * this.SCALE; },
|
||||
wy(worldY) { return this.OY - worldY * this.SCALE; },
|
||||
|
||||
// SVG px → world mm
|
||||
svgToWorldX(sx) { return (sx - this.OX) / this.SCALE; },
|
||||
svgToWorldY(sy) { return -(sy - this.OY) / this.SCALE; },
|
||||
};
|
||||
|
||||
// ── Arm length in px (for readable references) ────────────────────
|
||||
const PX_L1 = IK.ARM.l1 * SV.SCALE;
|
||||
const PX_L2 = IK.ARM.l2 * SV.SCALE;
|
||||
const PX_D2 = IK.ARM.d2 * SV.SCALE;
|
||||
|
||||
// ── Section state ─────────────────────────────────────────────────
|
||||
let steps1 = 0;
|
||||
let steps2 = 0;
|
||||
let eventCleanup = [];
|
||||
|
||||
// ── SVG element refs ──────────────────────────────────────────────
|
||||
let svgEl;
|
||||
// Real arm elements
|
||||
let arm1Prox, arm1Dist, arm2Prox, arm2Dist;
|
||||
let elbow1, elbow2, endEff, coordLabel, xhH, xhV;
|
||||
// Ghost arm
|
||||
let ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist;
|
||||
let ghostElbow1, ghostElbow2, ghostEnd, ghostLabel;
|
||||
// Target marker
|
||||
let targetMarker;
|
||||
|
||||
// ── Readout refs ──────────────────────────────────────────────────
|
||||
let elCurrX, elCurrY, elTheta1, elTheta2, elSteps1, elSteps2;
|
||||
let simBadge;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// SVG rendering helpers
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setLine(el, x1, y1, x2, y2) {
|
||||
el.setAttribute('x1', x1); el.setAttribute('y1', y1);
|
||||
el.setAttribute('x2', x2); el.setAttribute('y2', y2);
|
||||
}
|
||||
function setCircle(el, cx, cy) {
|
||||
el.setAttribute('cx', cx); el.setAttribute('cy', cy);
|
||||
}
|
||||
|
||||
/** Render the 5-bar arm from two motor angles. */
|
||||
function renderArm(theta1, theta2, isGhost = false) {
|
||||
const { elbow1: e1, elbow2: e2, endX, endY } = IK.forward(theta1, theta2);
|
||||
|
||||
const motor1sx = SV.wx(-IK.ARM.d2); const motor1sy = SV.wy(0);
|
||||
const motor2sx = SV.wx( IK.ARM.d2); const motor2sy = SV.wy(0);
|
||||
const elbow1sx = SV.wx(e1.x); const elbow1sy = SV.wy(e1.y);
|
||||
const elbow2sx = SV.wx(e2.x); const elbow2sy = SV.wy(e2.y);
|
||||
const endsx = SV.wx(endX); const endsy = SV.wy(endY);
|
||||
|
||||
// Guard: forward() can return NaN for degenerate angles – skip render
|
||||
if (!isFinite(endX) || !isFinite(endY)) return { endsx: SV.OX, endsy: SV.OY };
|
||||
|
||||
if (isGhost) {
|
||||
setLine(ghost1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy);
|
||||
setLine(ghost1Dist, elbow1sx, elbow1sy, endsx, endsy);
|
||||
setLine(ghost2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
|
||||
setLine(ghost2Dist, elbow2sx, elbow2sy, endsx, endsy);
|
||||
setCircle(ghostElbow1, elbow1sx, elbow1sy);
|
||||
setCircle(ghostElbow2, elbow2sx, elbow2sy);
|
||||
setCircle(ghostEnd, endsx, endsy);
|
||||
ghostLabel.setAttribute('x', endsx + 10);
|
||||
ghostLabel.setAttribute('y', endsy);
|
||||
} else {
|
||||
setLine(arm1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy);
|
||||
setLine(arm1Dist, elbow1sx, elbow1sy, endsx, endsy);
|
||||
setLine(arm2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
|
||||
setLine(arm2Dist, elbow2sx, elbow2sy, endsx, endsy);
|
||||
setCircle(elbow1, elbow1sx, elbow1sy);
|
||||
setCircle(elbow2, elbow2sx, elbow2sy);
|
||||
setCircle(endEff, endsx, endsy);
|
||||
|
||||
// Crosshairs
|
||||
xhH.setAttribute('x1', endsx - 13); xhH.setAttribute('y1', endsy);
|
||||
xhH.setAttribute('x2', endsx + 13); xhH.setAttribute('y2', endsy);
|
||||
xhV.setAttribute('x1', endsx); xhV.setAttribute('y1', endsy - 13);
|
||||
xhV.setAttribute('x2', endsx); xhV.setAttribute('y2', endsy + 13);
|
||||
|
||||
coordLabel.textContent = `(${endX.toFixed(1)}, ${endY.toFixed(1)}) mm`;
|
||||
updateReadouts(theta1, theta2, endX, endY);
|
||||
}
|
||||
|
||||
return { endsx, endsy };
|
||||
}
|
||||
|
||||
function renderArmFromSteps() {
|
||||
const t1 = IK.stepsToRad(steps1);
|
||||
const t2 = IK.stepsToRad(steps2);
|
||||
renderArm(t1, t2);
|
||||
}
|
||||
|
||||
function updateReadouts(t1, t2, ex, ey) {
|
||||
if (elCurrX) elCurrX.textContent = isFinite(ex) ? ex.toFixed(1) : '–';
|
||||
if (elCurrY) elCurrY.textContent = isFinite(ey) ? ey.toFixed(1) : '–';
|
||||
if (elTheta1) elTheta1.textContent = IK.radToDeg(t1).toFixed(1) + '°';
|
||||
if (elTheta2) elTheta2.textContent = IK.radToDeg(t2).toFixed(1) + '°';
|
||||
if (elSteps1) elSteps1.textContent = steps1;
|
||||
if (elSteps2) elSteps2.textContent = steps2;
|
||||
}
|
||||
|
||||
// ── Ghost arm show/hide ───────────────────────────────────────────
|
||||
function showGhost(theta1, theta2, labelText) {
|
||||
const ghosts = [ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
|
||||
ghostElbow1, ghostElbow2, ghostEnd, ghostLabel];
|
||||
ghosts.forEach(el => el.style.display = '');
|
||||
renderArm(theta1, theta2, true);
|
||||
ghostLabel.textContent = labelText;
|
||||
}
|
||||
function hideGhost() {
|
||||
[ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
|
||||
ghostElbow1, ghostElbow2, ghostEnd, ghostLabel]
|
||||
.forEach(el => el.style.display = 'none');
|
||||
}
|
||||
|
||||
// ── Target marker ─────────────────────────────────────────────────
|
||||
function showTargetMarker(sx, sy) {
|
||||
targetMarker.setAttribute('cx', sx);
|
||||
targetMarker.setAttribute('cy', sy);
|
||||
targetMarker.style.display = '';
|
||||
targetMarker.classList.remove('target-pulse');
|
||||
void targetMarker.offsetWidth; // force reflow
|
||||
targetMarker.classList.add('target-pulse');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// IK movement
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function moveToXY(targetX, targetY) {
|
||||
// Check workspace limits first
|
||||
const wsCheck = IK.checkWorkspace(targetX, targetY);
|
||||
if (!wsCheck.ok) {
|
||||
UI.log(`⛔ ${wsCheck.reason}`, 'warn');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { theta1, theta2 } = IK.solve(targetX, targetY);
|
||||
|
||||
const newSteps1 = IK.radToSteps(theta1);
|
||||
const newSteps2 = IK.radToSteps(theta2);
|
||||
const delta1 = newSteps1 - steps1;
|
||||
const delta2 = newSteps2 - steps2;
|
||||
|
||||
steps1 = newSteps1;
|
||||
steps2 = newSteps2;
|
||||
renderArm(theta1, theta2);
|
||||
|
||||
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
|
||||
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
|
||||
|
||||
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] IK→ θ1=${IK.radToDeg(theta1).toFixed(1)}° θ2=${IK.radToDeg(theta2).toFixed(1)}° | ${cmd1} ${cmd2}`, 'info');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Manual jog
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function jogMotor(motor, delta) {
|
||||
if (motor === 1) steps1 += delta;
|
||||
else steps2 += delta;
|
||||
renderArmFromSteps();
|
||||
const cmd = `S${motor}${delta >= 0 ? '+' : ''}${delta}`;
|
||||
BLE.isConnected()
|
||||
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
|
||||
: UI.log(`[sim] ${cmd}`, 'info');
|
||||
}
|
||||
|
||||
async function zeroMotor(motor) {
|
||||
if (motor === 1) steps1 = 0;
|
||||
else steps2 = 0;
|
||||
renderArmFromSteps();
|
||||
const cmd = `HOME${motor}`;
|
||||
BLE.isConnected()
|
||||
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
|
||||
: UI.log(`[sim] ${cmd}`, 'info');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// SVG workspace zones (pre-computed)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildZones() {
|
||||
const L = IK.LIMITS;
|
||||
// Outer bounding box rect
|
||||
const bx = SV.wx(L.X_MIN);
|
||||
const by = SV.wy(L.Y_MAX);
|
||||
const bw = (L.X_MAX - L.X_MIN) * SV.SCALE;
|
||||
const bh = (L.Y_MAX - L.Y_MIN) * SV.SCALE;
|
||||
|
||||
// Mechanism exclusion box
|
||||
const ex = SV.wx(-L.BOX_HALF_W);
|
||||
const ey = SV.wy(L.BOX_Y_MAX);
|
||||
const ew = L.BOX_HALF_W * 2 * SV.SCALE;
|
||||
const eh = (L.BOX_Y_MAX - L.BOX_Y_MIN) * SV.SCALE;
|
||||
|
||||
// Motor positions
|
||||
const m1x = SV.wx(-IK.ARM.d2);
|
||||
const m2x = SV.wx( IK.ARM.d2);
|
||||
const my = SV.wy(0);
|
||||
|
||||
// Scale ruler
|
||||
const r0x = SV.wx(0); const r0y = SV.wy(-20);
|
||||
const r1x = SV.wx(50);
|
||||
|
||||
return `
|
||||
<!-- Board boundary (outer workspace) -->
|
||||
<rect x="${bx}" y="${by}" width="${bw}" height="${bh}"
|
||||
fill="rgba(68,138,255,0.04)"
|
||||
stroke="rgba(68,138,255,0.30)" stroke-width="1.5" stroke-dasharray="6 4"
|
||||
rx="4"/>
|
||||
|
||||
<!-- Mechanism exclusion zone -->
|
||||
<rect x="${ex}" y="${ey}" width="${ew}" height="${eh}"
|
||||
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"
|
||||
font-size="8" fill="rgba(255,82,82,0.7)" font-family="monospace">
|
||||
NO-GO ZONE
|
||||
</text>
|
||||
|
||||
<!-- Axes -->
|
||||
<line x1="${SV.wx(-160)}" y1="${my}" x2="${SV.wx(160)}" y2="${my}"
|
||||
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
|
||||
<line x1="${SV.OX}" y1="${SV.wy(-40)}" x2="${SV.OX}" y2="${SV.wy(155)}"
|
||||
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
|
||||
<text x="${SV.wx(163)}" y="${my + 4}" font-size="8"
|
||||
fill="rgba(255,255,255,0.2)" font-family="monospace">+X</text>
|
||||
<text x="${SV.OX + 4}" y="${SV.wy(158)}" font-size="8"
|
||||
fill="rgba(255,255,255,0.2)" font-family="monospace">+Y</text>
|
||||
|
||||
<!-- Scale ruler: 50 mm -->
|
||||
<line x1="${r0x}" y1="${r0y}" x2="${r1x}" y2="${r0y}"
|
||||
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<line x1="${r0x}" y1="${r0y - 4}" x2="${r0x}" y2="${r0y + 4}"
|
||||
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"
|
||||
font-size="8" fill="rgba(255,255,255,0.3)" font-family="monospace">50 mm</text>
|
||||
|
||||
<!-- Motor markers -->
|
||||
<circle cx="${m1x}" cy="${my}" r="5"
|
||||
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
|
||||
<circle cx="${m2x}" cy="${my}" r="5"
|
||||
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
|
||||
<text x="${m1x}" y="${my + 16}" text-anchor="middle"
|
||||
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M1</text>
|
||||
<text x="${m2x}" y="${my + 16}" text-anchor="middle"
|
||||
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M2</text>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// HTML template
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildHTML() {
|
||||
return `
|
||||
<style>
|
||||
.test-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 260px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) { .test-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
#scara-svg { cursor: crosshair; display: block; width: 100%; }
|
||||
|
||||
@keyframes targetPulse {
|
||||
0% { r: 5px; opacity: 1; }
|
||||
100% { r: 20px; opacity: 0; }
|
||||
}
|
||||
.target-pulse { animation: targetPulse 0.45s ease-out forwards; }
|
||||
|
||||
.pos-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;
|
||||
}
|
||||
.pos-box {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 7px 8px; text-align: center;
|
||||
}
|
||||
.pos-val { font-size:1.05rem; font-weight:700; font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
|
||||
.pos-val.xy { color: var(--accent-green); }
|
||||
.pos-val.ang { color: var(--accent-blue); }
|
||||
.pos-val.stp { color: var(--text-secondary); font-size:0.88rem; }
|
||||
.pos-sub { font-size:0.62rem; font-weight:600; text-transform:uppercase;
|
||||
letter-spacing:0.09em; color:var(--text-muted); margin-top:2px; }
|
||||
.jog-compact { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.jog-motor { background:var(--surface-2); border:1px solid var(--border);
|
||||
border-radius:var(--radius-md); padding:12px;
|
||||
display:flex; flex-direction:column; gap:8px; }
|
||||
.jog-title { font-size:0.72rem; font-weight:700; text-transform:uppercase;
|
||||
letter-spacing:0.09em; color:var(--text-muted); }
|
||||
.quick-steps { display:flex; gap:4px; flex-wrap:wrap; }
|
||||
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
|
||||
border-radius:var(--radius-xs); }
|
||||
</style>
|
||||
|
||||
<div class="section-page fade-in">
|
||||
<div class="section-header">
|
||||
<h2>Stepper Test</h2>
|
||||
<p>Click anywhere on the SCARA map to move via IK. Red zone = mechanism exclusion. Dashed box = board limits.</p>
|
||||
</div>
|
||||
|
||||
<div class="test-grid">
|
||||
<!-- ── SCARA map ─────────────────────────────────────── -->
|
||||
<div class="card" style="padding:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||||
<span class="card-title">SCARA Map — click to move</span>
|
||||
<span id="sim-badge" style="font-size:0.67rem;padding:3px 8px;border-radius:100px;
|
||||
background:rgba(255,202,40,0.12);color:var(--accent-amber);
|
||||
border:1px solid rgba(255,202,40,0.25);font-weight:600">SIMULATION</span>
|
||||
</div>
|
||||
|
||||
<svg id="scara-svg" viewBox="0 0 ${SV.W} ${SV.H}" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background grid -->
|
||||
<defs>
|
||||
<pattern id="grid-pat" width="27" height="27" patternUnits="userSpaceOnUse"
|
||||
patternTransform="translate(${SV.OX % 27},${SV.OY % 27})">
|
||||
<path d="M 27 0 L 0 0 0 27" fill="none"
|
||||
stroke="rgba(255,255,255,0.035)" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="${SV.W}" height="${SV.H}" fill="#070a0f"/>
|
||||
<rect width="${SV.W}" height="${SV.H}" fill="url(#grid-pat)"/>
|
||||
|
||||
<!-- Workspace zones (board boundary, exclusion zone, axes) -->
|
||||
${buildZones()}
|
||||
|
||||
<!-- ── Ghost arm (hover preview) ─────────────────── -->
|
||||
<line id="ghost-1p" style="display:none"
|
||||
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line id="ghost-1d" style="display:none"
|
||||
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line id="ghost-2p" style="display:none"
|
||||
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line id="ghost-2d" style="display:none"
|
||||
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle id="ghost-e1" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
|
||||
<circle id="ghost-e2" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
|
||||
<circle id="ghost-end" r="5" style="display:none" fill="rgba(0,230,118,0.22)"/>
|
||||
<text id="ghost-label" font-size="9" fill="rgba(0,230,118,0.6)"
|
||||
font-family="monospace" style="display:none"/>
|
||||
|
||||
<!-- ── Real arm ──────────────────────────────────── -->
|
||||
<!-- Arm 1: proximal + distal -->
|
||||
<line id="arm1-prox"
|
||||
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(0)}"
|
||||
x2="${SV.wx(-IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
|
||||
stroke="#448aff" stroke-width="5" stroke-linecap="round"/>
|
||||
<line id="arm1-dist"
|
||||
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
|
||||
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
|
||||
stroke="#7eb8f7" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
|
||||
|
||||
<!-- Arm 2: proximal + distal -->
|
||||
<line id="arm2-prox"
|
||||
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(0)}"
|
||||
x2="${SV.wx(IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
|
||||
stroke="#f7a03c" stroke-width="5" stroke-linecap="round"/>
|
||||
<line id="arm2-dist"
|
||||
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
|
||||
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
|
||||
stroke="#ffd08a" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
|
||||
|
||||
<!-- Elbow joints -->
|
||||
<circle id="elbow1" r="5" fill="#a0c4ff" stroke="#0a0e18" stroke-width="1.5"
|
||||
cx="${SV.wx(-IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
|
||||
<circle id="elbow2" r="5" fill="#ffd08a" stroke="#0a0e18" stroke-width="1.5"
|
||||
cx="${SV.wx( IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
|
||||
|
||||
<!-- End effector -->
|
||||
<circle id="end-eff" r="7"
|
||||
cx="${SV.wx(0)}" cy="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
|
||||
fill="var(--accent-green)" stroke="#0a0e18" stroke-width="1.5"/>
|
||||
|
||||
<!-- Crosshair -->
|
||||
<line id="xh-h" stroke="rgba(0,230,118,0.5)" stroke-width="1"
|
||||
x1="0" y1="0" x2="0" y2="0"/>
|
||||
<line id="xh-v" stroke="rgba(0,230,118,0.5)" stroke-width="1"
|
||||
x1="0" y1="0" x2="0" y2="0"/>
|
||||
|
||||
<!-- Target marker -->
|
||||
<circle id="target-marker" r="5" style="display:none"
|
||||
fill="none" stroke="var(--accent-green)" stroke-width="1.5"/>
|
||||
|
||||
<!-- Coordinate label -->
|
||||
<text id="coord-label"
|
||||
x="${SV.OX}" y="${SV.H - 8}"
|
||||
text-anchor="middle" font-size="10"
|
||||
fill="rgba(0,230,118,0.65)" font-family="monospace">
|
||||
(0.0, 0.0) mm
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div style="display:flex;gap:16px;font-size:0.70rem;color:var(--text-muted);margin-top:6px;flex-wrap:wrap">
|
||||
<span style="color:rgba(68,138,255,0.8)">━ M1 (left)</span>
|
||||
<span style="color:rgba(247,160,60,0.8)">━ M2 (right)</span>
|
||||
<span style="color:rgba(255,82,82,0.7)">▪ NO-GO zone</span>
|
||||
<span style="color:rgba(68,138,255,0.5)">╌ board limits</span>
|
||||
<span>l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm d=${IK.ARM.d}mm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Right: controls ──────────────────────────────── -->
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<!-- Current position -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Current Position</span></div>
|
||||
<div class="pos-grid">
|
||||
<div class="pos-box">
|
||||
<div class="pos-val xy" id="curr-x">0.0</div>
|
||||
<div class="pos-sub">X (mm)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val xy" id="curr-y">0.0</div>
|
||||
<div class="pos-sub">Y (mm)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val ang" id="curr-t1">0.0°</div>
|
||||
<div class="pos-sub">θ1 (M1)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val ang" id="curr-t2">0.0°</div>
|
||||
<div class="pos-sub">θ2 (M2)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val stp" id="curr-s1">0</div>
|
||||
<div class="pos-sub">M1 steps</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val stp" id="curr-s2">0</div>
|
||||
<div class="pos-sub">M2 steps</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move to XY -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Move to XY</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<label class="form-label" style="width:18px">X</label>
|
||||
<input class="form-input" type="number" id="input-x"
|
||||
value="0" step="5" style="flex:1;text-align:center"/>
|
||||
<span class="form-label">mm</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<label class="form-label" style="width:18px">Y</label>
|
||||
<input class="form-input" type="number" id="input-y"
|
||||
value="100" step="5" style="flex:1;text-align:center"/>
|
||||
<span class="form-label">mm</span>
|
||||
</div>
|
||||
<button class="btn btn-success btn-full" id="btn-goto">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<span class="btn-text">Move to XY</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motion settings -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Motion</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Speed (steps/s)</label>
|
||||
<div class="range-row">
|
||||
<input class="form-range" type="range" id="speed-slider"
|
||||
min="100" max="1000" step="50" value="600"/>
|
||||
<span class="range-value" id="speed-val">600</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Accel (steps/s²)</label>
|
||||
<div class="range-row">
|
||||
<input class="form-range" type="range" id="accel-slider"
|
||||
min="50" max="500" step="25" value="100"/>
|
||||
<span class="range-value" id="accel-val">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /right panel -->
|
||||
</div><!-- /test-grid -->
|
||||
|
||||
<!-- Manual jog -->
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Manual Jog</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-muted)">Incremental step control — bypasses IK</span>
|
||||
</div>
|
||||
<div class="jog-compact">
|
||||
<!-- Motor 1 -->
|
||||
<div class="jog-motor">
|
||||
<div class="jog-title" style="color:rgba(68,138,255,0.9)">Motor 1 — Left</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-ccw">◀ CCW</button>
|
||||
<input class="form-input" type="number" id="jog-steps-1"
|
||||
value="50" min="1" max="2048"
|
||||
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
|
||||
<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('')}
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm btn-full" id="s1-zero">Zero M1</button>
|
||||
</div>
|
||||
<!-- Motor 2 -->
|
||||
<div class="jog-motor">
|
||||
<div class="jog-title" style="color:rgba(247,160,60,0.9)">Motor 2 — Right</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-ccw">◀ CCW</button>
|
||||
<input class="form-input" type="number" id="jog-steps-2"
|
||||
value="50" min="1" max="2048"
|
||||
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
|
||||
<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('')}
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm btn-full" id="s2-zero">Zero M2</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event log -->
|
||||
<div class="card">
|
||||
<div class="log-header">
|
||||
<span class="log-title">Event Log</span>
|
||||
<button class="log-clear-btn" id="clear-log-btn">Clear</button>
|
||||
</div>
|
||||
<div class="log-console" id="log-console" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Section lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const StepperTestSection = {
|
||||
mount(container) {
|
||||
container.innerHTML = buildHTML();
|
||||
|
||||
// ── Cache refs ──────────────────────────────────────────────
|
||||
svgEl = document.getElementById('scara-svg');
|
||||
arm1Prox = document.getElementById('arm1-prox');
|
||||
arm1Dist = document.getElementById('arm1-dist');
|
||||
arm2Prox = document.getElementById('arm2-prox');
|
||||
arm2Dist = document.getElementById('arm2-dist');
|
||||
elbow1 = document.getElementById('elbow1');
|
||||
elbow2 = document.getElementById('elbow2');
|
||||
endEff = document.getElementById('end-eff');
|
||||
coordLabel= document.getElementById('coord-label');
|
||||
xhH = document.getElementById('xh-h');
|
||||
xhV = document.getElementById('xh-v');
|
||||
|
||||
ghost1Prox = document.getElementById('ghost-1p');
|
||||
ghost1Dist = document.getElementById('ghost-1d');
|
||||
ghost2Prox = document.getElementById('ghost-2p');
|
||||
ghost2Dist = document.getElementById('ghost-2d');
|
||||
ghostElbow1 = document.getElementById('ghost-e1');
|
||||
ghostElbow2 = document.getElementById('ghost-e2');
|
||||
ghostEnd = document.getElementById('ghost-end');
|
||||
ghostLabel = document.getElementById('ghost-label');
|
||||
targetMarker= document.getElementById('target-marker');
|
||||
|
||||
elCurrX = document.getElementById('curr-x');
|
||||
elCurrY = document.getElementById('curr-y');
|
||||
elTheta1 = document.getElementById('curr-t1');
|
||||
elTheta2 = document.getElementById('curr-t2');
|
||||
elSteps1 = document.getElementById('curr-s1');
|
||||
elSteps2 = document.getElementById('curr-s2');
|
||||
simBadge = document.getElementById('sim-badge');
|
||||
|
||||
// ── SVG coordinate helpers ───────────────────────────────────
|
||||
function pointerToWorld(e) {
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const sx = (e.clientX - rect.left) * (SV.W / rect.width);
|
||||
const sy = (e.clientY - rect.top) * (SV.H / rect.height);
|
||||
return { wx: SV.svgToWorldX(sx), wy: SV.svgToWorldY(sy), sx, sy };
|
||||
}
|
||||
|
||||
// ── SVG hover ───────────────────────────────────────────────
|
||||
svgEl.addEventListener('mousemove', (e) => {
|
||||
const { wx, wy } = pointerToWorld(e);
|
||||
const ws = IK.checkWorkspace(wx, wy);
|
||||
if (ws.ok) {
|
||||
const { theta1, theta2 } = IK.solve(wx, wy);
|
||||
showGhost(theta1, theta2, `(${wx.toFixed(0)},${wy.toFixed(0)})`);
|
||||
svgEl.style.cursor = 'crosshair';
|
||||
} else {
|
||||
hideGhost();
|
||||
svgEl.style.cursor = 'not-allowed';
|
||||
}
|
||||
});
|
||||
svgEl.addEventListener('mouseleave', () => {
|
||||
hideGhost();
|
||||
svgEl.style.cursor = 'crosshair';
|
||||
});
|
||||
|
||||
// ── SVG click ───────────────────────────────────────────────
|
||||
svgEl.addEventListener('click', async (e) => {
|
||||
const { wx, wy, sx, sy } = pointerToWorld(e);
|
||||
const ws = IK.checkWorkspace(wx, wy);
|
||||
if (!ws.ok) {
|
||||
UI.log(`⛔ ${ws.reason}`, 'warn');
|
||||
return;
|
||||
}
|
||||
showTargetMarker(sx, sy);
|
||||
const xi = document.getElementById('input-x');
|
||||
const yi = document.getElementById('input-y');
|
||||
if (xi) xi.value = wx.toFixed(1);
|
||||
if (yi) yi.value = wy.toFixed(1);
|
||||
await moveToXY(wx, wy);
|
||||
});
|
||||
|
||||
// ── Move to XY button ────────────────────────────────────────
|
||||
document.getElementById('btn-goto').addEventListener('click', async () => {
|
||||
const x = parseFloat(document.getElementById('input-x').value);
|
||||
const y = parseFloat(document.getElementById('input-y').value);
|
||||
if (isNaN(x) || isNaN(y)) { UI.log('Invalid XY.', 'warn'); return; }
|
||||
await moveToXY(x, y);
|
||||
});
|
||||
|
||||
// ── 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-ccw').addEventListener('click', () => jogMotor(1, -gs(1)));
|
||||
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));
|
||||
|
||||
// ── Quick-step buttons ────────────────────────────────────────
|
||||
container.querySelectorAll('[data-motor][data-steps]').forEach(btn => {
|
||||
btn.addEventListener('click', () =>
|
||||
jogMotor(parseInt(btn.dataset.motor, 10), parseInt(btn.dataset.steps, 10)));
|
||||
});
|
||||
|
||||
// ── Speed / Accel sliders ─────────────────────────────────────
|
||||
let spdT, accT;
|
||||
document.getElementById('speed-slider').addEventListener('input', (e) => {
|
||||
document.getElementById('speed-val').textContent = e.target.value;
|
||||
clearTimeout(spdT);
|
||||
spdT = setTimeout(() => {
|
||||
const cmd = `SPD:${e.target.value}`;
|
||||
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
|
||||
}, 400);
|
||||
});
|
||||
document.getElementById('accel-slider').addEventListener('input', (e) => {
|
||||
document.getElementById('accel-val').textContent = e.target.value;
|
||||
clearTimeout(accT);
|
||||
accT = setTimeout(() => {
|
||||
const cmd = `ACC:${e.target.value}`;
|
||||
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// ── Log clear ─────────────────────────────────────────────────
|
||||
document.getElementById('clear-log-btn').addEventListener('click', UI.clearLog);
|
||||
|
||||
// ── BLE badge ─────────────────────────────────────────────────
|
||||
function updateBadge() {
|
||||
if (!simBadge) return;
|
||||
const live = BLE.isConnected();
|
||||
simBadge.textContent = live ? 'LIVE' : 'SIMULATION';
|
||||
simBadge.style.background = live ? 'rgba(0,230,118,0.12)' : 'rgba(255,202,40,0.12)';
|
||||
simBadge.style.color = live ? 'var(--accent-green)' : 'var(--accent-amber)';
|
||||
simBadge.style.border = live ? '1px solid rgba(0,230,118,0.3)' : '1px solid rgba(255,202,40,0.25)';
|
||||
}
|
||||
BLE.on('connected', updateBadge);
|
||||
BLE.on('disconnected', updateBadge);
|
||||
updateBadge();
|
||||
|
||||
// ── BLE NOTIFY position feedback ──────────────────────────────
|
||||
function onBLEStatus(e) {
|
||||
const msg = e.detail;
|
||||
if (msg.startsWith('P:')) {
|
||||
const [s1, s2] = msg.slice(2).split(',').map(Number);
|
||||
steps1 = s1; steps2 = s2;
|
||||
renderArmFromSteps();
|
||||
}
|
||||
}
|
||||
document.addEventListener('ble:status', onBLEStatus);
|
||||
|
||||
eventCleanup = [
|
||||
() => BLE.off('connected', updateBadge),
|
||||
() => BLE.off('disconnected', updateBadge),
|
||||
() => document.removeEventListener('ble:status', onBLEStatus),
|
||||
];
|
||||
|
||||
// ── Initial render ─────────────────────────────────────────────
|
||||
renderArmFromSteps();
|
||||
UI.log('Stepper Test ready — 5-bar parallel IK active.', 'success');
|
||||
UI.log(`Arm: d=${IK.ARM.d}mm l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm`, 'info');
|
||||
if (!BLE.isConnected()) {
|
||||
UI.log('Simulation mode — BLE commands logged but not sent.', 'warn');
|
||||
}
|
||||
},
|
||||
|
||||
unmount() {
|
||||
eventCleanup.forEach(fn => fn());
|
||||
eventCleanup = [];
|
||||
},
|
||||
};
|
||||
|
||||
export default StepperTestSection;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* WijiBoard – Shared UI Utilities
|
||||
* web/js/ui.js
|
||||
*
|
||||
* Exports a UI object with helpers used across all sections.
|
||||
* Requires the SPA shell to contain:
|
||||
* #ble-status, #ble-label (top-bar BLE indicator)
|
||||
* #log-console (global event log)
|
||||
* #btn-ble-connect (top-bar connect button)
|
||||
*/
|
||||
|
||||
import BLE from './ble.js';
|
||||
|
||||
const UI = (() => {
|
||||
|
||||
// ── Log console ─────────────────────────────────────────────
|
||||
function log(message, type = 'info') {
|
||||
const console = document.getElementById('log-console');
|
||||
if (!console) return;
|
||||
|
||||
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||||
const entry = document.createElement('span');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `[${ts}] ${message}`;
|
||||
|
||||
const br = document.createElement('br');
|
||||
console.appendChild(entry);
|
||||
console.appendChild(br);
|
||||
console.scrollTop = console.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
const el = document.getElementById('log-console');
|
||||
if (el) el.innerHTML = '';
|
||||
}
|
||||
|
||||
// ── BLE status indicator (top bar) ──────────────────────────
|
||||
function setConnectionStatus(connected, deviceName = null) {
|
||||
const statusEl = document.getElementById('ble-status');
|
||||
const labelEl = document.getElementById('ble-label');
|
||||
const btn = document.getElementById('btn-ble-connect');
|
||||
|
||||
if (statusEl) statusEl.classList.toggle('connected', connected);
|
||||
if (labelEl) labelEl.textContent = connected
|
||||
? (deviceName ?? 'Connected')
|
||||
: 'Disconnected';
|
||||
if (btn) {
|
||||
btn.disabled = connected;
|
||||
btn.innerHTML = connected
|
||||
? '<span class="btn-text">Disconnect</span>'
|
||||
: '<span class="btn-text">Connect</span>';
|
||||
btn.className = connected
|
||||
? 'btn btn-sm btn-danger'
|
||||
: 'btn btn-sm btn-primary';
|
||||
|
||||
// Rebind: when connected, button should disconnect
|
||||
btn.onclick = connected
|
||||
? () => BLE.disconnect()
|
||||
: () => BLE.connect().catch(e => {
|
||||
if (!e.message?.includes('cancelled')) {
|
||||
log(`Connect failed: ${e.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button loading state ─────────────────────────────────────
|
||||
function setButtonLoading(btnId, loading) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('loading', loading);
|
||||
btn.disabled = loading;
|
||||
|
||||
// Toggle spinner visibility
|
||||
const spinner = btn.querySelector('.spinner');
|
||||
if (spinner) spinner.style.display = loading ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ── Wire BLE events to UI ────────────────────────────────────
|
||||
function initBLEListeners() {
|
||||
BLE.on('connecting', () => {
|
||||
log('Scanning for WijiBoard…', 'info');
|
||||
setButtonLoading('btn-ble-connect', true);
|
||||
});
|
||||
|
||||
BLE.on('connected', (name) => {
|
||||
setButtonLoading('btn-ble-connect', false);
|
||||
setConnectionStatus(true, name);
|
||||
log(`Connected to "${name}" ✓`, 'success');
|
||||
});
|
||||
|
||||
BLE.on('disconnected', () => {
|
||||
setButtonLoading('btn-ble-connect', false);
|
||||
setConnectionStatus(false);
|
||||
log('BLE connection lost.', 'warn');
|
||||
});
|
||||
|
||||
BLE.on('sent', (cmd) => {
|
||||
log(`→ ${cmd}`, 'sent');
|
||||
});
|
||||
|
||||
BLE.on('status', (msg) => {
|
||||
// Forward raw status messages to sections that need them
|
||||
document.dispatchEvent(new CustomEvent('ble:status', { detail: msg }));
|
||||
});
|
||||
}
|
||||
|
||||
return { log, clearLog, setConnectionStatus, setButtonLoading, initBLEListeners };
|
||||
})();
|
||||
|
||||
export default UI;
|
||||
@@ -0,0 +1,131 @@
|
||||
/* ============================================================
|
||||
WijiBoard — Design Tokens, Reset & Typography
|
||||
web/styles/base.css
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* ── Design tokens ─────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Background layers */
|
||||
--bg: #0d0f14;
|
||||
--surface: #161923;
|
||||
--surface-2: #1e2330;
|
||||
--surface-3: #252a3a;
|
||||
|
||||
/* Borders */
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-hover: rgba(255, 255, 255, 0.14);
|
||||
|
||||
/* Accent palette */
|
||||
--accent-blue: #448aff;
|
||||
--accent-green: #00e676;
|
||||
--accent-red: #ff5252;
|
||||
--accent-amber: #ffca28;
|
||||
--accent-purple: #e040fb;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e8eaf0;
|
||||
--text-secondary: #a0a8bc;
|
||||
--text-muted: #6c7385;
|
||||
|
||||
/* Glow effects */
|
||||
--glow-blue: 0 0 28px rgba(68, 138, 255, 0.45);
|
||||
--glow-green: 0 0 28px rgba(0, 230, 118, 0.50);
|
||||
--glow-red: 0 0 28px rgba(255, 82, 82, 0.38);
|
||||
--glow-amber: 0 0 20px rgba(255, 202, 40, 0.40);
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
--topbar-height: 60px;
|
||||
--content-padding: 24px;
|
||||
|
||||
/* Shape */
|
||||
--radius-xl: 20px;
|
||||
--radius-lg: 16px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 5px;
|
||||
|
||||
/* Motion */
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-fast: 0.12s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 0.38s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Ambient background glow */
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 40% at 50% 0%, rgba(68, 138, 255, 0.09) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 40% 30% at 90% 80%, rgba(0, 230, 118, 0.06) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* ── Typography scale ───────────────────────────────────────── */
|
||||
h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.025em; line-height: 1.2; }
|
||||
h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; line-height: 1.3; }
|
||||
h3 { font-size: 1rem; font-weight: 600; letter-spacing: -0.01em; }
|
||||
h4 { font-size: 0.875rem;font-weight: 600; }
|
||||
|
||||
p { color: var(--text-secondary); line-height: 1.7; }
|
||||
a { color: var(--accent-blue); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
small { font-size: 0.75rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Utility: visually hidden ───────────────────────────────── */
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* ── Utility: scrollbars ────────────────────────────────────── */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface-3) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--surface-2); }
|
||||
|
||||
/* ── Utility: spinner ───────────────────────────────────────── */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.15);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.65s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Utility: pulse ─────────────────────────────────────────── */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Utility: fade-in ───────────────────────────────────────── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in { animation: fadeIn 0.3s var(--transition) both; }
|
||||
@@ -0,0 +1,657 @@
|
||||
/* ============================================================
|
||||
WijiBoard — Reusable Component Styles
|
||||
web/styles/components.css
|
||||
Requires: base.css
|
||||
============================================================ */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
APP SHELL — Top bar + Sidebar + Layout
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Top bar ─────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: var(--topbar-height);
|
||||
background: rgba(13, 15, 20, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 14px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar-brand .brand-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #1a2a4a, #0f1c38);
|
||||
border: 1px solid rgba(68,138,255,0.3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 12px rgba(68,138,255,0.25);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Hamburger button ────────────────────────────────────────── */
|
||||
.hamburger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 36px; height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hamburger:hover { background: var(--surface-2); }
|
||||
.hamburger-bar {
|
||||
width: 18px; height: 2px;
|
||||
background: var(--text-secondary);
|
||||
border-radius: 2px;
|
||||
transition: transform var(--transition), opacity var(--transition);
|
||||
}
|
||||
.hamburger.open .hamburger-bar:nth-child(1) { transform: translateY(7px) rotate(45deg); }
|
||||
.hamburger.open .hamburger-bar:nth-child(2) { opacity: 0; transform: scaleX(0); }
|
||||
.hamburger.open .hamburger-bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--topbar-height);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 10px;
|
||||
gap: 4px;
|
||||
z-index: 90;
|
||||
transform: translateX(0);
|
||||
transition: transform var(--transition-slow);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(calc(-1 * var(--sidebar-width)));
|
||||
}
|
||||
|
||||
/* Sidebar overlay (mobile / collapsed state backdrop) */
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 89;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-slow);
|
||||
}
|
||||
.sidebar-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sidebar nav section label */
|
||||
.sidebar-section-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 10px 6px;
|
||||
}
|
||||
|
||||
/* Sidebar nav item */
|
||||
.sidebar-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-nav-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sidebar-nav-item.active {
|
||||
background: rgba(68,138,255,0.12);
|
||||
color: var(--accent-blue);
|
||||
border: 1px solid rgba(68,138,255,0.2);
|
||||
}
|
||||
.sidebar-nav-item .nav-icon {
|
||||
width: 18px; height: 18px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-nav-item.active .nav-icon { opacity: 1; }
|
||||
|
||||
/* ── Main content area ───────────────────────────────────────── */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
margin-top: var(--topbar-height);
|
||||
flex: 1;
|
||||
height: calc(100vh - var(--topbar-height));
|
||||
overflow-y: auto;
|
||||
padding: var(--content-padding);
|
||||
transition: margin-left var(--transition-slow);
|
||||
}
|
||||
.main-content.sidebar-collapsed {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
BLE STATUS (top bar)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.ble-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
padding: 5px 12px 5px 8px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.ble-status.connected {
|
||||
border-color: rgba(0, 230, 118, 0.3);
|
||||
background: rgba(0, 230, 118, 0.07);
|
||||
}
|
||||
|
||||
.ble-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.ble-status.connected .ble-dot {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 8px rgba(0,230,118,0.7);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ble-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ble-status.connected .ble-label { color: var(--accent-green); }
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
BUTTONS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
transition: transform var(--transition), box-shadow var(--transition),
|
||||
background var(--transition), border-color var(--transition),
|
||||
opacity var(--transition);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255,255,255,0.07);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.btn:hover:not(:disabled)::after { opacity: 1; }
|
||||
.btn:active:not(:disabled) { transform: scale(0.96); }
|
||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
/* Variants */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #2a3f80, #1a2a5e);
|
||||
color: #fff;
|
||||
border-color: rgba(68,138,255,0.4);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: var(--glow-blue);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #0a3322, #052212);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0,230,118,0.3);
|
||||
}
|
||||
.btn-success:hover:not(:disabled) {
|
||||
box-shadow: var(--glow-green);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #3a1a1a, #2a1010);
|
||||
color: #ffb3b3;
|
||||
border-color: rgba(255,82,82,0.3);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
box-shadow: var(--glow-red);
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
border-color: var(--border-hover);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.btn-icon-only {
|
||||
padding: 9px;
|
||||
min-width: 38px;
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.btn-sm { padding: 6px 12px; font-size: 0.78rem; border-radius: var(--radius-sm); }
|
||||
.btn-lg { padding: 13px 22px; font-size: 0.95rem; }
|
||||
.btn-full { width: 100%; }
|
||||
|
||||
/* Loading state */
|
||||
.btn.loading .btn-text { opacity: 0; }
|
||||
.btn.loading .spinner { position: absolute; }
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
CARDS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.35),
|
||||
inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-title-accent {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
STATUS ROW / PILL
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-value {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
LOG CONSOLE
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.log-console {
|
||||
background: #07090d;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
height: 140px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.log-entry { display: block; }
|
||||
.log-entry.info { color: #6a7a9a; }
|
||||
.log-entry.success { color: var(--accent-green); }
|
||||
.log-entry.error { color: var(--accent-red); }
|
||||
.log-entry.warn { color: var(--accent-amber); }
|
||||
.log-entry.sent { color: #7eb8f7; }
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.log-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.70rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: color var(--transition), background var(--transition);
|
||||
}
|
||||
.log-clear-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.06); }
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
FORM CONTROLS (input, range, number)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.form-input {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 8px 12px;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
outline: none;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: rgba(68,138,255,0.5);
|
||||
box-shadow: 0 0 0 3px rgba(68,138,255,0.12);
|
||||
}
|
||||
.form-input[type="number"] { width: 80px; text-align: center; }
|
||||
|
||||
/* Range slider */
|
||||
.form-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-blue);
|
||||
box-shadow: 0 0 8px rgba(68,138,255,0.5);
|
||||
transition: box-shadow var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-range::-webkit-slider-thumb:hover {
|
||||
box-shadow: var(--glow-blue);
|
||||
}
|
||||
.form-range::-moz-range-thumb {
|
||||
width: 16px; height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.range-value {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
STEPPER JOG PANEL
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.stepper-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.stepper-card:hover {
|
||||
border-color: rgba(68,138,255,0.18);
|
||||
}
|
||||
|
||||
.stepper-pos-display {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.stepper-pos-item {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.stepper-pos-num {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-blue);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.stepper-pos-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.jog-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.jog-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.jog-row .btn {
|
||||
flex: 1;
|
||||
}
|
||||
.step-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.step-input-wrapper label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SCARA VISUALIZER
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.visualizer-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 18px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scara-svg {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
border-radius: var(--radius-md);
|
||||
background: #070a0f;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* SVG internal elements */
|
||||
.arm-seg-1 { stroke: var(--accent-blue); stroke-width: 5; stroke-linecap: round; }
|
||||
.arm-seg-2 { stroke: #7eb8f7; stroke-width: 4; stroke-linecap: round; }
|
||||
.joint-base { fill: var(--accent-blue); }
|
||||
.joint-elbow{ fill: #a0c4ff; }
|
||||
.end-eff { fill: var(--accent-green); }
|
||||
.workspace-arc { stroke: rgba(68,138,255,0.12); stroke-width: 1; fill: none; }
|
||||
.grid-line { stroke: rgba(255,255,255,0.04); stroke-width: 1; }
|
||||
.crosshair { stroke: rgba(0,230,118,0.35); stroke-width: 1; }
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SECTION WRAPPERS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.section-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
animation: fadeIn 0.25s var(--transition) both;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.section-header p {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Grid layouts */
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
.main-content { padding: 16px; }
|
||||
.sidebar { width: 220px; }
|
||||
:root { --sidebar-width: 220px; }
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
Reference in New Issue
Block a user