1363 lines
51 KiB
HTML
1363 lines
51 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 id="surplus-container" style="margin-top: 1.5rem; display: none;">
|
|
<div
|
|
style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 0.8rem; text-transform: uppercase;">
|
|
Surplus Byproducts</div>
|
|
<div id="surplus-list-main"></div>
|
|
</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">
|
|
Synthesis Instructions
|
|
<button class="close-btn" onclick="closeSteps()">×</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;
|
|
|
|
// 1. Check Surplus
|
|
const available = this.surplus[id] || 0;
|
|
if (available >= amount) {
|
|
this.surplus[id] -= amount;
|
|
this.addToTree(id, amount, depth, "surplus");
|
|
return this.tree[depth].length - 1;
|
|
}
|
|
|
|
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");
|
|
const nodeIdx = this.tree[depth].length - 1;
|
|
|
|
// 3. If Raw or explicitly Raw
|
|
if (recipeList.length === 0 || rIdx === -1) {
|
|
this.raw[id] = (this.raw[id] || 0) + needed;
|
|
return nodeIdx;
|
|
}
|
|
|
|
// 4. Execute Recipe
|
|
const recipe = recipeList[rIdx];
|
|
const yieldAmt = recipe.products[id] || 1;
|
|
const batches = needed / yieldAmt;
|
|
|
|
this.steps.push({
|
|
id: id,
|
|
recipe: recipe,
|
|
batches: batches,
|
|
amountProduced: batches * yieldAmt,
|
|
depth: depth
|
|
});
|
|
|
|
// 5. Produce Byproducts
|
|
for (const [pid, pAmt] of Object.entries(recipe.products)) {
|
|
const produced = pAmt * batches;
|
|
this.surplus[pid] = (this.surplus[pid] || 0) + produced;
|
|
}
|
|
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 catIdx = this.processRequest(rid, rdata.amount, depth + 1);
|
|
if (catIdx !== -1) node.children.push(catIdx);
|
|
} else {
|
|
const childIdx = this.processRequest(rid, reqAmt, depth + 1);
|
|
if (childIdx !== -1) node.children.push(childIdx);
|
|
}
|
|
}
|
|
return nodeIdx;
|
|
}
|
|
|
|
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;
|
|
}
|
|
});
|
|
|
|
// Sort by depth
|
|
merged.sort((a, b) => a.depth - b.depth);
|
|
|
|
// Reverse to get topological order (Dependencies first)
|
|
merged.reverse();
|
|
|
|
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);
|
|
renderSurplus(lastCalculation.surplus);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
|
|
function renderTree(columns) {
|
|
const container = document.getElementById('tree-container');
|
|
const svg = document.getElementById('svg-connectors');
|
|
|
|
Array.from(container.children).forEach(child => {
|
|
if (child.id !== 'svg-connectors') container.removeChild(child);
|
|
});
|
|
svg.innerHTML = '';
|
|
|
|
const nodeElements = new Map();
|
|
const colWidth = 400;
|
|
|
|
columns.forEach((nodes, depth) => {
|
|
const currentX = 4000 - (depth * colWidth);
|
|
const totalHeight = nodes.length * 200;
|
|
const startY = 2500 - (totalHeight / 2);
|
|
|
|
nodes.forEach((node, idx) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'tree-node';
|
|
if (node.type === 'surplus') {
|
|
div.style.border = '1px dashed var(--accent-orange)';
|
|
div.style.opacity = '0.7';
|
|
}
|
|
div.style.left = currentX + 'px';
|
|
div.style.top = (startY + (idx * 250)) + 'px';
|
|
|
|
const recipeList = recipes[node.id] || [];
|
|
const rIdx = selectedRecipes[node.id] !== undefined ? selectedRecipes[node.id] : 0;
|
|
const hasRecipe = recipeList.length > 0 && rIdx !== -1 && node.type !== 'surplus';
|
|
const currentRecipe = hasRecipe ? recipeList[rIdx] : null;
|
|
console.log(node.id, recipeList, rIdx, hasRecipe, currentRecipe);
|
|
let content = '';
|
|
if (node.type === 'surplus') {
|
|
content = `<div style="color: var(--accent-orange); font-size: 0.8rem;">[ FROM BYPRODUCTS ]</div>`;
|
|
} else if (hasRecipe && currentRecipe) {
|
|
content = `
|
|
<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>
|
|
${Object.entries(currentRecipe.reactants).map(([rid, rdata]) => `
|
|
<div class="node-reactant">
|
|
<span>${formatName(rid)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>`;
|
|
} else if (recipeList.length == 0) {
|
|
content = `<div style="color: var(--accent-cyan); font-size: 0.8rem; font-weight: bold;">[ RAW MATERIAL ]</div>`;
|
|
} else {
|
|
content = `
|
|
<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 style="color: var(--accent-cyan); font-size: 0.8rem; font-weight: bold;">[ TREATED AS RAW MATERIAL ]</div>`;
|
|
}
|
|
|
|
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>
|
|
${content}
|
|
`;
|
|
container.appendChild(div);
|
|
nodeElements.set(`${depth}-${idx}`, { div, node });
|
|
});
|
|
});
|
|
|
|
if (columns.length > 0) resetView();
|
|
|
|
// Draw connections after DOM update
|
|
setTimeout(() => {
|
|
columns.forEach((nodes, depth) => {
|
|
nodes.forEach((node, nodeIdx) => {
|
|
const entry = nodeElements.get(`${depth}-${nodeIdx}`);
|
|
if (!entry) return;
|
|
const el = entry.div;
|
|
|
|
node.children.forEach(childIdx => {
|
|
const childEntry = nodeElements.get(`${depth + 1}-${childIdx}`);
|
|
if (!childEntry) return;
|
|
const childEl = childEntry.div;
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
}, 50);
|
|
}
|
|
|
|
function closeSteps() {
|
|
document.getElementById('steps-modal').classList.remove('active');
|
|
}
|
|
|
|
function showSteps() {
|
|
if (!currentReagent || !lastCalculation) return;
|
|
const steps = lastCalculation.steps;
|
|
const surplus = lastCalculation.surplus;
|
|
|
|
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 recipe = step.recipe;
|
|
const batches = step.batches;
|
|
const producedAmt = step.amountProduced;
|
|
|
|
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">${producedAmt.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 * batches;
|
|
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>
|
|
${(() => {
|
|
const products = Object.entries(recipe.products);
|
|
const byproducts = products.filter(([pid, _]) => pid !== step.id);
|
|
if (byproducts.length === 0) return '';
|
|
return `
|
|
<div style="margin-top: 0.8rem; padding-top: 0.5rem; border-top: 1px dashed rgba(255,255,255,0.1);">
|
|
<div style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 0.3rem; text-transform: uppercase;">Byproducts Produced</div>
|
|
${byproducts.map(([pid, amt]) => `
|
|
<div class="ingredient-line" style="color: var(--accent-cyan); opacity: 0.8;">
|
|
<span>${formatName(pid)}</span>
|
|
<span style="font-family:'JetBrains Mono'">${(amt * batches).toFixed(1)} u</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>`;
|
|
})()}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const unused = Object.entries(surplus).filter(([_, amt]) => amt > 0.1);
|
|
if (unused.length > 0) {
|
|
container.innerHTML += `
|
|
<div style="margin-top:2rem; padding-top:1rem; border-top: 2px solid var(--border);">
|
|
<h4 style="color: var(--accent-orange);">Total Unused Byproducts (Surplus)</h4>
|
|
${unused.map(([id, amt]) => `
|
|
<div style="display:flex; justify-content:space-between; padding:5px 0;">
|
|
<span>${formatName(id)}</span>
|
|
<span style="font-family:'JetBrains Mono'">${amt.toFixed(1)}u</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
document.getElementById('steps-modal').classList.add('active');
|
|
}
|
|
|
|
function renderRaw(raw) {
|
|
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 renderSurplus(surplus) {
|
|
const container = document.getElementById('surplus-container');
|
|
const list = document.getElementById('surplus-list-main');
|
|
|
|
const unused = Object.entries(surplus).filter(([_, amt]) => amt > 0.1);
|
|
|
|
if (unused.length === 0) {
|
|
container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
container.style.display = 'block';
|
|
list.innerHTML = unused.sort((a, b) => b[1] - a[1]).map(([id, amt]) => `
|
|
<div class="raw-material-item" style="border-color: rgba(255, 152, 0, 0.2);">
|
|
<span>${formatName(id)}</span>
|
|
<span style="color: var(--accent-orange); font-family: 'JetBrains Mono', monospace; font-weight: 600;">${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> |