Files
ss14chemcalc/index.html
2026-01-24 16:34:15 -05:00

1626 lines
61 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nanotrasen Chemical Logistics - Crafting Calculator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=JetBrains+Mono:wght@400;700&display=swap"
rel="stylesheet">
<style>
:root {
--bg-deep: #0a0b10;
--bg-panel: rgba(20, 22, 32, 0.85);
--bg-card: rgba(35, 38, 55, 0.9);
--accent-blue: #00e5ff;
--accent-cyan: #00ffa2;
--accent-orange: #ff9d00;
--text-main: #e0e6ed;
--text-dim: #94a3b8;
--border: rgba(255, 255, 255, 0.1);
--glass-blur: blur(12px);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-deep);
color: var(--text-main);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background-image:
radial-gradient(circle at 50% 50%, rgba(0, 229, 255, 0.05) 0%, transparent 50%),
url("https://www.transparenttextures.com/patterns/carbon-fibre.png");
}
header {
padding: 1rem 2rem;
background: rgba(10, 11, 16, 0.95);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: var(--glass-blur);
z-index: 100;
}
.logo {
font-weight: 700;
font-size: 1.5rem;
letter-spacing: 2px;
color: var(--accent-blue);
text-transform: uppercase;
display: flex;
align-items: center;
gap: 10px;
}
.logo span {
color: white;
}
main {
flex: 1;
display: grid;
grid-template-columns: 320px 1fr 340px;
overflow: hidden;
gap: 1px;
background: var(--border);
}
.panel {
background: var(--bg-panel);
backdrop-filter: var(--glass-blur);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border);
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.search-container {
padding: 1rem;
}
#item-search {
width: 100%;
padding: 0.8rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border);
border-radius: 6px;
color: white;
font-family: inherit;
outline: none;
transition: border-color 0.3s;
}
#item-search:focus {
border-color: var(--accent-blue);
}
.item-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.item-btn {
width: 100%;
padding: 0.8rem 1rem;
background: transparent;
border: none;
color: var(--text-dim);
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
font-size: 0.9rem;
margin-bottom: 2px;
display: flex;
justify-content: space-between;
}
.item-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: white;
}
.item-btn.active {
background: rgba(0, 229, 255, 0.1);
color: var(--accent-blue);
border-left: 3px solid var(--accent-blue);
}
#viewport {
position: relative;
flex: 1;
background: #0d0e14;
overflow: hidden;
/* display: block; Default is block for div, so just removing flex is enough, or we can explictly set it if needed. absolute positioning of children works best with relative parent. */
background-image:
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
#tree-container {
position: absolute;
top: 0;
left: 0;
width: 5000px;
height: 5000px;
transform-origin: 0 0;
/* background: rgba(0,0,0,0.2); Debugging */
cursor: grab;
display: block;
}
.tree-node {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
min-width: 240px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
position: absolute;
/* Using absolute positioning */
z-index: 10;
}
.tree-node-header {
font-weight: 700;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
color: var(--accent-blue);
}
.recipe-select {
margin-bottom: 8px;
width: 100%;
background: #1a1a20;
color: #ddd;
border: 1px solid var(--border);
font-size: 0.75rem;
padding: 4px;
border-radius: 4px;
}
.quantity-input {
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.3);
padding: 5px;
border-radius: 6px;
border: 1px solid var(--border);
margin-bottom: 1rem;
}
.quantity-input input {
background: transparent;
border: none;
color: white;
width: 80px;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 1.1rem;
}
.raw-material-item {
display: flex;
justify-content: space-between;
padding: 0.6rem 0.8rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
margin-bottom: 6px;
font-size: 0.9rem;
border: 1px solid transparent;
transition: border-color 0.3s;
}
.raw-material-item:hover {
border-color: rgba(0, 229, 255, 0.3);
}
.raw-material-item span:last-child {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan);
font-weight: 600;
}
.btn {
padding: 0.5rem 1rem;
border: 1px solid var(--accent-blue);
background: transparent;
color: var(--accent-blue);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
transition: all 0.3s;
}
.btn:hover {
background: rgba(0, 229, 255, 0.1);
box-shadow: 0 0 10px rgba(0, 229, 255, 0.2);
}
#raw-total-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.node-reactant {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
opacity: 0.7;
margin-bottom: 2px;
}
.catalyst-tag {
color: var(--accent-orange);
font-size: 0.65rem;
font-weight: bold;
text-transform: uppercase;
border: 1px solid var(--accent-orange);
padding: 0 4px;
border-radius: 2px;
margin-left: 5px;
}
/* SVG Connectors */
#svg-connectors {
position: absolute;
top: 0;
left: 0;
width: 5000px;
height: 5000px;
pointer-events: none;
z-index: 1;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
backdrop-filter: blur(5px);
}
.modal.active {
display: flex;
}
.modal-content {
background: #1a1b26;
width: 90%;
max-width: 600px;
max-height: 85vh;
border-radius: 12px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.2rem;
font-weight: 700;
color: white;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
}
.step-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
}
.step-number {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
font-weight: 700;
color: var(--accent-blue);
opacity: 0.5;
}
.step-content {
flex: 1;
}
.step-header {
font-weight: 600;
color: white;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
}
.step-amount {
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
}
.step-ingredients {
font-size: 0.85rem;
color: var(--text-dim);
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem;
border-radius: 4px;
}
.ingredient-line {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: white;
}
.wizard-option {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
padding: 1rem;
margin-bottom: 0.5rem;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.wizard-option:hover {
border-color: var(--accent-blue);
background: rgba(0, 229, 255, 0.05);
}
.wizard-option-title {
font-weight: 600;
color: white;
margin-bottom: 0.3rem;
}
.wizard-ingredients {
font-size: 0.8rem;
color: var(--text-dim);
}
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
Nanotrasen <span>Chemical Logistics</span>
</div>
<div style="font-size: 0.8rem; color: var(--text-dim);">
AUTHORIZED PERSONNEL ONLY // SYSTEM ID: <span style="color: var(--accent-cyan);">CHEM-CALC-V2.1</span>
</div>
</header>
<main>
<div class="panel">
<div class="panel-header">REAGENT ARCHIVE</div>
<div class="search-container">
<input type="text" id="item-search" placeholder="Search reagents...">
<label
style="display: flex; align-items: center; gap: 8px; font-size: 0.75rem; color: var(--text-dim); margin-top: 8px; cursor: pointer; user-select: none;">
<input type="checkbox" id="hide-grind" onchange="syncGrindToggles(this.checked)">
Hide Grind/Juice Recipes
</label>
</div>
<div class="item-list" id="item-list"></div>
</div>
<div class="panel" style="background: transparent;">
<div class="panel-header" style="background: var(--bg-panel);">
SYNTHESIS TREE
<div style="font-size: 0.8rem; font-weight: normal; color: var(--text-dim);">
Scroll to Zoom | Drag to Pan
</div>
</div>
<div id="viewport">
<div id="tree-container">
<svg id="svg-connectors"></svg>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">LOGISTICS BREAKDOWN</div>
<div style="padding: 1rem;">
<div
style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 0.5rem; text-transform: uppercase;">
Target Quantity (u)</div>
<div class="quantity-input">
<button class="btn" onclick="adjustQty(-10)">-10</button>
<input type="number" id="target-qty" value="100" min="0">
<button class="btn" onclick="adjustQty(10)">+10</button>
</div>
</div>
<div id="raw-total-container">
<div
style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 0.8rem; text-transform: uppercase;">
Total Raw Materials</div>
<div id="raw-list"></div>
</div>
<div style="padding: 1rem; border-top: 1px solid var(--border); display: flex; gap: 10px;">
<button class="btn" style="flex: 1;" onclick="resetView()">Reset View</button>
<button class="btn" style="flex: 1; border-color: var(--accent-orange); color: var(--accent-orange);"
onclick="showSteps()">View Steps</button>
</div>
</div>
</main>
<div id="steps-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
Assembly Instructions
<button class="close-btn" onclick="closeSteps()">&times;</button>
</div>
<div class="modal-body" id="steps-list">
<!-- Steps go here -->
</div>
</div>
</div>
<div id="wizard-modal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
Configuration Wizard
<div style="font-size: 0.8rem; font-weight:normal; color: var(--text-dim);">Step <span
id="wizard-step-num">1</span></div>
</div>
<div class="modal-body">
<div style="margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border);">
<label
style="display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: var(--text-dim); cursor: pointer; user-select: none;">
<input type="checkbox" id="hide-grind-wizard" onchange="syncGrindToggles(this.checked)">
Hide Grind/Juice Recipes (Filters list below)
</label>
</div>
<h3 id="wizard-item-name"
style="color:var(--accent-blue); margin-bottom:1rem; font-family:'JetBrains Mono', monospace;"></h3>
<p style="margin-bottom:1rem; color:var(--text-dim);">How do you want to obtain this reagent?</p>
<div id="wizard-options"></div>
<button class="btn" onclick="cancelWizard()" style="width:100%; margin-top:1rem; opacity:0.7;">Cancel &
Use Defaults</button>
</div>
</div>
</div>
<script>
let recipes = {};
let currentReagent = null;
let selectedRecipes = {};
let targetQty = 100;
// Wizard State
let wizardQueue = [];
let wizardVisited = new Set();
let wizardSelections = {};
let scale = 1;
let posX = -1500; // Center ish of the 5000px container
let posY = -1500;
async function init() {
try {
const [recipesRes, extractablesRes] = await Promise.all([
fetch('recipes.json'),
fetch('extractables.json')
]);
recipes = await recipesRes.json();
const extractables = await extractablesRes.json();
processExtractables(extractables);
const sorted = Object.keys(recipes).sort();
const list = document.getElementById('item-list');
list.innerHTML = sorted.map(id => {
const name = formatName(id);
return `<button class="item-btn" onclick="selectReagent('${id}')" id="btn-${id}" data-search="${name.toLowerCase()}">
${name}
</button>`;
}).join('');
setupEvents();
if (sorted.length > 0) selectReagent(sorted[0], false); // Don't run wizard on initial load
// Set initial state
resetView();
} catch (e) {
console.error("Initialization error:", e);
}
}
function processExtractables(extractables) {
for (const itemId in extractables) {
const item = extractables[itemId];
// Grind Mapping
if (item.grind && item.grind.length > 0) {
const products = {};
item.grind.forEach(entry => {
products[entry.id] = entry.amount;
});
const recipe = {
id: `Grind ${item.name}`,
reactants: {
[item.id]: {
amount: 1,
catalyst: false
}
},
products: products,
minTemp: null,
requiredMixerCategories: ["Grind"],
priority: 0
};
for (const prodId of Object.keys(products)) {
if (!recipes[prodId]) recipes[prodId] = [];
recipes[prodId].push(recipe);
}
}
// Juice Mapping
if (item.juice && item.juice.length > 0) {
const products = {};
item.juice.forEach(entry => {
products[entry.id] = entry.amount;
});
const recipe = {
id: `Juice ${item.name}`,
reactants: {
[item.id]: {
amount: 1,
catalyst: false
}
},
products: products,
minTemp: null,
requiredMixerCategories: ["Juice"],
priority: 0
};
for (const prodId of Object.keys(products)) {
if (!recipes[prodId]) recipes[prodId] = [];
recipes[prodId].push(recipe);
}
}
}
}
function syncGrindToggles(checked) {
document.getElementById('hide-grind').checked = checked;
document.getElementById('hide-grind-wizard').checked = checked;
// Re-render UI
updateUI();
// If wizard is open, re-render current step
if (document.getElementById('wizard-modal').style.display === 'flex') {
processNextWizardStep();
}
}
function isGrindRecipe(recipe) {
if (!recipe.requiredMixerCategories) return false;
return recipe.requiredMixerCategories.includes('Grind') || recipe.requiredMixerCategories.includes('Juice');
}
function formatName(id) {
return id.replace(/([A-Z])/g, ' $1').trim();
}
function setupEvents() {
document.getElementById('item-search').addEventListener('input', (e) => {
const val = e.target.value.toLowerCase().trim();
document.querySelectorAll('.item-btn').forEach(btn => {
const searchData = btn.dataset.search || "";
if (searchData.includes(val)) {
btn.style.display = 'flex';
} else {
btn.style.display = 'none';
}
});
});
document.getElementById('target-qty').addEventListener('input', (e) => {
targetQty = parseFloat(e.target.value) || 0;
updateUI();
});
const viewport = document.getElementById('viewport');
const container = document.getElementById('tree-container');
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.min(Math.max(0.1, scale * delta), 3);
// Calculate mouse position relative to container
// We want the point under the mouse to remain stationary
// mouse X relative to viewport
const rect = viewport.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// The world coordinate under the mouse
const worldX = (mouseX - posX) / scale;
const worldY = (mouseY - posY) / scale;
// New PosX
posX = mouseX - worldX * newScale;
posY = mouseY - worldY * newScale;
scale = newScale;
updateTransform();
}, { passive: false });
let isDragging = false;
let lastX, lastY;
viewport.addEventListener('mousedown', (e) => {
isDragging = true;
lastX = e.clientX;
lastY = e.clientY;
container.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
posX += e.clientX - lastX;
posY += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
updateTransform();
});
window.addEventListener('mouseup', () => {
isDragging = false;
container.style.cursor = 'grab';
});
updateTransform();
}
// --- Recipe Calculator ---
class RecipeCalculator {
constructor(recipes, selectedRecipes) {
this.recipes = recipes;
this.selectedRecipes = selectedRecipes;
this.surplus = {}; // id -> amount available
this.steps = []; // Ordered list of steps
this.raw = {}; // id -> total amount needed
this.tree = []; // Visual tree structure columns
this.visited = new Set(); // For loop detection / memoization if needed?
// Actually standard recursion with surplus is stateful, so we might re-visit nodes but with different requirements.
// But we want to process dependencies first?
// "Pull" model:
// Request(ID, Amount) -> Check Surplus -> If not enough -> Run Recipe -> Add Byproducts to Surplus -> Add Ingredients to Request
}
calculate(targetId, targetAmount) {
this.surplus = {};
this.steps = [];
this.raw = {};
this.tree = []; // Array of arrays (columns)
// We need a specific "build node" recursive function that populates the visual tree
// AND handles the logic.
// To handle surplus correctly (byproduct recycling), we should process depth-first?
// Actually, if we produce A and B, and we need B later...
// Standard recursion mimics "Demand".
// If I need 10 Iron, I run the process. It produces 10 Iron + 10 Carbon.
// Now I have 10 Carbon surplus.
// Later (or in a parallel branch), I need 5 Carbon. I take from surplus.
this.processRequest(targetId, targetAmount, 0);
// The steps are currently in "process order" (recursive calls).
// This is roughly reverse topological sort (leaves first).
// But duplicates might exist if we process multiple times?
// For simply generating a linear list, "process order" is fine.
// We might want to consolidate steps? (e.g. grind steel 5 times -> Grind Steel x5)
// Let's consolidate steps by ID+Recipe at the end.
return {
steps: this.consolidateSteps(this.steps),
raw: this.raw,
tree: this.tree,
surplus: this.surplus
};
}
processRequest(id, amount, depth) {
if (amount <= 0.0001) return;
// 1. Check Surplus
const available = this.surplus[id] || 0;
if (available >= amount) {
this.surplus[id] -= amount;
this.addToTree(id, amount, depth, "surplus"); // Visual indication?
return; // Fully satisfied by surplus
}
const needed = amount - available;
if (available > 0) {
this.surplus[id] = 0; // All used
}
// 2. Find Recipe
const recipeList = this.recipes[id] || [];
const rIdx = this.getBestRecipeIndex(id);
// Visual Tree Node Creation
const node = this.addToTree(id, needed, depth, "producing");
// 3. If Raw or explicitly Raw
if (recipeList.length === 0 || rIdx === -1) {
this.raw[id] = (this.raw[id] || 0) + needed;
return;
}
// 4. Execute Recipe
const recipe = recipeList[rIdx];
const yieldAmt = recipe.products[id] || 1;
// How many batches?
const batches = needed / yieldAmt;
// Record Step
this.steps.push({
id: id,
recipe: recipe,
batches: batches,
amountProduced: batches * yieldAmt // might be slightly > needed if integer steps? keeping float for now
});
// 5. Produce Byproducts
// Everything produced by this recipe is now added to surplus
// EXCEPT the main product which we consume immediately (well, we produced 'needed')
// Actually, simpler model:
// Run recipe for 'batches'.
// Produces 'batches * productAmt' for ALL products.
// Add ALL to surplus.
// Then consume 'needed' from surplus (which should be fully covered).
// Retcon: Let's do the "Produce & Consume" flow to be safe.
// But we already did the "Check Surplus" at start.
// So we are crafting *exactly* enough to cover the deficit?
// If yield is 3 and we need 4, we maintain floats -> 1.33 batches.
for (const [pid, pAmt] of Object.entries(recipe.products)) {
const produced = pAmt * batches;
this.surplus[pid] = (this.surplus[pid] || 0) + produced;
}
// Consume the main reactant from surplus again?
// logic: needed = 10. produced = 10. surplus += 10.
// We proceed.
// The caller "consumed" it effectively by requesting it.
// Wait, if we add to surplus, subsequent calls might use it.
// But this current call stack *caused* it.
// We must ensure we don't double count.
// The "Surplus" check at the top handles "Previous" surplus.
// We are now filling the hole.
// We just generated new surplus.
// But we shouldn't use *this specific* surplus to satisfy *this* request, as we just calculated we needed it.
// So subtract 'needed' from surplus of ID.
this.surplus[id] -= needed;
// 6. Recurse for Ingredients
for (const [rid, rdata] of Object.entries(recipe.reactants)) {
const reqAmt = rdata.catalyst ? rdata.amount : rdata.amount * batches; // Catalysts not multiplied?
// Wait, catalyst amount is usually "per reaction" but in continuous logic...
// If I need 100u reaction, and catalyst is 5u... usually means "present".
// Logic varies. Standard parsing: Catalyst is not consumed.
// But we still need to Obtain it if we don't have it?
// For this dependency graph, yes, we need to ensure the catalyst exists.
// But quantity? Usually fixed amount.
// Let's assume standard reagent logic: simple multiplication for reactants, fixed for catalyst?
// Actually, let's just scale everything to be safe, or just 1x for catalyst if it's reused?
// SS14 Reagents: Catalysts are not consumed. You just need enough.
// Recursive: Need rid?
if (rdata.catalyst) {
// Dependent on having *some* amount. Let's say 1 unit or the recipe amount.
// And it's not consumed.
// Complex. Let's standardise: Request recipe amount once?
// Issue: Parallel steps might need it.
// For now, treat catalyst as a non-consumed dependency.
// Verify if it exists in surplus?
// If (surplus[rid] < rdata.amount) -> Request (rdata.amount - surplus[rid]).
// But do not consume it.
const catNeeded = rdata.amount; // Fixed amount needed present
const catAvail = this.surplus[rid] || 0;
if (catAvail < catNeeded) {
this.processRequest(rid, (catNeeded - catAvail), depth + 1);
// Now we have it. Do NOT decrement surplus.
}
// Add child to node for visual
node.children.push({ id: rid, amount: catNeeded, catalyst: true });
} else {
this.processRequest(rid, reqAmt, depth + 1);
node.children.push({ id: rid, amount: reqAmt, catalyst: false });
}
}
}
addToTree(id, amount, depth, type) {
if (!this.tree[depth]) this.tree[depth] = [];
// Check if we can merge with existing node in this column?
// Visual tree is strict: if we have separate branches, we usually want separate nodes
// unless we want to show a "bus".
// Existing logic separated them. Let's keep them separate but maybe mark them?
// Or, simple visual tree:
const node = {
id,
amount,
depth,
children: [],
type
};
this.tree[depth].push(node);
return node;
}
getBestRecipeIndex(id) {
const list = this.recipes[id] || [];
if (list.length === 0) return -1;
if (this.selectedRecipes[id] !== undefined) return this.selectedRecipes[id];
const exact = list.findIndex(r => r.id === id);
return exact !== -1 ? exact : 0;
}
consolidateSteps(stepList) {
// Reverse to get topological order (Dependencies first) -> No, Wait.
// Recursive was: Process(Target) -> Process(Ingredient).
// Ingredient pushed to list. Then Target pushed.
// So list is already [Ingredient, Target]. Correct order.
// Merge adjacent/duplicate steps?
// Map: ID -> Total Batches.
// But order matters for intermediate products.
// Simple approach: standard aggregation map.
const map = new Map();
// We must respect dependencies.
// If A produces B, and C produces B...
// "Total Steel Grinding".
const merged = [];
// We will just accumulate amounts for same-ID recipes.
// Since we have a global surplus model, it effectively "flattens" the graph into a list of total operations required.
const totals = {}; // id -> {recipe, batches}
stepList.forEach(s => {
if (!totals[s.id]) {
totals[s.id] = { ...s };
// Insert into merged list once (first time seen? or last?)
// Dependencies are processed first.
// So we should see 'Iron' before 'Steel' (if Iron comes from Steel? No).
// Steel (Raw) -> Iron (Product).
// Process(Iron) -> Process(Steel).
// Push Steel. Push Iron.
// So Ingredient is first.
merged.push(totals[s.id]);
} else {
totals[s.id].batches += s.batches;
totals[s.id].amountProduced += s.amountProduced;
}
});
return merged;
}
}
let lastCalculation = null;
function updateUI() {
if (!currentReagent) return;
const calculator = new RecipeCalculator(recipes, selectedRecipes);
lastCalculation = calculator.calculate(currentReagent, targetQty);
renderTree(lastCalculation.tree);
renderRaw(lastCalculation.raw);
}
function selectReagent(id, runWizard = true) {
currentReagent = id;
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-' + id)?.classList.add('active');
if (runWizard) {
startWizard(id);
} else {
updateUI();
}
}
// --- Wizard Logic ---
function startWizard(startId) {
wizardQueue = [startId];
wizardVisited = new Set([startId]);
wizardSelections = {};
document.getElementById('wizard-modal').style.display = 'flex'; // Use flex directly for modal
document.getElementById('wizard-step-num').innerText = '1';
processNextWizardStep();
}
function processNextWizardStep() {
if (wizardQueue.length === 0) {
// Done
selectedRecipes = wizardSelections;
closeWizard();
updateUI();
return;
}
const currentId = wizardQueue[0]; // Peek
const recipeList = recipes[currentId] || [];
// Skip leaf nodes (no recipes) -> auto-select Raw (-1)
if (recipeList.length === 0) {
wizardSelections[currentId] = -1;
wizardQueue.shift(); // Remove
processNextWizardStep();
return;
}
// Render Modal Choice
const container = document.getElementById('wizard-options');
container.innerHTML = '';
document.getElementById('wizard-item-name').innerText = formatName(currentId);
document.getElementById('wizard-step-num').innerText = Object.keys(wizardSelections).length + 1;
// Option: Raw Material
container.innerHTML += `
<div class="wizard-option" onclick="handleWizardSelection(-1)">
<div class="wizard-option-title">[ Already Have / Raw Material ]</div>
<div class="wizard-ingredients">Stop synthesis for this branch.</div>
</div>
`;
// Option: Recipes
recipeList.forEach((r, idx) => {
if (document.getElementById('hide-grind').checked && isGrindRecipe(r)) return;
// Format ingredients
const ingredients = Object.entries(r.reactants).map(([rid, rdata]) => {
return `${rdata.amount}u ${formatName(rid)}`;
}).join(', ');
// Format Extra Info
let details = [];
if (r.minTemp) details.push(`Temp: ${r.minTemp}K`);
const machines = r.requiredMixerCategories && r.requiredMixerCategories.length > 0 ? r.requiredMixerCategories.join(', ') : 'Mix';
details.push(`Machine: ${machines}`);
const detailStr = ` <span style="color:var(--accent-cyan);">(${details.join(' | ')})</span>`;
container.innerHTML += `
<div class="wizard-option" onclick="handleWizardSelection(${idx})">
<div class="wizard-option-title">${r.id}</div>
<div class="wizard-ingredients">Requires: ${ingredients}${detailStr}</div>
</div>
`;
});
}
function handleWizardSelection(idx) {
const currentId = wizardQueue.shift();
wizardSelections[currentId] = idx;
if (idx !== -1) {
// Add dependencies to queue
const recipe = recipes[currentId][idx];
for (const rid of Object.keys(recipe.reactants)) {
if (!wizardVisited.has(rid)) {
wizardVisited.add(rid);
wizardQueue.push(rid);
}
}
}
processNextWizardStep();
}
function closeWizard() {
document.getElementById('wizard-modal').style.display = 'none';
}
function cancelWizard() {
closeWizard();
updateUI(); // Fallback to defaults or whatever was there
}
function adjustQty(mod) {
targetQty = Math.max(0, targetQty + mod);
document.getElementById('target-qty').value = targetQty;
updateUI();
}
function changeRecipe(id, idx) {
selectedRecipes[id] = parseInt(idx);
updateUI();
}
// --- unified RecipeCalculator ---
class RecipeCalculator {
constructor(recipes, selectedRecipes) {
this.recipes = recipes;
this.selectedRecipes = selectedRecipes;
this.surplus = {};
this.steps = [];
this.raw = {};
this.tree = [];
}
calculate(targetId, targetAmount) {
this.surplus = {};
this.steps = [];
this.raw = {};
this.tree = [];
this.processRequest(targetId, targetAmount, 0);
return {
steps: this.consolidateSteps(this.steps),
raw: this.raw,
tree: this.tree,
surplus: this.surplus
};
}
processRequest(id, amount, depth) {
if (amount <= 0.0001) return;
// 1. Check Surplus
const available = this.surplus[id] || 0;
if (available >= amount) {
this.surplus[id] -= amount;
this.addToTree(id, amount, depth, "surplus");
return;
}
const needed = amount - available;
if (available > 0) {
this.surplus[id] = 0;
}
// 2. Find Recipe
const rIdx = this.getBestRecipeIndex(id);
const recipeList = this.recipes[id] || [];
// Visual Tree Node Creation
const node = this.addToTree(id, needed, depth, "producing");
// 3. If Raw or explicitly Raw
if (recipeList.length === 0 || rIdx === -1) {
this.raw[id] = (this.raw[id] || 0) + needed;
return;
}
// 4. Execute Recipe
const recipe = recipeList[rIdx];
const yieldAmt = recipe.products[id] || 1;
const batches = needed / yieldAmt;
// Record Step
this.steps.push({
id: id,
recipe: recipe,
batches: batches,
amountProduced: batches * yieldAmt
});
// 5. Produce Byproducts (and Main Product)
for (const [pid, pAmt] of Object.entries(recipe.products)) {
const produced = pAmt * batches;
this.surplus[pid] = (this.surplus[pid] || 0) + produced;
}
// Consume the main reactant from surplus
this.surplus[id] -= needed;
// 6. Recurse for Ingredients
for (const [rid, rdata] of Object.entries(recipe.reactants)) {
const reqAmt = rdata.catalyst ? rdata.amount : rdata.amount * batches;
if (rdata.catalyst) {
const catNeeded = rdata.amount;
const catAvail = this.surplus[rid] || 0;
if (catAvail < catNeeded) {
this.processRequest(rid, (catNeeded - catAvail), depth + 1);
}
node.children.push({ id: rid, amount: catNeeded, catalyst: true });
} else {
this.processRequest(rid, reqAmt, depth + 1);
node.children.push({ id: rid, amount: reqAmt, catalyst: false });
}
}
}
addToTree(id, amount, depth, type) {
if (!this.tree[depth]) this.tree[depth] = [];
const node = { id, amount, depth, children: [], type };
this.tree[depth].push(node);
return node;
}
getBestRecipeIndex(id) {
const list = this.recipes[id] || [];
if (list.length === 0) return -1;
if (this.selectedRecipes[id] !== undefined) return this.selectedRecipes[id];
const exact = list.findIndex(r => r.id === id);
return exact !== -1 ? exact : 0;
}
consolidateSteps(stepList) {
const merged = [];
const totals = {};
stepList.forEach(s => {
// Create unique key for step: ID + RecipeName (if recipes have names?) or just ID since we force selection
// But if parallel branches use different recipes for same ID (unlikely but possible), should split.
// For now, assume global selection wins.
if (!totals[s.id]) {
totals[s.id] = { ...s };
merged.push(totals[s.id]);
} else {
totals[s.id].batches += s.batches;
totals[s.id].amountProduced += s.amountProduced;
}
});
return merged;
}
}
let lastCalculation = null;
function updateUI() {
if (!currentReagent) return;
const calculator = new RecipeCalculator(recipes, selectedRecipes);
lastCalculation = calculator.calculate(currentReagent, targetQty);
renderTree(lastCalculation.tree);
renderRaw(lastCalculation.raw);
}
function renderTree() {
const container = document.getElementById('tree-container');
const svg = document.getElementById('svg-connectors');
// Clear previous node elements but keep SVG
Array.from(container.children).forEach(child => {
if (child.id !== 'svg-connectors') container.removeChild(child);
});
svg.innerHTML = '';
const columns = [];
const nodeElements = new Map();
// 1. Better Recipe Selection: Prioritize Standard Recipes
function getBestRecipeIndex(id) {
const recipeList = recipes[id] || [];
if (recipeList.length === 0) return -1;
if (selectedRecipes[id] !== undefined) return selectedRecipes[id];
// Priority: exact match -> first non-breakdown -> first one
const exact = recipeList.findIndex(r => r.id === id);
if (exact !== -1) return exact;
return 0;
}
function build(id, amount, depth) {
if (!columns[depth]) columns[depth] = [];
let node = columns[depth].find(n => n.id === id);
if (node) {
node.amount += amount;
} else {
node = { id, amount, depth, children: [] };
columns[depth].push(node);
}
const recipeList = recipes[id] || [];
if (recipeList.length > 0) {
const rIdx = getBestRecipeIndex(id);
// Auto-select for UI consistency
if (selectedRecipes[id] === undefined) selectedRecipes[id] = rIdx;
if (rIdx === -1) return;
const recipe = recipeList[rIdx];
const yieldAmount = recipe.products[id] || 1;
const batch = amount / yieldAmount;
for (const [rid, rdata] of Object.entries(recipe.reactants)) {
const reqAmt = rdata.catalyst ? rdata.amount : rdata.amount * batch;
node.children.push({ id: rid, amount: reqAmt });
build(rid, reqAmt, depth + 1);
}
}
}
build(currentReagent, targetQty, 0);
const colWidth = 400;
const maxDepth = columns.length - 1;
// Render nodes
columns.forEach((nodes, depth) => {
const currentX = 4000 - (depth * colWidth);
const totalHeight = nodes.length * 200; // rough est
const startY = 2500 - (totalHeight / 2);
nodes.forEach((node, idx) => {
const div = document.createElement('div');
div.className = 'tree-node';
div.style.left = currentX + 'px';
div.style.top = (startY + (idx * 250)) + 'px'; // 250px gap vertical
const recipeList = recipes[node.id] || [];
const rIdx = selectedRecipes[node.id] !== undefined ? selectedRecipes[node.id] : 0;
div.innerHTML = `
<div class="tree-node-header">${formatName(node.id)}</div>
<div style="font-family: 'JetBrains Mono'; font-size: 1.1rem; margin-bottom: 10px;">${node.amount.toFixed(1)} u</div>
${recipeList.length > 0 ? `
<select class="recipe-select" onchange="changeRecipe('${node.id}', this.value)">
<option value="-1" ${rIdx == -1 ? 'selected' : ''}>[ Already Have ]</option>
${recipeList.map((r, i) => {
if (document.getElementById('hide-grind').checked && isGrindRecipe(r) && rIdx !== i) return '';
return `<option value="${i}" ${rIdx == i ? 'selected' : ''}>${r.id}</option>`;
}).join('')}
</select>
<div>
${rIdx === -1 ?
`<div style="color: var(--accent-cyan); font-size: 0.8rem; margin-top:5px; font-weight:bold; opacity:0.8;">[ TREATED AS RAW ]</div>`
:
Object.entries(recipeList[rIdx].reactants).map(([rid, rdata]) => `
<div class="node-reactant">
<span>${formatName(rid)}</span>
<span>${rdata.amount}u ${rdata.catalyst ? '<span class="catalyst-tag">CAT</span>' : ''}</span>
</div>
`).join('')}
</div>
` : `<div style="color: var(--accent-cyan); font-size: 0.8rem; font-weight: bold;">[ RAW MATERIAL ]</div>`}
`;
container.appendChild(div);
nodeElements.set(depth + '-' + node.id, div);
});
});
// Draw connections after DOM update
setTimeout(() => {
columns.forEach((nodes, depth) => {
nodes.forEach(node => {
const el = nodeElements.get(depth + '-' + node.id);
if (!el) return;
const uniqueChildren = [...new Set(node.children.map(c => c.id))];
uniqueChildren.forEach(childId => {
const childEl = nodeElements.get((depth + 1) + '-' + childId);
if (!childEl) return;
const x1 = childEl.offsetLeft + childEl.offsetWidth;
const y1 = childEl.offsetTop + (childEl.offsetHeight / 2);
const x2 = el.offsetLeft;
const y2 = el.offsetTop + (el.offsetHeight / 2);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const dx = x2 - x1;
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + dx / 2} ${y1}, ${x1 + dx / 2} ${y2}, ${x2} ${y2}`);
path.setAttribute('stroke', 'rgba(0, 229, 255, 0.3)');
path.setAttribute('stroke-width', '2');
path.setAttribute('fill', 'none');
svg.appendChild(path);
});
});
});
// Auto-center after render
resetView();
}, 50);
}
function closeSteps() {
document.getElementById('steps-modal').classList.remove('active');
}
function showSteps() {
if (!currentReagent) return;
const steps = calculateSteps();
const container = document.getElementById('steps-list');
if (steps.length === 0) {
container.innerHTML = '<div style="text-align:center; color: var(--text-dim);">No steps required (Already have raw materials).</div>';
} else {
container.innerHTML = steps.map((step, idx) => {
const rIdx = selectedRecipes[step.id];
// Safety check, though calculateSteps filters this
if (rIdx === -1 || rIdx === undefined) return '';
const recipe = recipes[step.id][rIdx];
const yieldAmt = recipe.products[step.id] || 1;
const batch = step.amount / yieldAmt;
const method = recipe.requiredMixerCategories && recipe.requiredMixerCategories.length > 0 ? recipe.requiredMixerCategories.join(', ') : 'Mix';
const hasTemp = recipe.minTemp !== null && recipe.minTemp !== undefined && recipe.minTemp > 0;
return `
<div class="step-card">
<div class="step-number">${String(idx + 1).padStart(2, '0')}</div>
<div class="step-content">
<div class="step-header">
<span>${formatName(step.id)}</span>
<span class="step-amount">${step.amount.toFixed(1)} u</span>
</div>
<div style="font-size: 0.8rem; color: #bbb; margin-bottom: 0.5rem; display: flex; gap: 1rem;">
<span><span style="color: var(--accent-orange); font-weight: bold;">Method:</span> ${method}</span>
${hasTemp ? `<span><span style="color: var(--accent-orange); font-weight: bold;">Temp:</span> ${recipe.minTemp} K</span>` : ''}
</div>
<div class="step-ingredients">
${Object.entries(recipe.reactants).map(([rid, rdata]) => {
const rectAmt = rdata.catalyst ? rdata.amount : rdata.amount * batch;
return `
<div class="ingredient-line">
<span>${formatName(rid)} ${rdata.catalyst ? '(Catalyst)' : ''}</span>
<span style="font-family:'JetBrains Mono'">${rectAmt.toFixed(1)} u</span>
</div>`;
}).join('')}
</div>
</div>
</div>`;
}).join('');
}
document.getElementById('steps-modal').classList.add('active');
}
function calculateSteps() {
const totals = {};
const visited = new Set();
const stepOrder = [];
// Pass 1: Global Totals Aggregation
function tally(id, amount) {
totals[id] = (totals[id] || 0) + amount;
const recipeList = recipes[id] || [];
if (recipeList.length === 0) return; // Raw
let rIdx = selectedRecipes[id];
if (rIdx === undefined) {
const exact = recipeList.findIndex(r => r.id === id);
rIdx = exact !== -1 ? exact : 0;
}
if (rIdx === -1) return; // Treated as raw
const recipe = recipeList[rIdx];
const yieldAmt = recipe.products[id] || 1;
const batch = amount / yieldAmt;
for (const [rid, rdata] of Object.entries(recipe.reactants)) {
// Don't recurse for catalysts in terms of CONSUMPTION, but we might want them in the dependency graph?
// Standard practice: Catalysts are required present, so they are dependencies.
// But they aren't consumed. For "Steps", we often just list them.
// The tally logic sums *required quantities*.
// If we strictly follow "consumption", catalyst amount doesn't increase with depth.
// Simpler: Just accumulate requirements.
const reqAmt = rdata.catalyst ? 0 : rdata.amount * batch;
if (reqAmt > 0) tally(rid, reqAmt);
}
}
tally(currentReagent, targetQty);
// Pass 2: Topological Sort to determine Step Order
// We want to perform steps that use raw materials first.
// Post-order traversal of the dependency graph gives us dependencies first.
function visit(id) {
if (visited.has(id)) return;
visited.add(id);
const recipeList = recipes[id] || [];
// If it's a raw material (no recipe) or we treat it as raw, no step needed.
if (recipeList.length === 0) return;
let rIdx = selectedRecipes[id];
if (rIdx === undefined) {
const exact = recipeList.findIndex(r => r.id === id);
rIdx = exact !== -1 ? exact : 0;
}
if (rIdx === -1) return;
const recipe = recipeList[rIdx];
for (const rid of Object.keys(recipe.reactants)) {
visit(rid);
}
// All dependencies visited, now we can make this
stepOrder.push({ id: id, amount: totals[id] });
}
// We only care about visiting nodes that are actually involved.
// But 'visit' traverses everything again.
// It uses the same 'selectedRecipes' map so the graph structure is identical to Pass 1.
visit(currentReagent);
return stepOrder;
}
function calculateRaw() {
const raw = {};
function recurse(id, amount) {
const recipeList = recipes[id] || [];
if (recipeList.length === 0) {
raw[id] = (raw[id] || 0) + amount;
return;
}
// Use same selection logic as renderTree
// Check if we have a selected recipe, otherwise fallback to finding the best one
let rIdx = selectedRecipes[id];
if (rIdx === undefined) {
// Duplicate logic for safety or extract to helper
const exact = recipeList.findIndex(r => r.id === id);
rIdx = exact !== -1 ? exact : 0;
}
if (rIdx === -1) {
raw[id] = (raw[id] || 0) + amount;
return;
}
const recipe = recipeList[rIdx];
const yieldAmt = recipe.products[id] || 1;
const batch = amount / yieldAmt;
for (const [rid, rdata] of Object.entries(recipe.reactants)) {
const reqAmt = rdata.catalyst ? rdata.amount : rdata.amount * batch;
recurse(rid, reqAmt);
}
}
recurse(currentReagent, targetQty);
const list = document.getElementById('raw-list');
list.innerHTML = Object.entries(raw).sort((a, b) => b[1] - a[1]).map(([id, amt]) => `
<div class="raw-material-item">
<span>${formatName(id)}</span>
<span>${amt.toFixed(1)} u</span>
</div>
`).join('');
}
function updateTransform() {
const container = document.getElementById('tree-container');
container.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
}
function resetView() {
const nodes = document.querySelectorAll('.tree-node');
if (nodes.length === 0) {
// Default center of the 5000x5000 board
scale = 1;
posX = -1500;
posY = -1500;
updateTransform();
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(node => {
const x = parseFloat(node.style.left);
const y = parseFloat(node.style.top);
const w = node.offsetWidth || 250;
const h = node.offsetHeight || 100;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
});
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const viewport = document.getElementById('viewport');
const vW = viewport ? viewport.clientWidth : window.innerWidth;
const vH = viewport ? viewport.clientHeight : window.innerHeight;
scale = 0.8;
posX = (vW / 2) - (centerX * scale);
posY = (vH / 2) - (centerY * scale);
updateTransform();
}
window.onload = init;
</script>
</body>
</html>