# Calculator Upgrade v2.1.0 — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend the Calculator widget with 5 new modes — Scientific, Unit Converter, Satisfactory, Factorio, Stationeers — using a tab-based architecture with mode registration pattern. **Architecture:** Each mode lives in its own IIFE file that calls `Calculator.registerMode()`. The core `calculator.js` gains a `_modes` Map, tab-bar rendering, and `switchMode()` logic. The Shunting-Yard parser is extended with `^` (power) and `sqrt()` (function). All new files load between `calculator.js` and `timer.js` in newtab.html. **Tech Stack:** Vanilla JS ES2020, CSS Custom Properties, chrome.storage.local via Store, i18n via t() helper **Spec:** `docs/superpowers/specs/2026-04-16-calculator-upgrade-design.md` --- ### Task 1: Tab-System im Calculator Core **Files:** - Modify: `src/js/calculator.js` (全 Datei — Tab-System, registerMode, switchMode, Standard-Mode-Extraktion) - Modify: `src/css/main.css:1284` (neue CSS-Klassen nach bestehenden Calculator-Styles) **Kontext:** Aktuell rendert `Calculator.renderBody()` direkt das Standard-UI (Display + Buttons + History). Wir refactoren das zu einem Tab-System: `renderBody()` baut Tab-Bar + Mode-Container, dann delegiert an den aktiven Modus. Der Standard-Modus wird als interner Modus registriert. Externe Modi registrieren sich per `Calculator.registerMode()`. - [ ] **Step 1: Neue Properties und registerMode() hinzufügen** In `calculator.js`, nach `_keydownHandler: null,` (Zeile 19) folgende Properties ergänzen: ```javascript _modes: new Map(), _activeMode: 'standard', _tabBarEl: null, ``` Neue Methode nach `_keydownHandler` Block: ```javascript /** * Modus registrieren (wird von externen Mode-Dateien aufgerufen) * @param {string} name - Eindeutiger Modus-Name * @param {Object} config - { label, shortName, titleKey, render(bodyEl), destroy() } */ registerMode(name, config) { this._modes.set(name, config); // Tab-Bar aktualisieren falls Widget bereits offen if (this._tabBarEl) this._renderTabBar(); }, ``` - [ ] **Step 2: Standard-Modus als internen Modus registrieren** Am Ende der `init()` Methode (vor dem Schließen des Objekts), nach den Event-Listener-Registrierungen, den Standard-Modus registrieren: ```javascript // Standard-Modus intern registrieren this._modes.set('standard', { label: '🔢', shortName: 'Std', titleKey: 'calculator.tab.standard', render: (bodyEl) => this._renderStandardMode(bodyEl), destroy: () => { // Standard-Modus hat keinen speziellen Cleanup this._displayExprEl = null; this._displayResultEl = null; } }); ``` - [ ] **Step 3: renderBody() refactoren — Tab-Bar + Mode-Container** Die bestehende `renderBody()` Methode komplett ersetzen: ```javascript /** * Calculator-Body rendern: Tab-Bar + aktiver Modus * @param {HTMLElement} bodyEl */ renderBody(bodyEl) { bodyEl.textContent = ''; bodyEl.style.padding = '0'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.height = '100%'; // Tab-Bar const tabBar = document.createElement('div'); tabBar.className = 'calc-tab-bar'; this._tabBarEl = tabBar; this._renderTabBar(); // Mode-Body Container const modeBody = document.createElement('div'); modeBody.className = 'calc-mode-body'; bodyEl.append(tabBar, modeBody); // Aktiven Modus rendern const mode = this._modes.get(this._activeMode); if (mode) { mode.render(modeBody); } }, ``` - [ ] **Step 4: _renderTabBar() und _updateTabBar() erstellen** ```javascript /** * Tab-Bar mit Buttons aus _modes Map befüllen */ _renderTabBar() { if (!this._tabBarEl) return; // Alle bisherigen Tabs entfernen while (this._tabBarEl.firstChild) { this._tabBarEl.removeChild(this._tabBarEl.firstChild); } this._modes.forEach((config, name) => { const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'calc-tab' + (name === this._activeMode ? ' active' : ''); tab.dataset.mode = name; const icon = document.createElement('span'); icon.className = 'calc-tab-icon'; icon.textContent = config.label; const label = document.createElement('span'); label.className = 'calc-tab-label'; label.textContent = config.shortName; tab.append(icon, label); tab.addEventListener('click', () => this.switchMode(name)); this._tabBarEl.appendChild(tab); }); }, /** * Aktiven Tab visuell markieren (ohne Neuaufbau) */ _updateTabBar() { if (!this._tabBarEl) return; const tabs = this._tabBarEl.querySelectorAll('.calc-tab'); tabs.forEach(tab => { tab.classList.toggle('active', tab.dataset.mode === this._activeMode); }); }, ``` - [ ] **Step 5: switchMode() erstellen** ```javascript /** * Modus wechseln * @param {string} name - Ziel-Modus */ async switchMode(name) { if (name === this._activeMode) return; const mode = this._modes.get(name); if (!mode) return; // Alten Modus aufräumen const oldMode = this._modes.get(this._activeMode); if (oldMode && oldMode.destroy) oldMode.destroy(); this._activeMode = name; // Mode-Body leeren und neu rendern const entry = WidgetManager._widgets.get(this.WIDGET_ID); if (!entry) return; const modeBody = entry.el.querySelector('.calc-mode-body'); if (!modeBody) return; modeBody.textContent = ''; mode.render(modeBody); // Tab-UI aktualisieren this._updateTabBar(); // Auto-Resize für komplexe Modi const isComplex = name !== 'standard'; if (isComplex) { const state = WidgetManager.getState(this.WIDGET_ID); if (state) { const newW = Math.max(state.width, 320); const newH = Math.max(state.height, 480); if (newW !== state.width || newH !== state.height) { WidgetManager.resize(this.WIDGET_ID, newW, newH); } } } // Keyboard neu binden this._unbindKeyboard(); if (name === 'standard' || name === 'scientific') { if (entry) this._bindKeyboard(entry.el); } await this.save(); }, ``` - [ ] **Step 6: _renderStandardMode() extrahieren** Neue Methode, die den bisherigen `renderBody()` Inhalt enthält (Display + Buttons + History): ```javascript /** * Standard-Modus UI rendern * @param {HTMLElement} bodyEl */ _renderStandardMode(bodyEl) { bodyEl.style.padding = '8px'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.flex = '1'; bodyEl.style.overflow = 'hidden'; // Display const display = document.createElement('div'); display.className = 'calc-display'; const exprEl = document.createElement('div'); exprEl.className = 'calc-expression'; this._displayExprEl = exprEl; const resultEl = document.createElement('div'); resultEl.className = 'calc-result'; resultEl.textContent = '0'; this._displayResultEl = resultEl; display.append(exprEl, resultEl); // Buttons const buttonsEl = this._createButtons(); // History const historyEl = this._createHistoryPanel(); bodyEl.append(display, buttonsEl, historyEl); // Aktuellen State anzeigen this._updateDisplay(); }, ``` - [ ] **Step 7: save() und load() um activeMode erweitern** In `save()`, `calcData` um `activeMode` erweitern: ```javascript const calcData = { x: widgetState ? widgetState.x : 400, y: widgetState ? widgetState.y : 120, width: widgetState ? widgetState.width : 280, height: widgetState ? widgetState.height : 400, open: this._isOpen, activeMode: this._activeMode, history: this._history.slice(0, this.MAX_HISTORY) }; ``` In `load()`, activeMode wiederherstellen: ```javascript async load() { const data = await Store.get(this.STORAGE_KEY); if (data && data.calculator) { this._history = Array.isArray(data.calculator.history) ? data.calculator.history : []; if (data.calculator.activeMode) { this._activeMode = data.calculator.activeMode; } } }, ``` In `onClose()`, alten Modus aufräumen: ```javascript async onClose() { // Aktiven Modus aufräumen const mode = this._modes.get(this._activeMode); if (mode && mode.destroy) mode.destroy(); this._isOpen = false; this._unbindKeyboard(); this._tabBarEl = null; this._displayExprEl = null; this._displayResultEl = null; await this.save(); }, ``` - [ ] **Step 8: CSS für Tab-Bar und Mode-Body** In `src/css/main.css`, nach dem `.calc-history-item .calc-h-result` Block (Zeile 1283), vor den Timer-Styles einfügen: ```css /* Calculator Tab System */ .calc-tab-bar { display: flex; background: rgba(0,0,0,0.2); border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; flex-shrink: 0; } .calc-tab-bar::-webkit-scrollbar { display: none; } .calc-tab { display: flex; align-items: center; gap: 3px; padding: 6px 8px; border: none; border-bottom: 2px solid transparent; background: none; color: var(--text-muted); font-size: 11px; font-family: 'Rajdhani', sans-serif; cursor: pointer; white-space: nowrap; transition: color 0.15s, border-color 0.15s; flex-shrink: 0; } .calc-tab:hover { color: var(--text-secondary); } .calc-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } .calc-tab-icon { font-size: 12px; } .calc-tab-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } .calc-mode-body { flex: 1; overflow-y: auto; overflow-x: hidden; } ``` - [ ] **Step 9: i18n-Key für Standard-Tab** In `src/js/i18n.js`, nach `'calculator.error'` in beiden Sprachen: DE: ```javascript 'calculator.tab.standard': 'Standard', ``` EN: ```javascript 'calculator.tab.standard': 'Standard', ``` - [ ] **Step 10: Testen** 1. Extension in Chrome laden 2. Calculator öffnen → Tab-Bar mit einem Tab "🔢 Std" sichtbar 3. Calculator schließen und wieder öffnen → State bleibt erhalten 4. Standard-Rechner funktioniert wie vorher (Buttons, History, Keyboard) - [ ] **Step 11: Commit** ```bash git add src/js/calculator.js src/css/main.css src/js/i18n.js git commit -m "feat(calculator): Tab-System mit registerMode() und switchMode()" ``` --- ### Task 2: Parser-Erweiterung — ^ und sqrt **Files:** - Modify: `src/js/calculator.js` — `_evaluate()`, `_tokenize()`, `_parseExpression()` innere Funktionen **Kontext:** Der Shunting-Yard-Parser kennt aktuell nur `+`, `-`, `*`, `/`, `%`. Wir fügen `^` (Potenz, rechts-assoziativ) und `sqrt` (unäre Funktion) hinzu. Die neue Hierarchie: `parseExpr(+,-)` → `parseTerm(*,/,%)` → `parsePower(^)` → `parseFactor(number | parens | func)`. - [ ] **Step 1: Sanitizer erweitern** In `_evaluate()`, die Sanitizer-Regex erweitern um `^` und Buchstaben (für `sqrt`): ```javascript _evaluate(expr) { try { // Nur erlaubte Zeichen (inkl. ^ und sqrt-Buchstaben) const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, ''); if (!sanitized) return null; const tokens = this._tokenize(sanitized); if (!tokens) return null; return this._parseExpression(tokens); } catch { return null; } }, ``` - [ ] **Step 2: Tokenizer um ^ und sqrt erweitern** In `_tokenize()`, nach dem Operator-Block (`if (/[+\-*/%]/.test(ch))`) und vor dem Klammern-Block, `^` als Operator erkennen. Vor dem Zahlen-Block `sqrt` als Funktion erkennen: ```javascript _tokenize(expr) { const tokens = []; let i = 0; while (i < expr.length) { const ch = expr[i]; // Funktion: sqrt if (expr.substring(i, i + 4) === 'sqrt') { tokens.push({ type: 'func', value: 'sqrt' }); i += 4; continue; } // Zahl (inkl. Dezimal) if (/[0-9.]/.test(ch)) { let num = ''; while (i < expr.length && /[0-9.]/.test(expr[i])) { num += expr[i]; i++; } const parsed = parseFloat(num); if (isNaN(parsed)) return null; tokens.push({ type: 'number', value: parsed }); continue; } // Potenz-Operator if (ch === '^') { tokens.push({ type: 'op', value: '^' }); i++; continue; } // Operator if (/[+\-*/%]/.test(ch)) { // Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer if (ch === '-') { const prev = tokens[tokens.length - 1]; if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) { let num = '-'; i++; while (i < expr.length && /[0-9.]/.test(expr[i])) { num += expr[i]; i++; } if (num === '-') return null; const parsed = parseFloat(num); if (isNaN(parsed)) return null; tokens.push({ type: 'number', value: parsed }); continue; } } tokens.push({ type: 'op', value: ch }); i++; continue; } // Klammern if (ch === '(' || ch === ')') { tokens.push({ type: 'paren', value: ch }); i++; continue; } // Unbekannte Buchstaben überspringen (können Reste von Funktionsnamen sein) if (/[a-z]/.test(ch)) { return null; } // Unbekanntes Zeichen return null; } return tokens; }, ``` - [ ] **Step 3: Parser um parsePower() und Funktions-Support erweitern** Die `_parseExpression()` Methode komplett ersetzen. `parseTerm()` ruft jetzt `parsePower()` statt `parseFactor()` auf. `parsePower()` ist rechts-assoziativ. `parseFactor()` prüft auf Funktionen: ```javascript _parseExpression(tokens) { let pos = 0; function peek() { return tokens[pos]; } function consume() { return tokens[pos++]; } // Expression: Term (('+' | '-') Term)* function parseExpr() { let left = parseTerm(); if (left === null) return null; while (pos < tokens.length) { const tk = peek(); if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break; consume(); const right = parseTerm(); if (right === null) return null; left = tk.value === '+' ? left + right : left - right; } return left; } // Term: Power (('*' | '/' | '%') Power)* function parseTerm() { let left = parsePower(); if (left === null) return null; while (pos < tokens.length) { const tk = peek(); if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break; consume(); const right = parsePower(); if (right === null) return null; if (tk.value === '*') { left = left * right; } else if (tk.value === '/') { if (right === 0) return null; left = left / right; } else { left = left % right; } } return left; } // Power: Factor ('^' Power)? — rechts-assoziativ durch Rekursion function parsePower() { let base = parseFactor(); if (base === null) return null; const tk = peek(); if (tk && tk.type === 'op' && tk.value === '^') { consume(); const exp = parsePower(); // Rechts-assoziativ! if (exp === null) return null; return Math.pow(base, exp); } return base; } // Factor: func '(' Expression ')' | Number | '(' Expression ')' function parseFactor() { const tk = peek(); if (!tk) return null; // Funktion: sqrt(...) if (tk.type === 'func') { const funcName = tk.value; consume(); // Erwarte öffnende Klammer const open = peek(); if (!open || open.type !== 'paren' || open.value !== '(') return null; consume(); const val = parseExpr(); if (val === null) return null; const close = peek(); if (close && close.type === 'paren' && close.value === ')') { consume(); } if (funcName === 'sqrt') { if (val < 0) return null; // Keine negativen Wurzeln return Math.sqrt(val); } return null; } if (tk.type === 'number') { consume(); return tk.value; } if (tk.type === 'paren' && tk.value === '(') { consume(); const val = parseExpr(); if (val === null) return null; const closing = peek(); if (closing && closing.type === 'paren' && closing.value === ')') { consume(); } return val; } return null; } const result = parseExpr(); // Alle Tokens muessen verbraucht sein if (pos < tokens.length) return null; if (result === null || !isFinite(result)) return null; return result; }, ``` - [ ] **Step 4: _handleKey() um ^ erweitern** Im `default`-case der `_handleKey()` Methode, nach dem Ziffern-Check, `^` als Operator behandeln. In den Operator-Case (die bestehende `case '%': case '+': ...` Kette), `'^'` hinzufügen: ```javascript case '%': case '+': case '-': case '*': case '/': case '^': { // Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen if (this._lastResult && this._currentExpr === '') { this._currentExpr = this._lastResult; this._lastResult = ''; } // Doppelte Operatoren verhindern (letzten ersetzen) const last = this._currentExpr.slice(-1); if (/[+\-*/%^]/.test(last)) { this._currentExpr = this._currentExpr.slice(0, -1) + key; } else { this._currentExpr += key; } break; } ``` - [ ] **Step 5: _formatExpression() um ^ erweitern** ```javascript _formatExpression(expr) { return expr .replace(/\*/g, '\u00D7') .replace(/\//g, '\u00F7') .replace(/sqrt\(/g, '√('); }, ``` - [ ] **Step 6: Testen** 1. Calculator öffnen 2. `2^10` eingeben → Ergebnis: 1024 3. `2^3^2` eingeben → Ergebnis: 512 (rechts-assoziativ) 4. In Browser-Console: Calculator._evaluate('sqrt(144)') → 12 5. `3+sqrt(16)` → 7 6. `sqrt(-4)` → Fehler - [ ] **Step 7: Commit** ```bash git add src/js/calculator.js git commit -m "feat(calculator): Parser um ^ (Potenz) und sqrt() erweitern" ``` --- ### Task 3: Scientific-Modus **Files:** - Create: `src/js/calc-scientific.js` - Modify: `newtab.html:509` (Script-Tag nach calculator.js einfügen) - Modify: `src/js/i18n.js` (neue Keys) - Modify: `src/css/main.css` (Scientific-spezifische Styles) **Kontext:** Der Scientific-Modus erweitert den Standard-Rechner um 6 wissenschaftliche Buttons (√, x², xⁿ, π, e, ±) und einen Formel-Helfer mit 6 vorgefertigten Formeln. Er nutzt den gleichen `_handleKey()`/`_calculate()` Flow des Cores. - [ ] **Step 1: calc-scientific.js erstellen** ```javascript /* ============================================= HELLION NEWTAB — calc-scientific.js Scientific-Modus für Calculator Widget ============================================= */ (function() { 'use strict'; /** Formel-Definitionen */ const FORMULAS = [ { key: 'circle_area', fields: [{ key: 'radius', default: '' }], calc: (vals) => Math.PI * vals.radius * vals.radius }, { key: 'circle_circumference', fields: [{ key: 'radius', default: '' }], calc: (vals) => 2 * Math.PI * vals.radius }, { key: 'celsius_to_fahrenheit', fields: [{ key: 'temp', default: '' }], calc: (vals) => (vals.temp * 9 / 5) + 32 }, { key: 'fahrenheit_to_celsius', fields: [{ key: 'temp', default: '' }], calc: (vals) => (vals.temp - 32) * 5 / 9 }, { key: 'pythagoras', fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }], calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b) }, { key: 'percentage', fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }], calc: (vals) => vals.value * vals.percent / 100 } ]; /** Keyboard-Handler Referenz für Cleanup */ let _keyboardExtHandler = null; /** * Scientific-Buttons rendern * @param {HTMLElement} container */ function renderSciButtons(container) { const grid = document.createElement('div'); grid.className = 'calc-buttons calc-sci-buttons'; const buttons = [ ['√', 'sqrt', 'operator'], ['x²', 'square', 'operator'], ['xⁿ', 'power', 'operator'], ['π', 'pi', 'operator'], ['e', 'euler', 'operator'], ['±', 'negate', 'operator'] ]; buttons.forEach(([label, value, cls]) => { const btn = document.createElement('button'); btn.className = 'calc-btn' + (cls ? ' ' + cls : ''); btn.textContent = label; btn.type = 'button'; btn.addEventListener('click', () => handleSciKey(value)); grid.appendChild(btn); }); container.appendChild(grid); } /** * Scientific-Taste verarbeiten * @param {string} key */ function handleSciKey(key) { switch (key) { case 'sqrt': Calculator._currentExpr += 'sqrt('; Calculator._updateDisplay(); break; case 'square': Calculator._currentExpr += '^2'; Calculator._updateDisplay(); break; case 'power': Calculator._handleKey('^'); break; case 'pi': Calculator._currentExpr += '3.14159265359'; Calculator._updateDisplay(); break; case 'euler': Calculator._currentExpr += '2.71828182846'; Calculator._updateDisplay(); break; case 'negate': handleNegate(); break; } } /** * Vorzeichen-Wechsel: letzten numerischen Wert negieren */ function handleNegate() { const expr = Calculator._currentExpr; if (!expr && Calculator._lastResult) { // Ergebnis negieren const num = parseFloat(Calculator._lastResult); if (!isNaN(num)) { Calculator._currentExpr = String(-num); Calculator._lastResult = ''; Calculator._updateDisplay(); } return; } // Letzte Zahl in der Expression finden und negieren const match = expr.match(/(-?\d+\.?\d*)$/); if (match) { const num = parseFloat(match[1]); const negated = String(-num); Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated; Calculator._updateDisplay(); } } /** * Formel-Helfer UI rendern * @param {HTMLElement} container */ function renderFormulaHelper(container) { const wrapper = document.createElement('div'); wrapper.className = 'calc-formula-helper'; const label = document.createElement('div'); label.className = 'calc-formula-label'; label.textContent = t('calculator.sci.formulas'); const select = document.createElement('select'); select.className = 'calc-formula-select'; // Leer-Option const emptyOpt = document.createElement('option'); emptyOpt.value = ''; emptyOpt.textContent = t('calculator.sci.select_formula'); select.appendChild(emptyOpt); FORMULAS.forEach((f, i) => { const opt = document.createElement('option'); opt.value = String(i); opt.textContent = t('calculator.sci.formula.' + f.key); select.appendChild(opt); }); const inputsContainer = document.createElement('div'); inputsContainer.className = 'calc-formula-inputs'; const resultContainer = document.createElement('div'); resultContainer.className = 'calc-formula-result'; select.addEventListener('change', () => { // Inputs leeren while (inputsContainer.firstChild) { inputsContainer.removeChild(inputsContainer.firstChild); } resultContainer.textContent = ''; const idx = parseInt(select.value, 10); if (isNaN(idx)) return; const formula = FORMULAS[idx]; renderFormulaInputs(formula, inputsContainer, resultContainer); }); wrapper.append(label, select, inputsContainer, resultContainer); container.appendChild(wrapper); } /** * Formel-Eingabefelder und Live-Ergebnis rendern * @param {Object} formula * @param {HTMLElement} inputsEl * @param {HTMLElement} resultEl */ function renderFormulaInputs(formula, inputsEl, resultEl) { const inputs = {}; formula.fields.forEach(field => { const row = document.createElement('div'); row.className = 'calc-formula-row'; const lbl = document.createElement('label'); lbl.textContent = t('calculator.sci.field.' + field.key); const inp = document.createElement('input'); inp.type = 'number'; inp.className = 'calc-formula-input'; inp.placeholder = '0'; inp.step = 'any'; inputs[field.key] = inp; inp.addEventListener('input', () => { recalcFormula(formula, inputs, resultEl); }); row.append(lbl, inp); inputsEl.appendChild(row); }); } /** * Formel-Ergebnis live berechnen * @param {Object} formula * @param {Object} inputs - { key: HTMLInputElement } * @param {HTMLElement} resultEl */ function recalcFormula(formula, inputs, resultEl) { const vals = {}; let allValid = true; for (const field of formula.fields) { const v = parseFloat(inputs[field.key].value); if (isNaN(v)) { allValid = false; break; } vals[field.key] = v; } if (!allValid) { resultEl.textContent = ''; return; } const result = formula.calc(vals); if (result === null || !isFinite(result)) { resultEl.textContent = t('calculator.error'); return; } resultEl.textContent = '= ' + Calculator._formatResult(result); } /** * Keyboard-Erweiterung für Scientific-Modus * @param {HTMLElement} widgetEl */ function bindSciKeyboard(widgetEl) { _keyboardExtHandler = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.target.contentEditable === 'true') return; if (e.key === 'p') { handleSciKey('pi'); e.preventDefault(); e.stopPropagation(); } else if (e.key === '^') { handleSciKey('power'); e.preventDefault(); e.stopPropagation(); } }; widgetEl.addEventListener('keydown', _keyboardExtHandler); } // Modus registrieren Calculator.registerMode('scientific', { label: '📐', shortName: 'Sci', titleKey: 'calculator.tab.scientific', render(bodyEl) { bodyEl.style.padding = '8px'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.flex = '1'; bodyEl.style.overflow = 'hidden'; // Display (gemeinsam mit Standard) const display = document.createElement('div'); display.className = 'calc-display'; const exprEl = document.createElement('div'); exprEl.className = 'calc-expression'; Calculator._displayExprEl = exprEl; const resultEl = document.createElement('div'); resultEl.className = 'calc-result'; resultEl.textContent = Calculator._lastResult || '0'; Calculator._displayResultEl = resultEl; display.append(exprEl, resultEl); // Scientific-Buttons (2x3 Grid) const sciSection = document.createElement('div'); renderSciButtons(sciSection); // Standard-Buttons (4x5 Grid) const stdButtons = Calculator._createButtons(); // History const historyEl = Calculator._createHistoryPanel(); // Formel-Helfer const formulaSection = document.createElement('div'); renderFormulaHelper(formulaSection); bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection); Calculator._updateDisplay(); // Keyboard erweitern const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID); if (entry) bindSciKeyboard(entry.el); }, destroy() { // Keyboard-Extension entfernen if (_keyboardExtHandler) { const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID); if (entry) { entry.el.removeEventListener('keydown', _keyboardExtHandler); } _keyboardExtHandler = null; } Calculator._displayExprEl = null; Calculator._displayResultEl = null; } }); })(); ``` - [ ] **Step 2: Script-Tag in newtab.html einfügen** Nach Zeile 509 (``): ```html ``` - [ ] **Step 3: CSS für Scientific-Modus** In `src/css/main.css`, nach den Tab-System Styles: ```css /* Calculator Scientific Mode */ .calc-sci-buttons { grid-template-columns: repeat(3, 1fr); margin-bottom: 4px; } .calc-formula-helper { border-top: 1px solid var(--border); padding-top: 8px; margin-top: 4px; } .calc-formula-label { font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; } .calc-formula-select { width: 100%; padding: 4px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 12px; font-family: 'Rajdhani', sans-serif; margin-bottom: 6px; } .calc-formula-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .calc-formula-row label { font-size: 11px; color: var(--text-secondary); min-width: 50px; } .calc-formula-input { flex: 1; padding: 4px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 12px; font-family: 'Rajdhani', sans-serif; } .calc-formula-result { font-size: 14px; color: var(--accent); font-weight: 600; font-family: 'Rajdhani', monospace; text-align: right; min-height: 20px; padding: 2px 0; } ``` - [ ] **Step 4: i18n-Keys für Scientific** In `src/js/i18n.js`, DE-Block nach `'calculator.tab.standard'`: ```javascript 'calculator.tab.scientific': 'Wissenschaftlich', 'calculator.sci.formulas': 'Formel-Helfer', 'calculator.sci.select_formula': 'Formel wählen…', 'calculator.sci.formula.circle_area': 'Kreisfläche (π×r²)', 'calculator.sci.formula.circle_circumference':'Kreisumfang (2πr)', 'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F', 'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C', 'calculator.sci.formula.pythagoras': 'Pythagoras (√(a²+b²))', 'calculator.sci.formula.percentage': 'Prozentwert', 'calculator.sci.field.radius': 'Radius', 'calculator.sci.field.temp': 'Temperatur', 'calculator.sci.field.a': 'Seite a', 'calculator.sci.field.b': 'Seite b', 'calculator.sci.field.value': 'Wert', 'calculator.sci.field.percent': 'Prozent', ``` EN-Block nach `'calculator.tab.standard'`: ```javascript 'calculator.tab.scientific': 'Scientific', 'calculator.sci.formulas': 'Formula Helper', 'calculator.sci.select_formula': 'Choose formula…', 'calculator.sci.formula.circle_area': 'Circle Area (π×r²)', 'calculator.sci.formula.circle_circumference':'Circle Circumference (2πr)', 'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F', 'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C', 'calculator.sci.formula.pythagoras': 'Pythagoras (√(a²+b²))', 'calculator.sci.formula.percentage': 'Percentage', 'calculator.sci.field.radius': 'Radius', 'calculator.sci.field.temp': 'Temperature', 'calculator.sci.field.a': 'Side a', 'calculator.sci.field.b': 'Side b', 'calculator.sci.field.value': 'Value', 'calculator.sci.field.percent': 'Percent', ``` - [ ] **Step 5: Testen** 1. Calculator öffnen → 2 Tabs sichtbar (Std, Sci) 2. Sci-Tab klicken → 6 Scientific-Buttons + Standard-Grid + Formel-Helfer 3. √ klicken → `sqrt(` im Display, 9 eingeben, `)` eingeben, = → 3 4. π klicken → 3.14159265359 im Display 5. Formel-Dropdown "Kreisfläche" wählen → Radius-Feld, r=5 → = 78.5398... 6. `p`-Taste → Pi einfügen 7. `^`-Taste → Potenz-Operator - [ ] **Step 6: Commit** ```bash git add src/js/calc-scientific.js newtab.html src/css/main.css src/js/i18n.js git commit -m "feat(calculator): Scientific-Modus mit Formel-Helfer" ``` --- ### Task 4: Unit-Converter **Files:** - Create: `src/js/calc-converter.js` - Modify: `newtab.html` (Script-Tag nach calc-scientific.js) - Modify: `src/js/i18n.js` (neue Keys) - Modify: `src/css/main.css` (Converter-spezifische Styles) **Kontext:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit toBase/fromBase Pattern. Temperatur als Spezialfall. Live-Update bei Eingabe, Swap-Button, Schnellreferenz. - [ ] **Step 1: calc-converter.js erstellen** ```javascript /* ============================================= HELLION NEWTAB — calc-converter.js Unit-Converter Modus für Calculator Widget ============================================= */ (function() { 'use strict'; // ---- UNIT DEFINITIONS ---- const CATEGORIES = { length: { titleKey: 'calculator.conv.cat.length', baseUnit: 'm', units: { mm: { toBase: v => v / 1000, fromBase: v => v * 1000 }, cm: { toBase: v => v / 100, fromBase: v => v * 100 }, m: { toBase: v => v, fromBase: v => v }, km: { toBase: v => v * 1000, fromBase: v => v / 1000 }, in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 }, ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 }, yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 }, mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 } } }, weight: { titleKey: 'calculator.conv.cat.weight', baseUnit: 'g', units: { mg: { toBase: v => v / 1000, fromBase: v => v * 1000 }, g: { toBase: v => v, fromBase: v => v }, kg: { toBase: v => v * 1000, fromBase: v => v / 1000 }, t: { toBase: v => v * 1000000, fromBase: v => v / 1000000 }, oz: { toBase: v => v * 28.3495, fromBase: v => v / 28.3495 }, lb: { toBase: v => v * 453.592, fromBase: v => v / 453.592 } } }, temperature: { titleKey: 'calculator.conv.cat.temperature', baseUnit: null, // Spezialfall units: { '\u00B0C': null, '\u00B0F': null, 'K': null }, convert(value, from, to) { if (from === to) return value; const key = from + '_' + to; const conversions = { '\u00B0C_\u00B0F': v => (v * 9 / 5) + 32, '\u00B0C_K': v => v + 273.15, '\u00B0F_\u00B0C': v => (v - 32) * 5 / 9, '\u00B0F_K': v => (v - 32) * 5 / 9 + 273.15, 'K_\u00B0C': v => v - 273.15, 'K_\u00B0F': v => (v - 273.15) * 9 / 5 + 32 }; const fn = conversions[key]; return fn ? fn(value) : null; } }, volume: { titleKey: 'calculator.conv.cat.volume', baseUnit: 'ml', units: { ml: { toBase: v => v, fromBase: v => v }, L: { toBase: v => v * 1000, fromBase: v => v / 1000 }, 'm\u00B3':{ toBase: v => v * 1000000, fromBase: v => v / 1000000 }, 'gal(US)':{ toBase: v => v * 3785.41, fromBase: v => v / 3785.41 }, 'gal(UK)':{ toBase: v => v * 4546.09, fromBase: v => v / 4546.09 }, 'ft\u00B3':{ toBase: v => v * 28316.8, fromBase: v => v / 28316.8 } } }, speed: { titleKey: 'calculator.conv.cat.speed', baseUnit: 'm/s', units: { 'm/s': { toBase: v => v, fromBase: v => v }, 'km/h': { toBase: v => v / 3.6, fromBase: v => v * 3.6 }, 'mph': { toBase: v => v * 0.44704, fromBase: v => v / 0.44704 }, 'kn': { toBase: v => v * 0.514444, fromBase: v => v / 0.514444 } } }, area: { titleKey: 'calculator.conv.cat.area', baseUnit: 'm\u00B2', units: { 'mm\u00B2': { toBase: v => v / 1000000, fromBase: v => v * 1000000 }, 'cm\u00B2': { toBase: v => v / 10000, fromBase: v => v * 10000 }, 'm\u00B2': { toBase: v => v, fromBase: v => v }, 'km\u00B2': { toBase: v => v * 1000000, fromBase: v => v / 1000000 }, 'ha': { toBase: v => v * 10000, fromBase: v => v / 10000 }, 'acre': { toBase: v => v * 4046.86, fromBase: v => v / 4046.86 }, 'ft\u00B2': { toBase: v => v * 0.092903, fromBase: v => v / 0.092903 }, 'in\u00B2': { toBase: v => v * 0.00064516, fromBase: v => v / 0.00064516 } } } }; const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area']; // ---- STATE ---- let _currentCategory = 'length'; let _fromUnit = 'cm'; let _toUnit = 'in'; let _fromInput = null; let _toInput = null; let _refEl = null; // ---- CONVERSION ---- /** * Wert konvertieren * @param {number} value * @param {string} from - Quelleinheit * @param {string} to - Zieleinheit * @returns {number|null} */ function convert(value, from, to) { const cat = CATEGORIES[_currentCategory]; if (!cat) return null; if (cat.convert) { return cat.convert(value, from, to); } const fromDef = cat.units[from]; const toDef = cat.units[to]; if (!fromDef || !toDef) return null; const base = fromDef.toBase(value); return toDef.fromBase(base); } /** * Live-Berechnung auslösen */ function recalc() { if (!_fromInput || !_toInput) return; const val = parseFloat(_fromInput.value); if (isNaN(val)) { _toInput.value = ''; updateReference(); return; } const result = convert(val, _fromUnit, _toUnit); if (result === null) { _toInput.value = ''; } else { _toInput.value = Calculator._formatResult(result); } updateReference(); } /** * Schnellreferenz aktualisieren */ function updateReference() { if (!_refEl) return; _refEl.textContent = ''; const r1 = convert(1, _fromUnit, _toUnit); const r2 = convert(1, _toUnit, _fromUnit); if (r1 !== null) { const line1 = document.createElement('div'); line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit; _refEl.appendChild(line1); } if (r2 !== null) { const line2 = document.createElement('div'); line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit; _refEl.appendChild(line2); } } /** * Unit-Selects für aktuelle Kategorie befüllen * @param {HTMLSelectElement} selectEl * @param {string} selectedUnit */ function populateUnitSelect(selectEl, selectedUnit) { while (selectEl.firstChild) { selectEl.removeChild(selectEl.firstChild); } const cat = CATEGORIES[_currentCategory]; if (!cat) return; const units = Object.keys(cat.units); units.forEach(unit => { const opt = document.createElement('option'); opt.value = unit; opt.textContent = unit; if (unit === selectedUnit) opt.selected = true; selectEl.appendChild(opt); }); } /** * Kategorie-spezifische Defaults für Units * @param {string} catKey * @returns {{ from: string, to: string }} */ function getDefaultUnits(catKey) { const defaults = { length: { from: 'cm', to: 'in' }, weight: { from: 'kg', to: 'lb' }, temperature: { from: '\u00B0C', to: '\u00B0F' }, volume: { from: 'L', to: 'gal(US)' }, speed: { from: 'km/h', to: 'mph' }, area: { from: 'm\u00B2', to: 'ft\u00B2' } }; return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] }; } /** * State aus Storage laden */ async function loadState() { const data = await Store.get(Calculator.STORAGE_KEY); if (data && data.calculator && data.calculator.converter) { const s = data.calculator.converter; if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory; if (s.fromUnit) _fromUnit = s.fromUnit; if (s.toUnit) _toUnit = s.toUnit; } } /** * State in Storage speichern */ async function saveState() { const data = await Store.get(Calculator.STORAGE_KEY) || {}; if (!data.calculator) data.calculator = {}; data.calculator.converter = { lastCategory: _currentCategory, fromUnit: _fromUnit, toUnit: _toUnit }; await Store.set(Calculator.STORAGE_KEY, data); } // ---- REGISTRATION ---- Calculator.registerMode('converter', { label: '⚖️', shortName: 'Unit', titleKey: 'calculator.tab.converter', render(bodyEl) { bodyEl.style.padding = '8px'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.gap = '8px'; loadState().then(() => { buildUI(bodyEl); }); }, destroy() { _fromInput = null; _toInput = null; _refEl = null; saveState(); } }); /** * Converter-UI aufbauen * @param {HTMLElement} bodyEl */ function buildUI(bodyEl) { // Kategorie-Dropdown const catSelect = document.createElement('select'); catSelect.className = 'calc-conv-select'; CATEGORY_ORDER.forEach(catKey => { const opt = document.createElement('option'); opt.value = catKey; opt.textContent = t(CATEGORIES[catKey].titleKey); if (catKey === _currentCategory) opt.selected = true; catSelect.appendChild(opt); }); // From-Zeile const fromRow = document.createElement('div'); fromRow.className = 'calc-conv-row'; _fromInput = document.createElement('input'); _fromInput.type = 'number'; _fromInput.className = 'calc-conv-input'; _fromInput.placeholder = '0'; _fromInput.step = 'any'; const fromSelect = document.createElement('select'); fromSelect.className = 'calc-conv-unit'; populateUnitSelect(fromSelect, _fromUnit); fromRow.append(_fromInput, fromSelect); // Swap-Button const swapBtn = document.createElement('button'); swapBtn.type = 'button'; swapBtn.className = 'calc-conv-swap'; swapBtn.textContent = '\u21C5'; swapBtn.title = t('calculator.conv.swap'); // To-Zeile const toRow = document.createElement('div'); toRow.className = 'calc-conv-row'; _toInput = document.createElement('input'); _toInput.type = 'text'; _toInput.className = 'calc-conv-input'; _toInput.readOnly = true; _toInput.placeholder = '0'; const toSelect = document.createElement('select'); toSelect.className = 'calc-conv-unit'; populateUnitSelect(toSelect, _toUnit); toRow.append(_toInput, toSelect); // Schnellreferenz _refEl = document.createElement('div'); _refEl.className = 'calc-conv-ref'; // Event Listeners _fromInput.addEventListener('input', () => recalc()); fromSelect.addEventListener('change', () => { _fromUnit = fromSelect.value; recalc(); saveState(); }); toSelect.addEventListener('change', () => { _toUnit = toSelect.value; recalc(); saveState(); }); swapBtn.addEventListener('click', () => { const tmpUnit = _fromUnit; _fromUnit = _toUnit; _toUnit = tmpUnit; populateUnitSelect(fromSelect, _fromUnit); populateUnitSelect(toSelect, _toUnit); // Swap-Wert übernehmen const currentVal = _toInput.value; if (currentVal) { _fromInput.value = currentVal; } recalc(); saveState(); }); catSelect.addEventListener('change', () => { _currentCategory = catSelect.value; const defaults = getDefaultUnits(_currentCategory); _fromUnit = defaults.from; _toUnit = defaults.to; populateUnitSelect(fromSelect, _fromUnit); populateUnitSelect(toSelect, _toUnit); _fromInput.value = ''; _toInput.value = ''; updateReference(); saveState(); }); bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl); // Initiale Referenz anzeigen updateReference(); } })(); ``` - [ ] **Step 2: Script-Tag in newtab.html einfügen** Nach dem `calc-scientific.js` Script-Tag: ```html ``` - [ ] **Step 3: CSS für Converter** In `src/css/main.css`, nach den Scientific-Styles: ```css /* Calculator Converter Mode */ .calc-conv-select { width: 100%; padding: 6px 8px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 13px; font-family: 'Rajdhani', sans-serif; } .calc-conv-row { display: flex; gap: 6px; } .calc-conv-input { flex: 1; padding: 8px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 16px; font-family: 'Rajdhani', monospace; } .calc-conv-input:read-only { color: var(--accent); font-weight: 600; } .calc-conv-unit { width: 80px; padding: 4px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 12px; font-family: 'Rajdhani', sans-serif; } .calc-conv-swap { align-self: center; width: 36px; height: 28px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--accent); font-size: 16px; cursor: pointer; transition: all 0.15s; } .calc-conv-swap:hover { background: var(--accent-dim); } .calc-conv-ref { font-size: 11px; color: var(--text-muted); padding: 4px 0; border-top: 1px solid var(--border); } ``` - [ ] **Step 4: i18n-Keys für Converter** DE-Block: ```javascript 'calculator.tab.converter': 'Umrechner', 'calculator.conv.swap': 'Einheiten tauschen', 'calculator.conv.cat.length': 'Länge', 'calculator.conv.cat.weight': 'Gewicht', 'calculator.conv.cat.temperature': 'Temperatur', 'calculator.conv.cat.volume': 'Volumen', 'calculator.conv.cat.speed': 'Geschwindigkeit', 'calculator.conv.cat.area': 'Fläche', ``` EN-Block: ```javascript 'calculator.tab.converter': 'Converter', 'calculator.conv.swap': 'Swap units', 'calculator.conv.cat.length': 'Length', 'calculator.conv.cat.weight': 'Weight', 'calculator.conv.cat.temperature': 'Temperature', 'calculator.conv.cat.volume': 'Volume', 'calculator.conv.cat.speed': 'Speed', 'calculator.conv.cat.area': 'Area', ``` - [ ] **Step 5: Testen** 1. Calculator öffnen → 3 Tabs sichtbar (Std, Sci, Unit) 2. Unit-Tab klicken → Kategorie-Dropdown, Eingabe-Feld, Ergebnis 3. 100 cm eingeben → 39.3701 in 4. Swap-Button → cm und in tauschen, Wert übernehmen 5. Kategorie zu Temperatur wechseln → °C und °F als Units 6. 100 °C → 212 °F 7. 0 °F → -17.7778 °C - [ ] **Step 6: Commit** ```bash git add src/js/calc-converter.js newtab.html src/css/main.css src/js/i18n.js git commit -m "feat(calculator): Unit-Converter mit 6 Kategorien" ``` --- ### Task 5: Satisfactory Calculator **Files:** - Create: `src/js/calc-satisfactory.js` - Modify: `newtab.html` (Script-Tag nach calc-converter.js) - Modify: `src/js/i18n.js` (neue Keys) - Modify: `src/css/main.css` (Game-Calculator shared Styles) **Kontext:** 3 Sub-Modi: Items/Min, Overclock Power, Machines. Power-Exponent: 1.321928 (Update 8 Wert). Shared CSS-Klassen `.calc-game-*` werden hier definiert und von Factorio/Stationeers wiederverwendet. - [ ] **Step 1: calc-satisfactory.js erstellen** ```javascript /* ============================================= HELLION NEWTAB — calc-satisfactory.js Satisfactory Calculator Modus ============================================= */ (function() { 'use strict'; const POWER_EXPONENT = 1.321928; const SUB_MODES = ['itemsPerMin', 'power', 'machines']; let _activeSubMode = 'itemsPerMin'; // ---- SHARED HELPERS ---- /** * Sub-Tab-Leiste erstellen * @param {HTMLElement} container * @param {Array} modes * @param {string} activeMode * @param {string} i18nPrefix * @param {Function} onSwitch */ function createSubTabs(container, modes, activeMode, i18nPrefix, onSwitch) { const bar = document.createElement('div'); bar.className = 'calc-game-subtabs'; modes.forEach(mode => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'calc-game-subtab' + (mode === activeMode ? ' active' : ''); btn.textContent = t(i18nPrefix + mode); btn.dataset.mode = mode; btn.addEventListener('click', () => { bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); onSwitch(mode); }); bar.appendChild(btn); }); container.appendChild(bar); } /** * Eingabefeld mit Label erstellen * @param {string} labelKey - i18n-Key * @param {number} defaultVal * @param {Object} opts - { step, min, max } * @returns {{ row: HTMLElement, input: HTMLInputElement }} */ function createField(labelKey, defaultVal, opts) { opts = opts || {}; const row = document.createElement('div'); row.className = 'calc-game-field'; const label = document.createElement('label'); label.textContent = t(labelKey); const input = document.createElement('input'); input.type = 'number'; input.className = 'calc-game-input'; input.value = defaultVal; if (opts.step) input.step = opts.step; if (opts.min !== undefined) input.min = opts.min; if (opts.max !== undefined) input.max = opts.max; row.append(label, input); return { row, input }; } /** * Ergebnis-Zeile erstellen * @param {string} labelKey * @returns {{ row: HTMLElement, value: HTMLElement }} */ function createOutput(labelKey) { const row = document.createElement('div'); row.className = 'calc-game-output'; const label = document.createElement('span'); label.textContent = t(labelKey); const value = document.createElement('span'); value.className = 'calc-game-value'; row.append(label, value); return { row, value }; } // ---- SUB-MODE RENDERERS ---- function renderItemsPerMin(container) { const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 }); const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 }); const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 }); const output = createOutput('calculator.sat.output_per_min'); function calc() { const items = parseFloat(itemsField.input.value) || 0; const time = parseFloat(timeField.input.value) || 1; const clock = parseFloat(clockField.input.value) || 100; const result = (items * 60) / time * (clock / 100); output.value.textContent = Calculator._formatResult(result) + ' items/min'; } [itemsField, timeField, clockField].forEach(f => { f.input.addEventListener('input', calc); }); container.append(itemsField.row, timeField.row, clockField.row, output.row); calc(); } function renderPower(container) { const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 }); const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 }); const powerOutput = createOutput('calculator.sat.power_usage'); const effOutput = createOutput('calculator.sat.efficiency'); function calc() { const basePower = parseFloat(basePowerField.input.value) || 0; const clock = parseFloat(clockField.input.value) || 100; const ratio = clock / 100; const power = basePower * Math.pow(ratio, POWER_EXPONENT); const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1); powerOutput.value.textContent = Calculator._formatResult(power) + ' MW'; if (clock > 100) { const overhead = (effPerItem - 1) * 100; effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item'); effOutput.row.style.display = ''; } else { effOutput.row.style.display = 'none'; } } [basePowerField, clockField].forEach(f => { f.input.addEventListener('input', calc); }); container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row); calc(); } function renderMachines(container) { const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 }); const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 }); const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 }); const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 }); const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 }); const machinesOutput = createOutput('calculator.sat.machines_needed'); const totalPowerOutput = createOutput('calculator.sat.total_power'); function calc() { const target = parseFloat(targetField.input.value) || 0; const items = parseFloat(itemsField.input.value) || 1; const time = parseFloat(timeField.input.value) || 1; const clock = parseFloat(clockField.input.value) || 100; const basePower = parseFloat(basePowerField.input.value) || 0; const ratio = clock / 100; const itemsPerMin = (items * 60) / time * ratio; const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0; const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT); machinesOutput.value.textContent = machines; totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW'; } [targetField, itemsField, timeField, clockField, basePowerField].forEach(f => { f.input.addEventListener('input', calc); }); container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row); calc(); } // ---- STATE ---- async function loadState() { const data = await Store.get(Calculator.STORAGE_KEY); if (data && data.calculator && data.calculator.satisfactory) { const s = data.calculator.satisfactory; if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode; } } async function saveState() { const data = await Store.get(Calculator.STORAGE_KEY) || {}; if (!data.calculator) data.calculator = {}; data.calculator.satisfactory = { lastSubMode: _activeSubMode }; await Store.set(Calculator.STORAGE_KEY, data); } // ---- RENDER ---- function renderSubMode(container) { container.textContent = ''; switch (_activeSubMode) { case 'itemsPerMin': renderItemsPerMin(container); break; case 'power': renderPower(container); break; case 'machines': renderMachines(container); break; } } // ---- REGISTRATION ---- Calculator.registerMode('satisfactory', { label: '⚙️', shortName: 'SAT', titleKey: 'calculator.tab.satisfactory', render(bodyEl) { bodyEl.style.padding = '8px'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.gap = '8px'; loadState().then(() => { const subContent = document.createElement('div'); subContent.className = 'calc-game-content'; createSubTabs(bodyEl, SUB_MODES, _activeSubMode, 'calculator.sat.tab.', (mode) => { _activeSubMode = mode; renderSubMode(subContent); saveState(); }); bodyEl.appendChild(subContent); renderSubMode(subContent); }); }, destroy() { saveState(); } }); })(); ``` - [ ] **Step 2: Script-Tag in newtab.html** Nach dem `calc-converter.js` Script-Tag: ```html ``` - [ ] **Step 3: Shared Game-Calculator CSS** In `src/css/main.css`, nach den Converter-Styles: ```css /* Calculator Game Modes (shared) */ .calc-game-subtabs { display: flex; gap: 4px; margin-bottom: 8px; } .calc-game-subtab { flex: 1; padding: 5px 4px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-muted); font-size: 10px; font-family: 'Rajdhani', sans-serif; cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px; transition: all 0.15s; } .calc-game-subtab:hover { color: var(--text-secondary); } .calc-game-subtab.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); font-weight: 600; } .calc-game-field { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .calc-game-field label { font-size: 11px; color: var(--text-secondary); min-width: 90px; flex-shrink: 0; } .calc-game-input { flex: 1; padding: 5px 8px; background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 13px; font-family: 'Rajdhani', monospace; } .calc-game-output { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); border-radius: var(--radius-sm); margin-top: 4px; } .calc-game-output span:first-child { font-size: 11px; color: var(--text-muted); } .calc-game-value { font-size: 14px; color: var(--accent); font-weight: 600; font-family: 'Rajdhani', monospace; } .calc-game-warning { font-size: 10px; color: var(--danger); padding: 2px 0; } .calc-game-content { display: flex; flex-direction: column; gap: 4px; } ``` - [ ] **Step 4: i18n-Keys für Satisfactory** DE-Block: ```javascript 'calculator.tab.satisfactory': 'Satisfactory', 'calculator.sat.tab.itemsPerMin': 'Items/Min', 'calculator.sat.tab.power': 'Strom', 'calculator.sat.tab.machines': 'Maschinen', 'calculator.sat.items_per_craft': 'Items/Craft', 'calculator.sat.craft_time': 'Craftzeit (s)', 'calculator.sat.clock_speed': 'Taktrate (%)', 'calculator.sat.base_power': 'Grundleistung (MW)', 'calculator.sat.target_output': 'Ziel Output/Min', 'calculator.sat.output_per_min': 'Output', 'calculator.sat.power_usage': 'Stromverbrauch', 'calculator.sat.efficiency': 'Effizienz', 'calculator.sat.per_item': 'pro Item', 'calculator.sat.machines_needed': 'Maschinen benötigt', 'calculator.sat.total_power': 'Gesamtleistung', ``` EN-Block: ```javascript 'calculator.tab.satisfactory': 'Satisfactory', 'calculator.sat.tab.itemsPerMin': 'Items/Min', 'calculator.sat.tab.power': 'Power', 'calculator.sat.tab.machines': 'Machines', 'calculator.sat.items_per_craft': 'Items/Craft', 'calculator.sat.craft_time': 'Craft Time (s)', 'calculator.sat.clock_speed': 'Clock Speed (%)', 'calculator.sat.base_power': 'Base Power (MW)', 'calculator.sat.target_output': 'Target Output/Min', 'calculator.sat.output_per_min': 'Output', 'calculator.sat.power_usage': 'Power Usage', 'calculator.sat.efficiency': 'Efficiency', 'calculator.sat.per_item': 'per item', 'calculator.sat.machines_needed': 'Machines needed', 'calculator.sat.total_power': 'Total Power', ``` - [ ] **Step 5: Testen** 1. SAT-Tab klicken → 3 Sub-Tabs (Items/Min, Strom, Maschinen) 2. Items/Min: 1 Item, 4s Craft, 100% → 15 items/min 3. Power: 30 MW Base, 250% Clock → ~115 MW (wegen Exponent 1.321928) 4. Machines: 60 Target, 1 Item, 4s, 100%, 30 MW → 4 Maschinen, 120 MW - [ ] **Step 6: Commit** ```bash git add src/js/calc-satisfactory.js newtab.html src/css/main.css src/js/i18n.js git commit -m "feat(calculator): Satisfactory Calculator mit Overclock-Power" ``` --- ### Task 6: Factorio Calculator **Files:** - Create: `src/js/calc-factorio.js` - Modify: `newtab.html` (Script-Tag nach calc-satisfactory.js) - Modify: `src/js/i18n.js` (neue Keys) **Kontext:** 3 Sub-Modi: Ratio, Belt, Machines. Assembler-Speeds: Asm1=0.5, Asm2=0.75, Asm3=1.25. Belt-Throughput: Yellow=15/s, Red=30/s, Blue=45/s. Nutzt die shared `.calc-game-*` CSS-Klassen aus Task 5. - [ ] **Step 1: calc-factorio.js erstellen** ```javascript /* ============================================= HELLION NEWTAB — calc-factorio.js Factorio Calculator Modus ============================================= */ (function() { 'use strict'; const ASSEMBLERS = [ { key: 'asm1', speed: 0.5 }, { key: 'asm2', speed: 0.75 }, { key: 'asm3', speed: 1.25 } ]; const BELTS = [ { key: 'yellow', throughput: 15, perSide: 7.5 }, { key: 'red', throughput: 30, perSide: 15 }, { key: 'blue', throughput: 45, perSide: 22.5 } ]; const SUB_MODES = ['ratio', 'belt', 'machines']; let _activeSubMode = 'ratio'; /** * Assembler-Dropdown erstellen * @param {string} selectedKey * @returns {{ row: HTMLElement, select: HTMLSelectElement }} */ function createAssemblerSelect(selectedKey) { const row = document.createElement('div'); row.className = 'calc-game-field'; const label = document.createElement('label'); label.textContent = t('calculator.fac.assembler'); const select = document.createElement('select'); select.className = 'calc-game-input'; ASSEMBLERS.forEach(asm => { const opt = document.createElement('option'); opt.value = asm.key; opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)'; if (asm.key === selectedKey) opt.selected = true; select.appendChild(opt); }); row.append(label, select); return { row, select }; } /** * Belt-Dropdown erstellen * @param {string} selectedKey * @returns {{ row: HTMLElement, select: HTMLSelectElement }} */ function createBeltSelect(selectedKey) { const row = document.createElement('div'); row.className = 'calc-game-field'; const label = document.createElement('label'); label.textContent = t('calculator.fac.belt'); const select = document.createElement('select'); select.className = 'calc-game-input'; BELTS.forEach(belt => { const opt = document.createElement('option'); opt.value = belt.key; opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)'; if (belt.key === selectedKey) opt.selected = true; select.appendChild(opt); }); row.append(label, select); return { row, select }; } function getAssemblerSpeed(key) { const asm = ASSEMBLERS.find(a => a.key === key); return asm ? asm.speed : 1; } function getBelt(key) { return BELTS.find(b => b.key === key) || BELTS[0]; } /** * Findet den kleinsten Belt der den Throughput schafft * @param {number} throughput * @returns {Object|null} */ function findSmallestBelt(throughput) { for (const belt of BELTS) { if (belt.throughput >= throughput) return belt; } return null; } // ---- Field/Output helpers (reuse pattern from Satisfactory) ---- function createField(labelKey, defaultVal, opts) { opts = opts || {}; const row = document.createElement('div'); row.className = 'calc-game-field'; const label = document.createElement('label'); label.textContent = t(labelKey); const input = document.createElement('input'); input.type = 'number'; input.className = 'calc-game-input'; input.value = defaultVal; if (opts.step) input.step = opts.step; if (opts.min !== undefined) input.min = opts.min; row.append(label, input); return { row, input }; } function createOutput(labelKey) { const row = document.createElement('div'); row.className = 'calc-game-output'; const label = document.createElement('span'); label.textContent = t(labelKey); const value = document.createElement('span'); value.className = 'calc-game-value'; row.append(label, value); return { row, value }; } // ---- SUB-MODE RENDERERS ---- function renderRatio(container) { const asmSelect = createAssemblerSelect('asm3'); const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 }); const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 }); const perSecOutput = createOutput('calculator.fac.items_per_sec'); const perMinOutput = createOutput('calculator.fac.items_per_min'); function calc() { const speed = getAssemblerSpeed(asmSelect.select.value); const output = parseFloat(outputField.input.value) || 0; const time = parseFloat(timeField.input.value) || 1; const perSec = output * speed / time; const perMin = perSec * 60; perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s'; perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min'; } [outputField, timeField].forEach(f => f.input.addEventListener('input', calc)); asmSelect.select.addEventListener('change', calc); container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row); calc(); } function renderBelt(container) { const beltSelect = createBeltSelect('yellow'); const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 }); const machinesOutput = createOutput('calculator.fac.machines_per_belt'); const utilOutput = createOutput('calculator.fac.belt_utilization'); function calc() { const belt = getBelt(beltSelect.select.value); const consume = parseFloat(consumeField.input.value) || 1; const machines = Math.floor(belt.throughput / consume); const util = (consume * machines) / belt.throughput * 100; machinesOutput.value.textContent = machines; utilOutput.value.textContent = Calculator._formatResult(util) + '%'; } consumeField.input.addEventListener('input', calc); beltSelect.select.addEventListener('change', calc); container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row); calc(); } function renderMachines(container) { const asmSelect = createAssemblerSelect('asm3'); const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 }); const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 }); const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 }); const machinesOutput = createOutput('calculator.fac.machines_needed'); const beltOutput = createOutput('calculator.fac.belt_needed'); function calc() { const speed = getAssemblerSpeed(asmSelect.select.value); const target = parseFloat(targetField.input.value) || 0; const output = parseFloat(outputField.input.value) || 1; const time = parseFloat(timeField.input.value) || 1; const perMachine = output * speed / time; const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0; const totalThroughput = machines * perMachine; const belt = findSmallestBelt(totalThroughput); machinesOutput.value.textContent = machines; if (belt) { const util = (totalThroughput / belt.throughput) * 100; beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)'; } else { beltOutput.value.textContent = t('calculator.fac.exceeds_belt'); } } [targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc)); asmSelect.select.addEventListener('change', calc); container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row); calc(); } // ---- STATE ---- async function loadState() { const data = await Store.get(Calculator.STORAGE_KEY); if (data && data.calculator && data.calculator.factorio) { const s = data.calculator.factorio; if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode; } } async function saveState() { const data = await Store.get(Calculator.STORAGE_KEY) || {}; if (!data.calculator) data.calculator = {}; data.calculator.factorio = { lastSubMode: _activeSubMode }; await Store.set(Calculator.STORAGE_KEY, data); } function renderSubMode(container) { container.textContent = ''; switch (_activeSubMode) { case 'ratio': renderRatio(container); break; case 'belt': renderBelt(container); break; case 'machines': renderMachines(container); break; } } // ---- REGISTRATION ---- Calculator.registerMode('factorio', { label: '🏭', shortName: 'FAC', titleKey: 'calculator.tab.factorio', render(bodyEl) { bodyEl.style.padding = '8px'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.gap = '8px'; loadState().then(() => { const subContent = document.createElement('div'); subContent.className = 'calc-game-content'; // Sub-Tab helper aus Satisfactory ist nicht verfügbar (IIFE-Scope), // daher lokale Implementierung const bar = document.createElement('div'); bar.className = 'calc-game-subtabs'; SUB_MODES.forEach(mode => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : ''); btn.textContent = t('calculator.fac.tab.' + mode); btn.dataset.mode = mode; btn.addEventListener('click', () => { bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _activeSubMode = mode; renderSubMode(subContent); saveState(); }); bar.appendChild(btn); }); bodyEl.append(bar, subContent); renderSubMode(subContent); }); }, destroy() { saveState(); } }); })(); ``` - [ ] **Step 2: Script-Tag in newtab.html** Nach dem `calc-satisfactory.js` Script-Tag: ```html ``` - [ ] **Step 3: i18n-Keys für Factorio** DE-Block: ```javascript 'calculator.tab.factorio': 'Factorio', 'calculator.fac.tab.ratio': 'Ratio', 'calculator.fac.tab.belt': 'Belt', 'calculator.fac.tab.machines': 'Maschinen', 'calculator.fac.assembler': 'Assembler', 'calculator.fac.asm.asm1': 'Assembler 1', 'calculator.fac.asm.asm2': 'Assembler 2', 'calculator.fac.asm.asm3': 'Assembler 3', 'calculator.fac.belt': 'Belt-Typ', 'calculator.fac.belt.yellow': 'Gelb', 'calculator.fac.belt.red': 'Rot', 'calculator.fac.belt.blue': 'Blau', 'calculator.fac.recipe_output': 'Rezept-Output', 'calculator.fac.recipe_time': 'Rezeptzeit (s)', 'calculator.fac.consume_per_sec': 'Verbrauch/s', 'calculator.fac.target_output_sec': 'Ziel Output/s', 'calculator.fac.items_per_sec': 'Items/s', 'calculator.fac.items_per_min': 'Items/min', 'calculator.fac.machines_per_belt': 'Maschinen/Belt', 'calculator.fac.belt_utilization': 'Belt-Auslastung', 'calculator.fac.machines_needed': 'Maschinen benötigt', 'calculator.fac.belt_needed': 'Belt benötigt', 'calculator.fac.exceeds_belt': 'Übersteigt max. Belt', ``` EN-Block: ```javascript 'calculator.tab.factorio': 'Factorio', 'calculator.fac.tab.ratio': 'Ratio', 'calculator.fac.tab.belt': 'Belt', 'calculator.fac.tab.machines': 'Machines', 'calculator.fac.assembler': 'Assembler', 'calculator.fac.asm.asm1': 'Assembler 1', 'calculator.fac.asm.asm2': 'Assembler 2', 'calculator.fac.asm.asm3': 'Assembler 3', 'calculator.fac.belt': 'Belt Type', 'calculator.fac.belt.yellow': 'Yellow', 'calculator.fac.belt.red': 'Red', 'calculator.fac.belt.blue': 'Blue', 'calculator.fac.recipe_output': 'Recipe Output', 'calculator.fac.recipe_time': 'Recipe Time (s)', 'calculator.fac.consume_per_sec': 'Consume/s', 'calculator.fac.target_output_sec': 'Target Output/s', 'calculator.fac.items_per_sec': 'Items/s', 'calculator.fac.items_per_min': 'Items/min', 'calculator.fac.machines_per_belt': 'Machines/Belt', 'calculator.fac.belt_utilization': 'Belt Utilization', 'calculator.fac.machines_needed': 'Machines needed', 'calculator.fac.belt_needed': 'Belt needed', 'calculator.fac.exceeds_belt': 'Exceeds max belt', ``` - [ ] **Step 4: Testen** 1. FAC-Tab klicken → 3 Sub-Tabs (Ratio, Belt, Maschinen) 2. Ratio: Asm3 (1.25x), 1 Output, 1s → 1.25/s, 75/min 3. Belt: Yellow (15/s), 1/s consume → 15 Machines, 100% 4. Machines: Asm3, 10/s target, 1 output, 1s → 8 machines, Blue (22.2%) - [ ] **Step 5: Commit** ```bash git add src/js/calc-factorio.js newtab.html src/js/i18n.js git commit -m "feat(calculator): Factorio Calculator mit Assembler-Ratios" ``` --- ### Task 7: Stationeers Calculator **Files:** - Create: `src/js/calc-stationeers.js` - Modify: `newtab.html` (Script-Tag nach calc-factorio.js) - Modify: `src/js/i18n.js` (neue Keys) - Modify: `src/css/main.css` (Stationeers-spezifische Styles) **Kontext:** 4 Sub-Modi: Gas (PV=nRT), Furnace, Solar/Battery, Atmosphere. R=8314.46261815324, Combustion Energy=563452 J/mol. Komplexester Modus. Nutzt shared `.calc-game-*` CSS. - [ ] **Step 1: calc-stationeers.js erstellen** ```javascript /* ============================================= HELLION NEWTAB — calc-stationeers.js Stationeers Calculator Modus ============================================= */ (function() { 'use strict'; // ---- CONSTANTS ---- const R = 8314.46261815324; // L·Pa / (mol·K) const COMBUSTION_ENERGY = 563452; // J/mol bei 95% Effizienz const HEAT_CAP_PURE_FUEL = 61.9; // J/(mol·K) für 1:2 O₂:H₂ const HEAT_CAP_DELTA = 172.615; // 0.95 × (243.6 − 61.9) const BATTERY_CAPACITY = 50000; // Ws (Station Battery) const HEAT_CAPS = [ { gas: 'O\u2082', cp: 21.1 }, { gas: 'H\u2082', cp: 20.4 }, { gas: 'CO\u2082', cp: 28.2 }, { gas: 'N\u2082', cp: 20.6 }, { gas: 'H\u2082O', cp: 72.0 }, { gas: 'N\u2082O', cp: 23.0 }, { gas: 'Pollutant', cp: 24.8 } ]; const GAS_VARS = ['P', 'V', 'n', 'T']; const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo']; let _activeSubMode = 'gas'; // ---- Field/Output helpers ---- function createField(labelKey, defaultVal, opts) { opts = opts || {}; const row = document.createElement('div'); row.className = 'calc-game-field'; const label = document.createElement('label'); label.textContent = t(labelKey); const input = document.createElement('input'); input.type = 'number'; input.className = 'calc-game-input'; input.value = defaultVal; if (opts.step) input.step = opts.step; if (opts.min !== undefined) input.min = opts.min; if (opts.max !== undefined) input.max = opts.max; if (opts.disabled) input.disabled = true; row.append(label, input); return { row, input }; } function createOutput(labelKey) { const row = document.createElement('div'); row.className = 'calc-game-output'; const label = document.createElement('span'); label.textContent = t(labelKey); const value = document.createElement('span'); value.className = 'calc-game-value'; row.append(label, value); return { row, value }; } // ---- GAS (PV=nRT) ---- function renderGas(container) { // Solve-for Dropdown const solveRow = document.createElement('div'); solveRow.className = 'calc-game-field'; const solveLabel = document.createElement('label'); solveLabel.textContent = t('calculator.sta.solve_for'); const solveSelect = document.createElement('select'); solveSelect.className = 'calc-game-input'; GAS_VARS.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = t('calculator.sta.var.' + v); solveSelect.appendChild(opt); }); solveRow.append(solveLabel, solveSelect); container.appendChild(solveRow); // Eingabefelder für alle 4 Variablen const fields = {}; const units = { P: 'kPa', V: 'L', n: 'mol', T: 'K' }; const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 }; GAS_VARS.forEach(v => { const f = createField( 'calculator.sta.var.' + v + '_label', defaults[v], { step: 'any' } ); fields[v] = f; container.appendChild(f.row); }); // Hilfstext für Temperatur const tempHelper = document.createElement('div'); tempHelper.className = 'calc-game-hint'; container.appendChild(tempHelper); const resultOutput = createOutput('calculator.sta.result'); container.appendChild(resultOutput.row); function calc() { const solveFor = solveSelect.value; // Felder aktivieren/deaktivieren GAS_VARS.forEach(v => { fields[v].input.disabled = (v === solveFor); fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1'; }); const P_kPa = parseFloat(fields.P.input.value) || 0; const P = P_kPa * 1000; // kPa → Pa const V = parseFloat(fields.V.input.value) || 0; const n = parseFloat(fields.n.input.value) || 0; const T = parseFloat(fields.T.input.value) || 0; let result = null; let unit = ''; switch (solveFor) { case 'P': if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; } break; case 'V': if (P > 0) { result = (n * R * T) / P; unit = 'L'; } break; case 'n': if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; } break; case 'T': if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; } break; } if (result !== null && isFinite(result)) { fields[solveFor].input.value = Calculator._formatResult(result); resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit; } else { resultOutput.value.textContent = '-'; } // Temperatur-Hilfstext const tempVal = parseFloat(fields.T.input.value) || 0; tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C'; } // Events GAS_VARS.forEach(v => { fields[v].input.addEventListener('input', calc); }); solveSelect.addEventListener('change', calc); calc(); } // ---- FURNACE ---- function renderFurnace(container) { const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 }); const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 }); const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 }); const tempOutput = createOutput('calculator.sta.temp_after'); const pressOutput = createOutput('calculator.sta.pressure_after'); const warningEl = document.createElement('div'); warningEl.className = 'calc-game-warning'; function calc() { const fuel = parseFloat(fuelField.input.value) || 0; const T_vor = parseFloat(tempField.input.value) || 293.15; const P_vor = parseFloat(pressField.input.value) || 101.325; warningEl.textContent = ''; if (fuel < 0.05) { warningEl.textContent = t('calculator.sta.warn_low_fuel'); } if (P_vor < 10) { warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure'); } const specificHeat = HEAT_CAP_PURE_FUEL; const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA); const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor; tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)'; pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa'; } [fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc)); container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row); calc(); } // ---- SOLAR / BATTERY ---- function renderSolar(container) { const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 }); const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 }); const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 }); const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 }); const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 }); const genOutput = createOutput('calculator.sta.generation'); const surplusOutput = createOutput('calculator.sta.surplus'); const nightOutput = createOutput('calculator.sta.night_energy'); const battOutput = createOutput('calculator.sta.batteries_needed'); function calc() { const panels = parseFloat(panelField.input.value) || 0; const wpp = parseFloat(wattField.input.value) || 0; const nightLen = parseFloat(nightField.input.value) || 0; const consume = parseFloat(consumeField.input.value) || 0; const generation = panels * wpp; const surplus = generation - consume; const nightEnergy = consume * nightLen; const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0; genOutput.value.textContent = Calculator._formatResult(generation) + ' W'; surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W'; if (surplus < 0) { surplusOutput.value.style.color = 'var(--danger)'; } else { surplusOutput.value.style.color = ''; } nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws'; battOutput.value.textContent = batteries; } [panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc)); container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row, genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row); calc(); } // ---- ATMOSPHERE / MIXER ---- function renderAtmo(container) { const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 }); const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 }); const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 }); const m1Output = createOutput('calculator.sta.mixer_input1'); const m2Output = createOutput('calculator.sta.mixer_input2'); function calc() { const T0 = parseFloat(targetField.input.value) || 0; const T1 = parseFloat(gas1Field.input.value) || 0; const T2 = parseFloat(gas2Field.input.value) || 0; const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0); if (denom === 0) { m1Output.value.textContent = '50%'; m2Output.value.textContent = '50%'; return; } const M1 = Math.abs(T2 - T0) / denom; const M2 = 1 - M1; m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%'; m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%'; } [targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc)); container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row); calc(); // Aufklappbare Wärmekapazität-Referenz const details = document.createElement('details'); details.className = 'calc-game-details'; const summary = document.createElement('summary'); summary.textContent = t('calculator.sta.heat_cap_ref'); details.appendChild(summary); const table = document.createElement('table'); table.className = 'calc-game-table'; const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); const thGas = document.createElement('th'); thGas.textContent = t('calculator.sta.gas'); const thCp = document.createElement('th'); thCp.textContent = 'Cp (J/mol\u00B7K)'; headerRow.append(thGas, thCp); thead.appendChild(headerRow); const tbody = document.createElement('tbody'); HEAT_CAPS.forEach(entry => { const tr = document.createElement('tr'); const tdGas = document.createElement('td'); tdGas.textContent = entry.gas; const tdCp = document.createElement('td'); tdCp.textContent = entry.cp; tr.append(tdGas, tdCp); tbody.appendChild(tr); }); table.append(thead, tbody); details.appendChild(table); container.appendChild(details); } // ---- STATE ---- async function loadState() { const data = await Store.get(Calculator.STORAGE_KEY); if (data && data.calculator && data.calculator.stationeers) { const s = data.calculator.stationeers; if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode; } } async function saveState() { const data = await Store.get(Calculator.STORAGE_KEY) || {}; if (!data.calculator) data.calculator = {}; data.calculator.stationeers = { lastSubMode: _activeSubMode }; await Store.set(Calculator.STORAGE_KEY, data); } function renderSubMode(container) { container.textContent = ''; switch (_activeSubMode) { case 'gas': renderGas(container); break; case 'furnace': renderFurnace(container); break; case 'solar': renderSolar(container); break; case 'atmo': renderAtmo(container); break; } } // ---- REGISTRATION ---- Calculator.registerMode('stationeers', { label: '🚀', shortName: 'STA', titleKey: 'calculator.tab.stationeers', render(bodyEl) { bodyEl.style.padding = '8px'; bodyEl.style.display = 'flex'; bodyEl.style.flexDirection = 'column'; bodyEl.style.gap = '8px'; loadState().then(() => { const subContent = document.createElement('div'); subContent.className = 'calc-game-content'; const bar = document.createElement('div'); bar.className = 'calc-game-subtabs'; SUB_MODES.forEach(mode => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : ''); btn.textContent = t('calculator.sta.tab.' + mode); btn.dataset.mode = mode; btn.addEventListener('click', () => { bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _activeSubMode = mode; renderSubMode(subContent); saveState(); }); bar.appendChild(btn); }); bodyEl.append(bar, subContent); renderSubMode(subContent); }); }, destroy() { saveState(); } }); })(); ``` - [ ] **Step 2: Script-Tag in newtab.html** Nach dem `calc-factorio.js` Script-Tag: ```html ``` - [ ] **Step 3: CSS für Stationeers-spezifische Elemente** In `src/css/main.css`, nach den Game-Calculator shared Styles: ```css /* Calculator Stationeers specifics */ .calc-game-hint { font-size: 10px; color: var(--text-muted); font-style: italic; margin-top: -4px; text-align: right; } .calc-game-details { border-top: 1px solid var(--border); padding-top: 6px; margin-top: 4px; } .calc-game-details summary { font-size: 10px; color: var(--text-muted); cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px; } .calc-game-table { width: 100%; font-size: 11px; border-collapse: collapse; margin-top: 4px; } .calc-game-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 2px 6px; border-bottom: 1px solid var(--border); } .calc-game-table td { padding: 2px 6px; color: var(--text-secondary); } .calc-game-table tr:nth-child(even) td { background: rgba(0,0,0,0.1); } ``` - [ ] **Step 4: i18n-Keys für Stationeers** DE-Block: ```javascript 'calculator.tab.stationeers': 'Stationeers', 'calculator.sta.tab.gas': 'Gas', 'calculator.sta.tab.furnace': 'Ofen', 'calculator.sta.tab.solar': 'Solar', 'calculator.sta.tab.atmo': 'Atmo', 'calculator.sta.solve_for': 'Gesucht', 'calculator.sta.var.P': 'Druck (P)', 'calculator.sta.var.V': 'Volumen (V)', 'calculator.sta.var.n': 'Stoffmenge (n)', 'calculator.sta.var.T': 'Temperatur (T)', 'calculator.sta.var.P_label': 'Druck (kPa)', 'calculator.sta.var.V_label': 'Volumen (L)', 'calculator.sta.var.n_label': 'Stoffmenge (mol)', 'calculator.sta.var.T_label': 'Temperatur (K)', 'calculator.sta.result': 'Ergebnis', 'calculator.sta.fuel_ratio': 'Fuel-Anteil (0-1)', 'calculator.sta.start_temp': 'Start-Temperatur (K)', 'calculator.sta.start_pressure': 'Start-Druck (kPa)', 'calculator.sta.temp_after': 'T nach Zündung', 'calculator.sta.pressure_after': 'P nach Zündung', 'calculator.sta.warn_low_fuel': '⚠ Fuel unter 5%', 'calculator.sta.warn_low_pressure': '⚠ Druck unter 10 kPa', 'calculator.sta.panels': 'Anzahl Panels', 'calculator.sta.watts_per_panel': 'Watt/Panel', 'calculator.sta.day_length': 'Taglänge (s)', 'calculator.sta.night_length': 'Nachtlänge (s)', 'calculator.sta.consumption': 'Verbrauch (W)', 'calculator.sta.generation': 'Erzeugung', 'calculator.sta.surplus': 'Überschuss', 'calculator.sta.night_energy': 'Nacht-Energie', 'calculator.sta.batteries_needed': 'Batterien benötigt', 'calculator.sta.target_temp': 'Ziel-Temperatur (K)', 'calculator.sta.gas1_temp': 'Gas 1 Temperatur (K)', 'calculator.sta.gas2_temp': 'Gas 2 Temperatur (K)', 'calculator.sta.mixer_input1': 'Mixer Input 1', 'calculator.sta.mixer_input2': 'Mixer Input 2', 'calculator.sta.heat_cap_ref': 'Wärmekapazitäten (Referenz)', 'calculator.sta.gas': 'Gas', ``` EN-Block: ```javascript 'calculator.tab.stationeers': 'Stationeers', 'calculator.sta.tab.gas': 'Gas', 'calculator.sta.tab.furnace': 'Furnace', 'calculator.sta.tab.solar': 'Solar', 'calculator.sta.tab.atmo': 'Atmo', 'calculator.sta.solve_for': 'Solve for', 'calculator.sta.var.P': 'Pressure (P)', 'calculator.sta.var.V': 'Volume (V)', 'calculator.sta.var.n': 'Amount (n)', 'calculator.sta.var.T': 'Temperature (T)', 'calculator.sta.var.P_label': 'Pressure (kPa)', 'calculator.sta.var.V_label': 'Volume (L)', 'calculator.sta.var.n_label': 'Amount (mol)', 'calculator.sta.var.T_label': 'Temperature (K)', 'calculator.sta.result': 'Result', 'calculator.sta.fuel_ratio': 'Fuel Ratio (0-1)', 'calculator.sta.start_temp': 'Start Temperature (K)', 'calculator.sta.start_pressure': 'Start Pressure (kPa)', 'calculator.sta.temp_after': 'T after ignition', 'calculator.sta.pressure_after': 'P after ignition', 'calculator.sta.warn_low_fuel': '⚠ Fuel below 5%', 'calculator.sta.warn_low_pressure': '⚠ Pressure below 10 kPa', 'calculator.sta.panels': 'Panel Count', 'calculator.sta.watts_per_panel': 'Watts/Panel', 'calculator.sta.day_length': 'Day Length (s)', 'calculator.sta.night_length': 'Night Length (s)', 'calculator.sta.consumption': 'Consumption (W)', 'calculator.sta.generation': 'Generation', 'calculator.sta.surplus': 'Surplus', 'calculator.sta.night_energy': 'Night Energy', 'calculator.sta.batteries_needed': 'Batteries needed', 'calculator.sta.target_temp': 'Target Temperature (K)', 'calculator.sta.gas1_temp': 'Gas 1 Temperature (K)', 'calculator.sta.gas2_temp': 'Gas 2 Temperature (K)', 'calculator.sta.mixer_input1': 'Mixer Input 1', 'calculator.sta.mixer_input2': 'Mixer Input 2', 'calculator.sta.heat_cap_ref': 'Heat Capacities (Reference)', 'calculator.sta.gas': 'Gas', ``` - [ ] **Step 5: Testen** 1. STA-Tab klicken → 4 Sub-Tabs (Gas, Ofen, Solar, Atmo) 2. Gas: P gesucht, V=1000, n=1, T=293.15 → P=2437.66 kPa (Stationeers-R ist groß!) 3. Furnace: Fuel=0.5, T=293K, P=101kPa → hohe Temperatur nach Zündung 4. Solar: 12 Panels, 500W, 600s Tag/Nacht, 2000W → 6000W Gen, 4000W Surplus, 24 Batteries 5. Atmo: Target=300K, Gas1=200K, Gas2=400K → M1=50%, M2=50% 6. Wärmekapazität-Tabelle aufklappbar - [ ] **Step 6: Commit** ```bash git add src/js/calc-stationeers.js newtab.html src/css/main.css src/js/i18n.js git commit -m "feat(calculator): Stationeers Calculator mit Gas/Furnace/Solar/Atmo" ``` --- ### Task 8: Version Bump und CHANGELOG **Files:** - Modify: `manifest.json` — Version → 2.1.0 - Modify: `manifest.firefox.json` — Version → 2.1.0 - Modify: `manifest.opera.json` — Version → 2.1.0 - Modify: `newtab.html` — Version-String → 2.1.0 - Modify: `src/js/data.js` — Export-Version → 2.1.0 - Modify: `src/js/app.js` — Backup-Version → 2.1.0 - Modify: `CHANGELOG.md` — v2.1.0 Eintrag - [ ] **Step 1: Manifests aktualisieren** In allen drei Manifest-Dateien: ```json "version": "2.1.0" ``` - [ ] **Step 2: newtab.html Version-String** Den bestehenden Version-String von "2.0.1" auf "2.1.0" ändern. - [ ] **Step 3: data.js Export-Version** ```javascript version: '2.1.0', ``` - [ ] **Step 4: app.js Backup-Version** ```javascript version: '2.1.0', ``` - [ ] **Step 5: CHANGELOG.md Eintrag** Am Anfang der Datei (nach dem Header): ```markdown ## [2.1.0] — 2026-04-16 ### Added - **Calculator Tab-System:** 6 Modi über Tab-Leiste erreichbar (Standard, Scientific, Unit, SAT, FAC, STA) - **Scientific-Modus:** Wurzel, Potenz, Pi, Euler, Vorzeichen-Wechsel + Formel-Helfer (Kreis, Pythagoras, Prozent, Temperatur) - **Unit-Converter:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit Live-Konvertierung und Swap - **Satisfactory Calculator:** Items/Min, Overclock-Power (Exponent 1.321928), Maschinen-Rechner - **Factorio Calculator:** Assembler-Ratios, Belt-Throughput, Maschinen-Rechner mit Belt-Empfehlung - **Stationeers Calculator:** Idealgas (PV=nRT), Furnace/Verbrennung, Solar/Batterie-Dimensionierung, Atmosphären-Mixer ### Changed - Parser um `^` (Potenz, rechts-assoziativ) und `sqrt()` erweitert - Calculator-Widget Auto-Resize auf 320×480 für komplexe Modi - ~110 neue i18n-Keys (DE + EN) ``` - [ ] **Step 6: Commit** ```bash git add manifest.json manifest.firefox.json manifest.opera.json newtab.html src/js/data.js src/js/app.js CHANGELOG.md git commit -m "feat(release): Version auf v2.1.0 bumpen — Calculator Upgrade" ```