From d038cd1879c9cdf514197feed2b3a5bb48973805 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 15 Oct 2026 21:41:08 +0200 Subject: [PATCH] perf: implement fine-grained in-place DOM updates eliminating innerHTML teardowns and layout thrashing --- frontend/js/app.js | 153 +++++++++++++++++--------- frontend/js/ui/achievementsView.js | 46 ++++++-- frontend/js/ui/boardroomView.js | 58 +++++++--- frontend/js/ui/contractsView.js | 72 +++++++++++-- frontend/js/ui/inspectorView.js | 74 ++++++++++--- frontend/js/ui/productionView.js | 165 ++++++++++++++++++++++++----- 6 files changed, 449 insertions(+), 119 deletions(-) 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.`}

- @@ -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 += ` -
+
-
Level ${currentLvl} / ${perk.maxLevel}
+
Level ${currentLvl} / ${perk.maxLevel}

${perk.name}

@@ -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 = `
@@ -20,7 +24,7 @@ export function renderContractsBoard(container, state, onAction) {
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.

- -
@@ -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 += ` -
+
@@ -77,7 +78,7 @@ export function renderProductionFloor(container, state, onAction) {

${p.description}

@@ -209,14 +208,14 @@ export function renderProductionFloor(container, state, onAction) { 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); + } + } +}