diff --git a/frontend/js/app.js b/frontend/js/app.js
index 8de602f..04188fa 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -1,15 +1,16 @@
// Production - The Idle Game: Main Application Orchestrator
+// Fine-grained DOM updates without full innerHTML teardowns
import { GState } from './core/state.js';
import { SimulationEngine } from './core/engine.js';
import { SyncManager } from './core/sync.js';
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
import { PRODUCTS, getProduct } from './data/products.js';
-import { renderProductionFloor, calculateMultiCost } from './ui/productionView.js';
-import { renderContractsBoard } from './ui/contractsView.js';
-import { renderBoardroom } from './ui/boardroomView.js';
-import { renderInspector } from './ui/inspectorView.js';
-import { renderAchievements } from './ui/achievementsView.js';
+import { mountProductionFloor, updateProductionFloor, calculateMultiCost } from './ui/productionView.js';
+import { mountContractsBoard, updateContractsBoard } from './ui/contractsView.js';
+import { mountBoardroom, updateBoardroom } from './ui/boardroomView.js';
+import { mountInspector, updateInspector } from './ui/inspectorView.js';
+import { mountAchievements, updateAchievements } from './ui/achievementsView.js';
class ProductionApp {
constructor() {
@@ -18,6 +19,9 @@ class ProductionApp {
this.sync = new SyncManager(this.sm);
this.lastFrameTime = performance.now();
this.lastSaveTime = Date.now();
+ this.currentMountedTab = null;
+ this.currentMountedSector = null;
+ this.currentMountedProduct = null;
}
async init() {
@@ -32,27 +36,33 @@ class ProductionApp {
}
this.setupDomEvents();
- this.renderAll();
+
+ // Initial Mount (builds DOM nodes ONCE)
+ this.mountActiveView();
+ this.mountInspectorPane();
+ this.updateHud();
// Check if logged in and sync cloud state
if (this.sync.isLoggedIn()) {
this.sync.fetchCloudState().then(cloud => {
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
- // Cloud save is newer, merge and save
this.sm.state = { ...this.sm.state, ...cloud };
this.sm.save();
- this.renderAll();
+ this.mountActiveView();
+ this.mountInspectorPane();
}
});
}
- // Main game loop (tick at 30-60fps)
+ // Main game loop (ticks at 60fps, fine-grained updates without innerHTML)
const loop = (currentTime) => {
- const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5); // cap at 0.5s per frame
+ const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5);
this.lastFrameTime = currentTime;
this.engine.tick(dt);
this.updateHud();
+ this.updateActiveView();
+ this.updateInspectorPane();
// Auto-save every 5 seconds
if (Date.now() - this.lastSaveTime > 5000) {
@@ -63,12 +73,6 @@ class ProductionApp {
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
-
- // Periodic view refresh for timers and progress bars (every 250ms)
- setInterval(() => {
- this.renderActiveView();
- this.renderInspectorPane();
- }, 250);
}
setupDomEvents() {
@@ -81,7 +85,7 @@ class ProductionApp {
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
- this.renderActiveView();
+ this.mountActiveView();
};
});
@@ -92,7 +96,7 @@ class ProductionApp {
this.sm.state.buyMode = mode;
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
- this.renderActiveView();
+ this.mountActiveView(); // Remount button labels for 1x/10x/100x
};
});
@@ -124,13 +128,12 @@ class ProductionApp {
switch (action.type) {
case 'SELECT_SECTOR':
state.selectedSector = action.sector;
- this.renderActiveView();
+ this.mountActiveView();
break;
case 'SELECT_PRODUCT':
state.selectedProductId = action.productId;
- this.renderActiveView();
- this.renderInspectorPane();
+ this.mountInspectorPane();
break;
case 'UNLOCK_PRODUCT': {
@@ -143,7 +146,8 @@ class ProductionApp {
state.selectedProductId = p.id;
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
this.sync.queueAction(action);
- this.renderAll();
+ this.mountActiveView();
+ this.mountInspectorPane();
}
break;
}
@@ -158,7 +162,8 @@ class ProductionApp {
pState.lines += calc.count;
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
this.sync.queueAction(action);
- this.renderAll();
+ this.mountActiveView();
+ this.mountInspectorPane();
}
}
break;
@@ -173,7 +178,7 @@ class ProductionApp {
state.stats.managersHired = (state.stats.managersHired || 0) + 1;
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
this.sync.queueAction(action);
- this.renderAll();
+ this.mountActiveView();
}
break;
}
@@ -188,7 +193,8 @@ class ProductionApp {
pState.logistics = (pState.logistics || 0) + 1;
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
this.sync.queueAction(action);
- this.renderAll();
+ this.mountActiveView();
+ this.mountInspectorPane();
}
}
break;
@@ -198,7 +204,6 @@ class ProductionApp {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
- // Check inputs
const isRaw = !p.inputs || p.inputs.length === 0;
let canRun = true;
if (!isRaw) {
@@ -219,7 +224,6 @@ class ProductionApp {
} else {
this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
}
- this.renderAll();
}
break;
}
@@ -231,7 +235,6 @@ class ProductionApp {
const pState = state.products[c.productId];
if (pState && pState.stock >= c.targetAmount) {
pState.stock -= c.targetAmount;
- // Add subsidy bonus if researched
const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
const totalPayout = c.rewardCash * subsidyBonus;
state.cash += totalPayout;
@@ -242,7 +245,7 @@ class ProductionApp {
state.activeContracts.splice(idx, 1);
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
this.sync.queueAction(action);
- this.renderAll();
+ this.mountActiveView();
}
}
break;
@@ -254,7 +257,8 @@ class ProductionApp {
const success = this.sm.performIPO();
if (success) {
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
- this.renderAll();
+ this.mountActiveView();
+ this.mountInspectorPane();
}
}
break;
@@ -262,7 +266,6 @@ class ProductionApp {
case 'BUY_PERK': {
const perk = (state.boardroomPerks || {})[action.perkId] || 0;
- // Find perk definition
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
if (def && perk < def.maxLevel) {
@@ -271,7 +274,7 @@ class ProductionApp {
state.ventureShares -= cost;
state.boardroomPerks[action.perkId] = perk + 1;
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
- this.renderAll();
+ this.mountActiveView();
}
}
});
@@ -290,7 +293,6 @@ class ProductionApp {
state.cash += earned;
state.lifetimeRevenue += earned;
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
- this.renderAll();
}
}
break;
@@ -302,16 +304,28 @@ class ProductionApp {
const state = this.sm.state;
const elCash = document.getElementById('hud-cash');
- if (elCash) elCash.textContent = fmtNum(state.cash);
+ if (elCash) {
+ const val = fmtNum(state.cash);
+ if (elCash.textContent !== val) elCash.textContent = val;
+ }
const elRate = document.getElementById('hud-rate');
- if (elRate) elRate.textContent = fmtRate(this.engine.cashRate).replace('+$', '');
+ if (elRate) {
+ const val = fmtRate(this.engine.cashRate).replace('+$', '');
+ if (elRate.textContent !== val) elRate.textContent = val;
+ }
const elValuation = document.getElementById('hud-valuation');
- if (elValuation) elValuation.textContent = fmtCurrency(state.valuation);
+ if (elValuation) {
+ const val = fmtCurrency(state.valuation);
+ if (elValuation.textContent !== val) elValuation.textContent = val;
+ }
const elShares = document.getElementById('hud-shares');
- if (elShares) elShares.textContent = fmtNum(state.ventureShares, 0);
+ if (elShares) {
+ const val = fmtNum(state.ventureShares, 0);
+ if (elShares.textContent !== val) elShares.textContent = val;
+ }
const elContractsBadge = document.getElementById('contracts-badge');
if (elContractsBadge) {
@@ -326,34 +340,74 @@ class ProductionApp {
}
}
- renderActiveView() {
+ /**
+ * Mounts the main viewport view ONCE when the tab or sector changes
+ */
+ mountActiveView() {
const container = document.getElementById('main-viewport');
if (!container) return;
const tab = this.sm.state.activeTab || 'production';
+ const sector = this.sm.state.selectedSector || 'all';
+ this.currentMountedTab = tab;
+ this.currentMountedSector = sector;
if (tab === 'production') {
- renderProductionFloor(container, this.sm.state, (a) => this.handleAction(a));
+ mountProductionFloor(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'contracts') {
- renderContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
+ mountContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'boardroom') {
- renderBoardroom(container, this.sm.state, (a) => this.handleAction(a));
+ mountBoardroom(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'milestones') {
- renderAchievements(container, this.sm.state, (a) => this.handleAction(a));
+ mountAchievements(container, this.sm.state, (a) => this.handleAction(a));
}
}
- renderInspectorPane() {
+ /**
+ * Mounts the inspector panel ONCE when selected product changes
+ */
+ mountInspectorPane() {
const inspectorContainer = document.getElementById('desktop-inspector-panel');
if (inspectorContainer) {
- renderInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a));
+ this.currentMountedProduct = this.sm.state.selectedProductId;
+ mountInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a));
}
}
- renderAll() {
- this.updateHud();
- this.renderActiveView();
- this.renderInspectorPane();
+ /**
+ * Executes fine-grained in-place element updates without innerHTML teardowns
+ */
+ updateActiveView() {
+ const container = document.getElementById('main-viewport');
+ if (!container) return;
+
+ const tab = this.sm.state.activeTab || 'production';
+ if (tab !== this.currentMountedTab) {
+ this.mountActiveView();
+ return;
+ }
+
+ if (tab === 'production') {
+ updateProductionFloor(container, this.sm.state);
+ } else if (tab === 'contracts') {
+ updateContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
+ } else if (tab === 'boardroom') {
+ updateBoardroom(container, this.sm.state);
+ } else if (tab === 'milestones') {
+ updateAchievements(container, this.sm.state);
+ }
+ }
+
+ updateInspectorPane() {
+ const inspectorContainer = document.getElementById('desktop-inspector-panel');
+ if (!inspectorContainer) return;
+
+ if (this.sm.state.selectedProductId !== this.currentMountedProduct) {
+ this.mountInspectorPane();
+ return;
+ }
+
+ updateInspector(inspectorContainer, this.sm.state);
}
showToast(message, type = 'info') {
@@ -420,7 +474,8 @@ class ProductionApp {
const next = current === 'standard' ? 'scientific' : 'standard';
setNotation(next);
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
- this.renderAll();
+ this.mountActiveView();
+ this.mountInspectorPane();
}
}
diff --git a/frontend/js/ui/achievementsView.js b/frontend/js/ui/achievementsView.js
index a2b68ab..bd7c200 100644
--- a/frontend/js/ui/achievementsView.js
+++ b/frontend/js/ui/achievementsView.js
@@ -1,9 +1,10 @@
// Production - The Idle Game: Corporate Milestones View
+// Fine-grained DOM updates without innerHTML teardowns
import { ACHIEVEMENTS } from '../data/achievements.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
-export function renderAchievements(container, state, onAction) {
+export function mountAchievements(container, state, onAction) {
const achState = state.achievements || {};
let html = `
@@ -35,9 +36,9 @@ export function renderAchievements(container, state, onAction) {
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
html += `
-
+
-
@@ -45,15 +46,15 @@ export function renderAchievements(container, state, onAction) {
${ach.title}
- Tier ${currentTier} / ${maxTier}
+ Tier ${currentTier} / ${maxTier}
${ach.description}
- ${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}
- ${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}
+ ${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}
+ ${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}
@@ -63,3 +64,34 @@ export function renderAchievements(container, state, onAction) {
html += `
`;
container.innerHTML = html;
}
+
+export function updateAchievements(container, state) {
+ const achState = state.achievements || {};
+
+ for (const ach of ACHIEVEMENTS) {
+ const currentTier = achState[ach.id] || 0;
+ let currentVal = 0;
+ if (ach.reqType === 'total_produced') currentVal = state.stats.totalProduced || 0;
+ else if (ach.reqType === 'total_revenue') currentVal = state.lifetimeRevenue || 0;
+ else if (ach.reqType === 'contracts_completed') currentVal = state.stats.contractsCompleted || 0;
+ else if (ach.reqType === 'ipo_count') currentVal = state.ipoCount || 0;
+ else if (ach.reqType === 'managers_hired') currentVal = state.stats.managersHired || 0;
+
+ const maxTier = ach.thresholds.length;
+ const isMaxed = currentTier >= maxTier;
+ const nextThreshold = !isMaxed ? ach.thresholds[currentTier] : ach.thresholds[maxTier - 1];
+ const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
+
+ const elFill = document.getElementById(`ach-fill-${ach.id}`);
+ if (elFill) elFill.style.width = `${progressPct}%`;
+
+ const elCurr = document.getElementById(`ach-curr-val-${ach.id}`);
+ if (elCurr) elCurr.textContent = ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0);
+
+ const elTier = document.getElementById(`ach-tier-${ach.id}`);
+ if (elTier) elTier.textContent = `Tier ${currentTier} / ${maxTier}`;
+
+ const elIcon = document.getElementById(`ach-icon-${ach.id}`);
+ if (elIcon) elIcon.classList.toggle('ach-unlocked', currentTier > 0);
+ }
+}
diff --git a/frontend/js/ui/boardroomView.js b/frontend/js/ui/boardroomView.js
index 3bd5ce7..6a6d7f9 100644
--- a/frontend/js/ui/boardroomView.js
+++ b/frontend/js/ui/boardroomView.js
@@ -1,9 +1,10 @@
// Production - The Idle Game: Corporate IPO & Boardroom Perks View
+// Fine-grained DOM updates without innerHTML teardowns
import { BOARDROOM_PERKS, calculateSharesEarned, IPO_MIN_VALUATION } from '../data/boardroom.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
-export function renderBoardroom(container, state, onAction) {
+export function mountBoardroom(container, state, onAction) {
const earnedShares = calculateSharesEarned(state.lifetimeRevenue);
const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
const perks = state.boardroomPerks || {};
@@ -23,27 +24,27 @@ export function renderBoardroom(container, state, onAction) {
Corporate Enterprise Valuation
-
${fmtCurrency(state.valuation)}
-
Lifetime Gross Revenue: ${fmtCurrency(state.lifetimeRevenue)}
+
${fmtCurrency(state.valuation)}
+
Lifetime Gross Revenue: ${fmtCurrency(state.lifetimeRevenue)}
-
+
- +${fmtNum(earnedShares, 0)} $V-Shares
+ +${fmtNum(earnedShares, 0)} $V-Shares
Initial Public Offering (IPO) Ready
-
+
${canIPO
? `Execute corporate IPO now to claim ${fmtNum(earnedShares, 0)} Venture Shares and restart with permanent bonuses.`
: `Requires minimum ${fmtCurrency(IPO_MIN_VALUATION)} in lifetime revenue to file for IPO.`}
-
+
Launch IPO
@@ -53,11 +54,11 @@ export function renderBoardroom(container, state, onAction) {
Available $V-Shares:
- ${fmtNum(state.ventureShares, 0)}
+ ${fmtNum(state.ventureShares, 0)}
IPOs Completed:
- ${state.ipoCount || 0}
+ ${state.ipoCount || 0}
@@ -76,13 +77,13 @@ export function renderBoardroom(container, state, onAction) {
const canAfford = state.ventureShares >= nextCost && !isMaxed;
html += `
-
+
@@ -94,9 +95,9 @@ export function renderBoardroom(container, state, onAction) {
@@ -115,3 +116,34 @@ export function renderBoardroom(container, state, onAction) {
btn.onclick = () => onAction({ type: 'BUY_PERK', perkId: btn.dataset.perk });
});
}
+
+export function updateBoardroom(container, state) {
+ const earnedShares = calculateSharesEarned(state.lifetimeRevenue);
+ const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
+ const perks = state.boardroomPerks || {};
+
+ const elVal = document.getElementById('boardroom-val-number');
+ if (elVal) elVal.textContent = fmtCurrency(state.valuation);
+
+ const elLifetime = document.getElementById('boardroom-val-lifetime');
+ if (elLifetime) elLifetime.textContent = fmtCurrency(state.lifetimeRevenue);
+
+ const elEarned = document.getElementById('boardroom-earned-shares');
+ if (elEarned) elEarned.textContent = `+${fmtNum(earnedShares, 0)} $V-Shares`;
+
+ const btnIPO = document.getElementById('boardroom-btn-ipo');
+ if (btnIPO) btnIPO.disabled = !canIPO;
+
+ const elAvail = document.getElementById('boardroom-avail-shares');
+ if (elAvail) elAvail.textContent = fmtNum(state.ventureShares, 0);
+
+ for (const perk of BOARDROOM_PERKS) {
+ const currentLvl = perks[perk.id] || 0;
+ const isMaxed = currentLvl >= perk.maxLevel;
+ const nextCost = !isMaxed ? perk.costShares[currentLvl] : 0;
+ const canAfford = state.ventureShares >= nextCost && !isMaxed;
+
+ const elBtn = document.getElementById(`btn-perk-${perk.id}`);
+ if (elBtn) elBtn.disabled = !canAfford;
+ }
+}
diff --git a/frontend/js/ui/contractsView.js b/frontend/js/ui/contractsView.js
index 7430432..b22a065 100644
--- a/frontend/js/ui/contractsView.js
+++ b/frontend/js/ui/contractsView.js
@@ -1,11 +1,15 @@
// Production - The Idle Game: B2B Procurement Contracts Board
+// Fine-grained DOM updates without innerHTML teardowns
import { getProduct } from '../data/products.js';
import { getClient } from '../data/contracts.js';
import { fmtCurrency, fmtNum, fmtTime } from '../core/formatters.js';
-export function renderContractsBoard(container, state, onAction) {
+let currentMountedContractUids = '';
+
+export function mountContractsBoard(container, state, onAction) {
const contracts = state.activeContracts || [];
+ currentMountedContractUids = contracts.map(c => c.uid).join(',');
let html = `
Completed Tenders:
- ${state.stats.contractsCompleted || 0}
+ ${state.stats.contractsCompleted || 0}
@@ -45,7 +49,7 @@ export function renderContractsBoard(container, state, onAction) {
const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
html += `
-
+
@@ -56,9 +60,9 @@ export function renderContractsBoard(container, state, onAction) {
${client.badge}
-
+
- ${fmtTime(c.remainingSeconds)}
+ ${fmtTime(c.remainingSeconds)}
@@ -73,12 +77,12 @@ export function renderContractsBoard(container, state, onAction) {
- ${fmtNum(currentStock, 0)} / ${fmtNum(c.targetAmount, 0)} ${product?.unit || 'units'}
+ ${fmtNum(currentStock, 0)} / ${fmtNum(c.targetAmount, 0)} ${product?.unit || 'units'}
@@ -94,9 +98,9 @@ export function renderContractsBoard(container, state, onAction) {
@@ -110,3 +114,53 @@ export function renderContractsBoard(container, state, onAction) {
btn.onclick = () => onAction({ type: 'FULFILL_CONTRACT', uid: btn.dataset.uid });
});
}
+
+export function updateContractsBoard(container, state, onAction) {
+ const contracts = state.activeContracts || [];
+ const currentUids = contracts.map(c => c.uid).join(',');
+
+ // If active contracts changed (added, completed, expired), remount cleanly
+ if (currentUids !== currentMountedContractUids) {
+ mountContractsBoard(container, state, onAction);
+ return;
+ }
+
+ for (const c of contracts) {
+ const currentStock = state.products[c.productId]?.stock || 0;
+ const canFulfill = currentStock >= c.targetAmount;
+ const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
+
+ // Timer
+ const elTimerVal = document.getElementById(`timer-val-${c.uid}`);
+ if (elTimerVal) elTimerVal.textContent = fmtTime(c.remainingSeconds);
+
+ const elTimerBox = document.getElementById(`timer-box-${c.uid}`);
+ if (elTimerBox) {
+ elTimerBox.classList.toggle('timer-urgent', c.remainingSeconds < 60);
+ }
+
+ // Current Stock Text
+ const elCurrStock = document.getElementById(`c-curr-stock-${c.uid}`);
+ if (elCurrStock) elCurrStock.textContent = fmtNum(currentStock, 0);
+
+ // Progress bar
+ const elFill = document.getElementById(`c-fill-${c.uid}`);
+ if (elFill) elFill.style.width = `${progressPct}%`;
+
+ // Card fulfill class
+ const elCard = document.getElementById(`contract-${c.uid}`);
+ if (elCard) elCard.classList.toggle('ready-to-fulfill', canFulfill);
+
+ // Button disabled & text
+ const elBtn = document.getElementById(`c-btn-${c.uid}`);
+ if (elBtn) {
+ elBtn.disabled = !canFulfill;
+ const elBtnText = document.getElementById(`c-btn-text-${c.uid}`);
+ if (elBtnText) {
+ elBtnText.textContent = canFulfill
+ ? 'Deliver Shipment & Collect Payout'
+ : `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`;
+ }
+ }
+ }
+}
diff --git a/frontend/js/ui/inspectorView.js b/frontend/js/ui/inspectorView.js
index 7ee060f..cf6cadd 100644
--- a/frontend/js/ui/inspectorView.js
+++ b/frontend/js/ui/inspectorView.js
@@ -1,18 +1,21 @@
// Production - The Idle Game: Desktop Inspector & Supply Chain Analytics Panel
+// Fine-grained DOM updates without innerHTML teardowns
import { PRODUCTS, SECTORS, getProduct } from '../data/products.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
-export function renderInspector(container, state, onAction) {
+let currentMountedProductId = null;
+
+export function mountInspector(container, state, onAction) {
const selectedId = state.selectedProductId || 'wheat';
const product = getProduct(selectedId) || PRODUCTS[0];
const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0, unlocked: false };
const sector = SECTORS.find(s => s.id === product.sector);
+ currentMountedProductId = product.id;
- // Find downstream products that consume this product
+ // Downstream products that consume this product
const downstream = PRODUCTS.filter(p => p.inputs && p.inputs.some(inp => inp.id === product.id));
- // Calculate liquidation value
const totalLiquidationValue = (pState.stock || 0) * product.basePrice;
const halfLiquidationValue = totalLiquidationValue * 0.5;
@@ -33,7 +36,7 @@ export function renderInspector(container, state, onAction) {
Warehouse Stock:
- ${fmtNum(pState.stock, 0)} ${product.unit}
+ ${fmtNum(pState.stock, 0)} ${product.unit}
Wholesale Price:
@@ -41,11 +44,11 @@ export function renderInspector(container, state, onAction) {
Production Lines:
- ${pState.lines} active
+ ${pState.lines} active
Logistics Fleets:
- ${pState.logistics || 0} active
+ ${pState.logistics || 0} active
@@ -56,11 +59,11 @@ export function renderInspector(container, state, onAction) {
Liquidate stockpiles immediately onto the wholesale spot market.
- = 2 ? '' : 'disabled'}>
- Sell 50% (${fmtCurrency(halfLiquidationValue)})
+ = 2 ? '' : 'disabled'}>
+ Sell 50% (${fmtCurrency(halfLiquidationValue)} )
- = 1 ? '' : 'disabled'}>
- Sell 100% (${fmtCurrency(totalLiquidationValue)})
+ = 1 ? '' : 'disabled'}>
+ Sell 100% (${fmtCurrency(totalLiquidationValue)} )
@@ -87,7 +90,7 @@ export function renderInspector(container, state, onAction) {
${parent.name}
- ${inp.amount} per unit (${fmtNum(parentStock, 0)} on hand)
+ ${inp.amount} per unit (${fmtNum(parentStock, 0)} on hand)
`;
}).join('')}
@@ -110,7 +113,7 @@ export function renderInspector(container, state, onAction) {
${child.name}
-
+
${isUnlocked ? 'Active' : 'Locked'}
@@ -125,12 +128,12 @@ export function renderInspector(container, state, onAction) {
container.innerHTML = html;
- const halfBtn = container.querySelector('.btn-liquidate-half');
+ const halfBtn = container.querySelector('#insp-btn-half');
if (halfBtn) {
halfBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 0.5 });
}
- const allBtn = container.querySelector('.btn-liquidate-all');
+ const allBtn = container.querySelector('#insp-btn-all');
if (allBtn) {
allBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 1.0 });
}
@@ -139,3 +142,46 @@ export function renderInspector(container, state, onAction) {
row.onclick = () => onAction({ type: 'SELECT_PRODUCT', productId: row.dataset.product });
});
}
+
+export function updateInspector(container, state) {
+ const selectedId = state.selectedProductId || 'wheat';
+ if (selectedId !== currentMountedProductId) {
+ return; // Caller should remount if product changed
+ }
+
+ const product = getProduct(selectedId);
+ if (!product) return;
+ const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0 };
+
+ // Update stock text
+ const elStock = document.getElementById('insp-stock');
+ if (elStock) {
+ elStock.textContent = `${fmtNum(pState.stock, 0)} ${product.unit}`;
+ }
+
+ // Update liquidation values & buttons
+ const totalLiquidationValue = (pState.stock || 0) * product.basePrice;
+ const halfLiquidationValue = totalLiquidationValue * 0.5;
+
+ const elHalfVal = document.getElementById('insp-val-half');
+ if (elHalfVal) elHalfVal.textContent = fmtCurrency(halfLiquidationValue);
+
+ const elAllVal = document.getElementById('insp-val-all');
+ if (elAllVal) elAllVal.textContent = fmtCurrency(totalLiquidationValue);
+
+ const btnHalf = document.getElementById('insp-btn-half');
+ if (btnHalf) btnHalf.disabled = pState.stock < 2;
+
+ const btnAll = document.getElementById('insp-btn-all');
+ if (btnAll) btnAll.disabled = pState.stock < 1;
+
+ // Update input values
+ if (product.inputs) {
+ for (const inp of product.inputs) {
+ const elInp = document.getElementById(`insp-inp-val-${inp.id}`);
+ if (elInp) {
+ elInp.textContent = fmtNum(state.products[inp.id]?.stock || 0, 0);
+ }
+ }
+ }
+}
diff --git a/frontend/js/ui/productionView.js b/frontend/js/ui/productionView.js
index 15a7dc5..7167910 100644
--- a/frontend/js/ui/productionView.js
+++ b/frontend/js/ui/productionView.js
@@ -1,11 +1,10 @@
// Production - The Idle Game: Production Floor View
+// High-performance fine-grained DOM mounting & updating without innerHTML teardowns
import { PRODUCTS, SECTORS, getProduct, getNextMilestone, getUnitMilestoneMultiplier } from '../data/products.js';
import { fmtCurrency, fmtNum } from '../core/formatters.js';
export function calculateMultiCost(baseCost, currentCount, multiplier) {
- // Standard incremental scaling: cost = baseCost * 1.15^count
- // For n purchases: sum(baseCost * 1.15^(count + i))
const growth = 1.15;
let countToBuy = 1;
@@ -16,7 +15,7 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
const nextTarget = getNextMilestone(currentCount);
countToBuy = Math.max(1, nextTarget - currentCount);
} else if (multiplier === 'max') {
- countToBuy = 1; // Will be handled dynamically in loop
+ countToBuy = 1;
}
let totalCost = 0;
@@ -26,7 +25,10 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
return { count: countToBuy, cost: totalCost };
}
-export function renderProductionFloor(container, state, onAction) {
+/**
+ * Mounts the production floor DOM structure ONCE
+ */
+export function mountProductionFloor(container, state, onAction) {
const selectedSector = state.selectedSector || 'all';
const buyMode = state.buyMode || '1';
@@ -60,10 +62,9 @@ export function renderProductionFloor(container, state, onAction) {
const isSelected = state.selectedProductId === p.id;
if (!pState.unlocked) {
- // Locked Card
const canAffordUnlock = state.cash >= p.unlockCost;
html += `
-
+
`;
container.innerHTML = html;
- // Attach delegated events
+ // Attach event listeners ONCE
container.querySelectorAll('.sector-pill').forEach(btn => {
btn.onclick = () => onAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
});
container.querySelectorAll('.facility-card').forEach(card => {
card.onclick = (e) => {
- if (e.target.closest('button')) return; // Ignore clicks on action buttons
+ if (e.target.closest('button')) return;
onAction({ type: 'SELECT_PRODUCT', productId: card.dataset.product });
};
});
@@ -241,3 +240,115 @@ export function renderProductionFloor(container, state, onAction) {
btn.onclick = () => onAction({ type: 'BUY_LOGISTICS', productId: btn.dataset.product });
});
}
+
+/**
+ * Fine-grained in-place DOM updates (0% layout thrashing, 0% DOM teardown)
+ */
+export function updateProductionFloor(container, state) {
+ const buyMode = state.buyMode || '1';
+ const cash = state.cash;
+
+ for (const p of PRODUCTS) {
+ const pState = state.products[p.id];
+ if (!pState) continue;
+
+ // If card is locked: only update unlock button disabled state
+ if (!pState.unlocked) {
+ const btnUnlock = document.getElementById(`btn-unlock-${p.id}`);
+ if (btnUnlock) {
+ const canAfford = cash >= p.unlockCost;
+ if (btnUnlock.disabled !== !canAfford) {
+ btnUnlock.disabled = !canAfford;
+ }
+ }
+ continue;
+ }
+
+ // 1. Progress Bar (smooth width update)
+ const elProgress = document.getElementById(`progress-${p.id}`);
+ if (elProgress) {
+ const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));
+ elProgress.style.width = `${progressPct}%`;
+ }
+
+ // 2. Stockpile Text
+ const elStock = document.getElementById(`stock-${p.id}`);
+ if (elStock) {
+ const stockText = fmtNum(pState.stock, 0);
+ if (elStock.textContent !== stockText) {
+ elStock.textContent = stockText;
+ }
+ }
+
+ // 3. Status Badge (only update if bottlenecked state changed)
+ const elBadge = document.getElementById(`badge-${p.id}`);
+ if (elBadge) {
+ const isBottlenecked = pState.isBottlenecked;
+ const hasWarning = elBadge.classList.contains('badge-warning');
+ if (isBottlenecked && !hasWarning) {
+ elBadge.className = 'status-badge badge-warning';
+ elBadge.innerHTML = ` Waiting for Inputs`;
+ } else if (!isBottlenecked && hasWarning) {
+ const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
+ elBadge.className = 'status-badge badge-success';
+ elBadge.innerHTML = ` Operating (${milestoneMult}x Speed)`;
+ }
+ }
+
+ // 4. Input Chips Stock
+ if (p.inputs && p.inputs.length > 0) {
+ for (const inp of p.inputs) {
+ const currentStock = state.products[inp.id]?.stock || 0;
+ const elChipVal = document.getElementById(`chip-val-${p.id}-${inp.id}`);
+ if (elChipVal) {
+ const textVal = fmtNum(currentStock, 0);
+ if (elChipVal.textContent !== textVal) {
+ elChipVal.textContent = textVal;
+ }
+ }
+ const elChip = document.getElementById(`chip-${p.id}-${inp.id}`);
+ if (elChip) {
+ const needed = inp.amount * pState.lines;
+ const isLow = currentStock < needed;
+ elChip.classList.toggle('low-stock', isLow);
+ }
+ }
+ }
+
+ // 5. Expand Button disabled state
+ const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
+ const elBtnExpand = document.getElementById(`btn-expand-${p.id}`);
+ if (elBtnExpand) {
+ const canAfford = cash >= calc.cost;
+ if (elBtnExpand.disabled !== !canAfford) {
+ elBtnExpand.disabled = !canAfford;
+ }
+ }
+
+ // 6. Hire Manager disabled state
+ const elBtnHire = document.getElementById(`btn-hire-${p.id}`);
+ if (elBtnHire) {
+ const canAfford = cash >= p.baseManagerCost;
+ if (elBtnHire.disabled !== !canAfford) {
+ elBtnHire.disabled = !canAfford;
+ }
+ }
+
+ // 7. Logistics Button disabled state
+ const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
+ const elBtnLogistics = document.getElementById(`btn-logistics-${p.id}`);
+ if (elBtnLogistics) {
+ const canAfford = cash >= logisticsCost;
+ if (elBtnLogistics.disabled !== !canAfford) {
+ elBtnLogistics.disabled = !canAfford;
+ }
+ }
+
+ // 8. Selected Card class
+ const elCard = document.getElementById(`card-${p.id}`);
+ if (elCard) {
+ const isSelected = state.selectedProductId === p.id;
+ elCard.classList.toggle('selected', isSelected);
+ }
+ }
+}