Files
edgetx/web/src/App.svelte
T
2026-08-03 16:37:20 +08:00

2303 lines
69 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onDestroy } from 'svelte';
import { WasmRunner, radioKeyFromWasm } from './lib/wasm-runner';
import { LcdRenderer } from './lib/lcd-renderer';
import edgetxLogo from './assets/edgetx-logo.svg';
interface InputDef {
name: string;
type: 'STICK' | 'FLEX';
label: string;
default?: string;
}
interface SwitchDef {
name: string;
type: '2POS' | '3POS';
default: string;
}
interface TrimDef {
name: string;
}
interface KeyDef {
key: string; // e.g. "KEY_EXIT"
label: string; // display label
side: 'L' | 'R';
}
// EnumKeys indices from hal/key_driver.h
const KEY_INDEX: Record<string, number> = {
KEY_MENU: 0, KEY_EXIT: 1, KEY_ENTER: 2,
KEY_PAGEUP: 3, KEY_PAGEDN: 4,
KEY_UP: 5, KEY_DOWN: 6, KEY_LEFT: 7, KEY_RIGHT: 8,
KEY_PLUS: 9, KEY_MINUS: 10,
KEY_MODEL: 11, KEY_TELE: 12, KEY_SYS: 13,
KEY_SHIFT: 14, KEY_BIND: 15,
};
interface DisplayDef {
w: number;
h: number;
depth: number;
}
interface RadioEntry {
name: string;
wasm: string;
available?: boolean;
display?: DisplayDef;
inputs?: InputDef[];
switches?: SwitchDef[];
trims?: TrimDef[];
keys?: KeyDef[];
}
let canvas = $state<HTMLCanvasElement>(undefined!);
let canvasKey = $state(0);
let status = $state('Loading radio list...');
let traceLog = $state('');
let running = $state(false);
let loading = $state(false);
let radios = $state<RadioEntry[]>([]);
let selectedRadio = $state('');
let currentRadio = $state<RadioEntry | null>(null);
/** True when the FS Worker is alive and the sim is not running — uploads are possible. */
let fsReady = $derived(!running && !loading && runner !== null);
let fsHasContent = $state(false);
let runner = $state<WasmRunner | null>(null);
let lcdRenderer: LcdRenderer | null = null;
let lcdLoopActive = false;
let fps = $state(0);
let frameCount = 0;
let lastFpsTime = 0;
const themeCycle: Record<string, string> = { auto: 'dark', dark: 'light', light: 'auto' };
const themeIcon: Record<string, string> = { auto: '\u25D0', dark: '\u263E', light: '\u2600' };
let themeMode = $state(localStorage.getItem('theme') ?? 'auto');
function toggleTheme() {
themeMode = themeCycle[themeMode];
if (themeMode === 'auto') {
localStorage.removeItem('theme');
document.documentElement.removeAttribute('data-theme');
} else {
localStorage.setItem('theme', themeMode);
document.documentElement.setAttribute('data-theme', themeMode);
}
}
// Apply stored theme on mount
$effect(() => {
if (themeMode !== 'auto') {
document.documentElement.setAttribute('data-theme', themeMode);
}
});
// When the dropdown changes, tear down any old instance and init FS for the new radio
$effect(() => {
if (!selectedRadio || running || loading) return;
localStorage.setItem('selectedRadio', selectedRadio);
const key = radioKeyFromWasm(selectedRadio);
const url = new URL(location.href);
url.searchParams.set('radio', key);
history.replaceState(null, '', url);
// If there's already a runner for a different radio, tear it down
if (runner && currentRadio?.wasm !== selectedRadio) {
teardown().then(() => initFsForRadio(selectedRadio));
} else if (!runner) {
initFsForRadio(selectedRadio);
}
});
/** Create a WasmRunner and init its FS Worker for the given radio. */
async function initFsForRadio(wasmFile: string) {
const radioKey = radioKeyFromWasm(wasmFile);
const radio = radios.find((r) => r.wasm === wasmFile);
const r = new WasmRunner(onTrace, onAudio);
try {
const { hasContent } = await r.initFs(radioKey);
// Guard: if selectedRadio changed while we were awaiting, discard
if (selectedRadio !== wasmFile) {
r.stopFs().catch(() => {});
return;
}
runner = r;
currentRadio = radio ?? null;
fsHasContent = hasContent;
// Apply LCD dimensions from manifest
if (radio?.display) {
lcdWidth = radio.display.w;
lcdHeight = radio.display.h;
lcdDepth = radio.display.depth;
}
if (hasContent) {
const files = await r.fsListFiles('/');
onTrace(`[fs] ${files.length} file(s) in OPFS for "${radioKey}"\n`);
await readStickMode();
} else {
onTrace(`[fs] no previous data for "${radioKey}"\n`);
}
initControls();
status = `Ready — ${radio?.name ?? wasmFile}`;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
onTrace(`[fs] initFs error: ${msg}\n`);
}
}
let lcdWidth = $state(480);
let lcdHeight = $state(272);
let lcdDepth = $state(0);
let lcdSize = 0;
let isPortrait = $derived(lcdHeight > lcdWidth);
let cssWidth = $derived(Math.max(lcdWidth, 150 * lcdWidth / lcdHeight, 320));
let cssHeight = $derived(cssWidth * lcdHeight / lcdWidth);
// Stick mode: 0=Mode1(thr=RV), 1=Mode2(thr=LV), 2=Mode3(thr=RV), 3=Mode4(thr=LV)
let stickMode = 0;
// Audio playback via Web Audio API
let audioCtx: AudioContext | null = null;
const AUDIO_SAMPLE_RATE = 32000;
let audioNextTime = 0; // scheduled playback cursor
const AUDIO_LATENCY = 0.05; // 50ms latency buffer
// --- Control state ---
// Analog inputs: indexed by position in the inputs array
let analogValues = $state<number[]>([]);
// Switch states: indexed by position in switches array. -1=up, 0=mid, 1=down
let switchStates = $state<number[]>([]);
// 3POS direction tracker: remembers which way to go next from mid position
let switchDir: number[] = [];
// Custom switch LED colors: indexed by custom switch index (0-based), CSS color strings
let customSwitchColors = $state<string[]>([]);
// Gimbal drag state
let leftGimbalEl = $state<HTMLDivElement>(undefined!);
let rightGimbalEl = $state<HTMLDivElement>(undefined!);
let draggingGimbal = $state<'left' | 'right' | null>(null);
// Derived: stick indices (always first 4 inputs: LH, LV, RV, RH)
function getStickIndices(): { lh: number; lv: number; rv: number; rh: number } {
const inputs = currentRadio?.inputs ?? [];
let lh = -1, lv = -1, rv = -1, rh = -1;
for (let i = 0; i < inputs.length; i++) {
if (inputs[i].type === 'STICK') {
if (inputs[i].name === 'LH') lh = i;
else if (inputs[i].name === 'LV') lv = i;
else if (inputs[i].name === 'RV') rv = i;
else if (inputs[i].name === 'RH') rh = i;
}
}
return { lh, lv, rv, rh };
}
function isCustomSwitch(sw: SwitchDef): boolean {
return sw.name.startsWith('SW');
}
function getVisibleSwitches(): { sw: SwitchDef; index: number }[] {
const switches = currentRadio?.switches ?? [];
return switches
.map((sw, index) => ({ sw, index }))
.filter(({ sw }) => sw.default !== 'NONE' && !isCustomSwitch(sw));
}
function getCustomSwitches(): { sw: SwitchDef; index: number }[] {
const switches = currentRadio?.switches ?? [];
return switches
.map((sw, index) => ({ sw, index }))
.filter(({ sw }) => sw.default !== 'NONE' && isCustomSwitch(sw));
}
function getLeftSwitches(): { sw: SwitchDef; index: number }[] {
const all = getVisibleSwitches();
const half = Math.ceil(all.length / 2);
return all.slice(0, half);
}
function getRightSwitches(): { sw: SwitchDef; index: number }[] {
const all = getVisibleSwitches();
const half = Math.ceil(all.length / 2);
return all.slice(half);
}
function getFlexInputs(): { input: InputDef; index: number }[] {
const inputs = currentRadio?.inputs ?? [];
return inputs
.map((input, index) => ({ input, index }))
.filter(({ input }) => input.type === 'FLEX' && input.default && input.default !== 'NONE');
}
/** Pots + multipos in original JSON order (e.g. S1, 6POS, S2 on TX16S). */
function getPotsAndMultipos(): { input: InputDef; index: number }[] {
return getFlexInputs().filter(({ input }) =>
input.default === 'POT' || input.default === 'POT_CENTER' || input.default === 'MULTIPOS'
);
}
function getLeftPotsAndMultipos(): { input: InputDef; index: number }[] {
const all = getPotsAndMultipos();
return all.slice(0, Math.ceil(all.length / 2));
}
function getRightPotsAndMultipos(): { input: InputDef; index: number }[] {
const all = getPotsAndMultipos();
return all.slice(Math.ceil(all.length / 2));
}
function getSliders(): { input: InputDef; index: number }[] {
return getFlexInputs().filter(({ input }) => input.default === 'SLIDER');
}
/** Read stickMode from /RADIO/radio.yml via FS Worker. */
async function readStickMode(): Promise<void> {
if (!runner) return;
try {
const data = await runner.fsReadTextFile('/RADIO/radio.yml');
if (data) {
const match = data.match(/^stickMode:\s*(\d+)/m);
if (match) {
stickMode = parseInt(match[1], 10);
onTrace(`[fs] stickMode=${stickMode} (Mode ${stickMode + 1})\n`);
}
}
} catch {
// No radio.yml yet — use default mode
}
}
/** Get the input index of the throttle axis based on stick mode. */
function getThrottleIndex(): number {
const si = getStickIndices();
// Modes 1,3 (stickMode 0,2): throttle = RV; Modes 2,4 (stickMode 1,3): throttle = LV
return (stickMode & 1) ? si.lv : si.rv;
}
// Initialize control state from current radio
function initControls() {
if (!currentRadio) return;
const thrIdx = getThrottleIndex();
const inputs = currentRadio.inputs ?? [];
analogValues = inputs.map((input, i) => {
if (input.default === 'MULTIPOS') return 0;
if (i === thrIdx) return 0; // throttle at lowest
return 2048; // others centered
});
const switches = currentRadio.switches ?? [];
switchStates = switches.map(() => -1); // all up by default
switchDir = switches.map(() => 1); // first mid click goes down
}
// Send analog value to WASM in ADC range (0..4096, center=2048)
function updateAnalog(index: number, value: number) {
analogValues[index] = Math.round(Math.max(0, Math.min(4096, value)));
runner?.setAnalog(index, analogValues[index]);
}
// Send switch state to WASM
function updateSwitch(index: number, state: number) {
switchStates[index] = state;
runner?.exports?.simuSetSwitch(index, state);
}
// Discover which radios have WASM files available
async function discoverRadios() {
try {
const resp = await fetch('./radios.json');
if (!resp.ok) {
status = 'No radios.json manifest found';
return;
}
const manifest: RadioEntry[] = await resp.json();
// Probe each WASM file with a HEAD request, verify content-type
// (Vite's dev server returns 200 with text/html for missing files)
const checks = manifest.map(async (r) => {
try {
const head = await fetch(`./${r.wasm}`, { method: 'HEAD' });
const ct = head.headers.get('content-type') || '';
return { ...r, available: head.ok && ct.includes('wasm') };
} catch {
return { ...r, available: false };
}
});
radios = await Promise.all(checks);
const avail = radios.filter((r) => r.available);
if (avail.length > 0) {
const urlParam = new URLSearchParams(location.search).get('radio');
const saved = localStorage.getItem('selectedRadio');
const match = urlParam && avail.find((r) => {
const key = radioKeyFromWasm(r.wasm);
const q = urlParam.toLowerCase();
return key.toLowerCase() === q || r.name.toLowerCase() === q;
});
selectedRadio = match ? match.wasm
: (saved && avail.some((r) => r.wasm === saved)) ? saved
: avail[0].wasm;
status = `${avail.length} radio(s) available`;
} else {
status = 'No .wasm files found in public/';
}
} catch (e) {
status = `Error: ${e instanceof Error ? e.message : e}`;
}
}
function onTrace(text: string) {
traceLog += text;
const lines = traceLog.split('\n');
if (lines.length > 200) {
traceLog = lines.slice(-200).join('\n');
}
}
function onAudio(samples: Int16Array) {
if (!audioCtx) return;
const buf = audioCtx.createBuffer(1, samples.length, AUDIO_SAMPLE_RATE);
const chan = buf.getChannelData(0);
for (let i = 0; i < samples.length; i++) {
chan[i] = samples[i] / 32768;
}
const now = audioCtx.currentTime;
// If we've fallen behind or haven't started, reset the cursor
if (audioNextTime < now) {
audioNextTime = now + AUDIO_LATENCY;
}
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start(audioNextTime);
audioNextTime += samples.length / AUDIO_SAMPLE_RATE;
}
/** Load WASM + start the simulator in one step. */
async function run() {
if (!selectedRadio || loading) return;
try {
loading = true;
traceLog = '';
const radio = radios.find((r) => r.wasm === selectedRadio);
currentRadio = radio ?? null;
status = `Loading ${radio?.name ?? selectedRadio}...`;
const radioKey = radioKeyFromWasm(selectedRadio);
// Reuse existing runner/FS Worker from radio selection, or create fresh
if (!runner || !runner.hasFsWorker) {
await teardown();
runner = new WasmRunner(onTrace, onAudio);
const { hasContent } = await runner.initFs(radioKey);
if (hasContent) {
const files = await runner.fsListFiles('/');
onTrace(`[fs] ${files.length} file(s) in OPFS for "${radioKey}"\n`);
} else {
onTrace(`[fs] no previous data for "${radioKey}"\n`);
}
}
// Always re-read stick mode before starting (user may have changed it in a previous run)
await readStickMode();
await runner.load(`./${selectedRadio}`);
const ex = runner.exports!;
lcdWidth = ex.simuLcdGetWidth();
lcdHeight = ex.simuLcdGetHeight();
lcdDepth = ex.simuLcdGetDepth();
if (lcdDepth === 1) {
lcdSize = lcdWidth * ((lcdHeight + 7) >> 3);
} else if (lcdDepth === 4) {
lcdSize = (lcdWidth * lcdHeight * 4) >> 3;
} else {
lcdSize = lcdWidth * lcdHeight * (lcdDepth >> 3);
}
initControls();
// Push all analog/switch values to WASM before starting
for (let i = 0; i < analogValues.length; i++) {
runner.setAnalog(i, analogValues[i]);
}
for (let i = 0; i < switchStates.length; i++) {
ex.simuSetSwitch(i, switchStates[i]);
}
// --- Init audio ---
if (!audioCtx) {
audioCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE });
}
if (audioCtx.state === 'suspended') {
await audioCtx.resume();
}
audioNextTime = 0;
// --- Start sim ---
ex.simuInit();
runner.setFatfsPaths('/', '/');
ex.simuCreateDefaults();
ex.simuStart(1);
if (canvas) {
lcdRenderer = new LcdRenderer(canvas);
const dpr = window.devicePixelRatio || 1;
// Color LCDs: integer-scale for crisp pixels.
// Dot-matrix LCDs: match CSS size so shader has enough pixels for grid effect.
let canvasW: number, canvasH: number;
if (lcdDepth >= 16) {
const scale = Math.max(1, Math.round(dpr));
canvasW = lcdWidth * scale;
canvasH = lcdHeight * scale;
} else {
canvasW = Math.round(cssWidth * dpr);
canvasH = Math.round(cssHeight * dpr);
}
lcdRenderer.resize(canvasW, canvasH);
onTrace(`[lcd] ${lcdWidth}×${lcdHeight}@${lcdDepth}bpp css=${Math.round(cssWidth)}×${Math.round(cssHeight)} buf=${canvasW}×${canvasH} dpr=${dpr}\n`);
}
loading = false;
running = true;
status = 'Running';
lcdLoop().catch((e) => console.error('lcdLoop error:', e));
} catch (e) {
loading = false;
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('MIME') || msg.includes('Failed to fetch')) {
status = `WASM module not found: ${selectedRadio}`;
} else {
status = `Error: ${msg}`;
}
}
}
/** Stop the simulator. Keeps the FS Worker alive for uploads. */
async function stop() {
lcdLoopActive = false;
running = false;
loading = true; // suppress fsReady until fully stopped
lcdRenderer = null;
status = 'Stopping...';
if (runner?.exports) {
try {
runner.exports.simuStop();
} catch {
// simuStop() sets the shutdown flag and signals threads to stop,
// but traps when trying to join (Atomics.wait on main thread).
// Worker threads continue their shutdown asynchronously.
}
}
// Wait for worker threads to finish writing settings/files, then terminate them.
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
runner?.stopSim();
fsHasContent = true; // sim writes settings/models on shutdown
loading = false;
status = `Stopped — ${currentRadio?.name ?? selectedRadio}`;
}
/** Full teardown: stop sim + terminate FS Worker. */
async function teardown() {
lcdLoopActive = false;
const r = runner;
// Set state synchronously first to avoid re-entrant effects
runner = null;
lcdRenderer = null;
running = false;
currentRadio = null;
fsHasContent = false;
lcdDepth = 0;
// Force Svelte to recreate the canvas element (avoids stale WebGL context)
canvasKey++;
if (r) {
if (r.exports) {
try { r.exports.simuStop(); } catch { /* expected */ }
}
r.stopSim();
try { await r.stopFs(); } catch { /* best effort */ }
}
}
function pollCustomSwitches() {
const ex = runner?.exports;
if (!ex) return;
const numCs = ex.simuGetNumCustomSwitches();
if (numCs > 0) {
for (let i = 0; i < numCs; i++) {
const on = ex.simuGetCustomSwitchState(i);
if (on) {
const rgb = ex.simuGetCustomSwitchColor(i);
const r = (rgb >> 16) & 0xff, g = (rgb >> 8) & 0xff, b = rgb & 0xff;
customSwitchColors[i] = (r || g || b) ? `rgb(${r},${g},${b})` : '#38bff9';
} else {
customSwitchColors[i] = '';
}
}
}
}
/** Async LCD loop: waits for firmware frame notifications via Atomics.waitAsync. */
async function lcdLoop() {
lcdLoopActive = true;
frameCount = 0;
lastFpsTime = performance.now();
fps = 0;
// Update FPS display every 250ms, smoothed with exponential moving average
let smoothFps = 0;
const fpsInterval = setInterval(() => {
const now = performance.now();
const elapsed = now - lastFpsTime;
if (elapsed > 0) {
const instantFps = frameCount * 1000 / elapsed;
smoothFps += (instantFps - smoothFps) * 0.4;
fps = Math.round(smoothFps);
}
frameCount = 0;
lastFpsTime = now;
}, 250);
try {
while (lcdLoopActive && runner) {
const hasFrame = await runner.waitForLcdFrame(100);
if (!lcdLoopActive || !runner) break;
const ex = runner.exports;
if (!ex || !lcdRenderer) continue;
pollCustomSwitches();
const lcdChanged = ex.simuLcdChanged();
if (!hasFrame && !lcdChanged) continue;
const data = runner.copyLcd(lcdSize);
if (!data) continue;
lcdRenderer.render(data, lcdWidth, lcdHeight, lcdDepth);
ex.simuLcdFlushed();
frameCount++;
}
} finally {
clearInterval(fpsInterval);
fps = 0;
}
}
function canvasToLcd(clientX: number, clientY: number): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
const scaleX = lcdWidth / rect.width;
const scaleY = lcdHeight / rect.height;
return {
x: Math.round((clientX - rect.left) * scaleX),
y: Math.round((clientY - rect.top) * scaleY),
};
}
function handleCanvasMouseDown(e: MouseEvent) {
const ex = runner?.exports;
if (!ex || !running || !canvas) return;
const { x, y } = canvasToLcd(e.clientX, e.clientY);
ex.simuTouchDown(x, y);
const onMove = (ev: MouseEvent) => {
const pos = canvasToLcd(ev.clientX, ev.clientY);
ex.simuTouchDown(pos.x, pos.y);
};
const onUp = () => {
ex.simuTouchUp();
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function handleCanvasTouchStart(e: TouchEvent) {
e.preventDefault();
const ex = runner?.exports;
if (!ex || !running || !canvas) return;
const t = e.touches[0];
const { x, y } = canvasToLcd(t.clientX, t.clientY);
ex.simuTouchDown(x, y);
const onMove = (ev: TouchEvent) => {
ev.preventDefault();
const t = ev.touches[0];
const pos = canvasToLcd(t.clientX, t.clientY);
ex.simuTouchDown(pos.x, pos.y);
};
const onEnd = () => {
ex.simuTouchUp();
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
window.removeEventListener('touchcancel', onEnd);
};
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
window.addEventListener('touchcancel', onEnd);
}
let lastWheelTime = 0;
function handleWheel(e: WheelEvent) {
const ex = runner?.exports;
if (!ex || !running) return;
e.preventDefault();
const now = performance.now();
if (now - lastWheelTime < 200) return;
lastWheelTime = now;
const steps = e.deltaY > 0 ? 1 : -1;
ex.simuRotaryEncoderEvent(steps);
}
// Key constants matching radio/src/hal/key_driver.h
const KEY_MENU = 0, KEY_EXIT = 1, KEY_ENTER = 2;
const KEY_PAGEUP = 3, KEY_PAGEDN = 4;
const KEY_UP = 5, KEY_DOWN = 6, KEY_LEFT = 7, KEY_RIGHT = 8;
const KEY_PLUS = 9, KEY_MINUS = 10;
const KEY_MODEL = 11, KEY_TELE = 12, KEY_SYS = 13;
const ROTARY_ENCODER_GRANULARITY = 1;
// Map keyboard keys to radio key names
const KEYBOARD_MAP: Record<string, string> = {
'Escape': 'KEY_EXIT',
'Enter': 'KEY_ENTER',
'ArrowLeft': 'KEY_LEFT',
'ArrowRight': 'KEY_RIGHT',
'PageUp': 'KEY_PAGEUP',
'PageDown': 'KEY_PAGEDN',
'+': 'KEY_PLUS',
'-': 'KEY_MINUS',
'm': 'KEY_MENU',
's': 'KEY_SYS',
't': 'KEY_TELE',
};
function handleKey(e: KeyboardEvent) {
const ex = runner?.exports;
if (!ex || !running) return;
if (e.repeat) return;
if (e.metaKey || e.ctrlKey || e.altKey) return; // don't hijack browser shortcuts
const down = e.type === 'keydown';
// Rotary encoder: arrow up/down
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (down) ex.simuRotaryEncoderEvent(e.key === 'ArrowUp' ? -ROTARY_ENCODER_GRANULARITY : ROTARY_ENCODER_GRANULARITY);
e.preventDefault();
return;
}
const keyName = KEYBOARD_MAP[e.key];
if (!keyName) return; // don't prevent default for unmapped keys
if (down) keyDown(keyName);
else keyUp(keyName);
e.preventDefault();
}
// --- Gimbal handling ---
function gimbalPos(el: HTMLDivElement, e: MouseEvent | Touch): { x: number; y: number } {
const rect = el.getBoundingClientRect();
const rawX = (e.clientX - rect.left) / rect.width;
const rawY = (e.clientY - rect.top) / rect.height;
const x = Math.max(-1, Math.min(1, (rawX - 0.5) * 2));
const y = Math.max(-1, Math.min(1, (0.5 - rawY) * 2)); // invert Y: up is positive
return { x, y };
}
function handleGimbalDown(side: 'left' | 'right', e: MouseEvent) {
e.preventDefault();
draggingGimbal = side;
const el = side === 'left' ? leftGimbalEl : rightGimbalEl;
const pos = gimbalPos(el, e);
applyGimbal(side, pos.x, pos.y);
const onMove = (ev: MouseEvent) => {
ev.preventDefault();
const p = gimbalPos(el, ev);
applyGimbal(side, p.x, p.y);
};
const onUp = () => {
draggingGimbal = null;
releaseGimbal(side);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function handleGimbalTouchStart(side: 'left' | 'right', e: TouchEvent) {
e.preventDefault();
draggingGimbal = side;
const el = side === 'left' ? leftGimbalEl : rightGimbalEl;
const touch = e.touches[0];
const pos = gimbalPos(el, touch);
applyGimbal(side, pos.x, pos.y);
const onMove = (ev: TouchEvent) => {
ev.preventDefault();
const t = ev.touches[0];
const p = gimbalPos(el, t);
applyGimbal(side, p.x, p.y);
};
const onEnd = () => {
draggingGimbal = null;
releaseGimbal(side);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
window.removeEventListener('touchcancel', onEnd);
};
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
window.addEventListener('touchcancel', onEnd);
}
// Convert -1..+1 UI position to 0..4096 ADC range (center=2048)
function toAdc(v: number): number {
return Math.round((v + 1) * 2048);
}
// Convert 0..4096 ADC value back to -1..+1 for UI display
function fromAdc(v: number): number {
return v / 2048 - 1;
}
/** Animate an axis back to a target with damped spring physics. */
let springAnimations = new Map<number, number>(); // index → rafId
function springTo(index: number, target: number) {
// Cancel any existing animation on this axis
const existing = springAnimations.get(index);
if (existing) cancelAnimationFrame(existing);
let pos = analogValues[index];
let vel = 0;
let prev = performance.now();
const stiffness = 600; // spring force
const damping = 25; // friction
const tick = (now: number) => {
const dt = Math.min((now - prev) / 1000, 0.033);
prev = now;
const force = (target - pos) * stiffness;
vel = (vel + force * dt) * Math.exp(-damping * dt);
pos += vel * dt;
updateAnalog(index, Math.round(pos));
if (Math.abs(pos - target) < 1 && Math.abs(vel) < 10) {
updateAnalog(index, target);
springAnimations.delete(index);
return;
}
springAnimations.set(index, requestAnimationFrame(tick));
};
springAnimations.set(index, requestAnimationFrame(tick));
}
/** On release, spring back to center except throttle axis keeps its position. */
function releaseGimbal(side: 'left' | 'right') {
const si = getStickIndices();
const thrIdx = getThrottleIndex();
if (side === 'left') {
if (si.lh >= 0) springTo(si.lh, 2048);
if (si.lv >= 0 && si.lv !== thrIdx) springTo(si.lv, 2048);
} else {
if (si.rh >= 0) springTo(si.rh, 2048);
if (si.rv >= 0 && si.rv !== thrIdx) springTo(si.rv, 2048);
}
}
function applyGimbal(side: 'left' | 'right', x: number, y: number) {
const si = getStickIndices();
if (side === 'left') {
if (si.lh >= 0) updateAnalog(si.lh, toAdc(x));
if (si.lv >= 0) updateAnalog(si.lv, toAdc(y));
} else {
if (si.rh >= 0) updateAnalog(si.rh, toAdc(x));
if (si.rv >= 0) updateAnalog(si.rv, toAdc(y));
}
}
// Gimbal crosshair position as CSS percentage
function gimbalCrossX(side: 'left' | 'right'): number {
const si = getStickIndices();
const idx = side === 'left' ? si.lh : si.rh;
if (idx < 0) return 50;
return 50 + fromAdc(analogValues[idx]) * 50;
}
function gimbalCrossY(side: 'left' | 'right'): number {
const si = getStickIndices();
const idx = side === 'left' ? si.lv : si.rv;
if (idx < 0) return 50;
return 50 - fromAdc(analogValues[idx]) * 50;
}
// --- Key handling ---
function keyDown(keyName: string) {
const idx = KEY_INDEX[keyName];
if (idx !== undefined) runner?.exports?.simuSetKey(idx, 1);
}
function keyUp(keyName: string) {
const idx = KEY_INDEX[keyName];
if (idx !== undefined) runner?.exports?.simuSetKey(idx, 0);
}
function getLeftKeys(): KeyDef[] {
return (currentRadio?.keys ?? []).filter(k => k.side === 'L');
}
function getRightKeys(): KeyDef[] {
return (currentRadio?.keys ?? []).filter(k => k.side === 'R');
}
// --- Switch toggle handling ---
function handleSwitchKey(index: number, sw: SwitchDef, e: KeyboardEvent) {
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
const min = sw.type === '2POS' ? -1 : -1;
updateSwitch(index, Math.max(min, switchStates[index] - 1));
} else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault();
updateSwitch(index, Math.min(1, switchStates[index] + 1));
}
}
function handleSwitchTrackClick(index: number, sw: SwitchDef, e: MouseEvent) {
const track = e.currentTarget as HTMLElement;
const rect = track.getBoundingClientRect();
const y = (e.clientY - rect.top) / rect.height; // 0=top, 1=bottom
if (sw.type === '2POS') {
updateSwitch(index, y < 0.5 ? -1 : 1);
} else {
if (y < 0.33) updateSwitch(index, -1);
else if (y < 0.67) updateSwitch(index, 0);
else updateSwitch(index, 1);
}
}
function snapSwitch(index: number, sw: SwitchDef, track: HTMLElement, clientY: number) {
const rect = track.getBoundingClientRect();
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
if (sw.type === '2POS') {
updateSwitch(index, y < 0.5 ? -1 : 1);
} else {
if (y < 0.33) updateSwitch(index, -1);
else if (y < 0.67) updateSwitch(index, 0);
else updateSwitch(index, 1);
}
}
function handleSwitchDrag(index: number, sw: SwitchDef, e: MouseEvent) {
e.preventDefault();
const track = (e.currentTarget as HTMLElement).parentElement!;
const onMove = (ev: MouseEvent) => snapSwitch(index, sw, track, ev.clientY);
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function handleSwitchTouchDrag(index: number, sw: SwitchDef, e: TouchEvent) {
e.preventDefault();
const track = (e.currentTarget as HTMLElement).parentElement!;
const onMove = (ev: TouchEvent) => {
ev.preventDefault();
snapSwitch(index, sw, track, ev.touches[0].clientY);
};
const onEnd = () => {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
window.removeEventListener('touchcancel', onEnd);
};
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
window.addEventListener('touchcancel', onEnd);
}
function switchKnobTop(state: number, is3pos: boolean): string {
// Knob is 14px tall, use calc() to keep it inside the track
if (is3pos) {
if (state === -1) return '0px';
if (state === 0) return 'calc(50% - 7px)';
return 'calc(100% - 14px)';
}
return state === -1 ? '0px' : 'calc(100% - 14px)';
}
// --- Pot/Slider handling ---
function handlePotChange(index: number, e: Event) {
const val = parseInt((e.target as HTMLInputElement).value);
updateAnalog(index, val);
}
// --- Multipos handling ---
function handleMultiposChange(index: number, position: number) {
// 6 positions mapped to 0..4096 ADC range (matches SDL: (pos * 4096) / 5)
updateAnalog(index, Math.round((position * 4096) / 5));
}
// --- Trim handling ---
// Board::TrimSwitches enum (physical axis order):
// LH_DEC=0 LH_INC=1, LV_DEC=2 LV_INC=3, RV_DEC=4 RV_INC=5, RH_DEC=6 RH_INC=7, T5..T6..
// JSON trims T1..T4 map to physical axes in reverse: T1=RH(6/7), T2=RV(4/5), T3=LV(2/3), T4=LH(0/1)
// T5+ map sequentially starting at index 8.
function trimSwitchIndex(trimIndex: number, direction: 'dec' | 'inc'): number {
const maxMain = Math.min(currentRadio?.trims?.length ?? 4, 4);
const base = trimIndex < maxMain ? (maxMain - 1 - trimIndex) * 2 : trimIndex * 2;
return base + (direction === 'inc' ? 1 : 0);
}
function handleTrimDown(trimIndex: number, direction: 'dec' | 'inc') {
const ex = runner?.exports;
if (!ex || !running) return;
ex.simuSetTrim(trimSwitchIndex(trimIndex, direction), 1);
}
function handleTrimUp(trimIndex: number, direction: 'dec' | 'inc') {
const ex = runner?.exports;
if (!ex || !running) return;
ex.simuSetTrim(trimSwitchIndex(trimIndex, direction), 0);
}
async function resetSdCard() {
if (!runner) return;
if (!confirm('Wipe all SD card data for this radio? This cannot be undone.')) return;
await runner.fsWipe();
fsHasContent = false;
onTrace('[fs] SD card data wiped\n');
status = 'SD card wiped. Reload to start fresh.';
}
function handleFileUpload() {
if (!runner) return;
const targetDir = prompt('Upload to directory:', '/') ?? '/';
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = async () => {
if (!input.files || !runner) return;
const total = input.files.length;
let count = 0;
if (total > 1) status = `Uploading 0/${total} files...`;
for (const file of input.files) {
const dir = targetDir.endsWith('/') ? targetDir : targetDir + '/';
const path = dir + file.name;
const data = await file.arrayBuffer();
await runner.fsWriteFile(path, data);
count++;
if (total > 1 && (count % 10 === 0 || count === total)) {
status = `Uploading ${count}/${total} files to ${targetDir}...`;
}
}
onTrace(`[fs] uploaded ${count} file(s) to ${targetDir}\n`);
if (count > 0) fsHasContent = true;
status = `Uploaded ${count} file(s) to ${targetDir}`;
};
input.click();
}
function handleFolderUpload() {
if (!runner) return;
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
(input as any).webkitdirectory = true;
input.onchange = async () => {
if (!input.files || !runner) return;
const total = input.files.length;
let count = 0;
status = `Uploading 0/${total} files...`;
for (const file of input.files) {
// webkitRelativePath gives "SOUNDS/en/system/hello.wav"
// Keep as-is so selecting "SOUNDS" creates /SOUNDS/...
const relPath = (file as any).webkitRelativePath as string;
if (!relPath) continue;
const path = '/' + relPath;
const data = await file.arrayBuffer();
await runner.fsWriteFile(path, data);
count++;
if (count % 10 === 0 || count === total) {
status = `Uploading ${count}/${total} files...`;
}
}
onTrace(`[fs] uploaded ${count} file(s) from folder\n`);
if (count > 0) fsHasContent = true;
status = `Uploaded ${count} file(s)`;
};
input.click();
}
// Discover on mount
discoverRadios();
// OPFS writes are durable immediately — no save-on-unload needed.
onDestroy(() => {
teardown();
audioCtx?.close();
});
</script>
<svelte:document onkeydown={handleKey} onkeyup={handleKey} />
<main>
<div class="header">
<div class="logo-title">
<img src={edgetxLogo} alt="EdgeTX" class="logo" />
<span class="subtitle">Simulator</span>
</div>
<div class="toolbar">
<select
bind:value={selectedRadio}
disabled={running || loading}
>
{#each radios.filter(r => r.available) as radio}
<option value={radio.wasm}>
{radio.name}
</option>
{/each}
</select>
{#if !running && !loading}
<button onclick={run} disabled={!selectedRadio}>
Run
</button>
{/if}
{#if running}
<button onclick={stop}>Stop</button>
{/if}
{#if fsReady}
<button onclick={handleFileUpload} class="secondary">Upload Files</button>
<button onclick={handleFolderUpload} class="secondary">Upload Folder</button>
{#if fsHasContent}
<button onclick={resetSdCard} class="danger">Reset SD</button>
{/if}
{/if}
<button class="theme-toggle" onclick={toggleTheme}
aria-label="Toggle theme">{themeIcon[themeMode]}</button>
</div>
<p class="status">{status}</p>
</div>
<!-- Reusable snippets for switch controls and gimbals -->
{#snippet switchCtrl(sw: SwitchDef, index: number)}
<div class="switch-control">
<span class="switch-name">{sw.name}</span>
<div class="switch-track" class:is-3pos={sw.type === '3POS'}
role="slider" tabindex="0"
aria-label={sw.name}
aria-valuemin={sw.type === '3POS' ? -1 : 0}
aria-valuemax={1}
aria-valuenow={switchStates[index]}
onclick={(e) => handleSwitchTrackClick(index, sw, e)}
onkeydown={(e) => handleSwitchKey(index, sw, e)}>
{#if sw.type === '3POS'}
<div class="switch-notch" style:top="7px"></div>
<div class="switch-notch" style:top="50%"></div>
<div class="switch-notch" style:top="calc(100% - 7px)"></div>
{:else}
<div class="switch-notch" style:top="7px"></div>
<div class="switch-notch" style:top="calc(100% - 7px)"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="switch-knob" role="presentation"
class:sw-up={switchStates[index] === -1}
class:sw-mid={switchStates[index] === 0}
class:sw-down={switchStates[index] === 1}
style:top={switchKnobTop(switchStates[index], sw.type === '3POS')}
onmousedown={(e) => handleSwitchDrag(index, sw, e)}
ontouchstart={(e) => handleSwitchTouchDrag(index, sw, e)}
></div>
</div>
</div>
{/snippet}
{#snippet lcdScreen()}
<div class="lcd-bezel">
{#key canvasKey}
<canvas
bind:this={canvas}
class="lcd"
style:width="{cssWidth}px"
style:height="{cssHeight}px"
style:background="{lcdDepth > 0 && lcdDepth < 16 ? 'rgb(47, 123, 227)' : '#000'}"
onmousedown={handleCanvasMouseDown}
ontouchstart={handleCanvasTouchStart}
onwheel={handleWheel}
></canvas>
{/key}
</div>
{/snippet}
{#snippet leftGimbalBlock()}
<div class="gimbal-wrapper gimbal-left">
{#if (currentRadio?.trims?.length ?? 0) >= 4}
<div class="trim-col">
<button class="trim-btn"
onmousedown={() => handleTrimDown(2, 'inc')}
onmouseup={() => handleTrimUp(2, 'inc')}
ontouchstart={() => handleTrimDown(2, 'inc')}
ontouchend={() => handleTrimUp(2, 'inc')}
>+</button>
<span class="trim-label">T3</span>
<button class="trim-btn"
onmousedown={() => handleTrimDown(2, 'dec')}
onmouseup={() => handleTrimUp(2, 'dec')}
ontouchstart={() => handleTrimDown(2, 'dec')}
ontouchend={() => handleTrimUp(2, 'dec')}
>-</button>
</div>
{/if}
<div class="gimbal-center">
<div class="gimbal-label">Left Stick</div>
<div
class="gimbal"
bind:this={leftGimbalEl}
onmousedown={(e) => handleGimbalDown('left', e)}
ontouchstart={(e) => handleGimbalTouchStart('left', e)}
role="slider"
aria-label="Left gimbal"
tabindex="0"
aria-valuemin={-1024}
aria-valuemax={1024}
aria-valuenow={analogValues[getStickIndices().lh] ?? 0}
>
<div class="gimbal-crosshair-h" style:top="{gimbalCrossY('left')}%"></div>
<div class="gimbal-crosshair-v" style:left="{gimbalCrossX('left')}%"></div>
<div
class="gimbal-dot"
style:left="{gimbalCrossX('left')}%"
style:top="{gimbalCrossY('left')}%"
></div>
</div>
{#if (currentRadio?.trims?.length ?? 0) >= 4}
<div class="trim-row">
<button class="trim-btn"
onmousedown={() => handleTrimDown(3, 'dec')}
onmouseup={() => handleTrimUp(3, 'dec')}
ontouchstart={() => handleTrimDown(3, 'dec')}
ontouchend={() => handleTrimUp(3, 'dec')}
>-</button>
<span class="trim-label">T4</span>
<button class="trim-btn"
onmousedown={() => handleTrimDown(3, 'inc')}
onmouseup={() => handleTrimUp(3, 'inc')}
ontouchstart={() => handleTrimDown(3, 'inc')}
ontouchend={() => handleTrimUp(3, 'inc')}
>+</button>
</div>
{/if}
</div>
</div>
{/snippet}
{#snippet rightGimbalBlock()}
<div class="gimbal-wrapper gimbal-right">
<div class="gimbal-center">
<div class="gimbal-label">Right Stick</div>
<div
class="gimbal"
bind:this={rightGimbalEl}
onmousedown={(e) => handleGimbalDown('right', e)}
ontouchstart={(e) => handleGimbalTouchStart('right', e)}
role="slider"
aria-label="Right gimbal"
tabindex="0"
aria-valuemin={-1024}
aria-valuemax={1024}
aria-valuenow={analogValues[getStickIndices().rv] ?? 0}
>
<div class="gimbal-crosshair-h" style:top="{gimbalCrossY('right')}%"></div>
<div class="gimbal-crosshair-v" style:left="{gimbalCrossX('right')}%"></div>
<div
class="gimbal-dot"
style:left="{gimbalCrossX('right')}%"
style:top="{gimbalCrossY('right')}%"
></div>
</div>
{#if (currentRadio?.trims?.length ?? 0) >= 2}
<div class="trim-row">
<button class="trim-btn"
onmousedown={() => handleTrimDown(0, 'dec')}
onmouseup={() => handleTrimUp(0, 'dec')}
ontouchstart={() => handleTrimDown(0, 'dec')}
ontouchend={() => handleTrimUp(0, 'dec')}
>-</button>
<span class="trim-label">T1</span>
<button class="trim-btn"
onmousedown={() => handleTrimDown(0, 'inc')}
onmouseup={() => handleTrimUp(0, 'inc')}
ontouchstart={() => handleTrimDown(0, 'inc')}
ontouchend={() => handleTrimUp(0, 'inc')}
>+</button>
</div>
{/if}
</div>
{#if (currentRadio?.trims?.length ?? 0) >= 2}
<div class="trim-col">
<button class="trim-btn"
onmousedown={() => handleTrimDown(1, 'inc')}
onmouseup={() => handleTrimUp(1, 'inc')}
ontouchstart={() => handleTrimDown(1, 'inc')}
ontouchend={() => handleTrimUp(1, 'inc')}
>+</button>
<span class="trim-label">T2</span>
<button class="trim-btn"
onmousedown={() => handleTrimDown(1, 'dec')}
onmouseup={() => handleTrimUp(1, 'dec')}
ontouchstart={() => handleTrimDown(1, 'dec')}
ontouchend={() => handleTrimUp(1, 'dec')}
>-</button>
</div>
{/if}
</div>
{/snippet}
{#snippet flexCtrl(input: InputDef, index: number)}
{#if input.default === 'MULTIPOS'}
<div class="multipos-control">
<span class="control-label">{input.label}</span>
<div class="multipos-buttons">
{#each [0, 1, 2, 3, 4, 5] as pos}
<button
class="multipos-btn"
class:active={Math.round(analogValues[index] * 5 / 4096) === pos}
onclick={() => handleMultiposChange(index, pos)}
>{pos + 1}</button>
{/each}
</div>
<span class="pot-value">&nbsp;</span>
</div>
{:else}
{@render potCtrl(input, index)}
{/if}
{/snippet}
{#snippet potsRow()}
{#if getPotsAndMultipos().length > 0}
<div class="pots-row">
{#if getCustomSwitches().length > 0}
<!-- Radios with custom switches: left pots | custom switches | right pots -->
{#each getLeftPotsAndMultipos() as { input, index }}
{@render flexCtrl(input, index)}
{/each}
{#each getCustomSwitches() as { sw, index: swIdx }, csIdx}
<div class="custom-switch">
<button
class="custom-switch-btn"
class:active={switchStates[swIdx] === 1}
aria-label={sw.name}
style:background={customSwitchColors[csIdx] || ''}
style:border-color={customSwitchColors[csIdx] || ''}
style:box-shadow={customSwitchColors[csIdx] ? `0 0 8px ${customSwitchColors[csIdx]}` : ''}
onmousedown={() => updateSwitch(swIdx, 1)}
onmouseup={() => updateSwitch(swIdx, -1)}
onmouseleave={() => { if (switchStates[swIdx] === 1) updateSwitch(swIdx, -1); }}
ontouchstart={(e) => { e.preventDefault(); updateSwitch(swIdx, 1); }}
ontouchend={(e) => { e.preventDefault(); updateSwitch(swIdx, -1); }}
></button>
<span class="custom-switch-label">{sw.name}</span>
</div>
{/each}
{#each getRightPotsAndMultipos() as { input, index }}
{@render flexCtrl(input, index)}
{/each}
{:else}
<!-- No custom switches: render pots+multipos in JSON order -->
{#each getPotsAndMultipos() as { input, index }}
{@render flexCtrl(input, index)}
{/each}
{/if}
</div>
{/if}
{/snippet}
{#snippet customSwitchesRow()}
{#if getCustomSwitches().length > 0}
<div class="custom-switches-row">
{#each getCustomSwitches() as { sw, index }, csIdx}
<div class="custom-switch">
<button
class="custom-switch-btn"
class:active={switchStates[index] === 1}
aria-label={sw.name}
style:background={customSwitchColors[csIdx] || ''}
style:border-color={customSwitchColors[csIdx] || ''}
style:box-shadow={customSwitchColors[csIdx] ? `0 0 8px ${customSwitchColors[csIdx]}` : ''}
onmousedown={() => updateSwitch(index, 1)}
onmouseup={() => updateSwitch(index, -1)}
onmouseleave={() => { if (switchStates[index] === 1) updateSwitch(index, -1); }}
ontouchstart={(e) => { e.preventDefault(); updateSwitch(index, 1); }}
ontouchend={(e) => { e.preventDefault(); updateSwitch(index, -1); }}
></button>
<span class="custom-switch-label">{sw.name}</span>
</div>
{/each}
</div>
{/if}
{/snippet}
{#snippet potCtrl(input: InputDef, index: number)}
<div class="pot-control">
<span class="control-label">{input.label}</span>
<input type="range" min="0" max="4096"
value={analogValues[index]}
oninput={(e) => handlePotChange(index, e)}
class="pot-knob" />
<span class="pot-value">{analogValues[index]}</span>
</div>
{/snippet}
{#snippet keyRow()}
{#if currentRadio?.keys?.length}
<div class="key-row">
{#each getLeftKeys() as keyDef}
<button class="hw-key"
onmousedown={() => keyDown(keyDef.key)}
onmouseup={() => keyUp(keyDef.key)}
onmouseleave={() => keyUp(keyDef.key)}
ontouchstart={(e) => { e.preventDefault(); keyDown(keyDef.key); }}
ontouchend={(e) => { e.preventDefault(); keyUp(keyDef.key); }}
>{keyDef.label}</button>
{/each}
{#each getRightKeys() as keyDef}
<button class="hw-key"
onmousedown={() => keyDown(keyDef.key)}
onmouseup={() => keyUp(keyDef.key)}
onmouseleave={() => keyUp(keyDef.key)}
ontouchstart={(e) => { e.preventDefault(); keyDown(keyDef.key); }}
ontouchend={(e) => { e.preventDefault(); keyUp(keyDef.key); }}
>{keyDef.label}</button>
{/each}
</div>
{/if}
{/snippet}
{#snippet extraTrims()}
{#if (currentRadio?.trims?.length ?? 0) > 4}
<div class="extra-trims">
{#each (currentRadio?.trims ?? []).slice(4) as trim, i}
<div class="trim-row">
<button class="trim-btn"
onmousedown={() => handleTrimDown(4 + i, 'dec')}
onmouseup={() => handleTrimUp(4 + i, 'dec')}
ontouchstart={() => handleTrimDown(4 + i, 'dec')}
ontouchend={() => handleTrimUp(4 + i, 'dec')}
>-</button>
<span class="trim-label">{trim.name}</span>
<button class="trim-btn"
onmousedown={() => handleTrimDown(4 + i, 'inc')}
onmouseup={() => handleTrimUp(4 + i, 'inc')}
ontouchstart={() => handleTrimDown(4 + i, 'inc')}
ontouchend={() => handleTrimUp(4 + i, 'inc')}
>+</button>
</div>
{/each}
</div>
{/if}
{/snippet}
<div class="radio-body" class:portrait={isPortrait}>
{#if isPortrait && currentRadio}
<!-- Portrait layout: left/right columns flanking LCD, keys below -->
<div class="portrait-main">
<!-- Left column: switches, pots, slider, gimbal -->
<div class="portrait-side">
<div class="switch-column">
{#each getLeftSwitches() as { sw, index }}
{@render switchCtrl(sw, index)}
{/each}
</div>
{#if getLeftPotsAndMultipos().length > 0}
<div class="portrait-pots">
{#each getLeftPotsAndMultipos() as { input, index }}
{@render flexCtrl(input, index)}
{/each}
</div>
{/if}
{#if getSliders().length > 0}
<div class="slider-column">
{#each getSliders().slice(0, 1) as { input, index }}
<div class="slider-control">
<span class="control-label">{input.label}</span>
<input type="range" min="0" max="4096"
value={analogValues[index]}
oninput={(e) => handlePotChange(index, e)}
orient="vertical" class="vertical-slider" />
</div>
{/each}
</div>
{/if}
{@render leftGimbalBlock()}
</div>
<!-- Centre: LCD screen -->
<div class="portrait-center">
{@render lcdScreen()}
{@render keyRow()}
</div>
<!-- Right column: switches, pots, slider, gimbal -->
<div class="portrait-side">
<div class="switch-column">
{#each getRightSwitches() as { sw, index }}
{@render switchCtrl(sw, index)}
{/each}
</div>
{#if getRightPotsAndMultipos().length > 0}
<div class="portrait-pots">
{#each getRightPotsAndMultipos() as { input, index }}
{@render flexCtrl(input, index)}
{/each}
</div>
{/if}
{#if getSliders().length > 1}
<div class="slider-column">
{#each getSliders().slice(1, 2) as { input, index }}
<div class="slider-control">
<span class="control-label">{input.label}</span>
<input type="range" min="0" max="4096"
value={analogValues[index]}
oninput={(e) => handlePotChange(index, e)}
orient="vertical" class="vertical-slider" />
</div>
{/each}
</div>
{/if}
{@render rightGimbalBlock()}
</div>
</div>
{#if getPotsAndMultipos().length === 0}
{@render customSwitchesRow()}
{/if}
{@render extraTrims()}
{:else}
<!-- Landscape layout: LCD on top, controls below -->
<div class="lcd-area">
{#if currentRadio?.keys?.length}
<div class="key-column">
{#each getLeftKeys() as keyDef}
<button class="hw-key"
onmousedown={() => keyDown(keyDef.key)}
onmouseup={() => keyUp(keyDef.key)}
onmouseleave={() => keyUp(keyDef.key)}
ontouchstart={(e) => { e.preventDefault(); keyDown(keyDef.key); }}
ontouchend={(e) => { e.preventDefault(); keyUp(keyDef.key); }}
>{keyDef.label}</button>
{/each}
</div>
{/if}
{@render lcdScreen()}
{#if currentRadio?.keys?.length}
<div class="key-column">
{#each getRightKeys() as keyDef}
<button class="hw-key"
onmousedown={() => keyDown(keyDef.key)}
onmouseup={() => keyUp(keyDef.key)}
onmouseleave={() => keyUp(keyDef.key)}
ontouchstart={(e) => { e.preventDefault(); keyDown(keyDef.key); }}
ontouchend={(e) => { e.preventDefault(); keyUp(keyDef.key); }}
>{keyDef.label}</button>
{/each}
</div>
{/if}
</div>
{#if currentRadio}
{@render potsRow()}
{#if getPotsAndMultipos().length === 0}
{@render customSwitchesRow()}
{/if}
<div class="controls-area">
<div class="switch-column">
{#each getLeftSwitches() as { sw, index }}
{@render switchCtrl(sw, index)}
{/each}
</div>
{#if getSliders().length > 0}
<div class="slider-column">
{#each getSliders().slice(0, 1) as { input, index }}
<div class="slider-control">
<span class="control-label">{input.label}</span>
<input type="range" min="0" max="4096"
value={analogValues[index]}
oninput={(e) => handlePotChange(index, e)}
orient="vertical" class="vertical-slider" />
</div>
{/each}
</div>
{/if}
{@render leftGimbalBlock()}
{@render rightGimbalBlock()}
{#if getSliders().length > 1}
<div class="slider-column">
{#each getSliders().slice(1, 2) as { input, index }}
<div class="slider-control">
<span class="control-label">{input.label}</span>
<input type="range" min="0" max="4096"
value={analogValues[index]}
oninput={(e) => handlePotChange(index, e)}
orient="vertical" class="vertical-slider" />
</div>
{/each}
</div>
{/if}
<div class="switch-column">
{#each getRightSwitches() as { sw, index }}
{@render switchCtrl(sw, index)}
{/each}
</div>
</div>
{@render extraTrims()}
{/if}
{/if}
</div>
<details>
<summary class="trace-summary">Trace output{#if running}<span class="fps">{fps} fps</span>{/if}</summary>
<pre class="trace">{traceLog}</pre>
</details>
</main>
<style>
/* ---- Theme tokens ---- */
/* Light mode: default */
:global(:root) {
--bg-page: #f0f0f0;
--bg-body: #e0e0e0;
--bg-surface: #d5d5d5;
--bg-element: #ccc;
--bg-hover: #bbb;
--bg-active: #aaa;
--border: #bbb;
--border-light: #aaa;
--border-body: #ccc;
--text: #1a1a1a;
--text-muted: #555;
--text-label: #444;
--text-dim: #666;
--text-bright: #333;
--accent: #0e96df;
--accent-dark: #0a6fb0;
--accent-glow: rgba(14, 150, 223, 0.3);
--bezel-top: #c0c0c0;
--bezel-mid: #d0d0d0;
--bezel-bot: #c8c8c8;
--bezel-shadow: rgba(0, 0, 0, 0.3);
--bezel-highlight: rgba(255, 255, 255, 0.4);
--gimbal-inner: #d5d5d5;
--gimbal-outer: #c0c0c0;
--shadow: rgba(0, 0, 0, 0.15);
--shadow-strong: rgba(0, 0, 0, 0.2);
--btn-secondary-bg: #c5d8c5;
--btn-secondary-border: #8ab08a;
--btn-danger-bg: #e0c5c5;
--btn-danger-border: #b08a8a;
--btn-danger-hover: #d5b0b0;
--trace-bg: #fff;
--trace-text: #060;
--logo-invert: 1;
}
/* Dark mode: forced via attribute or auto via OS preference */
:global([data-theme="dark"]),
:global(:root:not([data-theme])) {
--bg-page: #1a1a1a;
--bg-body: #222;
--bg-surface: #2a2a2a;
--bg-element: #333;
--bg-hover: #444;
--bg-active: #555;
--border: #444;
--border-light: #555;
--border-body: #333;
--text: #e0e0e0;
--text-muted: #888;
--text-label: #aaa;
--text-dim: #666;
--text-bright: #ccc;
--accent: #38bff9;
--accent-dark: #0e96df;
--accent-glow: rgba(56, 191, 249, 0.25);
--bezel-top: #1a1a1a;
--bezel-mid: #2a2a2a;
--bezel-bot: #222;
--bezel-shadow: rgba(0, 0, 0, 0.8);
--bezel-highlight: rgba(255, 255, 255, 0.05);
--gimbal-inner: #2a2a2a;
--gimbal-outer: #1a1a1a;
--shadow: rgba(0, 0, 0, 0.5);
--shadow-strong: rgba(0, 0, 0, 0.4);
--btn-secondary-bg: #2a3a2a;
--btn-secondary-border: #4a6a4a;
--btn-danger-bg: #3a2a2a;
--btn-danger-border: #6a4a4a;
--btn-danger-hover: #4a2a2a;
--trace-bg: #111;
--trace-text: #0f0;
--logo-invert: 0;
}
/* Auto mode: respect OS light preference */
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) {
--bg-page: #f0f0f0;
--bg-body: #e0e0e0;
--bg-surface: #d5d5d5;
--bg-element: #ccc;
--bg-hover: #bbb;
--bg-active: #aaa;
--border: #bbb;
--border-light: #aaa;
--border-body: #ccc;
--text: #1a1a1a;
--text-muted: #555;
--text-label: #444;
--text-dim: #666;
--text-bright: #333;
--accent: #0e96df;
--accent-dark: #0a6fb0;
--accent-glow: rgba(14, 150, 223, 0.3);
--bezel-top: #c0c0c0;
--bezel-mid: #d0d0d0;
--bezel-bot: #c8c8c8;
--bezel-shadow: rgba(0, 0, 0, 0.3);
--bezel-highlight: rgba(255, 255, 255, 0.4);
--gimbal-inner: #d5d5d5;
--gimbal-outer: #c0c0c0;
--shadow: rgba(0, 0, 0, 0.15);
--shadow-strong: rgba(0, 0, 0, 0.2);
--btn-secondary-bg: #c5d8c5;
--btn-secondary-border: #8ab08a;
--btn-danger-bg: #e0c5c5;
--btn-danger-border: #b08a8a;
--btn-danger-hover: #d5b0b0;
--trace-bg: #fff;
--trace-text: #060;
--logo-invert: 1;
}
}
:global(body) {
margin: 0;
background: var(--bg-page);
color: var(--text);
transition: background 0.2s, color 0.2s;
}
main {
max-width: 820px;
margin: 0 auto;
padding: 0.75rem;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.header {
text-align: center;
margin-bottom: 0.5rem;
}
.logo-title {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.logo {
height: 1.6rem;
filter: invert(var(--logo-invert));
transition: filter 0.2s;
}
.subtitle {
font-size: 1.2rem;
color: var(--text-muted);
font-weight: 300;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
margin: 0.5rem 0;
flex-wrap: wrap;
}
.theme-toggle {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
select {
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
background: var(--bg-surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
}
.status {
color: var(--text-muted);
margin: 0.25rem 0;
font-size: 0.85rem;
}
button {
padding: 0.35rem 1rem;
font-size: 0.9rem;
cursor: pointer;
background: var(--bg-element);
color: var(--text);
border: 1px solid var(--border-light);
border-radius: 4px;
transition: background 0.15s;
}
button:hover {
background: var(--bg-hover);
}
button:active {
background: var(--bg-active);
}
button.secondary {
background: var(--btn-secondary-bg);
border-color: var(--btn-secondary-border);
}
button.danger {
background: var(--btn-danger-bg);
border-color: var(--btn-danger-border);
}
button.danger:hover {
background: var(--btn-danger-hover);
}
/* Radio body */
.radio-body {
background: var(--bg-body);
border-radius: 12px;
padding: 1rem;
border: 2px solid var(--border-body);
box-shadow: 0 4px 20px var(--shadow);
transition: background 0.2s, border-color 0.2s;
}
/* LCD */
.lcd-area {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.lcd-bezel {
display: inline-block;
padding: 6px;
border-radius: 10px;
background: linear-gradient(180deg, var(--bezel-top) 0%, var(--bezel-mid) 40%, var(--bezel-bot) 100%);
box-shadow:
inset 0 2px 4px var(--bezel-shadow),
inset 0 -1px 2px var(--bezel-highlight),
0 1px 3px var(--shadow);
}
.lcd {
display: block;
background: #000;
image-rendering: pixelated;
cursor: crosshair;
border-radius: 4px;
border: none;
max-width: 100%;
}
.key-column {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.hw-key {
padding: 0.3rem 0.6rem;
font-size: 0.7rem;
font-weight: bold;
background: var(--bg-surface);
color: var(--text-bright);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
min-width: 3.5rem;
user-select: none;
-webkit-user-select: none;
}
.hw-key:active {
background: var(--bg-active);
border-color: var(--text-muted);
}
/* Pots row */
.pots-row {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 1.5rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
flex-wrap: wrap;
}
.pot-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.pot-control .control-label {
font-size: 0.75rem;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pot-knob {
width: 80px;
accent-color: var(--accent);
}
.pot-value {
font-size: 0.65rem;
color: var(--text-dim);
font-family: monospace;
}
.multipos-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.multipos-control .control-label {
font-size: 0.75rem;
color: var(--text-label);
text-transform: uppercase;
}
.multipos-buttons {
display: flex;
gap: 2px;
}
.multipos-btn {
width: 22px;
height: 22px;
padding: 0;
font-size: 0.65rem;
border-radius: 3px;
background: var(--bg-element);
border: 1px solid var(--border-light);
}
.multipos-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Custom switches (push buttons with LED) */
.custom-switches-row {
display: flex;
justify-content: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.custom-switch {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.custom-switch-btn {
width: 36px;
height: 22px;
border-radius: 6px;
border: 3px solid var(--border);
background: var(--bg-element);
cursor: pointer;
box-shadow: 0 2px 4px var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition: all 0.1s;
user-select: none;
-webkit-user-select: none;
}
.custom-switch-btn:active,
.custom-switch-btn.active {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 8px rgba(56, 191, 249, 0.5), inset 0 1px 3px rgba(0, 0, 0, 0.2);
transform: scale(0.95);
}
.custom-switch-label {
font-size: 0.65rem;
color: var(--text-dim);
font-family: monospace;
}
/* Controls area */
.controls-area {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
/* Portrait layout (tall-screen radios like EL18, NB4+, NV14) */
.portrait .portrait-main {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.portrait .portrait-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
min-width: 52px;
}
.portrait .portrait-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.portrait .portrait-pots {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.portrait .key-row {
display: flex;
justify-content: center;
gap: 0.4rem;
flex-wrap: wrap;
}
/* Switch column */
.switch-column {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
max-width: calc(3 * 52px);
gap: 0.4rem;
min-width: 52px;
padding-top: 0.5rem;
}
.switch-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.switch-name {
font-size: 0.65rem;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.switch-track {
width: 16px;
height: 36px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
position: relative;
cursor: pointer;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
}
.switch-track.is-3pos {
height: 44px;
}
.switch-notch {
position: absolute;
left: -3px;
right: -3px;
height: 1px;
background: var(--border);
pointer-events: none;
}
.switch-knob {
position: absolute;
left: -2px;
right: -2px;
height: 14px;
border-radius: 4px;
transition: top 0.1s, background 0.1s;
cursor: grab;
border: 1px solid var(--border-light);
}
.switch-knob:active {
cursor: grabbing;
}
.switch-knob.sw-up {
background: linear-gradient(180deg, #38bff9 0%, #0e96df 100%);
border-color: #38bff9;
}
.switch-knob.sw-mid {
background: linear-gradient(180deg, #ff9800 0%, #e65100 100%);
border-color: #ff9800;
}
.switch-knob.sw-down {
background: linear-gradient(180deg, #f44336 0%, #c62828 100%);
border-color: #f44336;
}
/* Slider column */
.slider-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding-top: 0.5rem;
}
.slider-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.slider-control .control-label {
font-size: 0.7rem;
color: var(--text-label);
text-transform: uppercase;
}
.vertical-slider {
writing-mode: vertical-lr;
direction: rtl;
height: 120px;
width: 20px;
accent-color: var(--accent);
}
/* Gimbal */
.gimbal-wrapper {
display: flex;
align-items: center;
gap: 0.25rem;
}
.gimbal-label {
font-size: 0.7rem;
color: var(--text-dim);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
}
.gimbal-center {
display: flex;
flex-direction: column;
align-items: center;
}
.gimbal {
width: 150px;
height: 150px;
background: radial-gradient(circle at center, var(--gimbal-inner) 0%, var(--gimbal-outer) 100%);
border: 2px solid var(--border);
border-radius: 12px;
position: relative;
cursor: crosshair;
touch-action: none;
user-select: none;
box-shadow: inset 0 2px 8px var(--shadow);
}
.gimbal-crosshair-h {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--accent-glow);
pointer-events: none;
}
.gimbal-crosshair-v {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--accent-glow);
pointer-events: none;
}
.gimbal-dot {
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: radial-gradient(circle at 40% 40%, #5bcfff 0%, #38bff9 60%, #3b56c8 100%);
border: 2px solid #81c784;
transform: translate(-50%, -50%);
pointer-events: none;
box-shadow: 0 2px 6px var(--shadow-strong);
}
/* Trims */
.trim-row {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.3rem;
}
.trim-label {
font-size: 0.6rem;
color: var(--text-dim);
min-width: 18px;
text-align: center;
}
.trim-btn {
width: 22px;
height: 22px;
padding: 0;
font-size: 0.6rem;
border-radius: 3px;
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-bright);
font-family: monospace;
}
.trim-btn:active {
background: var(--accent);
color: #000;
}
.trim-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
/* Extra trims */
.extra-trims {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
/* Trace */
details {
margin-top: 0.75rem;
}
summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.85rem;
padding: 0.25rem 0;
}
.trace-summary {
display: flex;
justify-content: space-between;
align-items: center;
}
.fps {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: normal;
}
.trace {
max-height: 300px;
overflow-y: auto;
background: var(--trace-bg);
color: var(--trace-text);
padding: 0.5rem;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-all;
border-radius: 4px;
border: 1px solid var(--border-body);
}
</style>