Created
September 26, 2022 10:09
-
-
Save CyberShadow/a77e882874661f56f0a4f4c0f02fd37e to your computer and use it in GitHub Desktop.
Satisfactory planner
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<script crossorigin src="https://visjs.github.io/vis-network/standalone/umd/vis-network.min.js"></script> | |
<style type="text/css"> | |
* { | |
box-sizing: border-box; | |
} | |
#mynetwork, #mynetwork canvas { | |
width: 100%; | |
height: 100%; | |
/* border: 1px solid lightgray; */ | |
} | |
body { | |
margin: 0; | |
padding: 0; | |
min-height: 100vh; | |
display: grid; | |
grid-template: auto 1fr / 1fr; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="toolbar"> | |
</div> | |
<div style="position: relative; width: 100%; height: 100%;"> | |
<div style="position: absolute; top: 0px; right: 0px; bottom: 0px; left: 0px;" id="mynetwork" /> | |
</div> | |
<script type="text/javascript" src="script.js"></script> | |
</body> | |
</html> | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(function() { | |
const conversions = [ | |
// Specials | |
{ | |
name : "Conveyor - Splitter", | |
time : 0, | |
power : 0, // MW | |
}, | |
{ | |
name : "Conveyor - Merger", | |
time : 0, | |
power : 0, // MW | |
}, | |
{ | |
name : "Storage", | |
time : 0, | |
power : 0, // MW | |
}, | |
// Miner | |
{ | |
name : "Miner - Iron", | |
inputs : [], | |
outputs: [{resource: 'Iron Ore', quantity: 1}], | |
time : 0.5, // 120/min. | |
power : 5, // MW | |
}, | |
{ | |
name : "Miner - Copper", | |
inputs : [], | |
outputs: [{resource: 'Copper Ore', quantity: 1}], | |
time : 0.5, // 120/min. | |
power : 5, // MW | |
}, | |
{ | |
name : "Miner - Limestone", | |
inputs : [], | |
outputs: [{resource: 'Limestone', quantity: 1}], | |
time : 0.5, // 120/min. | |
power : 5, // MW | |
}, | |
// Smelter | |
{ | |
name : "Smelter - Iron", | |
inputs : [{resource: 'Iron Ore', quantity: 1}], | |
outputs: [{resource: 'Iron Ingot', quantity: 1}], | |
time : 2, // 30/min. | |
power : 4, // MW | |
}, | |
{ | |
name : "Smelter - Copper", | |
inputs : [{resource: 'Copper Ore', quantity: 1}], | |
outputs: [{resource: 'Copper Ingot', quantity: 1}], | |
time : 2, // 30/min. | |
power : 4, // MW | |
}, | |
// Constructor | |
// Standard Parts | |
{ | |
name : "Constructor - Iron Plate", | |
inputs : [{resource: 'Iron Ingot', quantity: 3}], | |
outputs: [{resource: 'Iron Plate', quantity: 2}], | |
time : 6, // 30/min. -> 20/min. | |
power : 4, // MW | |
}, | |
{ | |
name : "Constructor - Iron Rod", | |
inputs : [{resource: 'Iron Ingot', quantity: 1}], | |
outputs: [{resource: 'Iron Rod', quantity: 1}], | |
time : 4, // 15/min. -> 15/min. | |
power : 4, // MW | |
}, | |
{ | |
name : "Constructor - Screw", | |
inputs : [{resource: 'Iron Rod', quantity: 1}], | |
outputs: [{resource: 'Screw', quantity: 4}], | |
time : 6, // 10/min. -> 40/min. | |
power : 4, // MW | |
}, | |
{ | |
name : "Constructor - Cast Screw", | |
inputs : [{resource: 'Iron Ingot', quantity: 5}], | |
outputs: [{resource: 'Screw', quantity: 20}], | |
time : 24, // 12.5/min. -> 50/min. | |
power : 4, // MW | |
}, | |
{ | |
name : "Constructor - Copper Sheet", | |
inputs : [{resource: 'Copper Ingot', quantity: 2}], | |
outputs: [{resource: 'Copper Sheet', quantity: 1}], | |
time : 6, // 20/min. -> 10/min. | |
power : 4, // MW | |
}, | |
// Electronics | |
{ | |
name : "Constructor - Wire", | |
inputs : [{resource: 'Copper Ingot', quantity: 1}], | |
outputs: [{resource: 'Wire', quantity: 2}], | |
time : 4, // 15/min. -> 30/min. | |
power : 4, // MW | |
}, | |
{ | |
name : "Constructor - Cable", | |
inputs : [{resource: 'Wire', quantity: 2}], | |
outputs: [{resource: 'Cable', quantity: 1}], | |
time : 2, // 60/min. -> 30/min. | |
power : 4, // MW | |
}, | |
// Combounds | |
{ | |
name : "Constructor - Concrete", | |
inputs : [{resource: 'Limestone', quantity: 3}], | |
outputs: [{resource: 'Concrete', quantity: 1}], | |
time : 4, // 45/min. -> 15/min. | |
power : 4, // MW | |
}, | |
// Assembler | |
// Standard Parts | |
{ | |
name : "Assembler - Reinforced Iron Plate", | |
inputs : [{resource: 'Iron Plate', quantity: 6}, {resource: 'Screw', quantity: 12}], | |
outputs: [{resource: 'Reinforced Iron Plate', quantity: 1}], | |
time : 12, // 30/min. + 60/min. -> 5/min. | |
power : 15, // MW | |
}, | |
{ | |
name : "Assembler - Stitched Iron Plate", | |
inputs : [{resource: 'Iron Plate', quantity: 10}, {resource: 'Wire', quantity: 20}], | |
outputs: [{resource: 'Reinforced Iron Plate', quantity: 3}], | |
time : 32, // 18.75/min. + 37.5/min. -> 5.625/min. | |
power : 15, // MW | |
}, | |
{ | |
name : "Assembler - Modular Frame", | |
inputs : [{resource: 'Reinforced Iron Plate', quantity: 3}, {resource: 'Iron Rod', quantity: 12}], | |
outputs: [{resource: 'Modular Frame', quantity: 2}], | |
time : 60, // 3/min. + 12/min. -> 2/min. | |
power : 15, // MW | |
}, | |
{ | |
name : "Assembler - Rotor", | |
inputs : [{resource: 'Iron Rod', quantity: 5}, {resource: 'Screw', quantity: 25}], | |
outputs: [{resource: 'Rotor', quantity: 2}], | |
time : 15, // 20/min. + 100/min. -> 4/min. | |
power : 15, // MW | |
}, | |
{ | |
name : "Assembler - Smart Plating", | |
inputs : [{resource: 'Reinforced Iron Plate', quantity: 1}, {resource: 'Rotor', quantity: 1}], | |
outputs: [{resource: 'Smart Plating', quantity: 1}], | |
time : 30, // 2/min. + 2/min. -> 2/min. | |
power : 15, // MW | |
}, | |
]; | |
/////////////////////////////////////////////////////////////////////////////////////////// | |
function enforce(cond, msg) { | |
if (!cond) | |
throw new Error(msg); | |
return cond; | |
} | |
/////////////////////////////////////////////////////////////////////////////////////////// | |
// https://visjs.github.io/vis-network/examples/network/other/saveAndLoad.html | |
function getNodeData(data) { | |
var networkNodes = []; | |
data.forEach(function (elem, index, array) { | |
networkNodes.push(elem); | |
}); | |
return new vis.DataSet(networkNodes); | |
} | |
function getEdgeData(data) { | |
var networkEdges = []; | |
data.forEach(function (node) { | |
// add the connection | |
node.connections.forEach(function (connId, cIndex, conns) { | |
networkEdges.push({ from: node.id, to: connId }); | |
}); | |
}); | |
return new vis.DataSet(networkEdges); | |
} | |
function getLoadData() { | |
if ('plannerGraph' in window.localStorage) { | |
var inputData = JSON.parse(window.localStorage.plannerGraph); | |
return { | |
nodes: getNodeData(inputData), | |
edges: getEdgeData(inputData), | |
}; | |
} else { | |
return { | |
nodes: new vis.DataSet([]), | |
edges: new vis.DataSet([]), | |
}; | |
} | |
} | |
function save() { | |
const positions = network.getPositions(); | |
const nodeIDs = data.nodes.getIds(); | |
const nodes = []; | |
for (let id of nodeIDs) | |
nodes.push({ | |
...data.nodes.get(id), | |
...positions[id], | |
connections: network.getConnectedNodes(id, 'to'), | |
}); | |
window.localStorage.plannerGraph = JSON.stringify(nodes); | |
} | |
/////////////////////////////////////////////////////////////////////////////////////////// | |
const toolbar = document.getElementById('toolbar'); | |
function describeIngredients(ingredients, time) { | |
if (!ingredients.length) | |
return '(nothing)'; | |
return ingredients.map(ing => `${ing.quantity}x (${(ing.quantity/time*60)}/min) ${ing.resource}`).join(' + '); | |
} | |
function addNode(conversionName, fields) { | |
return data.nodes.add({ | |
conversion: conversionName, | |
label: conversionName.replace(' - ', ':\n'), | |
shape: 'box', | |
...(fields || {}) | |
})[0]; | |
} | |
(function() { | |
var lastCategory = ''; | |
conversions.forEach(conversion => { | |
const category = conversion.name.split(' - ')[0]; | |
if (lastCategory && category !== lastCategory) | |
toolbar.appendChild(document.createTextNode(' ')); | |
lastCategory = category; | |
let button = document.createElement('button'); | |
button.textContent = conversion.name; | |
button.addEventListener('click', function() { | |
addNode(conversion.name); | |
}); | |
if ('inputs' in conversion) | |
button.title = `${describeIngredients(conversion.inputs, conversion.time)} -> ${conversion.time} sec -> ${describeIngredients(conversion.outputs, conversion.time)}`; | |
toolbar.appendChild(button); | |
}); | |
})(); | |
/////////////////////////////////////////////////////////////////////////////////////////// | |
function recalculate() { | |
const seenNodes = {}; // 'visiting', 'visited', or 'looped' | |
function recalculateNode(id) { | |
var color = '#808080'; | |
var title = ''; | |
try { | |
if (id in seenNodes) { | |
if (seenNodes[id] == 'visiting') | |
seenNodes[id] = 'looped'; | |
return; | |
} | |
seenNodes[id] = 'visiting'; | |
const node = data.nodes.get(id); | |
const fromNodeIds = network.getConnectedNodes(id, 'from'); | |
const toNodeIds = network.getConnectedNodes(id, 'to'); | |
// Ensure sources are calculated | |
for (let from of fromNodeIds) | |
recalculateNode(from); | |
// Check for loops | |
enforce(seenNodes[id] != 'looped', 'Loop detected'); | |
const edgeIds = network.getConnectedEdges(id); | |
const edges = edgeIds.map(edgeId => data.edges.get(edgeId)); | |
const fromEdges = edges.filter(edge => edge.to == id); | |
const toEdges = edges.filter(edge => edge.from == id); | |
let conversion = conversions.find(c => c.name == node.conversion); | |
enforce(conversion, 'Unknown conversion: ' + node.conversion); | |
switch (conversion.name) { | |
case "Conveyor - Splitter": | |
enforce(fromEdges.length == 1, "Splitter needs one input"); | |
enforce(toEdges.length > 0, "Splitter needs outputs"); | |
conversion = { | |
...conversion, | |
inputs: [{resource: fromEdges[0].spResource, quantity: toEdges.length}], | |
outputs: Array(toEdges.length).fill({resource: fromEdges[0].spResource, quantity: 1}), | |
}; | |
break; | |
case "Conveyor - Merger": | |
enforce(toEdges.length == 1, "Merger needs one output"); | |
enforce(fromEdges.length > 0, "Merger needs inputs"); | |
for (let fromEdge of fromEdges) { | |
enforce(fromEdge.spResource == fromEdges[0].spResource, 'Inconsistent input resources'); | |
} | |
conversion = { | |
...conversion, | |
inputs: Array(fromEdges.length).fill({resource: fromEdges[0].spResource, quantity: 1}), | |
outputs: [{resource: fromEdges[0].spResource, quantity: fromEdges.length}], | |
}; | |
break; | |
case "Storage": | |
enforce(fromEdges.length == 1, "Storage needs one input"); | |
enforce(toEdges.length <= 1, "Storage can't have more than one output"); | |
conversion = { | |
...conversion, | |
inputs: [{resource: fromEdges[0].spResource, quantity: 1}], | |
outputs: Array(toEdges.length).fill({resource: fromEdges[0].spResource, quantity: 1}), | |
}; | |
break; | |
} | |
// Check input arity | |
enforce(fromNodeIds.length === conversion.inputs.length, | |
`Input arity mismatch: have ${fromNodeIds.length}, want ${conversion.inputs.length}`); | |
// Check output arity | |
enforce(toNodeIds.length === conversion.outputs.length, | |
`Output arity mismatch: have ${toNodeIds.length}, want ${conversion.outputs.length}`); | |
// Initial ideal (inputs are saturated) speed value | |
var inputTime = 0; | |
var bottleneck = '(nothing)'; | |
// Match up the labels with the inputs | |
conversion.inputs.sort((a, b) => a.resource.localeCompare(b.resource)); | |
fromEdges.sort((a, b) => a.spResource.localeCompare(b.spResource)); | |
conversion.inputs.forEach((input, index) => { | |
const edge = fromEdges[index]; | |
enforce(edge.spResource == input.resource, | |
`Input resource mismatch: have ${edge.spResource}, want ${input.resource}`); | |
const edgeTime = edge.spTime * input.quantity; | |
if (edgeTime > inputTime) { | |
inputTime = edgeTime; | |
bottleneck = edge.spResource; | |
} | |
}); | |
var finalTime = inputTime; | |
if (conversion.time >= inputTime) { | |
bottleneck = '(conversion speed)'; | |
finalTime = conversion.time; | |
} | |
title = `${finalTime} sec (${60 / finalTime} / min)\nBottlenecked by ${bottleneck}`; | |
if (conversion.time == 0) | |
color = '#a0a0a0'; | |
else if (Math.abs(conversion.time - inputTime) < 0.0001) | |
color = '#00ff00'; // ideal | |
else if (inputTime > conversion.time) | |
color = '#00a0a0'; // over-saturated | |
else | |
color = '#a0a000'; // under-saturated | |
conversion.outputs.forEach((output, index) => { | |
const resTime = finalTime / output.quantity; | |
data.edges.update({ | |
id: toEdges[index].id, | |
spResource: output.resource, | |
spTime: resTime, | |
title: `${output.resource}\n${resTime} sec\n(${60 / resTime} / min)`, | |
color: ( | |
resTime < 60/270 ? '#ff0000' : | |
resTime < 60/120 ? '#00ff00' : | |
resTime < 60/ 60 ? '#00a0a0' : | |
resTime > 0 ? '#4040ff' : | |
'#808080' | |
), | |
}); | |
}); | |
seenNodes[id] = 'visited'; | |
} catch (err) { | |
color = '#ff0000'; | |
title = err.message; | |
} | |
data.nodes.update({id, color, title}); | |
} | |
for (let id of data.nodes.getIds()) | |
recalculateNode(id); | |
} | |
/////////////////////////////////////////////////////////////////////////////////////////// | |
// create a network | |
const container = document.getElementById("mynetwork"); | |
const options = { | |
interaction: { hover: true }, | |
edges: { | |
arrows: "to", | |
}, | |
layout: { | |
hierarchical: { | |
enabled: true, | |
sortMethod: 'directed', | |
shakeTowards: 'roots', | |
}, | |
}, | |
}; | |
const data = getLoadData(); | |
const network = new vis.Network(container, data, options); | |
var lastStabilized = 0; | |
network.on('stabilized', function() { | |
const now = (new Date()).getTime(); | |
if (now - lastStabilized < 100) | |
return; | |
lastStabilized = now; | |
recalculate(); | |
save(); | |
}); | |
network.on('oncontext', function (params) { | |
params.event.preventDefault(); | |
network.disableEditMode(); | |
const edge = this.getEdgeAt(params.pointer.DOM); | |
if (edge) { | |
data.edges.remove(edge); | |
return; | |
} | |
const node = this.getNodeAt(params.pointer.DOM); | |
if (node) { | |
data.nodes.remove(node); | |
return; | |
} | |
}); | |
network.on('doubleClick', function (params) { | |
params.event.preventDefault(); | |
const node = this.getNodeAt(params.pointer.DOM); | |
if (node) { | |
network.addEdgeMode(); | |
return; | |
} | |
const edge = this.getEdgeAt(params.pointer.DOM); | |
if (edge) { | |
network.editEdgeMode(); | |
return; | |
} | |
}); | |
container.addEventListener('mousedown', function (event) { | |
if (event.button == 1) { | |
// const edge = this.getEdgeAt(params.pointer.DOM); | |
// if (edge) { | |
// return; | |
// } | |
const nodeId = network.getNodeAt({x: event.offsetX, y: event.offsetY}); | |
if (nodeId) { | |
event.preventDefault(); | |
const node = data.nodes.get(nodeId); | |
const node2Id = addNode(node.conversion, network.getPosition(nodeId)); | |
const edgeIds = network.getConnectedEdges(nodeId); | |
const edges = edgeIds.map(edgeId => data.edges.get(edgeId)); | |
const edges2 = edges.map(edge => ({ | |
from: edge.from == nodeId ? node2Id : edge.from, | |
to: edge.to == nodeId ? node2Id : edge.to, | |
})); | |
data.edges.add(edges2); | |
return; | |
} | |
} | |
}); | |
container.addEventListener('mouseup', function (event) { | |
save(); | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment