/**
* @fileoverview NNPlaygroundController — Orchestrierung des NN-TTT-Playgrounds.
*
* Phase 2: DRY Game-Engine, SVG-verschmolzene Boards, Trainer-Tab.
* - Tab-Switching in der Sidebar (5 Tabs)
* - Topologie-Preset-Auswahl
* - Board-Interaktion via TTTRegularBoard (DRY Game-Engine)
* - Lokaler Forward-Pass + Training
* - Gewichts-Filter-Slider
* - Trainer-Tab: Trainings-Beispiele visualisieren
* - Metriken: Loss/Accuracy-History für Charts
* - Receptive-Field-Hover via NNMLPVisualizer
*
* Convention: Adapter-Layer. Verbindet UI-Elemente mit Core (NeuralNetwork)
* und Rendering (NNMLPVisualizer). Keine DOM-Erzeugung (außer
* dynamische Updates). Kein direktes console.log.
*
* @author Alexander Wolf
* @see ToDos/NN_PLAYGROUND_PLAN_v2.md
*/
/* global NeuralNetwork, NNMLPVisualizer, NNTrainingCharts,
TTTRegularBoard, CELL_EMPTY, PLAYER1, PLAYER2, NONE, DRAW,
generateTTTTrainingData, shuffleArray */
/**
* Topology Presets for didactic purposes.
* @type {Object<string, Object>}
*/
const NN_PRESETS = {
trivial: {
label: '🚫 Sinnlos (kein Hidden Layer)',
topology: [9, 9],
activations: ['softmax'],
description: 'Direkte lineare Abbildung ohne Hidden Layer – kann TTT nicht lernen.',
learningRate: 0.05,
epochs: 5000
},
minimal: {
label: '🤏 Minimal (9 Hidden)',
topology: [9, 9, 9],
activations: ['relu', 'softmax'],
description: '9 Hidden-Neuronen = Feldanzahl. Minimale Kapazität – reicht das?',
learningRate: 0.01,
epochs: 10000
},
sweet_spot: {
label: '✅ Der Sweetspot (36 Hidden)',
topology: [9, 36, 9],
activations: ['relu', 'softmax'],
description: '36 Neuronen = 4× Feldanzahl. Genug Kapazität für Muster-Erkennung.',
learningRate: 0.01,
epochs: 20000
},
deep: {
label: '🏗️ Deep (3×9 Hidden)',
topology: [9, 9, 9, 9],
activations: ['relu', 'relu', 'softmax'],
description: '3 Hidden Layers à 9 Neuronen – testet Tiefe vs. Breite.',
learningRate: 0.01,
epochs: 15000
},
overkill: {
label: '💥 Overkill (2×32 Hidden)',
topology: [9, 32, 32, 9],
activations: ['relu', 'relu', 'softmax'],
description: '2 breite Hidden Layers – deutlich mehr Kapazität als nötig.',
learningRate: 0.005,
epochs: 20000
}
};
class NNPlaygroundController {
/**
* @param {Object} config — ID-Mapping aller DOM-Elemente
*/
constructor(config) {
this.config = config;
/** @type {string} Current preset key */
this.currentPreset = 'sweet_spot';
/** @type {NeuralNetwork|null} */
this.network = null;
/** @type {NNMLPVisualizer|null} */
this.mlpViz = null;
/** @type {NNTrainingCharts|null} */
this.charts = null;
/** @type {TTTRegularBoard} DRY Game Engine */
this.board = new TTTRegularBoard();
/** @type {boolean} */
this.isTraining = false;
/** @type {number} */
this.epoch = 0;
/** @type {number[]} Loss history for chart */
this.lossHistory = [];
/** @type {number[]} Accuracy history for chart */
this.accuracyHistory = [];
/** @type {number} Epochs per animation frame during training */
this.trainingSpeed = 1;
/** @type {number} Frame counter for throttled updates */
this._frameCount = 0;
/** @type {number|null} Animation frame ID */
this._trainingRAF = null;
/** @type {Object[]|null} Cached training data */
this._trainingData = null;
this._init();
}
/**
* Initializes the controller: binds UI, creates network & visualizers.
* @private
*/
_init() {
this._bindTabs();
this._bindToolbar();
this._bindPresetSelector();
this._bindWeightFilter();
// Create NN with default preset
this._createNetwork(this.currentPreset);
// Create visualizer (no more heatmap!)
this._createVisualizer();
// Initial forward pass to populate output
this._runForwardPass();
// Populate trainer tab with initial sample
this._populateTrainerOnInit();
}
// ========================================================================
// TAB SYSTEM
// ========================================================================
/**
* Binds sidebar tab switching (5 tabs).
* @private
*/
_bindTabs() {
const tabBtns = document.querySelectorAll('.nn-sidebar__tab-btn');
const tabPanels = document.querySelectorAll('.nn-sidebar__tab-panel');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.dataset.tab;
tabBtns.forEach(b => b.classList.remove('active'));
tabPanels.forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
});
});
}
// ========================================================================
// TOOLBAR
// ========================================================================
/**
* Binds toolbar buttons (Step, Train, Pause, Reset, Speed).
* @private
*/
_bindToolbar() {
const stepBtn = document.getElementById(this.config.stepBtn);
const trainBtn = document.getElementById(this.config.trainBtn);
const pauseBtn = document.getElementById(this.config.pauseBtn);
const resetBtn = document.getElementById(this.config.resetBtn);
const speedSlider = document.getElementById(this.config.speedSlider);
if (stepBtn) {
stepBtn.addEventListener('click', () => this._trainStep());
}
if (trainBtn) {
trainBtn.addEventListener('click', () => this._startTraining());
}
if (pauseBtn) {
pauseBtn.addEventListener('click', () => this._pauseTraining());
}
if (resetBtn) {
resetBtn.addEventListener('click', () => this._reset());
}
if (speedSlider) {
speedSlider.addEventListener('input', (e) => {
this.trainingSpeed = parseInt(e.target.value);
});
}
}
// ========================================================================
// WEIGHT FILTER
// ========================================================================
/**
* Binds the weight (connection) filter slider.
* @private
*/
_bindWeightFilter() {
const slider = document.getElementById(this.config.weightFilter);
const valueDisplay = document.getElementById(this.config.weightFilterValue);
if (!slider) return;
slider.addEventListener('input', (e) => {
const pct = parseInt(e.target.value);
if (valueDisplay) valueDisplay.textContent = pct + '%';
if (this.mlpViz) {
this.mlpViz.applyWeightFilter(pct);
}
});
}
// ========================================================================
// PRESET SELECTOR
// ========================================================================
/**
* Binds the topology preset dropdown in the Architecture tab.
* @private
*/
_bindPresetSelector() {
const selector = document.getElementById(this.config.presetSelector);
if (!selector) return;
// Populate dropdown
selector.innerHTML = '';
for (const [key, preset] of Object.entries(NN_PRESETS)) {
const option = document.createElement('option');
option.value = key;
option.textContent = preset.label;
if (key === this.currentPreset) option.selected = true;
selector.appendChild(option);
}
// Description
const descEl = document.getElementById(this.config.presetDescription);
if (descEl) {
descEl.textContent = NN_PRESETS[this.currentPreset].description;
}
selector.addEventListener('change', (e) => {
const newPreset = e.target.value;
this.currentPreset = newPreset;
this._createNetwork(newPreset);
this._recreateVisualizer();
this._resetBoard();
this._runForwardPass();
if (descEl) {
descEl.textContent = NN_PRESETS[newPreset].description;
}
this._updateParamCount();
});
this._updateParamCount();
}
/**
* Updates the parameter count display.
* @private
*/
_updateParamCount() {
const el = document.getElementById(this.config.paramCountDisplay);
if (el && this.network) {
el.textContent = this.network.getTotalParameterCount().toLocaleString();
}
}
// ========================================================================
// NEURAL NETWORK
// ========================================================================
/**
* Creates a new NeuralNetwork from a preset.
* @param {string} presetKey
* @private
*/
_createNetwork(presetKey) {
const preset = NN_PRESETS[presetKey];
if (!preset) return;
this.network = new NeuralNetwork({
layers: preset.topology,
activations: preset.activations,
lossFunction: 'crossEntropy',
learningRate: preset.learningRate
});
this.epoch = 0;
this.isTraining = false;
this.lossHistory = [];
this.accuracyHistory = [];
this._updateEpochDisplay();
this._updateLossDisplay(null);
}
// ========================================================================
// VISUALIZER
// ========================================================================
/**
* Creates the MLP visualizer (SVG-merged, no separate heatmap).
* @private
*/
_createVisualizer() {
const preset = NN_PRESETS[this.currentPreset];
// MLP Visualizer with integrated input/output boards
this.mlpViz = new NNMLPVisualizer({
container: this.config.networkContainer,
callbacks: {
onInputCellClick: (index) => this._handleCellClick(index),
onHiddenNeuronHover: (neuronIdx, weights) => {
this.mlpViz.highlightInputWeights(weights);
},
onHiddenNeuronLeave: () => {
this.mlpViz.resetInputHighlight(this.board.grid);
}
}
});
this.mlpViz.init(preset.topology);
// Training charts (if container exists)
const chartsContainer = document.getElementById(this.config.chartsContainer);
if (chartsContainer && typeof NNTrainingCharts !== 'undefined') {
this.charts = new NNTrainingCharts({ container: chartsContainer });
}
}
/**
* Destroys and recreates the visualizer (after topology change).
* @private
*/
_recreateVisualizer() {
if (this.mlpViz) this.mlpViz.destroy();
this._createVisualizer();
}
// ========================================================================
// BOARD INTERACTION (DRY via TTTRegularBoard)
// ========================================================================
/**
* Handles a cell click on the input board.
* Uses TTTRegularBoard.makeMove() for proper game flow.
* @param {number} index — Field index (0–8)
* @private
*/
_handleCellClick(index) {
// Don't allow moves during training
if (this.isTraining) return;
// If game is over, reset and then make the move
if (this.board.winner !== NONE) {
this._resetBoard();
}
// Try to make the move via game engine
const success = this.board.makeMove(index);
if (!success) return;
// Sync board to visualizer
this._syncBoardDisplay();
this._runForwardPass();
this._updateGameStatus();
}
/**
* Resets the board to empty state.
* @private
*/
_resetBoard() {
this.board = new TTTRegularBoard();
this._syncBoardDisplay();
this._updateGameStatus();
}
/**
* Syncs the current board state to the visualizer.
* @private
*/
_syncBoardDisplay() {
if (this.mlpViz) {
this.mlpViz.updateInputBoard(this.board.grid);
}
}
/**
* Updates the game status overlay (win/draw/turn info).
* Game-over states show a clickable "Neues Spiel" indicator.
* @private
*/
_updateGameStatus() {
const el = document.getElementById(this.config.gameStatus);
if (!el) return;
// Remove old click listener
el.onclick = null;
if (this.board.winner === PLAYER1) {
el.innerHTML = '✕ gewinnt! <span class="nn-game-status__restart">↺ Neues Spiel</span>';
el.className = 'nn-game-status nn-game-status--visible nn-game-status--win';
el.style.pointerEvents = 'auto';
el.style.cursor = 'pointer';
el.onclick = () => { this._resetBoard(); this._runForwardPass(); };
} else if (this.board.winner === PLAYER2) {
el.innerHTML = '○ gewinnt! <span class="nn-game-status__restart">↺ Neues Spiel</span>';
el.className = 'nn-game-status nn-game-status--visible nn-game-status--win';
el.style.pointerEvents = 'auto';
el.style.cursor = 'pointer';
el.onclick = () => { this._resetBoard(); this._runForwardPass(); };
} else if (this.board.winner === DRAW) {
el.innerHTML = 'Unentschieden <span class="nn-game-status__restart">↺ Neues Spiel</span>';
el.className = 'nn-game-status nn-game-status--visible nn-game-status--draw';
el.style.pointerEvents = 'auto';
el.style.cursor = 'pointer';
el.onclick = () => { this._resetBoard(); this._runForwardPass(); };
} else {
el.textContent = '';
el.className = 'nn-game-status';
el.style.pointerEvents = 'none';
el.style.cursor = '';
}
}
// ========================================================================
// BOARD ENCODING (DRY)
// ========================================================================
/**
* Encodes the current board state for the neural network.
* Current player's pieces -> +1, opponent -> -1, empty -> 0.
* @returns {Float64Array} — 9 values in [-1, 0, +1]
* @private
*/
_encodeBoardForNetwork() {
const currentPlayer = this.board.currentPlayer;
return new Float64Array(this.board.grid.map(cell => {
if (cell === CELL_EMPTY) return 0;
return cell === currentPlayer ? 1 : -1;
}));
}
/**
* Creates a legal move mask from the current board.
* @returns {number[]} — 1 for empty/legal, 0 for occupied
* @private
*/
_getLegalMask() {
return this.board.grid.map(cell => cell === CELL_EMPTY ? 1 : 0);
}
// ========================================================================
// FORWARD PASS
// ========================================================================
/**
* Runs a forward pass through the network with the current board state
* and updates the output heatmap in the SVG.
* @private
*/
_runForwardPass() {
if (!this.network) return;
const input = this._encodeBoardForNetwork();
const legalMask = this._getLegalMask();
const hasLegalMoves = legalMask.some(v => v === 1);
let output;
if (hasLegalMoves) {
output = this.network.forwardMasked(input, legalMask);
} else {
output = this.network.forward(input);
}
// Update output heatmap in SVG
if (this.mlpViz) {
this.mlpViz.updateOutputHeatmap(output, this.board.grid);
const snapshot = this.network.getSnapshot();
this.mlpViz.updateFromSnapshot(snapshot);
}
}
// ========================================================================
// TRAINING
// ========================================================================
/**
* Generates training data on-demand (cached).
* @returns {TTTSample[]}
* @private
*/
_getTrainingData() {
if (!this._trainingData) {
this._trainingData = generateTTTTrainingData({ useSymmetries: false, bothPlayers: true });
shuffleArray(this._trainingData);
}
return this._trainingData;
}
/**
* Executes a single training step (1 epoch over all data).
* @private
*/
_trainStep() {
if (!this.network) return;
const data = this._getTrainingData();
const result = this.network.trainEpoch(data);
this.epoch++;
this._recordMetrics(result.loss, data);
this._updateEpochDisplay();
this._updateLossDisplay(result.loss);
this._updateTrainerTab(data);
this._runForwardPass();
}
/**
* Starts continuous training via requestAnimationFrame.
* Live TF-Playground-like feel: update visualization EVERY frame.
* @private
*/
_startTraining() {
if (this.isTraining) return;
this.isTraining = true;
this._frameCount = 0;
this._updateTrainingButtons(true);
const data = this._getTrainingData();
const maxEpochs = NN_PRESETS[this.currentPreset].epochs;
const trainLoop = () => {
if (!this.isTraining) return;
// Run trainingSpeed epochs per frame (1–10)
const batchSize = Math.max(1, Math.min(this.trainingSpeed, 10));
let lastResult = null;
for (let i = 0; i < batchSize && this.epoch < maxEpochs; i++) {
// trainEpoch shuffles internally — no external shuffle needed
lastResult = this.network.trainEpoch(data);
this.epoch++;
}
this._frameCount++;
if (lastResult) {
this._recordMetrics(lastResult.loss, data);
this._updateEpochDisplay();
this._updateLossDisplay(lastResult.loss);
}
// Update SVG weights every 5 frames (snapshot is expensive)
if (this._frameCount % 5 === 0 && this.mlpViz) {
const snapshot = this.network.getSnapshot();
this.mlpViz.updateFromSnapshot(snapshot);
}
// Update output heatmap every frame
if (this.mlpViz) {
const input = this._encodeBoardForNetwork();
const legalMask = this._getLegalMask();
const hasLegalMoves = legalMask.some(v => v === 1);
const output = hasLegalMoves
? this.network.forwardMasked(input, legalMask)
: this.network.forward(input);
this.mlpViz.updateOutputHeatmap(output, this.board.grid);
}
// Charts every 10 frames
if (this._frameCount % 10 === 0) {
this._updateCharts();
}
// Trainer tab every 30 frames (~0.5s)
if (this._frameCount % 30 === 0) {
this._updateTrainerTab(data);
}
if (this.epoch >= maxEpochs) {
this._pauseTraining();
return;
}
this._trainingRAF = requestAnimationFrame(trainLoop);
};
this._trainingRAF = requestAnimationFrame(trainLoop);
}
/**
* Pauses training.
* @private
*/
_pauseTraining() {
this.isTraining = false;
if (this._trainingRAF) {
cancelAnimationFrame(this._trainingRAF);
this._trainingRAF = null;
}
this._updateTrainingButtons(false);
this._runForwardPass();
this._updateCharts();
}
/**
* Full reset: new network, clear board, reset epoch.
* @private
*/
_reset() {
this._pauseTraining();
this._trainingData = null;
this._createNetwork(this.currentPreset);
this._resetBoard();
this._recreateVisualizer();
this._runForwardPass();
}
// ========================================================================
// METRICS / CHARTS
// ========================================================================
/**
* Records loss and accuracy for chart display.
* @param {number} loss
* @param {Object[]} data — Training data for accuracy calculation
* @private
*/
_recordMetrics(loss, data) {
this.lossHistory.push(loss);
// Sample accuracy every 50 epochs to avoid overhead
if (this.epoch % 50 === 0 && data.length > 0) {
let correct = 0;
const sampleSize = Math.min(100, data.length);
for (let i = 0; i < sampleSize; i++) {
const sample = data[i];
const output = this.network.forward(sample.input);
const predicted = Array.from(output).indexOf(Math.max(...output));
const expected = Array.from(sample.target).indexOf(Math.max(...sample.target));
if (predicted === expected) correct++;
}
const acc = correct / sampleSize;
this.accuracyHistory.push(acc);
this._updateAccuracyDisplay(acc);
}
// Update progress bar
this._updateProgressBar();
}
/**
* Updates the training charts in the Metriken tab.
* @private
*/
_updateCharts() {
if (this.charts) {
this.charts.update(this.lossHistory, this.accuracyHistory);
}
}
// ========================================================================
// TRAINER TAB
// ========================================================================
/**
* Updates the Trainer tab with a random training example.
* Shows: input board, KI prediction, teacher answer, loss.
* @param {Object[]} data — Training data
* @private
*/
_updateTrainerTab(data) {
if (!data || data.length === 0) return;
// Pick a random sample
const idx = Math.floor(Math.random() * data.length);
const sample = data[idx];
// Index display
const idxEl = document.getElementById('trainerSampleIdx');
if (idxEl) idxEl.textContent = idx + 1;
// Render input board (3×3 mini-grid)
this._renderTrainerGrid('trainerInputGrid', sample.input, 'input');
// Get KI prediction
const output = this.network.forward(sample.input);
this._renderTrainerGrid('trainerOutputGrid', output, 'output');
// Teacher's correct answer
const teacherIdx = Array.from(sample.target).indexOf(Math.max(...sample.target));
const kiIdx = Array.from(output).indexOf(Math.max(...output));
const teacherEl = document.getElementById('trainerTeacherMove');
if (teacherEl) {
const row = Math.floor(teacherIdx / 3) + 1;
const col = (teacherIdx % 3) + 1;
teacherEl.textContent = '✅ Lehrer sagt: Feld ' + row + '×' + col;
}
const kiEl = document.getElementById('trainerKIMove');
if (kiEl) {
const row = Math.floor(kiIdx / 3) + 1;
const col = (kiIdx % 3) + 1;
const correct = kiIdx === teacherIdx;
kiEl.textContent = (correct ? '✅' : '❌') + ' KI wählte: Feld ' + row + '×' + col;
kiEl.style.color = correct ? '#2ecc71' : '#e74c3c';
}
// Loss info
const lossEl = document.getElementById('trainerLossDelta');
if (lossEl && this.lossHistory.length > 0) {
lossEl.textContent = 'Loss: ' + this.lossHistory[this.lossHistory.length - 1].toFixed(4);
}
}
/**
* Renders a 3×3 mini-grid in the Trainer tab.
* @param {string} containerId — DOM element ID
* @param {Float64Array|number[]} values — 9 values
* @param {'input'|'output'} mode — Rendering mode
* @private
*/
_renderTrainerGrid(containerId, values, mode) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
for (let i = 0; i < 9; i++) {
const cell = document.createElement('div');
cell.className = 'nn-trainer__cell';
const v = values[i];
if (mode === 'input') {
// Input encoding: 1 = current player, -1 = opponent, 0 = empty
if (v > 0.5) {
cell.textContent = '✕';
cell.classList.add('nn-trainer__cell--x');
} else if (v < -0.5) {
cell.textContent = '○';
cell.classList.add('nn-trainer__cell--o');
}
} else {
// Output: softmax probabilities
const pct = (v * 100).toFixed(0);
cell.textContent = pct + '%';
const intensity = Math.min(v * 3, 1); // Amplify for visibility
cell.style.backgroundColor = 'rgba(46, 204, 113, ' + (0.05 + intensity * 0.6) + ')';
cell.style.color = intensity > 0.3 ? '#fff' : 'rgba(255,255,255,0.5)';
}
container.appendChild(cell);
}
}
// ========================================================================
// UI UPDATES
// ========================================================================
/**
* Updates the epoch counter display.
* @private
*/
_updateEpochDisplay() {
const el = document.getElementById(this.config.epochDisplay);
if (el) el.textContent = this.epoch.toLocaleString();
}
/**
* Updates the loss display.
* @param {number|null} loss
* @private
*/
_updateLossDisplay(loss) {
const el = document.getElementById(this.config.lossDisplay);
if (el) {
el.textContent = loss !== null ? loss.toFixed(4) : '–';
}
}
/**
* Updates the accuracy display.
* @param {number} accuracy — 0–1
* @private
*/
_updateAccuracyDisplay(accuracy) {
const el = document.getElementById('accuracyDisplay');
if (el) {
el.textContent = (accuracy * 100).toFixed(0) + '%';
}
}
/**
* Updates the training progress bar.
* @private
*/
_updateProgressBar() {
const bar = document.getElementById('trainingProgress');
if (!bar) return;
const maxEpochs = NN_PRESETS[this.currentPreset].epochs;
const pct = Math.min(100, (this.epoch / maxEpochs) * 100);
bar.style.width = pct.toFixed(1) + '%';
}
/**
* Updates training button states.
* @param {boolean} isTraining
* @private
*/
_updateTrainingButtons(isTraining) {
const trainBtn = document.getElementById(this.config.trainBtn);
const pauseBtn = document.getElementById(this.config.pauseBtn);
const stepBtn = document.getElementById(this.config.stepBtn);
if (trainBtn) {
trainBtn.disabled = isTraining;
trainBtn.classList.toggle('btn--active', isTraining);
}
if (pauseBtn) {
pauseBtn.disabled = !isTraining;
}
if (stepBtn) {
stepBtn.disabled = isTraining;
}
}
/**
* Populates the Trainer tab with an initial sample (before training).
* Gives users a preview of what the teacher data looks like.
* @private
*/
_populateTrainerOnInit() {
const data = this._getTrainingData();
if (data && data.length > 0) {
this._updateTrainerTab(data);
}
}
/**
* Destroys the controller and cleans up.
*/
destroy() {
this._pauseTraining();
if (this.mlpViz) this.mlpViz.destroy();
if (this.charts) this.charts.destroy();
}
}