478 lines
22 KiB
JavaScript
478 lines
22 KiB
JavaScript
// ── UUIDs ────────────────────────────────────────────────────────────────────
|
|
// v3.3: 4 characteristics instead of 10
|
|
const SVC_UUID = '00001234-0000-1000-8000-00805f9b34fb';
|
|
const CHR = {
|
|
configBlob: '00001235-0000-1000-8000-00805f9b34fb', // ConfigBlob R/W 16 bytes
|
|
command: '00001236-0000-1000-8000-00805f9b34fb', // Command W 1 byte
|
|
telemetry: '00001237-0000-1000-8000-00805f9b34fb', // Telemetry R/N 24 bytes
|
|
imuStream: '00001238-0000-1000-8000-00805f9b34fb', // ImuStream N 14 bytes
|
|
};
|
|
|
|
// Local shadow of the current config (kept in sync with device)
|
|
const config = { sensitivity:600, deadZone:0.06, accelStrength:0.08, curve:0, axisFlip:0, chargeMode:1 };
|
|
|
|
let device=null, server=null, chars={}, userDisconnected=false;
|
|
let currentChargeStatus=0, currentBattPct=null;
|
|
|
|
// ── Logging ──────────────────────────────────────────────────────────────────
|
|
function log(msg, type='') {
|
|
const el=document.getElementById('console');
|
|
const now=new Date();
|
|
const ts=`${p2(now.getHours())}:${p2(now.getMinutes())}:${p2(now.getSeconds())}.${p3(now.getMilliseconds())}`;
|
|
const d=document.createElement('div'); d.className='log-line';
|
|
d.innerHTML=`<span class="log-time">${ts}</span><span class="log-msg ${type}">${msg}</span>`;
|
|
el.appendChild(d); el.scrollTop=el.scrollHeight;
|
|
}
|
|
const p2=n=>String(n).padStart(2,'0'), p3=n=>String(n).padStart(3,'0');
|
|
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
|
|
|
// ── Connection ───────────────────────────────────────────────────────────────
|
|
async function doConnect() {
|
|
if (!navigator.bluetooth) { log('Web Bluetooth not supported.','err'); return; }
|
|
userDisconnected = false;
|
|
setStatus('connecting');
|
|
log('Scanning for IMU Mouse…','info');
|
|
try {
|
|
device = await navigator.bluetooth.requestDevice({
|
|
filters:[{name:'IMU Mouse'},{name:'IMU Mouse (safe)'}],
|
|
optionalServices:[SVC_UUID,'battery_service']
|
|
});
|
|
device.addEventListener('gattserverdisconnected', onDisconnected);
|
|
log(`Found: ${device.name}`,'ok');
|
|
server = await device.gatt.connect();
|
|
log('GATT connected','ok');
|
|
await discoverServices();
|
|
setStatus('connected');
|
|
log('Ready','ok');
|
|
} catch(e) { log(`Connection failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
|
}
|
|
|
|
function doDisconnect() {
|
|
if (device && device.gatt.connected) {
|
|
userDisconnected = true;
|
|
log('Disconnecting…','warn');
|
|
device.gatt.disconnect();
|
|
}
|
|
}
|
|
|
|
async function discoverServices() {
|
|
log('Discovering services…','info');
|
|
try {
|
|
const svc = await server.getPrimaryService(SVC_UUID);
|
|
|
|
chars.configBlob = await svc.getCharacteristic(CHR.configBlob);
|
|
chars.command = await svc.getCharacteristic(CHR.command);
|
|
chars.telemetry = await svc.getCharacteristic(CHR.telemetry);
|
|
chars.imuStream = await svc.getCharacteristic(CHR.imuStream);
|
|
|
|
// Read config blob and populate UI
|
|
await readConfigBlob();
|
|
|
|
// Telemetry notify (1 Hz) — also carries chargeStatus
|
|
chars.telemetry.addEventListener('characteristicvaluechanged', e => parseTelemetry(e.target.value));
|
|
await chars.telemetry.startNotifications();
|
|
// Initial read so values show immediately
|
|
parseTelemetry(await chars.telemetry.readValue());
|
|
|
|
// IMU stream notify (~100 Hz)
|
|
chars.imuStream.addEventListener('characteristicvaluechanged', e => parseImuStream(e.target.value));
|
|
await chars.imuStream.startNotifications();
|
|
document.getElementById('vizLive').classList.add('on');
|
|
log('IMU stream subscribed','ok');
|
|
|
|
log('Config service ready (4 chars)','ok');
|
|
} catch(e) {
|
|
log(`Service discovery failed: ${e.message}`,'err');
|
|
// Safe mode device might not have config service
|
|
if (e.message.includes('not found')) log('Device may be in safe mode — basic mouse only','warn');
|
|
}
|
|
|
|
// Battery service (standard — always present)
|
|
try {
|
|
const bsvc = await server.getPrimaryService('battery_service');
|
|
const bch = await bsvc.getCharacteristic('battery_level');
|
|
bch.addEventListener('characteristicvaluechanged', e => {
|
|
currentBattPct = e.target.value.getUint8(0);
|
|
updateBatteryBar(currentBattPct, currentChargeStatus);
|
|
});
|
|
await bch.startNotifications();
|
|
const v = await bch.readValue();
|
|
currentBattPct = v.getUint8(0);
|
|
updateBatteryBar(currentBattPct, currentChargeStatus);
|
|
log(`Battery: ${currentBattPct}%`,'ok');
|
|
} catch(e) { log('Battery service unavailable','warn'); }
|
|
}
|
|
|
|
// ── ConfigBlob read / write ──────────────────────────────────────────────────
|
|
// ConfigBlob layout (16 bytes LE):
|
|
// float sensitivity [0], float deadZone [4], float accelStrength [8]
|
|
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14], uint8 pad [15]
|
|
|
|
async function readConfigBlob() {
|
|
if (!chars.configBlob) return;
|
|
try {
|
|
const dv = await chars.configBlob.readValue();
|
|
const view = new DataView(dv.buffer ?? dv);
|
|
config.sensitivity = view.getFloat32(0, true);
|
|
config.deadZone = view.getFloat32(4, true);
|
|
config.accelStrength = view.getFloat32(8, true);
|
|
config.curve = view.getUint8(12);
|
|
config.axisFlip = view.getUint8(13);
|
|
config.chargeMode = view.getUint8(14);
|
|
applyConfigToUI();
|
|
log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)}`,'ok');
|
|
} catch(e) { log(`Config read error: ${e.message}`,'err'); }
|
|
}
|
|
|
|
function applyConfigToUI() {
|
|
document.getElementById('slSensitivity').value = config.sensitivity;
|
|
updateDisplay('sensitivity', config.sensitivity);
|
|
document.getElementById('slDeadZone').value = config.deadZone;
|
|
updateDisplay('deadZone', config.deadZone);
|
|
document.getElementById('slAccel').value = config.accelStrength;
|
|
updateDisplay('accel', config.accelStrength);
|
|
setCurveUI(config.curve);
|
|
document.getElementById('flipX').checked = !!(config.axisFlip & 1);
|
|
document.getElementById('flipY').checked = !!(config.axisFlip & 2);
|
|
setChargeModeUI(config.chargeMode);
|
|
}
|
|
|
|
async function writeConfigBlob() {
|
|
if (!chars.configBlob) return;
|
|
|
|
// Gather current UI values into the config shadow
|
|
config.sensitivity = +document.getElementById('slSensitivity').value;
|
|
config.deadZone = +document.getElementById('slDeadZone').value;
|
|
config.accelStrength = +document.getElementById('slAccel').value;
|
|
config.axisFlip = (document.getElementById('flipX').checked ? 1 : 0)
|
|
| (document.getElementById('flipY').checked ? 2 : 0);
|
|
// config.curve and config.chargeMode are updated directly by setCurve/setChargeMode
|
|
|
|
const buf = new ArrayBuffer(16);
|
|
const view = new DataView(buf);
|
|
view.setFloat32(0, config.sensitivity, true);
|
|
view.setFloat32(4, config.deadZone, true);
|
|
view.setFloat32(8, config.accelStrength, true);
|
|
view.setUint8(12, config.curve);
|
|
view.setUint8(13, config.axisFlip);
|
|
view.setUint8(14, config.chargeMode);
|
|
view.setUint8(15, 0);
|
|
|
|
try {
|
|
await chars.configBlob.writeValue(buf);
|
|
log(`Config written — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} curve=${config.curve} chg=${config.chargeMode}`,'ok');
|
|
} catch(e) { log(`Config write failed: ${e.message}`,'err'); }
|
|
}
|
|
|
|
// ── Individual control handlers ───────────────────────────────────────────────
|
|
// These update the local config shadow then write the full blob
|
|
|
|
async function setCurve(val) {
|
|
config.curve = val;
|
|
setCurveUI(val);
|
|
await writeConfigBlob();
|
|
log(`Curve → ${['LINEAR','SQUARE','SQRT'][val]}`,'ok');
|
|
}
|
|
function setCurveUI(val) {
|
|
['curveLinear','curveSquare','curveSqrt'].forEach((id,i)=>
|
|
document.getElementById(id).classList.toggle('active', i===val));
|
|
}
|
|
|
|
async function setChargeMode(val) {
|
|
config.chargeMode = val;
|
|
setChargeModeUI(val);
|
|
await writeConfigBlob();
|
|
log(`Charge → ${['OFF','SLOW 50mA','FAST 100mA'][val]}`,'warn');
|
|
}
|
|
function setChargeModeUI(val) {
|
|
[['chgOff','off'],['chgSlow','slow'],['chgFast','fast']].forEach(([id,cls],i) => {
|
|
const b = document.getElementById(id);
|
|
b.classList.remove('active','off','slow','fast');
|
|
if (i===val) b.classList.add('active', cls);
|
|
});
|
|
document.getElementById('ciMode').textContent = ['Off (0mA)','50 mA','100 mA'][val] ?? '--';
|
|
}
|
|
|
|
async function sendCalibrate() {
|
|
if (!chars.command) return;
|
|
try { await chars.command.writeValue(new Uint8Array([0x01])); log('Calibration sent — hold still!','warn'); }
|
|
catch(e) { log(`Calibrate failed: ${e.message}`,'err'); }
|
|
}
|
|
function confirmReset() { document.getElementById('overlay').classList.add('show'); }
|
|
function closeModal() { document.getElementById('overlay').classList.remove('show'); }
|
|
async function doReset() {
|
|
closeModal(); if (!chars.command) return;
|
|
try {
|
|
await chars.command.writeValue(new Uint8Array([0xFF]));
|
|
log('Factory reset sent…','warn');
|
|
setTimeout(async () => { await readConfigBlob(); log('Config reloaded','ok'); }, 1500);
|
|
} catch(e) { log(`Reset failed: ${e.message}`,'err'); }
|
|
}
|
|
|
|
// ── Telemetry ────────────────────────────────────────────────────────────────
|
|
// TelemetryPacket (24 bytes LE):
|
|
// uint32 uptime [0], uint32 leftClicks [4], uint32 rightClicks [8]
|
|
// float temp [12], float biasRms [16]
|
|
// uint16 recalCount [20], uint8 chargeStatus [22], uint8 pad [23]
|
|
function parseTelemetry(dv) {
|
|
let view;
|
|
try {
|
|
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
|
|
} catch(e) { log(`parseTelemetry: DataView wrap failed — ${e.message}`,'err'); return; }
|
|
|
|
if (view.byteLength < 24) {
|
|
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
const hex = Array.from(bytes).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
|
log(`TELEM: expected 24B, got ${view.byteLength}B — MTU too small? raw: ${hex}`,'err');
|
|
return;
|
|
}
|
|
|
|
let uptime, leftClicks, rightClicks, temp, biasRms, recalCount, chargeStatus;
|
|
try {
|
|
uptime = view.getUint32(0, true);
|
|
leftClicks = view.getUint32(4, true);
|
|
rightClicks = view.getUint32(8, true);
|
|
temp = view.getFloat32(12,true);
|
|
biasRms = view.getFloat32(16,true);
|
|
recalCount = view.getUint16(20, true);
|
|
chargeStatus= view.getUint8(22);
|
|
} catch(e) { log(`parseTelemetry: parse error at offset — ${e.message}`,'err'); return; }
|
|
|
|
document.getElementById('telTemp').textContent = temp.toFixed(1)+'°';
|
|
document.getElementById('telUptime').textContent = formatUptime(uptime);
|
|
document.getElementById('telLeft').textContent = leftClicks.toLocaleString();
|
|
document.getElementById('telRight').textContent = rightClicks.toLocaleString();
|
|
document.getElementById('telBias').textContent = biasRms.toFixed(4);
|
|
document.getElementById('telRecal').textContent = recalCount;
|
|
const tEl = document.getElementById('telTemp');
|
|
tEl.className = 'telem-val '+(temp>40?'warn':'accent');
|
|
|
|
// chargeStatus is now delivered via telemetry (no separate characteristic)
|
|
if (chargeStatus !== currentChargeStatus) {
|
|
currentChargeStatus = chargeStatus;
|
|
updateChargeUI();
|
|
}
|
|
}
|
|
function formatUptime(s) {
|
|
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60;
|
|
return h>0 ? `${h}h ${p2(m)}m` : `${m}m ${p2(ss)}s`;
|
|
}
|
|
function clearTelemetry() {
|
|
['telTemp','telUptime','telLeft','telRight','telBias','telRecal'].forEach(id=>
|
|
document.getElementById(id).textContent='--');
|
|
}
|
|
|
|
// ── Battery & Charge UI ───────────────────────────────────────────────────────
|
|
function updateBatteryBar(pct, status) {
|
|
document.getElementById('battBar').style.display='flex';
|
|
document.getElementById('battPct').textContent=pct+'%';
|
|
document.getElementById('ciPct').textContent=pct+'%';
|
|
document.getElementById('badgeCharging').classList.toggle('show', status===1);
|
|
document.getElementById('badgeFull').classList.toggle('show', status===2);
|
|
const cells=document.getElementById('battCells'); cells.innerHTML='';
|
|
const filled=Math.round(pct/10);
|
|
for (let i=0;i<10;i++) {
|
|
const c=document.createElement('div'); c.className='batt-cell';
|
|
if (i<filled) c.className+=status===1?' f charging':pct<=20?' f crit':pct<=40?' f warn':' f';
|
|
cells.appendChild(c);
|
|
}
|
|
}
|
|
function updateChargeUI() {
|
|
const sl=['Discharging','Charging','Full'];
|
|
const sc=['var(--label)','var(--accent)','var(--ok)'];
|
|
const el=document.getElementById('ciStatus');
|
|
el.textContent=sl[currentChargeStatus]??'--';
|
|
el.style.color=sc[currentChargeStatus]??'var(--label)';
|
|
if (currentBattPct!==null) updateBatteryBar(currentBattPct, currentChargeStatus);
|
|
}
|
|
|
|
// ── Param display ─────────────────────────────────────────────────────────────
|
|
function updateDisplay(key, val) {
|
|
const map = {
|
|
sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)],
|
|
deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)],
|
|
accel: ['valAccel', v=>parseFloat(v).toFixed(2)],
|
|
};
|
|
const [id,fmt] = map[key];
|
|
document.getElementById(id).textContent = fmt(val);
|
|
}
|
|
|
|
// ── Status UI ────────────────────────────────────────────────────────────────
|
|
function setStatus(state) {
|
|
const pill=document.getElementById('statusPill');
|
|
document.getElementById('statusText').textContent={connected:'CONNECTED',connecting:'CONNECTING…',disconnected:'DISCONNECTED'}[state];
|
|
pill.className='status-pill '+state;
|
|
document.body.className=state;
|
|
const cBtn=document.getElementById('connectBtn'), dBtn=document.getElementById('disconnectBtn');
|
|
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn');
|
|
if (state==='connected') {
|
|
cBtn.style.display='none'; dBtn.style.display='';
|
|
inputs.forEach(el=>el.disabled=false);
|
|
} else if (state==='connecting') {
|
|
cBtn.disabled=true; cBtn.style.display=''; dBtn.style.display='none';
|
|
inputs.forEach(el=>el.disabled=true);
|
|
} else {
|
|
cBtn.disabled=false; cBtn.style.display=''; dBtn.style.display='none';
|
|
inputs.forEach(el=>el.disabled=true);
|
|
}
|
|
}
|
|
function onDisconnected() {
|
|
log('Device disconnected','warn');
|
|
const savedDevice = device;
|
|
chars={}; device=null; server=null;
|
|
setStatus('disconnected');
|
|
document.getElementById('battBar').style.display='none';
|
|
document.getElementById('badgeCharging').classList.remove('show');
|
|
document.getElementById('badgeFull').classList.remove('show');
|
|
document.getElementById('vizLive').classList.remove('on');
|
|
clearTelemetry();
|
|
if (!userDisconnected && document.getElementById('autoReconnect').checked && savedDevice) {
|
|
log('Auto-reconnecting…','info');
|
|
setTimeout(async () => {
|
|
try {
|
|
setStatus('connecting');
|
|
server = await savedDevice.gatt.connect();
|
|
device = savedDevice;
|
|
userDisconnected = false;
|
|
log('GATT reconnected','ok');
|
|
await discoverServices();
|
|
setStatus('connected');
|
|
log('Ready','ok');
|
|
} catch(e) { log(`Reconnect failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
|
}, 1000);
|
|
} else {
|
|
userDisconnected = false;
|
|
}
|
|
}
|
|
|
|
// ── IMU Stream + Visualiser ──────────────────────────────────────────────────
|
|
// ImuPacket (14 bytes LE):
|
|
// int16 gyroY_mDPS [0], int16 gyroZ_mDPS [2]
|
|
// int16 accelX_mg [4], int16 accelY_mg [6], int16 accelZ_mg [8]
|
|
// int8 moveX [10], int8 moveY [11], uint8 flags [12], uint8 pad [13]
|
|
const canvas = document.getElementById('vizCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const TRAIL_LEN = 120;
|
|
let cursorX = canvas.width/2, cursorY = canvas.height/2, trail = [];
|
|
|
|
function parseImuStream(dv) {
|
|
let view;
|
|
try {
|
|
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
|
|
} catch(e) { log(`parseImuStream: DataView wrap failed — ${e.message}`,'err'); return; }
|
|
|
|
if (view.byteLength < 14) {
|
|
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
const hex = Array.from(bytes).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
|
log(`IMU raw (${view.byteLength}B, expected 14): ${hex}`,'err');
|
|
return;
|
|
}
|
|
|
|
let gyroY, gyroZ, moveX, moveY, flags;
|
|
try {
|
|
gyroY = view.getInt16(0, true);
|
|
gyroZ = view.getInt16(2, true);
|
|
moveX = view.getInt8(10);
|
|
moveY = view.getInt8(11);
|
|
flags = view.getUint8(12);
|
|
} catch(e) { log(`parseImuStream: parse error — ${e.message}`,'err'); return; }
|
|
const idle = !!(flags & 0x01);
|
|
const single = !!(flags & 0x02);
|
|
const dbl = !!(flags & 0x04);
|
|
|
|
updateAxisBar('gy', gyroY, 30000);
|
|
updateAxisBar('gz', gyroZ, 30000);
|
|
|
|
if (!idle) {
|
|
cursorX = Math.max(4, Math.min(canvas.width - 4, cursorX + moveX * 1.5));
|
|
cursorY = Math.max(4, Math.min(canvas.height - 4, cursorY + moveY * 1.5));
|
|
}
|
|
trail.push({x:cursorX, y:cursorY, t:Date.now(), idle});
|
|
if (trail.length > TRAIL_LEN) trail.shift();
|
|
|
|
if (single) flashTap('Left');
|
|
if (dbl) flashTap('Right');
|
|
drawViz(idle);
|
|
}
|
|
|
|
function updateAxisBar(axis, val, max) {
|
|
const pct=Math.abs(val)/max*50, neg=val<0;
|
|
const bar=document.getElementById(axis+'Bar'), label=document.getElementById(axis+'Val');
|
|
bar.style.width=pct+'%';
|
|
bar.style.left=neg?(50-pct)+'%':'50%';
|
|
bar.className='axis-bar-fill'+(neg?' neg':'');
|
|
label.textContent=(val/1000).toFixed(1);
|
|
}
|
|
|
|
function drawViz(idle) {
|
|
const W=canvas.width, H=canvas.height;
|
|
ctx.fillStyle=cssVar('--canvas-fade'); ctx.fillRect(0,0,W,H);
|
|
ctx.strokeStyle=cssVar('--canvas-grid'); ctx.lineWidth=0.5;
|
|
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
|
|
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
|
|
ctx.strokeStyle=cssVar('--canvas-center'); ctx.lineWidth=0.5;
|
|
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H);ctx.stroke();
|
|
ctx.beginPath();ctx.moveTo(0,H/2);ctx.lineTo(W,H/2);ctx.stroke();
|
|
const now=Date.now();
|
|
const trailRgb=cssVar('--trail-rgb'), trailIdleRgb=cssVar('--trail-idle-rgb');
|
|
for(let i=1;i<trail.length;i++){
|
|
const age=(now-trail[i].t)/1200, alpha=Math.max(0,1-age); if(alpha<=0) continue;
|
|
ctx.strokeStyle=trail[i].idle?`rgba(${trailIdleRgb},${alpha*0.4})`:`rgba(${trailRgb},${alpha*0.7})`;
|
|
ctx.lineWidth=1.5;
|
|
ctx.beginPath();ctx.moveTo(trail[i-1].x,trail[i-1].y);ctx.lineTo(trail[i].x,trail[i].y);ctx.stroke();
|
|
}
|
|
const dotColor=idle?cssVar('--canvas-dot-idle'):cssVar('--canvas-dot');
|
|
const dotGlow=idle?'transparent':cssVar('--canvas-dot-glow');
|
|
ctx.shadowColor=dotGlow; ctx.shadowBlur=12;
|
|
ctx.fillStyle=dotColor;
|
|
ctx.beginPath();ctx.arc(cursorX,cursorY,idle?3:5,0,Math.PI*2);ctx.fill();
|
|
ctx.shadowBlur=0;
|
|
if(idle){ctx.fillStyle=cssVar('--canvas-idle-text');ctx.font='10px Share Tech Mono,monospace';ctx.textAlign='center';ctx.fillText('IDLE',W/2,H-10);ctx.textAlign='left';}
|
|
}
|
|
|
|
function flashTap(side){
|
|
const el=document.getElementById('tapFlash'+side);
|
|
el.classList.add('show'); setTimeout(()=>el.classList.remove('show'),300);
|
|
}
|
|
|
|
function drawInitState() {
|
|
const W=canvas.width,H=canvas.height;
|
|
ctx.fillStyle=cssVar('--canvas-bg');ctx.fillRect(0,0,W,H);
|
|
ctx.strokeStyle=cssVar('--canvas-grid');ctx.lineWidth=0.5;
|
|
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
|
|
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
|
|
ctx.strokeStyle=cssVar('--canvas-center');
|
|
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H);ctx.stroke();
|
|
ctx.beginPath();ctx.moveTo(0,H/2);ctx.lineTo(W,H/2);ctx.stroke();
|
|
ctx.fillStyle=cssVar('--canvas-idle-text');ctx.font='10px Share Tech Mono,monospace';
|
|
ctx.textAlign='center';ctx.fillText('connect to activate stream',W/2,H/2+4);ctx.textAlign='left';
|
|
}
|
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
const THEMES = ['auto','dark','light'];
|
|
const THEME_LABELS = {auto:'AUTO',dark:'DARK',light:'LIGHT'};
|
|
let themeIdx = 0;
|
|
|
|
function cycleTheme() {
|
|
themeIdx = (themeIdx + 1) % 3;
|
|
applyTheme(THEMES[themeIdx]);
|
|
}
|
|
function applyTheme(t) {
|
|
document.documentElement.classList.remove('theme-dark','theme-light');
|
|
if (t === 'dark') document.documentElement.classList.add('theme-dark');
|
|
if (t === 'light') document.documentElement.classList.add('theme-light');
|
|
document.getElementById('themeBtn').querySelector('span').textContent = THEME_LABELS[t];
|
|
localStorage.setItem('theme', t);
|
|
if (!chars.imuStream) drawInitState();
|
|
}
|
|
(function(){
|
|
const saved = localStorage.getItem('theme') ?? 'auto';
|
|
themeIdx = Math.max(0, THEMES.indexOf(saved));
|
|
applyTheme(saved);
|
|
})();
|
|
|
|
if (!navigator.bluetooth) {
|
|
document.getElementById('mainContent').innerHTML=`<div class="no-ble"><h2>⚠ Web Bluetooth Not Supported</h2><p>Use <strong>Chrome</strong> or <strong>Edge</strong> on desktop.<br>Linux: enable <code>chrome://flags/#enable-web-bluetooth</code></p></div>`;
|
|
} else {
|
|
log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info');
|
|
}
|