diff --git a/TEST-README.md b/TEST-README.md
new file mode 100644
index 0000000..05e9ade
--- /dev/null
+++ b/TEST-README.md
@@ -0,0 +1,196 @@
+# i18n Test Suite Documentation
+
+This document describes the comprehensive test suite for the internationalization (i18n) functionality.
+
+## Quick Start
+
+**Run all tests with a single command:**
+```bash
+node test.js
+```
+
+This will run the comprehensive i18n tests, providing a unified summary.
+
+## Test Files
+
+### 0. `test.js` ⭐ **RECOMMENDED**
+A unified test runner that executes all Node.js-based tests and provides a comprehensive summary.
+
+**Usage:**
+```bash
+# Run all tests
+node test.js
+
+# Show help
+node test.js --help
+```
+
+**Features:**
+- Runs the comprehensive i18n test suite
+- Provides colored output and clear summaries
+- Shows overall pass/fail status
+- Reminds about browser tests
+
+### 1. `test-i18n.js`
+A Node.js-based test suite that performs static analysis and validation of the i18n implementation. This test can be run from the command line and doesn't require a browser.
+
+**Usage:**
+```bash
+node test-i18n.js
+```
+
+**What it tests:**
+- Translation file structure and completeness
+- i18n.js module code analysis
+- HTML integration with data-i18n attributes
+- JavaScript integration with translation functions
+- Translation key coverage
+- Code quality and best practices
+- Edge cases and error handling
+- Accessibility and internationalization
+
+### 2. `test-i18n-browser.html`
+A browser-based test suite that tests the runtime behavior of the i18n system. This test requires opening the HTML file in a web browser.
+
+**Usage:**
+1. Open `test-i18n-browser.html` in a web browser
+2. Tests will run automatically on page load
+3. Use the buttons to run specific test suites
+
+**What it tests:**
+- Basic i18n module functionality
+- Language switching behavior
+- DOM element translation
+- Error handling
+- LocalStorage persistence
+- Translation completeness
+
+### 3. `test-i18n.js` (also run via `test.js`)
+The comprehensive test file that verifies the i18n implementation. This test performs static analysis and checks for hardcoded text, translation coverage, and i18n integration.
+
+**Usage:**
+```bash
+# Run directly
+node test-i18n.js
+
+# Or run via the unified test runner
+node test.js
+```
+
+## Test Coverage
+
+The comprehensive test suite includes **65 tests** across 8 test suites:
+
+### Test Suite 1: Translation File Structure and Completeness (7 tests)
+- Validates JSON structure
+- Ensures both languages have matching keys
+- Verifies all values are strings
+- Checks parameter placeholder consistency
+
+### Test Suite 2: i18n.js Module Code Analysis (18 tests)
+- Verifies all exported functions exist
+- Checks for proper error handling
+- Validates localStorage usage
+- Ensures event dispatching
+
+### Test Suite 3: HTML Integration (14 tests)
+- Verifies data-i18n attributes are used
+- Checks language toggle button
+- Validates all translation keys exist
+- Ensures proper HTML structure
+
+### Test Suite 4: JavaScript Integration (9 tests)
+- Verifies t() function usage
+- Checks translation key references
+- Validates parameter substitution
+- Ensures language change listeners
+
+### Test Suite 5: Translation Key Coverage (3 tests)
+- Verifies error keys are used
+- Checks UI key references
+- Validates parameter placeholders
+
+### Test Suite 6: Code Quality and Best Practices (5 tests)
+- Checks for hardcoded German text
+- Validates translation file formatting
+- Ensures no empty strings
+- Verifies naming conventions
+
+### Test Suite 7: Edge Cases and Error Handling (5 tests)
+- Tests missing key handling
+- Validates fallback behavior
+- Checks special character handling
+- Tests parameter substitution edge cases
+
+### Test Suite 8: Accessibility and Internationalization (4 tests)
+- Verifies HTML lang attribute updates
+- Checks translatable aria-labels
+- Validates accessibility attributes
+- Ensures proper ARIA roles
+
+## Running All Tests
+
+**Recommended: Use the unified test runner:**
+```bash
+node test.js
+```
+
+**Or run tests individually:**
+```bash
+# Run comprehensive Node.js tests (via unified runner)
+node test.js
+
+# Run comprehensive tests directly
+node test-i18n.js
+
+# Open browser tests (requires a web server or file:// protocol)
+open test-i18n-browser.html
+```
+
+## Test Results
+
+All tests should pass. The comprehensive test suite reports:
+- **65 tests passed, 0 failed** (as of last run)
+
+## Adding New Tests
+
+When adding new translation keys or i18n features:
+
+1. **Add translation keys to both `en.json` and `de.json`**
+2. **Update HTML with `data-i18n` attributes if needed**
+3. **Use `t()` function in JavaScript for dynamic translations**
+4. **Run the test suite to verify everything works**
+
+## Troubleshooting
+
+### Test fails: "Translation key not found"
+- Ensure the key exists in both `en.json` and `de.json`
+- Check that the key path uses dot notation (e.g., `"drawing.title"`)
+
+### Test fails: "Hardcoded German text found"
+- Remove any hardcoded German strings from HTML or JavaScript
+- Use translation keys instead
+
+### Browser tests don't run
+- Ensure `assets/i18n.js` is accessible
+- Check browser console for errors
+- Verify translation JSON files are in `assets/i18n/`
+
+## Continuous Integration
+
+These tests can be integrated into CI/CD pipelines:
+
+```yaml
+# Example GitHub Actions workflow
+- name: Run i18n tests
+ run: |
+ node test.js
+```
+
+## Notes
+
+- The comprehensive test suite performs static analysis and doesn't require a browser
+- The browser test suite tests actual runtime behavior
+- Both test suites complement each other for complete coverage
+- Tests are designed to catch regressions when modifying i18n code
+
diff --git a/assets/i18n.js b/assets/i18n.js
new file mode 100644
index 0000000..2dfcfbe
--- /dev/null
+++ b/assets/i18n.js
@@ -0,0 +1,174 @@
+/**
+ * Internationalization (i18n) module
+ * Handles language switching and translation
+ */
+
+let currentLanguage = 'en';
+let translations = {};
+
+/**
+ * Initialize i18n system
+ * @param {string} lang - Language code ('en' or 'de')
+ */
+async function initI18n(lang = 'en') {
+ currentLanguage = lang || 'en';
+
+ try {
+ const response = await fetch(`./assets/i18n/${currentLanguage}.json`);
+ if (!response.ok) {
+ throw new Error(`Failed to load translations for ${currentLanguage}`);
+ }
+ translations = await response.json();
+
+ // Update HTML lang attribute
+ document.documentElement.lang = currentLanguage;
+
+ // Update page title
+ document.title = translations.pageTitle || 'MNIST MLP – Inference Visualization';
+
+ // Apply translations to all elements with data-i18n attribute
+ applyTranslations();
+
+ // Save language preference
+ localStorage.setItem('preferredLanguage', currentLanguage);
+
+ return translations;
+ } catch (error) {
+ console.error('Error loading translations:', error);
+ // Fallback to English if translation file fails to load
+ if (currentLanguage !== 'en') {
+ return initI18n('en');
+ }
+ throw error;
+ }
+}
+
+/**
+ * Get translation for a key
+ * @param {string} key - Translation key (supports dot notation, e.g., "drawing.title")
+ * @param {object} params - Parameters to replace in translation (e.g., {digit: 5})
+ * @returns {string} Translated string
+ */
+function t(key, params = {}) {
+ const keys = key.split('.');
+ let value = translations;
+
+ for (const k of keys) {
+ if (value && typeof value === 'object' && k in value) {
+ value = value[k];
+ } else {
+ console.warn(`Translation key not found: ${key}`);
+ return key;
+ }
+ }
+
+ if (typeof value !== 'string') {
+ console.warn(`Translation value is not a string for key: ${key}`);
+ return key;
+ }
+
+ // Replace parameters in the format {param}
+ if (params && Object.keys(params).length > 0) {
+ return value.replace(/\{(\w+)\}/g, (match, paramKey) => {
+ return params[paramKey] !== undefined ? String(params[paramKey]) : match;
+ });
+ }
+
+ return value;
+}
+
+/**
+ * Apply translations to all elements with data-i18n attribute
+ */
+function applyTranslations() {
+ const elements = document.querySelectorAll('[data-i18n]');
+ elements.forEach(element => {
+ const key = element.getAttribute('data-i18n');
+ const translation = t(key);
+
+ // Handle different element types
+ if (element.tagName === 'INPUT' && element.type === 'text') {
+ element.value = translation;
+ } else if (element.hasAttribute('aria-label')) {
+ element.setAttribute('aria-label', translation);
+ } else if (element.hasAttribute('placeholder')) {
+ element.placeholder = translation;
+ } else if (element.hasAttribute('title')) {
+ element.title = translation;
+ } else {
+ element.textContent = translation;
+ }
+ });
+
+ // Handle elements with data-i18n-html for HTML content
+ const htmlElements = document.querySelectorAll('[data-i18n-html]');
+ htmlElements.forEach(element => {
+ const key = element.getAttribute('data-i18n-html');
+ const translation = t(key);
+ element.innerHTML = translation;
+ });
+
+ // Handle elements with data-i18n-aria-label for aria-label attributes
+ const ariaLabelElements = document.querySelectorAll('[data-i18n-aria-label]');
+ ariaLabelElements.forEach(element => {
+ const key = element.getAttribute('data-i18n-aria-label');
+ const translation = t(key);
+ element.setAttribute('aria-label', translation);
+ });
+
+ // Update language toggle button text
+ const languageToggleText = document.getElementById('languageToggleText');
+ if (languageToggleText) {
+ languageToggleText.textContent = currentLanguage.toUpperCase();
+ }
+}
+
+/**
+ * Switch language
+ * @param {string} lang - Language code ('en' or 'de')
+ */
+async function switchLanguage(lang) {
+ if (lang === currentLanguage) return;
+ await initI18n(lang);
+
+ // Trigger custom event for components that need to update
+ window.dispatchEvent(new CustomEvent('languageChanged', { detail: { language: lang } }));
+}
+
+/**
+ * Get current language
+ * @returns {string} Current language code
+ */
+function getCurrentLanguage() {
+ return currentLanguage;
+}
+
+// Load saved language preference on initialization
+document.addEventListener('DOMContentLoaded', () => {
+ const savedLanguage = localStorage.getItem('preferredLanguage') || 'en';
+ initI18n(savedLanguage).catch(error => {
+ console.error('Failed to initialize i18n:', error);
+ });
+
+ // Setup language toggle button
+ const languageToggleButton = document.getElementById('languageToggleButton');
+ if (languageToggleButton) {
+ languageToggleButton.addEventListener('click', () => {
+ const currentLang = getCurrentLanguage();
+ const newLang = currentLang === 'en' ? 'de' : 'en';
+ switchLanguage(newLang);
+ });
+ }
+});
+
+// Export for use in other modules
+if (typeof window !== 'undefined') {
+ window.i18n = {
+ init: initI18n,
+ t,
+ switchLanguage,
+ getCurrentLanguage,
+ applyTranslations
+ };
+}
+
diff --git a/assets/i18n/de.json b/assets/i18n/de.json
new file mode 100644
index 0000000..e760c3f
--- /dev/null
+++ b/assets/i18n/de.json
@@ -0,0 +1,128 @@
+{
+ "pageTitle": "MNIST MLP – Visualisierung der Inferenz",
+ "drawing": {
+ "title": "Ziffer zeichnen",
+ "instructions": "Auf dem Raster klicken und ziehen (Rechtsklick zum Löschen)",
+ "mobileInstructions": "Tippen und auf dem Raster ziehen"
+ },
+ "controls3d": {
+ "title": "3D-Steuerung:",
+ "instructions": "• Linke Taste + ziehen = drehen • Rechte Taste + ziehen = verschieben • Scrollrad = zoomen",
+ "rotate": "Drehen",
+ "pan": "Verschieben",
+ "zoom": "Zoomen",
+ "rotateDesktop": "Linke Maustaste gedrückt halten und ziehen",
+ "panDesktop": "Rechte Maustaste gedrückt halten und ziehen",
+ "zoomDesktop": "Mausrad benutzen",
+ "rotateMobile": "Mit einem Finger ziehen",
+ "panMobile": "Mit zwei Fingern bewegen",
+ "zoomMobile": "Finger zusammenziehen oder spreizen"
+ },
+ "mobile": {
+ "touchControls": "Touch-Steuerung"
+ },
+ "predictions": {
+ "title": "Wahrscheinlichkeiten der Ziffern"
+ },
+ "network": {
+ "overview": "Netzwerk-Übersicht",
+ "noData": "Keine Netzwerkdaten verfügbar.",
+ "totalParameters": "Gesamtparameter",
+ "inputNodes": "Eingabeknoten",
+ "outputClasses": "Ausgabeklassen",
+ "layers": "Ebenen (inkl. Ausgabe)",
+ "weights": "Gewichte",
+ "bias": "Bias",
+ "total": "Gesamt"
+ },
+ "neuronDetail": {
+ "clearSelection": "Auswahl löschen",
+ "incomingContributions": "Eingehende Beiträge",
+ "outgoingContributions": "Ausgehende Beiträge",
+ "source": "Quelle",
+ "target": "Ziel",
+ "input": "Eingabe",
+ "weight": "Gewicht",
+ "product": "Produkt",
+ "activation": "Aktivierung (Ziel)",
+ "contribution": "Beitrag",
+ "noIncomingConnections": "Keine eingehenden Verbindungen für diese Ebene.",
+ "inputLayerSize": "Eingabeebenen-Größe:",
+ "outputLayerSize": "Ausgabeebenen-Größe:"
+ },
+ "timeline": {
+ "title": "Trainingsfortschritt",
+ "ariaLabel": "Trainingsfortschritt"
+ },
+ "infoModal": {
+ "title": "MNIST-Ziffernklassifizierung – Visualisierung der Inferenz",
+ "subtitle": "Interaktive Visualisierung eines neuronalen Netzes",
+ "description": "Diese Anwendung zeigt ein kompaktes Multi-Layer-Perceptron (MLP), das auf MNIST trainiert wurde. Zeichnen Sie eine Ziffer und beobachten Sie, wie die Aktivierungen in Echtzeit durch alle vollvernetzten Schichten propagieren.",
+ "howItWorks": "So funktioniert es:",
+ "howItWorksDrawing": "Im 2D-Raster (links oben) klicken und ziehen, um eine Ziffer zu skizzieren",
+ "howItWorksObserving": "Verfolgen Sie, wie Ihre Skizze durch die Schichten des Netzes im 3D-Raum wandert",
+ "howItWorksPrediction": "Prüfen Sie die Wahrscheinlichkeit für jede Ziffer (0–9) im Diagramm (rechts oben)",
+ "networkArchitecture": "Netzarchitektur (Standardexport):",
+ "inputLayer": "Eingabeschicht:",
+ "inputLayerDesc": "28×28 Pixelraster (Ihre Zeichnung)",
+ "denseLayer": "Dichte Ebene",
+ "denseLayerDesc": "Neuronen mit ReLU",
+ "outputLayer": "Ausgabeschicht:",
+ "outputLayerDesc": "32 → 10 Logits → Softmax-Wahrscheinlichkeiten",
+ "colorCoding": "Farbcodierung:",
+ "colorCodingNodes": "Die Farbe zeigt die Aktivierungsstärke (dunkle Blautöne für niedrige/negative Werte, kräftige Korallenfarben für starke positive Aktivierungen)",
+ "colorCodingConnections": "Warme Farben kennzeichnen starke positive Beiträge, kühle Töne negative Einflüsse, gedämpfte Linien nahezu Null.",
+ "training": "Eigenes Modell trainieren:",
+ "trainingStep1": "python training/mlp_train.py starten, um das MLP zu trainieren (inkl. Apple-Metal-Beschleunigung, falls verfügbar).",
+ "trainingStep2": "Das Skript schreibt exports/mlp_weights.json, das der Visualisierer beim Laden einliest.",
+ "trainingStep3": "Anzahl versteckter Neuronen, Epochen oder Exportpfade über die in training/mlp_train.py dokumentierten CLI-Optionen verändern.",
+ "realtimeFeatures": "Echtzeitfunktionen:",
+ "realtimeActivations": "Schichtaktivierungen: Instanzen von Kugeln zeigen Aktivierungen pro Neuron mit farbcodierter Stärke.",
+ "realtimeConnections": "Wichtige Verbindungen: Jedes Zielneuron hebt seine stärksten Eingangsgewichte für bessere Lesbarkeit hervor.",
+ "realtimeProbabilities": "Live-Wahrscheinlichkeiten: Das Balkendiagramm aktualisiert die Logits → Softmax-Werte in Echtzeit.",
+ "note": "Das Netzwerk ist bewusst kompakt gehalten, um eine flüssige Echtzeitdarstellung zu gewährleisten. Sie können gern mit anderen Schichtgrößen neu trainieren – halten Sie die Architektur nur schlank, damit die 3D-Ansicht reaktionsschnell bleibt."
+ },
+ "advancedSettings": {
+ "title": "Erweiterte Einstellungen",
+ "maxConnections": "Maximale Verbindungen pro Neuron",
+ "maxConnectionsHint": "Steuert, wie viele der stärksten eingehenden Gewichte pro Zielneuron gleichzeitig angezeigt werden; hohe Werte können die Darstellung verlangsamen.",
+ "hideWeakConnections": "Schwache Verbindungen ausblenden",
+ "hideWeakConnectionsHint": "Blendet Verbindungen mit niedrigem absoluten Gewicht aus; 0 zeigt alle Verbindungen.",
+ "connectionThickness": "Verbindungsstärke",
+ "connectionThicknessHint": "Passt den Radius der Verbindungszylinder an; höhere Werte machen alle Linien sichtbar dicker.",
+ "brushThickness": "Pinselstärke",
+ "brushThicknessHint": "Kleinere Werte erzeugen feinere Linien; größere Werte füllen das Raster schneller.",
+ "brushStrength": "Pinselstärke",
+ "brushStrengthHint": "Bestimmt, wie stark ein Pinselstrich die Pixelhelligkeit erhöht."
+ },
+ "errors": {
+ "initializationFailed": "Visualisierung konnte nicht initialisiert werden. Details in der Konsole.",
+ "loadMnistManifest": "MNIST-Manifest konnte nicht geladen werden",
+ "invalidManifest": "Manifest enthält keine gültigen Dateipfade für Bilder oder Beschriftungen.",
+ "loadMnistImages": "MNIST-Bilddaten konnten nicht geladen werden",
+ "loadMnistLabels": "MNIST-Beschriftungsdaten konnten nicht geladen werden",
+ "inferSampleSize": "Probengröße konnte nicht aus MNIST-Bilddaten abgeleitet werden.",
+ "labelMismatch": "Anzahl der Beschriftungen stimmt nicht mit abgeleiteten Proben überein.",
+ "invalidManifestSize": "Manifest enthält keine gültige Probengröße.",
+ "imageDataMismatch": "Länge der MNIST-Bilddaten stimmt nicht mit erwarteter Größe überein.",
+ "labelDataMismatch": "Länge der MNIST-Beschriftungsdaten stimmt nicht mit erwarteter Größe überein.",
+ "invalidNetworkDefinition": "Ungültige Netzwerkdefinition.",
+ "noTimelineSnapshots": "Keine gültigen Timeline-Snapshots gefunden.",
+ "loadNetworkWeights": "Netzwerkgewichte konnten nicht geladen werden",
+ "base64Unavailable": "Base64-Decodierung ist in dieser Umgebung nicht verfügbar.",
+ "invalidFloat16Length": "Float16-Daten haben ungültige Länge.",
+ "invalidFloat16Count": "Erwartet {expected} Float16-Werte, aber {actual} erhalten.",
+ "loadSnapshot": "Snapshot konnte nicht geladen werden",
+ "invalidSnapshot": "Snapshot-Datei enthält keine gültigen Ebenendaten",
+ "gridContainerNotFound": "Raster-Container nicht gefunden",
+ "invalidPixelValues": "Ungültige Pixelwerte für Zeichenfläche"
+ },
+ "aria": {
+ "loadRandom": "Zufällige {digit} laden"
+ },
+ "fps": {
+ "idle": "inaktiv",
+ "fps": "fps"
+ }
+}
+
diff --git a/assets/i18n/en.json b/assets/i18n/en.json
new file mode 100644
index 0000000..e9685dd
--- /dev/null
+++ b/assets/i18n/en.json
@@ -0,0 +1,128 @@
+{
+ "pageTitle": "MNIST MLP – Inference Visualization",
+ "drawing": {
+ "title": "Draw digit",
+ "instructions": "Click and drag on the grid (right-click to erase)",
+ "mobileInstructions": "Tap and drag on the grid"
+ },
+ "controls3d": {
+ "title": "3D Controls:",
+ "instructions": "• Left button + drag = rotate • Right button + drag = pan • Scroll wheel = zoom",
+ "rotate": "Rotate",
+ "pan": "Pan",
+ "zoom": "Zoom",
+ "rotateDesktop": "Hold left mouse button and drag",
+ "panDesktop": "Hold right mouse button and drag",
+ "zoomDesktop": "Use mouse wheel",
+ "rotateMobile": "Drag with one finger",
+ "panMobile": "Move with two fingers",
+ "zoomMobile": "Pinch or spread fingers"
+ },
+ "mobile": {
+ "touchControls": "Touch Controls"
+ },
+ "predictions": {
+ "title": "Digit Probabilities"
+ },
+ "network": {
+ "overview": "Network Overview",
+ "noData": "No network data available.",
+ "totalParameters": "Total Parameters",
+ "inputNodes": "Input Nodes",
+ "outputClasses": "Output Classes",
+ "layers": "Layers (incl. Output)",
+ "weights": "Weights",
+ "bias": "Bias",
+ "total": "Total"
+ },
+ "neuronDetail": {
+ "clearSelection": "Clear selection",
+ "incomingContributions": "Incoming Contributions",
+ "outgoingContributions": "Outgoing Contributions",
+ "source": "Source",
+ "target": "Target",
+ "input": "Input",
+ "weight": "Weight",
+ "product": "Product",
+ "activation": "Activation (Target)",
+ "contribution": "Contribution",
+ "noIncomingConnections": "No incoming connections for this layer.",
+ "inputLayerSize": "Input Layer Size:",
+ "outputLayerSize": "Output Layer Size:"
+ },
+ "timeline": {
+ "title": "Training Progress",
+ "ariaLabel": "Training Progress"
+ },
+ "infoModal": {
+ "title": "MNIST Digit Classification – Inference Visualization",
+ "subtitle": "Interactive Neural Network Visualization",
+ "description": "This application shows a compact Multi-Layer Perceptron (MLP) trained on MNIST. Draw a digit and observe how activations propagate in real-time through all fully connected layers.",
+ "howItWorks": "How it works:",
+ "howItWorksDrawing": "Click and drag in the 2D grid (top left) to sketch a digit",
+ "howItWorksObserving": "Track how your sketch travels through the network layers in 3D space",
+ "howItWorksPrediction": "Check the probability for each digit (0–9) in the chart (top right)",
+ "networkArchitecture": "Network Architecture (Default Export):",
+ "inputLayer": "Input Layer:",
+ "inputLayerDesc": "28×28 pixel grid (your drawing)",
+ "denseLayer": "Dense Layer",
+ "denseLayerDesc": "neurons with ReLU",
+ "outputLayer": "Output Layer:",
+ "outputLayerDesc": "32 → 10 logits → Softmax probabilities",
+ "colorCoding": "Color Coding:",
+ "colorCodingNodes": "The color shows activation strength (dark blue tones for low/negative values, vibrant coral colors for strong positive activations)",
+ "colorCodingConnections": "Warm colors indicate strong positive contributions, cool tones negative influences, muted lines near zero.",
+ "training": "Training Your Own Model:",
+ "trainingStep1": "Run python training/mlp_train.py to train the MLP (including Apple Metal acceleration, if available).",
+ "trainingStep2": "The script writes exports/mlp_weights.json, which the visualizer reads on load.",
+ "trainingStep3": "Change the number of hidden neurons, epochs, or export paths via the CLI options documented in training/mlp_train.py.",
+ "realtimeFeatures": "Real-time Features:",
+ "realtimeActivations": "Layer Activations: Sphere instances show activations per neuron with color-coded strength.",
+ "realtimeConnections": "Important Connections: Each target neuron highlights its strongest input weights for better readability.",
+ "realtimeProbabilities": "Live Probabilities: The bar chart updates logits → Softmax values in real-time.",
+ "note": "The network is intentionally kept compact to ensure smooth real-time rendering. Feel free to retrain with different layer sizes – just keep the architecture lean so the 3D view remains responsive."
+ },
+ "advancedSettings": {
+ "title": "Advanced Settings",
+ "maxConnections": "Maximum Connections per Neuron",
+ "maxConnectionsHint": "Controls how many of the strongest incoming weights per target neuron are displayed simultaneously; high values may slow down rendering.",
+ "hideWeakConnections": "Hide Weak Connections",
+ "hideWeakConnectionsHint": "Hides connections with low absolute weight; 0 shows all connections.",
+ "connectionThickness": "Connection Thickness",
+ "connectionThicknessHint": "Adjusts the radius of connection cylinders; higher values make all lines visibly thicker.",
+ "brushThickness": "Brush Thickness",
+ "brushThicknessHint": "Smaller values create finer lines; larger values fill the grid faster.",
+ "brushStrength": "Brush Strength",
+ "brushStrengthHint": "Determines how much a brush stroke increases pixel brightness."
+ },
+ "errors": {
+ "initializationFailed": "Visualization could not be initialized. See console for details.",
+ "loadMnistManifest": "Could not load MNIST manifest",
+ "invalidManifest": "Manifest does not contain valid file paths for images or labels.",
+ "loadMnistImages": "Could not load MNIST image data",
+ "loadMnistLabels": "Could not load MNIST label data",
+ "inferSampleSize": "Could not infer sample size from MNIST image data.",
+ "labelMismatch": "Number of labels does not match inferred samples.",
+ "invalidManifestSize": "Manifest does not contain a valid sample size.",
+ "imageDataMismatch": "MNIST image data length does not match expected size.",
+ "labelDataMismatch": "MNIST label data length does not match expected size.",
+ "invalidNetworkDefinition": "Invalid network definition.",
+ "noTimelineSnapshots": "No valid timeline snapshots found.",
+ "loadNetworkWeights": "Network weights could not be loaded",
+ "base64Unavailable": "Base64 decoding is not available in this environment.",
+ "invalidFloat16Length": "Float16 data has invalid length.",
+ "invalidFloat16Count": "Expected {expected} Float16 values, but got {actual}.",
+ "loadSnapshot": "Snapshot could not be loaded",
+ "invalidSnapshot": "Snapshot file does not contain valid layer data",
+ "gridContainerNotFound": "Grid container not found",
+ "invalidPixelValues": "Invalid pixel values for drawing pad"
+ },
+ "aria": {
+ "loadRandom": "Load random {digit}"
+ },
+ "fps": {
+ "idle": "idle",
+ "fps": "fps"
+ }
+}
+
diff --git a/assets/main.css b/assets/main.css
index 8dc482b..f5a3781 100644
--- a/assets/main.css
+++ b/assets/main.css
@@ -598,6 +598,19 @@ body > canvas {
font-size: 1.3rem;
}
+.language-toggle-button {
+ border-color: rgba(91, 160, 255, 0.35);
+ color: rgba(173, 205, 255, 0.88);
+ font-size: 0.85rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+}
+
+.language-toggle-button span {
+ font-size: 0.9rem;
+ font-weight: 700;
+}
+
.timeline-overlay {
position: absolute;
bottom: 24px;
diff --git a/assets/main.js b/assets/main.js
index 587bf86..146388b 100644
--- a/assets/main.js
+++ b/assets/main.js
@@ -20,10 +20,18 @@ const VISUALIZER_CONFIG = {
const MNIST_SAMPLE_MANIFEST_URL = "./assets/data/mnist-test-manifest.json";
+// i18n helper function
+function t(key, params) {
+ if (window.i18n && typeof window.i18n.t === 'function') {
+ return window.i18n.t(key, params);
+ }
+ return key;
+}
+
document.addEventListener("DOMContentLoaded", () => {
initializeVisualizer().catch((error) => {
console.error(error);
- renderErrorMessage("Visualisierung konnte nicht initialisiert werden. Details finden Sie in der Konsole.");
+ renderErrorMessage(t("errors.initializationFailed"));
});
});
@@ -31,7 +39,7 @@ async function loadMnistTestSamples(manifestPath = MNIST_SAMPLE_MANIFEST_URL) {
const manifestUrl = new URL(manifestPath, window.location.href);
const manifestResponse = await fetch(manifestUrl.toString());
if (!manifestResponse.ok) {
- throw new Error(`Konnte MNIST-Manifest nicht laden (${manifestResponse.status}).`);
+ throw new Error(`Could not load MNIST manifest (${manifestResponse.status}).`);
}
const manifest = await manifestResponse.json();
const rows = Number(manifest?.imageShape?.[0]) || 28;
@@ -41,19 +49,19 @@ async function loadMnistTestSamples(manifestPath = MNIST_SAMPLE_MANIFEST_URL) {
const imageFile = manifest?.image?.file;
const labelFile = manifest?.labels?.file;
if (!imageFile || !labelFile) {
- throw new Error("Manifest enthält keine gültigen Dateipfade für Bilder oder Labels.");
+ throw new Error("Manifest does not contain valid file paths for images or labels.");
}
const [imageBuffer, labelBuffer] = await Promise.all([
fetch(new URL(imageFile, manifestUrl).toString()).then((response) => {
if (!response.ok) {
- throw new Error(`Konnte MNIST-Bilddaten nicht laden (${response.status}).`);
+ throw new Error(`Could not load MNIST image data (${response.status}).`);
}
return response.arrayBuffer();
}),
fetch(new URL(labelFile, manifestUrl).toString()).then((response) => {
if (!response.ok) {
- throw new Error(`Konnte MNIST-Labeldaten nicht laden (${response.status}).`);
+ throw new Error(`Could not load MNIST label data (${response.status}).`);
}
return response.arrayBuffer();
}),
@@ -65,22 +73,22 @@ async function loadMnistTestSamples(manifestPath = MNIST_SAMPLE_MANIFEST_URL) {
if (sampleSize > 0) {
const inferredSamples = Math.floor(imageBytes.length / sampleSize);
if (inferredSamples <= 0) {
- throw new Error("Aus den MNIST-Bilddaten konnte keine Stichprobengröße abgeleitet werden.");
+ throw new Error("Could not infer sample size from MNIST image data.");
}
if (labelBytes.length !== inferredSamples) {
- throw new Error("Anzahl der Labels stimmt nicht mit den abgeleiteten Stichproben überein.");
+ throw new Error("Number of labels does not match inferred samples.");
}
} else {
- throw new Error("Manifest enthält keine gültige Stichprobengröße.");
+ throw new Error("Manifest does not contain a valid sample size.");
}
}
const totalSamples = numSamples > 0 ? numSamples : Math.floor(imageBytes.length / sampleSize);
if (imageBytes.length !== totalSamples * sampleSize) {
- throw new Error("MNIST-Bilddatenlänge stimmt nicht mit der erwarteten Größe überein.");
+ throw new Error("MNIST image data length does not match expected size.");
}
if (labelBytes.length !== totalSamples) {
- throw new Error("MNIST-Labeldatenlänge stimmt nicht mit der erwarteten Größe überein.");
+ throw new Error("MNIST label data length does not match expected size.");
}
const digitBuckets = Array.from({ length: 10 }, () => []);
@@ -141,7 +149,7 @@ async function setupMnistSampleButtons({ digitCanvas, onSampleApplied, manifestP
try {
loader = await loadMnistTestSamples(manifestPath ?? MNIST_SAMPLE_MANIFEST_URL);
} catch (error) {
- console.warn("MNIST-Testdaten konnten nicht geladen werden:", error);
+ console.warn("MNIST test data could not be loaded:", error);
return null;
}
const column = document.createElement("div");
@@ -151,7 +159,7 @@ async function setupMnistSampleButtons({ digitCanvas, onSampleApplied, manifestP
button.type = "button";
button.className = "digit-button";
button.textContent = String(digit);
- button.setAttribute("aria-label", `Zufällige ${digit} laden`);
+ button.setAttribute("aria-label", t("aria.loadRandom", { digit }));
button.addEventListener("click", () => {
const sample = loader.getRandomSample(digit);
if (!sample) return;
@@ -175,7 +183,7 @@ async function initializeVisualizer() {
const weightDefinitionUrl = new URL(VISUALIZER_CONFIG.weightUrl, window.location.href);
const definition = await fetchNetworkDefinition(weightDefinitionUrl.toString());
if (!definition?.network) {
- throw new Error("Ungültige Netzwerkdefinition.");
+ throw new Error("Invalid network definition.");
}
const timelineSnapshots = hydrateTimeline(definition.timeline, {
@@ -183,7 +191,7 @@ async function initializeVisualizer() {
baseUrl: weightDefinitionUrl,
});
if (!timelineSnapshots.length) {
- throw new Error("Keine gültigen Timeline-Snapshots gefunden.");
+ throw new Error("No valid timeline snapshots found.");
}
const defaultSnapshotIndex = Math.max(timelineSnapshots.length - 1, 0);
const initialSnapshot = timelineSnapshots[defaultSnapshotIndex];
@@ -664,7 +672,7 @@ function initializeAdvancedSettings({ neuralScene, digitCanvas, onConnectionsSet
async function fetchNetworkDefinition(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
- throw new Error(`Netzwerkgewichte konnten nicht geladen werden (${response.status})`);
+ throw new Error(`Network weights could not be loaded (${response.status})`);
}
return response.json();
}
@@ -681,7 +689,7 @@ function resolveRelativeUrl(base, relativePath) {
const baseUrl = base instanceof URL ? base : new URL(base, window.location.href);
return new URL(relativePath, baseUrl).toString();
} catch (error) {
- console.warn("Konnte relative URL nicht auflösen:", relativePath, error);
+ console.warn("Could not resolve relative URL:", relativePath, error);
return null;
}
}
@@ -699,7 +707,7 @@ function decodeBase64ToUint8Array(base64) {
if (typeof Buffer === "function") {
return Uint8Array.from(Buffer.from(base64, "base64"));
}
- throw new Error("Base64-Dekodierung ist in dieser Umgebung nicht verfügbar.");
+ throw new Error("Base64 decoding is not available in this environment.");
}
function float16ToFloat32(value) {
@@ -726,13 +734,13 @@ function float16ToFloat32(value) {
function decodeFloat16Base64(base64, expectedLength) {
const bytes = decodeBase64ToUint8Array(base64);
if (bytes.byteLength % 2 !== 0) {
- throw new Error("Float16-Daten haben eine ungültige Länge.");
+ throw new Error("Float16 data has invalid length.");
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const length = bytes.byteLength / 2;
if (Number.isFinite(expectedLength) && expectedLength > 0 && length !== expectedLength) {
throw new Error(
- `Erwartete ${expectedLength} Float16-Werte, erhalten wurden jedoch ${length}.`,
+ `Expected ${expectedLength} Float16 values, but got ${length}.`,
);
}
const result = new Float32Array(length);
@@ -798,14 +806,14 @@ function normaliseWeightsDescriptor(descriptor, baseUrl) {
async function fetchSnapshotPayload(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
- throw new Error(`Snapshot konnte nicht geladen werden (${response.status})`);
+ throw new Error(`Snapshot could not be loaded (${response.status})`);
}
return response.json();
}
function decodeSnapshotLayers(payload, layerMetadata) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.layers)) {
- throw new Error("Snapshot-Datei enthält keine gültigen Layerdaten.");
+ throw new Error("Snapshot file does not contain valid layer data.");
}
return layerMetadata.map((meta, index) => {
@@ -813,21 +821,21 @@ function decodeSnapshotLayers(payload, layerMetadata) {
payload.layers[index] ??
payload.layers.find((layer) => Number(layer?.layer_index) === meta.layerIndex);
if (!layerPayload) {
- throw new Error(`Snapshot fehlt Layer ${meta.layerIndex}.`);
+ throw new Error(`Snapshot missing layer ${meta.layerIndex}.`);
}
const weightsInfo = layerPayload.weights ?? {};
const biasesInfo = layerPayload.biases ?? {};
if (typeof weightsInfo.data !== "string" || typeof biasesInfo.data !== "string") {
- throw new Error("Snapshot-Layer enthält keine kodierten Gewichte.");
+ throw new Error("Snapshot layer does not contain encoded weights.");
}
const weightShape = normaliseShape(weightsInfo.shape, meta.weightShape);
const biasShape = normaliseShape(biasesInfo.shape, meta.biasShape);
if (weightShape.length !== 2) {
- throw new Error("Snapshot-Layer hat eine ungültige Gewichtsdimension.");
+ throw new Error("Snapshot layer has invalid weight dimension.");
}
if (biasShape.length === 0) {
- throw new Error("Snapshot-Layer hat eine ungültige Bias-Dimension.");
+ throw new Error("Snapshot layer has invalid bias dimension.");
}
const weights = decodeWeightMatrix(weightsInfo.data, weightShape);
@@ -1019,7 +1027,7 @@ function setupTimelineSlider(timelineSnapshots, options = {}) {
const nextIndex = Number(event.target.value);
if (Number.isNaN(nextIndex)) return;
setActiveIndex(nextIndex, { emit: true }).catch((error) => {
- console.error("Fehler beim Aktualisieren des Snapshots:", error);
+ console.error("Error updating snapshot:", error);
});
});
@@ -1027,7 +1035,7 @@ function setupTimelineSlider(timelineSnapshots, options = {}) {
const nextIndex = Number(event.target.value);
if (Number.isNaN(nextIndex)) return;
setActiveIndex(nextIndex, { emit: true }).catch((error) => {
- console.error("Fehler beim Aktualisieren des Snapshots:", error);
+ console.error("Error updating snapshot:", error);
});
});
@@ -1045,7 +1053,7 @@ function setupTimelineSlider(timelineSnapshots, options = {}) {
class DigitSketchPad {
constructor(container, rows, cols, options = {}) {
if (!container) {
- throw new Error("Raster-Container nicht gefunden.");
+ throw new Error("Grid container not found.");
}
this.container = container;
this.rows = rows;
@@ -1085,7 +1093,7 @@ class DigitSketchPad {
this.container.innerHTML = "";
const title = document.createElement("div");
title.className = "grid-title";
- title.textContent = "Ziffer zeichnen";
+ title.textContent = t("drawing.title");
this.interactionRow = document.createElement("div");
this.interactionRow.className = "grid-interaction-row";
this.interactionRow.appendChild(this.gridElement);
@@ -1096,6 +1104,16 @@ class DigitSketchPad {
this.gridElement.addEventListener("pointermove", (event) => this.handlePointerMove(event));
window.addEventListener("pointerup", () => this.handlePointerUp());
this.gridElement.addEventListener("contextmenu", (event) => event.preventDefault());
+
+ // Listen for language changes
+ window.addEventListener("languageChanged", () => this.updateLanguage());
+ }
+
+ updateLanguage() {
+ const title = this.container.querySelector(".grid-title");
+ if (title) {
+ title.textContent = t("drawing.title");
+ }
}
setChangeHandler(handler) {
@@ -1230,11 +1248,11 @@ class DigitSketchPad {
setPixels(pixels) {
if (!pixels || typeof pixels.length !== "number") {
- throw new Error("Ungültige Pixelwerte für den Zeichenblock.");
+ throw new Error("Invalid pixel values for drawing pad.");
}
if (pixels.length !== this.values.length) {
throw new Error(
- `Erwartete ${this.values.length} Pixel, erhielt aber ${pixels.length}.`,
+ `Expected ${this.values.length} pixels, but got ${pixels.length}.`,
);
}
for (let i = 0; i < this.values.length; i += 1) {
@@ -1271,7 +1289,7 @@ class DigitSketchPad {
class FeedForwardModel {
constructor(definition) {
if (!definition.layers?.length) {
- throw new Error("Die Netzwerkdefinition muss Schichten enthalten.");
+ throw new Error("Network definition must contain layers.");
}
this.normalization = definition.normalization ?? { mean: 0, std: 1 };
this.architecture = Array.isArray(definition.architecture)
@@ -1322,7 +1340,7 @@ class FeedForwardModel {
updateLayers(layerDefinitions) {
if (!Array.isArray(layerDefinitions) || layerDefinitions.length === 0) {
- throw new Error("Neue Layerdefinitionen müssen mindestens eine Schicht enthalten.");
+ throw new Error("New layer definitions must contain at least one layer.");
}
this.layers = layerDefinitions.map((layer, index) => this.normaliseLayer(layer, index));
this.architecture = this.computeArchitecture(this.layers);
@@ -1379,21 +1397,30 @@ class ProbabilityPanel {
this.container = container;
this.rows = [];
if (!this.container) {
- throw new Error("Vorhersage-Diagrammcontainer nicht gefunden.");
+ throw new Error("Prediction chart container not found.");
}
this.build();
+
+ // Listen for language changes
+ window.addEventListener("languageChanged", () => this.updateLanguage());
+ }
+
+ updateLanguage() {
+ if (this.titleElement) {
+ this.titleElement.textContent = t("predictions.title");
+ }
}
build() {
this.container.innerHTML = "";
- const title = document.createElement("h3");
- title.textContent = "Wahrscheinlichkeiten der Ziffern";
- this.container.appendChild(title);
+ this.titleElement = document.createElement("h3");
+ this.titleElement.textContent = t("predictions.title");
+ this.container.appendChild(this.titleElement);
this.chartElement = document.createElement("div");
this.chartElement.className = "prediction-chart";
this.container.appendChild(this.chartElement);
-
+
for (let digit = 0; digit < 10; digit += 1) {
const row = document.createElement("div");
row.className = "prediction-bar-container";
@@ -1443,9 +1470,9 @@ class NetworkInfoPanel {
constructor(container) {
this.container = container;
if (!this.container) {
- throw new Error("Netzwerkinfo-Container nicht gefunden.");
+ throw new Error("Network info container not found.");
}
- this.numberFormatter = new Intl.NumberFormat("de-DE");
+ this.numberFormatter = new Intl.NumberFormat("en-US");
this.build();
}
@@ -1456,7 +1483,7 @@ class NetworkInfoPanel {
}
this.titleElement = document.createElement("h3");
this.titleElement.className = "network-info-panel__title";
- this.titleElement.textContent = "Netzwerkübersicht";
+ this.titleElement.textContent = t("network.overview");
this.summaryElement = document.createElement("div");
this.summaryElement.className = "network-info-panel__summary";
@@ -1466,7 +1493,7 @@ class NetworkInfoPanel {
this.emptyElement = document.createElement("div");
this.emptyElement.className = "network-info-panel__empty";
- this.emptyElement.textContent = "Keine Netzwerkdaten verfügbar.";
+ this.emptyElement.textContent = t("network.noData");
this.container.appendChild(this.titleElement);
this.container.appendChild(this.summaryElement);
@@ -1476,6 +1503,22 @@ class NetworkInfoPanel {
this.summaryElement.style.display = "none";
this.layersElement.style.display = "none";
this.emptyElement.style.display = "block";
+
+ // Listen for language changes
+ window.addEventListener("languageChanged", () => this.updateLanguage());
+ }
+
+ updateLanguage() {
+ if (this.titleElement) {
+ this.titleElement.textContent = t("network.overview");
+ }
+ if (this.emptyElement) {
+ this.emptyElement.textContent = t("network.noData");
+ }
+ // Rebuild if we have data
+ if (this.lastModel) {
+ this.update(this.lastModel);
+ }
}
formatNumber(value) {
@@ -1517,6 +1560,7 @@ class NetworkInfoPanel {
}
update(model) {
+ this.lastModel = model;
if (!model || !Array.isArray(model.layers) || model.layers.length === 0) {
this.emptyElement.style.display = "block";
this.summaryElement.style.display = "none";
@@ -1563,7 +1607,7 @@ class NetworkInfoPanel {
const totalParameters = layerSummaries.reduce((sum, entry) => sum + entry.parameterCount, 0);
this.summaryElement.innerHTML = "";
- this.summaryElement.appendChild(this.buildSummaryLine("Gesamtparameter", totalParameters));
+ this.summaryElement.appendChild(this.buildSummaryLine(t("network.totalParameters"), totalParameters));
if (architecture.length > 0) {
const firstArchitectureValue = architecture[0];
const lastArchitectureValue = architecture[architecture.length - 1];
@@ -1576,10 +1620,10 @@ class NetworkInfoPanel {
typeof lastArchitectureValue === "number" && Number.isFinite(lastArchitectureValue)
? lastArchitectureValue
: lastLayer?.outputSize ?? 0;
- this.summaryElement.appendChild(this.buildSummaryLine("Eingabeknoten", inputNodes));
- this.summaryElement.appendChild(this.buildSummaryLine("Ausgabeklassen", outputNodes));
+ this.summaryElement.appendChild(this.buildSummaryLine(t("network.inputNodes"), inputNodes));
+ this.summaryElement.appendChild(this.buildSummaryLine(t("network.outputClasses"), outputNodes));
}
- this.summaryElement.appendChild(this.buildSummaryLine("Layer (inkl. Ausgaben)", layerSummaries.length));
+ this.summaryElement.appendChild(this.buildSummaryLine(t("network.layers"), layerSummaries.length));
this.layersElement.innerHTML = "";
layerSummaries.forEach((entry) => {
@@ -1593,9 +1637,9 @@ class NetworkInfoPanel {
const metrics = document.createElement("div");
metrics.className = "network-info-panel__layer-metrics";
- metrics.appendChild(this.buildMetric("Gewichte", entry.weightCount));
- metrics.appendChild(this.buildMetric("Bias", entry.biasCount));
- metrics.appendChild(this.buildMetric("Summe", entry.parameterCount));
+ metrics.appendChild(this.buildMetric(t("network.weights"), entry.weightCount));
+ metrics.appendChild(this.buildMetric(t("network.bias"), entry.biasCount));
+ metrics.appendChild(this.buildMetric(t("network.total"), entry.parameterCount));
layerRow.appendChild(title);
layerRow.appendChild(metrics);
@@ -1650,10 +1694,10 @@ class NeuronDetailPanel {
.map(
(entry) => `