2026-03-22 10:01:50 +01:00
|
|
|
// Control panel: unified action buttons, status display
|
2026-02-11 13:17:17 +01:00
|
|
|
|
|
|
|
|
export class Controls {
|
|
|
|
|
constructor(container, callbacks) {
|
|
|
|
|
this.callbacks = callbacks;
|
|
|
|
|
this.el = container;
|
|
|
|
|
this.build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
build() {
|
|
|
|
|
this.el.innerHTML = `
|
2026-03-22 10:01:50 +01:00
|
|
|
<div class="controls-actions" id="controls-actions">
|
2026-02-11 13:17:17 +01:00
|
|
|
<button class="btn btn-good" id="btn-thumbsup">
|
|
|
|
|
<span style="font-size:1.4em">+</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-bad" id="btn-thumbsdown">
|
|
|
|
|
<span style="font-size:1.4em">−</span>
|
|
|
|
|
</button>
|
2026-03-22 10:01:50 +01:00
|
|
|
<button class="btn btn-primary" id="btn-add">Add Example</button>
|
|
|
|
|
<button class="btn" id="btn-train">Train</button>
|
|
|
|
|
<button class="btn" id="btn-randomize">Randomize</button>
|
|
|
|
|
<button class="btn" id="btn-clear-examples">Clear Examples</button>
|
|
|
|
|
<button class="btn btn-danger" id="btn-clear">Clear All</button>
|
2026-02-11 13:17:17 +01:00
|
|
|
</div>
|
|
|
|
|
<div class="controls-status">
|
|
|
|
|
<span id="status-examples">Examples: 0</span>
|
|
|
|
|
<span id="status-loss"></span>
|
2026-03-22 10:01:50 +01:00
|
|
|
<span id="status-noise">Noise: 0.05</span>
|
2026-02-11 13:17:17 +01:00
|
|
|
</div>
|
2026-02-11 16:56:24 +01:00
|
|
|
<div class="metrics-grid">
|
|
|
|
|
<span class="metric-label">Joystick</span>
|
|
|
|
|
<span class="metric-value" id="metric-joystick">0.50, 0.50</span>
|
|
|
|
|
<span class="metric-label">Output Mean</span>
|
|
|
|
|
<span class="metric-value" id="metric-mean">0.000</span>
|
|
|
|
|
<span class="metric-label">Output Spread</span>
|
|
|
|
|
<span class="metric-value" id="metric-spread">0.000</span>
|
|
|
|
|
<span class="metric-label">Best Loss</span>
|
|
|
|
|
<span class="metric-value" id="metric-best-loss">-</span>
|
|
|
|
|
<span class="metric-label">Train Iters</span>
|
|
|
|
|
<span class="metric-value" id="metric-train-iters">0</span>
|
|
|
|
|
<span class="metric-label">Gamepad</span>
|
|
|
|
|
<span class="metric-value" id="metric-gamepad">Disconnected</span>
|
|
|
|
|
<span class="metric-label">Follow</span>
|
|
|
|
|
<span class="metric-value" id="metric-follow">Off</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="loss-plot-wrap">
|
|
|
|
|
<canvas id="loss-plot" width="420" height="86"></canvas>
|
|
|
|
|
</div>
|
2026-02-11 13:17:17 +01:00
|
|
|
`;
|
|
|
|
|
|
2026-03-22 10:01:50 +01:00
|
|
|
// Action buttons
|
|
|
|
|
this.el.querySelector('#btn-thumbsup').addEventListener('click', () => this.callbacks.onThumbsUp?.());
|
|
|
|
|
this.el.querySelector('#btn-thumbsdown').addEventListener('click', () => this.callbacks.onThumbsDown?.());
|
2026-02-11 13:17:17 +01:00
|
|
|
this.el.querySelector('#btn-add').addEventListener('click', () => this.callbacks.onAddExample?.());
|
|
|
|
|
this.el.querySelector('#btn-train').addEventListener('click', () => this.callbacks.onTrain?.());
|
|
|
|
|
this.el.querySelector('#btn-randomize').addEventListener('click', () => this.callbacks.onRandomize?.());
|
2026-03-22 10:01:50 +01:00
|
|
|
this.el.querySelector('#btn-clear-examples').addEventListener('click', () => this.callbacks.onClearExamples?.());
|
2026-02-11 13:17:17 +01:00
|
|
|
this.el.querySelector('#btn-clear').addEventListener('click', () => this.callbacks.onClear?.());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateStatus(exampleCount, loss, noiseLevel) {
|
|
|
|
|
this.el.querySelector('#status-examples').textContent = `Examples: ${exampleCount}`;
|
|
|
|
|
if (loss !== null && loss !== undefined) {
|
|
|
|
|
this.el.querySelector('#status-loss').textContent = `Loss: ${loss.toFixed(5)}`;
|
2026-02-11 16:56:24 +01:00
|
|
|
} else {
|
|
|
|
|
this.el.querySelector('#status-loss').textContent = 'Loss: -';
|
2026-02-11 13:17:17 +01:00
|
|
|
}
|
|
|
|
|
if (noiseLevel !== undefined) {
|
|
|
|
|
this.el.querySelector('#status-noise').textContent = `Noise: ${noiseLevel.toFixed(3)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 16:56:24 +01:00
|
|
|
|
|
|
|
|
updateMetrics(metrics = {}) {
|
|
|
|
|
const set = (id, value) => {
|
|
|
|
|
const el = this.el.querySelector(id);
|
|
|
|
|
if (el) el.textContent = value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (metrics.joystickX !== undefined && metrics.joystickY !== undefined) {
|
|
|
|
|
set('#metric-joystick', `${metrics.joystickX.toFixed(2)}, ${metrics.joystickY.toFixed(2)}`);
|
|
|
|
|
}
|
|
|
|
|
if (metrics.outputMean !== undefined) set('#metric-mean', metrics.outputMean.toFixed(3));
|
|
|
|
|
if (metrics.outputSpread !== undefined) set('#metric-spread', metrics.outputSpread.toFixed(3));
|
|
|
|
|
if (metrics.bestLoss !== undefined && metrics.bestLoss !== null) {
|
|
|
|
|
set('#metric-best-loss', metrics.bestLoss.toFixed(5));
|
|
|
|
|
}
|
|
|
|
|
if (metrics.totalTrainingIterations !== undefined) {
|
|
|
|
|
set('#metric-train-iters', String(metrics.totalTrainingIterations));
|
|
|
|
|
}
|
|
|
|
|
if (metrics.gamepadConnected !== undefined) {
|
|
|
|
|
set('#metric-gamepad', metrics.gamepadConnected ? 'Connected' : 'Disconnected');
|
|
|
|
|
}
|
|
|
|
|
if (metrics.followMode !== undefined) {
|
|
|
|
|
set('#metric-follow', metrics.followMode ? 'On' : 'Off');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateLossPlot(lossHistory = []) {
|
|
|
|
|
const canvas = this.el.querySelector('#loss-plot');
|
|
|
|
|
if (!canvas) return;
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
const width = canvas.width;
|
|
|
|
|
const height = canvas.height;
|
|
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
|
ctx.fillStyle = '#121212';
|
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
|
ctx.strokeStyle = '#2b2b2b';
|
|
|
|
|
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
|
|
|
|
|
|
|
|
|
if (!lossHistory.length) {
|
|
|
|
|
ctx.fillStyle = '#666';
|
|
|
|
|
ctx.font = '11px monospace';
|
|
|
|
|
ctx.fillText('Loss history appears after training.', 10, 18);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const points = lossHistory.slice(-420);
|
|
|
|
|
let min = Infinity;
|
|
|
|
|
let max = -Infinity;
|
|
|
|
|
for (const v of points) {
|
|
|
|
|
if (v < min) min = v;
|
|
|
|
|
if (v > max) max = v;
|
|
|
|
|
}
|
|
|
|
|
const range = Math.max(max - min, 1e-9);
|
|
|
|
|
const leftPad = 6;
|
|
|
|
|
const rightPad = 6;
|
|
|
|
|
const topPad = 6;
|
|
|
|
|
const bottomPad = 14;
|
|
|
|
|
const plotW = width - leftPad - rightPad;
|
|
|
|
|
const plotH = height - topPad - bottomPad;
|
|
|
|
|
|
2026-03-22 00:36:00 +01:00
|
|
|
ctx.strokeStyle = '#352418';
|
2026-02-11 16:56:24 +01:00
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.moveTo(leftPad, topPad + plotH / 2);
|
|
|
|
|
ctx.lineTo(width - rightPad, topPad + plotH / 2);
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
2026-03-22 00:36:00 +01:00
|
|
|
ctx.strokeStyle = '#ff6a00';
|
2026-02-11 16:56:24 +01:00
|
|
|
ctx.lineWidth = 1.6;
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
for (let i = 0; i < points.length; i++) {
|
|
|
|
|
const x = leftPad + (i / Math.max(points.length - 1, 1)) * plotW;
|
|
|
|
|
const yNorm = (points[i] - min) / range;
|
|
|
|
|
const y = topPad + (1 - yNorm) * plotH;
|
|
|
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
|
|
|
else ctx.lineTo(x, y);
|
|
|
|
|
}
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = '#8a8a8a';
|
|
|
|
|
ctx.font = '10px monospace';
|
|
|
|
|
ctx.fillText(`min ${min.toFixed(5)}`, leftPad, height - 3);
|
|
|
|
|
ctx.fillText(`max ${max.toFixed(5)}`, width - rightPad - 78, height - 3);
|
|
|
|
|
}
|
2026-02-11 13:17:17 +01:00
|
|
|
}
|