Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
94 KiB
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:
_modes: new Map(),
_activeMode: 'standard',
_tabBarEl: null,
Neue Methode nach _keydownHandler Block:
/**
* 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:
// 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:
/**
* 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
/**
* 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
/**
* 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):
/**
* 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:
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:
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:
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:
/* 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:
'calculator.tab.standard': 'Standard',
EN:
'calculator.tab.standard': 'Standard',
- Step 10: Testen
- Extension in Chrome laden
- Calculator öffnen → Tab-Bar mit einem Tab "🔢 Std" sichtbar
- Calculator schließen und wieder öffnen → State bleibt erhalten
- Standard-Rechner funktioniert wie vorher (Buttons, History, Keyboard)
- Step 11: Commit
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):
_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:
_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:
_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:
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
_formatExpression(expr) {
return expr
.replace(/\*/g, '\u00D7')
.replace(/\//g, '\u00F7')
.replace(/sqrt\(/g, '√(');
},
- Step 6: Testen
- Calculator öffnen
2^10eingeben → Ergebnis: 10242^3^2eingeben → Ergebnis: 512 (rechts-assoziativ)- In Browser-Console: Calculator._evaluate('sqrt(144)') → 12
3+sqrt(16)→ 7sqrt(-4)→ Fehler
- Step 7: Commit
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
/* =============================================
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 (<script src="src/js/calculator.js"></script>):
<script src="src/js/calc-scientific.js"></script>
- Step 3: CSS für Scientific-Modus
In src/css/main.css, nach den Tab-System Styles:
/* 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':
'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':
'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
- Calculator öffnen → 2 Tabs sichtbar (Std, Sci)
- Sci-Tab klicken → 6 Scientific-Buttons + Standard-Grid + Formel-Helfer
- √ klicken →
sqrt(im Display, 9 eingeben,)eingeben, = → 3 - π klicken → 3.14159265359 im Display
- Formel-Dropdown "Kreisfläche" wählen → Radius-Feld, r=5 → = 78.5398...
p-Taste → Pi einfügen^-Taste → Potenz-Operator
- Step 6: Commit
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
/* =============================================
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:
<script src="src/js/calc-converter.js"></script>
- Step 3: CSS für Converter
In src/css/main.css, nach den Scientific-Styles:
/* 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:
'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:
'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
- Calculator öffnen → 3 Tabs sichtbar (Std, Sci, Unit)
- Unit-Tab klicken → Kategorie-Dropdown, Eingabe-Feld, Ergebnis
- 100 cm eingeben → 39.3701 in
- Swap-Button → cm und in tauschen, Wert übernehmen
- Kategorie zu Temperatur wechseln → °C und °F als Units
- 100 °C → 212 °F
- 0 °F → -17.7778 °C
- Step 6: Commit
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
/* =============================================
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<string>} 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:
<script src="src/js/calc-satisfactory.js"></script>
- Step 3: Shared Game-Calculator CSS
In src/css/main.css, nach den Converter-Styles:
/* 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:
'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:
'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
- SAT-Tab klicken → 3 Sub-Tabs (Items/Min, Strom, Maschinen)
- Items/Min: 1 Item, 4s Craft, 100% → 15 items/min
- Power: 30 MW Base, 250% Clock → ~115 MW (wegen Exponent 1.321928)
- Machines: 60 Target, 1 Item, 4s, 100%, 30 MW → 4 Maschinen, 120 MW
- Step 6: Commit
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
/* =============================================
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:
<script src="src/js/calc-factorio.js"></script>
- Step 3: i18n-Keys für Factorio
DE-Block:
'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:
'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
- FAC-Tab klicken → 3 Sub-Tabs (Ratio, Belt, Maschinen)
- Ratio: Asm3 (1.25x), 1 Output, 1s → 1.25/s, 75/min
- Belt: Yellow (15/s), 1/s consume → 15 Machines, 100%
- Machines: Asm3, 10/s target, 1 output, 1s → 8 machines, Blue (22.2%)
- Step 5: Commit
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
/* =============================================
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:
<script src="src/js/calc-stationeers.js"></script>
- Step 3: CSS für Stationeers-spezifische Elemente
In src/css/main.css, nach den Game-Calculator shared Styles:
/* 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:
'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:
'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
- STA-Tab klicken → 4 Sub-Tabs (Gas, Ofen, Solar, Atmo)
- Gas: P gesucht, V=1000, n=1, T=293.15 → P=2437.66 kPa (Stationeers-R ist groß!)
- Furnace: Fuel=0.5, T=293K, P=101kPa → hohe Temperatur nach Zündung
- Solar: 12 Panels, 500W, 600s Tag/Nacht, 2000W → 6000W Gen, 4000W Surplus, 24 Batteries
- Atmo: Target=300K, Gas1=200K, Gas2=400K → M1=50%, M2=50%
- Wärmekapazität-Tabelle aufklappbar
- Step 6: Commit
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:
"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
version: '2.1.0',
- Step 4: app.js Backup-Version
version: '2.1.0',
- Step 5: CHANGELOG.md Eintrag
Am Anfang der Datei (nach dem Header):
## [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
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"