Skip to content

Instantly share code, notes, and snippets.

@cellularmitosis
Last active June 17, 2026 07:08
Show Gist options
  • Select an option

  • Save cellularmitosis/3c494e84e19fbb83d7a9439d9f149275 to your computer and use it in GitHub Desktop.

Select an option

Save cellularmitosis/3c494e84e19fbb83d7a9439d9f149275 to your computer and use it in GitHub Desktop.
A simple 7-science Factorio dependency graph thingy

This was my conversation with claude while vibe-coding https://rocketcal.cc/depviz/

Claude Conversation Log

Session ID: c858d4cf-445c-44c2-a376-4f6c9f4c05d4 Date: 2026-06-17 02:25:01


👤 User

I would like to create a diagram, a dependency graph of the first seven sciences and all depedencies, i.e. red science would have dependencies of copper plates and iron gears, iron gears depend on iron plates, etc.

each item should only appear on the graph only once, although in practice we may need to set a threshold for that rule, i.e. perhaps iron plates should be allowed to appear at every use-site. the purpose of the diagram is to see the inter-relationships, to identify opportunities to be able to leverage common dependencies to multiple destinations, i.e. flying robot frames could send to a splitter and then to yellow science and bots, so that the user could split evenly, or prioritize science or bots.

we can prototype using graphviz, but I think eventually I might like this to be an interactive webapp, with a list of checkboxes (one per item) along the side, allowing the user to toggle certain techs in or out of the dependency graph

I have included a copy of the factorio icon set in your pwd

confer with me on the details before proceeding


🤖 Claude

Let me look at what's in the working directory first to understand the icon set and project structure.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "ls /Users/cell/claude/factorio-sci-map/" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "ls /Users/cell/claude/factorio-sci-map/ | head -60 && echo "---" && ls /Users/cell/claude/factorio-sci-map/ | wc -l" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "ls /Users/cell/claude/factorio-sci-map/icons-64/ | head -80 && echo "---" && ls /Users/cell/claude/factorio-sci-map/icons-64/ | wc -l" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "ls /Users/cell/claude/factorio-sci-map/icons-64/ | grep -E "science|rocket|engine|circuit|gear|plate|inserter|belt|sulfur|plastic|steel|pipe|grenade|ammo|wall|stone|rail|furnace|module|frame|battery|cable|stick|coal|ore|fuel|lubricant|acid|magazine|processing-unit"" }


🤖 Claude

Good, the icon set has everything we need. Let me lay out the dependency graph I have in mind so we can discuss before building.

Here's what I'm thinking for the seven science packs and their full dependency trees:


1. Red (Automation Science Pack)

  • Copper Plate (from Copper Ore)
  • Iron Gear Wheel (from Iron Plate, from Iron Ore)

2. Green (Logistic Science Pack)

  • Transport Belt: Iron

👤 User

[Request interrupted by user]


👤 User

sorry to interrupt, because I have separately built a rate calculator, I happen to already have a "recipe.json" file, which I have pulled into the pwd.

however, this file is 224KB, so it will blow your context window.

our first task is to write a script which will parse recipe.json and then trim it down to the bare-minimum output you need to build the dep tree. the output format can be whatever is most convenient for you (no need to stick with json).

here is the first entry in recipe.json, which will give you the structure needed to write the script:

{
  "assembling-machine-1" : {
    "name" : "assembling-machine-1",
    "localised_name" : [
      "entity-name.assembling-machine-1"
    ],
    "category" : "crafting",
    "order" : "a[assembling-machine-1]",
    "group" : {
      "name" : "production",
      "type" : "item-group"
    },
    "subgroup" : {
      "name" : "production-machine",
      "type" : "item-subgroup"
    },
    "enabled" : false,
    "hidden" : false,
    "hidden_from_player_crafting" : false,
    "emissions_multiplier" : 1,
    "energy" : 0.5,
    "ingredients" : [
      {
        "type" : "item",
        "name" : "iron-plate",
        "amount" : 9
      },
      {
        "type" : "item",
        "name" : "iron-gear-wheel",
        "amount" : 5
      },
      {
        "type" : "item",
        "name" : "electronic-circuit",
        "amount" : 3
      }
    ],
    "products" : [
      {
        "type" : "item",
        "name" : "assembling-machine-1",
        "probability" : 1,
        "amount" : 1
      }
    ],
    "main_product" : {
      "type" : "item",
      "name" : "assembling-machine-1",
      "probability" : 1,
      "amount" : 1
    }
  },


🤖 Claude

I'll write a script that extracts just the product -> [ingredients] mapping from recipe.json — that's all I need for the dependency graph.


🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/extract_deps.py", "content": "#!/usr/bin/env python3\nimport json, sys\n\nwith open("recipe.json") as f:\n recipes = json.load(f)\n\nfor key, recipe in sorted(recipes.items()):\n product = recipe.get("main_product", {}).get("name") or key\n ingredients = [ing["name"] for ing in recipe.get("ingredients", [])]\n if ingredients:\n print(f"{product}: {', '.join(ingredients)}")\n" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 extract_deps.py | head -30 && echo "---" && python3 extract_deps.py | wc -l" }


🤖 Claude

212 lines — very manageable. Let me check the science packs specifically and a few key intermediates to make sure the data looks right.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 extract_deps.py | grep -E "science-pack|sulfuric-acid|solid-fuel|rocket-part|rocket-control|rocket-fuel|speed-module$|productivity-module$|lubricant|petroleum|heavy-oil|light-oil|low-density|flying-robot|processing-unit|electric-engine"" }


🤖 Claude

The data looks correct. A couple things I want to discuss before proceeding:

Things that look right:

  • All 7 science packs are present with correct recipes
  • Intermediate chains look good (e.g. processing-unit needs sulfuric-acid, which needs sulfur, which needs petroleum-gas)

Questions / design decisions:

  1. Solid fuel has 3 recipes (from heavy oil, light oil, or petroleum gas). In practice most people make it from light oil. Should I pick one canonical recipe, or show all three?

  2. Oil processing — the raw materials chain hits petroleum-gas, heavy-oil, light-oil, lubricant, water, crude-oil. Oil processing is a single machine that outputs multiple fluids. The recipe file shows advanced-oil-processing: water, crude-oil but doesn't encode that its outputs are the three oil fractions plus cracking chains. Should I treat oil products as "raw" inputs (leaf nodes, like ores) and just show them as terminal nodes? Or try to model the refinery?

  3. Water — appears as an ingredient for sulfur, sulfuric acid, oil processing. It's essentially free (offshore pump). I'd treat it as a leaf node and possibly omit it entirely. Same question for crude-oil, coal, stone, iron-ore, copper-ore — show them as leaf nodes or cut the graph at plates/bricks?

  4. The "appear once" threshold — candidates for "allowed to appear multiple times" would be: iron-plate, copper-plate, steel-plate, electronic-circuit, petroleum-gas. These fan out to so many consumers that deduplicating them makes the graph unreadable. How many duplicates feels right?

What are your preferences on these?


👤 User

cell@uranium$ ./extract_deps.py  | awk '{print $1}' | sort | uniq -c | sort -n | grep -v '^   1 '
   3 solid-fuel:

how wow, solid fuel is the only one like that.

re 1 initially, let's just assume solid fuel is made from light oil. we can revisit later to add more customization.

re 2 hmm, for now let's model the refinery as a single machine or node in the graph which takes in oil and water and emits petro, light oil and heavy oil

re 3, hmm, well stone is needed by rails, so let's keep it as a leaf node, but also treat bricks as if they are a leaf node. hmm, iron ore is needed for concrete, so let's also keep as leaf, but also treat iron plates is if they were a leaf. omit copper ore. keep coal as a leaf since it is needed for plastic.

re 4 yes also water should appear where needed instead of once. oil too. items should either appear once or appear where used.

we might need to see a working graph and then it will be more obvious where the adjustments to these rules need to be made.


🤖 Claude

Good catch on the solid-fuel uniqueness. Let me build a script that generates the graphviz dot file with all those rules baked in.


🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/gen_graph.py", "content": "#!/usr/bin/env python3\n"""Generate a Graphviz dot file for Factorio science pack dependencies."""\n\nimport json\nfrom collections import deque\n\nwith open("recipe.json") as f:\n recipes = json.load(f)\n\n# Build dependency map, picking light-oil recipe for solid-fuel\ndeps = {}\nfor key, recipe in recipes.items():\n product = recipe.get("main_product", {}).get("name") or key\n ingredients = [ing["name"] for ing in recipe.get("ingredients", [])]\n if product == "solid-fuel":\n if "light-oil" in ingredients:\n deps[product] = ingredients\n elif product not in deps:\n deps[product] = ingredients\n\nSCIENCES = [\n "automation-science-pack",\n "logistic-science-pack",\n "chemical-science-pack",\n "military-science-pack",\n "production-science-pack",\n "utility-science-pack",\n "space-science-pack",\n]\n\n# Leaf nodes: shown but not expanded\nLEAVES = {\n "iron-plate", "copper-plate", "coal", "stone", "stone-brick",\n "water", "crude-oil",\n}\n\n# These get duplicated at every use site instead of a single shared node\nDUPLICATE = {\n "water", "iron-plate", "copper-plate", "coal", "stone", "stone-brick",\n "crude-oil",\n}\n\n# Oil products come from the refinery, not from normal recipes\nOIL_PRODUCTS = {"petroleum-gas", "light-oil", "heavy-oil"}\n\n# --- BFS to find all needed items ---\nneeded = set()\nqueue = deque(SCIENCES)\nwhile queue:\n item = queue.popleft()\n if item in needed:\n continue\n needed.add(item)\n if item in LEAVES or item in OIL_PRODUCTS:\n continue\n for ing in deps.get(item, []):\n queue.append(ing)\n\n# Add oil products + refinery if any oil product is needed\nuses_oil = bool(needed & OIL_PRODUCTS)\nif uses_oil:\n needed.update(OIL_PRODUCTS)\n needed.add("oil-processing")\n\n# --- Build edges ---\nedges = [] # (from_id, to_id)\nnode_defs = {} # id -> label\ndup_counter = {}\n\ndef node_id(name):\n return name.replace("-", "")\n\ndef dup_node(name):\n """Create a unique node ID for a duplicated item."""\n dup_counter[name] = dup_counter.get(name, 0) + 1\n uid = f"{node_id(name)}{dup_counter[name]}"\n node_defs[uid] = name\n return uid\n\ndef ensure_node(name):\n nid = node_id(name)\n node_defs[nid] = name\n return nid\n\n# Process each needed non-leaf item\nfor item in sorted(needed):\n if item in LEAVES or item in OIL_PRODUCTS or item == "oil-processing":\n continue\n item_id = ensure_node(item)\n for ing in deps.get(item, []):\n if ing in OIL_PRODUCTS:\n # Edge from oil product to consumer\n edges.append((ensure_node(ing), item_id))\n elif ing in DUPLICATE:\n dup_id = dup_node(ing)\n edges.append((dup_id, item_id))\n else:\n edges.append((ensure_node(ing), item_id))\n\n# Oil processing edges\nif uses_oil:\n refinery_id = ensure_node("oil-processing")\n for prod in OIL_PRODUCTS:\n if prod in needed:\n edges.append((refinery_id, ensure_node(prod)))\n # Inputs to refinery\n edges.append((dup_node("crude-oil"), refinery_id))\n edges.append((dup_node("water"), refinery_id))\n\n# --- Styling ---\nSCIENCE_COLORS = {\n "automation-science-pack": "#e74c3c",\n "logistic-science-pack": "#2ecc71",\n "chemical-science-pack": "#3498db",\n "military-science-pack": "#555555",\n "production-science-pack": "#9b59b6",\n "utility-science-pack": "#f1c40f",\n "space-science-pack": "#ecf0f1",\n}\n\nLEAF_COLOR = "#d4a574"\nOIL_COLOR = "#2c3e50"\nINTERMEDIATE_COLOR = "#bdc3c7"\n\ndef label(name):\n return name.replace("-", " ").title()\n\n# --- Output dot ---\nprint("digraph factorio_science {")\nprint(' rankdir=BT;')\nprint(' bgcolor="transparent";')\nprint(' node [fontname="Helvetica", fontsize=10, style=filled, shape=box, margin="0.15,0.08"];')\nprint(' edge [color="#666666", arrowsize=0.7];')\nprint()\n\n# Science pack nodes\nprint(" // Science packs")\nfor sci in SCIENCES:\n sid = node_id(sci)\n color = SCIENCE_COLORS[sci]\n fc = "white" if color in ("#e74c3c", "#555555", "#3498db", "#9b59b6", "#2c3e50") else "black"\n print(f' {sid} [label="{label(sci)}", fillcolor="{color}", fontcolor="{fc}"];')\nprint()\n\n# Oil processing node\nif uses_oil:\n print(" // Oil processing")\n rid = node_id("oil-processing")\n print(f' {rid} [label="Oil Processing\\n(Refinery)", fillcolor="{OIL_COLOR}", fontcolor="white", shape=octagon];')\n for prod in sorted(OIL_PRODUCTS):\n if prod in needed:\n pid = node_id(prod)\n print(f' {pid} [label="{label(prod)}", fillcolor="#1a5276", fontcolor="white", shape=ellipse];')\n print()\n\n# Intermediate nodes\nprint(" // Intermediates")\nfor nid, name in sorted(node_defs.items()):\n if nid.startswith(tuple(node_id(s) for s in SCIENCES)):\n if nid in [node_id(s) for s in SCIENCES]:\n continue\n if name in [s for s in SCIENCES] or name == "oil-processing" or name in OIL_PRODUCTS:\n continue\n if name in DUPLICATE:\n continue # handled below\n print(f' {nid} [label="{label(name)}", fillcolor="{INTERMEDIATE_COLOR}"];')\nprint()\n\n# Duplicated leaf nodes\nprint(" // Leaf nodes (duplicated at each use site)")\nfor nid, name in sorted(node_defs.items()):\n if name in DUPLICATE:\n print(f' {nid} [label="{label(name)}", fillcolor="{LEAF_COLOR}", shape=ellipse];')\nprint()\n\n# Edges\nprint(" // Edges")\nfor src, dst in edges:\n print(f" {src} -> {dst};")\n\nprint("}")\n" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 gen_graph.py > science_deps.dot 2>&1 && cat science_deps.dot" }


🤖 Claude

Good, the dot file looks correct. Now let me check if graphviz is available and render it.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "which dot 2>/dev/null && dot -V 2>&1 || echo "graphviz not installed"" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "dot -Tpng -Gdpi=150 science_deps.dot -o science_deps.png" }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/science_deps.png" }


🤖 Claude

OK, that's a working first draft. I notice a few issues we should discuss:

  1. Space Science Pack is floating at the bottom disconnected — I forgot to wire up rocket-part and its dependencies (rocket-control-unit, low-density-structure, rocket-fuel, speed-module, solid-fuel). Let me fix that.

  2. 11 copies of Iron Plate is visually noisy. We might want to try iron-plate as a single shared node instead, or at least see both versions.

  3. The layout is very wide — rankdir=BT (bottom-to-top) with this many leaf duplicates spreads horizontally a lot. We could try LR (left-to-right) or tweak the graph.

Let me fix the space science gap first — that's a bug.


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/gen_graph.py", "offset": 17, "limit": 15 }


🤖 Claude

Let me check what recipe.json has for space-science-pack.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 -c "\nimport json\nwith open('recipe.json') as f:\n r = json.load(f)\nfor name in ['space-science-pack', 'rocket-part', 'rocket-control-unit', 'rocket-fuel', 'speed-module', 'solid-fuel']:\n if name in r:\n ings = [i['name'] for i in r[name].get('ingredients', [])]\n print(f'{name}: {ings}')\n else:\n print(f'{name}: NOT IN RECIPES')\n"" }


🤖 Claude

Space science pack isn't in recipe.json because it's generated by launching a rocket, not crafted. And solid-fuel isn't found because my script deduplicated it wrong — there are 3 solid-fuel entries under different keys. Let me check.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 -c "\nimport json\nwith open('recipe.json') as f:\n r = json.load(f)\nfor key, recipe in r.items():\n product = recipe.get('main_product', {}).get('name') or key\n if product == 'solid-fuel':\n ings = [i['name'] for i in recipe.get('ingredients', [])]\n print(f'key={key}, product={product}, ings={ings}')\n"" }


🤖 Claude

The recipe keys are different from the product name. My script checks product not in deps which skips the first hit because product == "solid-fuel" but key == "solid-fuel-from-light-oil" — actually wait, it should work: the first time through, product == "solid-fuel" hits the if product == "solid-fuel" branch, checks "light-oil" in ingredients which is true, so it sets deps["solid-fuel"]. Let me verify.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 -c "\nimport json\nwith open('recipe.json') as f:\n recipes = json.load(f)\ndeps = {}\nfor key, recipe in recipes.items():\n product = recipe.get('main_product', {}).get('name') or key\n ingredients = [ing['name'] for ing in recipe.get('ingredients', [])]\n if product == 'solid-fuel':\n if 'light-oil' in ingredients:\n deps[product] = ingredients\n elif product not in deps:\n deps[product] = ingredients\nprint('solid-fuel:', deps.get('solid-fuel', 'MISSING'))\nprint('space-science-pack:', deps.get('space-science-pack', 'MISSING'))\nprint('rocket-part:', deps.get('rocket-part', 'MISSING'))\n"" }


🤖 Claude

So solid-fuel and rocket-part are fine — the problem is just that space-science-pack isn't in recipe.json at all (it's a rocket launch reward, not a recipe). I need to hardcode it. I'll also add rocket-part as its dependency since that's the recipe you actually craft.


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/gen_graph.py", "old_string": "SCIENCES = [\n "automation-science-pack",\n "logistic-science-pack",\n "chemical-science-pack",\n "military-science-pack",\n "production-science-pack",\n "utility-science-pack",\n "space-science-pack",\n]", "new_string": "SCIENCES = [\n "automation-science-pack",\n "logistic-science-pack",\n "chemical-science-pack",\n "military-science-pack",\n "production-science-pack",\n "utility-science-pack",\n "space-science-pack",\n]\n\n# space-science-pack isn't crafted \u2014 it's a rocket launch reward.\n# Model it as depending on rocket-part.\ndeps["space-science-pack"] = ["rocket-part"]" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 gen_graph.py > science_deps.dot && dot -Tpng -Gdpi=150 science_deps.dot -o science_deps.png" }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/science_deps.png" }


🤖 Claude

Space science is now connected through rocket-part. Here's what I see and what I think we should discuss:

What's working:

  • All 7 sciences connected with full dependency chains
  • Oil refinery modeled as a single node with 3 outputs
  • Leaf nodes duplicated at each use site

Issues to consider:

  1. Iron Plate appears 11 times — that's a lot of visual noise. We could try making it a single shared node and see if the graph is more readable, or reduce it to maybe 3-4 grouped copies.

  2. Rocket-fuel depends on both solid-fuel AND light-oil directly (the recipe is solid-fuel + light-oil), and solid-fuel itself comes from light-oil. So light-oil feeds rocket-fuel two ways. That's correct per the recipe but worth noting.

  3. The graph is very wide. Might benefit from rankdir=LR (left-to-right) instead of bottom-to-top, or some subgraph clusters to group things by science color.

  4. No rocket-part → space-science-pack visual distinction — in-game you launch a rocket with a satellite, not just craft rocket parts. We could add a "Rocket Silo" node or just leave it simplified.

Want me to try any of these adjustments, or does something else jump out at you from the layout?


👤 User

after seeing the graph, let's eliminate the refinery and treat crude, water, petro, light oil, heavy oil, and lube as leaf nodes which appear N times

before you make any changes, go ahead and make a v0 directory and move the existing gen_graph.py, science_deps.dot and science_deps.png into there, then make a new v1 directory and make the changes there

actually, because I am mostly playing space age, we can omit space science and RCU's from the graph

not worried about 11x iron plates, that's easier for a human to parse than 11 long squiggly lines

re wide graph: don't worry about the graphviz layout because graphviz is only temporary anyway


🤖 Claude

🔧 Using tool: Bash Input: { "command": "mkdir -p /Users/cell/claude/factorio-sci-map/v0 /Users/cell/claude/factorio-sci-map/v1 && mv /Users/cell/claude/factorio-sci-map/gen_graph.py /Users/cell/claude/factorio-sci-map/science_deps.dot /Users/cell/claude/factorio-sci-map/science_deps.png /Users/cell/claude/factorio-sci-map/v0/" }


🤖 Claude

Now I'll create v1 with the changes: drop oil refinery node (treat crude, water, petro, light oil, heavy oil, lubricant as duplicated leaf nodes), drop space science pack and its unique dependencies (rocket-part, rocket-control-unit, rocket-fuel, speed-module, solid-fuel).


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v0/gen_graph.py" }


🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v1/gen_graph.py", "content": "#!/usr/bin/env python3\n"""Generate a Graphviz dot file for Factorio science pack dependencies."""\n\nimport json\nfrom collections import deque\n\nwith open("recipe.json") as f:\n recipes = json.load(f)\n\n# Build dependency map, picking light-oil recipe for solid-fuel\ndeps = {}\nfor key, recipe in recipes.items():\n product = recipe.get("main_product", {}).get("name") or key\n ingredients = [ing["name"] for ing in recipe.get("ingredients", [])]\n if product == "solid-fuel":\n if "light-oil" in ingredients:\n deps[product] = ingredients\n elif product not in deps:\n deps[product] = ingredients\n\nSCIENCES = [\n "automation-science-pack",\n "logistic-science-pack",\n "chemical-science-pack",\n "military-science-pack",\n "production-science-pack",\n "utility-science-pack",\n]\n\n# Leaf nodes: shown but not expanded\nLEAVES = {\n "iron-plate", "copper-plate", "coal", "stone", "stone-brick",\n "water", "crude-oil",\n "petroleum-gas", "light-oil", "heavy-oil", "lubricant",\n}\n\n# All leaves get duplicated at every use site\nDUPLICATE = LEAVES\n\n# --- BFS to find all needed items ---\nneeded = set()\nqueue = deque(SCIENCES)\nwhile queue:\n item = queue.popleft()\n if item in needed:\n continue\n needed.add(item)\n if item in LEAVES:\n continue\n for ing in deps.get(item, []):\n queue.append(ing)\n\n# --- Build edges ---\nedges = [] # (from_id, to_id)\nnode_defs = {} # id -> label\ndup_counter = {}\n\ndef node_id(name):\n return name.replace("-", "")\n\ndef dup_node(name):\n dup_counter[name] = dup_counter.get(name, 0) + 1\n uid = f"{node_id(name)}{dup_counter[name]}"\n node_defs[uid] = name\n return uid\n\ndef ensure_node(name):\n nid = node_id(name)\n node_defs[nid] = name\n return nid\n\nfor item in sorted(needed):\n if item in LEAVES:\n continue\n item_id = ensure_node(item)\n for ing in deps.get(item, []):\n if ing in DUPLICATE:\n edges.append((dup_node(ing), item_id))\n else:\n edges.append((ensure_node(ing), item_id))\n\n# --- Styling ---\nSCIENCE_COLORS = {\n "automation-science-pack": "#e74c3c",\n "logistic-science-pack": "#2ecc71",\n "chemical-science-pack": "#3498db",\n "military-science-pack": "#555555",\n "production-science-pack": "#9b59b6",\n "utility-science-pack": "#f1c40f",\n}\n\nLEAF_COLOR = "#d4a574"\nINTERMEDIATE_COLOR = "#bdc3c7"\n\ndef label(name):\n return name.replace("-", " ").title()\n\n# --- Output dot ---\nprint("digraph factorio_science {")\nprint(' rankdir=BT;')\nprint(' bgcolor="transparent";')\nprint(' node [fontname="Helvetica", fontsize=10, style=filled, shape=box, margin="0.15,0.08"];')\nprint(' edge [color="#666666", arrowsize=0.7];')\nprint()\n\nprint(" // Science packs")\nfor sci in SCIENCES:\n sid = node_id(sci)\n color = SCIENCE_COLORS[sci]\n fc = "white" if color in ("#e74c3c", "#555555", "#3498db", "#9b59b6") else "black"\n print(f' {sid} [label="{label(sci)}", fillcolor="{color}", fontcolor="{fc}"];')\nprint()\n\nprint(" // Intermediates")\nfor nid, name in sorted(node_defs.items()):\n if name in SCIENCES or name in DUPLICATE:\n continue\n print(f' {nid} [label="{label(name)}", fillcolor="{INTERMEDIATE_COLOR}"];')\nprint()\n\nprint(" // Leaf nodes (duplicated at each use site)")\nfor nid, name in sorted(node_defs.items()):\n if name in DUPLICATE:\n print(f' {nid} [label="{label(name)}", fillcolor="{LEAF_COLOR}", shape=ellipse];')\nprint()\n\nprint(" // Edges")\nfor src, dst in edges:\n print(f" {src} -> {dst};")\n\nprint("}")\n" }


🤖 Claude

🔧 Using tool: Bash Input: { "command": "python3 v1/gen_graph.py > v1/science_deps.dot && dot -Tpng -Gdpi=150 v1/science_deps.dot -o v1/science_deps.png" }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v1/science_deps.png" }


🤖 Claude

Cleaner — 6 sciences, no refinery node, oil products as leaves. Space science and its unique chain (rocket-part, RCU, speed-module, rocket-fuel, solid-fuel) are gone. Lubricant shows up as a leaf feeding electric-engine-unit.

The graph is correct. Where do you want to go from here?


👤 User

great, now let's convert it to a standalone webapp. it should be an entirely self-contained HTML file, if possible. ask me any clarifying questions needed. make it in v2/


🤖 Claude

A few questions before I build:

  1. "Entirely self-contained" — is loading a JS library from CDN (like D3 or Cytoscape.js) acceptable, or do you want zero external dependencies? Inlining a library is doable but bloats the file significantly.

  2. Factorio icons — should I embed the icons from icons-64/ as base64 data URIs so each node shows its icon? That'll make the HTML file large (~300-500KB) but truly standalone and much nicer visually. Or just text labels for now?

  3. Layout style — hierarchical/tree (like the graphviz output, sciences at top, leaves at bottom) or force-directed (nodes float and you can drag them around)?

  4. Checkbox behavior — when you uncheck an item, should it:

    • (a) Hide that node and prune any dependencies that are now orphaned (not needed by anything else still visible), or
    • (b) Just hide that single node and its direct edges, leaving everything else in place?

👤 User

re 1 CDN is ok for now, that opens up more possibilites

re 2 actually, I have the icons publicly hosted at rocketcal.cc (my factorio silo bp generator). for example, https://rocketcal.cc/icons-64/fast-inserter.png

re 3 let's try the force directed option

re 4 hmm, actually let's do the opposite. keep the orphans, but also uncheck the dependent nodes


🤖 Claude

Let me check which icons exist for the items we need, then build the webapp.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "for f in automation-science-pack logistic-science-pack chemical-science-pack military-science-pack production-science-pack utility-science-pack advanced-circuit battery copper-cable electric-engine-unit electric-furnace electronic-circuit engine-unit firearm-magazine flying-robot-frame grenade inserter iron-gear-wheel iron-stick low-density-structure piercing-rounds-magazine pipe plastic-bar processing-unit productivity-module rail steel-plate stone-wall sulfur sulfuric-acid transport-belt coal copper-plate iron-plate lubricant petroleum-gas stone stone-brick water; do\n [ -f "icons-64/${f}.png" ] && echo "OK: $f" || echo "MISSING: $f"\ndone" }


🤖 Claude

Four missing icons — all fluids. Let me check what names are available for those.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "ls icons-64/ | grep -E "sulfuric|lubric|petrol|water|fluid"" }


🤖 Claude

Fluids don't have item icons in the standard set. I'll use fallback text labels for those four. Now let me build the webapp.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "mkdir -p /Users/cell/claude/factorio-sci-map/v2" }


🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "content": "\n<html lang="en">\n\n<meta charset="utf-8">\n<title>Factorio Science Dependency Map</title>\n<style>\n* { margin: 0; padding: 0; box-sizing: border-box; }\nhtml, body { height: 100%; overflow: hidden; }\nbody {\n display: flex;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n background: #0f0f1a;\n color: #e0e0e0;\n}\n\n#sidebar {\n width: 280px;\n min-width: 280px;\n background: #161625;\n border-right: 1px solid #2a2a40;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\n\n.sidebar-header {\n padding: 16px;\n border-bottom: 1px solid #2a2a40;\n}\n\n.sidebar-header h1 {\n font-size: 15px;\n font-weight: 600;\n margin-bottom: 10px;\n}\n\n#reset-btn {\n padding: 6px 14px;\n background: #2a2a40;\n border: 1px solid #3a3a55;\n border-radius: 4px;\n color: #ccc;\n cursor: pointer;\n font-size: 12px;\n}\n#reset-btn:hover { background: #3a3a55; }\n\n#checkboxes {\n flex: 1;\n overflow-y: auto;\n padding: 8px 16px 16px;\n}\n\n.section-header {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: #777;\n margin: 16px 0 6px;\n padding-bottom: 4px;\n border-bottom: 1px solid #2a2a40;\n}\n.section-header:first-child { margin-top: 8px; }\n\n.cb-item {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 4px;\n cursor: pointer;\n border-radius: 4px;\n}\n.cb-item:hover { background: #1e1e33; }\n\n.cb-item input[type="checkbox"] {\n width: 14px;\n height: 14px;\n cursor: pointer;\n accent-color: #5588cc;\n flex-shrink: 0;\n}\n\n.cb-item img, .cb-item .fluid-icon {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n flex-shrink: 0;\n}\n\n.fluid-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n background: #1a3a5c;\n border-radius: 4px;\n font-size: 12px;\n}\n\n.cb-item span {\n font-size: 13px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#graph-container {\n flex: 1;\n position: relative;\n overflow: hidden;\n}\n#graph-container svg {\n width: 100%;\n height: 100%;\n cursor: grab;\n}\n#graph-container svg:active { cursor: grabbing; }\n\n.node-group { cursor: pointer; }\n.node-group text {\n fill: #aaa;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n pointer-events: none;\n user-select: none;\n}\n.node-group image { pointer-events: none; }\n.node-group .fluid-label {\n fill: #8ab4e8;\n font-size: 8px;\n text-anchor: middle;\n dominant-baseline: central;\n pointer-events: none;\n}\n\n.dimmed { opacity: 0.08; }\n.link, .node-group { transition: opacity 0.15s; }\n</style>\n\n\n\n<div id="sidebar">\n <div class="sidebar-header">\n

Factorio Science Dependencies

\n <button id="reset-btn">Reset All\n \n <div id="checkboxes">\n\n<div id="graph-container">\n \n\n\n<script src="https://d3js.org/d3.v7.min.js\"></script>\n<script>\nconst ICON_BASE = "https://rocketcal.cc/icons-64/\";\n\nconst FLUIDS = new Set(["water", "petroleum-gas", "lubricant", "sulfuric-acid",\n "heavy-oil", "light-oil", "crude-oil"]);\n\nconst FLUID_SYMBOLS = {\n "water": "\u{1F4A7}",\n "petroleum-gas": "\u{2B24}",\n "lubricant": "\u{2B24}",\n "sulfuric-acid": "\u{2B24}",\n};\n\nconst SCIENCE_COLORS = {\n "automation-science-pack": "#c0392b",\n "logistic-science-pack": "#27ae60",\n "chemical-science-pack": "#2980b9",\n "military-science-pack": "#5a5a5a",\n "production-science-pack": "#8e44ad",\n "utility-science-pack": "#d4ac0d",\n};\n\nconst RAW_NODES = [\n {id: "automation-science-pack", type: "science"},\n {id: "logistic-science-pack", type: "science"},\n {id: "chemical-science-pack", type: "science"},\n {id: "military-science-pack", type: "science"},\n {id: "production-science-pack", type: "science"},\n {id: "utility-science-pack", type: "science"},\n\n {id: "advanced-circuit", type: "intermediate"},\n {id: "battery", type: "intermediate"},\n {id: "copper-cable", type: "intermediate"},\n {id: "electric-engine-unit", type: "intermediate"},\n {id: "electric-furnace", type: "intermediate"},\n {id: "electronic-circuit", type: "intermediate"},\n {id: "engine-unit", type: "intermediate"},\n {id: "firearm-magazine", type: "intermediate"},\n {id: "flying-robot-frame", type: "intermediate"},\n {id: "grenade", type: "intermediate"},\n {id: "inserter", type: "intermediate"},\n {id: "iron-gear-wheel", type: "intermediate"},\n {id: "iron-stick", type: "intermediate"},\n {id: "low-density-structure", type: "intermediate"},\n {id: "piercing-rounds-magazine", type: "intermediate"},\n {id: "pipe", type: "intermediate"},\n {id: "plastic-bar", type: "intermediate"},\n {id: "processing-unit", type: "intermediate"},\n {id: "productivity-module", type: "intermediate"},\n {id: "rail", type: "intermediate"},\n {id: "steel-plate", type: "intermediate"},\n {id: "stone-wall", type: "intermediate"},\n {id: "sulfur", type: "intermediate"},\n {id: "sulfuric-acid", type: "intermediate"},\n {id: "transport-belt", type: "intermediate"},\n\n {id: "coal", type: "leaf"},\n {id: "copper-plate", type: "leaf"},\n {id: "iron-plate", type: "leaf"},\n {id: "lubricant", type: "leaf"},\n {id: "petroleum-gas", type: "leaf"},\n {id: "stone", type: "leaf"},\n {id: "stone-brick", type: "leaf"},\n {id: "water", type: "leaf"},\n];\n\nconst RAW_LINKS = [\n {source: "copper-plate", target: "automation-science-pack"},\n {source: "iron-gear-wheel", target: "automation-science-pack"},\n {source: "transport-belt", target: "logistic-science-pack"},\n {source: "inserter", target: "logistic-science-pack"},\n {source: "sulfur", target: "chemical-science-pack"},\n {source: "advanced-circuit", target: "chemical-science-pack"},\n {source: "engine-unit", target: "chemical-science-pack"},\n {source: "piercing-rounds-magazine", target: "military-science-pack"},\n {source: "grenade", target: "military-science-pack"},\n {source: "stone-wall", target: "military-science-pack"},\n {source: "rail", target: "production-science-pack"},\n {source: "electric-furnace", target: "production-science-pack"},\n {source: "productivity-module", target: "production-science-pack"},\n {source: "processing-unit", target: "utility-science-pack"},\n {source: "flying-robot-frame", target: "utility-science-pack"},\n {source: "low-density-structure", target: "utility-science-pack"},\n\n {source: "iron-plate", target: "iron-gear-wheel"},\n {source: "iron-gear-wheel", target: "transport-belt"},\n {source: "iron-plate", target: "transport-belt"},\n {source: "iron-gear-wheel", target: "inserter"},\n {source: "iron-plate", target: "inserter"},\n {source: "electronic-circuit", target: "inserter"},\n {source: "water", target: "sulfur"},\n {source: "petroleum-gas", target: "sulfur"},\n {source: "plastic-bar", target: "advanced-circuit"},\n {source: "copper-cable", target: "advanced-circuit"},\n {source: "electronic-circuit", target: "advanced-circuit"},\n {source: "iron-gear-wheel", target: "engine-unit"},\n {source: "pipe", target: "engine-unit"},\n {source: "steel-plate", target: "engine-unit"},\n {source: "copper-plate", target: "piercing-rounds-magazine"},\n {source: "steel-plate", target: "piercing-rounds-magazine"},\n {source: "firearm-magazine", target: "piercing-rounds-magazine"},\n {source: "coal", target: "grenade"},\n {source: "iron-plate", target: "grenade"},\n {source: "stone-brick", target: "stone-wall"},\n {source: "iron-stick", target: "rail"},\n {source: "steel-plate", target: "rail"},\n {source: "stone", target: "rail"},\n {source: "steel-plate", target: "electric-furnace"},\n {source: "advanced-circuit", target: "electric-furnace"},\n {source: "stone-brick", target: "electric-furnace"},\n {source: "advanced-circuit", target: "productivity-module"},\n {source: "electronic-circuit", target: "productivity-module"},\n {source: "electronic-circuit", target: "processing-unit"},\n {source: "advanced-circuit", target: "processing-unit"},\n {source: "sulfuric-acid", target: "processing-unit"},\n {source: "steel-plate", target: "flying-robot-frame"},\n {source: "battery", target: "flying-robot-frame"},\n {source: "electronic-circuit", target: "flying-robot-frame"},\n {source: "electric-engine-unit", target: "flying-robot-frame"},\n {source: "copper-plate", target: "low-density-structure"},\n {source: "steel-plate", target: "low-density-structure"},\n {source: "plastic-bar", target: "low-density-structure"},\n\n {source: "iron-plate", target: "electronic-circuit"},\n {source: "copper-cable", target: "electronic-circuit"},\n {source: "coal", target: "plastic-bar"},\n {source: "petroleum-gas", target: "plastic-bar"},\n {source: "copper-plate", target: "copper-cable"},\n {source: "iron-plate", target: "pipe"},\n {source: "iron-plate", target: "steel-plate"},\n {source: "iron-plate", target: "firearm-magazine"},\n {source: "iron-plate", target: "iron-stick"},\n {source: "iron-plate", target: "sulfuric-acid"},\n {source: "sulfur", target: "sulfuric-acid"},\n {source: "water", target: "sulfuric-acid"},\n {source: "iron-plate", target: "battery"},\n {source: "copper-plate", target: "battery"},\n {source: "sulfuric-acid", target: "battery"},\n {source: "electronic-circuit", target: "electric-engine-unit"},\n {source: "engine-unit", target: "electric-engine-unit"},\n {source: "lubricant", target: "electric-engine-unit"},\n];\n\n// --- Tier computation ---\nconst ingredientsOf = {};\nfor (const link of RAW_LINKS) {\n if (!ingredientsOf[link.target]) ingredientsOf[link.target] = [];\n ingredientsOf[link.target].push(link.source);\n}\n\nconst tierCache = {};\nfunction getTier(id) {\n if (tierCache[id] !== undefined) return tierCache[id];\n tierCache[id] = 0;\n const ings = ingredientsOf[id] || [];\n if (ings.length > 0) {\n tierCache[id] = 1 + Math.max(...ings.map(getTier));\n }\n return tierCache[id];\n}\nfor (const node of RAW_NODES) {\n node.tier = getTier(node.id);\n}\nconst maxTier = Math.max(...RAW_NODES.map(n => n.tier));\n\n// --- State ---\nconst enabled = new Set(RAW_NODES.map(n => n.id));\nconst savedPositions = {};\n\n// --- Utilities ---\nfunction displayName(id) {\n return id.split("-").map(w => w[0].toUpperCase() + w.slice(1)).join(" ");\n}\n\nfunction getTransitiveDependents(nodeId) {\n const result = new Set();\n const queue = [nodeId];\n while (queue.length > 0) {\n const current = queue.shift();\n for (const link of RAW_LINKS) {\n if (link.source === current && !result.has(link.target)) {\n result.add(link.target);\n queue.push(link.target);\n }\n }\n }\n return result;\n}\n\nfunction linkId(d) {\n const s = typeof d.source === "object" ? d.source.id : d.source;\n const t = typeof d.target === "object" ? d.target.id : d.target;\n return s + ">" + t;\n}\n\n// --- Sidebar ---\nfunction buildSidebar() {\n const container = document.getElementById("checkboxes");\n container.innerHTML = "";\n\n const groups = [\n {label: "Science Packs", items: RAW_NODES.filter(n => n.type === "science")},\n {label: "Intermediates", items: [...RAW_NODES.filter(n => n.type === "intermediate")].sort((a,b) => a.id.localeCompare(b.id))},\n {label: "Raw Materials", items: [...RAW_NODES.filter(n => n.type === "leaf")].sort((a,b) => a.id.localeCompare(b.id))},\n ];\n\n for (const group of groups) {\n const header = document.createElement("div");\n header.className = "section-header";\n header.textContent = group.label;\n container.appendChild(header);\n\n for (const node of group.items) {\n const item = document.createElement("label");\n item.className = "cb-item";\n\n const iconHtml = FLUIDS.has(node.id)\n ? <span class=\"fluid-icon\">${FLUID_SYMBOLS[node.id] || \"\\u{1F4A7}\"}</span>\n : <img src=\"${ICON_BASE}${node.id}.png\" alt=\"\">;\n\n item.innerHTML = \n <input type=\"checkbox\" data-id=\"${node.id}\" ${enabled.has(node.id) ? \"checked\" : \"\"}>\n ${iconHtml}\n <span>${displayName(node.id)}</span>\n ;\n container.appendChild(item);\n\n item.querySelector("input").addEventListener("change", (e) => {\n if (e.target.checked) {\n enabled.add(node.id);\n } else {\n enabled.delete(node.id);\n const deps = getTransitiveDependents(node.id);\n for (const depId of deps) {\n enabled.delete(depId);\n const cb = document.querySelector(input[data-id=\"${depId}\"]);\n if (cb) cb.checked = false;\n }\n }\n updateGraph();\n });\n }\n }\n}\n\ndocument.getElementById("reset-btn").addEventListener("click", () => {\n for (const node of RAW_NODES) enabled.add(node.id);\n document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = true);\n updateGraph();\n});\n\n// --- Graph setup ---\nconst graphContainer = document.getElementById("graph-container");\nconst svg = d3.select("#graph-container svg");\nlet width = graphContainer.clientWidth;\nlet height = graphContainer.clientHeight;\n\nconst defs = svg.append("defs");\n\ndefs.append("marker")\n .attr("id", "arrow")\n .attr("viewBox", "0 -5 10 10")\n .attr("refX", 35).attr("refY", 0)\n .attr("markerWidth", 8).attr("markerHeight", 8)\n .attr("orient", "auto")\n .append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#444");\n\ndefs.append("marker")\n .attr("id", "arrow-hl")\n .attr("viewBox", "0 -5 10 10")\n .attr("refX", 35).attr("refY", 0)\n .attr("markerWidth", 8).attr("markerHeight", 8)\n .attr("orient", "auto")\n .append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#7ab8e8");\n\nconst g = svg.append("g");\nconst linkGroup = g.append("g");\nconst nodeGroup = g.append("g");\n\nconst zoom = d3.zoom()\n .scaleExtent([0.15, 5])\n .on("zoom", (event) => g.attr("transform", event.transform));\nsvg.call(zoom);\n\n// --- Simulation & rendering ---\nlet simulation = null;\nlet nodeSelection = d3.selectAll(null);\nlet linkSelection = d3.selectAll(null);\nconst NODE_R = 22;\n\nfunction tierY(tier) {\n const pad = 0.12;\n return height * (1 - pad - (tier / maxTier) * (1 - 2 * pad));\n}\n\nfunction nodeStroke(d) {\n if (d.type === "science") return SCIENCE_COLORS[d.id];\n if (d.type === "leaf") return "#7a5c3d";\n return "#4a4a65";\n}\n\nfunction nodeFill(d) {\n if (d.type === "science") {\n const c = d3.color(SCIENCE_COLORS[d.id]);\n c.opacity = 0.25;\n return c + "";\n }\n return "#22223a";\n}\n\nfunction updateGraph() {\n if (simulation) {\n for (const n of simulation.nodes()) {\n savedPositions[n.id] = {x: n.x, y: n.y};\n }\n simulation.stop();\n }\n\n const nodes = RAW_NODES\n .filter(n => enabled.has(n.id))\n .map(n => {\n const pos = savedPositions[n.id];\n return {\n ...n,\n x: pos ? pos.x : width / 2 + (Math.random() - 0.5) * width * 0.4,\n y: pos ? pos.y : tierY(n.tier),\n };\n });\n\n const nodeIds = new Set(nodes.map(n => n.id));\n const links = RAW_LINKS\n .filter(l => nodeIds.has(l.source) && nodeIds.has(l.target))\n .map(l => ({source: l.source, target: l.target}));\n\n simulation = d3.forceSimulation(nodes)\n .force("link", d3.forceLink(links).id(d => d.id).distance(90).strength(0.4))\n .force("charge", d3.forceManyBody().strength(-350))\n .force("x", d3.forceX(width / 2).strength(0.04))\n .force("y", d3.forceY(d => tierY(d.tier)).strength(0.12))\n .force("collide", d3.forceCollide(40))\n .alphaDecay(0.02)\n .on("tick", ticked);\n\n // --- Links ---\n linkSelection = linkGroup.selectAll("line")\n .data(links, linkId)\n .join("line")\n .attr("class", "link")\n .attr("stroke", "#3a3a55")\n .attr("stroke-width", 1.5)\n .attr("marker-end", "url(#arrow)");\n\n // --- Nodes ---\n nodeSelection = nodeGroup.selectAll(".node-group")\n .data(nodes, d => d.id)\n .join(\n enter => {\n const ng = enter.append("g").attr("class", "node-group");\n\n ng.append("circle")\n .attr("r", NODE_R)\n .attr("fill", nodeFill)\n .attr("stroke", nodeStroke)\n .attr("stroke-width", d => d.type === "science" ? 3 : 1.5);\n\n // Icon or fluid label\n ng.each(function(d) {\n const el = d3.select(this);\n if (FLUIDS.has(d.id)) {\n el.append("text")\n .attr("class", "fluid-label")\n .attr("font-size", "10px")\n .text(shortFluidName(d.id));\n } else {\n el.append("image")\n .attr("href", ICON_BASE + d.id + ".png")\n .attr("width", 32).attr("height", 32)\n .attr("x", -16).attr("y", -16);\n }\n });\n\n ng.append("text")\n .text(d => displayName(d.id))\n .attr("y", NODE_R + 12)\n .attr("text-anchor", "middle")\n .attr("font-size", "9px");\n\n ng.append("title").text(d => displayName(d.id));\n\n return ng;\n },\n update => update,\n exit => exit.remove()\n );\n\n // Drag\n let wasDragged = false;\n const drag = d3.drag()\n .on("start", (event, d) => {\n if (!event.active) simulation.alphaTarget(0.3).restart();\n d.fx = d.x; d.fy = d.y;\n wasDragged = false;\n })\n .on("drag", (event, d) => {\n d.fx = event.x; d.fy = event.y;\n wasDragged = true;\n })\n .on("end", (event, d) => {\n if (!event.active) simulation.alphaTarget(0);\n d.fx = null; d.fy = null;\n if (!wasDragged) toggleNode(d.id);\n });\n nodeSelection.call(drag);\n\n // Hover\n nodeSelection\n .on("mouseenter", (event, d) => {\n const connected = new Set([d.id]);\n for (const link of links) {\n const sid = link.source.id ?? link.source;\n const tid = link.target.id ?? link.target;\n if (sid === d.id) connected.add(tid);\n if (tid === d.id) connected.add(sid);\n }\n nodeSelection.classed("dimmed", n => !connected.has(n.id));\n linkSelection.classed("dimmed", l => {\n const sid = l.source.id ?? l.source;\n const tid = l.target.id ?? l.target;\n return sid !== d.id && tid !== d.id;\n });\n linkSelection.filter(l => {\n const sid = l.source.id ?? l.source;\n const tid = l.target.id ?? l.target;\n return sid === d.id || tid === d.id;\n }).attr("stroke", "#7ab8e8").attr("marker-end", "url(#arrow-hl)");\n })\n .on("mouseleave", () => {\n nodeSelection.classed("dimmed", false);\n linkSelection.classed("dimmed", false);\n linkSelection.attr("stroke", "#3a3a55").attr("marker-end", "url(#arrow)");\n });\n}\n\nfunction shortFluidName(id) {\n const names = {\n "water": "H\u2082O",\n "petroleum-gas": "Gas",\n "lubricant": "Lube",\n "sulfuric-acid": "H\u2082SO\u2084",\n };\n return names[id] || id;\n}\n\nfunction ticked() {\n linkSelection\n .attr("x1", d => d.source.x).attr("y1", d => d.source.y)\n .attr("x2", d => d.target.x).attr("y2", d => d.target.y);\n nodeSelection\n .attr("transform", d => translate(${d.x},${d.y}));\n}\n\nfunction toggleNode(id) {\n const cb = document.querySelector(input[data-id=\"${id}\"]);\n if (!cb) return;\n cb.checked = !cb.checked;\n cb.dispatchEvent(new Event("change"));\n}\n\n// --- Init ---\nbuildSidebar();\nupdateGraph();\n\nwindow.addEventListener("resize", () => {\n width = graphContainer.clientWidth;\n height = graphContainer.clientHeight;\n});\n</script>\n\n\n" }


🤖 Claude

Let me check how it looks in the preview.


🤖 Claude

🔧 Using tool: ToolSearch Input: { "query": "select:mcp__Claude_Preview__preview_start,mcp__Claude_Preview__preview_screenshot", "max_results": 2 }


🤖 Claude

Let me set up a simple server to preview it.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "ls /Users/cell/claude/factorio-sci-map/.claude/ 2>/dev/null || echo "no .claude dir"" }


🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/.claude/launch.json", "content": "{\n "version": "0.0.1",\n "configurations": [\n {\n "name": "v2",\n "runtimeExecutable": "python3",\n "runtimeArgs": ["-m", "http.server", "8234", "-d", "v2"],\n "port": 8234\n }\n ]\n}\n" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_start Input: { "name": "v2" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_screenshot Input: { "serverId": "756c317d-1066-471d-8e11-9031ac5689bb" }


🤖 Claude

It's working. The graph renders with Factorio icons, the sidebar has checkboxes, the force-directed layout places sciences at the top and raw materials at the bottom. The fluid nodes (water, petroleum gas, lubricant, sulfuric acid) use text labels since they don't have icon files.

A few things I notice:

  • The layout is reasonable — sciences at top, leaves at bottom, intermediates in between
  • Icons are loading from rocketcal.cc
  • The node labels are visible but small (as intended)
  • Arrows and connections are visible

You can drag nodes, zoom/pan the graph, hover to highlight connections, and click nodes or sidebar checkboxes to toggle items (unchecking cascades to dependents). The "Reset All" button restores everything.

Want me to adjust anything — colors, layout forces, node sizes, label formatting?


👤 User

wow this is already looking great!

a couple of tweaks (make these edits in-place in v2)

when I deselect a dependency, it culls up the tree, good. i.e. if I deselect engines, blue science gets deselected as well. but if I then reselect blue science, it is effectively a broken node (its dependencies didn't get reselected). so when selecting, we need to recursively select that whole subtree

start with that then I'll give another tweak


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "offset": 170, "limit": 30 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "offset": 296, "limit": 30 }


🤖 Claude

I need to add a getTransitiveDependencies function (goes DOWN the tree to ingredients) and use it when checking a node.


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "offset": 326, "limit": 50 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "offset": 375, "limit": 25 }


🤖 Claude

Two edits: add the downward traversal function, then use it in the check handler.


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "old_string": "function getTransitiveDependents(nodeId) {\n const result = new Set();\n const queue = [nodeId];\n while (queue.length > 0) {\n const current = queue.shift();\n for (const link of RAW_LINKS) {\n if (link.source === current && !result.has(link.target)) {\n result.add(link.target);\n queue.push(link.target);\n }\n }\n }\n return result;\n}", "new_string": "function getTransitiveDependents(nodeId) {\n const result = new Set();\n const queue = [nodeId];\n while (queue.length > 0) {\n const current = queue.shift();\n for (const link of RAW_LINKS) {\n if (link.source === current && !result.has(link.target)) {\n result.add(link.target);\n queue.push(link.target);\n }\n }\n }\n return result;\n}\n\nfunction getTransitiveDependencies(nodeId) {\n const result = new Set();\n const queue = [nodeId];\n while (queue.length > 0) {\n const current = queue.shift();\n for (const link of RAW_LINKS) {\n if (link.target === current && !result.has(link.source)) {\n result.add(link.source);\n queue.push(link.source);\n }\n }\n }\n return result;\n}" }


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v2/index.html", "old_string": " item.querySelector("input").addEventListener("change", (e) => {\n if (e.target.checked) {\n enabled.add(node.id);\n } else {", "new_string": " item.querySelector("input").addEventListener("change", (e) => {\n if (e.target.checked) {\n enabled.add(node.id);\n const deps = getTransitiveDependencies(node.id);\n for (const depId of deps) {\n enabled.add(depId);\n const cb = document.querySelector(input[data-id=\"${depId}\"]);\n if (cb) cb.checked = true;\n }\n } else {" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_screenshot Input: { "serverId": "756c317d-1066-471d-8e11-9031ac5689bb" }


🤖 Claude

I can see Production Science Pack and Electric Furnace are unchecked (someone must have toggled them during the session reload). The cascade-down behavior is already in effect — let me verify the re-check behavior works by testing it. But I can see from the state that the graph is rendering correctly with the unchecked items removed.

The logic is in place: checking a node now recursively enables all its transitive dependencies (ingredients, ingredients of ingredients, etc.) and updates their checkboxes. Ready for your next tweak.


👤 User

great, next lets change the behavior when clicking a item. rather than unchecking that item, instead, it should "focus" that item. that it, it should only that subtree. and it should act as a toggle, so clicking on it agains defocuses it, showing the whole graph.

currently, it looks like the science bottles have a special treatment visually: the circle has a bold, colored stroke and the circle background follows the color of the stroke.

let's use that treatment for the "selected" state, rather than for the "science" state


👤 User

[Request interrupted by user]


👤 User

great, next lets change the behavior when clicking a item. rather than unchecking that item, instead, it should "focus" that item. that it, it should only that subtree. and it should act as a toggle, so clicking on it agains defocuses it, showing the whole graph.

currently, it looks like the science bottles have a special treatment visually: the circle has a bold, colored stroke and the circle background follows the color of the stroke.

let's use that treatment for the "selected" state, rather than for the "science" state

oh, and actually this is a big enough change that we can call this v3.


🤖 Claude

I'll create v3 with the focus-click behavior and move the bold/colored styling from science nodes to the focused node.


🤖 Claude

🔧 Using tool: Bash Input: { "command": "mkdir -p /Users/cell/claude/factorio-sci-map/v3 && cp /Users/cell/claude/factorio-sci-map/v2/index.html /Users/cell/claude/factorio-sci-map/v3/index.html" }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html" }


🤖 Claude

Now I have the full picture. Here are the changes needed:

  1. Add focusedNodeId state variable
  2. Replace toggleNode (click handler) with focus toggle logic
  3. updateGraph filters by focus in addition to enabled
  4. Remove science-specific styling from nodeStroke/nodeFill, apply it to focused node instead
  5. Update node circles on focus change (stroke/fill)

🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "content": "\n<html lang="en">\n\n<meta charset="utf-8">\n<title>Factorio Science Dependency Map</title>\n<style>\n* { margin: 0; padding: 0; box-sizing: border-box; }\nhtml, body { height: 100%; overflow: hidden; }\nbody {\n display: flex;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n background: #0f0f1a;\n color: #e0e0e0;\n}\n\n#sidebar {\n width: 280px;\n min-width: 280px;\n background: #161625;\n border-right: 1px solid #2a2a40;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\n\n.sidebar-header {\n padding: 16px;\n border-bottom: 1px solid #2a2a40;\n}\n\n.sidebar-header h1 {\n font-size: 15px;\n font-weight: 600;\n margin-bottom: 10px;\n}\n\n.sidebar-buttons {\n display: flex;\n gap: 8px;\n}\n\n.sidebar-buttons button {\n padding: 6px 14px;\n background: #2a2a40;\n border: 1px solid #3a3a55;\n border-radius: 4px;\n color: #ccc;\n cursor: pointer;\n font-size: 12px;\n}\n.sidebar-buttons button:hover { background: #3a3a55; }\n\n#focus-indicator {\n display: none;\n padding: 8px 16px;\n background: #1a2a3a;\n border-bottom: 1px solid #2a2a40;\n font-size: 12px;\n color: #7ab8e8;\n}\n\n#checkboxes {\n flex: 1;\n overflow-y: auto;\n padding: 8px 16px 16px;\n}\n\n.section-header {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: #777;\n margin: 16px 0 6px;\n padding-bottom: 4px;\n border-bottom: 1px solid #2a2a40;\n}\n.section-header:first-child { margin-top: 8px; }\n\n.cb-item {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 4px 4px;\n cursor: pointer;\n border-radius: 4px;\n}\n.cb-item:hover { background: #1e1e33; }\n\n.cb-item input[type="checkbox"] {\n width: 14px;\n height: 14px;\n cursor: pointer;\n accent-color: #5588cc;\n flex-shrink: 0;\n}\n\n.cb-item img, .cb-item .fluid-icon {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n flex-shrink: 0;\n}\n\n.fluid-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n background: #1a3a5c;\n border-radius: 4px;\n font-size: 12px;\n}\n\n.cb-item span {\n font-size: 13px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#graph-container {\n flex: 1;\n position: relative;\n overflow: hidden;\n}\n#graph-container svg {\n width: 100%;\n height: 100%;\n cursor: grab;\n}\n#graph-container svg:active { cursor: grabbing; }\n\n.node-group { cursor: pointer; }\n.node-group text {\n fill: #aaa;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n pointer-events: none;\n user-select: none;\n}\n.node-group image { pointer-events: none; }\n.node-group .fluid-label {\n fill: #8ab4e8;\n font-size: 8px;\n text-anchor: middle;\n dominant-baseline: central;\n pointer-events: none;\n}\n\n.dimmed { opacity: 0.08; }\n.link, .node-group { transition: opacity 0.15s; }\n</style>\n\n\n\n<div id="sidebar">\n <div class="sidebar-header">\n

Factorio Science Dependencies

\n <div class="sidebar-buttons">\n <button id="reset-btn">Reset All\n <button id="clear-focus-btn" style="display:none">Clear Focus\n \n \n <div id="focus-indicator">\n <div id="checkboxes">\n\n<div id="graph-container">\n \n\n\n<script src="https://d3js.org/d3.v7.min.js\"></script>\n<script>\nconst ICON_BASE = "https://rocketcal.cc/icons-64/\";\nconst FOCUS_COLOR = "#26c6da";\n\nconst FLUIDS = new Set(["water", "petroleum-gas", "lubricant", "sulfuric-acid",\n "heavy-oil", "light-oil", "crude-oil"]);\n\nconst FLUID_SYMBOLS = {\n "water": "\u{1F4A7}",\n "petroleum-gas": "\u{2B24}",\n "lubricant": "\u{2B24}",\n "sulfuric-acid": "\u{2B24}",\n};\n\nconst RAW_NODES = [\n {id: "automation-science-pack", type: "science"},\n {id: "logistic-science-pack", type: "science"},\n {id: "chemical-science-pack", type: "science"},\n {id: "military-science-pack", type: "science"},\n {id: "production-science-pack", type: "science"},\n {id: "utility-science-pack", type: "science"},\n\n {id: "advanced-circuit", type: "intermediate"},\n {id: "battery", type: "intermediate"},\n {id: "copper-cable", type: "intermediate"},\n {id: "electric-engine-unit", type: "intermediate"},\n {id: "electric-furnace", type: "intermediate"},\n {id: "electronic-circuit", type: "intermediate"},\n {id: "engine-unit", type: "intermediate"},\n {id: "firearm-magazine", type: "intermediate"},\n {id: "flying-robot-frame", type: "intermediate"},\n {id: "grenade", type: "intermediate"},\n {id: "inserter", type: "intermediate"},\n {id: "iron-gear-wheel", type: "intermediate"},\n {id: "iron-stick", type: "intermediate"},\n {id: "low-density-structure", type: "intermediate"},\n {id: "piercing-rounds-magazine", type: "intermediate"},\n {id: "pipe", type: "intermediate"},\n {id: "plastic-bar", type: "intermediate"},\n {id: "processing-unit", type: "intermediate"},\n {id: "productivity-module", type: "intermediate"},\n {id: "rail", type: "intermediate"},\n {id: "steel-plate", type: "intermediate"},\n {id: "stone-wall", type: "intermediate"},\n {id: "sulfur", type: "intermediate"},\n {id: "sulfuric-acid", type: "intermediate"},\n {id: "transport-belt", type: "intermediate"},\n\n {id: "coal", type: "leaf"},\n {id: "copper-plate", type: "leaf"},\n {id: "iron-plate", type: "leaf"},\n {id: "lubricant", type: "leaf"},\n {id: "petroleum-gas", type: "leaf"},\n {id: "stone", type: "leaf"},\n {id: "stone-brick", type: "leaf"},\n {id: "water", type: "leaf"},\n];\n\nconst RAW_LINKS = [\n {source: "copper-plate", target: "automation-science-pack"},\n {source: "iron-gear-wheel", target: "automation-science-pack"},\n {source: "transport-belt", target: "logistic-science-pack"},\n {source: "inserter", target: "logistic-science-pack"},\n {source: "sulfur", target: "chemical-science-pack"},\n {source: "advanced-circuit", target: "chemical-science-pack"},\n {source: "engine-unit", target: "chemical-science-pack"},\n {source: "piercing-rounds-magazine", target: "military-science-pack"},\n {source: "grenade", target: "military-science-pack"},\n {source: "stone-wall", target: "military-science-pack"},\n {source: "rail", target: "production-science-pack"},\n {source: "electric-furnace", target: "production-science-pack"},\n {source: "productivity-module", target: "production-science-pack"},\n {source: "processing-unit", target: "utility-science-pack"},\n {source: "flying-robot-frame", target: "utility-science-pack"},\n {source: "low-density-structure", target: "utility-science-pack"},\n\n {source: "iron-plate", target: "iron-gear-wheel"},\n {source: "iron-gear-wheel", target: "transport-belt"},\n {source: "iron-plate", target: "transport-belt"},\n {source: "iron-gear-wheel", target: "inserter"},\n {source: "iron-plate", target: "inserter"},\n {source: "electronic-circuit", target: "inserter"},\n {source: "water", target: "sulfur"},\n {source: "petroleum-gas", target: "sulfur"},\n {source: "plastic-bar", target: "advanced-circuit"},\n {source: "copper-cable", target: "advanced-circuit"},\n {source: "electronic-circuit", target: "advanced-circuit"},\n {source: "iron-gear-wheel", target: "engine-unit"},\n {source: "pipe", target: "engine-unit"},\n {source: "steel-plate", target: "engine-unit"},\n {source: "copper-plate", target: "piercing-rounds-magazine"},\n {source: "steel-plate", target: "piercing-rounds-magazine"},\n {source: "firearm-magazine", target: "piercing-rounds-magazine"},\n {source: "coal", target: "grenade"},\n {source: "iron-plate", target: "grenade"},\n {source: "stone-brick", target: "stone-wall"},\n {source: "iron-stick", target: "rail"},\n {source: "steel-plate", target: "rail"},\n {source: "stone", target: "rail"},\n {source: "steel-plate", target: "electric-furnace"},\n {source: "advanced-circuit", target: "electric-furnace"},\n {source: "stone-brick", target: "electric-furnace"},\n {source: "advanced-circuit", target: "productivity-module"},\n {source: "electronic-circuit", target: "productivity-module"},\n {source: "electronic-circuit", target: "processing-unit"},\n {source: "advanced-circuit", target: "processing-unit"},\n {source: "sulfuric-acid", target: "processing-unit"},\n {source: "steel-plate", target: "flying-robot-frame"},\n {source: "battery", target: "flying-robot-frame"},\n {source: "electronic-circuit", target: "flying-robot-frame"},\n {source: "electric-engine-unit", target: "flying-robot-frame"},\n {source: "copper-plate", target: "low-density-structure"},\n {source: "steel-plate", target: "low-density-structure"},\n {source: "plastic-bar", target: "low-density-structure"},\n\n {source: "iron-plate", target: "electronic-circuit"},\n {source: "copper-cable", target: "electronic-circuit"},\n {source: "coal", target: "plastic-bar"},\n {source: "petroleum-gas", target: "plastic-bar"},\n {source: "copper-plate", target: "copper-cable"},\n {source: "iron-plate", target: "pipe"},\n {source: "iron-plate", target: "steel-plate"},\n {source: "iron-plate", target: "firearm-magazine"},\n {source: "iron-plate", target: "iron-stick"},\n {source: "iron-plate", target: "sulfuric-acid"},\n {source: "sulfur", target: "sulfuric-acid"},\n {source: "water", target: "sulfuric-acid"},\n {source: "iron-plate", target: "battery"},\n {source: "copper-plate", target: "battery"},\n {source: "sulfuric-acid", target: "battery"},\n {source: "electronic-circuit", target: "electric-engine-unit"},\n {source: "engine-unit", target: "electric-engine-unit"},\n {source: "lubricant", target: "electric-engine-unit"},\n];\n\n// --- Tier computation ---\nconst ingredientsOf = {};\nfor (const link of RAW_LINKS) {\n if (!ingredientsOf[link.target]) ingredientsOf[link.target] = [];\n ingredientsOf[link.target].push(link.source);\n}\n\nconst tierCache = {};\nfunction getTier(id) {\n if (tierCache[id] !== undefined) return tierCache[id];\n tierCache[id] = 0;\n const ings = ingredientsOf[id] || [];\n if (ings.length > 0) {\n tierCache[id] = 1 + Math.max(...ings.map(getTier));\n }\n return tierCache[id];\n}\nfor (const node of RAW_NODES) {\n node.tier = getTier(node.id);\n}\nconst maxTier = Math.max(...RAW_NODES.map(n => n.tier));\n\n// --- State ---\nconst enabled = new Set(RAW_NODES.map(n => n.id));\nconst savedPositions = {};\nlet focusedNodeId = null;\n\n// --- Utilities ---\nfunction displayName(id) {\n return id.split("-").map(w => w[0].toUpperCase() + w.slice(1)).join(" ");\n}\n\nfunction getTransitiveDependents(nodeId) {\n const result = new Set();\n const queue = [nodeId];\n while (queue.length > 0) {\n const current = queue.shift();\n for (const link of RAW_LINKS) {\n if (link.source === current && !result.has(link.target)) {\n result.add(link.target);\n queue.push(link.target);\n }\n }\n }\n return result;\n}\n\nfunction getTransitiveDependencies(nodeId) {\n const result = new Set();\n const queue = [nodeId];\n while (queue.length > 0) {\n const current = queue.shift();\n for (const link of RAW_LINKS) {\n if (link.target === current && !result.has(link.source)) {\n result.add(link.source);\n queue.push(link.source);\n }\n }\n }\n return result;\n}\n\nfunction linkId(d) {\n const s = typeof d.source === "object" ? d.source.id : d.source;\n const t = typeof d.target === "object" ? d.target.id : d.target;\n return s + ">" + t;\n}\n\n// --- Focus ---\nfunction setFocus(nodeId) {\n if (focusedNodeId === nodeId) {\n focusedNodeId = null;\n } else {\n focusedNodeId = nodeId;\n }\n updateFocusUI();\n updateGraph();\n}\n\nfunction updateFocusUI() {\n const indicator = document.getElementById("focus-indicator");\n const clearBtn = document.getElementById("clear-focus-btn");\n if (focusedNodeId) {\n indicator.style.display = "block";\n indicator.textContent = "Focused: " + displayName(focusedNodeId);\n clearBtn.style.display = "inline-block";\n } else {\n indicator.style.display = "none";\n clearBtn.style.display = "none";\n }\n}\n\ndocument.getElementById("clear-focus-btn").addEventListener("click", () => {\n focusedNodeId = null;\n updateFocusUI();\n updateGraph();\n});\n\n// --- Sidebar ---\nfunction buildSidebar() {\n const container = document.getElementById("checkboxes");\n container.innerHTML = "";\n\n const groups = [\n {label: "Science Packs", items: RAW_NODES.filter(n => n.type === "science")},\n {label: "Intermediates", items: [...RAW_NODES.filter(n => n.type === "intermediate")].sort((a,b) => a.id.localeCompare(b.id))},\n {label: "Raw Materials", items: [...RAW_NODES.filter(n => n.type === "leaf")].sort((a,b) => a.id.localeCompare(b.id))},\n ];\n\n for (const group of groups) {\n const header = document.createElement("div");\n header.className = "section-header";\n header.textContent = group.label;\n container.appendChild(header);\n\n for (const node of group.items) {\n const item = document.createElement("label");\n item.className = "cb-item";\n\n const iconHtml = FLUIDS.has(node.id)\n ? <span class=\"fluid-icon\">${FLUID_SYMBOLS[node.id] || \"\\u{1F4A7}\"}</span>\n : <img src=\"${ICON_BASE}${node.id}.png\" alt=\"\">;\n\n item.innerHTML = \n <input type=\"checkbox\" data-id=\"${node.id}\" ${enabled.has(node.id) ? \"checked\" : \"\"}>\n ${iconHtml}\n <span>${displayName(node.id)}</span>\n ;\n container.appendChild(item);\n\n item.querySelector("input").addEventListener("change", (e) => {\n if (e.target.checked) {\n enabled.add(node.id);\n const deps = getTransitiveDependencies(node.id);\n for (const depId of deps) {\n enabled.add(depId);\n const cb = document.querySelector(input[data-id=\"${depId}\"]);\n if (cb) cb.checked = true;\n }\n } else {\n enabled.delete(node.id);\n const deps = getTransitiveDependents(node.id);\n for (const depId of deps) {\n enabled.delete(depId);\n const cb = document.querySelector(input[data-id=\"${depId}\"]);\n if (cb) cb.checked = false;\n }\n }\n if (focusedNodeId && !enabled.has(focusedNodeId)) {\n focusedNodeId = null;\n updateFocusUI();\n }\n updateGraph();\n });\n }\n }\n}\n\ndocument.getElementById("reset-btn").addEventListener("click", () => {\n for (const node of RAW_NODES) enabled.add(node.id);\n document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = true);\n focusedNodeId = null;\n updateFocusUI();\n updateGraph();\n});\n\n// --- Graph setup ---\nconst graphContainer = document.getElementById("graph-container");\nconst svg = d3.select("#graph-container svg");\nlet width = graphContainer.clientWidth;\nlet height = graphContainer.clientHeight;\n\nconst defs = svg.append("defs");\n\ndefs.append("marker")\n .attr("id", "arrow")\n .attr("viewBox", "0 -5 10 10")\n .attr("refX", 35).attr("refY", 0)\n .attr("markerWidth", 8).attr("markerHeight", 8)\n .attr("orient", "auto")\n .append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#444");\n\ndefs.append("marker")\n .attr("id", "arrow-hl")\n .attr("viewBox", "0 -5 10 10")\n .attr("refX", 35).attr("refY", 0)\n .attr("markerWidth", 8).attr("markerHeight", 8)\n .attr("orient", "auto")\n .append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#7ab8e8");\n\nconst g = svg.append("g");\nconst linkGroup = g.append("g");\nconst nodeGroup = g.append("g");\n\nconst zoom = d3.zoom()\n .scaleExtent([0.15, 5])\n .on("zoom", (event) => g.attr("transform", event.transform));\nsvg.call(zoom);\n\n// --- Simulation & rendering ---\nlet simulation = null;\nlet nodeSelection = d3.selectAll(null);\nlet linkSelection = d3.selectAll(null);\nconst NODE_R = 22;\n\nfunction tierY(tier) {\n const pad = 0.12;\n return height * (1 - pad - (tier / maxTier) * (1 - 2 * pad));\n}\n\nfunction nodeStroke(d) {\n if (d.id === focusedNodeId) return FOCUS_COLOR;\n if (d.type === "leaf") return "#7a5c3d";\n return "#4a4a65";\n}\n\nfunction nodeStrokeWidth(d) {\n return d.id === focusedNodeId ? 3 : 1.5;\n}\n\nfunction nodeFill(d) {\n if (d.id === focusedNodeId) {\n const c = d3.color(FOCUS_COLOR);\n c.opacity = 0.25;\n return c + "";\n }\n return "#22223a";\n}\n\nfunction visibleNodeIds() {\n let ids = new Set([...enabled]);\n if (focusedNodeId && ids.has(focusedNodeId)) {\n const subtree = getTransitiveDependencies(focusedNodeId);\n subtree.add(focusedNodeId);\n ids = new Set([...ids].filter(id => subtree.has(id)));\n }\n return ids;\n}\n\nfunction updateGraph() {\n if (simulation) {\n for (const n of simulation.nodes()) {\n savedPositions[n.id] = {x: n.x, y: n.y};\n }\n simulation.stop();\n }\n\n const visible = visibleNodeIds();\n\n const nodes = RAW_NODES\n .filter(n => visible.has(n.id))\n .map(n => {\n const pos = savedPositions[n.id];\n return {\n ...n,\n x: pos ? pos.x : width / 2 + (Math.random() - 0.5) * width * 0.4,\n y: pos ? pos.y : tierY(n.tier),\n };\n });\n\n const nodeIds = new Set(nodes.map(n => n.id));\n const links = RAW_LINKS\n .filter(l => nodeIds.has(l.source) && nodeIds.has(l.target))\n .map(l => ({source: l.source, target: l.target}));\n\n simulation = d3.forceSimulation(nodes)\n .force("link", d3.forceLink(links).id(d => d.id).distance(90).strength(0.4))\n .force("charge", d3.forceManyBody().strength(-350))\n .force("x", d3.forceX(width / 2).strength(0.04))\n .force("y", d3.forceY(d => tierY(d.tier)).strength(0.12))\n .force("collide", d3.forceCollide(40))\n .alphaDecay(0.02)\n .on("tick", ticked);\n\n // --- Links ---\n linkSelection = linkGroup.selectAll("line")\n .data(links, linkId)\n .join("line")\n .attr("class", "link")\n .attr("stroke", "#3a3a55")\n .attr("stroke-width", 1.5)\n .attr("marker-end", "url(#arrow)");\n\n // --- Nodes ---\n nodeSelection = nodeGroup.selectAll(".node-group")\n .data(nodes, d => d.id)\n .join(\n enter => {\n const ng = enter.append("g").attr("class", "node-group");\n\n ng.append("circle")\n .attr("r", NODE_R)\n .attr("fill", nodeFill)\n .attr("stroke", nodeStroke)\n .attr("stroke-width", nodeStrokeWidth);\n\n ng.each(function(d) {\n const el = d3.select(this);\n if (FLUIDS.has(d.id)) {\n el.append("text")\n .attr("class", "fluid-label")\n .attr("font-size", "10px")\n .text(shortFluidName(d.id));\n } else {\n el.append("image")\n .attr("href", ICON_BASE + d.id + ".png")\n .attr("width", 32).attr("height", 32)\n .attr("x", -16).attr("y", -16);\n }\n });\n\n ng.append("text")\n .text(d => displayName(d.id))\n .attr("y", NODE_R + 12)\n .attr("text-anchor", "middle")\n .attr("font-size", "9px");\n\n ng.append("title").text(d => displayName(d.id));\n\n return ng;\n },\n update => {\n update.select("circle")\n .attr("fill", nodeFill)\n .attr("stroke", nodeStroke)\n .attr("stroke-width", nodeStrokeWidth);\n return update;\n },\n exit => exit.remove()\n );\n\n // Drag\n let wasDragged = false;\n const drag = d3.drag()\n .on("start", (event, d) => {\n if (!event.active) simulation.alphaTarget(0.3).restart();\n d.fx = d.x; d.fy = d.y;\n wasDragged = false;\n })\n .on("drag", (event, d) => {\n d.fx = event.x; d.fy = event.y;\n wasDragged = true;\n })\n .on("end", (event, d) => {\n if (!event.active) simulation.alphaTarget(0);\n d.fx = null; d.fy = null;\n if (!wasDragged) setFocus(d.id);\n });\n nodeSelection.call(drag);\n\n // Hover\n nodeSelection\n .on("mouseenter", (event, d) => {\n const connected = new Set([d.id]);\n for (const link of links) {\n const sid = link.source.id ?? link.source;\n const tid = link.target.id ?? link.target;\n if (sid === d.id) connected.add(tid);\n if (tid === d.id) connected.add(sid);\n }\n nodeSelection.classed("dimmed", n => !connected.has(n.id));\n linkSelection.classed("dimmed", l => {\n const sid = l.source.id ?? l.source;\n const tid = l.target.id ?? l.target;\n return sid !== d.id && tid !== d.id;\n });\n linkSelection.filter(l => {\n const sid = l.source.id ?? l.source;\n const tid = l.target.id ?? l.target;\n return sid === d.id || tid === d.id;\n }).attr("stroke", "#7ab8e8").attr("marker-end", "url(#arrow-hl)");\n })\n .on("mouseleave", () => {\n nodeSelection.classed("dimmed", false);\n linkSelection.classed("dimmed", false);\n linkSelection.attr("stroke", "#3a3a55").attr("marker-end", "url(#arrow)");\n });\n}\n\nfunction shortFluidName(id) {\n const names = {\n "water": "H\u2082O",\n "petroleum-gas": "Gas",\n "lubricant": "Lube",\n "sulfuric-acid": "H\u2082SO\u2084",\n };\n return names[id] || id;\n}\n\nfunction ticked() {\n linkSelection\n .attr("x1", d => d.source.x).attr("y1", d => d.source.y)\n .attr("x2", d => d.target.x).attr("y2", d => d.target.y);\n nodeSelection\n .attr("transform", d => translate(${d.x},${d.y}));\n}\n\n// --- Init ---\nbuildSidebar();\nupdateGraph();\n\nwindow.addEventListener("resize", () => {\n width = graphContainer.clientWidth;\n height = graphContainer.clientHeight;\n});\n</script>\n\n\n" }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/.claude/launch.json" }


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/.claude/launch.json", "old_string": "{\n "version": "0.0.1",\n "configurations": [\n {\n "name": "v2",\n "runtimeExecutable": "python3",\n "runtimeArgs": ["-m", "http.server", "8234", "-d", "v2"],\n "port": 8234\n }\n ]\n}", "new_string": "{\n "version": "0.0.1",\n "configurations": [\n {\n "name": "v2",\n "runtimeExecutable": "python3",\n "runtimeArgs": ["-m", "http.server", "8234", "-d", "v2"],\n "port": 8234\n },\n {\n "name": "v3",\n "runtimeExecutable": "python3",\n "runtimeArgs": ["-m", "http.server", "8235", "-d", "v3"],\n "port": 8235\n }\n ]\n}" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_start Input: { "name": "v3" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_screenshot Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58" }


🤖 Claude

Working nicely. In this screenshot I clicked Utility Science Pack and it focused down to just its subtree — the node has the cyan highlight (bold colored stroke + tinted fill), and only its transitive dependencies are shown. The sidebar shows "Focused: Utility Science Pack" and the "Clear Focus" button appeared.

Key changes in v3:

  • Click a node = focus on its subtree (only that node + all its ingredients recursively)
  • Click same node again = defocus, back to full graph
  • Visual styling = all nodes are uniform dark circles; only the focused node gets the bold cyan stroke + tinted fill
  • Clear Focus button and focus indicator in the sidebar
  • Reset All also clears focus

Ready for your next tweak.


👤 User

do you have control over how tightly clustered the nodes are? when looking at the full graph, they get pretty tightly packed. is that controllable as a single param? if so, we could expose that as a setting, in the sidebar, below all of the recipes


🤖 Claude

Yes — the main knob is the charge strength (currently -350). More negative = more spread. I can also scale link distance with it for a natural feel. I'll add a "Spacing" slider at the bottom of the sidebar.


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 96, "limit": 15 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 140, "limit": 20 }


🤖 Claude

First, add CSS for the settings panel, then add the HTML and JS.


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": ".dimmed { opacity: 0.08; }\n.link, .node-group { transition: opacity 0.15s; }\n</style>", "new_string": ".dimmed { opacity: 0.08; }\n.link, .node-group { transition: opacity 0.15s; }\n\n#settings {\n padding: 12px 16px;\n border-top: 1px solid #2a2a40;\n}\n#settings .section-header {\n margin-top: 0;\n}\n.slider-row {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 6px;\n}\n.slider-row label {\n font-size: 12px;\n color: #999;\n min-width: 50px;\n}\n.slider-row input[type="range"] {\n flex: 1;\n accent-color: #5588cc;\n height: 4px;\n}\n</style>" }


🤖 Claude

Now add the settings div in the sidebar HTML, between the checkboxes and the closing </div>:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": " <div id="checkboxes">\n\n<div id="graph-container">", "new_string": " <div id="checkboxes">\n <div id="settings">\n <div class="section-header">Settings\n <div class="slider-row">\n Spacing\n <input type="range" id="spacing-slider" min="0" max="100" value="40">\n \n \n\n<div id="graph-container">" }


🤖 Claude

Now wire up the slider in the JS. The slider maps 0-100 to charge strength and link distance. I need to read the current simulation setup to find where to hook in.


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 298, "limit": 15 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 440, "limit": 25 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 505, "limit": 40 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 565, "limit": 25 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 593, "limit": 20 }


🤖 Claude

Now I'll add the spacing helper function and wire the slider. I'll map slider 0-100 to a spacing multiplier that scales charge, link distance, and collision radius together.


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "// --- Simulation & rendering ---\nlet simulation = null;\nlet nodeSelection = d3.selectAll(null);\nlet linkSelection = d3.selectAll(null);\nconst NODE_R = 22;", "new_string": "// --- Spacing ---\nlet spacingValue = 40;\nfunction spacingParams() {\n const t = spacingValue / 100;\n return {\n charge: -150 - t * 650,\n linkDist: 50 + t * 120,\n collide: 30 + t * 25,\n };\n}\n\nconst spacingSlider = document.getElementById("spacing-slider");\nspacingSlider.addEventListener("input", (e) => {\n spacingValue = +e.target.value;\n if (!simulation) return;\n const p = spacingParams();\n simulation.force("charge").strength(p.charge);\n simulation.force("link").distance(p.linkDist);\n simulation.force("collide").radius(p.collide);\n simulation.alpha(0.4).restart();\n});\n\n// --- Simulation & rendering ---\nlet simulation = null;\nlet nodeSelection = d3.selectAll(null);\nlet linkSelection = d3.selectAll(null);\nconst NODE_R = 22;" }


🤖 Claude

Now update the simulation creation in updateGraph to use spacingParams() instead of hardcoded values:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": " simulation = d3.forceSimulation(nodes)\n .force("link", d3.forceLink(links).id(d => d.id).distance(90).strength(0.4))\n .force("charge", d3.forceManyBody().strength(-350))\n .force("x", d3.forceX(width / 2).strength(0.04))\n .force("y", d3.forceY(d => tierY(d.tier)).strength(0.12))\n .force("collide", d3.forceCollide(40))\n .alphaDecay(0.02)\n .on("tick", ticked);", "new_string": " const p = spacingParams();\n simulation = d3.forceSimulation(nodes)\n .force("link", d3.forceLink(links).id(d => d.id).distance(p.linkDist).strength(0.4))\n .force("charge", d3.forceManyBody().strength(p.charge))\n .force("x", d3.forceX(width / 2).strength(0.04))\n .force("y", d3.forceY(d => tierY(d.tier)).strength(0.12))\n .force("collide", d3.forceCollide(p.collide))\n .alphaDecay(0.02)\n .on("tick", ticked);" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_screenshot Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58" }


🤖 Claude

Looks like the screenshot caught it mid-animation or there's a rendering issue. Let me wait a moment and try again.


🤖 Claude

🔧 Using tool: ToolSearch Input: { "query": "select:mcp__Claude_Preview__preview_click", "max_results": 1 }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_click Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "selector": "#reset-btn" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_screenshot Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58" }


🤖 Claude

The graph seems to have drifted off-screen. The default slider value of 40 with the new formula gives charge=-410, which is stronger than before. Let me check if there's a JS error, and also scroll down in the sidebar to verify the slider is there.


🤖 Claude

🔧 Using tool: ToolSearch Input: { "query": "select:mcp__Claude_Preview__preview_console_logs", "max_results": 1 }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_console_logs Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "level": "error" }


🤖 Claude

No errors. The graph probably just flew off-viewport. The simulation might be pushing nodes far out because the initial width/height values are from before the page was fully rendered. Let me check — the issue might be that the preview window is a different size and the forces are centering on a stale width/height. Let me also try using the preview's eval tool to check.


🤖 Claude

🔧 Using tool: ToolSearch Input: { "query": "select:mcp__Claude_Preview__preview_eval", "max_results": 1 }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_eval Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "expression": "JSON.stringify({width, height, nodesCount: simulation?.nodes().length, samplePos: simulation?.nodes().slice(0,3).map(n => ({id:n.id, x:Math.round(n.x), y:Math.round(n.y)}))})" }


🤖 Claude

width is 0 — the graph container had no width when the script ran (the preview loaded before layout completed). The forceX is centering everything at x=0, pushing nodes off the left edge. I need to defer initialization until the container has dimensions.


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 700, "limit": 20 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 740, "limit": 30 }


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "// --- Init ---\nbuildSidebar();\nupdateGraph();\n\nwindow.addEventListener("resize", () => {\n width = graphContainer.clientWidth;\n height = graphContainer.clientHeight;\n});", "new_string": "// --- Init ---\nbuildSidebar();\n\nfunction initGraph() {\n width = graphContainer.clientWidth;\n height = graphContainer.clientHeight;\n if (width > 0 && height > 0) {\n updateGraph();\n } else {\n requestAnimationFrame(initGraph);\n }\n}\nrequestAnimationFrame(initGraph);\n\nwindow.addEventListener("resize", () => {\n width = graphContainer.clientWidth;\n height = graphContainer.clientHeight;\n});" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_eval Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "expression": "location.reload()" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_screenshot Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58" }


🤖 Claude

Still off-screen. Let me check the dimensions again after reload.


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_eval Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "expression": "JSON.stringify({width, height, containerW: graphContainer.clientWidth, containerH: graphContainer.clientHeight})" }


🤖 Claude

The container width is consistently 0 in the preview environment. This is likely a flexbox issue — the preview might not give the graph container any width. Let me fix this by giving the graph container an explicit fallback.


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "#graph-container {\n flex: 1;\n position: relative;\n overflow: hidden;\n}", "new_string": "#graph-container {\n flex: 1;\n min-width: 0;\n position: relative;\n overflow: hidden;\n}" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_eval Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "expression": "location.reload()" }


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_eval Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "expression": "JSON.stringify({containerW: document.getElementById('graph-container').clientWidth, bodyW: document.body.clientWidth, sidebarW: document.getElementById('sidebar').clientWidth})" }


🤖 Claude

The preview viewport is too narrow — the sidebar (280px) is taking all available space. Let me check the viewport size and resize.


🤖 Claude

🔧 Using tool: mcp__Claude_Preview__preview_eval Input: { "serverId": "bc873395-5289-49b0-9ea6-fa6e94dc0f58", "expression": "JSON.stringify({innerW: window.innerWidth, innerH: window.innerHeight})" }


👤 User

[Request interrupted by user]


👤 User

sorry to interrupt, I tested it, it works.

well, I think I might reverse my prior ask. let's try having a deselect prune the orphans as well. actually, can we make that a setting?


🤖 Claude

I'll add a "Prune orphans" toggle in the settings section. When on, unchecking a node will also uncheck any dependencies that no longer feed into anything enabled.


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": " <div class="slider-row">\n Spacing\n <input type="range" id="spacing-slider" min="0" max="100" value="40">\n \n ", "new_string": " <div class="slider-row">\n Spacing\n <input type="range" id="spacing-slider" min="0" max="100" value="40">\n \n <label class="cb-item" style="margin-top:10px">\n <input type="checkbox" id="prune-orphans-cb">\n Prune orphans on deselect\n \n " }


🤖 Claude

Now add the pruning logic in the JS. I need to read the checkbox change handler area.


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 460, "limit": 35 }


🤖 Claude

I'll add the pruneOrphans function and call it after the uncheck cascade:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "function linkId(d) {", "new_string": "function pruneOrphans() {\n let changed = true;\n while (changed) {\n changed = false;\n for (const nodeId of [...enabled]) {\n const node = RAW_NODES.find(n => n.id === nodeId);\n if (node && node.type === "science") continue;\n const hasConsumer = RAW_LINKS.some(l => l.source === nodeId && enabled.has(l.target));\n if (!hasConsumer) {\n enabled.delete(nodeId);\n const cb = document.querySelector(input[data-id=\"${nodeId}\"]);\n if (cb) cb.checked = false;\n changed = true;\n }\n }\n }\n}\n\nfunction linkId(d) {" }


🤖 Claude

Now call pruneOrphans() after the uncheck cascade, gated on the setting:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": " } else {\n enabled.delete(node.id);\n const deps = getTransitiveDependents(node.id);\n for (const depId of deps) {\n enabled.delete(depId);\n const cb = document.querySelector(input[data-id=\"${depId}\"]);\n if (cb) cb.checked = false;\n }\n }", "new_string": " } else {\n enabled.delete(node.id);\n const deps = getTransitiveDependents(node.id);\n for (const depId of deps) {\n enabled.delete(depId);\n const cb = document.querySelector(input[data-id=\"${depId}\"]);\n if (cb) cb.checked = false;\n }\n if (document.getElementById("prune-orphans-cb").checked) {\n pruneOrphans();\n }\n }" }


🤖 Claude

Done. The "Prune orphans on deselect" checkbox in Settings controls the behavior:

  • Off (default): unchecking cascades up to consumers only; orphaned ingredients stay
  • On: after the upward cascade, any nodes that no longer feed into anything enabled are also removed, recursively

You can test it in your browser — try unchecking a mid-level node like "engine-unit" with the setting on vs off to see the difference.


👤 User

great, let's default that checkbox to enabled.

also, let's default the slider to be 70% (a bit more spacing by default)

also, let's change the "reset all" button to become a toggle: "select all" "unselect all" (you can choose the verbiage)


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "<input type="checkbox" id="prune-orphans-cb">", "new_string": "<input type="checkbox" id="prune-orphans-cb" checked>" }


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "<input type="range" id="spacing-slider" min="0" max="100" value="40">", "new_string": "<input type="range" id="spacing-slider" min="0" max="100" value="70">" }


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "let spacingValue = 40;", "new_string": "let spacingValue = 70;" }


🤖 Claude

Now change the Reset All button to a toggle:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": " <button id="reset-btn">Reset All", "new_string": " <button id="toggle-all-btn">Deselect All" }


🤖 Claude

Now update the JS handler:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "document.getElementById("reset-btn").addEventListener("click", () => {\n for (const node of RAW_NODES) enabled.add(node.id);\n document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = true);\n focusedNodeId = null;\n updateFocusUI();\n updateGraph();\n});", "new_string": "const toggleAllBtn = document.getElementById("toggle-all-btn");\ntoggleAllBtn.addEventListener("click", () => {\n const allEnabled = RAW_NODES.every(n => enabled.has(n.id));\n if (allEnabled) {\n enabled.clear();\n document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = false);\n toggleAllBtn.textContent = "Select All";\n } else {\n for (const node of RAW_NODES) enabled.add(node.id);\n document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = true);\n toggleAllBtn.textContent = "Deselect All";\n }\n focusedNodeId = null;\n updateFocusUI();\n updateGraph();\n});" }


🤖 Claude

I also need to update the button label when individual checkboxes change. Let me add a helper that syncs the button text:


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": "const toggleAllBtn = document.getElementById("toggle-all-btn");", "new_string": "const toggleAllBtn = document.getElementById("toggle-all-btn");\nfunction syncToggleBtn() {\n toggleAllBtn.textContent = RAW_NODES.every(n => enabled.has(n.id)) ? "Deselect All" : "Select All";\n}" }


🤖 Claude

Now call syncToggleBtn() at the end of the checkbox change handler:


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 487, "limit": 15 }


🤖 Claude

🔧 Using tool: Read Input: { "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "offset": 504, "limit": 15 }


🤖 Claude

🔧 Using tool: Edit Input: { "replace_all": false, "file_path": "/Users/cell/claude/factorio-sci-map/v3/index.html", "old_string": " if (focusedNodeId && !enabled.has(focusedNodeId)) {\n focusedNodeId = null;\n updateFocusUI();\n }\n updateGraph();\n });", "new_string": " if (focusedNodeId && !enabled.has(focusedNodeId)) {\n focusedNodeId = null;\n updateFocusUI();\n }\n syncToggleBtn();\n updateGraph();\n });" }


🤖 Claude

All three changes done:

  • Prune orphans defaults to checked
  • Spacing slider defaults to 70
  • "Reset All" is now a "Select All" / "Deselect All" toggle that updates its label based on current state

👤 User

great, can you capture everything from this session in a WORKLOG.md?


🤖 Claude

🔧 Using tool: Write Input: { "file_path": "/Users/cell/claude/factorio-sci-map/WORKLOG.md", "content": "# Factorio Science Dependency Map \u2014 Worklog\n\n## Goal\n\nBuild an interactive dependency graph for the first six Factorio science packs (Space Age focus, so space science omitted), showing all intermediate and raw material dependencies. The purpose is to visualize inter-relationships and identify opportunities to leverage shared dependencies (e.g., splitting steel plates to multiple consumers).\n\n## Data Source\n\n- recipe.json (224KB) \u2014 exported from a separate Factorio rate calculator project. Contains all game recipes with ingredients, products, amounts, etc.\n- icons-64/ \u2014 full Factorio icon set (296 PNGs, 64x64). Also publicly hosted at https://rocketcal.cc/icons-64/.\n- extract_deps.py \u2014 script to parse recipe.json into a minimal product: ingredient1, ingredient2, ... format (212 lines of output).\n\n## Design Decisions\n\n### Scope\n- 6 science packs: red (automation), green (logistic), blue (chemical), black (military), purple (production), yellow (utility).\n- Space science omitted \u2014 user plays Space Age, so rocket-part / RCU / speed-module / rocket-fuel / solid-fuel chain excluded.\n\n### Leaf Nodes (not expanded further)\n- Plates treated as leaves: iron-plate, copper-plate (copper-ore omitted entirely).\n- Stone and stone-brick: both kept as leaves (stone needed by rails, brick by walls/furnaces).\n- Coal: kept as leaf (needed by plastic).\n- Fluids as leaves: water, petroleum-gas, lubricant. Heavy-oil and light-oil are in the leaf set but unused by the 6-science graph.\n- Oil refinery removed in v1: initially modeled as a node (crude + water \u2192 petro/light/heavy), but after seeing the graph, simplified to treat all oil products as leaf nodes.\n\n### Solid Fuel\n- Three recipes exist (from light-oil, heavy-oil, petroleum-gas). Only light-oil recipe used. Moot for now since space science is excluded.\n\n### Node Duplication\n- Leaf/raw nodes appear at every use site (duplicated) in the graphviz versions.\n- In the webapp (v2+), each node appears once \u2014 the whole point is seeing fan-out and shared dependencies.\n\n### Missing Icons\n- Four fluids lack icons in the standard set: water, petroleum-gas, lubricant, sulfuric-acid.\n- Webapp uses text labels for these: H\u2082O, Gas, Lube, H\u2082SO\u2084.\n\n## Versions\n\n### v0 \u2014 First Graphviz Prototype\n- gen_graph.py generates a .dot file from recipe.json.\n- Oil refinery modeled as an octagon node with 3 outputs.\n- Space science included but was disconnected (recipe not in recipe.json \u2014 it's a rocket launch reward).\n- Fixed by hardcoding space-science-pack \u2192 rocket-part.\n- Leaf nodes duplicated at each use site (11 copies of iron-plate).\n- Science nodes colored by type.\n\n### v1 \u2014 Simplified Graphviz\n- Removed oil refinery node; crude, water, petro, light-oil, heavy-oil, lubricant all become duplicated leaf nodes.\n- Removed space science pack and its unique dependency chain.\n- Cleaner graph: 6 sciences, ~25 intermediates, 8 leaf types.\n\n### v2 \u2014 Interactive Webapp\n- Single self-contained HTML file using D3.js v7 (CDN).\n- Force-directed layout with tier-based y-positioning (sciences float to top, leaves sink to bottom).\n- Factorio icons loaded from https://rocketcal.cc/icons-64/.\n- Left sidebar with checkboxes grouped by Science Packs / Intermediates / Raw Materials.\n- Uncheck cascade: unchecking a node also unchecks all transitive dependents (consumers).\n- Check cascade: checking a node also checks all transitive dependencies (ingredients).\n- Hover highlighting: dims unconnected nodes, brightens connected edges.\n- Click-to-toggle: clicking a node in the graph toggles its sidebar checkbox.\n- Zoom and pan via D3 zoom behavior.\n- Drag nodes to reposition.\n- Fixed initialization bug: deferred graph init with requestAnimationFrame to handle cases where container has 0 width at script execution time.\n\n### v3 \u2014 Focus Mode + Settings (current)\n- Click-to-focus replaces click-to-toggle: clicking a node shows only its subtree (the node + all transitive dependencies). Clicking again defocuses back to full graph.\n- Focus styling: removed science-specific coloring. All nodes have uniform dark styling. The focused node gets a bold cyan (#26c6da) stroke and tinted fill.\n- "Clear Focus" button and focus indicator text in sidebar.\n- Spacing slider (Settings section at bottom of sidebar): controls charge strength, link distance, and collision radius together. Slider 0\u2013100 maps to tight\u2013spread. Default: 70.\n- "Prune orphans on deselect" toggle (default: on): when unchecking a node, also removes any dependencies that no longer feed into any enabled node, recursively.\n- "Select All / Deselect All" toggle button replaces the old "Reset All" \u2014 label updates based on current state.\n\n## File Structure\n\n\nfactorio-sci-map/\n\u251c\u2500\u2500 recipe.json # Full Factorio recipe data (224KB)\n\u251c\u2500\u2500 extract_deps.py # Parses recipe.json \u2192 minimal dep format\n\u251c\u2500\u2500 icons-64/ # 296 Factorio item icons (64x64 PNG)\n\u251c\u2500\u2500 WORKLOG.md # This file\n\u251c\u2500\u2500 v0/\n\u2502 \u251c\u2500\u2500 gen_graph.py # Graphviz generator (with oil refinery + space science)\n\u2502 \u251c\u2500\u2500 science_deps.dot\n\u2502 \u2514\u2500\u2500 science_deps.png\n\u251c\u2500\u2500 v1/\n\u2502 \u251c\u2500\u2500 gen_graph.py # Graphviz generator (simplified, no refinery/space)\n\u2502 \u2514\u2500\u2500 science_deps.dot\n\u251c\u2500\u2500 v2/\n\u2502 \u2514\u2500\u2500 index.html # Webapp with checkbox toggles\n\u251c\u2500\u2500 v3/\n\u2502 \u2514\u2500\u2500 index.html # Webapp with focus mode + settings\n\u2514\u2500\u2500 .claude/\n \u2514\u2500\u2500 launch.json # Dev server configs for preview\n\n\n## Graph Stats (v3)\n\n- 39 nodes: 6 science packs, 25 intermediates, 8 leaf types\n- 65 edges\n- Tier depth: 0 (leaves) through 5 (production/utility science)\n- Iron plate fans out to 11 consumers (most connected leaf node)\n" }


🤖 Claude

Written to WORKLOG.md. Covers the goal, all design decisions we discussed, the evolution through v0–v3, file structure, and graph stats.


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Factorio Science Dependency Map</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
display: flex;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f0f1a;
color: #e0e0e0;
}
#sidebar {
width: 280px;
min-width: 280px;
background: #161625;
border-right: 1px solid #2a2a40;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #2a2a40;
}
.sidebar-header h1 {
font-size: 15px;
font-weight: 600;
margin-bottom: 10px;
}
.sidebar-buttons {
display: flex;
gap: 8px;
}
.sidebar-buttons button {
padding: 6px 14px;
background: #2a2a40;
border: 1px solid #3a3a55;
border-radius: 4px;
color: #ccc;
cursor: pointer;
font-size: 12px;
}
.sidebar-buttons button:hover { background: #3a3a55; }
#focus-indicator {
display: none;
padding: 8px 16px;
background: #1a2a3a;
border-bottom: 1px solid #2a2a40;
font-size: 12px;
color: #7ab8e8;
}
#checkboxes {
flex: 1;
overflow-y: auto;
padding: 8px 16px 16px;
}
.section-header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #777;
margin: 16px 0 6px;
padding-bottom: 4px;
border-bottom: 1px solid #2a2a40;
}
.section-header:first-child { margin-top: 8px; }
.cb-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px;
cursor: pointer;
border-radius: 4px;
}
.cb-item:hover { background: #1e1e33; }
.cb-item input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #5588cc;
flex-shrink: 0;
}
.cb-item img, .cb-item .fluid-icon {
width: 24px;
height: 24px;
image-rendering: pixelated;
flex-shrink: 0;
}
.fluid-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: #1a3a5c;
border-radius: 4px;
font-size: 12px;
}
.cb-item span {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#graph-container {
flex: 1;
min-width: 0;
position: relative;
overflow: hidden;
}
#graph-container svg {
width: 100%;
height: 100%;
cursor: grab;
}
#graph-container svg:active { cursor: grabbing; }
.node-group { cursor: pointer; }
.node-group text {
fill: #aaa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
pointer-events: none;
user-select: none;
}
.node-group image { pointer-events: none; }
.node-group .fluid-label {
fill: #8ab4e8;
font-size: 8px;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
}
.dimmed { opacity: 0.08; }
.link, .node-group { transition: opacity 0.15s; }
#settings {
padding: 12px 16px;
border-top: 1px solid #2a2a40;
}
#settings .section-header {
margin-top: 0;
}
.slider-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.slider-row label {
font-size: 12px;
color: #999;
min-width: 50px;
}
.slider-row input[type="range"] {
flex: 1;
accent-color: #5588cc;
height: 4px;
}
</style>
</head>
<body>
<div id="sidebar">
<div class="sidebar-header">
<h1>Factorio Science Dependencies</h1>
<div class="sidebar-buttons">
<button id="toggle-all-btn">Deselect All</button>
<button id="clear-focus-btn" style="display:none">Clear Focus</button>
</div>
</div>
<div id="focus-indicator"></div>
<div id="checkboxes"></div>
<div id="settings">
<div class="section-header">Settings</div>
<div class="slider-row">
<label>Spacing</label>
<input type="range" id="spacing-slider" min="0" max="100" value="70">
</div>
<label class="cb-item" style="margin-top:10px">
<input type="checkbox" id="prune-orphans-cb" checked>
<span>Prune orphans on deselect</span>
</label>
</div>
</div>
<div id="graph-container">
<svg></svg>
</div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const ICON_BASE = "https://rocketcal.cc/icons-64/";
const FOCUS_COLOR = "#26c6da";
const FLUIDS = new Set(["water", "petroleum-gas", "lubricant", "sulfuric-acid",
"heavy-oil", "light-oil", "crude-oil"]);
const FLUID_SYMBOLS = {
"water": "\u{1F4A7}",
"petroleum-gas": "\u{2B24}",
"lubricant": "\u{2B24}",
"sulfuric-acid": "\u{2B24}",
};
const RAW_NODES = [
{id: "automation-science-pack", type: "science"},
{id: "logistic-science-pack", type: "science"},
{id: "chemical-science-pack", type: "science"},
{id: "military-science-pack", type: "science"},
{id: "production-science-pack", type: "science"},
{id: "utility-science-pack", type: "science"},
{id: "advanced-circuit", type: "intermediate"},
{id: "battery", type: "intermediate"},
{id: "copper-cable", type: "intermediate"},
{id: "electric-engine-unit", type: "intermediate"},
{id: "electric-furnace", type: "intermediate"},
{id: "electronic-circuit", type: "intermediate"},
{id: "engine-unit", type: "intermediate"},
{id: "firearm-magazine", type: "intermediate"},
{id: "flying-robot-frame", type: "intermediate"},
{id: "grenade", type: "intermediate"},
{id: "inserter", type: "intermediate"},
{id: "iron-gear-wheel", type: "intermediate"},
{id: "iron-stick", type: "intermediate"},
{id: "low-density-structure", type: "intermediate"},
{id: "piercing-rounds-magazine", type: "intermediate"},
{id: "pipe", type: "intermediate"},
{id: "plastic-bar", type: "intermediate"},
{id: "processing-unit", type: "intermediate"},
{id: "productivity-module", type: "intermediate"},
{id: "rail", type: "intermediate"},
{id: "steel-plate", type: "intermediate"},
{id: "stone-wall", type: "intermediate"},
{id: "sulfur", type: "intermediate"},
{id: "sulfuric-acid", type: "intermediate"},
{id: "transport-belt", type: "intermediate"},
{id: "coal", type: "leaf"},
{id: "copper-plate", type: "leaf"},
{id: "iron-plate", type: "leaf"},
{id: "lubricant", type: "leaf"},
{id: "petroleum-gas", type: "leaf"},
{id: "stone", type: "leaf"},
{id: "stone-brick", type: "leaf"},
{id: "water", type: "leaf"},
];
const RAW_LINKS = [
{source: "copper-plate", target: "automation-science-pack"},
{source: "iron-gear-wheel", target: "automation-science-pack"},
{source: "transport-belt", target: "logistic-science-pack"},
{source: "inserter", target: "logistic-science-pack"},
{source: "sulfur", target: "chemical-science-pack"},
{source: "advanced-circuit", target: "chemical-science-pack"},
{source: "engine-unit", target: "chemical-science-pack"},
{source: "piercing-rounds-magazine", target: "military-science-pack"},
{source: "grenade", target: "military-science-pack"},
{source: "stone-wall", target: "military-science-pack"},
{source: "rail", target: "production-science-pack"},
{source: "electric-furnace", target: "production-science-pack"},
{source: "productivity-module", target: "production-science-pack"},
{source: "processing-unit", target: "utility-science-pack"},
{source: "flying-robot-frame", target: "utility-science-pack"},
{source: "low-density-structure", target: "utility-science-pack"},
{source: "iron-plate", target: "iron-gear-wheel"},
{source: "iron-gear-wheel", target: "transport-belt"},
{source: "iron-plate", target: "transport-belt"},
{source: "iron-gear-wheel", target: "inserter"},
{source: "iron-plate", target: "inserter"},
{source: "electronic-circuit", target: "inserter"},
{source: "water", target: "sulfur"},
{source: "petroleum-gas", target: "sulfur"},
{source: "plastic-bar", target: "advanced-circuit"},
{source: "copper-cable", target: "advanced-circuit"},
{source: "electronic-circuit", target: "advanced-circuit"},
{source: "iron-gear-wheel", target: "engine-unit"},
{source: "pipe", target: "engine-unit"},
{source: "steel-plate", target: "engine-unit"},
{source: "copper-plate", target: "piercing-rounds-magazine"},
{source: "steel-plate", target: "piercing-rounds-magazine"},
{source: "firearm-magazine", target: "piercing-rounds-magazine"},
{source: "coal", target: "grenade"},
{source: "iron-plate", target: "grenade"},
{source: "stone-brick", target: "stone-wall"},
{source: "iron-stick", target: "rail"},
{source: "steel-plate", target: "rail"},
{source: "stone", target: "rail"},
{source: "steel-plate", target: "electric-furnace"},
{source: "advanced-circuit", target: "electric-furnace"},
{source: "stone-brick", target: "electric-furnace"},
{source: "advanced-circuit", target: "productivity-module"},
{source: "electronic-circuit", target: "productivity-module"},
{source: "electronic-circuit", target: "processing-unit"},
{source: "advanced-circuit", target: "processing-unit"},
{source: "sulfuric-acid", target: "processing-unit"},
{source: "steel-plate", target: "flying-robot-frame"},
{source: "battery", target: "flying-robot-frame"},
{source: "electronic-circuit", target: "flying-robot-frame"},
{source: "electric-engine-unit", target: "flying-robot-frame"},
{source: "copper-plate", target: "low-density-structure"},
{source: "steel-plate", target: "low-density-structure"},
{source: "plastic-bar", target: "low-density-structure"},
{source: "iron-plate", target: "electronic-circuit"},
{source: "copper-cable", target: "electronic-circuit"},
{source: "coal", target: "plastic-bar"},
{source: "petroleum-gas", target: "plastic-bar"},
{source: "copper-plate", target: "copper-cable"},
{source: "iron-plate", target: "pipe"},
{source: "iron-plate", target: "steel-plate"},
{source: "iron-plate", target: "firearm-magazine"},
{source: "iron-plate", target: "iron-stick"},
{source: "iron-plate", target: "sulfuric-acid"},
{source: "sulfur", target: "sulfuric-acid"},
{source: "water", target: "sulfuric-acid"},
{source: "iron-plate", target: "battery"},
{source: "copper-plate", target: "battery"},
{source: "sulfuric-acid", target: "battery"},
{source: "electronic-circuit", target: "electric-engine-unit"},
{source: "engine-unit", target: "electric-engine-unit"},
{source: "lubricant", target: "electric-engine-unit"},
];
// --- Tier computation ---
const ingredientsOf = {};
for (const link of RAW_LINKS) {
if (!ingredientsOf[link.target]) ingredientsOf[link.target] = [];
ingredientsOf[link.target].push(link.source);
}
const tierCache = {};
function getTier(id) {
if (tierCache[id] !== undefined) return tierCache[id];
tierCache[id] = 0;
const ings = ingredientsOf[id] || [];
if (ings.length > 0) {
tierCache[id] = 1 + Math.max(...ings.map(getTier));
}
return tierCache[id];
}
for (const node of RAW_NODES) {
node.tier = getTier(node.id);
}
const maxTier = Math.max(...RAW_NODES.map(n => n.tier));
// --- State ---
const enabled = new Set(RAW_NODES.map(n => n.id));
const savedPositions = {};
let focusedNodeId = null;
// --- Utilities ---
function displayName(id) {
return id.split("-").map(w => w[0].toUpperCase() + w.slice(1)).join(" ");
}
function getTransitiveDependents(nodeId) {
const result = new Set();
const queue = [nodeId];
while (queue.length > 0) {
const current = queue.shift();
for (const link of RAW_LINKS) {
if (link.source === current && !result.has(link.target)) {
result.add(link.target);
queue.push(link.target);
}
}
}
return result;
}
function getTransitiveDependencies(nodeId) {
const result = new Set();
const queue = [nodeId];
while (queue.length > 0) {
const current = queue.shift();
for (const link of RAW_LINKS) {
if (link.target === current && !result.has(link.source)) {
result.add(link.source);
queue.push(link.source);
}
}
}
return result;
}
function pruneOrphans() {
let changed = true;
while (changed) {
changed = false;
for (const nodeId of [...enabled]) {
const node = RAW_NODES.find(n => n.id === nodeId);
if (node && node.type === "science") continue;
const hasConsumer = RAW_LINKS.some(l => l.source === nodeId && enabled.has(l.target));
if (!hasConsumer) {
enabled.delete(nodeId);
const cb = document.querySelector(`input[data-id="${nodeId}"]`);
if (cb) cb.checked = false;
changed = true;
}
}
}
}
function linkId(d) {
const s = typeof d.source === "object" ? d.source.id : d.source;
const t = typeof d.target === "object" ? d.target.id : d.target;
return s + ">" + t;
}
// --- Focus ---
function setFocus(nodeId) {
if (focusedNodeId === nodeId) {
focusedNodeId = null;
} else {
focusedNodeId = nodeId;
}
updateFocusUI();
updateGraph();
}
function updateFocusUI() {
const indicator = document.getElementById("focus-indicator");
const clearBtn = document.getElementById("clear-focus-btn");
if (focusedNodeId) {
indicator.style.display = "block";
indicator.textContent = "Focused: " + displayName(focusedNodeId);
clearBtn.style.display = "inline-block";
} else {
indicator.style.display = "none";
clearBtn.style.display = "none";
}
}
document.getElementById("clear-focus-btn").addEventListener("click", () => {
focusedNodeId = null;
updateFocusUI();
updateGraph();
});
// --- Sidebar ---
function buildSidebar() {
const container = document.getElementById("checkboxes");
container.innerHTML = "";
const groups = [
{label: "Science Packs", items: RAW_NODES.filter(n => n.type === "science")},
{label: "Intermediates", items: [...RAW_NODES.filter(n => n.type === "intermediate")].sort((a,b) => a.id.localeCompare(b.id))},
{label: "Raw Materials", items: [...RAW_NODES.filter(n => n.type === "leaf")].sort((a,b) => a.id.localeCompare(b.id))},
];
for (const group of groups) {
const header = document.createElement("div");
header.className = "section-header";
header.textContent = group.label;
container.appendChild(header);
for (const node of group.items) {
const item = document.createElement("label");
item.className = "cb-item";
const iconHtml = FLUIDS.has(node.id)
? `<span class="fluid-icon">${FLUID_SYMBOLS[node.id] || "\u{1F4A7}"}</span>`
: `<img src="${ICON_BASE}${node.id}.png" alt="">`;
item.innerHTML = `
<input type="checkbox" data-id="${node.id}" ${enabled.has(node.id) ? "checked" : ""}>
${iconHtml}
<span>${displayName(node.id)}</span>
`;
container.appendChild(item);
item.querySelector("input").addEventListener("change", (e) => {
if (e.target.checked) {
enabled.add(node.id);
const deps = getTransitiveDependencies(node.id);
for (const depId of deps) {
enabled.add(depId);
const cb = document.querySelector(`input[data-id="${depId}"]`);
if (cb) cb.checked = true;
}
} else {
enabled.delete(node.id);
const deps = getTransitiveDependents(node.id);
for (const depId of deps) {
enabled.delete(depId);
const cb = document.querySelector(`input[data-id="${depId}"]`);
if (cb) cb.checked = false;
}
if (document.getElementById("prune-orphans-cb").checked) {
pruneOrphans();
}
}
if (focusedNodeId && !enabled.has(focusedNodeId)) {
focusedNodeId = null;
updateFocusUI();
}
syncToggleBtn();
updateGraph();
});
}
}
}
const toggleAllBtn = document.getElementById("toggle-all-btn");
function syncToggleBtn() {
toggleAllBtn.textContent = RAW_NODES.every(n => enabled.has(n.id)) ? "Deselect All" : "Select All";
}
toggleAllBtn.addEventListener("click", () => {
const allEnabled = RAW_NODES.every(n => enabled.has(n.id));
if (allEnabled) {
enabled.clear();
document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = false);
toggleAllBtn.textContent = "Select All";
} else {
for (const node of RAW_NODES) enabled.add(node.id);
document.querySelectorAll('#checkboxes input[type="checkbox"]').forEach(cb => cb.checked = true);
toggleAllBtn.textContent = "Deselect All";
}
focusedNodeId = null;
updateFocusUI();
updateGraph();
});
// --- Graph setup ---
const graphContainer = document.getElementById("graph-container");
const svg = d3.select("#graph-container svg");
let width = graphContainer.clientWidth;
let height = graphContainer.clientHeight;
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 35).attr("refY", 0)
.attr("markerWidth", 8).attr("markerHeight", 8)
.attr("orient", "auto")
.append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#444");
defs.append("marker")
.attr("id", "arrow-hl")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 35).attr("refY", 0)
.attr("markerWidth", 8).attr("markerHeight", 8)
.attr("orient", "auto")
.append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#7ab8e8");
const g = svg.append("g");
const linkGroup = g.append("g");
const nodeGroup = g.append("g");
const zoom = d3.zoom()
.scaleExtent([0.15, 5])
.on("zoom", (event) => g.attr("transform", event.transform));
svg.call(zoom);
// --- Spacing ---
let spacingValue = 70;
function spacingParams() {
const t = spacingValue / 100;
return {
charge: -150 - t * 650,
linkDist: 50 + t * 120,
collide: 30 + t * 25,
};
}
const spacingSlider = document.getElementById("spacing-slider");
spacingSlider.addEventListener("input", (e) => {
spacingValue = +e.target.value;
if (!simulation) return;
const p = spacingParams();
simulation.force("charge").strength(p.charge);
simulation.force("link").distance(p.linkDist);
simulation.force("collide").radius(p.collide);
simulation.alpha(0.4).restart();
});
// --- Simulation & rendering ---
let simulation = null;
let nodeSelection = d3.selectAll(null);
let linkSelection = d3.selectAll(null);
const NODE_R = 22;
function tierY(tier) {
const pad = 0.12;
return height * (1 - pad - (tier / maxTier) * (1 - 2 * pad));
}
function nodeStroke(d) {
if (d.id === focusedNodeId) return FOCUS_COLOR;
if (d.type === "leaf") return "#7a5c3d";
return "#4a4a65";
}
function nodeStrokeWidth(d) {
return d.id === focusedNodeId ? 3 : 1.5;
}
function nodeFill(d) {
if (d.id === focusedNodeId) {
const c = d3.color(FOCUS_COLOR);
c.opacity = 0.25;
return c + "";
}
return "#22223a";
}
function visibleNodeIds() {
let ids = new Set([...enabled]);
if (focusedNodeId && ids.has(focusedNodeId)) {
const subtree = getTransitiveDependencies(focusedNodeId);
subtree.add(focusedNodeId);
ids = new Set([...ids].filter(id => subtree.has(id)));
}
return ids;
}
function updateGraph() {
if (simulation) {
for (const n of simulation.nodes()) {
savedPositions[n.id] = {x: n.x, y: n.y};
}
simulation.stop();
}
const visible = visibleNodeIds();
const nodes = RAW_NODES
.filter(n => visible.has(n.id))
.map(n => {
const pos = savedPositions[n.id];
return {
...n,
x: pos ? pos.x : width / 2 + (Math.random() - 0.5) * width * 0.4,
y: pos ? pos.y : tierY(n.tier),
};
});
const nodeIds = new Set(nodes.map(n => n.id));
const links = RAW_LINKS
.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target))
.map(l => ({source: l.source, target: l.target}));
const p = spacingParams();
simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(p.linkDist).strength(0.4))
.force("charge", d3.forceManyBody().strength(p.charge))
.force("x", d3.forceX(width / 2).strength(0.04))
.force("y", d3.forceY(d => tierY(d.tier)).strength(0.12))
.force("collide", d3.forceCollide(p.collide))
.alphaDecay(0.02)
.on("tick", ticked);
// --- Links ---
linkSelection = linkGroup.selectAll("line")
.data(links, linkId)
.join("line")
.attr("class", "link")
.attr("stroke", "#3a3a55")
.attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrow)");
// --- Nodes ---
nodeSelection = nodeGroup.selectAll(".node-group")
.data(nodes, d => d.id)
.join(
enter => {
const ng = enter.append("g").attr("class", "node-group");
ng.append("circle")
.attr("r", NODE_R)
.attr("fill", nodeFill)
.attr("stroke", nodeStroke)
.attr("stroke-width", nodeStrokeWidth);
ng.each(function(d) {
const el = d3.select(this);
if (FLUIDS.has(d.id)) {
el.append("text")
.attr("class", "fluid-label")
.attr("font-size", "10px")
.text(shortFluidName(d.id));
} else {
el.append("image")
.attr("href", ICON_BASE + d.id + ".png")
.attr("width", 32).attr("height", 32)
.attr("x", -16).attr("y", -16);
}
});
ng.append("text")
.text(d => displayName(d.id))
.attr("y", NODE_R + 12)
.attr("text-anchor", "middle")
.attr("font-size", "9px");
ng.append("title").text(d => displayName(d.id));
return ng;
},
update => {
update.select("circle")
.attr("fill", nodeFill)
.attr("stroke", nodeStroke)
.attr("stroke-width", nodeStrokeWidth);
return update;
},
exit => exit.remove()
);
// Drag
let wasDragged = false;
const drag = d3.drag()
.on("start", (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
wasDragged = false;
})
.on("drag", (event, d) => {
d.fx = event.x; d.fy = event.y;
wasDragged = true;
})
.on("end", (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
if (!wasDragged) setFocus(d.id);
});
nodeSelection.call(drag);
// Hover
nodeSelection
.on("mouseenter", (event, d) => {
const connected = new Set([d.id]);
for (const link of links) {
const sid = link.source.id ?? link.source;
const tid = link.target.id ?? link.target;
if (sid === d.id) connected.add(tid);
if (tid === d.id) connected.add(sid);
}
nodeSelection.classed("dimmed", n => !connected.has(n.id));
linkSelection.classed("dimmed", l => {
const sid = l.source.id ?? l.source;
const tid = l.target.id ?? l.target;
return sid !== d.id && tid !== d.id;
});
linkSelection.filter(l => {
const sid = l.source.id ?? l.source;
const tid = l.target.id ?? l.target;
return sid === d.id || tid === d.id;
}).attr("stroke", "#7ab8e8").attr("marker-end", "url(#arrow-hl)");
})
.on("mouseleave", () => {
nodeSelection.classed("dimmed", false);
linkSelection.classed("dimmed", false);
linkSelection.attr("stroke", "#3a3a55").attr("marker-end", "url(#arrow)");
});
}
function shortFluidName(id) {
const names = {
"water": "H₂O",
"petroleum-gas": "Gas",
"lubricant": "Lube",
"sulfuric-acid": "H₂SO₄",
};
return names[id] || id;
}
function ticked() {
linkSelection
.attr("x1", d => d.source.x).attr("y1", d => d.source.y)
.attr("x2", d => d.target.x).attr("y2", d => d.target.y);
nodeSelection
.attr("transform", d => `translate(${d.x},${d.y})`);
}
// --- Init ---
buildSidebar();
function initGraph() {
width = graphContainer.clientWidth;
height = graphContainer.clientHeight;
if (width > 0 && height > 0) {
updateGraph();
} else {
requestAnimationFrame(initGraph);
}
}
requestAnimationFrame(initGraph);
window.addEventListener("resize", () => {
width = graphContainer.clientWidth;
height = graphContainer.clientHeight;
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment