perf: implement fine-grained in-place DOM updates eliminating innerHTML teardowns and layout thrashing
This commit is contained in:
parent
bcf32f3ca7
commit
d038cd1879
6 changed files with 449 additions and 119 deletions
|
|
@ -1,15 +1,16 @@
|
||||||
// Production - The Idle Game: Main Application Orchestrator
|
// Production - The Idle Game: Main Application Orchestrator
|
||||||
|
// Fine-grained DOM updates without full innerHTML teardowns
|
||||||
|
|
||||||
import { GState } from './core/state.js';
|
import { GState } from './core/state.js';
|
||||||
import { SimulationEngine } from './core/engine.js';
|
import { SimulationEngine } from './core/engine.js';
|
||||||
import { SyncManager } from './core/sync.js';
|
import { SyncManager } from './core/sync.js';
|
||||||
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
|
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
|
||||||
import { PRODUCTS, getProduct } from './data/products.js';
|
import { PRODUCTS, getProduct } from './data/products.js';
|
||||||
import { renderProductionFloor, calculateMultiCost } from './ui/productionView.js';
|
import { mountProductionFloor, updateProductionFloor, calculateMultiCost } from './ui/productionView.js';
|
||||||
import { renderContractsBoard } from './ui/contractsView.js';
|
import { mountContractsBoard, updateContractsBoard } from './ui/contractsView.js';
|
||||||
import { renderBoardroom } from './ui/boardroomView.js';
|
import { mountBoardroom, updateBoardroom } from './ui/boardroomView.js';
|
||||||
import { renderInspector } from './ui/inspectorView.js';
|
import { mountInspector, updateInspector } from './ui/inspectorView.js';
|
||||||
import { renderAchievements } from './ui/achievementsView.js';
|
import { mountAchievements, updateAchievements } from './ui/achievementsView.js';
|
||||||
|
|
||||||
class ProductionApp {
|
class ProductionApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -18,6 +19,9 @@ class ProductionApp {
|
||||||
this.sync = new SyncManager(this.sm);
|
this.sync = new SyncManager(this.sm);
|
||||||
this.lastFrameTime = performance.now();
|
this.lastFrameTime = performance.now();
|
||||||
this.lastSaveTime = Date.now();
|
this.lastSaveTime = Date.now();
|
||||||
|
this.currentMountedTab = null;
|
||||||
|
this.currentMountedSector = null;
|
||||||
|
this.currentMountedProduct = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
@ -32,27 +36,33 @@ class ProductionApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setupDomEvents();
|
this.setupDomEvents();
|
||||||
this.renderAll();
|
|
||||||
|
// Initial Mount (builds DOM nodes ONCE)
|
||||||
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
|
this.updateHud();
|
||||||
|
|
||||||
// Check if logged in and sync cloud state
|
// Check if logged in and sync cloud state
|
||||||
if (this.sync.isLoggedIn()) {
|
if (this.sync.isLoggedIn()) {
|
||||||
this.sync.fetchCloudState().then(cloud => {
|
this.sync.fetchCloudState().then(cloud => {
|
||||||
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
|
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
|
||||||
// Cloud save is newer, merge and save
|
|
||||||
this.sm.state = { ...this.sm.state, ...cloud };
|
this.sm.state = { ...this.sm.state, ...cloud };
|
||||||
this.sm.save();
|
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 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.lastFrameTime = currentTime;
|
||||||
|
|
||||||
this.engine.tick(dt);
|
this.engine.tick(dt);
|
||||||
this.updateHud();
|
this.updateHud();
|
||||||
|
this.updateActiveView();
|
||||||
|
this.updateInspectorPane();
|
||||||
|
|
||||||
// Auto-save every 5 seconds
|
// Auto-save every 5 seconds
|
||||||
if (Date.now() - this.lastSaveTime > 5000) {
|
if (Date.now() - this.lastSaveTime > 5000) {
|
||||||
|
|
@ -63,12 +73,6 @@ class ProductionApp {
|
||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
|
|
||||||
// Periodic view refresh for timers and progress bars (every 250ms)
|
|
||||||
setInterval(() => {
|
|
||||||
this.renderActiveView();
|
|
||||||
this.renderInspectorPane();
|
|
||||||
}, 250);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupDomEvents() {
|
setupDomEvents() {
|
||||||
|
|
@ -81,7 +85,7 @@ class ProductionApp {
|
||||||
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
|
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
|
||||||
b.classList.toggle('active', b.dataset.tab === tab);
|
b.classList.toggle('active', b.dataset.tab === tab);
|
||||||
});
|
});
|
||||||
this.renderActiveView();
|
this.mountActiveView();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,7 +96,7 @@ class ProductionApp {
|
||||||
this.sm.state.buyMode = mode;
|
this.sm.state.buyMode = mode;
|
||||||
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('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) {
|
switch (action.type) {
|
||||||
case 'SELECT_SECTOR':
|
case 'SELECT_SECTOR':
|
||||||
state.selectedSector = action.sector;
|
state.selectedSector = action.sector;
|
||||||
this.renderActiveView();
|
this.mountActiveView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'SELECT_PRODUCT':
|
case 'SELECT_PRODUCT':
|
||||||
state.selectedProductId = action.productId;
|
state.selectedProductId = action.productId;
|
||||||
this.renderActiveView();
|
this.mountInspectorPane();
|
||||||
this.renderInspectorPane();
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'UNLOCK_PRODUCT': {
|
case 'UNLOCK_PRODUCT': {
|
||||||
|
|
@ -143,7 +146,8 @@ class ProductionApp {
|
||||||
state.selectedProductId = p.id;
|
state.selectedProductId = p.id;
|
||||||
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
|
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
|
||||||
this.sync.queueAction(action);
|
this.sync.queueAction(action);
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +162,8 @@ class ProductionApp {
|
||||||
pState.lines += calc.count;
|
pState.lines += calc.count;
|
||||||
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
|
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
|
||||||
this.sync.queueAction(action);
|
this.sync.queueAction(action);
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -173,7 +178,7 @@ class ProductionApp {
|
||||||
state.stats.managersHired = (state.stats.managersHired || 0) + 1;
|
state.stats.managersHired = (state.stats.managersHired || 0) + 1;
|
||||||
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
|
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
|
||||||
this.sync.queueAction(action);
|
this.sync.queueAction(action);
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +193,8 @@ class ProductionApp {
|
||||||
pState.logistics = (pState.logistics || 0) + 1;
|
pState.logistics = (pState.logistics || 0) + 1;
|
||||||
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
|
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
|
||||||
this.sync.queueAction(action);
|
this.sync.queueAction(action);
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -198,7 +204,6 @@ class ProductionApp {
|
||||||
const p = getProduct(action.productId);
|
const p = getProduct(action.productId);
|
||||||
const pState = state.products[action.productId];
|
const pState = state.products[action.productId];
|
||||||
if (p && pState) {
|
if (p && pState) {
|
||||||
// Check inputs
|
|
||||||
const isRaw = !p.inputs || p.inputs.length === 0;
|
const isRaw = !p.inputs || p.inputs.length === 0;
|
||||||
let canRun = true;
|
let canRun = true;
|
||||||
if (!isRaw) {
|
if (!isRaw) {
|
||||||
|
|
@ -219,7 +224,6 @@ class ProductionApp {
|
||||||
} else {
|
} else {
|
||||||
this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
|
this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
|
||||||
}
|
}
|
||||||
this.renderAll();
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +235,6 @@ class ProductionApp {
|
||||||
const pState = state.products[c.productId];
|
const pState = state.products[c.productId];
|
||||||
if (pState && pState.stock >= c.targetAmount) {
|
if (pState && pState.stock >= c.targetAmount) {
|
||||||
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 subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
|
||||||
const totalPayout = c.rewardCash * subsidyBonus;
|
const totalPayout = c.rewardCash * subsidyBonus;
|
||||||
state.cash += totalPayout;
|
state.cash += totalPayout;
|
||||||
|
|
@ -242,7 +245,7 @@ class ProductionApp {
|
||||||
state.activeContracts.splice(idx, 1);
|
state.activeContracts.splice(idx, 1);
|
||||||
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
|
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
|
||||||
this.sync.queueAction(action);
|
this.sync.queueAction(action);
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -254,7 +257,8 @@ class ProductionApp {
|
||||||
const success = this.sm.performIPO();
|
const success = this.sm.performIPO();
|
||||||
if (success) {
|
if (success) {
|
||||||
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
|
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -262,7 +266,6 @@ class ProductionApp {
|
||||||
|
|
||||||
case 'BUY_PERK': {
|
case 'BUY_PERK': {
|
||||||
const perk = (state.boardroomPerks || {})[action.perkId] || 0;
|
const perk = (state.boardroomPerks || {})[action.perkId] || 0;
|
||||||
// Find perk definition
|
|
||||||
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
|
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
|
||||||
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
|
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
|
||||||
if (def && perk < def.maxLevel) {
|
if (def && perk < def.maxLevel) {
|
||||||
|
|
@ -271,7 +274,7 @@ class ProductionApp {
|
||||||
state.ventureShares -= cost;
|
state.ventureShares -= cost;
|
||||||
state.boardroomPerks[action.perkId] = perk + 1;
|
state.boardroomPerks[action.perkId] = perk + 1;
|
||||||
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
|
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -290,7 +293,6 @@ class ProductionApp {
|
||||||
state.cash += earned;
|
state.cash += earned;
|
||||||
state.lifetimeRevenue += earned;
|
state.lifetimeRevenue += earned;
|
||||||
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
|
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
|
||||||
this.renderAll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -302,16 +304,28 @@ class ProductionApp {
|
||||||
const state = this.sm.state;
|
const state = this.sm.state;
|
||||||
|
|
||||||
const elCash = document.getElementById('hud-cash');
|
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');
|
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');
|
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');
|
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');
|
const elContractsBadge = document.getElementById('contracts-badge');
|
||||||
if (elContractsBadge) {
|
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');
|
const container = document.getElementById('main-viewport');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const tab = this.sm.state.activeTab || 'production';
|
const tab = this.sm.state.activeTab || 'production';
|
||||||
|
const sector = this.sm.state.selectedSector || 'all';
|
||||||
|
this.currentMountedTab = tab;
|
||||||
|
this.currentMountedSector = sector;
|
||||||
|
|
||||||
if (tab === 'production') {
|
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') {
|
} 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') {
|
} 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') {
|
} 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');
|
const inspectorContainer = document.getElementById('desktop-inspector-panel');
|
||||||
if (inspectorContainer) {
|
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();
|
* Executes fine-grained in-place element updates without innerHTML teardowns
|
||||||
this.renderActiveView();
|
*/
|
||||||
this.renderInspectorPane();
|
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') {
|
showToast(message, type = 'info') {
|
||||||
|
|
@ -420,7 +474,8 @@ class ProductionApp {
|
||||||
const next = current === 'standard' ? 'scientific' : 'standard';
|
const next = current === 'standard' ? 'scientific' : 'standard';
|
||||||
setNotation(next);
|
setNotation(next);
|
||||||
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
|
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
|
||||||
this.renderAll();
|
this.mountActiveView();
|
||||||
|
this.mountInspectorPane();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// Production - The Idle Game: Corporate Milestones View
|
// Production - The Idle Game: Corporate Milestones View
|
||||||
|
// Fine-grained DOM updates without innerHTML teardowns
|
||||||
|
|
||||||
import { ACHIEVEMENTS } from '../data/achievements.js';
|
import { ACHIEVEMENTS } from '../data/achievements.js';
|
||||||
import { fmtCurrency, fmtNum } from '../core/formatters.js';
|
import { fmtCurrency, fmtNum } from '../core/formatters.js';
|
||||||
|
|
||||||
export function renderAchievements(container, state, onAction) {
|
export function mountAchievements(container, state, onAction) {
|
||||||
const achState = state.achievements || {};
|
const achState = state.achievements || {};
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
|
|
@ -35,9 +36,9 @@ export function renderAchievements(container, state, onAction) {
|
||||||
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
|
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextThreshold) * 100));
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="achievement-card ${currentTier > 0 ? 'achieved' : ''}">
|
<div class="achievement-card ${currentTier > 0 ? 'achieved' : ''}" id="ach-card-${ach.id}">
|
||||||
<div class="ach-icon-col">
|
<div class="ach-icon-col">
|
||||||
<div class="ach-icon-circle ${currentTier > 0 ? 'ach-unlocked' : ''}">
|
<div class="ach-icon-circle ${currentTier > 0 ? 'ach-unlocked' : ''}" id="ach-icon-${ach.id}">
|
||||||
<iconify-icon icon="${ach.icon}"></iconify-icon>
|
<iconify-icon icon="${ach.icon}"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,15 +46,15 @@ export function renderAchievements(container, state, onAction) {
|
||||||
<div class="ach-info-col">
|
<div class="ach-info-col">
|
||||||
<div class="ach-title-row">
|
<div class="ach-title-row">
|
||||||
<h4 class="ach-title">${ach.title}</h4>
|
<h4 class="ach-title">${ach.title}</h4>
|
||||||
<span class="ach-tier-badge">Tier ${currentTier} / ${maxTier}</span>
|
<span class="ach-tier-badge" id="ach-tier-${ach.id}">Tier ${currentTier} / ${maxTier}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="ach-desc">${ach.description}</p>
|
<p class="ach-desc">${ach.description}</p>
|
||||||
<div class="ach-progress-bar-track">
|
<div class="ach-progress-bar-track">
|
||||||
<div class="ach-progress-bar-fill" style="width: ${progressPct}%;"></div>
|
<div class="ach-progress-bar-fill" id="ach-fill-${ach.id}" style="width: ${progressPct}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ach-progress-labels font-mono">
|
<div class="ach-progress-labels font-mono">
|
||||||
<span>${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}</span>
|
<span id="ach-curr-val-${ach.id}">${ach.reqType === 'total_revenue' ? fmtCurrency(currentVal) : fmtNum(currentVal, 0)}</span>
|
||||||
<span>${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}</span>
|
<span id="ach-next-val-${ach.id}">${isMaxed ? 'Completed!' : (ach.reqType === 'total_revenue' ? fmtCurrency(nextThreshold) : fmtNum(nextThreshold, 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -63,3 +64,34 @@ export function renderAchievements(container, state, onAction) {
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
container.innerHTML = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// Production - The Idle Game: Corporate IPO & Boardroom Perks View
|
// 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 { BOARDROOM_PERKS, calculateSharesEarned, IPO_MIN_VALUATION } from '../data/boardroom.js';
|
||||||
import { fmtCurrency, fmtNum } from '../core/formatters.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 earnedShares = calculateSharesEarned(state.lifetimeRevenue);
|
||||||
const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
|
const canIPO = state.lifetimeRevenue >= IPO_MIN_VALUATION;
|
||||||
const perks = state.boardroomPerks || {};
|
const perks = state.boardroomPerks || {};
|
||||||
|
|
@ -23,27 +24,27 @@ export function renderBoardroom(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-valuation-card">
|
<div class="hero-valuation-card">
|
||||||
<div class="val-label">Corporate Enterprise Valuation</div>
|
<div class="val-label">Corporate Enterprise Valuation</div>
|
||||||
<div class="val-number font-mono">${fmtCurrency(state.valuation)}</div>
|
<div class="val-number font-mono" id="boardroom-val-number">${fmtCurrency(state.valuation)}</div>
|
||||||
<div class="val-lifetime">Lifetime Gross Revenue: <strong>${fmtCurrency(state.lifetimeRevenue)}</strong></div>
|
<div class="val-lifetime">Lifetime Gross Revenue: <strong id="boardroom-val-lifetime">${fmtCurrency(state.lifetimeRevenue)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ipo-action-banner ${canIPO ? 'ready-ipo' : 'locked-ipo'}">
|
<div class="ipo-action-banner ${canIPO ? 'ready-ipo' : 'locked-ipo'}" id="boardroom-ipo-banner">
|
||||||
<div class="ipo-banner-info">
|
<div class="ipo-banner-info">
|
||||||
<div class="ipo-shares-badge font-mono">
|
<div class="ipo-shares-badge font-mono">
|
||||||
<iconify-icon icon="lucide:gem"></iconify-icon>
|
<iconify-icon icon="lucide:gem"></iconify-icon>
|
||||||
<span>+${fmtNum(earnedShares, 0)} $V-Shares</span>
|
<span id="boardroom-earned-shares">+${fmtNum(earnedShares, 0)} $V-Shares</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="ipo-banner-title">Initial Public Offering (IPO) Ready</h4>
|
<h4 class="ipo-banner-title">Initial Public Offering (IPO) Ready</h4>
|
||||||
<p class="ipo-banner-sub">
|
<p class="ipo-banner-sub" id="boardroom-ipo-sub">
|
||||||
${canIPO
|
${canIPO
|
||||||
? `Execute corporate IPO now to claim ${fmtNum(earnedShares, 0)} Venture Shares and restart with permanent bonuses.`
|
? `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.`}
|
: `Requires minimum ${fmtCurrency(IPO_MIN_VALUATION)} in lifetime revenue to file for IPO.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary-corp btn-ipo-trigger" ${canIPO ? '' : 'disabled'}>
|
<button class="btn-primary-corp btn-ipo-trigger" id="boardroom-btn-ipo" ${canIPO ? '' : 'disabled'}>
|
||||||
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||||||
<span>Launch IPO</span>
|
<span>Launch IPO</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -53,11 +54,11 @@ export function renderBoardroom(container, state, onAction) {
|
||||||
<div class="boardroom-shares-summary">
|
<div class="boardroom-shares-summary">
|
||||||
<div class="shares-pill">
|
<div class="shares-pill">
|
||||||
<span class="shares-label">Available $V-Shares:</span>
|
<span class="shares-label">Available $V-Shares:</span>
|
||||||
<strong class="shares-val font-mono"><iconify-icon icon="lucide:gem"></iconify-icon> ${fmtNum(state.ventureShares, 0)}</strong>
|
<strong class="shares-val font-mono"><iconify-icon icon="lucide:gem"></iconify-icon> <span id="boardroom-avail-shares">${fmtNum(state.ventureShares, 0)}</span></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="shares-pill">
|
<div class="shares-pill">
|
||||||
<span class="shares-label">IPOs Completed:</span>
|
<span class="shares-label">IPOs Completed:</span>
|
||||||
<strong class="shares-val font-mono">${state.ipoCount || 0}</strong>
|
<strong class="shares-val font-mono" id="boardroom-ipos-completed">${state.ipoCount || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -76,13 +77,13 @@ export function renderBoardroom(container, state, onAction) {
|
||||||
const canAfford = state.ventureShares >= nextCost && !isMaxed;
|
const canAfford = state.ventureShares >= nextCost && !isMaxed;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="perk-card ${isMaxed ? 'perk-maxed' : ''}">
|
<div class="perk-card ${isMaxed ? 'perk-maxed' : ''}" id="perk-card-${perk.id}">
|
||||||
<div class="perk-header">
|
<div class="perk-header">
|
||||||
<div class="perk-icon-wrap" style="background: ${perk.color}15; color: ${perk.color};">
|
<div class="perk-icon-wrap" style="background: ${perk.color}15; color: ${perk.color};">
|
||||||
<iconify-icon icon="${perk.icon}" class="icon-lg"></iconify-icon>
|
<iconify-icon icon="${perk.icon}" class="icon-lg"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="perk-tier-badge">Level ${currentLvl} / ${perk.maxLevel}</div>
|
<div class="perk-tier-badge" id="perk-tier-${perk.id}">Level ${currentLvl} / ${perk.maxLevel}</div>
|
||||||
<h4 class="perk-name">${perk.name}</h4>
|
<h4 class="perk-name">${perk.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,9 +95,9 @@ export function renderBoardroom(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="perk-footer">
|
<div class="perk-footer">
|
||||||
<button class="btn-corp-perk btn-buy-perk" data-perk="${perk.id}" ${canAfford ? '' : 'disabled'}>
|
<button class="btn-corp-perk btn-buy-perk" id="btn-perk-${perk.id}" data-perk="${perk.id}" ${canAfford ? '' : 'disabled'}>
|
||||||
<iconify-icon icon="lucide:gem"></iconify-icon>
|
<iconify-icon icon="lucide:gem"></iconify-icon>
|
||||||
<span>${isMaxed ? 'Max Tier Acquired' : `Upgrade (${fmtNum(nextCost, 0)} $V-Shares)`}</span>
|
<span id="btn-perk-text-${perk.id}">${isMaxed ? 'Max Tier Acquired' : `Upgrade (${fmtNum(nextCost, 0)} $V-Shares)`}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,3 +116,34 @@ export function renderBoardroom(container, state, onAction) {
|
||||||
btn.onclick = () => onAction({ type: 'BUY_PERK', perkId: btn.dataset.perk });
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
// Production - The Idle Game: B2B Procurement Contracts Board
|
// Production - The Idle Game: B2B Procurement Contracts Board
|
||||||
|
// Fine-grained DOM updates without innerHTML teardowns
|
||||||
|
|
||||||
import { getProduct } from '../data/products.js';
|
import { getProduct } from '../data/products.js';
|
||||||
import { getClient } from '../data/contracts.js';
|
import { getClient } from '../data/contracts.js';
|
||||||
import { fmtCurrency, fmtNum, fmtTime } from '../core/formatters.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 || [];
|
const contracts = state.activeContracts || [];
|
||||||
|
currentMountedContractUids = contracts.map(c => c.uid).join(',');
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div class="contracts-header-pane">
|
<div class="contracts-header-pane">
|
||||||
|
|
@ -20,7 +24,7 @@ export function renderContractsBoard(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
<div class="contracts-meta-badge">
|
<div class="contracts-meta-badge">
|
||||||
<span>Completed Tenders:</span>
|
<span>Completed Tenders:</span>
|
||||||
<strong>${state.stats.contractsCompleted || 0}</strong>
|
<strong id="contract-stats-completed">${state.stats.contractsCompleted || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -45,7 +49,7 @@ export function renderContractsBoard(container, state, onAction) {
|
||||||
const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
|
const progressPct = Math.min(100, Math.floor((currentStock / c.targetAmount) * 100));
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="contract-card ${canFulfill ? 'ready-to-fulfill' : ''}">
|
<div class="contract-card ${canFulfill ? 'ready-to-fulfill' : ''}" id="contract-${c.uid}">
|
||||||
<div class="contract-client-row">
|
<div class="contract-client-row">
|
||||||
<div class="client-brand">
|
<div class="client-brand">
|
||||||
<div class="client-avatar">
|
<div class="client-avatar">
|
||||||
|
|
@ -56,9 +60,9 @@ export function renderContractsBoard(container, state, onAction) {
|
||||||
<span class="client-badge">${client.badge}</span>
|
<span class="client-badge">${client.badge}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="contract-timer font-mono ${c.remainingSeconds < 60 ? 'timer-urgent' : ''}">
|
<div class="contract-timer font-mono ${c.remainingSeconds < 60 ? 'timer-urgent' : ''}" id="timer-box-${c.uid}">
|
||||||
<iconify-icon icon="lucide:timer"></iconify-icon>
|
<iconify-icon icon="lucide:timer"></iconify-icon>
|
||||||
<span>${fmtTime(c.remainingSeconds)}</span>
|
<span id="timer-val-${c.uid}">${fmtTime(c.remainingSeconds)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -73,12 +77,12 @@ export function renderContractsBoard(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="contract-req-progress font-mono">
|
<div class="contract-req-progress font-mono">
|
||||||
${fmtNum(currentStock, 0)} / <strong>${fmtNum(c.targetAmount, 0)}</strong> ${product?.unit || 'units'}
|
<span id="c-curr-stock-${c.uid}">${fmtNum(currentStock, 0)}</span> / <strong>${fmtNum(c.targetAmount, 0)}</strong> ${product?.unit || 'units'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contract-progress-track">
|
<div class="contract-progress-track">
|
||||||
<div class="contract-progress-fill" style="width: ${progressPct}%;"></div>
|
<div class="contract-progress-fill" id="c-fill-${c.uid}" style="width: ${progressPct}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contract-payout-box">
|
<div class="contract-payout-box">
|
||||||
|
|
@ -94,9 +98,9 @@ export function renderContractsBoard(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contract-footer">
|
<div class="contract-footer">
|
||||||
<button class="btn-primary-corp btn-fulfill-contract" data-uid="${c.uid}" ${canFulfill ? '' : 'disabled'}>
|
<button class="btn-primary-corp btn-fulfill-contract" id="c-btn-${c.uid}" data-uid="${c.uid}" ${canFulfill ? '' : 'disabled'}>
|
||||||
<iconify-icon icon="lucide:check-check"></iconify-icon>
|
<iconify-icon icon="lucide:check-check"></iconify-icon>
|
||||||
<span>${canFulfill ? 'Deliver Shipment & Collect Payout' : `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`}</span>
|
<span id="c-btn-text-${c.uid}">${canFulfill ? 'Deliver Shipment & Collect Payout' : `Fulfill Tender (Need ${fmtNum(c.targetAmount - currentStock, 0)} more)`}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,3 +114,53 @@ export function renderContractsBoard(container, state, onAction) {
|
||||||
btn.onclick = () => onAction({ type: 'FULFILL_CONTRACT', uid: btn.dataset.uid });
|
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)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
// Production - The Idle Game: Desktop Inspector & Supply Chain Analytics Panel
|
// 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 { PRODUCTS, SECTORS, getProduct } from '../data/products.js';
|
||||||
import { fmtCurrency, fmtNum } from '../core/formatters.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 selectedId = state.selectedProductId || 'wheat';
|
||||||
const product = getProduct(selectedId) || PRODUCTS[0];
|
const product = getProduct(selectedId) || PRODUCTS[0];
|
||||||
const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0, unlocked: false };
|
const pState = state.products[product.id] || { stock: 0, lines: 0, logistics: 0, unlocked: false };
|
||||||
const sector = SECTORS.find(s => s.id === product.sector);
|
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));
|
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 totalLiquidationValue = (pState.stock || 0) * product.basePrice;
|
||||||
const halfLiquidationValue = totalLiquidationValue * 0.5;
|
const halfLiquidationValue = totalLiquidationValue * 0.5;
|
||||||
|
|
||||||
|
|
@ -33,7 +36,7 @@ export function renderInspector(container, state, onAction) {
|
||||||
<div class="inspector-metrics-grid">
|
<div class="inspector-metrics-grid">
|
||||||
<div class="metric-box">
|
<div class="metric-box">
|
||||||
<span class="metric-label">Warehouse Stock:</span>
|
<span class="metric-label">Warehouse Stock:</span>
|
||||||
<strong class="metric-val font-mono">${fmtNum(pState.stock, 0)} ${product.unit}</strong>
|
<strong class="metric-val font-mono" id="insp-stock">${fmtNum(pState.stock, 0)} ${product.unit}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-box">
|
<div class="metric-box">
|
||||||
<span class="metric-label">Wholesale Price:</span>
|
<span class="metric-label">Wholesale Price:</span>
|
||||||
|
|
@ -41,11 +44,11 @@ export function renderInspector(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-box">
|
<div class="metric-box">
|
||||||
<span class="metric-label">Production Lines:</span>
|
<span class="metric-label">Production Lines:</span>
|
||||||
<strong class="metric-val font-mono">${pState.lines} active</strong>
|
<strong class="metric-val font-mono" id="insp-lines">${pState.lines} active</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-box">
|
<div class="metric-box">
|
||||||
<span class="metric-label">Logistics Fleets:</span>
|
<span class="metric-label">Logistics Fleets:</span>
|
||||||
<strong class="metric-val font-mono">${pState.logistics || 0} active</strong>
|
<strong class="metric-val font-mono" id="insp-logistics">${pState.logistics || 0} active</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -56,11 +59,11 @@ export function renderInspector(container, state, onAction) {
|
||||||
</h4>
|
</h4>
|
||||||
<p class="section-subtext">Liquidate stockpiles immediately onto the wholesale spot market.</p>
|
<p class="section-subtext">Liquidate stockpiles immediately onto the wholesale spot market.</p>
|
||||||
<div class="liquidation-buttons">
|
<div class="liquidation-buttons">
|
||||||
<button class="btn-corp-secondary btn-liquidate-half" data-product="${product.id}" ${pState.stock >= 2 ? '' : 'disabled'}>
|
<button class="btn-corp-secondary" id="insp-btn-half" data-product="${product.id}" ${pState.stock >= 2 ? '' : 'disabled'}>
|
||||||
Sell 50% (${fmtCurrency(halfLiquidationValue)})
|
Sell 50% (<span id="insp-val-half">${fmtCurrency(halfLiquidationValue)}</span>)
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-primary-corp btn-liquidate-all" data-product="${product.id}" ${pState.stock >= 1 ? '' : 'disabled'}>
|
<button class="btn-primary-corp" id="insp-btn-all" data-product="${product.id}" ${pState.stock >= 1 ? '' : 'disabled'}>
|
||||||
Sell 100% (${fmtCurrency(totalLiquidationValue)})
|
Sell 100% (<span id="insp-val-all">${fmtCurrency(totalLiquidationValue)}</span>)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,7 +90,7 @@ export function renderInspector(container, state, onAction) {
|
||||||
<iconify-icon icon="${parent.icon}" style="color: ${parent.color};"></iconify-icon>
|
<iconify-icon icon="${parent.icon}" style="color: ${parent.color};"></iconify-icon>
|
||||||
<span>${parent.name}</span>
|
<span>${parent.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="chain-item-ratio font-mono">${inp.amount} per unit (${fmtNum(parentStock, 0)} on hand)</span>
|
<span class="chain-item-ratio font-mono">${inp.amount} per unit (<strong id="insp-inp-val-${inp.id}">${fmtNum(parentStock, 0)}</strong> on hand)</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
|
@ -110,7 +113,7 @@ export function renderInspector(container, state, onAction) {
|
||||||
<iconify-icon icon="${child.icon}" style="color: ${child.color};"></iconify-icon>
|
<iconify-icon icon="${child.icon}" style="color: ${child.color};"></iconify-icon>
|
||||||
<span>${child.name}</span>
|
<span>${child.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="chain-badge ${isUnlocked ? 'badge-open' : 'badge-locked'}">
|
<span class="chain-badge ${isUnlocked ? 'badge-open' : 'badge-locked'}" id="insp-child-${child.id}">
|
||||||
${isUnlocked ? 'Active' : 'Locked'}
|
${isUnlocked ? 'Active' : 'Locked'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,12 +128,12 @@ export function renderInspector(container, state, onAction) {
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
const halfBtn = container.querySelector('.btn-liquidate-half');
|
const halfBtn = container.querySelector('#insp-btn-half');
|
||||||
if (halfBtn) {
|
if (halfBtn) {
|
||||||
halfBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 0.5 });
|
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) {
|
if (allBtn) {
|
||||||
allBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 1.0 });
|
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 });
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// Production - The Idle Game: Production Floor View
|
// 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 { PRODUCTS, SECTORS, getProduct, getNextMilestone, getUnitMilestoneMultiplier } from '../data/products.js';
|
||||||
import { fmtCurrency, fmtNum } from '../core/formatters.js';
|
import { fmtCurrency, fmtNum } from '../core/formatters.js';
|
||||||
|
|
||||||
export function calculateMultiCost(baseCost, currentCount, multiplier) {
|
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;
|
const growth = 1.15;
|
||||||
let countToBuy = 1;
|
let countToBuy = 1;
|
||||||
|
|
||||||
|
|
@ -16,7 +15,7 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
|
||||||
const nextTarget = getNextMilestone(currentCount);
|
const nextTarget = getNextMilestone(currentCount);
|
||||||
countToBuy = Math.max(1, nextTarget - currentCount);
|
countToBuy = Math.max(1, nextTarget - currentCount);
|
||||||
} else if (multiplier === 'max') {
|
} else if (multiplier === 'max') {
|
||||||
countToBuy = 1; // Will be handled dynamically in loop
|
countToBuy = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalCost = 0;
|
let totalCost = 0;
|
||||||
|
|
@ -26,7 +25,10 @@ export function calculateMultiCost(baseCost, currentCount, multiplier) {
|
||||||
return { count: countToBuy, cost: totalCost };
|
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 selectedSector = state.selectedSector || 'all';
|
||||||
const buyMode = state.buyMode || '1';
|
const buyMode = state.buyMode || '1';
|
||||||
|
|
||||||
|
|
@ -60,10 +62,9 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
const isSelected = state.selectedProductId === p.id;
|
const isSelected = state.selectedProductId === p.id;
|
||||||
|
|
||||||
if (!pState.unlocked) {
|
if (!pState.unlocked) {
|
||||||
// Locked Card
|
|
||||||
const canAffordUnlock = state.cash >= p.unlockCost;
|
const canAffordUnlock = state.cash >= p.unlockCost;
|
||||||
html += `
|
html += `
|
||||||
<div class="facility-card locked-card ${isSelected ? 'selected' : ''}" data-product="${p.id}">
|
<div class="facility-card locked-card ${isSelected ? 'selected' : ''}" id="card-${p.id}" data-product="${p.id}">
|
||||||
<div class="facility-header">
|
<div class="facility-header">
|
||||||
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
|
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
|
||||||
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
|
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
|
||||||
|
|
@ -77,7 +78,7 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
<p class="facility-desc">${p.description}</p>
|
<p class="facility-desc">${p.description}</p>
|
||||||
<div class="locked-footer">
|
<div class="locked-footer">
|
||||||
<button class="btn-primary-corp btn-unlock" data-product="${p.id}" ${canAffordUnlock ? '' : 'disabled'}>
|
<button class="btn-primary-corp btn-unlock" id="btn-unlock-${p.id}" data-product="${p.id}" ${canAffordUnlock ? '' : 'disabled'}>
|
||||||
<iconify-icon icon="lucide:lock-open"></iconify-icon>
|
<iconify-icon icon="lucide:lock-open"></iconify-icon>
|
||||||
<span>Acquire Permit (${fmtCurrency(p.unlockCost)})</span>
|
<span>Acquire Permit (${fmtCurrency(p.unlockCost)})</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -87,7 +88,6 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlocked Active Card
|
|
||||||
const nextM = getNextMilestone(pState.lines);
|
const nextM = getNextMilestone(pState.lines);
|
||||||
const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
|
const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
|
||||||
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
|
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, buyMode);
|
||||||
|
|
@ -97,7 +97,6 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
|
const logisticsCost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
|
||||||
const canAffordLogistics = state.cash >= logisticsCost;
|
const canAffordLogistics = state.cash >= logisticsCost;
|
||||||
|
|
||||||
// Input Requirements status
|
|
||||||
const isRaw = !p.inputs || p.inputs.length === 0;
|
const isRaw = !p.inputs || p.inputs.length === 0;
|
||||||
let inputsHtml = '';
|
let inputsHtml = '';
|
||||||
if (!isRaw) {
|
if (!isRaw) {
|
||||||
|
|
@ -111,9 +110,9 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
const neededPerLine = inp.amount * pState.lines;
|
const neededPerLine = inp.amount * pState.lines;
|
||||||
const isLow = currentStock < neededPerLine;
|
const isLow = currentStock < neededPerLine;
|
||||||
return `
|
return `
|
||||||
<span class="input-chip ${isLow ? 'low-stock' : ''}" title="${inpProduct?.name}">
|
<span class="input-chip ${isLow ? 'low-stock' : ''}" id="chip-${p.id}-${inp.id}" title="${inpProduct?.name}">
|
||||||
<iconify-icon icon="${inpProduct?.icon || 'lucide:box'}"></iconify-icon>
|
<iconify-icon icon="${inpProduct?.icon || 'lucide:box'}"></iconify-icon>
|
||||||
${inpProduct?.name}: <strong>${fmtNum(currentStock, 0)}</strong> / ${fmtNum(neededPerLine, 0)}
|
${inpProduct?.name}: <strong id="chip-val-${p.id}-${inp.id}">${fmtNum(currentStock, 0)}</strong> / ${fmtNum(neededPerLine, 0)}
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
|
@ -124,17 +123,17 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
|
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
if (pState.isBottlenecked) {
|
if (pState.isBottlenecked) {
|
||||||
statusBadge = `<span class="status-badge badge-warning"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`;
|
statusBadge = `<span class="status-badge badge-warning" id="badge-${p.id}"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`;
|
||||||
} else if (pState.lines > 0) {
|
} else if (pState.lines > 0) {
|
||||||
statusBadge = `<span class="status-badge badge-success"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`;
|
statusBadge = `<span class="status-badge badge-success" id="badge-${p.id}"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`;
|
||||||
} else {
|
} else {
|
||||||
statusBadge = `<span class="status-badge badge-idle">Idle (0 Lines)</span>`;
|
statusBadge = `<span class="status-badge badge-idle" id="badge-${p.id}">Idle (0 Lines)</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));
|
const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="facility-card active-card ${isSelected ? 'selected' : ''}" data-product="${p.id}">
|
<div class="facility-card active-card ${isSelected ? 'selected' : ''}" id="card-${p.id}" data-product="${p.id}">
|
||||||
<div class="facility-header">
|
<div class="facility-header">
|
||||||
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
|
<div class="facility-icon-wrap" style="background: ${p.color}15; color: ${p.color};">
|
||||||
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
|
<iconify-icon icon="${p.icon}" class="icon-lg"></iconify-icon>
|
||||||
|
|
@ -144,23 +143,23 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
<span class="sector-badge" style="color: ${sector?.color || '#64748b'};">
|
<span class="sector-badge" style="color: ${sector?.color || '#64748b'};">
|
||||||
${sector?.name} • Tier ${p.tier}
|
${sector?.name} • Tier ${p.tier}
|
||||||
</span>
|
</span>
|
||||||
${statusBadge}
|
<div id="badge-wrap-${p.id}">${statusBadge}</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="facility-name">${p.name}</h3>
|
<h3 class="facility-name">${p.name}</h3>
|
||||||
<span class="facility-type-sub">${p.facilityName} • <strong>${pState.lines}</strong> lines (${nextM - pState.lines} to 2x)</span>
|
<span class="facility-type-sub" id="type-sub-${p.id}">${p.facilityName} • <strong>${pState.lines}</strong> lines (${nextM - pState.lines} to 2x)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cycle Progress Bar -->
|
<!-- Cycle Progress Bar -->
|
||||||
<div class="cycle-bar-track">
|
<div class="cycle-bar-track">
|
||||||
<div class="cycle-bar-fill" style="width: ${progressPct}%; background-color: ${p.color};"></div>
|
<div class="cycle-bar-fill" id="progress-${p.id}" style="width: ${progressPct}%; background-color: ${p.color};"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stock and Inventory Summary -->
|
<!-- Stock and Inventory Summary -->
|
||||||
<div class="facility-stats-grid">
|
<div class="facility-stats-grid">
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-label">Stockpile:</span>
|
<span class="stat-label">Stockpile:</span>
|
||||||
<span class="stat-value font-mono"><strong>${fmtNum(pState.stock, 0)}</strong> ${p.unit}</span>
|
<span class="stat-value font-mono"><strong id="stock-${p.id}">${fmtNum(pState.stock, 0)}</strong> ${p.unit}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-label">Wholesale Price:</span>
|
<span class="stat-label">Wholesale Price:</span>
|
||||||
|
|
@ -168,7 +167,7 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<span class="stat-label">Logistics Fleet:</span>
|
<span class="stat-label">Logistics Fleet:</span>
|
||||||
<span class="stat-value font-mono">${pState.logistics || 0} fleets</span>
|
<span class="stat-value font-mono" id="logistics-${p.id}">${pState.logistics || 0} fleets</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -181,13 +180,13 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
<span>Run Batch</span>
|
<span>Run Batch</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn-primary-corp btn-expand" data-product="${p.id}" ${canAffordBuy ? '' : 'disabled'}>
|
<button class="btn-primary-corp btn-expand" id="btn-expand-${p.id}" data-product="${p.id}" ${canAffordBuy ? '' : 'disabled'}>
|
||||||
<iconify-icon icon="lucide:plus-circle"></iconify-icon>
|
<iconify-icon icon="lucide:plus-circle"></iconify-icon>
|
||||||
<span>+${calc.count} Lines (${fmtCurrency(calc.cost)})</span>
|
<span id="btn-expand-text-${p.id}">+${calc.count} Lines (${fmtCurrency(calc.cost)})</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
${!pState.manager ? `
|
${!pState.manager ? `
|
||||||
<button class="btn-corp-secondary btn-hire-mgr" data-product="${p.id}" ${canAffordManager ? '' : 'disabled'} title="Automates production continuously">
|
<button class="btn-corp-secondary btn-hire-mgr" id="btn-hire-${p.id}" data-product="${p.id}" ${canAffordManager ? '' : 'disabled'} title="Automates production continuously">
|
||||||
<iconify-icon icon="lucide:user-check"></iconify-icon>
|
<iconify-icon icon="lucide:user-check"></iconify-icon>
|
||||||
<span>Hire Lead (${fmtCurrency(managerCost)})</span>
|
<span>Hire Lead (${fmtCurrency(managerCost)})</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -197,9 +196,9 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
</span>
|
</span>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<button class="btn-corp-secondary btn-expand-logistics" data-product="${p.id}" ${canAffordLogistics ? '' : 'disabled'} title="Increase passive sales capacity">
|
<button class="btn-corp-secondary btn-expand-logistics" id="btn-logistics-${p.id}" data-product="${p.id}" ${canAffordLogistics ? '' : 'disabled'} title="Increase passive sales capacity">
|
||||||
<iconify-icon icon="lucide:truck"></iconify-icon>
|
<iconify-icon icon="lucide:truck"></iconify-icon>
|
||||||
<span>+Logistics (${fmtCurrency(logisticsCost)})</span>
|
<span id="btn-logistics-text-${p.id}">+Logistics (${fmtCurrency(logisticsCost)})</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -209,14 +208,14 @@ export function renderProductionFloor(container, state, onAction) {
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Attach delegated events
|
// Attach event listeners ONCE
|
||||||
container.querySelectorAll('.sector-pill').forEach(btn => {
|
container.querySelectorAll('.sector-pill').forEach(btn => {
|
||||||
btn.onclick = () => onAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
|
btn.onclick = () => onAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
|
||||||
});
|
});
|
||||||
|
|
||||||
container.querySelectorAll('.facility-card').forEach(card => {
|
container.querySelectorAll('.facility-card').forEach(card => {
|
||||||
card.onclick = (e) => {
|
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 });
|
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 });
|
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 = `<iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs`;
|
||||||
|
} else if (!isBottlenecked && hasWarning) {
|
||||||
|
const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
|
||||||
|
elBadge.className = 'status-badge badge-success';
|
||||||
|
elBadge.innerHTML = `<iconify-icon icon="lucide:check-circle"></iconify-icon> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue