10 Ревизии 633caa3b73 ... ec5667d392

Автор SHA1 Съобщение Дата
  clovis ec5667d392 implent topology and check преди 1 месец
  clovis 2dd22b2977 try topology with graph преди 1 месец
  clovis 9d09be9098 init topology builder преди 1 месец
  clovis a1208ae2a3 rename index to model.ts преди 1 месец
  clovis ba53acbd71 fix simulator преди 1 месец
  clovis ec7f78fef1 final polish of custom select преди 1 месец
  clovis 5030df872c add vertical ticks преди 1 месец
  clovis ab3cf38579 harmonise styles преди 1 месец
  clovis 519c95461f horizontal scrolling timeline преди 1 месец
  clovis 0bf6215ac2 fix icon and make signals reorder i timeline row преди 1 месец
променени са 68 файла, в които са добавени 3863 реда и са изтрити 1847 реда
  1. 3 0
      index.html
  2. 75 501
      package-lock.json
  3. 1 1
      package.json
  4. 2 0
      scripts/factorio-dump/process-data.models.ts
  5. 4 0
      scripts/factorio-dump/process-data.ts
  6. 0 25
      src/ClockBuilder.module.css
  7. 27 24
      src/ClockBuilder.tsx
  8. 1 0
      src/Layout.tsx
  9. 58 55
      src/assets/ClockTimeline/ClockTimeline.module.css
  10. 50 24
      src/assets/ClockTimeline/ClockTimeline.tsx
  11. 69 0
      src/assets/ClockTimeline/SignalEditorList.tsx
  12. 48 40
      src/assets/ClockTimeline/TimelineRow.tsx
  13. 1 63
      src/assets/ClockTimeline/model.ts
  14. 10 0
      src/assets/Icon.module.css
  15. 1 25
      src/assets/SelectedBlockPanel.module.css
  16. 14 19
      src/assets/SelectedBlockPanel.tsx
  17. 92 0
      src/assets/Selector/BeaconConfigurator.module.css
  18. 106 0
      src/assets/Selector/BeaconConfigurator.tsx
  19. 62 4
      src/assets/Selector/MachineSelector.module.css
  20. 98 80
      src/assets/Selector/MachineSelector.tsx
  21. 0 0
      src/assets/Selector/ModuleSlots.module.css
  22. 50 36
      src/assets/Selector/ModuleSlots.tsx
  23. 5 2
      src/assets/Selector/SelectFactorioMenu.module.css
  24. 18 5
      src/assets/Selector/SelectFactorioMenu.tsx
  25. 0 1
      src/assets/Selector/SelectSignal.module.css
  26. 14 8
      src/assets/Selector/SelectSignal.tsx
  27. 0 140
      src/assets/components/BeaconConfigurator.tsx
  28. 13 9
      src/assets/components/ClockWizard.tsx
  29. 0 11
      src/assets/components/ExpressionInput.module.css
  30. 6 6
      src/assets/components/InputConfigurator.tsx
  31. 35 0
      src/assets/components/NumberInput.module.css
  32. 58 0
      src/assets/components/NumberInput.tsx
  33. 18 0
      src/assets/components/Preset.tsx
  34. 15 9
      src/assets/components/QualityIcon.tsx
  35. 92 0
      src/assets/components/Select.module.css
  36. 139 0
      src/assets/components/Select.tsx
  37. 345 1
      src/assets/data/2.0/data.json
  38. 26 19
      src/assets/icon.tsx
  39. 2 4
      src/blueprint/Blueprintbuilder.ts
  40. 60 0
      src/blueprint/model.ts
  41. 0 218
      src/engine/Dashboard/Dashboard.module.css
  42. 136 134
      src/engine/Dashboard/GraphView.tsx
  43. 1 1
      src/engine/Dashboard/types.ts
  44. 120 0
      src/engine/Topology/EdgeInspector.tsx
  45. 85 0
      src/engine/Topology/InserterEdge.tsx
  46. 202 0
      src/engine/Topology/NodeInspector.tsx
  47. 207 0
      src/engine/Topology/TopologyBuilder.module.css
  48. 499 0
      src/engine/Topology/TopologyBuilder.tsx
  49. 180 0
      src/engine/Topology/TopologyBuilderV0.tsx
  50. 176 0
      src/engine/Topology/TopologyNodeUI.tsx
  51. 39 0
      src/engine/Topology/model.ts
  52. 121 114
      src/engine/batch.test.ts
  53. 13 15
      src/engine/batch.ts
  54. 74 50
      src/engine/clockGenerator.test.ts
  55. 8 8
      src/engine/clockGenerator.ts
  56. 2 2
      src/engine/index.ts
  57. 14 13
      src/engine/model.ts
  58. 0 0
      src/engine/simulator.next.ts
  59. 141 62
      src/engine/simulator.test.ts
  60. 49 31
      src/engine/simulator.ts
  61. 3 10
      src/engine/stats.test.ts
  62. 4 4
      src/engine/stats.ts
  63. 77 45
      src/hooks/useQualityScroller.ts
  64. 14 10
      src/hooks/useTimelineDrag.ts
  65. 57 5
      src/index.css
  66. 2 0
      src/main.tsx
  67. 10 13
      src/store/useClockStore.ts
  68. 11 0
      src/utils.ts

+ 3 - 0
index.html

@@ -10,6 +10,9 @@
       href="https://fonts.googleapis.com/css2?family=Titillium+Web:ital,wght@0,200;0,300;0,400;0,600;0,700;0,900;1,200;1,300;1,400;1,600;1,700&display=swap"
       href="https://fonts.googleapis.com/css2?family=Titillium+Web:ital,wght@0,200;0,300;0,400;0,600;0,700;0,900;1,200;1,300;1,400;1,600;1,700&display=swap"
       rel="stylesheet"
       rel="stylesheet"
     />
     />
+    <link rel="preload" href="/data/2.0/icon_56.webp" as="image" type="image/webp" />
+    <link rel="preload" href="/data/2.0/icon_64.webp" as="image" type="image/webp" />
+    <link rel="preload" href="/data/2.0/icon_128.webp" as="image" type="image/webp" />
     <title>factorio-clockmaster</title>
     <title>factorio-clockmaster</title>
   </head>
   </head>
   <body>
   <body>

+ 75 - 501
package-lock.json

@@ -11,11 +11,11 @@
         "@emotion/react": "^11.14.0",
         "@emotion/react": "^11.14.0",
         "@emotion/styled": "^11.14.1",
         "@emotion/styled": "^11.14.1",
         "@mui/material": "^7.3.5",
         "@mui/material": "^7.3.5",
+        "@xyflow/react": "^12.11.3",
         "pako": "^2.1.0",
         "pako": "^2.1.0",
         "react": "^19.2.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-router": "^8.3.0",
         "react-router": "^8.3.0",
-        "reactflow": "^11.11.4",
         "sharp": "^0.35.3",
         "sharp": "^0.35.3",
         "simplebar-react": "^3.3.2",
         "simplebar-react": "^3.3.2",
         "zustand": "^5.0.14"
         "zustand": "^5.0.14"
@@ -1417,276 +1417,6 @@
         "url": "https://opencollective.com/popperjs"
         "url": "https://opencollective.com/popperjs"
       }
       }
     },
     },
-    "node_modules/@reactflow/background": {
-      "version": "11.3.14",
-      "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
-      "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
-      "license": "MIT",
-      "dependencies": {
-        "@reactflow/core": "11.11.4",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/background/node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@reactflow/controls": {
-      "version": "11.2.14",
-      "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
-      "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
-      "license": "MIT",
-      "dependencies": {
-        "@reactflow/core": "11.11.4",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/controls/node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@reactflow/core": {
-      "version": "11.11.4",
-      "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
-      "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3": "^7.4.0",
-        "@types/d3-drag": "^3.0.1",
-        "@types/d3-selection": "^3.0.3",
-        "@types/d3-zoom": "^3.0.1",
-        "classcat": "^5.0.3",
-        "d3-drag": "^3.0.0",
-        "d3-selection": "^3.0.0",
-        "d3-zoom": "^3.0.0",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/core/node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@reactflow/minimap": {
-      "version": "11.7.14",
-      "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
-      "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@reactflow/core": "11.11.4",
-        "@types/d3-selection": "^3.0.3",
-        "@types/d3-zoom": "^3.0.1",
-        "classcat": "^5.0.3",
-        "d3-selection": "^3.0.0",
-        "d3-zoom": "^3.0.0",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/minimap/node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@reactflow/node-resizer": {
-      "version": "2.2.14",
-      "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
-      "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
-      "license": "MIT",
-      "dependencies": {
-        "@reactflow/core": "11.11.4",
-        "classcat": "^5.0.4",
-        "d3-drag": "^3.0.0",
-        "d3-selection": "^3.0.0",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/node-resizer/node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@reactflow/node-toolbar": {
-      "version": "1.3.14",
-      "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
-      "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@reactflow/core": "11.11.4",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.1"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
-    "node_modules/@reactflow/node-toolbar/node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/@rolldown/binding-android-arm64": {
     "node_modules/@rolldown/binding-android-arm64": {
       "version": "1.2.2",
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
       "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
@@ -1980,102 +1710,12 @@
         "assertion-error": "^2.0.1"
         "assertion-error": "^2.0.1"
       }
       }
     },
     },
-    "node_modules/@types/d3": {
-      "version": "7.4.3",
-      "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
-      "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-array": "*",
-        "@types/d3-axis": "*",
-        "@types/d3-brush": "*",
-        "@types/d3-chord": "*",
-        "@types/d3-color": "*",
-        "@types/d3-contour": "*",
-        "@types/d3-delaunay": "*",
-        "@types/d3-dispatch": "*",
-        "@types/d3-drag": "*",
-        "@types/d3-dsv": "*",
-        "@types/d3-ease": "*",
-        "@types/d3-fetch": "*",
-        "@types/d3-force": "*",
-        "@types/d3-format": "*",
-        "@types/d3-geo": "*",
-        "@types/d3-hierarchy": "*",
-        "@types/d3-interpolate": "*",
-        "@types/d3-path": "*",
-        "@types/d3-polygon": "*",
-        "@types/d3-quadtree": "*",
-        "@types/d3-random": "*",
-        "@types/d3-scale": "*",
-        "@types/d3-scale-chromatic": "*",
-        "@types/d3-selection": "*",
-        "@types/d3-shape": "*",
-        "@types/d3-time": "*",
-        "@types/d3-time-format": "*",
-        "@types/d3-timer": "*",
-        "@types/d3-transition": "*",
-        "@types/d3-zoom": "*"
-      }
-    },
-    "node_modules/@types/d3-array": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
-      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-axis": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
-      "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/d3-brush": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
-      "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/d3-chord": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
-      "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
-      "license": "MIT"
-    },
     "node_modules/@types/d3-color": {
     "node_modules/@types/d3-color": {
       "version": "3.1.3",
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
       "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
       "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/@types/d3-contour": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
-      "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-array": "*",
-        "@types/geojson": "*"
-      }
-    },
-    "node_modules/@types/d3-delaunay": {
-      "version": "6.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
-      "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-dispatch": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
-      "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
-      "license": "MIT"
-    },
     "node_modules/@types/d3-drag": {
     "node_modules/@types/d3-drag": {
       "version": "3.0.7",
       "version": "3.0.7",
       "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
@@ -2085,54 +1725,6 @@
         "@types/d3-selection": "*"
         "@types/d3-selection": "*"
       }
       }
     },
     },
-    "node_modules/@types/d3-dsv": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
-      "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-ease": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
-      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-fetch": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
-      "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-dsv": "*"
-      }
-    },
-    "node_modules/@types/d3-force": {
-      "version": "3.0.10",
-      "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
-      "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-format": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
-      "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-geo": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
-      "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/geojson": "*"
-      }
-    },
-    "node_modules/@types/d3-hierarchy": {
-      "version": "3.1.7",
-      "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
-      "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
-      "license": "MIT"
-    },
     "node_modules/@types/d3-interpolate": {
     "node_modules/@types/d3-interpolate": {
       "version": "3.0.4",
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2142,78 +1734,12 @@
         "@types/d3-color": "*"
         "@types/d3-color": "*"
       }
       }
     },
     },
-    "node_modules/@types/d3-path": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
-      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-polygon": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
-      "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-quadtree": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
-      "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-random": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz",
-      "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-scale": {
-      "version": "4.0.9",
-      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
-      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-time": "*"
-      }
-    },
-    "node_modules/@types/d3-scale-chromatic": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
-      "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
-      "license": "MIT"
-    },
     "node_modules/@types/d3-selection": {
     "node_modules/@types/d3-selection": {
       "version": "3.0.11",
       "version": "3.0.11",
       "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
       "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
       "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/@types/d3-shape": {
-      "version": "3.1.8",
-      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
-      "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-path": "*"
-      }
-    },
-    "node_modules/@types/d3-time": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
-      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-time-format": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
-      "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-timer": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
-      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
-      "license": "MIT"
-    },
     "node_modules/@types/d3-transition": {
     "node_modules/@types/d3-transition": {
       "version": "3.0.9",
       "version": "3.0.9",
       "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -2243,12 +1769,6 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/@types/geojson": {
-      "version": "7946.0.16",
-      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
-      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
-      "license": "MIT"
-    },
     "node_modules/@types/json-schema": {
     "node_modules/@types/json-schema": {
       "version": "7.0.15",
       "version": "7.0.15",
       "dev": true,
       "dev": true,
@@ -2284,7 +1804,7 @@
     },
     },
     "node_modules/@types/react-dom": {
     "node_modules/@types/react-dom": {
       "version": "19.2.3",
       "version": "19.2.3",
-      "dev": true,
+      "devOptional": true,
       "license": "MIT",
       "license": "MIT",
       "peerDependencies": {
       "peerDependencies": {
         "@types/react": "^19.2.0"
         "@types/react": "^19.2.0"
@@ -2663,6 +2183,76 @@
         "url": "https://opencollective.com/vitest"
         "url": "https://opencollective.com/vitest"
       }
       }
     },
     },
+    "node_modules/@xyflow/react": {
+      "version": "12.11.3",
+      "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz",
+      "integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@xyflow/system": "0.0.80",
+        "classcat": "^5.0.3",
+        "zustand": "^4.4.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=17",
+        "@types/react-dom": ">=17",
+        "react": ">=17",
+        "react-dom": ">=17"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@xyflow/react/node_modules/zustand": {
+      "version": "4.5.7",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+      "license": "MIT",
+      "dependencies": {
+        "use-sync-external-store": "^1.2.2"
+      },
+      "engines": {
+        "node": ">=12.7.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=16.8",
+        "immer": ">=9.0.6",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@xyflow/system": {
+      "version": "0.0.80",
+      "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz",
+      "integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-drag": "^3.0.7",
+        "@types/d3-interpolate": "^3.0.4",
+        "@types/d3-selection": "^3.0.10",
+        "@types/d3-transition": "^3.0.8",
+        "@types/d3-zoom": "^3.0.8",
+        "d3-drag": "^3.0.0",
+        "d3-interpolate": "^3.0.1",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      }
+    },
     "node_modules/acorn": {
     "node_modules/acorn": {
       "version": "8.15.0",
       "version": "8.15.0",
       "dev": true,
       "dev": true,
@@ -4195,7 +3785,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/nanoid": {
     "node_modules/nanoid": {
-      "version": "3.3.17",
+      "version": "3.3.18",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -4559,24 +4151,6 @@
         "react-dom": ">=16.6.0"
         "react-dom": ">=16.6.0"
       }
       }
     },
     },
-    "node_modules/reactflow": {
-      "version": "11.11.4",
-      "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
-      "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
-      "license": "MIT",
-      "dependencies": {
-        "@reactflow/background": "11.3.14",
-        "@reactflow/controls": "11.2.14",
-        "@reactflow/core": "11.11.4",
-        "@reactflow/minimap": "11.7.14",
-        "@reactflow/node-resizer": "2.2.14",
-        "@reactflow/node-toolbar": "1.3.14"
-      },
-      "peerDependencies": {
-        "react": ">=17",
-        "react-dom": ">=17"
-      }
-    },
     "node_modules/resolve": {
     "node_modules/resolve": {
       "version": "1.22.11",
       "version": "1.22.11",
       "license": "MIT",
       "license": "MIT",

+ 1 - 1
package.json

@@ -18,11 +18,11 @@
     "@emotion/react": "^11.14.0",
     "@emotion/react": "^11.14.0",
     "@emotion/styled": "^11.14.1",
     "@emotion/styled": "^11.14.1",
     "@mui/material": "^7.3.5",
     "@mui/material": "^7.3.5",
+    "@xyflow/react": "^12.11.3",
     "pako": "^2.1.0",
     "pako": "^2.1.0",
     "react": "^19.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-router": "^8.3.0",
     "react-router": "^8.3.0",
-    "reactflow": "^11.11.4",
     "sharp": "^0.35.3",
     "sharp": "^0.35.3",
     "simplebar-react": "^3.3.2",
     "simplebar-react": "^3.3.2",
     "zustand": "^5.0.14"
     "zustand": "^5.0.14"

+ 2 - 0
scripts/factorio-dump/process-data.models.ts

@@ -19,6 +19,7 @@ export type Item = {
   hidden: boolean;
   hidden: boolean;
   subgroup: string;
   subgroup: string;
   order?: string;
   order?: string;
+  stackSize?: number;
 };
 };
 export type Machine = {
 export type Machine = {
   name: string;
   name: string;
@@ -82,4 +83,5 @@ export type ProcessedData = {
   recipeCategories: RecipeCategory[];
   recipeCategories: RecipeCategory[];
   recipeGroup: Group<Recipe>[];
   recipeGroup: Group<Recipe>[];
   signalGroup: Group<Signal>[];
   signalGroup: Group<Signal>[];
+  stackSizes: Record<string, number>;
 };
 };

+ 4 - 0
scripts/factorio-dump/process-data.ts

@@ -76,6 +76,7 @@ function extractItems(dataRaw: FactorioRawData): Record<string, Item> {
         icon: `${isFluid ? "fluid" : "item"}/${item.name}.png`,
         icon: `${isFluid ? "fluid" : "item"}/${item.name}.png`,
         subgroup: item.subgroup ?? (isFluid ? "fluid" : "other"),
         subgroup: item.subgroup ?? (isFluid ? "fluid" : "other"),
         order: item.order,
         order: item.order,
+        stackSize: item.stack_size,
       };
       };
     });
     });
     return acc;
     return acc;
@@ -132,6 +133,7 @@ export async function processData(outputFolder: string) {
             };
             };
         }
         }
     }
     }
+  const stackSizes: Record<string, number> = {};
   for (let item of Object.values(items)) {
   for (let item of Object.values(items)) {
     if (!item.hidden) {
     if (!item.hidden) {
       const signal = {
       const signal = {
@@ -151,6 +153,7 @@ export async function processData(outputFolder: string) {
       }
       }
       signals[k] = signal;
       signals[k] = signal;
     }
     }
+    if (item.name && item.stackSize && Number.isInteger(item.stackSize)) stackSizes[item.name] = item.stackSize;
   }
   }
   for (let recipe of recipes) {
   for (let recipe of recipes) {
     if (!(recipe.name in signals))
     if (!(recipe.name in signals))
@@ -174,6 +177,7 @@ export async function processData(outputFolder: string) {
     recipeCategories,
     recipeCategories,
     recipeGroup,
     recipeGroup,
     signalGroup,
     signalGroup,
+    stackSizes,
   };
   };
   fs.mkdirSync(outputFolder, { recursive: true });
   fs.mkdirSync(outputFolder, { recursive: true });
 
 

+ 0 - 25
src/ClockBuilder.module.css

@@ -14,31 +14,6 @@
   flex-wrap: wrap;
   flex-wrap: wrap;
   gap: 14px;
   gap: 14px;
 }
 }
-.field {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-.field label {
-  font-size: 11px;
-  color: #999;
-}
-.field input,
-.field select {
-  background-color: #242324;
-  border: 1px solid #646464;
-  color: #ffe6c0;
-  border-radius: 4px;
-  padding: 5px 8px;
-  font-family: ui-monospace, monospace;
-  font-size: 12.5px;
-  min-width: 120px;
-}
-.field input:focus,
-.field select:focus {
-  outline: 2px solid #f1be64;
-  outline-offset: 1px;
-}
 
 
 .footer {
 .footer {
   display: flex;
   display: flex;

+ 27 - 24
src/ClockBuilder.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useMemo, useRef, useState } from "react";
 import { useEffect, useMemo, useRef, useState } from "react";
 import { buildBlueprint } from "./blueprint/Blueprintbuilder";
 import { buildBlueprint } from "./blueprint/Blueprintbuilder";
 import { encodeBlueprintFileBrowser } from "./blueprint/parser";
 import { encodeBlueprintFileBrowser } from "./blueprint/parser";
-import type { ClockBlock } from "./assets/types";
+import type { ClockBlock } from "./assets/ClockTimeline/model";
 import styles from "./ClockBuilder.module.css";
 import styles from "./ClockBuilder.module.css";
 import SelectedBlockPanel from "./assets/SelectedBlockPanel";
 import SelectedBlockPanel from "./assets/SelectedBlockPanel";
 import SelectSignal from "./assets/Selector/SelectSignal";
 import SelectSignal from "./assets/Selector/SelectSignal";
@@ -9,6 +9,8 @@ import ExpressionInput from "./assets/components/ExpressionInpux";
 import { useClockStore } from "./store/useClockStore";
 import { useClockStore } from "./store/useClockStore";
 import ClockWizard from "./assets/components/ClockWizard";
 import ClockWizard from "./assets/components/ClockWizard";
 import ClockTimeline from "./assets/ClockTimeline";
 import ClockTimeline from "./assets/ClockTimeline";
+import Select from "./assets/components/Select";
+import { downloadJSON } from "./utils";
 
 
 export default function ClockBuilder() {
 export default function ClockBuilder() {
   //  Subscribe to the Global Store
   //  Subscribe to the Global Store
@@ -90,15 +92,7 @@ export default function ClockBuilder() {
       rows: rowsArray,
       rows: rowsArray,
       blocks: blocksArray,
       blocks: blocksArray,
     };
     };
-    const blob = new Blob([JSON.stringify(config, null, 2)], {
-      type: "application/json",
-    });
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement("a");
-    a.href = url;
-    a.download = `clock-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.json`;
-    a.click();
-    URL.revokeObjectURL(url);
+    downloadJSON(config, `clock-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.json`);
   };
   };
 
 
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
@@ -174,36 +168,45 @@ export default function ClockBuilder() {
   return (
   return (
     <div className={styles.wrap}>
     <div className={styles.wrap}>
       <div className={styles.header}>
       <div className={styles.header}>
-        <div className={styles.field}>
+        <div className="field">
           <label>Cycle length (ticks)</label>
           <label>Cycle length (ticks)</label>
-          <ExpressionInput value={duration} min={2} onCommit={setDuration} />
+          <ExpressionInput className="dark" value={duration} min={2} onCommit={setDuration} />
         </div>
         </div>
 
 
-        <div className={styles.field}>
+        <div className="field">
           <label>Clock signal icon</label>
           <label>Clock signal icon</label>
           <SelectSignal value={clockSignal?.name} onSelectSignal={(_, sig) => setClockSignal(sig)} />
           <SelectSignal value={clockSignal?.name} onSelectSignal={(_, sig) => setClockSignal(sig)} />
         </div>
         </div>
-        <div className={styles.field}>
+        <div className="field">
           <label>Throughput unit</label>
           <label>Throughput unit</label>
-          <select value={displayUnit} onChange={(e) => setDisplayUnit(e.target.value as "s" | "m")}>
-            <option value="s">items/s</option>
-            <option value="m">items/min</option>
-          </select>
+          <Select
+            style={{ minWidth: 100 }}
+            options={[
+              { id: "s", label: "items/s" },
+              { id: "m", label: "items/min" },
+            ]}
+            isDark
+            value={displayUnit}
+            onChange={(e) => setDisplayUnit(e as "s" | "m")}
+          />
         </div>
         </div>
-        <div className={styles.field}>
+        <div className="field">
           <label>Belt reference</label>
           <label>Belt reference</label>
           <input
           <input
             type="text"
             type="text"
+            className={"dark"}
             value={beltReference.name}
             value={beltReference.name}
             onChange={(e) => setBeltReference({ ...beltReference, name: e.target.value })}
             onChange={(e) => setBeltReference({ ...beltReference, name: e.target.value })}
           />
           />
-          <input
-            type="number"
-            value={beltReference.itemsPerSecond}
-            onChange={(e) =>
+
+          <ExpressionInput
+            className={"dark"}
+            value={duration}
+            min={2}
+            onCommit={(e) =>
               setBeltReference({
               setBeltReference({
                 ...beltReference,
                 ...beltReference,
-                itemsPerSecond: Number(e.target.value) || 0,
+                itemsPerSecond: Number(e) || 0,
               })
               })
             }
             }
           />
           />

+ 1 - 0
src/Layout.tsx

@@ -6,6 +6,7 @@ const NAV_ITEMS = [
   { label: "Component Tests", path: "/tests" },
   { label: "Component Tests", path: "/tests" },
   { label: "Simulator", path: "/simulator" },
   { label: "Simulator", path: "/simulator" },
   { label: "InputConfigurator", path: "/InputConfigurator" },
   { label: "InputConfigurator", path: "/InputConfigurator" },
+  { label: "Topology", path: "/topology" },
 ];
 ];
 
 
 export default function Layout() {
 export default function Layout() {

+ 58 - 55
src/assets/ClockTimeline/ClockTimeline.module.css

@@ -1,7 +1,7 @@
 .wrap {
 .wrap {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  gap: 10px;
+  gap: 4px;
   background-color: #242324;
   background-color: #242324;
   border: 1px solid #646464;
   border: 1px solid #646464;
   padding: 10px;
   padding: 10px;
@@ -67,46 +67,63 @@
   width: 2px;
   width: 2px;
   background: #f1be64;
   background: #f1be64;
 }
 }
+.scrollWrapper {
+  overflow-x: auto;
+  overflow-y: auto;
+  flex: 1;
+  container-type: inline-size;
+}
+.scrollContent {
+  position: relative;
+  width: max-content;
+  min-width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-bottom: 8px;
+}
+.gridBackground {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 0;
+  pointer-events: none;
+}
 
 
+/* The individual vertical lines */
+.gridLine {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 1px;
+  background-color: rgba(255, 255, 255, 0.05); /* Subtle faint white line */
+}
 .rows {
 .rows {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 10px;
   gap: 10px;
 }
 }
 .rowBlockWrap {
 .rowBlockWrap {
+  position: relative;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
+  width: max-content;
+  min-width: 100%;
   gap: 4px;
   gap: 4px;
 }
 }
 .rowHeader {
 .rowHeader {
+  position: sticky;
+  left: 0;
   display: flex;
   display: flex;
-  align-items: center;
+  align-items: normal;
+  width: 100cqw;
   gap: 8px;
   gap: 8px;
 }
 }
 .rowNameInput {
 .rowNameInput {
-  background: #373737;
-  border: 1px solid #646464;
-  color: #ffe6c0;
-  border-radius: 4px;
-  padding: 3px 8px;
-  font-size: 12px;
-  width: 110px;
-}
-.rowNameInput:focus {
-  outline: 2px solid #f1be64;
-  outline-offset: 1px;
+  width: 200px;
 }
 }
-.removeBtn {
-  margin-left: auto;
-  background: none;
-  border: none;
-  color: #999;
-  cursor: pointer;
-}
-.removeBtn:hover {
-  color: #d9614f;
-}
-
 .lane {
 .lane {
   position: relative;
   position: relative;
   background-color: #1a1a1a;
   background-color: #1a1a1a;
@@ -114,6 +131,8 @@
   border-radius: 4px;
   border-radius: 4px;
   touch-action: none;
   touch-action: none;
   transition: height 0.12s ease;
   transition: height 0.12s ease;
+  background-image: linear-gradient(90deg, #333 1px, transparent 1px);
+  background-position: 0 0;
 }
 }
 
 
 .block {
 .block {
@@ -153,21 +172,6 @@
   cursor: ew-resize;
   cursor: ew-resize;
   background: rgba(249, 180, 75, 0.35);
   background: rgba(249, 180, 75, 0.35);
 }
 }
-
-.addRowBtn {
-  align-self: flex-start;
-  background: none;
-  border: 1px dashed #646464;
-  color: #999;
-  border-radius: 4px;
-  padding: 6px 12px;
-  font-size: 12px;
-  cursor: pointer;
-}
-.addRowBtn:hover {
-  border-color: #f1be64;
-  color: #f1be64;
-}
 .rowThroughput {
 .rowThroughput {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -189,9 +193,6 @@
   font-family: ui-monospace, monospace;
   font-family: ui-monospace, monospace;
   color: #f1be64;
   color: #f1be64;
 }
 }
-.rows {
-  position: relative; /* anchors the alignment guide across all rows */
-}
 .alignGuide {
 .alignGuide {
   position: absolute;
   position: absolute;
   top: 0;
   top: 0;
@@ -208,25 +209,27 @@
 }
 }
 
 
 .signalList {
 .signalList {
-  display: flex;
+  display: inline-flex;
   align-items: center;
   align-items: center;
-  gap: 4px;
+  gap: 10px;
 }
 }
 .signalChip {
 .signalChip {
-  display: flex;
-  align-items: center;
-  gap: 2px;
+  display: inline-flex;
+  gap: 4px;
+  position: relative;
 }
 }
-.signalRemove {
-  background: none;
-  border: none;
-  color: #999;
+
+.signalRemove,
+.removeBtn {
   font-size: 10px;
   font-size: 10px;
-  cursor: pointer;
+  height: 32px;
+  min-width: 10px;
+  padding: 8px 3px;
+  margin-left: 0px;
+  margin-right: 0;
 }
 }
-
-.signalRemove:hover {
-  color: #d9614f;
+.removeBtn {
+  margin-left: auto;
 }
 }
 
 
 .autoFillPreset {
 .autoFillPreset {

+ 50 - 24
src/assets/ClockTimeline/ClockTimeline.tsx

@@ -1,7 +1,7 @@
 import { useMemo } from "react";
 import { useMemo } from "react";
 import styles from "./ClockTimeline.module.css";
 import styles from "./ClockTimeline.module.css";
 import { useClockStore } from "../../store/useClockStore";
 import { useClockStore } from "../../store/useClockStore";
-import { ACTION_PRESETS } from "../types";
+import { ACTION_PRESETS } from "./model";
 import Icon from "../icon";
 import Icon from "../icon";
 import TimelineRow from "./TimelineRow";
 import TimelineRow from "./TimelineRow";
 
 
@@ -15,15 +15,39 @@ export const defaultActivationSignal = {
   order: "a[checked]",
   order: "a[checked]",
 };
 };
 
 
+const MIN_TICK_DISTANCE_PX = 100;
+
 export default function ClockTimeline({}: Props) {
 export default function ClockTimeline({}: Props) {
-  const { duration, rowOrder, alignmentTick, addRow } = useClockStore();
+  const { duration, pixelsPerTick, rowOrder, alignmentTick, addRow } = useClockStore();
+  const timelineWidth = duration * pixelsPerTick;
+  const { majorTicks, step } = useMemo(() => {
+    const rawStep = MIN_TICK_DISTANCE_PX / pixelsPerTick;
+    let step = 1;
+    if (rawStep > 1) {
+      // Find the order of magnitude (e.g., 10, 100, 1000)
+      const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+
+      // Normalize the step to a number between 1 and 10
+      const normalized = rawStep / magnitude;
+
+      // Snap to 1, 2, 5, or 10 based on proximity
+      if (normalized < 1.5) step = 1 * magnitude;
+      else if (normalized < 3.5) step = 2 * magnitude;
+      else if (normalized < 7.5) step = 5 * magnitude;
+      else step = 10 * magnitude;
+    }
 
 
-  const majorTicks = useMemo(() => {
-    const step = Math.max(1, Math.round(duration / 10));
+    // Generate the ticks
     const out: number[] = [];
     const out: number[] = [];
-    for (let t = 0; t <= duration; t += step) out.push(t);
-    return out;
-  }, [duration]);
+    for (let t = step; t <= duration; t += step) out.push(t);
+
+    // Ensure the final duration tick is always drawn (optional but looks nice)
+    if (out[out.length - 1] === duration) {
+      out.pop();
+    }
+
+    return { step: step, majorTicks: out };
+  }, [duration, pixelsPerTick]);
 
 
   const onPaletteDragStart = (e: React.DragEvent, presetId: string) => {
   const onPaletteDragStart = (e: React.DragEvent, presetId: string) => {
     e.dataTransfer.setData("text/plain", presetId);
     e.dataTransfer.setData("text/plain", presetId);
@@ -52,26 +76,28 @@ export default function ClockTimeline({}: Props) {
         ))}
         ))}
         <span className={styles.hint}>Drag onto a row · Shift-click to multi-select · Ctrl+D to duplicate</span>
         <span className={styles.hint}>Drag onto a row · Shift-click to multi-select · Ctrl+D to duplicate</span>
       </div>
       </div>
-
-      <div className={styles.ruler}>
-        {majorTicks.map((t) => (
-          <div key={t} className={styles.rulerTick} style={{ left: `${(t / duration) * 100}%` }}>
-            <span>{t}</span>
+      <div className={styles.scrollWrapper}>
+        <div className={styles.scrollContent}>
+          <div className={styles.ruler} style={{ width: timelineWidth, minWidth: "100cqw" }}>
+            {majorTicks.map((t) => (
+              <div key={t} className={styles.rulerTick} style={{ left: `${(t / duration) * 100}%` }}>
+                <span>{t}</span>
+              </div>
+            ))}
+            <div className={styles.rulerReset} />
           </div>
           </div>
-        ))}
-        <div className={styles.rulerReset} />
-      </div>
 
 
-      <div className={styles.rows}>
-        {alignmentTick !== null && (
-          <div className={styles.alignGuide} style={{ left: `${(alignmentTick / duration) * 100}%` }} />
-        )}
+          {alignmentTick !== null && (
+            <div className={styles.alignGuide} style={{ left: `${(alignmentTick / duration) * 100}%` }} />
+          )}
 
 
-        {rowOrder.map((rowId) => (
-          <TimelineRow key={rowId} rowId={rowId} />
-        ))}
-
-        <button className={styles.addRowBtn} onClick={handleAddRow}>
+          {rowOrder.map((rowId) => (
+            <TimelineRow key={rowId} rowId={rowId} step={step} />
+          ))}
+        </div>
+      </div>
+      <div>
+        <button className={"button"} onClick={handleAddRow}>
           + Add row
           + Add row
         </button>
         </button>
       </div>
       </div>

+ 69 - 0
src/assets/ClockTimeline/SignalEditorList.tsx

@@ -0,0 +1,69 @@
+import { useRef } from "react";
+import type { Signal } from "./model";
+
+import styles from "./ClockTimeline.module.css";
+import SelectSignal from "../Selector/SelectSignal";
+
+interface SignalEditorListProps {
+  signals: Signal[];
+  placeholder?: string;
+  onAdd: (signal: Signal | null) => void;
+  onUpdate: (index: number, signal: Signal | null) => void;
+  onRemove: (index: number) => void;
+  onReorder: (sourceIdx: number, targetIdx: number) => void;
+}
+
+export default function SignalEditorList({
+  signals,
+  placeholder,
+  onAdd,
+  onUpdate,
+  onRemove,
+  onReorder,
+}: SignalEditorListProps) {
+  const draggedSignalIdx = useRef<number | null>(null);
+
+  return (
+    <div className={styles.signalList}>
+      {signals.map((s, i) => (
+        <span
+          key={`${s.name}-${i}`}
+          className={styles.signalChip}
+          draggable={signals.length > 1}
+          onDragStart={(e) => {
+            draggedSignalIdx.current = i;
+            e.dataTransfer.effectAllowed = "move";
+          }}
+          onDragOver={(e) => {
+            e.preventDefault();
+            e.dataTransfer.dropEffect = "move";
+          }}
+          onDrop={(e) => {
+            e.preventDefault();
+            const sourceIdx = draggedSignalIdx.current;
+            if (sourceIdx !== null && sourceIdx !== i) {
+              onReorder(sourceIdx, i);
+            }
+            draggedSignalIdx.current = null;
+          }}
+          onDragEnd={() => {
+            draggedSignalIdx.current = null;
+          }}
+        >
+          <SelectSignal value={s.name} onSelectSignal={(_, sig) => onUpdate(i, sig)} />
+          {signals.length > 1 && (
+            <button className={"button " + styles.signalRemove} onClick={() => onRemove(i)}>
+              ✕
+            </button>
+          )}
+        </span>
+      ))}
+      <SelectSignal
+        placeholder={placeholder}
+        className={styles.signalEmpty}
+        key={signals.length}
+        onSelectSignal={(_, sig) => onAdd(sig)}
+      />
+    </div>
+  );
+}

+ 48 - 40
src/assets/ClockTimeline/TimelineRow.tsx

@@ -1,11 +1,13 @@
-import { useRef } from "react";
+import { useRef, useState } from "react";
 import { useClockStore } from "../../store/useClockStore";
 import { useClockStore } from "../../store/useClockStore";
 import { useTimelineDrag } from "../../hooks/useTimelineDrag";
 import { useTimelineDrag } from "../../hooks/useTimelineDrag";
-import SelectSignal from "../Selector/SelectSignal";
 import ExpressionInput from "../components/ExpressionInpux";
 import ExpressionInput from "../components/ExpressionInpux";
 import Icon from "../icon";
 import Icon from "../icon";
 import styles from "./ClockTimeline.module.css";
 import styles from "./ClockTimeline.module.css";
-import { ACTION_PRESETS, expandBlockInstances, getPreset, type ClockBlock, type Signal } from "../types";
+import { ACTION_PRESETS, expandBlockInstances, getPreset, type ClockBlock, type Signal } from "./model";
+import SignalEditorList from "./SignalEditorList";
+import Select from "../components/Select";
+import Preset from "../components/Preset";
 
 
 type PackedInstance = {
 type PackedInstance = {
   id: string;
   id: string;
@@ -36,12 +38,14 @@ function packLanes(instances: { id: string; blockId: string; start: number; dura
 }
 }
 const LANE_HEIGHT = 30;
 const LANE_HEIGHT = 30;
 const GAP = 4;
 const GAP = 4;
-export default function TimelineRow({ rowId }: { rowId: string }) {
+export default function TimelineRow({ rowId, step }: { rowId: string; step?: number }) {
   const { updateRow, removeRow, addBlocks, selectBlocks, blocks, rows, duration, selectedBlockIds, alignmentTick } =
   const { updateRow, removeRow, addBlocks, selectBlocks, blocks, rows, duration, selectedBlockIds, alignmentTick } =
     useClockStore();
     useClockStore();
   const { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick } = useTimelineDrag();
   const { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick } = useTimelineDrag();
 
 
   const laneRef = useRef<HTMLDivElement>(null);
   const laneRef = useRef<HTMLDivElement>(null);
+  const [autofillType, setAutofillType] = useState("chest_to_chest");
+
   const row = rows[rowId];
   const row = rows[rowId];
   if (!row) return null;
   if (!row) return null;
 
 
@@ -65,7 +69,7 @@ export default function TimelineRow({ rowId }: { rowId: string }) {
       rowId,
       rowId,
       presetId: preset.id,
       presetId: preset.id,
       start: tick,
       start: tick,
-      duration: preset.ticks + 1,
+      duration: preset.ticks,
       count: 1,
       count: 1,
     };
     };
 
 
@@ -76,17 +80,26 @@ export default function TimelineRow({ rowId }: { rowId: string }) {
     if (!signal) return;
     if (!signal) return;
     updateRow(rowId, { signals: [...row.signals, signal] });
     updateRow(rowId, { signals: [...row.signals, signal] });
   };
   };
+
   const updateRowSignal = (index: number, signal: Signal | null) => {
   const updateRowSignal = (index: number, signal: Signal | null) => {
     if (!signal) return;
     if (!signal) return;
     const next = [...row.signals];
     const next = [...row.signals];
     next[index] = signal;
     next[index] = signal;
     updateRow(rowId, { signals: next });
     updateRow(rowId, { signals: next });
   };
   };
+
   const removeRowSignal = (index: number) => {
   const removeRowSignal = (index: number) => {
     if (!row || row.signals.length <= 1) return;
     if (!row || row.signals.length <= 1) return;
     updateRow(rowId, { signals: row.signals.filter((_, i) => i !== index) });
     updateRow(rowId, { signals: row.signals.filter((_, i) => i !== index) });
   };
   };
 
 
+  const reorderRowSignals = (sourceIdx: number, targetIdx: number) => {
+    const nextSignals = [...row.signals];
+    const [movedSignal] = nextSignals.splice(sourceIdx, 1);
+    nextSignals.splice(targetIdx, 0, movedSignal);
+    updateRow(rowId, { signals: nextSignals });
+  };
+
   const autoFillRow = (presetId: string) => {
   const autoFillRow = (presetId: string) => {
     if (!row) return;
     if (!row) return;
     const preset = getPreset(presetId);
     const preset = getPreset(presetId);
@@ -106,58 +119,54 @@ export default function TimelineRow({ rowId }: { rowId: string }) {
   return (
   return (
     <div className={styles.rowBlockWrap}>
     <div className={styles.rowBlockWrap}>
       <div className={styles.rowHeader}>
       <div className={styles.rowHeader}>
-        <input
-          className={styles.rowNameInput}
-          value={row.name}
-          onChange={(e) => updateRow(row.id, { name: e.target.value })}
-        />
-        <div className={styles.signalList}>
-          {row.signals.map((s, i) => (
-            <div key={`${s.name}-${i}`} className={styles.signalChip}>
-              <SelectSignal value={s.name} onSelectSignal={(_, sig) => updateRowSignal(i, sig)} />
-              {row.signals.length > 1 && (
-                <button className={styles.signalRemove} onClick={() => removeRowSignal(i)}>
-                  ✕
-                </button>
-              )}
-            </div>
-          ))}
-          <SelectSignal
-            className={styles.signalEmpty}
-            key={row.signals.length}
-            onSelectSignal={(_, sig) => addRowSignal(sig)}
+        <div className="field horizontal">
+          <input
+            type="text"
+            className={styles.rowNameInput}
+            value={row.name}
+            onChange={(e) => updateRow(row.id, { name: e.target.value })}
           />
           />
         </div>
         </div>
-        <div className={styles.rowStats}>
-          <label>Stack</label>
+        <SignalEditorList
+          placeholder="Add Signal"
+          signals={row.signals}
+          onAdd={addRowSignal}
+          onUpdate={updateRowSignal}
+          onRemove={removeRowSignal}
+          onReorder={reorderRowSignals}
+        />
+        <div className="field horizontal">
+          <label>Stack size</label>
           <ExpressionInput value={row.stackSize} min={1} onCommit={(v) => updateRow(rowId, { stackSize: v })} />
           <ExpressionInput value={row.stackSize} min={1} onCommit={(v) => updateRow(rowId, { stackSize: v })} />
+        </div>
+        <div className="field horizontal">
           <label>Inserters</label>
           <label>Inserters</label>
           <ExpressionInput value={row.inserterCount} min={1} onCommit={(v) => updateRow(rowId, { inserterCount: v })} />
           <ExpressionInput value={row.inserterCount} min={1} onCommit={(v) => updateRow(rowId, { inserterCount: v })} />
         </div>
         </div>
-        <select className={styles.autoFillPreset} defaultValue="chest_to_belt" id={`autofill-preset-${row.id}`}>
-          {ACTION_PRESETS.filter((p) => p.id !== "custom").map((p) => (
-            <option key={p.id} value={p.id}>
-              {p.label}
-            </option>
-          ))}
-        </select>
+        <div className="field horizontal">
+          <Select
+            style={{ minWidth: 120 }}
+            value={autofillType}
+            onChange={setAutofillType}
+            options={ACTION_PRESETS.filter((p) => p.id !== "custom").map((p) => ({ ...p }))}
+          />
+        </div>
         <button
         <button
           className="button-green"
           className="button-green"
           onClick={() => {
           onClick={() => {
-            const select = document.getElementById(`autofill-preset-${row.id}`) as HTMLSelectElement | null;
-            autoFillRow(select?.value ?? "chest_to_belt");
+            autoFillRow(autofillType ?? "chest_to_belt");
           }}
           }}
         >
         >
           Auto-fill ({row.inserterCount})
           Auto-fill ({row.inserterCount})
         </button>
         </button>
-        <button className={styles.removeBtn} onClick={() => removeRow(row.id)}>
+        <button className={"button " + styles.removeBtn} onClick={() => removeRow(row.id)}>
         </button>
         </button>
       </div>
       </div>
 
 
       <div
       <div
         className={styles.lane}
         className={styles.lane}
-        style={{ height: laneAreaHeight }}
+        style={{ height: laneAreaHeight, backgroundSize: `${((step ?? 0) / duration) * 100}% 100%` }}
         ref={laneRef}
         ref={laneRef}
         onDragOver={(e) => e.preventDefault()}
         onDragOver={(e) => e.preventDefault()}
         onDrop={handleDrop}
         onDrop={handleDrop}
@@ -183,8 +192,7 @@ export default function TimelineRow({ rowId }: { rowId: string }) {
               onPointerDown={(e) => onPointerDownBlock(e, block, "move", laneRef.current)}
               onPointerDown={(e) => onPointerDownBlock(e, block, "move", laneRef.current)}
             >
             >
               <div className={styles.blockIcons}>
               <div className={styles.blockIcons}>
-                {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={18} />}
-                {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={18} />}
+                <Preset preset={preset} size={16} arrow=" " />
               </div>
               </div>
               <div
               <div
                 className={styles.resizeHandle}
                 className={styles.resizeHandle}

+ 1 - 63
src/assets/types.ts → src/assets/ClockTimeline/model.ts

@@ -26,7 +26,7 @@ export type Signal = {
   subgroup: string;
   subgroup: string;
   icon: string;
   icon: string;
   order: string;
   order: string;
-  quality?: string;
+  quality?: number;
 };
 };
 
 
 /** A single activation Clock, placed freely on the timeline. */
 /** A single activation Clock, placed freely on the timeline. */
@@ -82,65 +82,3 @@ export function blockDescription(block: ClockBlock, index: number): string {
         : "";
         : "";
   return `Activation ${index + 1}${itemLine}`;
   return `Activation ${index + 1}${itemLine}`;
 }
 }
-
-type Entity = {
-  entity_number: number;
-  name: string;
-  position: {
-    x: number;
-    y: number;
-  };
-  direction: 1 | 2 | 3 | 4;
-};
-
-export type BlueprintSignal = {
-  type?: string;
-  name: string;
-  quality?: string;
-};
-type NetworkState = {
-  red: boolean;
-  green: boolean;
-};
-export type DeciderCondition = {
-  first_signal: BlueprintSignal;
-  comparator: "<" | ">" | "=" | "≥" | "≤" | "≠";
-  compare_type?: "and" | "or";
-  first_signal_networks?: NetworkState;
-} & ({ second_signal: BlueprintSignal; second_signal_networks?: NetworkState } | { constant?: number });
-
-type DeciderOutput = {
-  signal: BlueprintSignal;
-} & ({ copy_count_from_input: false; constant?: number } | { networks?: { red: boolean; green: boolean } });
-
-export type DeciderCombinator = Entity & {
-  name: "decider-combinator";
-  control_behavior: {
-    decider_conditions: {
-      conditions?: DeciderCondition[];
-      outputs?: DeciderOutput[];
-      else_outputs?: DeciderOutput[];
-    };
-  };
-  player_description: string;
-};
-export type Blueprint = {
-  blueprint: {
-    item: "blueprint";
-    version: 562954249109505;
-    icons: Array<{ signal: BlueprintSignal; index: 1 | 2 | 3 | 4 }>;
-    entities: [];
-    /**Each wires is represented by 4 number
-     * [a, b, c, d]
-     * a : entity_number of first entity
-     * b : wire connector (1-4) of first entity
-     * a : entity_number of second entity
-     * b : wire connector (1-4) of second entity
-     * Most entity have only 2 connector (red and green circuit)
-     * Decider Combinator, Arithmetic Combinator and Selector Combinator have 4 connectors (2 inputs, 2 outputs)
-     * 1 & 3 red
-     * 2 & 4 green
-     */
-    wires: Array<Array<number>>;
-  };
-};

+ 10 - 0
src/assets/Icon.module.css

@@ -1,9 +1,19 @@
 .factorio-icon {
 .factorio-icon {
   display: inline-block;
   display: inline-block;
   position: relative;
   position: relative;
+  background-clip: border-box;
+  background-origin: border-box;
+  background-repeat: repeat;
+  overflow: hidden;
 }
 }
 .factorio-icon .factorio-icon {
 .factorio-icon .factorio-icon {
   position: absolute;
   position: absolute;
   left: 0;
   left: 0;
   bottom: 0;
   bottom: 0;
 }
 }
+.factorio-icon img {
+  position: absolute;
+  max-width: none;
+  pointer-events: none;
+  user-select: none;
+}

+ 1 - 25
src/assets/SelectedBlockPanel.module.css

@@ -33,31 +33,7 @@
   gap: 18px;
   gap: 18px;
   flex-wrap: wrap;
   flex-wrap: wrap;
 }
 }
-.field {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-.field label {
-  font-size: 11px;
-  color: #999;
-}
-.field input,
-.field select {
-  background-color: #373737;
-  border: 1px solid #646464;
-  color: #ffe6c0;
-  border-radius: 4px;
-  padding: 5px 8px;
-  font-family: ui-monospace, monospace;
-  font-size: 12.5px;
-  width: 100px;
-}
-.field input:focus,
-.field select:focus {
-  outline: 2px solid #f1be64;
-  outline-offset: 1px;
-}
+
 .removeBtn {
 .removeBtn {
   margin-left: auto;
   margin-left: auto;
   background-color: #fe5a5a;
   background-color: #fe5a5a;

+ 14 - 19
src/assets/SelectedBlockPanel.tsx

@@ -1,9 +1,10 @@
 import styles from "./SelectedBlockPanel.module.css";
 import styles from "./SelectedBlockPanel.module.css";
-import { ACTION_PRESETS, getPreset } from "./types";
-import Icon from "./icon";
+import { ACTION_PRESETS, getPreset } from "./ClockTimeline/model";
 import ExpressionInput from "./components/ExpressionInpux";
 import ExpressionInput from "./components/ExpressionInpux";
 import { useClockStore } from "../store/useClockStore";
 import { useClockStore } from "../store/useClockStore";
 import { useMemo } from "react";
 import { useMemo } from "react";
+import Preset from "./components/Preset";
+import Select from "./components/Select";
 
 
 export default function SelectedBlockPanel() {
 export default function SelectedBlockPanel() {
   const { selectedBlockIds, removeBlocks, blocks, updateBlock } = useClockStore();
   const { selectedBlockIds, removeBlocks, blocks, updateBlock } = useClockStore();
@@ -32,41 +33,35 @@ export default function SelectedBlockPanel() {
   return (
   return (
     <div className={styles.wrap}>
     <div className={styles.wrap}>
       <div className={styles.preview}>
       <div className={styles.preview}>
-        {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={40} />}
-        <span className={styles.arrow}>→</span>
-        {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={40} />}
+        <Preset preset={preset} size={40} />
       </div>
       </div>
 
 
       <div className={styles.fields}>
       <div className={styles.fields}>
-        <div className={styles.field}>
+        <div className="field">
           <label>Action</label>
           <label>Action</label>
-          <select
+          <Select
+            style={{ minWidth: 120 }}
             value={block.presetId}
             value={block.presetId}
             onChange={(e) => {
             onChange={(e) => {
-              const p = getPreset(e.target.value);
+              const p = getPreset(e);
               updateBlock(blockId, { presetId: p.id, duration: p.ticks + 1 });
               updateBlock(blockId, { presetId: p.id, duration: p.ticks + 1 });
             }}
             }}
-          >
-            {ACTION_PRESETS.map((p) => (
-              <option key={p.id} value={p.id}>
-                {p.label}
-              </option>
-            ))}
-          </select>
+            options={ACTION_PRESETS.filter((p) => p.id !== "custom").map((p) => ({ ...p }))}
+          />
         </div>
         </div>
-        <div className={styles.field}>
+        <div className="field">
           <label>Start (tick)</label>
           <label>Start (tick)</label>
           <ExpressionInput value={block.start} min={0} onCommit={(v) => updateBlock(blockId, { start: v })} />
           <ExpressionInput value={block.start} min={0} onCommit={(v) => updateBlock(blockId, { start: v })} />
         </div>
         </div>
-        <div className={styles.field}>
+        <div className="field">
           <label>Duration (ticks)</label>
           <label>Duration (ticks)</label>
           <ExpressionInput value={block.duration} min={1} onCommit={(v) => updateBlock(blockId, { duration: v })} />
           <ExpressionInput value={block.duration} min={1} onCommit={(v) => updateBlock(blockId, { duration: v })} />
         </div>
         </div>
-        <div className={styles.field}>
+        <div className="field">
           <label>Count</label>
           <label>Count</label>
           <ExpressionInput value={block.count} min={1} onCommit={(v) => updateBlock(blockId, { count: v })} />
           <ExpressionInput value={block.count} min={1} onCommit={(v) => updateBlock(blockId, { count: v })} />
         </div>
         </div>
-        <div className={styles.field}>
+        <div className="field">
           <label>Repeat ×</label>
           <label>Repeat ×</label>
           <ExpressionInput value={block.repeat ?? 1} min={1} onCommit={(v) => updateBlock(blockId, { repeat: v })} />
           <ExpressionInput value={block.repeat ?? 1} min={1} onCommit={(v) => updateBlock(blockId, { repeat: v })} />
         </div>
         </div>

+ 92 - 0
src/assets/Selector/BeaconConfigurator.module.css

@@ -0,0 +1,92 @@
+.container {
+  display: flex;
+  gap: 12px;
+}
+
+/* Stacking directions */
+.vertical {
+  flex-direction: column;
+}
+
+.horizontal {
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: flex-start;
+}
+
+.groupCard {
+  display: flex;
+  gap: 16px;
+  align-items: center;
+  background-color: #242324;
+  padding: 10px 14px;
+  border-radius: 6px;
+  border: 1px solid #3a3a3a;
+  position: relative;
+  transition: border-color 0.15s;
+}
+
+.groupCard:hover {
+  border-color: #4a4a4a;
+}
+
+.iconWrapper {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: ns-resize;
+  gap: 4px;
+  padding: 0 8px;
+}
+
+.iconWrapper label {
+  font-size: 11px;
+  color: #999;
+  user-select: none;
+}
+
+.removeBtn {
+  margin-left: auto;
+  background: none;
+  border: none;
+  color: #d9614f;
+  cursor: pointer;
+  font-size: 16px;
+  padding: 4px;
+  border-radius: 4px;
+  transition:
+    background-color 0.15s,
+    color 0.15s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+}
+
+.removeBtn:hover {
+  background-color: rgba(217, 97, 79, 0.15);
+  color: #ff7864;
+}
+
+.addBtn {
+  align-self: flex-start;
+  background: transparent;
+  border: 1px dashed #646464;
+  color: #999;
+  padding: 8px 16px;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 13px;
+  font-weight: 500;
+  transition:
+    color 0.15s,
+    border-color 0.15s,
+    background-color 0.15s;
+}
+
+.addBtn:hover {
+  color: #e39827; /* Factorio Orange Accent */
+  border-color: #e39827;
+  background-color: rgba(227, 152, 39, 0.05);
+}

+ 106 - 0
src/assets/Selector/BeaconConfigurator.tsx

@@ -0,0 +1,106 @@
+import data from "../../assets/data/2.0/data.json"; // Adjust path if needed
+import ModuleSlots from "./ModuleSlots";
+import type { Beacon } from "../../../scripts/factorio-dump/process-data.models";
+import { qualityShortcut, useQualityScroller } from "../../hooks/useQualityScroller";
+import Icon from "../icon";
+import NumberInput from "../components/NumberInput";
+import styles from "./BeaconConfigurator.module.css";
+import type { BeaconGroup } from "../../engine";
+
+const beaconsData = data.beacons as Beacon[];
+
+export type BeaconGroupConf = BeaconGroup & {
+  id: string;
+};
+
+type BeaconConfiguratorProps = {
+  groups: BeaconGroupConf[];
+  onChange: (g: BeaconGroupConf[]) => void;
+  direction?: "horizontal" | "vertical";
+};
+
+function BeaconIconWrapper({
+  beacon,
+  qualityLevel,
+  onChangeQuality,
+}: {
+  beacon: Beacon;
+  qualityLevel: number;
+  onChangeQuality: (qualityLevel: number) => void;
+}) {
+  // Use the hook in controlled mode by passing qualityLevel
+  const { scrollRef, activeQuality } = useQualityScroller("normal", qualityLevel, (q) => onChangeQuality(q.level));
+
+  return (
+    <div ref={scrollRef} className={styles.iconWrapper} title={qualityShortcut + " to change beacon quality"}>
+      <label>Beacon</label>
+      <Icon iconName={beacon.icon ?? ""} size={44} qualityLevel={activeQuality.level} />
+    </div>
+  );
+}
+
+export default function BeaconConfigurator({ groups, onChange, direction = "vertical" }: BeaconConfiguratorProps) {
+  const handleAddGroup = () => {
+    const defaultBeacon = beaconsData[0];
+    onChange([
+      ...groups,
+      {
+        id: crypto.randomUUID(), // Use modern UUID instead of Date.now()
+        beacon: defaultBeacon,
+        beaconQualityLevel: 0,
+        count: 1,
+        modules: [],
+      },
+    ]);
+  };
+
+  const updateGroup = (id: string, patch: Partial<BeaconGroup>) => {
+    onChange(groups.map((g) => (g.id === id ? { ...g, ...patch } : g)));
+  };
+
+  return (
+    <div className={`${styles.container} ${direction === "horizontal" ? styles.horizontal : styles.vertical}`}>
+      {groups.map((g) => (
+        <div key={g.id} className={styles.groupCard}>
+          {/* Beacon Count */}
+          <div className="field" style={{ minWidth: "60px" }}>
+            <label style={{ fontSize: "11px", color: "#999", marginBottom: "4px" }}>Count</label>
+            <NumberInput min={1} max={50} value={g.count} onChange={(val) => updateGroup(g.id, { count: val || 1 })} />
+          </div>
+
+          {/* Beacon Type & Quality */}
+          <BeaconIconWrapper
+            beacon={g.beacon}
+            qualityLevel={g.beaconQualityLevel ?? 0} // Pass the controlled state down!
+            onChangeQuality={(q) => updateGroup(g.id, { beaconQualityLevel: q })}
+          />
+
+          {/* Beacon Modules */}
+          <div className="field">
+            <label style={{ fontSize: "11px", color: "#999", marginBottom: "4px" }}>
+              Modules ({g.beacon.module_slots})
+            </label>
+            <ModuleSlots
+              value={g.modules} // Pass the controlled state down!
+              maxSlots={g.beacon.module_slots}
+              allowedEffects={g.beacon.allowed_effects as string[]}
+              onChange={(mods) => updateGroup(g.id, { modules: mods })}
+            />
+          </div>
+
+          <button
+            className={styles.removeBtn}
+            onClick={() => onChange(groups.filter((x) => x.id !== g.id))}
+            title="Remove Beacon Group"
+          >
+            ✕
+          </button>
+        </div>
+      ))}
+
+      <button onClick={handleAddGroup} className={styles.addBtn}>
+        + Add Beacons
+      </button>
+    </div>
+  );
+}

+ 62 - 4
src/assets/Selector/MachineSelector.module.css

@@ -1,4 +1,62 @@
-.machine-spec-recipe{
-    border: 1px solid black;
-    color: #000;
-}
+.machine-spec-recipe {
+  border: 1px solid black;
+  color: #000;
+}
+.trigger {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 44px;
+  min-width: 44px;
+  padding: 0 0px;
+  background-color: #8e8e8e;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  box-shadow:
+    inset 8px 0px 4px -8px #000,
+    inset -8px 0px 4px -8px #000,
+    inset 0px 10px 2px -8px #e3e3e3,
+    inset 0px 10px 2px -8px #282828,
+    inset 0px -9px 2px -8px #000,
+    0px 0px 4px 0px #000;
+}
+.trigger:hover {
+  box-shadow:
+    inset 8px 0px 4px -8px #000,
+    inset -8px 0px 4px -8px #000,
+    inset 0px 9px 2px -8px #fff,
+    inset 0px 8px 4px -8px #000,
+    inset 0px -8px 4px -8px #000,
+    inset 0px -9px 2px -8px #432400,
+    0px 0px 4px 0px #000,
+    inset 0px 0px 4px 2px #f9b44b;
+  background-color: #e39827;
+}
+.trigger.open {
+  box-shadow:
+    inset 0px 10px 2px -8px #000,
+    inset 0px 9px 2px -8px #000,
+    inset 8px 0px 4px -8px #563a10,
+    inset 8px 0px 4px -8px #563a10,
+    inset -8px 0px 4px -8px #563a10,
+    inset -8px 0px 4px -8px #563a10,
+    inset 0px 9px 2px -8px #563a10,
+    inset 0px -9px 2px -8px #563a10,
+    inset 0px -8.5px 0px -8px #563a10,
+    0px 0px 4px 0px #000;
+  background-color: #f1be64;
+}
+.placeholder {
+  color: #2a2a2a;
+  white-space: nowrap;
+  padding: 0px 10px;
+}
+.machine {
+  display: inline-flex;
+  gap: 5px;
+  align-items: center;
+}
+.bigSelect {
+  height: 44px;
+}

+ 98 - 80
src/assets/Selector/MachineSelector.tsx

@@ -2,35 +2,53 @@ import React, { useCallback, useEffect, useMemo, useState, type CSSProperties }
 import data from "../../assets/data/2.0/data.json";
 import data from "../../assets/data/2.0/data.json";
 import styles from "./MachineSelector.module.css";
 import styles from "./MachineSelector.module.css";
 import Icon from "../icon";
 import Icon from "../icon";
-import { Autocomplete, Box, Popper, TextField, InputAdornment } from "@mui/material";
+import { Popper } from "@mui/material";
 import SelectMenu from "./SelectFactorioMenu";
 import SelectMenu from "./SelectFactorioMenu";
 import Tooltip from "../Tooltip";
 import Tooltip from "../Tooltip";
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Machine } from "../../../scripts/factorio-dump/process-data.models";
 import type { Machine } from "../../../scripts/factorio-dump/process-data.models";
-import { useQualityScroller } from "../../hooks/useQualityScroller";
+import { qualityShortcut, useQualityScroller } from "../../hooks/useQualityScroller";
+import Select, { type SelectOption } from "../components/Select";
 
 
 const recipeList = data.recipeGroup.flatMap((r) => r.subGroup.flatMap((s) => (s.children ?? []) as Recipe[]));
 const recipeList = data.recipeGroup.flatMap((r) => r.subGroup.flatMap((s) => (s.children ?? []) as Recipe[]));
-const machines = data.machines as Machine[];
-
+const machines = (data.machines as Machine[]).map((m) => ({ ...m, label: m.name, id: m.name }));
+type MachineOption = Machine & SelectOption;
 type MachineSelectorProps = {
 type MachineSelectorProps = {
   className?: string;
   className?: string;
   style?: CSSProperties;
   style?: CSSProperties;
+  placeholder?: string;
+  value?: { machine: Machine | null; qualityLevel: number; recipe: Recipe | null };
   onChange?: (selection: { machine: Machine | null; qualityLevel: number; recipe: Recipe | null }) => void;
   onChange?: (selection: { machine: Machine | null; qualityLevel: number; recipe: Recipe | null }) => void;
 };
 };
 
 
-function MachineSelector({ style, className, onChange }: MachineSelectorProps) {
-  const [machine, setMachine] = React.useState<Machine | null>(null);
-  const [recipe, setRecipe] = useState<null | Recipe>(null);
+function RenderMachineOption({ machine, qualityLevel }: { machine: MachineOption; qualityLevel?: number }) {
+  return (
+    <div className={styles.machine}>
+      <Icon iconName={machine?.icon ?? ""} size={34} qualityLevel={qualityLevel}></Icon>
+      <span>{machine?.label}</span>
+    </div>
+  );
+}
+function MachineSelector({ style, className, placeholder, value, onChange }: MachineSelectorProps) {
+  const isControlled = value !== undefined;
+  const [internalMachine, setInternalMachine] = useState<MachineOption | null>(null);
+  const [internalRecipe, setInternalRecipe] = useState<null | Recipe>(null);
+  const [recipeQuality, setRecipeQuality] = useState(0);
   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
   const [showMenuRecipe, setShowMenuRecipe] = useState(false);
   const [showMenuRecipe, setShowMenuRecipe] = useState(false);
+  const currentQualityLevel = isControlled ? value.qualityLevel : undefined;
 
 
-  const { scrollRef, activeQuality, setQualityName } = useQualityScroller("normal");
+  const { scrollRef, activeQuality } = useQualityScroller(
+    "normal", // initial (fallback)
+    currentQualityLevel, // controlled state!
+    (newQuality) => {
+      onChange?.({ machine, qualityLevel: newQuality.level, recipe });
+    },
+  );
+  const qualityLevel = activeQuality.level;
 
 
-  useEffect(() => {
-    if (onChange) {
-      onChange({ machine, qualityLevel: activeQuality.level, recipe });
-    }
-  }, [machine, activeQuality, recipe]);
+  const machine = isControlled ? (value.machine as MachineOption | null) : internalMachine;
+  const recipe = isControlled ? value.recipe : internalRecipe;
 
 
   const machineOptions = useMemo(() => {
   const machineOptions = useMemo(() => {
     if (recipe == null) return [];
     if (recipe == null) return [];
@@ -38,11 +56,6 @@ function MachineSelector({ style, className, onChange }: MachineSelectorProps) {
     return machines.filter((m) => m.crafting_categories.some((c) => categories.includes(c)));
     return machines.filter((m) => m.crafting_categories.some((c) => categories.includes(c)));
   }, [recipe]);
   }, [recipe]);
 
 
-  useEffect(() => {
-    if (machineOptions.length === 1) setMachine(machineOptions[0]);
-    if (machine && !machineOptions.includes(machine)) setMachine(null);
-  }, [machineOptions, machine]);
-
   const onOpenRecipe = useCallback(
   const onOpenRecipe = useCallback(
     (event: React.MouseEvent<HTMLElement>) => {
     (event: React.MouseEvent<HTMLElement>) => {
       setAnchorEl(anchorEl ? null : event.currentTarget);
       setAnchorEl(anchorEl ? null : event.currentTarget);
@@ -51,73 +64,78 @@ function MachineSelector({ style, className, onChange }: MachineSelectorProps) {
     [anchorEl, showMenuRecipe],
     [anchorEl, showMenuRecipe],
   );
   );
 
 
-  const onSelectRecipe = useCallback((name: string) => {
-    setRecipe(recipeList.find((r) => r.name === name) ?? null);
-    setAnchorEl(null);
-    setShowMenuRecipe(false);
-  }, []);
+  const onSelectRecipe = useCallback(
+    (name: string, qLevel: number) => {
+      const newRecipe = recipeList.find((r) => r.name === name) ?? null;
+      setRecipeQuality(qLevel);
+
+      let newMachine = machine;
+
+      // Auto-select machine logic (moved OUT of useEffect to prevent loops!)
+      if (newRecipe) {
+        const categories = newRecipe.categories ?? [];
+        const validMachines = machines.filter((m) => m.crafting_categories.some((c) => categories.includes(c)));
+
+        if (validMachines.length === 1) {
+          newMachine = validMachines[0] as MachineOption;
+        } else if (newMachine && !validMachines.some((m) => m.id === newMachine!.id)) {
+          newMachine = null; // Clear if currently selected machine can't craft this
+        }
+      }
+
+      if (!isControlled) {
+        setInternalRecipe(newRecipe);
+        setInternalMachine(newMachine);
+      }
+
+      onChange?.({ machine: newMachine, qualityLevel, recipe: newRecipe });
+      setAnchorEl(null);
+      setShowMenuRecipe(false);
+    },
+    [machine, isControlled, onChange, qualityLevel],
+  );
+
+  const onSelectMachine = useCallback(
+    (newValue: string) => {
+      const newMachine = (machines.find((o) => o.id === newValue) as MachineOption) ?? null;
+      if (!isControlled) {
+        setInternalMachine(newMachine);
+      }
+      onChange?.({ machine: newMachine, qualityLevel, recipe });
+    },
+    [isControlled, onChange, qualityLevel, recipe],
+  );
 
 
   return (
   return (
     <div className={className} style={{ ...style, display: "flex", gap: "12px", alignItems: "center" }}>
     <div className={className} style={{ ...style, display: "flex", gap: "12px", alignItems: "center" }}>
       {/* Recipe Trigger */}
       {/* Recipe Trigger */}
-      <div onClick={onOpenRecipe} className={styles.machineSpecRecipe} style={{ cursor: "pointer" }}>
-        {recipe == null ? (
-          <div
-            style={{
-              padding: "8px 12px",
-              border: "1px dashed #646464",
-              borderRadius: "4px",
-            }}
-          >
-            Select recipe
-          </div>
-        ) : (
-          <Tooltip title={recipe.name}>
-            <div>
-              <Icon iconName={recipe.icon ?? ""} size={40} />
-            </div>
-          </Tooltip>
-        )}
+      <div className="field">
+        <label>Recipe</label>
+        <button className={`button ${className ?? ""} ${styles.trigger}`} onClick={onOpenRecipe}>
+          {recipe == null ? (
+            <span className={styles.placeholder}>{placeholder ?? "Select Recipe"}</span>
+          ) : (
+            <Tooltip title={recipe.name}>
+              <Icon iconName={recipe.icon ?? ""} size={34} qualityLevel={recipeQuality} />
+            </Tooltip>
+          )}
+        </button>
+      </div>
+      <div ref={scrollRef} className="field" style={{ minWidth: 250 }}>
+        <label>Machine ({qualityShortcut} for changing quality)</label>
+        <Select
+          className={styles.bigSelect}
+          value={machine ? machine.id : ""}
+          onChange={onSelectMachine}
+          options={machineOptions}
+          renderValue={(machine) =>
+            machine && <RenderMachineOption machine={machine} qualityLevel={activeQuality.level ?? 0} />
+          }
+          renderOption={(machine: MachineOption) => {
+            return <RenderMachineOption machine={machine} />;
+          }}
+        />
       </div>
       </div>
-
-      <Autocomplete
-        ref={scrollRef}
-        value={machine}
-        onChange={(_: any, newValue: Machine | null) => {
-          setMachine(newValue);
-        }}
-        disablePortal
-        options={machineOptions}
-        sx={{ width: 300 }}
-        getOptionLabel={(option) => option.name}
-        disabled={!recipe}
-        renderInput={(params: any) => (
-          <TextField
-            {...params}
-            label={recipe ? "Machine (Shift+Scroll)" : "Select recipe first"}
-            InputProps={{
-              ...params.InputProps,
-              startAdornment: machine ? (
-                <InputAdornment position="start">
-                  {/* Just attach the scrollRef here! */}
-                  <div title="Shift + Scroll to change quality" style={{ cursor: "ns-resize", display: "flex" }}>
-                    <Icon iconName={machine.icon ?? ""} size={28} qualityLevel={activeQuality.level} />
-                  </div>
-                </InputAdornment>
-              ) : null,
-            }}
-          />
-        )}
-        renderOption={(props: any, option: Machine) => {
-          const { key, ...optionProps } = props;
-          return (
-            <Box key={key} component="li" {...optionProps} sx={{ gap: 2 }}>
-              <Icon iconName={option.icon ?? ""} size={30} qualityLevel={activeQuality.level} />
-              {option.name}
-            </Box>
-          );
-        }}
-      />
 
 
       <Popper open={showMenuRecipe} anchorEl={anchorEl} placement="bottom-start" style={{ zIndex: 1300 }}>
       <Popper open={showMenuRecipe} anchorEl={anchorEl} placement="bottom-start" style={{ zIndex: 1300 }}>
         <SelectMenu
         <SelectMenu

+ 0 - 0
src/assets/components/ModuleSlots.module.css → src/assets/Selector/ModuleSlots.module.css


+ 50 - 36
src/assets/components/ModuleSlots.tsx → src/assets/Selector/ModuleSlots.tsx

@@ -1,32 +1,34 @@
-import { useState, useEffect, useMemo } from "react";
+import { useState, useMemo } from "react";
 import { Popper } from "@mui/material";
 import { Popper } from "@mui/material";
 import styles from "./ModuleSlots.module.css";
 import styles from "./ModuleSlots.module.css";
 import data from "../../assets/data/2.0/data.json";
 import data from "../../assets/data/2.0/data.json";
 import type { Module } from "../../../scripts/factorio-dump/process-data.models";
 import type { Module } from "../../../scripts/factorio-dump/process-data.models";
-import type { Category, MenuItem, SubGroup } from "../Selector/SelectFactorioMenu";
-import { orderedQualities, useQualityScroller } from "../../hooks/useQualityScroller";
+import type { Category, MenuItem, SubGroup } from "./SelectFactorioMenu";
+import { useQualityScroller, type Quality } from "../../hooks/useQualityScroller";
 import Icon from "../icon";
 import Icon from "../icon";
-import SelectMenu from "../Selector/SelectFactorioMenu";
+import SelectMenu from "./SelectFactorioMenu";
 
 
 // Build category structure
 // Build category structure
 const prod = data.signalGroup.find((g) => g.name === "production") as Category<MenuItem>;
 const prod = data.signalGroup.find((g) => g.name === "production") as Category<MenuItem>;
 const moduleSubGroup = prod?.subGroup.find((s) => s.name === "module") as SubGroup<MenuItem>;
 const moduleSubGroup = prod?.subGroup.find((s) => s.name === "module") as SubGroup<MenuItem>;
 
 
 type ModuleSlotsProps = {
 type ModuleSlotsProps = {
+  value?: { module: Module; qualityLevel: number }[]; // Make it controllable!
   maxSlots: number;
   maxSlots: number;
   allowedEffects?: string[];
   allowedEffects?: string[];
   onChange: (modules: { module: Module; qualityLevel: number }[]) => void;
   onChange: (modules: { module: Module; qualityLevel: number }[]) => void;
 };
 };
 
 
-function OccupiedSlot({ module, initialQualityLevel, onChangeQuality }: any) {
-  const initialQualityName = orderedQualities.find((q) => q.level === initialQualityLevel)?.name || "normal";
+function OccupiedSlot({ module, qualityLevel, onChangeQuality }: any) {
+  // Use the hook in controlled mode by passing the qualityLevel directly
   const { scrollRef, activeQuality } = useQualityScroller(
   const { scrollRef, activeQuality } = useQualityScroller(
-    initialQualityName,
-    (newQuality) => {
+    "normal", // fallback
+    qualityLevel, // controlled level
+    (newQuality: Quality) => {
       onChangeQuality(newQuality.level);
       onChangeQuality(newQuality.level);
     },
     },
-    "factorio-module-quality",
-  ); // Persist module quality separately!
+    // Deliberately omitted persistKey so individual slots don't overwrite each other in localStorage!
+  );
 
 
   return (
   return (
     <div ref={scrollRef} className={styles.occupiedSlot}>
     <div ref={scrollRef} className={styles.occupiedSlot}>
@@ -35,18 +37,24 @@ function OccupiedSlot({ module, initialQualityLevel, onChangeQuality }: any) {
   );
   );
 }
 }
 
 
-export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: ModuleSlotsProps) {
-  const [slots, setSlots] = useState<Array<{ module: Module; qualityLevel: number } | null>>([]);
+export default function ModuleSlots({ value, maxSlots, allowedEffects, onChange }: ModuleSlotsProps) {
+  const isControlled = value !== undefined;
+
+  const [internalSlots, setInternalSlots] = useState<Array<{ module: Module; qualityLevel: number } | null>>([]);
   const [activeSlot, setActiveSlot] = useState<number | null>(null);
   const [activeSlot, setActiveSlot] = useState<number | null>(null);
   const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
   const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
 
 
-  useEffect(() => {
-    setSlots((prev) => {
-      const newSlots = Array(maxSlots).fill(null);
-      for (let i = 0; i < Math.min(prev.length, maxSlots); i++) newSlots[i] = prev[i];
-      return newSlots;
-    });
-  }, [maxSlots]);
+  // Dynamically derive the fixed-length padded array for rendering
+  const effectiveSlots = useMemo(() => {
+    const baseList = isControlled ? value || [] : internalSlots;
+    const padded = Array(maxSlots).fill(null);
+
+    // Pack the slots left-to-right up to maxSlots
+    for (let i = 0; i < Math.min(baseList.length, maxSlots); i++) {
+      padded[i] = baseList[i] || null;
+    }
+    return padded;
+  }, [isControlled, value, internalSlots, maxSlots]);
 
 
   const moduleCategories = useMemo(() => {
   const moduleCategories = useMemo(() => {
     let validModules = data.modules as Module[];
     let validModules = data.modules as Module[];
@@ -62,23 +70,31 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
     return [{ ...prod, subGroup: [{ ...moduleSubGroup, children: validModules as unknown as MenuItem[] }] }];
     return [{ ...prod, subGroup: [{ ...moduleSubGroup, children: validModules as unknown as MenuItem[] }] }];
   }, [allowedEffects]);
   }, [allowedEffects]);
 
 
+  const commitChanges = (newSlots: Array<{ module: Module; qualityLevel: number } | null>) => {
+    if (!isControlled) {
+      setInternalSlots(newSlots);
+    }
+    // Always pack the array (remove nulls) when sending it up to the parent
+    onChange(newSlots.filter((s) => s !== null) as any);
+  };
+
   const handleSelectModule = (moduleName: string, qualityLevel: number) => {
   const handleSelectModule = (moduleName: string, qualityLevel: number) => {
     const selectedModule = data.modules.find((m: any) => m.name === moduleName) as Module;
     const selectedModule = data.modules.find((m: any) => m.name === moduleName) as Module;
     if (!selectedModule || activeSlot === null) return;
     if (!selectedModule || activeSlot === null) return;
 
 
-    const newSlots = [...slots];
+    const newSlots = [...effectiveSlots];
     newSlots[activeSlot] = { module: selectedModule, qualityLevel };
     newSlots[activeSlot] = { module: selectedModule, qualityLevel };
-    setSlots(newSlots);
+
+    commitChanges(newSlots);
     setActiveSlot(null);
     setActiveSlot(null);
-    onChange(newSlots.filter((s) => s !== null) as any);
   };
   };
 
 
   const handleFillAll = () => {
   const handleFillAll = () => {
-    const template = slots.find((s) => s !== null);
+    const template = effectiveSlots.find((s) => s !== null);
     if (!template) return;
     if (!template) return;
-    const newSlots = slots.map((s) => s || template);
-    setSlots(newSlots);
-    onChange(newSlots as any);
+
+    const newSlots = effectiveSlots.map((s) => s || template);
+    commitChanges(newSlots);
   };
   };
 
 
   if (maxSlots === 0) return null;
   if (maxSlots === 0) return null;
@@ -86,7 +102,7 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
   return (
   return (
     <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
     <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
       <div className={styles.slotsContainer}>
       <div className={styles.slotsContainer}>
-        {slots.map((slot, i) => (
+        {effectiveSlots.map((slot, i) => (
           <div
           <div
             key={i}
             key={i}
             className={styles.slot}
             className={styles.slot}
@@ -96,21 +112,19 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
             }}
             }}
             onContextMenu={(e) => {
             onContextMenu={(e) => {
               e.preventDefault();
               e.preventDefault();
-              const n = [...slots];
+              const n = [...effectiveSlots];
               n[i] = null;
               n[i] = null;
-              setSlots(n);
-              onChange(n.filter((s) => s) as any);
+              commitChanges(n);
             }}
             }}
           >
           >
             {slot ? (
             {slot ? (
               <OccupiedSlot
               <OccupiedSlot
                 module={slot.module}
                 module={slot.module}
-                initialQualityLevel={slot.qualityLevel}
+                qualityLevel={slot.qualityLevel}
                 onChangeQuality={(lvl: number) => {
                 onChangeQuality={(lvl: number) => {
-                  const n = [...slots];
-                  n[i]!.qualityLevel = lvl;
-                  setSlots(n);
-                  onChange(n.filter((s) => s) as any);
+                  const n = [...effectiveSlots];
+                  n[i] = { ...n[i]!, qualityLevel: lvl };
+                  commitChanges(n);
                 }}
                 }}
               />
               />
             ) : (
             ) : (
@@ -120,7 +134,7 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
         ))}
         ))}
       </div>
       </div>
 
 
-      {slots.some((s) => s !== null) && slots.some((s) => s === null) && (
+      {effectiveSlots.some((s) => s !== null) && effectiveSlots.some((s) => s === null) && (
         <button onClick={handleFillAll} className={styles.fillBtn} title="Fill empty slots with the first module">
         <button onClick={handleFillAll} className={styles.fillBtn} title="Fill empty slots with the first module">
           Fill All
           Fill All
         </button>
         </button>

+ 5 - 2
src/assets/Selector/SelectFactorioMenu.module.css

@@ -4,8 +4,11 @@
   flex-direction: column;
   flex-direction: column;
   gap: 8px;
   gap: 8px;
   padding: 8px;
   padding: 8px;
-  background: #646464;
-  background-color: #313031;
+  background: #313031;
+  box-shadow:
+    0px 0px 1px 1px rgba(0, 0, 0, 0.2),
+    0px 1px 1px 0px rgba(0, 0, 0, 0.5),
+    0px 1px 3px 0px rgba(0, 0, 0, 0.5);
 }
 }
 
 
 .select-menu-header {
 .select-menu-header {

+ 18 - 5
src/assets/Selector/SelectFactorioMenu.tsx

@@ -27,6 +27,7 @@ export type SubGroup<T> = {
 export type MenuItem = { name: string; icon: string };
 export type MenuItem = { name: string; icon: string };
 
 
 interface SubGroupProps {
 interface SubGroupProps {
+  activeQualityLevel?: number;
   subgroup: SubGroup<MenuItem>;
   subgroup: SubGroup<MenuItem>;
   selectedItem: string;
   selectedItem: string;
   onSelectItem: (name: string) => void;
   onSelectItem: (name: string) => void;
@@ -71,7 +72,7 @@ function filterCategory(category: Category<MenuItem>, search: string): Category<
  * @component
  * @component
  * @param {SubGroupProps} props
  * @param {SubGroupProps} props
  */
  */
-function SubGroupRow({ subgroup, selectedItem, onSelectItem }: SubGroupProps) {
+function SubGroupRow({ subgroup, activeQualityLevel, selectedItem, onSelectItem }: SubGroupProps) {
   const emptySlotsCounts = useMemo(() => {
   const emptySlotsCounts = useMemo(() => {
     const lastRowSlots = subgroup.children.length % ITEM_PER_ROW;
     const lastRowSlots = subgroup.children.length % ITEM_PER_ROW;
     return lastRowSlots > 0 ? ITEM_PER_ROW - lastRowSlots : 0;
     return lastRowSlots > 0 ? ITEM_PER_ROW - lastRowSlots : 0;
@@ -84,7 +85,7 @@ function SubGroupRow({ subgroup, selectedItem, onSelectItem }: SubGroupProps) {
             className={`${styles.selectMenuIcon} ${o.name === selectedItem ? styles.active : ""}`}
             className={`${styles.selectMenuIcon} ${o.name === selectedItem ? styles.active : ""}`}
             onClick={() => onSelectItem(o.name)}
             onClick={() => onSelectItem(o.name)}
           >
           >
-            <Icon iconName={o.icon} size={30} />
+            <Icon iconName={o.icon} size={30} qualityLevel={activeQualityLevel} />
           </div>
           </div>
         </Tooltip>
         </Tooltip>
       ))}
       ))}
@@ -104,6 +105,7 @@ type SelectMenuProps = {
   style?: CSSProperties;
   style?: CSSProperties;
   categories: Category<MenuItem>[];
   categories: Category<MenuItem>[];
   title?: string;
   title?: string;
+  showQuality?: boolean;
   onClose?: () => void;
   onClose?: () => void;
   onSelectItem?: (itemName: string, qualityLevel: number) => void;
   onSelectItem?: (itemName: string, qualityLevel: number) => void;
 };
 };
@@ -121,12 +123,17 @@ type SelectMenuProps = {
  * @component
  * @component
  * @param {SelectMenuProps} props
  * @param {SelectMenuProps} props
  */
  */
-function SelectMenu({ style, className, title, categories, onClose, onSelectItem }: SelectMenuProps) {
+function SelectMenu({ style, className, title, categories, showQuality, onClose, onSelectItem }: SelectMenuProps) {
   const [category, setCategory] = useState(categories[0].name);
   const [category, setCategory] = useState(categories[0].name);
   const [item, selectItem] = useState("");
   const [item, selectItem] = useState("");
   const [showSearch, setShowSearch] = useState(false);
   const [showSearch, setShowSearch] = useState(false);
   const [search, setSearch] = useState("");
   const [search, setSearch] = useState("");
-  const { scrollRef, activeQuality, setQualityName } = useQualityScroller("normal", undefined, "factorio-menu-quality");
+  const { scrollRef, activeQuality, setQualityName } = useQualityScroller(
+    "normal",
+    undefined, // uncontrolled while the menu is open so the user can scroll!
+    undefined, // no onChange needed here
+    "factorio-menu-quality", // persistKey
+  );
 
 
   const activeCategory = useMemo(() => categories.find((r) => r.name == category), [category, categories]);
   const activeCategory = useMemo(() => categories.find((r) => r.name == category), [category, categories]);
   const handleItemSelect = (name: string) => {
   const handleItemSelect = (name: string) => {
@@ -173,7 +180,13 @@ function SelectMenu({ style, className, title, categories, onClose, onSelectItem
       <div className={styles.selectMenuContent}>
       <div className={styles.selectMenuContent}>
         <SimpleBar style={{ maxHeight: 300 }}>
         <SimpleBar style={{ maxHeight: 300 }}>
           {filteredContent?.subGroup.map((subgroup) => (
           {filteredContent?.subGroup.map((subgroup) => (
-            <SubGroupRow key={subgroup.name} subgroup={subgroup} selectedItem={item} onSelectItem={handleItemSelect} />
+            <SubGroupRow
+              key={subgroup.name}
+              subgroup={subgroup}
+              selectedItem={item}
+              onSelectItem={handleItemSelect}
+              activeQualityLevel={showQuality ? activeQuality.level : 0}
+            />
           ))}
           ))}
         </SimpleBar>
         </SimpleBar>
       </div>
       </div>

+ 0 - 1
src/assets/Selector/SelectSignal.module.css

@@ -9,7 +9,6 @@
   border: none;
   border: none;
   border-radius: 4px;
   border-radius: 4px;
   cursor: pointer;
   cursor: pointer;
-  color: #000;
   box-shadow:
   box-shadow:
     inset 8px 0px 4px -8px #000,
     inset 8px 0px 4px -8px #000,
     inset -8px 0px 4px -8px #000,
     inset -8px 0px 4px -8px #000,

+ 14 - 8
src/assets/Selector/SelectSignal.tsx

@@ -5,7 +5,7 @@ import Icon from "../icon";
 import { Popper } from "@mui/material";
 import { Popper } from "@mui/material";
 import SelectMenu from "./SelectFactorioMenu";
 import SelectMenu from "./SelectFactorioMenu";
 import Tooltip from "../Tooltip";
 import Tooltip from "../Tooltip";
-import type { Signal } from "../types";
+import type { Signal } from "../ClockTimeline/model";
 
 
 const signals = data.signalGroup.flatMap((r) => r.subGroup.flatMap((s) => (s.children ?? []) as Signal[]));
 const signals = data.signalGroup.flatMap((r) => r.subGroup.flatMap((s) => (s.children ?? []) as Signal[]));
 
 
@@ -32,8 +32,9 @@ function SelectSignal({ style, className, value, placeholder, onSelectSignal }:
     [anchorEl, showMenu],
     [anchorEl, showMenu],
   );
   );
   const onSelectRecipe = useCallback(
   const onSelectRecipe = useCallback(
-    (name: string) => {
-      const found = signals.find((o) => o.name === name) ?? null;
+    (name: string, qualityLevel: number) => {
+      const rawSignal = signals.find((o) => o.name === name);
+      const found = rawSignal ? { ...rawSignal, quality: qualityLevel } : null;
       onSelectSignal && onSelectSignal(name, found);
       onSelectSignal && onSelectSignal(name, found);
       setInternalSignal(found);
       setInternalSignal(found);
       setAnchorEl(null);
       setAnchorEl(null);
@@ -43,10 +44,15 @@ function SelectSignal({ style, className, value, placeholder, onSelectSignal }:
   );
   );
 
 
   return (
   return (
-    <div className={className} style={style}>
-      <button type="button" className={`${styles.trigger} ${showMenu ? styles.open : ""}`} onClick={onOpenMenu}>
+    <>
+      <button
+        style={style}
+        type="button"
+        className={`${className ?? ""} ${styles.trigger} ${showMenu ? styles.open : ""}`}
+        onClick={onOpenMenu}
+      >
         {signal == null ? (
         {signal == null ? (
-          <span className={styles.placeholder}>Select signal</span>
+          <span className={styles.placeholder}>{placeholder ?? "Select signal"}</span>
         ) : (
         ) : (
           <Tooltip title={signal.name}>
           <Tooltip title={signal.name}>
             <Icon iconName={signal.icon ?? ""} size={24} />
             <Icon iconName={signal.icon ?? ""} size={24} />
@@ -55,13 +61,13 @@ function SelectSignal({ style, className, value, placeholder, onSelectSignal }:
       </button>
       </button>
       <Popper open={showMenu} anchorEl={anchorEl} placement="right">
       <Popper open={showMenu} anchorEl={anchorEl} placement="right">
         <SelectMenu
         <SelectMenu
-          title={placeholder ?? "Select signal"}
+          title="Select signal"
           categories={data.signalGroup}
           categories={data.signalGroup}
           onSelectItem={onSelectRecipe}
           onSelectItem={onSelectRecipe}
           onClose={() => setShowMenu(false)}
           onClose={() => setShowMenu(false)}
         />
         />
       </Popper>
       </Popper>
-    </div>
+    </>
   );
   );
 }
 }
 export default SelectSignal;
 export default SelectSignal;

+ 0 - 140
src/assets/components/BeaconConfigurator.tsx

@@ -1,140 +0,0 @@
-import data from "../../assets/data/2.0/data.json";
-import ModuleSlots from "./ModuleSlots";
-import type { Beacon, Module } from "../../../scripts/factorio-dump/process-data.models";
-import { useQualityScroller } from "../../hooks/useQualityScroller";
-import Icon from "../icon";
-
-const beaconsData = data.beacons as Beacon[];
-
-export type BeaconGroup = {
-  id: string;
-  beacon: Beacon;
-  qualityLevel: number;
-  count: number;
-  modules: { module: Module; qualityLevel: number }[];
-};
-
-function BeaconIconWrapper({
-  beacon,
-  onChangeQuality,
-}: {
-  beacon: Beacon;
-  onChangeQuality: (qualityLevel: number) => void;
-}) {
-  const { scrollRef, activeQuality } = useQualityScroller("normal", (q) => onChangeQuality(q.level));
-  return (
-    <div
-      ref={scrollRef}
-      style={{ display: "flex", flexDirection: "column", alignItems: "center", cursor: "ns-resize", gap: "4px" }}
-      title="Shift+Scroll to change beacon quality"
-    >
-      <label style={{ fontSize: "11px", color: "#999" }}>Beacon</label>
-      <Icon iconName={beacon.icon ?? ""} size={44} qualityLevel={activeQuality.level} />
-    </div>
-  );
-}
-export default function BeaconConfigurator({
-  groups,
-  onChange,
-}: {
-  groups: BeaconGroup[];
-  onChange: (g: BeaconGroup[]) => void;
-}) {
-  const handleAddGroup = () => {
-    const defaultBeacon = beaconsData[0];
-    onChange([
-      ...groups,
-      {
-        id: Date.now().toString(),
-        beacon: defaultBeacon,
-        qualityLevel: 0,
-        count: 1,
-        modules: [],
-      },
-    ]);
-  };
-
-  const updateGroup = (id: string, patch: Partial<BeaconGroup>) => {
-    onChange(groups.map((g) => (g.id === id ? { ...g, ...patch } : g)));
-  };
-
-  return (
-    <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
-      {groups.map((g) => (
-        <div
-          key={g.id}
-          style={{
-            display: "flex",
-            gap: "16px",
-            alignItems: "center",
-            background: "#242324",
-            padding: "8px",
-            borderRadius: "4px",
-            border: "1px solid #3a3a3a",
-          }}
-        >
-          {/* Beacon Count */}
-          <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
-            <label style={{ fontSize: "11px", color: "#999" }}>Count</label>
-            <input
-              type="number"
-              min="1"
-              max="50"
-              value={g.count}
-              onChange={(e) => updateGroup(g.id, { count: Number(e.target.value) || 1 })}
-              style={{
-                width: "50px",
-                background: "#1a1a1a",
-                color: "#ffe6c0",
-                border: "1px solid #646464",
-                padding: "4px",
-                borderRadius: "4px",
-              }}
-            />
-          </div>
-
-          {/* Beacon Type & Quality */}
-          <BeaconIconWrapper beacon={g.beacon} onChangeQuality={(q) => updateGroup(g.id, { qualityLevel: q })} />
-
-          {/* Beacon Modules */}
-          <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
-            <label style={{ fontSize: "11px", color: "#999" }}>Modules ({g.beacon.module_slots})</label>
-            <ModuleSlots
-              maxSlots={g.beacon.module_slots}
-              allowedEffects={g.beacon.allowed_effects as string[]}
-              onChange={(mods) => updateGroup(g.id, { modules: mods })}
-            />
-          </div>
-
-          <button
-            onClick={() => onChange(groups.filter((x) => x.id !== g.id))}
-            style={{
-              marginLeft: "auto",
-              background: "none",
-              border: "none",
-              color: "#d9614f",
-              cursor: "pointer",
-              fontSize: "16px",
-            }}
-          >
-            ✕
-          </button>
-        </div>
-      ))}
-      <button
-        onClick={handleAddGroup}
-        style={{
-          alignSelf: "flex-start",
-          background: "none",
-          border: "1px dashed #646464",
-          color: "#999",
-          padding: "6px 12px",
-          borderRadius: "4px",
-          cursor: "pointer",
-        }}
-      >
-        + Add Beacons
-      </button>
-    </div>
-  );
-}

+ 13 - 9
src/assets/components/ClockWizard.tsx

@@ -1,12 +1,12 @@
 import { useMemo, useState } from "react";
 import { useMemo, useState } from "react";
-import ModuleSlots from "./ModuleSlots";
+import ModuleSlots from "../Selector/ModuleSlots";
 import styles from "./ClockWizard.module.css";
 import styles from "./ClockWizard.module.css";
 import { useClockStore } from "../../store/useClockStore";
 import { useClockStore } from "../../store/useClockStore";
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Machine, Module } from "../../../scripts/factorio-dump/process-data.models";
 import type { Machine, Module } from "../../../scripts/factorio-dump/process-data.models";
 
 
 import MachineSelector from "../Selector/MachineSelector";
 import MachineSelector from "../Selector/MachineSelector";
-import BeaconConfigurator, { type BeaconGroup } from "./BeaconConfigurator";
+import BeaconConfigurator, { type BeaconGroup } from "../Selector/BeaconConfigurator";
 import ExpressionInput from "./ExpressionInpux";
 import ExpressionInput from "./ExpressionInpux";
 import InputConfigurator from "./InputConfigurator";
 import InputConfigurator from "./InputConfigurator";
 import { calculateOptimalBatch, computeMachineStats, generateAdvancedClock, type ClockConfig } from "../../engine";
 import { calculateOptimalBatch, computeMachineStats, generateAdvancedClock, type ClockConfig } from "../../engine";
@@ -132,8 +132,7 @@ export default function ClockWizard() {
     >
     >
       {/*  Recipe & Machine */}
       {/*  Recipe & Machine */}
       <div style={{ display: "flex", gap: "20px", alignItems: "flex-start" }}>
       <div style={{ display: "flex", gap: "20px", alignItems: "flex-start" }}>
-        <div style={{ flex: 1 }}>
-          <h2 className={styles.h2}>1. Setup Machine</h2>
+        <div style={{ flex: 1, display: "flex", flexDirection: "row", alignItems: "center", gap: 8 }}>
           <MachineSelector
           <MachineSelector
             onChange={(res) => {
             onChange={(res) => {
               setMachine(res.machine);
               setMachine(res.machine);
@@ -141,6 +140,16 @@ export default function ClockWizard() {
               setRecipe(res.recipe);
               setRecipe(res.recipe);
             }}
             }}
           />
           />
+          {machine && recipe && (
+            <div className="field">
+              <label>Modules </label>
+              <ModuleSlots
+                maxSlots={machine.module_slots || 0}
+                allowedEffects={machine.allowed_effects as string[]}
+                onChange={setMachineModules}
+              />
+            </div>
+          )}
         </div>
         </div>
 
 
         {/* --- STATS PREVIEW DASHBOARD --- */}
         {/* --- STATS PREVIEW DASHBOARD --- */}
@@ -214,11 +223,6 @@ export default function ClockWizard() {
           <div style={{ display: "flex", gap: "40px" }}>
           <div style={{ display: "flex", gap: "40px" }}>
             <div style={{ flex: 1 }}>
             <div style={{ flex: 1 }}>
               <h2 className={styles.h2}>2. Machine Modules</h2>
               <h2 className={styles.h2}>2. Machine Modules</h2>
-              <ModuleSlots
-                maxSlots={machine.module_slots || 0}
-                allowedEffects={machine.allowed_effects as string[]}
-                onChange={setMachineModules}
-              />
             </div>
             </div>
             <div style={{ flex: 2 }}>
             <div style={{ flex: 2 }}>
               <h2 className={styles.h2}>3. Beacons</h2>
               <h2 className={styles.h2}>3. Beacons</h2>

+ 0 - 11
src/assets/components/ExpressionInput.module.css

@@ -1,14 +1,3 @@
 .input {
 .input {
-  background-color: #373737;
-  border: 1px solid #646464;
-  color: #ffe6c0;
-  border-radius: 4px;
-  padding: 5px 8px;
-  font-family: ui-monospace, monospace;
-  font-size: 12.5px;
   width: 100px;
   width: 100px;
 }
 }
-.input:focus {
-  outline: 2px solid #f1be64;
-  outline-offset: 1px;
-}

+ 6 - 6
src/assets/components/InputConfigurator.tsx

@@ -74,16 +74,16 @@ export default function InputConfigurator({ recipe, configs, onChange }: InputCo
         </thead>
         </thead>
         <tbody>
         <tbody>
           {solidIngredients.map((ing) => {
           {solidIngredients.map((ing) => {
-            const config = configs[ing.itemId];
+            const config = configs[ing.name];
             if (!config) return null;
             if (!config) return null;
 
 
             return (
             return (
-              <tr key={ing.itemId}>
+              <tr key={ing.name}>
                 <td>
                 <td>
                   <div className={styles.itemLabel}>
                   <div className={styles.itemLabel}>
-                    <Icon iconName={`item/${ing.itemId}.png`} size={24} />
+                    <Icon iconName={`item/${ing.name}.png`} size={24} />
                     <span>
                     <span>
-                      {ing.amount} {ing.itemId}
+                      {ing.amount} {ing.name}
                     </span>
                     </span>
                   </div>
                   </div>
                 </td>
                 </td>
@@ -91,7 +91,7 @@ export default function InputConfigurator({ recipe, configs, onChange }: InputCo
                   <input
                   <input
                     type="text"
                     type="text"
                     value={config.inserterId}
                     value={config.inserterId}
-                    onChange={(e) => updateConfig(ing.itemId, { inserterId: e.target.value })}
+                    onChange={(e) => updateConfig(ing.name, { inserterId: e.target.value })}
                     className={styles.groupInput}
                     className={styles.groupInput}
                     title="Give ingredients the same ID to put them on a mixed belt / shared inserter"
                     title="Give ingredients the same ID to put them on a mixed belt / shared inserter"
                   />
                   />
@@ -99,7 +99,7 @@ export default function InputConfigurator({ recipe, configs, onChange }: InputCo
                 <td>
                 <td>
                   <select
                   <select
                     value={config.source}
                     value={config.source}
-                    onChange={(e) => updateConfig(ing.itemId, { source: e.target.value as "chest" | "belt" })}
+                    onChange={(e) => updateConfig(ing.name, { source: e.target.value as "chest" | "belt" })}
                   >
                   >
                     <option value="chest">Chest (Fast: ~8 ticks)</option>
                     <option value="chest">Chest (Fast: ~8 ticks)</option>
                     <option value="belt">Belt (Slow: ~12 ticks)</option>
                     <option value="belt">Belt (Slow: ~12 ticks)</option>

+ 35 - 0
src/assets/components/NumberInput.module.css

@@ -0,0 +1,35 @@
+/* Wrapper ensures the total height is exactly 44px */
+.numberInputWrap {
+  display: inline-flex;
+  align-items: stretch;
+  height: 44px;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+/* The + and - buttons */
+.numBtn {
+  height: 44px;
+  min-width: 10px;
+  padding: 8px 6px;
+  margin: 0;
+}
+/* The actual text input in the middle */
+.numInput {
+  width: 48px;
+  text-align: center;
+  height: 100%;
+  font-family: inherit;
+  font-size: 110%;
+  font-weight: bold;
+}
+
+/* Hide native up/down arrows (spinners) */
+.numInput {
+  -moz-appearance: textfield;
+}
+.numInput::-webkit-outer-spin-button,
+.numInput::-webkit-inner-spin-button {
+  -webkit-appearance: none;
+  margin: 0;
+}

+ 58 - 0
src/assets/components/NumberInput.tsx

@@ -0,0 +1,58 @@
+import type { ChangeEvent } from "react";
+import styles from "./NumberInput.module.css";
+
+interface NumberInputProps {
+  value: number;
+  min?: number;
+  max?: number;
+  onChange: (val: number) => void;
+  isDark?: boolean;
+}
+
+export default function NumberInput({ value, min = 1, max = 50, onChange, isDark = true }: NumberInputProps) {
+  const handleDecrement = () => onChange(Math.max(min, value - 1));
+  const handleIncrement = () => onChange(Math.min(max, value + 1));
+
+  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    // Allow empty string briefly while typing, otherwise parse
+    if (e.target.value === "") {
+      onChange(min);
+      return;
+    }
+    const val = parseInt(e.target.value, 10);
+    if (!isNaN(val)) {
+      onChange(Math.min(max, Math.max(min, val)));
+    }
+  };
+
+  return (
+    <div className={`${styles.numberInputWrap} ${isDark ? styles.dark : ""}`}>
+      <button
+        type="button"
+        className={`button ${styles.numBtn} ${styles.minusBtn}`}
+        onClick={handleDecrement}
+        disabled={value <= min}
+      >
+        −
+      </button>
+
+      <input
+        type="number"
+        className={"customNumberInput " + styles.numInput}
+        value={value}
+        onChange={handleChange}
+        min={min}
+        max={max}
+      />
+
+      <button
+        type="button"
+        className={`button ${styles.numBtn} ${styles.plusBtn}`}
+        onClick={handleIncrement}
+        disabled={value >= max}
+      >
+        +
+      </button>
+    </div>
+  );
+}

+ 18 - 0
src/assets/components/Preset.tsx

@@ -0,0 +1,18 @@
+import Icon from "../icon";
+import type { ActionPreset } from "../ClockTimeline/model";
+
+interface PresetProps {
+  preset: ActionPreset;
+  size?: number;
+  arrow?: string;
+}
+
+export default function Preset({ preset, arrow, size = 30 }: PresetProps) {
+  return (
+    <>
+      {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={size} />}
+      <span>{arrow ?? "→"}</span>
+      {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={size} />}
+    </>
+  );
+}

+ 15 - 9
src/assets/components/QualityIcon.tsx

@@ -1,10 +1,11 @@
-import  { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef } from "react";
 
 
 import styles from "./QualityIcon.module.css";
 import styles from "./QualityIcon.module.css";
 import data from "../../assets/data/2.0/data.json";
 import data from "../../assets/data/2.0/data.json";
 import Icon from "../icon";
 import Icon from "../icon";
+import { qualityShortcut } from "../../hooks/useQualityScroller";
 
 
-const QUALITY_LEVELS = data.qualityLevels.map(q => q.name); // ["normal", "uncommon", "rare", "epic", "legendary"]
+const QUALITY_LEVELS = data.qualityLevels.map((q) => q.name); // ["normal", "uncommon", "rare", "epic", "legendary"]
 
 
 type QualityIconProps = {
 type QualityIconProps = {
   iconName: string;
   iconName: string;
@@ -13,7 +14,12 @@ type QualityIconProps = {
   onChangeQuality?: (newQuality: string) => void;
   onChangeQuality?: (newQuality: string) => void;
 };
 };
 
 
-export default function QualityIcon({ iconName, size = 40, initialQuality = "normal", onChangeQuality }: QualityIconProps) {
+export default function QualityIcon({
+  iconName,
+  size = 40,
+  initialQuality = "normal",
+  onChangeQuality,
+}: QualityIconProps) {
   const [quality, setQuality] = useState(initialQuality);
   const [quality, setQuality] = useState(initialQuality);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
 
 
@@ -21,13 +27,13 @@ export default function QualityIcon({ iconName, size = 40, initialQuality = "nor
     const handleWheel = (e: WheelEvent) => {
     const handleWheel = (e: WheelEvent) => {
       if (!e.shiftKey) return;
       if (!e.shiftKey) return;
       e.preventDefault(); // Prevent page scroll
       e.preventDefault(); // Prevent page scroll
-      
+
       const currentIndex = QUALITY_LEVELS.indexOf(quality);
       const currentIndex = QUALITY_LEVELS.indexOf(quality);
       const direction = Math.sign(e.deltaY); // 1 for down, -1 for up
       const direction = Math.sign(e.deltaY); // 1 for down, -1 for up
-      
+
       // Shift+Scroll Up = Higher quality, Down = Lower quality
       // Shift+Scroll Up = Higher quality, Down = Lower quality
       const nextIndex = Math.max(0, Math.min(QUALITY_LEVELS.length - 1, currentIndex - direction));
       const nextIndex = Math.max(0, Math.min(QUALITY_LEVELS.length - 1, currentIndex - direction));
-      
+
       if (nextIndex !== currentIndex) {
       if (nextIndex !== currentIndex) {
         const newQuality = QUALITY_LEVELS[nextIndex];
         const newQuality = QUALITY_LEVELS[nextIndex];
         setQuality(newQuality);
         setQuality(newQuality);
@@ -43,11 +49,11 @@ export default function QualityIcon({ iconName, size = 40, initialQuality = "nor
     }
     }
   }, [quality, onChangeQuality]);
   }, [quality, onChangeQuality]);
 
 
-  const qualityData = data.qualityLevels.find(q => q.name === quality);
+  const qualityData = data.qualityLevels.find((q) => q.name === quality);
 
 
   return (
   return (
-    <div ref={containerRef} className={styles.wrap} title="Shift + Scroll to change quality">
+    <div ref={containerRef} className={styles.wrap} title={qualityShortcut + " to change quality"}>
       <Icon iconName={iconName} size={size} qualityLevel={qualityData?.level} />
       <Icon iconName={iconName} size={size} qualityLevel={qualityData?.level} />
     </div>
     </div>
   );
   );
-}
+}

+ 92 - 0
src/assets/components/Select.module.css

@@ -0,0 +1,92 @@
+.customSelectWrap {
+  position: relative;
+  display: inline-block;
+  width: 100%;
+}
+.chevron {
+  font-size: 10px;
+  margin-left: 8px;
+  pointer-events: none;
+}
+
+/* The Dropdown Container */
+.customSelectMenu {
+  font-size: 14px;
+  position: absolute;
+  top: calc(100% + 4px);
+  left: 0;
+  width: 100%;
+  z-index: 100;
+
+  /* Matches your light theme focus background */
+  background: #f0dab4;
+  color: #000;
+  border: 1px solid #74624b;
+  box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.3);
+  border-radius: 4px;
+  max-height: 250px;
+  overflow-y: auto;
+}
+
+.customSelectMenu.dark {
+  background: #242324;
+  border-color: #646464;
+}
+
+/* Variant A: Simple List */
+.menuSimple {
+  padding: 4px 0;
+}
+.optionSimple {
+  padding: 2px 8px;
+  cursor: pointer;
+  color: #000;
+}
+.customSelectMenu.dark .optionSimple {
+  color: #ffe6c0;
+}
+
+.optionSimple:hover,
+.optionSimple.selected {
+  background: rgba(0, 0, 0, 0.1);
+}
+.customSelectMenu.dark .optionSimple:hover {
+  background: rgba(255, 255, 255, 0.1);
+}
+
+/* Variant B: Stacked Buttons */
+.menuStacked {
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  background: transparent; /* Remove background so buttons float */
+  border: none;
+  box-shadow: none;
+}
+.optionStacked {
+  /* Reuse your trigger styles for the options to make them look like buttons */
+  background: #8e8e8e;
+  border-radius: 4px;
+  padding: 5px 8px;
+  cursor: pointer;
+  box-shadow:
+    inset 0px 4px 1px -2px #000,
+    inset 0px -4px 1px -2px #c5c5c5,
+    inset 2px 0px 1px 0px #5f5f5f,
+    inset -2px 0px 1px 0px #5f5f5f,
+    inset 0px -2px 2px 0px #5f5f5f,
+    0px 0px 4px 1px #2e2521;
+}
+.optionStacked:hover,
+.optionStacked.selected {
+  background: #f0dab4;
+  color: #000;
+  box-shadow:
+    inset 0px 4px 2px -2px #000,
+    inset 0px -1px 1px 0px #74624b,
+    inset 0px -4px 2px -2px #e0e0e0,
+    inset 2px 0px 2px 0px #a6885c,
+    inset -2px 0px 2px 0px #a6885c,
+    0px 0px 4px 1px #2e2521;
+}

+ 139 - 0
src/assets/components/Select.tsx

@@ -0,0 +1,139 @@
+import { useState, useRef, useEffect, type ReactNode, type CSSProperties } from "react";
+import styles from "./Select.module.css";
+import { createPortal } from "react-dom";
+export interface SelectOption {
+  id: string;
+  label: string;
+}
+
+interface CustomSelectProps<T extends SelectOption> {
+  style?: CSSProperties;
+  className?: string;
+  options: T[];
+  value: string;
+  onChange: (value: string) => void;
+
+  // Custom renderers
+  renderValue?: (option: T | undefined) => ReactNode;
+  renderOption?: (option: T) => ReactNode;
+
+  // Styling
+  isDark?: boolean;
+  variant?: "simple" | "stacked";
+  placeholder?: string;
+}
+
+export default function Select<T extends SelectOption>({
+  style,
+  className,
+  options,
+  value,
+  onChange,
+  renderValue,
+  renderOption,
+  isDark = false,
+  variant = "simple",
+  placeholder = "Select...",
+}: CustomSelectProps<T>) {
+  const [isOpen, setIsOpen] = useState(false);
+  const [menuCoords, setMenuCoords] = useState({ top: 0, left: 0, width: 0 });
+
+  const triggerRef = useRef<HTMLButtonElement>(null);
+  const menuRef = useRef<HTMLDivElement>(null);
+
+  const selectedOption = options.find((opt) => opt.id === value);
+
+  const toggleMenu = () => {
+    if (!isOpen && triggerRef.current) {
+      // Calculate exact screen position before opening
+      const rect = triggerRef.current.getBoundingClientRect();
+      setMenuCoords({
+        top: rect.bottom, // Place right below the button
+        left: rect.left,
+        width: rect.width, // Match the button's width
+      });
+    }
+    setIsOpen(!isOpen);
+  };
+  useEffect(() => {
+    if (!isOpen) return;
+
+    const handleClickOutside = (e: MouseEvent | PointerEvent) => {
+      const target = e.target as Node;
+      // If we clicked the trigger or inside the menu, do nothing
+      if (
+        (triggerRef.current && triggerRef.current.contains(target)) ||
+        (menuRef.current && menuRef.current.contains(target))
+      ) {
+        return;
+      }
+      // Otherwise, we clicked outside, so close it
+      setIsOpen(false);
+    };
+
+    const handleScroll = (e: Event) => {
+      if (menuRef.current && menuRef.current.contains(e.target as Node)) {
+        return;
+      }
+      setIsOpen(false);
+    };
+    document.addEventListener("pointerdown", handleClickOutside, true);
+    document.addEventListener("mousedown", handleClickOutside, true);
+    // Use capture phase (true) to catch all scroll events anywhere on the page
+    window.addEventListener("scroll", handleScroll, true);
+    window.addEventListener("resize", () => setIsOpen(false));
+
+    return () => {
+      document.removeEventListener("pointerdown", handleClickOutside, true);
+      document.removeEventListener("mousedown", handleClickOutside, true);
+      window.removeEventListener("scroll", handleScroll, true);
+      window.removeEventListener("resize", () => setIsOpen(false));
+    };
+  }, [isOpen]);
+
+  const menuContent = isOpen ? (
+    <div
+      ref={menuRef}
+      className={`${styles.customSelectMenu} ${isDark ? "dark" : ""} ${variant === "simple" ? styles.menuSimple : styles.menuStacked}`}
+      style={{
+        position: "fixed",
+        top: `${menuCoords.top + 4}px`,
+        left: `${menuCoords.left}px`,
+        width: `${menuCoords.width}px`,
+      }}
+    >
+      {options.map((option) => (
+        <div
+          key={option.id}
+          className={`${variant === "simple" ? styles.optionSimple : styles.optionStacked} ${
+            option.id === value ? "selected" : ""
+          }`}
+          onClick={() => {
+            onChange(option.id);
+            setIsOpen(false);
+          }}
+        >
+          {renderOption ? renderOption(option) : option.label}
+        </div>
+      ))}
+    </div>
+  ) : null;
+  return (
+    <>
+      <button
+        ref={triggerRef}
+        type="button"
+        className={`customSelectTrigger ${isOpen ? "isOpen" : ""} ${isDark ? "dark" : ""} ${className ?? ""}`}
+        style={style}
+        onClick={toggleMenu}
+      >
+        <div style={{ flex: 1, overflow: "hidden" }}>
+          {selectedOption ? (renderValue ? renderValue(selectedOption) : selectedOption.label) : placeholder}
+        </div>
+        <span className={styles.chevron}>{isOpen ? "▲" : "▼"}</span>
+      </button>
+      {/* DROPDOWN MENU */}
+      {isOpen && createPortal(menuContent, document.body)}
+    </>
+  );
+}

+ 345 - 1
src/assets/data/2.0/data.json

@@ -17453,5 +17453,349 @@
         }
         }
       ]
       ]
     }
     }
-  ]
+  ],
+  "stackSizes": {
+    "uranium-rounds-magazine": 100,
+    "flamethrower-ammo": 100,
+    "rocket": 100,
+    "explosive-rocket": 100,
+    "atomic-bomb": 10,
+    "piercing-shotgun-shell": 100,
+    "cannon-shell": 100,
+    "explosive-cannon-shell": 100,
+    "uranium-cannon-shell": 100,
+    "explosive-uranium-cannon-shell": 100,
+    "artillery-shell": 1,
+    "firearm-magazine": 100,
+    "piercing-rounds-magazine": 100,
+    "shotgun-shell": 100,
+    "railgun-ammo": 10,
+    "capture-robot-rocket": 10,
+    "tesla-ammo": 100,
+    "modular-armor": 1,
+    "power-armor": 1,
+    "power-armor-mk2": 1,
+    "light-armor": 1,
+    "heavy-armor": 1,
+    "mech-armor": 1,
+    "blueprint": 1,
+    "blueprint-book": 1,
+    "raw-fish": 100,
+    "grenade": 100,
+    "defender-capsule": 100,
+    "cluster-grenade": 100,
+    "poison-capsule": 100,
+    "slowdown-capsule": 100,
+    "distractor-capsule": 100,
+    "destroyer-capsule": 100,
+    "cliff-explosives": 20,
+    "discharge-defense-remote": 1,
+    "artillery-targeting-remote": 1,
+    "yumako": 50,
+    "jellynut": 50,
+    "yumako-mash": 100,
+    "jelly": 100,
+    "bioflux": 100,
+    "copy-paste-tool": 1,
+    "cut-paste-tool": 1,
+    "deconstruction-planner": 1,
+    "parameter-0": 1,
+    "parameter-1": 1,
+    "parameter-2": 1,
+    "parameter-3": 1,
+    "parameter-4": 1,
+    "parameter-5": 1,
+    "parameter-6": 1,
+    "parameter-7": 1,
+    "parameter-8": 1,
+    "parameter-9": 1,
+    "flamethrower": 5,
+    "tank-machine-gun": 1,
+    "tank-flamethrower": 1,
+    "rocket-launcher": 5,
+    "combat-shotgun": 5,
+    "tank-cannon": 1,
+    "artillery-wagon-cannon": 1,
+    "spidertron-rocket-launcher-1": 1,
+    "spidertron-rocket-launcher-2": 1,
+    "spidertron-rocket-launcher-3": 1,
+    "spidertron-rocket-launcher-4": 1,
+    "pistol": 5,
+    "submachine-gun": 5,
+    "vehicle-machine-gun": 1,
+    "shotgun": 5,
+    "railgun": 1,
+    "teslagun": 5,
+    "item-unknown": 1,
+    "stone-brick": 100,
+    "wood": 100,
+    "coal": 50,
+    "stone": 50,
+    "iron-ore": 50,
+    "copper-ore": 50,
+    "iron-plate": 100,
+    "copper-plate": 100,
+    "copper-cable": 200,
+    "iron-stick": 100,
+    "iron-gear-wheel": 100,
+    "electronic-circuit": 200,
+    "wooden-chest": 50,
+    "stone-furnace": 50,
+    "burner-mining-drill": 50,
+    "electric-mining-drill": 50,
+    "burner-inserter": 50,
+    "inserter": 50,
+    "fast-inserter": 50,
+    "long-handed-inserter": 50,
+    "offshore-pump": 20,
+    "pipe": 100,
+    "boiler": 50,
+    "steam-engine": 10,
+    "small-electric-pole": 50,
+    "radar": 50,
+    "small-lamp": 50,
+    "pipe-to-ground": 50,
+    "assembling-machine-1": 50,
+    "assembling-machine-2": 50,
+    "red-wire": 1,
+    "green-wire": 1,
+    "copper-wire": 1,
+    "no-item": 1,
+    "stone-wall": 100,
+    "lab": 10,
+    "automation-science-pack": 200,
+    "logistic-science-pack": 200,
+    "steel-plate": 100,
+    "engine-unit": 50,
+    "electric-furnace": 50,
+    "solid-fuel": 50,
+    "rocket-fuel": 20,
+    "iron-chest": 50,
+    "big-electric-pole": 50,
+    "medium-electric-pole": 50,
+    "steel-furnace": 50,
+    "gate": 50,
+    "steel-chest": 50,
+    "solar-panel": 50,
+    "train-stop": 10,
+    "rail-signal": 50,
+    "rail-chain-signal": 50,
+    "concrete": 100,
+    "refined-concrete": 100,
+    "hazard-concrete": 100,
+    "refined-hazard-concrete": 100,
+    "landfill": 100,
+    "accumulator": 50,
+    "uranium-ore": 50,
+    "transport-belt": 100,
+    "fast-transport-belt": 100,
+    "express-transport-belt": 100,
+    "bulk-inserter": 50,
+    "assembling-machine-3": 50,
+    "chemical-science-pack": 200,
+    "military-science-pack": 200,
+    "production-science-pack": 200,
+    "utility-science-pack": 200,
+    "space-science-pack": 200,
+    "underground-belt": 50,
+    "fast-underground-belt": 50,
+    "express-underground-belt": 50,
+    "splitter": 50,
+    "lane-splitter": 50,
+    "fast-splitter": 50,
+    "express-splitter": 50,
+    "loader": 50,
+    "fast-loader": 50,
+    "express-loader": 50,
+    "advanced-circuit": 200,
+    "processing-unit": 100,
+    "logistic-robot": 50,
+    "construction-robot": 50,
+    "passive-provider-chest": 50,
+    "active-provider-chest": 50,
+    "storage-chest": 50,
+    "buffer-chest": 50,
+    "requester-chest": 50,
+    "rocket-silo": 1,
+    "cargo-landing-pad": 1,
+    "roboport": 10,
+    "coin": 100000,
+    "substation": 50,
+    "beacon": 20,
+    "storage-tank": 50,
+    "pump": 50,
+    "pumpjack": 20,
+    "oil-refinery": 10,
+    "chemical-plant": 10,
+    "sulfur": 50,
+    "barrel": 10,
+    "plastic-bar": 100,
+    "electric-engine-unit": 50,
+    "explosives": 50,
+    "battery": 200,
+    "flying-robot-frame": 50,
+    "low-density-structure": 50,
+    "nuclear-fuel": 1,
+    "rocket-part": 5,
+    "electric-energy-interface": 50,
+    "heat-interface": 20,
+    "nuclear-reactor": 10,
+    "uranium-235": 100,
+    "uranium-238": 100,
+    "centrifuge": 10,
+    "uranium-fuel-cell": 50,
+    "depleted-uranium-fuel-cell": 50,
+    "heat-exchanger": 50,
+    "steam-turbine": 10,
+    "heat-pipe": 50,
+    "simple-entity-with-force": 50,
+    "simple-entity-with-owner": 50,
+    "infinity-chest": 10,
+    "infinity-cargo-wagon": 5,
+    "infinity-pipe": 10,
+    "burner-generator": 10,
+    "linked-chest": 10,
+    "proxy-container": 10,
+    "bottomless-chest": 10,
+    "linked-belt": 10,
+    "one-way-valve": 10,
+    "overflow-valve": 10,
+    "top-up-valve": 10,
+    "empty-module-slot": 1,
+    "land-mine": 100,
+    "solar-panel-equipment": 20,
+    "fission-reactor-equipment": 20,
+    "electric-energy-interface-equipment": 1,
+    "battery-equipment": 20,
+    "battery-mk2-equipment": 20,
+    "belt-immunity-equipment": 20,
+    "exoskeleton-equipment": 20,
+    "personal-roboport-equipment": 20,
+    "personal-roboport-mk2-equipment": 20,
+    "night-vision-equipment": 20,
+    "energy-shield-equipment": 20,
+    "energy-shield-mk2-equipment": 20,
+    "personal-laser-defense-equipment": 20,
+    "discharge-defense-equipment": 20,
+    "gun-turret": 50,
+    "laser-turret": 50,
+    "flamethrower-turret": 50,
+    "artillery-turret": 10,
+    "arithmetic-combinator": 50,
+    "decider-combinator": 50,
+    "constant-combinator": 50,
+    "selector-combinator": 50,
+    "power-switch": 10,
+    "programmable-speaker": 10,
+    "display-panel": 10,
+    "science": 1,
+    "rail-support": 20,
+    "recycler": 20,
+    "space-platform-foundation": 100,
+    "metallurgic-science-pack": 200,
+    "agricultural-science-pack": 200,
+    "electromagnetic-science-pack": 200,
+    "cryogenic-science-pack": 200,
+    "promethium-science-pack": 200,
+    "turbo-transport-belt": 100,
+    "turbo-underground-belt": 50,
+    "turbo-splitter": 50,
+    "turbo-loader": 50,
+    "toolbelt-equipment": 20,
+    "battery-mk3-equipment": 20,
+    "cargo-bay": 10,
+    "landing-pad-unloading-bay": 10,
+    "metallic-asteroid-chunk": 1,
+    "carbonic-asteroid-chunk": 1,
+    "oxide-asteroid-chunk": 1,
+    "promethium-asteroid-chunk": 1,
+    "asteroid-collector": 10,
+    "crusher": 10,
+    "thruster": 10,
+    "ice": 50,
+    "carbon": 50,
+    "calcite": 50,
+    "tungsten-ore": 50,
+    "tungsten-plate": 50,
+    "big-mining-drill": 20,
+    "tungsten-carbide": 50,
+    "foundry": 20,
+    "railgun-turret": 10,
+    "copper-bacteria": 50,
+    "iron-bacteria": 50,
+    "yumako-seed": 10,
+    "jellynut-seed": 10,
+    "nutrients": 100,
+    "artificial-yumako-soil": 100,
+    "overgrowth-yumako-soil": 100,
+    "artificial-jellynut-soil": 100,
+    "overgrowth-jellynut-soil": 100,
+    "agricultural-tower": 20,
+    "biochamber": 20,
+    "biolab": 5,
+    "captive-biter-spawner": 1,
+    "biter-egg": 100,
+    "pentapod-egg": 20,
+    "carbon-fiber": 100,
+    "stack-inserter": 50,
+    "rocket-turret": 10,
+    "holmium-ore": 50,
+    "holmium-plate": 100,
+    "lithium": 50,
+    "lithium-plate": 100,
+    "scrap": 50,
+    "lightning-rod": 50,
+    "lightning-collector": 20,
+    "heating-tower": 20,
+    "electromagnetic-plant": 20,
+    "superconductor": 200,
+    "supercapacitor": 100,
+    "tesla-turret": 10,
+    "quantum-processor": 100,
+    "fusion-reactor-equipment": 20,
+    "fusion-power-cell": 50,
+    "fusion-reactor": 1,
+    "fusion-generator": 5,
+    "cryogenic-plant": 20,
+    "spoilage": 200,
+    "ice-platform": 100,
+    "foundation": 50,
+    "space-platform-hub": 1,
+    "tree-seed": 10,
+    "water-barrel": 10,
+    "sulfuric-acid-barrel": 10,
+    "crude-oil-barrel": 10,
+    "heavy-oil-barrel": 10,
+    "light-oil-barrel": 10,
+    "petroleum-gas-barrel": 10,
+    "lubricant-barrel": 10,
+    "fluoroketone-cold-barrel": 10,
+    "fluoroketone-hot-barrel": 10,
+    "car": 1,
+    "locomotive": 5,
+    "cargo-wagon": 5,
+    "fluid-wagon": 5,
+    "artillery-wagon": 5,
+    "tank": 1,
+    "spidertron": 1,
+    "speed-module": 50,
+    "speed-module-2": 50,
+    "speed-module-3": 50,
+    "efficiency-module": 50,
+    "efficiency-module-2": 50,
+    "efficiency-module-3": 50,
+    "productivity-module": 50,
+    "productivity-module-2": 50,
+    "productivity-module-3": 50,
+    "quality-module": 50,
+    "quality-module-2": 50,
+    "quality-module-3": 50,
+    "rail": 100,
+    "rail-ramp": 10,
+    "repair-pack": 100,
+    "selection-tool": 1,
+    "space-platform-starter-pack": 1,
+    "spidertron-remote": 1,
+    "upgrade-planner": 1
+  }
 }
 }

+ 26 - 19
src/assets/icon.tsx

@@ -18,32 +18,39 @@ const iconCountPerSize = iconMap.reduce((acc, icon) => {
   return acc;
   return acc;
 }, new Map<number, number>());
 }, new Map<number, number>());
 
 
-const mapSizePerSize = new Map<number, number>();
+const mapSizePerSize = new Map<number, number[]>();
 for (let [size, count] of iconCountPerSize.entries()) {
 for (let [size, count] of iconCountPerSize.entries()) {
-  mapSizePerSize.set(size, Math.floor(Math.sqrt(count)) + 1);
+  const cols = Math.ceil(Math.sqrt(count));
+  const rows = Math.ceil(count / cols);
+
+  mapSizePerSize.set(size, [cols, rows]);
 }
 }
 
 
 function Icon({ style, className, iconName, size = 64, qualityLevel = 0 }: IconProps) {
 function Icon({ style, className, iconName, size = 64, qualityLevel = 0 }: IconProps) {
   const icon = useMemo(() => iconMap.find((o) => o.name == iconName), [iconName]);
   const icon = useMemo(() => iconMap.find((o) => o.name == iconName), [iconName]);
-  const iconStyle = useMemo(() => {
-    if (icon) {
-      const bgSizePx = (mapSizePerSize.get(icon.size) ?? 1) * size;
-      const offsetX = (-(icon.x ?? 0) * size) / (icon.size ?? 1);
-      const offsetY = (-(icon.y ?? 0) * size) / (icon.size ?? 1);
-      return {
-        backgroundImage: `url("/data/2.0/icon_${icon?.size}.webp")`,
-        backgroundPosition: `${offsetX}px ${offsetY}px`,
-        backgroundSize: `${bgSizePx}px`,
-        width: size,
-        height: size,
-        ...style,
-      };
-    }
-  }, [icon, size, style]);
+  if (!icon) return null;
+
+  const shape = mapSizePerSize.get(icon.size) ?? [];
+  const bgWidthPx = (shape[0] ?? 1) * size;
+  const bgHeightPx = (shape[1] ?? 1) * size;
+
+  const offsetX = (-(icon.x ?? 0) * size) / (icon.size ?? 1);
+  const offsetY = (-(icon.y ?? 0) * size) / (icon.size ?? 1);
   const qualityIcon = useMemo(() => qualityLevels.find((q) => q.level == qualityLevel)?.icon, [qualityLevel]);
   const qualityIcon = useMemo(() => qualityLevels.find((q) => q.level == qualityLevel)?.icon, [qualityLevel]);
   return (
   return (
-    <i className={styles.factorioIcon + " " + className} style={iconStyle}>
-      {qualityLevel > 0 && qualityIcon != undefined && <Icon iconName={qualityIcon} size={size / 2}></Icon>}
+    <i className={styles.factorioIcon + " " + (className ?? "")} style={{ width: size, height: size, ...styles }}>
+      <img
+        src={`/data/2.0/icon_${icon.size}.webp`}
+        alt={iconName}
+        draggable={false}
+        style={{
+          width: bgWidthPx,
+          height: bgHeightPx,
+          left: offsetX,
+          top: offsetY,
+        }}
+      />
+      {qualityLevel > 0 && qualityIcon != undefined && <Icon iconName={qualityIcon} size={size / 3}></Icon>}
     </i>
     </i>
   );
   );
 }
 }

+ 2 - 4
src/blueprint/Blueprintbuilder.ts

@@ -1,13 +1,11 @@
 import {
 import {
   expandBlockInstances,
   expandBlockInstances,
   getPreset,
   getPreset,
-  type BlueprintSignal,
   type ClockBlock,
   type ClockBlock,
   type ClockRow,
   type ClockRow,
-  type DeciderCombinator,
-  type DeciderCondition,
   type Signal,
   type Signal,
-} from "../assets/types";
+} from "../assets/ClockTimeline/model";
+import { type BlueprintSignal, type DeciderCombinator, type DeciderCondition } from "./model";
 
 
 const BP_VERSION = 562954249109505; // version stamp reused from a real 2.0 export
 const BP_VERSION = 562954249109505; // version stamp reused from a real 2.0 export
 
 

+ 60 - 0
src/blueprint/model.ts

@@ -0,0 +1,60 @@
+type Entity = {
+  entity_number: number;
+  name: string;
+  position: {
+    x: number;
+    y: number;
+  };
+  direction: 1 | 2 | 3 | 4;
+};
+
+export type BlueprintSignal = {
+  type?: string;
+  name: string;
+  quality?: string;
+};
+type NetworkState = {
+  red: boolean;
+  green: boolean;
+};
+export type DeciderCondition = {
+  first_signal: BlueprintSignal;
+  comparator: "<" | ">" | "=" | "≥" | "≤" | "≠";
+  compare_type?: "and" | "or";
+  first_signal_networks?: NetworkState;
+} & ({ second_signal: BlueprintSignal; second_signal_networks?: NetworkState } | { constant?: number });
+type DeciderOutput = {
+  signal: BlueprintSignal;
+} & ({ copy_count_from_input: false; constant?: number } | { networks?: { red: boolean; green: boolean } });
+
+export type DeciderCombinator = Entity & {
+  name: "decider-combinator";
+  control_behavior: {
+    decider_conditions: {
+      conditions?: DeciderCondition[];
+      outputs?: DeciderOutput[];
+      else_outputs?: DeciderOutput[];
+    };
+  };
+  player_description: string;
+};
+export type Blueprint = {
+  blueprint: {
+    item: "blueprint";
+    version: 562954249109505;
+    icons: Array<{ signal: BlueprintSignal; index: 1 | 2 | 3 | 4 }>;
+    entities: [];
+    /**Each wires is represented by 4 number
+     * [a, b, c, d]
+     * a : entity_number of first entity
+     * b : wire connector (1-4) of first entity
+     * a : entity_number of second entity
+     * b : wire connector (1-4) of second entity
+     * Most entity have only 2 connector (red and green circuit)
+     * Decider Combinator, Arithmetic Combinator and Selector Combinator have 4 connectors (2 inputs, 2 outputs)
+     * 1 & 3 red
+     * 2 & 4 green
+     */
+    wires: Array<Array<number>>;
+  };
+};

+ 0 - 218
src/engine/Dashboard/Dashboard.module.css

@@ -99,224 +99,6 @@
   position: relative;
   position: relative;
 }
 }
 
 
-/* --- USER PROVIDED FACTORIO BUTTONS --- */
-.panel-button {
-  border: none;
-  cursor: pointer;
-  position: relative;
-  background-color: #313031;
-  height: 24px;
-  aspect-ratio: 1;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 8px 4px -8px #000,
-    inset 0px -8px 4px -8px #000,
-    inset 0px 8px 4px -8px #fff,
-    inset 0px -8px 2px -8px #432400,
-    0px 0px 4px 0px #000;
-}
-.panel-button:active,
-.panel-button.active {
-  box-shadow:
-    inset 0px 9px 2px -8px #000,
-    inset 8px 0px 4px -8px #563a10,
-    inset 8px 0px 4px -8px #563a10,
-    inset -8px 0px 4px -8px #563a10,
-    inset -8px 0px 4px -8px #563a10,
-    inset 0px -9px 2px -8px #fff,
-    0px 0px 4px 0px #000;
-  background-color: #f1be64;
-  filter: none;
-  outline: 0;
-}
-.panel-button.hover,
-.panel-button:focus,
-.panel-button:hover {
-  color: #000;
-  text-decoration: none;
-  outline: 0;
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 9px 2px -8px #fff,
-    inset 0px -8px 4px -8px hsl(36, 94%, 20%),
-    0px 0px 4px 0px #000,
-    inset 0px 0px 4px 2px #f9b44b;
-  background-color: #e39827;
-  filter: drop-shadow(0 0 2px #f9b44b);
-}
-input:focus {
-  border: none;
-  filter: drop-shadow(0 0 2px #f9b44b);
-}
-.button {
-  background-color: #8e8e8e;
-  padding: 10px 12px;
-  font-size: 100%;
-  text-align: left;
-  color: #000;
-  font-weight: 600;
-  display: inline-block;
-  vertical-align: baseline;
-  min-width: 128px;
-  border: none;
-  line-height: inherit;
-  white-space: nowrap;
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 10px 2px -8px #e3e3e3,
-    inset 0px 10px 2px -8px #282828,
-    inset 0px -9px 2px -8px #000,
-    0px 0px 4px 0px #000;
-  position: relative;
-  margin-right: 14px;
-  cursor: pointer;
-  user-select: none;
-  height: 36px;
-  text-align: left;
-}
-.button:hover,
-.button.hover {
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 9px 2px -8px #fff,
-    inset 0px 8px 4px -8px #000,
-    inset 0px -8px 4px -8px #000,
-    inset 0px -9px 2px -8px #432400,
-    0px 0px 4px 0px #000,
-    inset 0px 0px 4px 2px #f9b44b;
-  background-color: #e39827;
-  filter: drop-shadow(0 0 2px #f9b44b);
-}
-.button:active,
-.button.active {
-  box-shadow:
-    inset 0px 10px 2px -8px #000,
-    inset 0px 9px 2px -8px #000,
-    inset 8px 0px 4px -8px #563a10,
-    inset 8px 0px 4px -8px #563a10,
-    inset -8px 0px 4px -8px #563a10,
-    inset -8px 0px 4px -8px #563a10,
-    inset 0px 9px 2px -8px #563a10,
-    inset 0px -9px 2px -8px #563a10,
-    inset 0px -8.5px 0px -8px #563a10,
-    0px 0px 4px 0px #000;
-  background-color: #f1be64;
-}
-.button.disabled {
-  background-color: #3d3d3d;
-  color: #818181;
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 8px 4px -8px #000,
-    inset 0px -6px 4px -8px #818181,
-    inset 0px -8px 4px -8px #000,
-    0px 0px 4px 0px #000;
-}
-.button-green {
-  background-color: #5eb663;
-  padding: 10px 12px;
-  font-size: 100%;
-  color: #000;
-  font-weight: 600;
-  display: inline-block;
-  min-width: 128px;
-  border: none;
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 10px 2px -8px #95df99,
-    inset 0px 10px 2px -8px #163218,
-    inset 0px -9px 2px -8px #000,
-    0px 0px 4px 0px #000;
-  margin-right: 14px;
-  cursor: pointer;
-  user-select: none;
-  height: 36px;
-}
-.button-green:hover {
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 9px 2px -8px #cdf1cf,
-    inset 0px 8px 4px -8px #000,
-    inset 0px -8px 4px -8px #000,
-    inset 0px -9px 2px -8px #432400,
-    0px 0px 4px 0px #000,
-    inset 0px 0px 4px 2px #34be3c;
-  background-color: #92e897;
-  filter: drop-shadow(0 0 2px #34be3c);
-}
-.button-red {
-  background-color: #fe5a5a;
-  padding: 10px 12px;
-  font-size: 100%;
-  color: #000;
-  font-weight: 600;
-  display: inline-block;
-  min-width: 128px;
-  border: none;
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 10px 2px -8px #fda1a1,
-    inset 0px 10px 2px -8px #8b0101,
-    inset 0px -9px 2px -8px #000,
-    0px 0px 4px 0px #000;
-  margin-right: 14px;
-  cursor: pointer;
-  user-select: none;
-  height: 36px;
-}
-.button-red:hover {
-  box-shadow:
-    inset 8px 0px 4px -8px #000,
-    inset -8px 0px 4px -8px #000,
-    inset 0px 9px 2px -8px #f8eaea,
-    inset 0px 8px 4px -8px #000,
-    inset 0px -8px 4px -8px #000,
-    inset 0px -9px 2px -8px #432400,
-    0px 0px 4px 0px #000,
-    inset 0px 0px 4px 2px #c35353;
-  background-color: #ff9b9b;
-  filter: drop-shadow(0 0 2px #c35353);
-}
-input[type="text"],
-input[type="number"] {
-  height: 36px;
-  background: #8e8e8e;
-  border-radius: 4px;
-  padding: 6px;
-  border: none;
-  box-shadow:
-    inset 0px 4px 1px -2px #000,
-    inset 0px -4px 1px -2px #c5c5c5,
-    inset 2px 0px 1px 0px #5f5f5f,
-    inset -2px 0px 1px 0px #5f5f5f,
-    inset 0px -2px 2px 0px #5f5f5f,
-    0px 0px 4px 1px #2e2521;
-  outline: none;
-}
-input[type="text"]:focus,
-input[type="number"]:focus {
-  color: #000;
-  background: #f0dab4;
-  box-shadow:
-    inset 0px 4px 2px -2px #000,
-    inset 0px -1px 1px 0px #74624b,
-    inset 0px -4px 2px -2px #e0e0e0,
-    inset 2px 0px 2px 0px #a6885c,
-    inset -2px 0px 2px 0px #a6885c,
-    0px 0px 4px 1px #2e2521;
-}
-
 .timelineScrollArea {
 .timelineScrollArea {
   overflow-x: auto;
   overflow-x: auto;
   overflow-y: hidden;
   overflow-y: hidden;

+ 136 - 134
src/engine/Dashboard/GraphView.tsx

@@ -1,151 +1,153 @@
-import { useMemo, useEffect, useState } from "react";
-import ReactFlow, { Background, Controls, MarkerType } from "reactflow";
-import type { NodeProps, Node, Edge } from "reactflow";
-import "reactflow/dist/style.css";
-import styles from "./Dashboard.module.css";
+// import { useMemo, useEffect, useState } from "react";
+// import ReactFlow, { Background, Controls, MarkerType } from "reactflow";
+// import type { NodeProps, Node, Edge } from "reactflow";
+// import "reactflow/dist/style.css";
+// import styles from "./Dashboard.module.css";
 import type { MachineData, UIOrchestrator } from "./types";
 import type { MachineData, UIOrchestrator } from "./types";
 
 
-// Type for React Flow Node Data
-interface CustomNodeData {
-  instance: MachineData;
-  label: string;
-  currentTick?: number;
-}
+// // Type for React Flow Node Data
+// interface CustomNodeData {
+//   instance: MachineData;
+//   label: string;
+//   currentTick?: number;
+// }
 
 
-const MachineNode = ({ data }: NodeProps<CustomNodeData>) => {
-  const machine = data.instance as MachineData;
-  return (
-    <div
-      className={styles.machineCard}
-      style={{ margin: 0, minWidth: "220px", boxShadow: "inset 0 0 10px rgba(0,0,0,0.5)" }}
-    >
-      <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
-        <span style={{ color: "#5eb663", fontWeight: "bold" }}>{machine.recipe.name}</span>
-        <span className={styles.statusBadge} style={{ color: machine.isCrafting ? "#5eb663" : "#fe5a5a" }}>
-          {machine.isCrafting ? "CRAFTING" : "HALTED"}
-        </span>
-      </div>
-      <div
-        style={{
-          width: "100%",
-          height: "6px",
-          backgroundColor: "#1a1a1a",
-          border: "1px solid #000",
-          marginBottom: "8px",
-        }}
-      >
-        <div style={{ width: `${machine.craftProgress * 100}%`, height: "100%", backgroundColor: "#5eb663" }} />
-      </div>
-      <div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px" }}>
-        <div style={{ display: "flex", gap: "4px" }}>
-          <span style={{ color: "#8e8e8e" }}>IN:</span>
-          {Object.entries(machine.inputBuffer).map(([k, v]) => (
-            <span key={k} style={{ color: v === 0 ? "#fe5a5a" : "#fff" }}>
-              {v}
-            </span>
-          ))}
-        </div>
-        <div style={{ display: "flex", gap: "4px" }}>
-          <span style={{ color: "#8e8e8e" }}>OUT:</span>
-          {Object.entries(machine.outputBuffer).map(([k, v]) => {
-            const isFull = v >= (machine.outputBlockLimits[k] || 0);
-            return (
-              <span key={k} style={{ color: isFull ? "#fe5a5a" : "#fff" }}>
-                {v}
-              </span>
-            );
-          })}
-        </div>
-      </div>
-    </div>
-  );
-};
+// const MachineNode = ({ data }: NodeProps<CustomNodeData>) => {
+//   const machine = data.instance as MachineData;
+//   return (
+//     <div
+//       className={styles.machineCard}
+//       style={{ margin: 0, minWidth: "220px", boxShadow: "inset 0 0 10px rgba(0,0,0,0.5)" }}
+//     >
+//       <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
+//         <span style={{ color: "#5eb663", fontWeight: "bold" }}>{machine.recipe.name}</span>
+//         <span className={styles.statusBadge} style={{ color: machine.isCrafting ? "#5eb663" : "#fe5a5a" }}>
+//           {machine.isCrafting ? "CRAFTING" : "HALTED"}
+//         </span>
+//       </div>
+//       <div
+//         style={{
+//           width: "100%",
+//           height: "6px",
+//           backgroundColor: "#1a1a1a",
+//           border: "1px solid #000",
+//           marginBottom: "8px",
+//         }}
+//       >
+//         <div style={{ width: `${machine.craftProgress * 100}%`, height: "100%", backgroundColor: "#5eb663" }} />
+//       </div>
+//       <div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px" }}>
+//         <div style={{ display: "flex", gap: "4px" }}>
+//           <span style={{ color: "#8e8e8e" }}>IN:</span>
+//           {Object.entries(machine.inputBuffer).map(([k, v]) => (
+//             <span key={k} style={{ color: v === 0 ? "#fe5a5a" : "#fff" }}>
+//               {v}
+//             </span>
+//           ))}
+//         </div>
+//         <div style={{ display: "flex", gap: "4px" }}>
+//           <span style={{ color: "#8e8e8e" }}>OUT:</span>
+//           {Object.entries(machine.outputBuffer).map(([k, v]) => {
+//             const isFull = v >= (machine.outputBlockLimits[k] || 0);
+//             return (
+//               <span key={k} style={{ color: isFull ? "#fe5a5a" : "#fff" }}>
+//                 {v}
+//               </span>
+//             );
+//           })}
+//         </div>
+//       </div>
+//     </div>
+//   );
+// };
 
 
-const ChestNode = ({ data }: NodeProps<CustomNodeData>) => (
-  <div
-    style={{
-      backgroundColor: "#3d3d3d",
-      padding: "12px",
-      borderRadius: "4px",
-      border: "2px solid #8e8e8e",
-      minWidth: "120px",
-      textAlign: "center",
-      color: "#e3e3e3",
-      fontWeight: "bold",
-    }}
-  >
-    {data.label}
-  </div>
-);
+// const ChestNode = ({ data }: NodeProps<CustomNodeData>) => (
+//   <div
+//     style={{
+//       backgroundColor: "#3d3d3d",
+//       padding: "12px",
+//       borderRadius: "4px",
+//       border: "2px solid #8e8e8e",
+//       minWidth: "120px",
+//       textAlign: "center",
+//       color: "#e3e3e3",
+//       fontWeight: "bold",
+//     }}
+//   >
+//     {data.label}
+//   </div>
+// );
 
 
-const nodeTypes = { machine: MachineNode, chest: ChestNode };
+// const nodeTypes = { machine: MachineNode, chest: ChestNode };
 
 
 interface GraphViewProps {
 interface GraphViewProps {
   orchestrator: UIOrchestrator;
   orchestrator: UIOrchestrator;
   tick: number;
   tick: number;
 }
 }
-
 export default function GraphView({ orchestrator, tick }: GraphViewProps) {
 export default function GraphView({ orchestrator, tick }: GraphViewProps) {
-  const { initialNodes, initialEdges } = useMemo(() => {
-    const nodes: Node<CustomNodeData>[] = [];
-    const edges: Edge[] = [];
-    const objToId = new Map<any, string>();
-    let idCounter = 0;
+  return <div></div>;
+}
+// export default function GraphView({ orchestrator, tick }: GraphViewProps) {
+//   const { initialNodes, initialEdges } = useMemo(() => {
+//     const nodes: Node<CustomNodeData>[] = [];
+//     const edges: Edge[] = [];
+//     const objToId = new Map<any, string>();
+//     let idCounter = 0;
 
 
-    const getId = (obj: any, type: string, label: string): string => {
-      if (!objToId.has(obj)) {
-        const id = `node_${idCounter++}`;
-        objToId.set(obj, id);
-        nodes.push({
-          id,
-          type,
-          position: { x: (idCounter % 3) * 300, y: Math.floor(idCounter / 3) * 150 },
-          data: { instance: obj, label },
-        });
-      }
-      return objToId.get(obj)!;
-    };
+//     const getId = (obj: any, type: string, label: string): string => {
+//       if (!objToId.has(obj)) {
+//         const id = `node_${idCounter++}`;
+//         objToId.set(obj, id);
+//         nodes.push({
+//           id,
+//           type,
+//           position: { x: (idCounter % 3) * 300, y: Math.floor(idCounter / 3) * 150 },
+//           data: { instance: obj, label },
+//         });
+//       }
+//       return objToId.get(obj)!;
+//     };
 
 
-    orchestrator.getInserters().forEach((ins, i) => {
-      edges.push({
-        id: `edge_${i}`,
-        source: getId(ins.source, ins.source.recipe ? "machine" : "chest", "Source"),
-        target: getId(ins.destination, ins.destination.recipe ? "machine" : "chest", "Destination"),
-        label: ins.targetItemId,
-        animated: false,
-        style: { stroke: "#8e8e8e", strokeWidth: 2 },
-        markerEnd: { type: MarkerType.ArrowClosed, color: "#8e8e8e" },
-      });
-    });
+//     orchestrator.getInserters().forEach((ins, i) => {
+//       edges.push({
+//         id: `edge_${i}`,
+//         source: getId(ins.source, ins.source.recipe ? "machine" : "chest", "Source"),
+//         target: getId(ins.destination, ins.destination.recipe ? "machine" : "chest", "Destination"),
+//         label: ins.targetItemId,
+//         animated: false,
+//         style: { stroke: "#8e8e8e", strokeWidth: 2 },
+//         markerEnd: { type: MarkerType.ArrowClosed, color: "#8e8e8e" },
+//       });
+//     });
 
 
-    return { initialNodes: nodes, initialEdges: edges };
-  }, [orchestrator]);
+//     return { initialNodes: nodes, initialEdges: edges };
+//   }, [orchestrator]);
 
 
-  const [nodes, setNodes] = useState<Node<CustomNodeData>[]>(initialNodes);
-  const [edges, setEdges] = useState<Edge[]>(initialEdges);
+//   const [nodes, setNodes] = useState<Node<CustomNodeData>[]>(initialNodes);
+//   const [edges, setEdges] = useState<Edge[]>(initialEdges);
 
 
-  useEffect(() => {
-    setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, currentTick: tick } })));
-    setEdges((eds) =>
-      eds.map((edge, i) => {
-        const ins = orchestrator.getInserters()[i];
-        const isSwinging = ins.state > 0 && ins.state < 4;
-        return {
-          ...edge,
-          animated: isSwinging,
-          style: { stroke: isSwinging ? "#5eb663" : "#8e8e8e", strokeWidth: isSwinging ? 3 : 2 },
-          markerEnd: { type: MarkerType.ArrowClosed, color: isSwinging ? "#5eb663" : "#8e8e8e" },
-        };
-      }),
-    );
-  }, [tick, orchestrator]);
+//   useEffect(() => {
+//     setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data, currentTick: tick } })));
+//     setEdges((eds) =>
+//       eds.map((edge, i) => {
+//         const ins = orchestrator.getInserters()[i];
+//         const isSwinging = ins.state > 0 && ins.state < 4;
+//         return {
+//           ...edge,
+//           animated: isSwinging,
+//           style: { stroke: isSwinging ? "#5eb663" : "#8e8e8e", strokeWidth: isSwinging ? 3 : 2 },
+//           markerEnd: { type: MarkerType.ArrowClosed, color: isSwinging ? "#5eb663" : "#8e8e8e" },
+//         };
+//       }),
+//     );
+//   }, [tick, orchestrator]);
 
 
-  return (
-    <div className={styles.graphWrapper}>
-      <ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView>
-        <Background color="#5f5f5f" gap={16} />
-        <Controls />
-      </ReactFlow>
-    </div>
-  );
-}
+//   return (
+//     <div className={styles.graphWrapper}>
+//       <ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView>
+//         <Background color="#5f5f5f" gap={16} />
+//         <Controls />
+//       </ReactFlow>
+//     </div>
+//   );
+// }

+ 1 - 1
src/engine/Dashboard/types.ts

@@ -1,4 +1,4 @@
-import type { MachineSetup } from "../types";
+import type { MachineSetup } from "../model";
 
 
 export interface MachineData {
 export interface MachineData {
   id: string;
   id: string;

+ 120 - 0
src/engine/Topology/EdgeInspector.tsx

@@ -0,0 +1,120 @@
+import { useMemo } from "react";
+import Select, { type SelectOption } from "../../assets/components/Select";
+import Icon from "../../assets/icon";
+import { ContainerType } from "../simulator";
+import type { TopologyEdge, TopologyNode } from "./model";
+import styles from "./TopologyBuilder.module.css";
+
+interface EdgeInspectorProps {
+  edge: TopologyEdge;
+  nodes: Record<string, TopologyNode>;
+  onChange: (e: TopologyEdge) => void;
+}
+
+export default function EdgeInspector({ edge, nodes, onChange }: EdgeInspectorProps) {
+  const nodeList = Object.values(nodes);
+
+  // Auto-generate item options from the connected nodes' recipes!
+  const itemOptions = useMemo(() => {
+    const source = nodes[edge.sourceId];
+    const dest = nodes[edge.destinationId];
+
+    const optionsMap = new Map<string, SelectOption>();
+
+    const addOptions = (items: { name: string }[]) => {
+      items.forEach((item) => {
+        if (!optionsMap.has(item.name)) {
+          // Format "copper-cable" to "Copper cable"
+          const label = item.name.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase());
+          optionsMap.set(item.name, { id: item.name, label });
+        }
+      });
+    };
+
+    // If source is a machine, we can extract its results
+    if (source?.type === ContainerType.Machine && source.machineConfig?.recipe?.results) {
+      addOptions(source.machineConfig.recipe.results as any);
+    }
+
+    // If destination is a machine, we can insert its ingredients
+    if (dest?.type === ContainerType.Machine && dest.machineConfig?.recipe?.ingredients) {
+      addOptions(dest.machineConfig.recipe.ingredients as any);
+    }
+
+    return Array.from(optionsMap.values());
+  }, [edge.sourceId, edge.destinationId, nodes]);
+
+  // Helper renderers for the Select component
+  const renderItemWithIcon = (opt: SelectOption | undefined) => {
+    if (!opt) return null;
+    return (
+      <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
+        <Icon iconName={`item/${opt.id}.png`} size={24} />
+        <span>{opt.label}</span>
+      </div>
+    );
+  };
+
+  return (
+    <div className={styles.panel}>
+      <h2>Edit Inserter</h2>
+
+      <div className={styles.formGroup}>
+        <label>Source Container</label>
+        <select
+          className={styles.input}
+          value={edge.sourceId}
+          onChange={(e) => onChange({ ...edge, sourceId: e.target.value, itemId: "" })} // Clear item if source changes
+        >
+          <option value="">-- Select Source --</option>
+          {nodeList.map((n) => (
+            <option key={n.id} value={n.id}>
+              {n.name}
+            </option>
+          ))}
+        </select>
+      </div>
+
+      <div className={styles.formGroup}>
+        <label>Destination Container</label>
+        <select
+          className={styles.input}
+          value={edge.destinationId}
+          onChange={(e) => onChange({ ...edge, destinationId: e.target.value, itemId: "" })} // Clear item if dest changes
+        >
+          <option value="">-- Select Destination --</option>
+          {nodeList.map((n) => (
+            <option key={n.id} value={n.id}>
+              {n.name}
+            </option>
+          ))}
+        </select>
+      </div>
+
+      <div className={styles.formGroup}>
+        <label>Target Item</label>
+        <Select
+          options={itemOptions}
+          value={edge.itemId ?? ""}
+          onChange={(itemId) => onChange({ ...edge, itemId })}
+          placeholder={itemOptions.length === 0 ? "Connect machines to see items" : "Select item..."}
+          renderOption={renderItemWithIcon}
+          renderValue={renderItemWithIcon}
+          isDark={true}
+        />
+      </div>
+
+      <div className={styles.formGroup}>
+        <label>Inserter Hand Size (Stack Capacity)</label>
+        <input
+          type="number"
+          min="1"
+          max="12"
+          value={edge.stackSize}
+          onChange={(e) => onChange({ ...edge, stackSize: Math.max(1, parseInt(e.target.value, 10) || 1) })}
+          className={styles.input}
+        />
+      </div>
+    </div>
+  );
+}

+ 85 - 0
src/engine/Topology/InserterEdge.tsx

@@ -0,0 +1,85 @@
+import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from "@xyflow/react";
+import Icon from "../../assets/icon";
+import type { TopologyEdge } from "./model";
+
+type FlowEdgeData = TopologyEdge & Record<string, unknown> & { onEdgeSelect?: (id: string) => void };
+
+export default function InserterEdge({
+  id,
+  sourceX,
+  sourceY,
+  targetX,
+  targetY,
+  sourcePosition,
+  targetPosition,
+  style,
+  markerEnd,
+  data,
+}: EdgeProps<FlowEdgeData>) {
+  const [edgePath, labelX, labelY] = getBezierPath({
+    sourceX,
+    sourceY,
+    sourcePosition,
+    targetX,
+    targetY,
+    targetPosition,
+  });
+
+  const inserterIcon = (data?.inserterName as string) || "stack-inserter";
+  const inserterQuality = (data?.inserterQuality as number) ?? 5;
+
+  // Backwards compatibility with itemId, preferring the new filters array
+  const filters = data?.filters?.length ? data.filters : data?.itemId ? [data.itemId] : [];
+
+  return (
+    <>
+      <BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
+      <EdgeLabelRenderer>
+        <div
+          style={{
+            position: "absolute",
+            transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
+            pointerEvents: "all",
+            display: "flex",
+            flexDirection: "column",
+            alignItems: "center",
+            justifyContent: "center",
+            background: "rgba(30, 30, 30, 0.8)",
+            padding: "4px",
+            borderRadius: "6px",
+            border: "1px solid #4a4a4a",
+            cursor: "pointer",
+          }}
+          className="nodrag nopan"
+          onClick={(e) => {
+            e.stopPropagation();
+            if (data?.onEdgeSelect) data.onEdgeSelect(id);
+          }}
+        >
+          {/* Filters List overlay (Up to 5) */}
+          {filters.length > 0 && (
+            <div
+              style={{
+                display: "flex",
+                gap: "2px",
+                position: "absolute",
+                bottom: -10,
+                right: -5,
+                background: "#1a1a1a",
+                padding: "2px 4px",
+                borderRadius: "4px",
+                border: "1px solid #3a3a3a",
+              }}
+            >
+              {filters.slice(0, 5).map((f) => (
+                <Icon key={f} iconName={`item/${f}.png`} size={16} />
+              ))}
+            </div>
+          )}
+
+          <Icon iconName={`item/${inserterIcon}.png`} size={32} qualityLevel={inserterQuality} />
+        </div>
+      </EdgeLabelRenderer>
+    </>
+  );
+}

+ 202 - 0
src/engine/Topology/NodeInspector.tsx

@@ -0,0 +1,202 @@
+import React from "react";
+import styles from "./TopologyBuilder.module.css";
+import type { TopologyNode } from "./model";
+import MachineSelector from "../../assets/Selector/MachineSelector";
+import ModuleSlots from "../../assets/Selector/ModuleSlots";
+import BeaconConfigurator from "../../assets/Selector/BeaconConfigurator";
+import { ContainerType } from "../simulator";
+import Icon from "../../assets/icon";
+
+export default function NodeInspector({ node, onChange }: { node: TopologyNode; onChange: (n: TopologyNode) => void }) {
+  const updateName = (e: React.ChangeEvent<HTMLInputElement>) => onChange({ ...node, name: e.target.value });
+
+  const updateMachineConfig = (updates: Partial<TopologyNode["machineConfig"]>) => {
+    if (!node.machineConfig) return;
+    onChange({
+      ...node,
+      machineConfig: { ...node.machineConfig, ...updates },
+    });
+  };
+
+  return (
+    <div className={styles.panel}>
+      <h2>Edit {node.type}</h2>
+
+      <div className={styles.formGroup}>
+        <label>Node Name (ID: {node.id.split("_")[1]})</label>
+        <input value={node.name} onChange={updateName} />
+      </div>
+
+      {node.type === ContainerType.Machine && node.machineConfig && (
+        <div className={styles.machineConfig}>
+          <div className={styles.formGroup}>
+            <label>Multiplier (Parallel Instances)</label>
+            <input
+              type="number"
+              min="1"
+              value={node.machineConfig.multiplier}
+              onChange={(e) => updateMachineConfig({ multiplier: parseInt(e.target.value, 10) || 1 })}
+            />
+          </div>
+
+          <div className={styles.formGroup}>
+            <label>Machine Type</label>
+            <MachineSelector
+              key={node.id}
+              value={{
+                machine: node.machineConfig.setup.machine,
+                qualityLevel: node.machineConfig.setup.machineQualityLevel || 0,
+                recipe: node.machineConfig.recipe,
+              }}
+              onChange={({ machine, qualityLevel, recipe }) =>
+                updateMachineConfig({
+                  setup: {
+                    ...node.machineConfig!.setup,
+                    machine: machine as any,
+                    machineQualityLevel: qualityLevel,
+                  },
+                  recipe,
+                })
+              }
+            />
+          </div>
+
+          <div className={styles.formGroup}>
+            <label>Modules</label>
+            <ModuleSlots
+              value={node.machineConfig.setup.machineModules}
+              maxSlots={node.machineConfig.setup.machine?.module_slots || 0}
+              allowedEffects={node.machineConfig.setup.machine?.allowed_effects as string[]}
+              onChange={(modules) =>
+                updateMachineConfig({
+                  setup: { ...node.machineConfig!.setup, machineModules: modules },
+                })
+              }
+            />
+          </div>
+
+          <div className={styles.formGroup}>
+            <label>Beacons</label>
+            <BeaconConfigurator
+              groups={node.machineConfig.setup.beacons}
+              onChange={(beacons) =>
+                updateMachineConfig({
+                  setup: { ...node.machineConfig!.setup, beacons },
+                })
+              }
+            />
+          </div>
+        </div>
+      )}
+      {node.validationReport && (
+        <div
+          style={{
+            marginTop: "16px",
+            background: node.isBottleneck ? "rgba(217, 97, 79, 0.1)" : "rgba(76, 175, 80, 0.1)",
+            border: `1px solid ${node.isBottleneck ? "#d9614f" : "#4CAF50"}`,
+            borderRadius: "6px",
+            padding: "12px",
+          }}
+        >
+          <h4 style={{ margin: "0 0 8px 0", display: "flex", alignItems: "center", gap: "8px" }}>
+            Performance Report
+            {node.isBottleneck ? (
+              <span title="Bottlenecked" style={{ cursor: "help" }}>
+                ⚠️
+              </span>
+            ) : (
+              <span title="Running smoothly" style={{ cursor: "help" }}>
+                ✅
+              </span>
+            )}
+          </h4>
+
+          {node.type === ContainerType.Machine && (
+            <div style={{ marginBottom: "12px", fontSize: "13px" }}>
+              <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
+                <span style={{ color: "#aaa" }}>Demanded:</span>
+                <strong style={{ color: node.isBottleneck ? "#d9614f" : "#fff" }}>
+                  {node.validationReport.demandedCrafts.toFixed(2)} crafts/s
+                </strong>
+              </div>
+              <div style={{ display: "flex", justifyContent: "space-between" }}>
+                <span style={{ color: "#aaa" }}>Max Capacity:</span>
+                <strong>{node.validationReport.maxCrafts.toFixed(2)} crafts/s</strong>
+              </div>
+
+              {/* Progress Bar */}
+              <div
+                style={{
+                  height: "6px",
+                  background: "#1a1a1a",
+                  borderRadius: "3px",
+                  marginTop: "8px",
+                  overflow: "hidden",
+                }}
+              >
+                <div
+                  style={{
+                    height: "100%",
+                    background: node.isBottleneck ? "#d9614f" : "#4CAF50",
+                    width: `${Math.min(100, (node.validationReport.demandedCrafts / Math.max(0.01, node.validationReport.maxCrafts)) * 100)}%`,
+                  }}
+                />
+              </div>
+            </div>
+          )}
+
+          {/* Inputs & Outputs */}
+          <div style={{ display: "flex", gap: "16px", fontSize: "12px" }}>
+            {Object.keys(node.validationReport.inputs).length > 0 && (
+              <div style={{ flex: 1 }}>
+                <span
+                  style={{
+                    color: "#aaa",
+                    borderBottom: "1px solid #4a4a4a",
+                    display: "block",
+                    paddingBottom: "2px",
+                    marginBottom: "4px",
+                  }}
+                >
+                  Pulling (Inputs)
+                </span>
+                {Object.entries(node.validationReport.inputs).map(([item, rate]) => (
+                  <div key={item} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
+                    <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
+                      <Icon iconName={`item/${item}.png`} size={14} /> {item}
+                    </div>
+                    <span>{Number(rate).toFixed(1)}/s</span>
+                  </div>
+                ))}
+              </div>
+            )}
+
+            {Object.keys(node.validationReport.outputs).length > 0 && (
+              <div style={{ flex: 1 }}>
+                <span
+                  style={{
+                    color: "#aaa",
+                    borderBottom: "1px solid #4a4a4a",
+                    display: "block",
+                    paddingBottom: "2px",
+                    marginBottom: "4px",
+                  }}
+                >
+                  Pushing (Outputs)
+                </span>
+                {Object.entries(node.validationReport.outputs).map(([item, rate]) => (
+                  <div key={item} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
+                    <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
+                      <Icon iconName={`item/${item}.png`} size={14} /> {item}
+                    </div>
+                    <span>{Number(rate).toFixed(1)}/s</span>
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 207 - 0
src/engine/Topology/TopologyBuilder.module.css

@@ -0,0 +1,207 @@
+/* TopologyBuilder.module.css */
+
+.container {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  min-height: 600px;
+  background-color: #1a1a1a;
+  color: #e3e3e3;
+  border: 1px solid #3a3a3a;
+  border-radius: 6px;
+  overflow: hidden;
+  font-family:
+    system-ui,
+    -apple-system,
+    sans-serif;
+}
+
+/* ========================================== */
+/* LEFT PANE: Sidebar                         */
+/* ========================================== */
+
+.sidebar {
+  width: 320px;
+  min-width: 320px;
+  background-color: #212121;
+  border-right: 1px solid #3a3a3a;
+  display: flex;
+  flex-direction: column;
+}
+
+.sidebarHeader {
+  padding: 16px;
+  border-bottom: 1px solid #3a3a3a;
+}
+
+.sidebarHeader h3 {
+  margin: 0 0 12px 0;
+  font-size: 16px;
+  color: #fff;
+  font-weight: 600;
+}
+
+.actions {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.actions button {
+  background-color: #333;
+  border: 1px solid #4a4a4a;
+  color: #e3e3e3;
+  padding: 4px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 12px;
+  transition:
+    background-color 0.15s,
+    border-color 0.15s;
+}
+
+.actions button:hover {
+  background-color: #444;
+  border-color: #e39827; /* Factorio Orange Accent */
+}
+
+.listSection {
+  padding: 12px;
+  overflow-y: auto;
+  flex-grow: 1;
+}
+
+.listSection h4 {
+  margin: 0 0 8px 4px;
+  font-size: 12px;
+  text-transform: uppercase;
+  color: #888;
+  letter-spacing: 0.5px;
+}
+
+.listItem {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 12px;
+  margin-bottom: 4px;
+  background-color: transparent;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  border: 1px solid transparent;
+  transition: background-color 0.15s;
+}
+
+.listItem:hover {
+  background-color: #2a2a2a;
+}
+
+.listItem.active {
+  background-color: #2d2d2d;
+  border: 1px solid #e39827;
+  color: #fff;
+}
+
+.badge {
+  font-size: 10px;
+  padding: 2px 6px;
+  background-color: #3a3a3a;
+  color: #aaa;
+  border-radius: 12px;
+  text-transform: uppercase;
+}
+
+.listItem.active .badge {
+  background-color: #e39827;
+  color: #1a1a1a;
+  font-weight: bold;
+}
+
+/* ========================================== */
+/* RIGHT PANE: Inspector                      */
+/* ========================================== */
+
+.inspector {
+  width: 400px;
+  background-color: #1a1a1a;
+  overflow-y: auto;
+  position: relative;
+  flex-shrink: 0;
+  border-left: 1px solid #3a3a3a;
+  background: #1a1a1a;
+  overflow-y: auto;
+}
+
+.emptyState {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  color: #666;
+  font-size: 14px;
+  font-style: italic;
+}
+
+.panel {
+  padding: 24px;
+  max-width: 600px;
+}
+
+.panel h2 {
+  margin: 0 0 24px 0;
+  font-size: 20px;
+  color: #fff;
+  border-bottom: 1px solid #3a3a3a;
+  padding-bottom: 12px;
+}
+
+/* ========================================== */
+/* FORMS                                      */
+/* ========================================== */
+
+.formGroup {
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: column;
+}
+
+.formGroup label {
+  margin-bottom: 6px;
+  font-size: 13px;
+  color: #aaa;
+  font-weight: 500;
+}
+
+.input {
+  background-color: #212121;
+  border: 1px solid #3a3a3a;
+  color: #e3e3e3;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+  outline: none;
+  transition: border-color 0.2s;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.input:focus {
+  border-color: #e39827;
+}
+
+/* For standard select elements before replacing with your Custom Select */
+select.input {
+  appearance: none;
+  background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23888888%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
+  background-repeat: no-repeat;
+  background-position: right 12px top 50%;
+  background-size: 10px auto;
+  padding-right: 32px;
+}
+
+.machineConfig {
+  margin-top: 16px;
+  padding-top: 16px;
+  border-top: 1px dashed #3a3a3a;
+}

+ 499 - 0
src/engine/Topology/TopologyBuilder.tsx

@@ -0,0 +1,499 @@
+import { useState, useRef, useCallback, useMemo } from "react";
+import {
+  ReactFlow,
+  Background,
+  Controls,
+  applyNodeChanges,
+  applyEdgeChanges,
+  addEdge as connectEdges,
+  type Node,
+  type Edge,
+  type Connection,
+  type NodeChange,
+  type EdgeChange,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+
+import styles from "./TopologyBuilder.module.css";
+import type { TopologyEdge, TopologyNode, ValidationReport } from "./model";
+import { ContainerType } from "../simulator";
+import NodeInspector from "./NodeInspector";
+import EdgeInspector from "./EdgeInspector";
+import TopologyNodeUI from "./TopologyNodeUI";
+import InserterEdge from "./InserterEdge";
+import { downloadJSON } from "../../utils";
+import { computeMachineStats } from "../stats";
+
+const nodeTypes = { containerNode: TopologyNodeUI };
+const edgeTypes = { inserterEdge: InserterEdge };
+
+type FlowNodeData = TopologyNode & Record<string, unknown>;
+type FlowEdgeData = TopologyEdge & Record<string, unknown> & { onEdgeSelect?: (id: string) => void };
+
+export default function TopologyBuilder() {
+  const [nodes, setNodes] = useState<Node<FlowNodeData>[]>([]);
+  const [edges, setEdges] = useState<Edge<FlowEdgeData>[]>([]);
+
+  const [selectedId, setSelectedId] = useState<string | null>(null);
+  const [selectionType, setSelectionType] = useState<"node" | "edge" | null>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const handleEdgeSelect = useCallback((id: string) => {
+    setSelectedId(id);
+    setSelectionType("edge");
+
+    setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
+    setNodes((nds) => nds.map((n) => ({ ...n, selected: false })));
+  }, []);
+
+  const onNodesChange = useCallback(
+    (changes: NodeChange<Node<FlowNodeData>>[]) =>
+      setNodes((nds) => applyNodeChanges(changes, nds) as Node<FlowNodeData>[]),
+    [],
+  );
+
+  const onEdgesChange = useCallback(
+    (changes: EdgeChange<Edge<FlowEdgeData>>[]) =>
+      setEdges((eds) => applyEdgeChanges(changes, eds) as Edge<FlowEdgeData>[]),
+    [],
+  );
+
+  const onConnect = useCallback(
+    (connection: Connection) => {
+      const id = `edge_${crypto.randomUUID().slice(0, 8)}`;
+
+      // Auto-detect filter from the target handle ID!
+      // Example: "in-iron-plate" -> extracts "iron-plate"
+      let autoFilters: string[] = [];
+      let autoItemId = "";
+
+      if (
+        connection.targetHandle &&
+        connection.targetHandle.startsWith("in-") &&
+        connection.targetHandle !== "in-any"
+      ) {
+        const extractedItem = connection.targetHandle.replace("in-", "");
+        autoFilters = [extractedItem];
+        autoItemId = extractedItem; // Set both just in case other parts of your app still rely on itemId
+      }
+
+      // Save to Domain Model
+      const newDomainEdge: TopologyEdge = {
+        id,
+        sourceId: connection.source,
+        destinationId: connection.target,
+        sourceHandle: connection.sourceHandle,
+        targetHandle: connection.targetHandle,
+        itemId: autoItemId,
+        filters: autoFilters, // <-- Automatically applied!
+        stackSize: 16,
+      };
+
+      // Save to React Flow
+      const newFlowEdge: Edge<FlowEdgeData> = {
+        id,
+        source: connection.source,
+        target: connection.target,
+        sourceHandle: connection.sourceHandle,
+        targetHandle: connection.targetHandle,
+        type: "inserterEdge",
+        data: { ...newDomainEdge, onEdgeSelect: handleEdgeSelect },
+        animated: true,
+      };
+
+      setEdges((eds) => connectEdges(newFlowEdge, eds) as Edge<FlowEdgeData>[]);
+      setSelectedId(id);
+      setSelectionType("edge");
+    },
+    [handleEdgeSelect],
+  );
+
+  const addNode = (type: ContainerType) => {
+    const shortHash = crypto.randomUUID().slice(0, 4).toUpperCase();
+    const id = `node_${crypto.randomUUID().slice(0, 8)}`;
+    let defaultName = `Node ${shortHash}`;
+    if (type === ContainerType.Machine) defaultName = `Assembler ${shortHash}`;
+    if (type === ContainerType.Chest) defaultName = `Chest ${shortHash}`;
+    if (type === ContainerType.Belt) defaultName = `Belt ${shortHash}`;
+
+    const flowNode: Node<FlowNodeData> = {
+      id,
+      type: "containerNode",
+      position: { x: Math.random() * 200 + 100, y: Math.random() * 200 + 100 },
+      data: {
+        id,
+        name: defaultName,
+        type,
+        ...(type === ContainerType.Machine
+          ? {
+              machineConfig: {
+                setup: { machine: null as any, machineModules: [], beacons: [], machineQualityLevel: 0 },
+                recipe: null,
+                multiplier: 1,
+              },
+            }
+          : {}),
+      },
+    };
+    setNodes((nds) => [...nds, flowNode]);
+    setSelectedId(id);
+    setSelectionType("node");
+  };
+
+  const domainNodesMap = useMemo(() => {
+    return nodes.reduce((acc, n) => ({ ...acc, [n.id]: n.data }), {} as Record<string, TopologyNode>);
+  }, [nodes]);
+
+  const updateDomainNode = (updated: TopologyNode) => {
+    setNodes((nds) => nds.map((n) => (n.id === updated.id ? { ...n, data: { ...n.data, ...updated } } : n)));
+  };
+
+  const updateDomainEdge = (updated: TopologyEdge) => {
+    setEdges((eds) =>
+      eds.map((e) =>
+        e.id === updated.id
+          ? {
+              ...e,
+              source: updated.sourceId,
+              target: updated.destinationId,
+              sourceHandle: updated.sourceHandle,
+              targetHandle: updated.targetHandle,
+              data: { ...e.data, ...updated, onEdgeSelect: handleEdgeSelect },
+            }
+          : e,
+      ),
+    );
+  };
+
+  const handleExport = () => downloadJSON({ nodes, edges }, `topology-${Date.now()}.json`);
+
+  const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (!file) return;
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      try {
+        const payload = JSON.parse(e.target?.result as string);
+        const restoredEdges = (payload.edges || []).map((edge: any) => ({
+          ...edge,
+          data: { ...edge.data, onEdgeSelect: handleEdgeSelect },
+        }));
+        setNodes(payload.nodes || []);
+        setEdges(restoredEdges);
+      } catch (err) {
+        alert("Invalid file.");
+      }
+    };
+    reader.readAsText(file);
+    if (fileInputRef.current) fileInputRef.current.value = "";
+  };
+  const onNodesDelete = useCallback(
+    (deleted: Node<FlowNodeData>[]) => {
+      if (deleted.some((n) => n.id === selectedId)) {
+        setSelectedId(null);
+        setSelectionType(null);
+      }
+    },
+    [selectedId],
+  );
+
+  const onEdgesDelete = useCallback(
+    (deleted: Edge<FlowEdgeData>[]) => {
+      if (deleted.some((e) => e.id === selectedId)) {
+        setSelectedId(null);
+        setSelectionType(null);
+      }
+    },
+    [selectedId],
+  );
+  const handleValidateTopology = () => {
+    const outEdges: Record<string, typeof edges> = {};
+    const inEdges: Record<string, typeof edges> = {};
+    nodes.forEach((n) => {
+      outEdges[n.id] = [];
+      inEdges[n.id] = [];
+    });
+    edges.forEach((e) => {
+      outEdges[e.source]?.push(e);
+      inEdges[e.target]?.push(e);
+    });
+
+    const machineStats: Record<string, { maxCraftsPerSec: number; prodMultiplier: number; recipe: any }> = {};
+
+    nodes.forEach((n) => {
+      if (
+        n.data.type === ContainerType.Machine &&
+        n.data.machineConfig?.recipe &&
+        n.data.machineConfig?.setup?.machine
+      ) {
+        const timings = computeMachineStats(n.data.machineConfig.setup, n.data.machineConfig.recipe);
+        console.log(n.data.machineConfig.recipe.name, timings.craftsPerSecond, timings.actualCraftingSpeed);
+        machineStats[n.id] = {
+          maxCraftsPerSec: timings.craftsPerSecond,
+          // `productivityBonus` is e.g. 0.4. Multiplier becomes 1.4
+          prodMultiplier: 1 + timings.productivityBonus,
+          recipe: n.data.machineConfig.recipe,
+        };
+      }
+    });
+
+    const edgeFlows: Record<string, number> = {};
+    const edgeLimits: Record<string, number> = {};
+    const nodeRequiredInputs: Record<string, Record<string, number>> = {};
+    const nodeBottlenecks: Record<string, boolean> = {};
+    const nodeReports: Record<string, ValidationReport> = {};
+
+    nodes.forEach((n) => {
+      nodeRequiredInputs[n.id] = {};
+      nodeBottlenecks[n.id] = false;
+      nodeReports[n.id] = { demandedCrafts: 0, maxCrafts: 0, inputs: {}, outputs: {} }; // <-- ADD THIS
+    });
+
+    const queue = nodes.filter((n) => outEdges[n.id].length === 0 || n.data.role === "void").map((n) => n.id);
+    const inQueue = new Set(queue);
+    const visitCount: Record<string, number> = {};
+
+    while (queue.length > 0) {
+      const nodeId = queue.shift()!;
+      inQueue.delete(nodeId);
+
+      if ((visitCount[nodeId] || 0) > nodes.length + 2) continue;
+      visitCount[nodeId] = (visitCount[nodeId] || 0) + 1;
+
+      const node = nodes.find((n) => n.id === nodeId)!;
+      const isEndNode = outEdges[nodeId].length === 0 || node.data.role === "void";
+
+      // Calculate Cumulated Downstream Demand
+      const requiredOutputs: Record<string, number> = {};
+      outEdges[nodeId].forEach((e) => {
+        const flow = edgeFlows[e.id] || 0;
+        let item = (e.data?.computedItem as string) || e.data?.filters?.[0] || e.data?.itemId;
+        if (!item && e.targetHandle?.startsWith("in-") && e.targetHandle !== "in-any")
+          item = e.targetHandle.replace("in-", "");
+
+        if (item && flow > 0) {
+          requiredOutputs[item] = (requiredOutputs[item] || 0) + flow;
+        }
+      });
+
+      if (node.data.type === ContainerType.Machine && machineStats[nodeId]) {
+        const stats = machineStats[nodeId];
+        let demandedCrafts = 0;
+
+        // PATHFINDER: Does this machine eventually dump into a Sink without hitting another machine?
+        const goesToSink = (() => {
+          if (isEndNode) return true;
+          const stack = outEdges[nodeId].map((e) => e.target);
+          const visited = new Set<string>();
+          while (stack.length > 0) {
+            const curr = stack.pop()!;
+            if (visited.has(curr)) continue;
+            visited.add(curr);
+            const n = nodes.find((nx) => nx.id === curr);
+            if (!n) continue;
+            if (outEdges[curr].length === 0 || n.data.role === "void") return true;
+            if (n.data.type === ContainerType.Machine) continue; // Blocked by downstream machine demand
+            outEdges[curr].forEach((e) => stack.push(e.target));
+          }
+          return false;
+        })();
+
+        if (goesToSink) {
+          // If it dumps into a chest, it wants to run at 100% capacity!
+          demandedCrafts = stats.maxCraftsPerSec;
+        } else {
+          Object.keys(requiredOutputs).forEach((item) => {
+            const res = stats.recipe.results?.find((r: any) => r.name === item);
+            const yieldPerCraft = (res?.amount || res?.amount_min || 1) * stats.prodMultiplier;
+            const craftsForThis = requiredOutputs[item] / yieldPerCraft;
+            if (craftsForThis > demandedCrafts) demandedCrafts = craftsForThis;
+          });
+        }
+
+        nodeBottlenecks[nodeId] = demandedCrafts > stats.maxCraftsPerSec;
+        const actualCraftsToDraw = Math.min(demandedCrafts, stats.maxCraftsPerSec);
+
+        // Demand ingredients upstream
+        stats.recipe.ingredients?.forEach((ing: any) => {
+          nodeRequiredInputs[nodeId][ing.name] = actualCraftsToDraw * ing.amount;
+        });
+        const producedOutputs: Record<string, number> = {};
+        stats.recipe.results?.forEach((res: any) => {
+          const yieldPerCraft = (res.amount || res.amount_min || 1) * stats.prodMultiplier;
+          producedOutputs[res.name] = actualCraftsToDraw * yieldPerCraft;
+        });
+        nodeReports[nodeId] = {
+          demandedCrafts: demandedCrafts,
+          maxCrafts: stats.maxCraftsPerSec,
+          inputs: { ...nodeRequiredInputs[nodeId] },
+          outputs: producedOutputs,
+        };
+        outEdges[nodeId].forEach((e) => {
+          let item = e.data?.computedItem || e.data?.filters?.[0] || e.data?.itemId;
+          if (!item) item = stats.recipe.results?.[0]?.name;
+
+          if (item) {
+            const res = stats.recipe.results?.find((r: any) => r.name === item);
+            const yieldPerCraft = (res?.amount || res?.amount_min || 1) * stats.prodMultiplier;
+
+            const outEdgesForItem = outEdges[nodeId].filter((oe) => {
+              let oeItem = oe.data?.computedItem || oe.data?.filters?.[0] || oe.data?.itemId;
+              if (!oeItem) oeItem = stats.recipe.results?.[0]?.name;
+              return oeItem === item;
+            });
+
+            const flowPerEdge = (actualCraftsToDraw * yieldPerCraft) / (outEdgesForItem.length || 1);
+            edgeFlows[e.id] = flowPerEdge;
+            e.data.computedItem = item;
+          }
+        });
+      } else {
+        // Belts/Chests pass demand straight through
+        nodeRequiredInputs[nodeId] = { ...requiredOutputs };
+      }
+
+      // Propagate Required Inputs Upstream (Set edge flows for incoming edges)
+      const inGroups: Record<string, typeof edges> = {};
+      inEdges[nodeId].forEach((e) => {
+        let item = e.data?.filters?.[0] || e.data?.itemId;
+        if (!item && e.targetHandle?.startsWith("in-") && e.targetHandle !== "in-any")
+          item = e.targetHandle.replace("in-", "");
+
+        if (!item && nodes.find((n) => n.id === e.source)?.data.type === ContainerType.Machine) {
+          const srcStats = machineStats[e.source];
+          if (srcStats) item = srcStats.recipe.results?.[0]?.name;
+        }
+
+        if (item) {
+          if (!inGroups[item]) inGroups[item] = [];
+          inGroups[item].push(e);
+          e.data.computedItem = item;
+        }
+      });
+
+      Object.keys(inGroups).forEach((item) => {
+        const edgesForItem = inGroups[item];
+        const totalReq = nodeRequiredInputs[nodeId][item] || 0;
+        const flowPerEdge = totalReq / edgesForItem.length;
+
+        edgesForItem.forEach((e) => {
+          // Only set the flow if it wasn't already forcefully set by a Machine pushing into it
+          if (edgeFlows[e.id] === undefined) {
+            edgeFlows[e.id] = flowPerEdge;
+          }
+
+          const srcNode = nodes.find((n) => n.id === e.source)!.data;
+          const tgtNode = node.data;
+          const isSrcInv = srcNode.type === ContainerType.Chest || srcNode.type === ContainerType.Machine;
+          const isTgtInv = tgtNode.type === ContainerType.Chest || tgtNode.type === ContainerType.Machine;
+
+          let limit = 80;
+          if (isSrcInv && isTgtInv) limit = 120;
+          else if (isSrcInv && tgtNode.type === ContainerType.Belt) limit = 80;
+          else if (srcNode.type === ContainerType.Belt && isTgtInv) limit = 80;
+
+          edgeLimits[e.id] = limit;
+
+          if (!inQueue.has(e.source)) {
+            queue.push(e.source);
+            inQueue.add(e.source);
+          }
+        });
+      });
+    }
+
+    setNodes((nds) =>
+      nds.map((n) => ({
+        ...n,
+        data: { ...n.data, isBottleneck: nodeBottlenecks[n.id] || false, validationReport: nodeReports[n.id] },
+      })),
+    );
+
+    setEdges((eds) =>
+      eds.map((e) => {
+        const flow = edgeFlows[e.id] || 0;
+        const max = edgeLimits[e.id] || 80;
+        return {
+          ...e,
+          data: {
+            ...e.data,
+            computedThroughput: Number(flow.toFixed(2)),
+            maxThroughput: max,
+            isBottleneck: flow > max,
+          },
+        };
+      }),
+    );
+  };
+  return (
+    <div className={styles.container} style={{ display: "flex", height: "100vh", width: "100%" }}>
+      <input type="file" accept=".json" ref={fileInputRef} style={{ display: "none" }} onChange={handleImport} />
+
+      {/* LEFT PANE: React Flow Canvas (Flex Grow ensures it takes most of the screen) */}
+      <div style={{ flexGrow: 1, minWidth: 0, position: "relative" }}>
+        <div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "8px" }}>
+          <button className="button small" onClick={() => addNode(ContainerType.Machine)}>
+            + Machine
+          </button>
+          <button className="button small" onClick={() => addNode(ContainerType.Chest)}>
+            + Chest
+          </button>
+          <button className="button small" onClick={() => addNode(ContainerType.Belt)}>
+            + Belt
+          </button>
+          <div style={{ width: "1px", background: "#4a4a4a", margin: "0 4px" }} /> {/* Divider */}
+          <button className="button small" onClick={handleValidateTopology}>
+            Check topology
+          </button>
+          <button className="button small" onClick={() => fileInputRef.current?.click()}>
+            📂 Import
+          </button>
+          <button className="button small" onClick={handleExport}>
+            💾 Export
+          </button>
+        </div>
+
+        <ReactFlow
+          nodes={nodes}
+          edges={edges}
+          onNodesChange={onNodesChange}
+          onEdgesChange={onEdgesChange}
+          onNodesDelete={onNodesDelete}
+          onEdgesDelete={onEdgesDelete}
+          onConnect={onConnect}
+          nodeTypes={nodeTypes}
+          edgeTypes={edgeTypes}
+          onNodeClick={(_, node) => {
+            setSelectedId(node.id);
+            setSelectionType("node");
+          }}
+          onEdgeClick={(_, edge) => handleEdgeSelect(edge.id)}
+          onPaneClick={() => {
+            setSelectedId(null);
+            setSelectionType(null);
+          }}
+          deleteKeyCode={["Backspace", "Delete"]}
+          fitView
+          theme="dark"
+        >
+          <Background color="#3a3a3a" gap={20} />
+          <Controls />
+        </ReactFlow>
+      </div>
+
+      {/* RIGHT PANE: Inspectors (Fixed Width) */}
+      <div className={styles.inspector}>
+        {selectionType === "node" && selectedId && domainNodesMap[selectedId] && (
+          <NodeInspector node={domainNodesMap[selectedId]} onChange={updateDomainNode} />
+        )}
+        {selectionType === "edge" && selectedId && edges.find((e) => e.id === selectedId) && (
+          <EdgeInspector
+            edge={edges.find((e) => e.id === selectedId)!.data}
+            nodes={domainNodesMap}
+            onChange={updateDomainEdge}
+          />
+        )}
+      </div>
+    </div>
+  );
+}

+ 180 - 0
src/engine/Topology/TopologyBuilderV0.tsx

@@ -0,0 +1,180 @@
+import { useState, useRef } from "react";
+import styles from "./TopologyBuilder.module.css";
+import type { TopologyEdge, TopologyNode } from "./model";
+import { ContainerType } from "../simulator";
+import NodeInspector from "./NodeInspector";
+import EdgeInspector from "./EdgeInspector";
+import { downloadJSON } from "../../utils";
+
+export default function TopologyBuilder() {
+  const [nodes, setNodes] = useState<Record<string, TopologyNode>>({});
+  const [edges, setEdges] = useState<Record<string, TopologyEdge>>({});
+
+  const [selectedId, setSelectedId] = useState<string | null>(null);
+  const [selectionType, setSelectionType] = useState<"node" | "edge" | null>(null);
+
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const addNode = (type: ContainerType) => {
+    const id = `node_${crypto.randomUUID().slice(0, 8)}`;
+    setNodes((prev) => ({
+      ...prev,
+      [id]: {
+        id,
+        name: `New ${type}`,
+        type,
+        ...(type === ContainerType.Machine
+          ? {
+              machineConfig: {
+                setup: { machine: null as any, machineModules: [], beacons: [], machineQualityLevel: 0 },
+                recipe: null,
+                multiplier: 1,
+              },
+            }
+          : {}),
+      },
+    }));
+    setSelectedId(id);
+    setSelectionType("node");
+  };
+
+  const addEdge = () => {
+    const id = `edge_${crypto.randomUUID().slice(0, 8)}`;
+    setEdges((prev) => ({
+      ...prev,
+      // Default to Legendary Stack Inserter capacity (16)
+      [id]: { id, sourceId: "", destinationId: "", itemId: "", stackSize: 16 },
+    }));
+    setSelectedId(id);
+    setSelectionType("edge");
+  };
+
+  // --- IMPORT / EXPORT LOGIC ---
+  const handleExport = () => {
+    const payload = { nodes, edges };
+    downloadJSON(payload, `topology-${new Date().toISOString().slice(0, 10)}.json`);
+  };
+
+  const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (!file) return;
+
+    const reader = new FileReader();
+    reader.onload = (e) => {
+      try {
+        const payload = JSON.parse(e.target?.result as string);
+        if (payload.nodes && payload.edges) {
+          setNodes(payload.nodes);
+          setEdges(payload.edges);
+          setSelectedId(null);
+          setSelectionType(null);
+        } else {
+          alert("Invalid topology file format.");
+        }
+      } catch (err) {
+        console.error("Failed to parse JSON", err);
+        alert("Failed to read file. Make sure it is a valid JSON.");
+      }
+    };
+    reader.readAsText(file);
+
+    // Reset input so the same file can be imported again if needed
+    if (fileInputRef.current) {
+      fileInputRef.current.value = "";
+    }
+  };
+
+  return (
+    <div className={styles.container}>
+      {/* Hidden file input for importing JSON */}
+      <input type="file" accept=".json" ref={fileInputRef} style={{ display: "none" }} onChange={handleImport} />
+
+      {/* LEFT PANE: Directory / Graph List */}
+      <div className={styles.sidebar}>
+        <div className={styles.sidebarHeader}>
+          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px" }}>
+            <h3 style={{ margin: 0 }}>Topology</h3>
+            <div>
+              <button className="button icon" onClick={() => fileInputRef.current?.click()} title="Import JSON">
+                📂
+              </button>
+              <button className="button icon" onClick={handleExport} title="Export JSON">
+                💾
+              </button>
+            </div>
+          </div>
+          <div className={styles.actions}>
+            <button onClick={() => addNode(ContainerType.Machine)}>+ Machine</button>
+            <button onClick={() => addNode(ContainerType.Chest)}>+ Chest</button>
+            <button onClick={() => addNode(ContainerType.Belt)}>+ Belt</button>
+            <button onClick={addEdge} style={{ marginLeft: "8px" }}>
+              + Inserter
+            </button>
+          </div>
+        </div>
+
+        <div className={styles.listSection}>
+          <h4>Nodes</h4>
+          {Object.values(nodes).map((node) => (
+            <div
+              key={node.id}
+              className={`${styles.listItem} ${selectedId === node.id ? styles.active : ""}`}
+              onClick={() => {
+                setSelectedId(node.id);
+                setSelectionType("node");
+              }}
+            >
+              {node.name} <span className={styles.badge}>{node.type}</span>
+            </div>
+          ))}
+
+          <h4 style={{ marginTop: "16px" }}>Inserters (Edges)</h4>
+          {Object.values(edges).map((edge) => {
+            const srcName = nodes[edge.sourceId]?.name || "?";
+            const dstName = nodes[edge.destinationId]?.name || "?";
+            return (
+              <div
+                key={edge.id}
+                className={`${styles.listItem} ${selectedId === edge.id ? styles.active : ""}`}
+                onClick={() => {
+                  setSelectedId(edge.id);
+                  setSelectionType("edge");
+                }}
+              >
+                {srcName} → {dstName}
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* RIGHT PANE: Inspector */}
+      <div className={styles.inspector}>
+        {selectionType === "node" && selectedId && nodes[selectedId] && (
+          <NodeInspector
+            node={nodes[selectedId]}
+            onChange={(updated) => setNodes((prev) => ({ ...prev, [updated.id]: updated }))}
+          />
+        )}
+
+        {selectionType === "edge" && selectedId && edges[selectedId] && (
+          <EdgeInspector
+            edge={edges[selectedId]}
+            nodes={nodes}
+            onChange={(updated) => setEdges((prev) => ({ ...prev, [updated.id]: updated }))}
+          />
+        )}
+
+        {!selectedId && (
+          <div className={styles.emptyState}>
+            {Object.keys(nodes).length === 0 ? (
+              <span>Start by adding a Machine, Chest, or Belt on the left.</span>
+            ) : (
+              <span>Select a node or inserter to configure</span>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 176 - 0
src/engine/Topology/TopologyNodeUI.tsx

@@ -0,0 +1,176 @@
+import { Handle, Position } from "@xyflow/react";
+import { ContainerType } from "../simulator";
+import type { TopologyNode } from "./model";
+import Icon from "../../assets/icon";
+
+type FlowNodeData = TopologyNode & Record<string, unknown>;
+
+export default function TopologyNodeUI({ data, selected }: { data: FlowNodeData; selected: boolean }) {
+  // End-game defaults
+  let iconName = "item/steel-chest.png";
+  if (data.type === ContainerType.Belt) iconName = "item/turbo-transport-belt.png";
+  if (data.type === ContainerType.Machine) {
+    iconName = data.machineConfig?.setup.machine?.icon || "item/assembling-machine-1.png";
+  }
+
+  // Source/Sink logic (Computed state if passed down, otherwise visual fallbacks)
+  const isVoid = data.role === "void";
+  const isSource = data.role === "source";
+  const isBottleneck = data.isBottleneck;
+  const providedItemIcon = data.providedItemId ? `item/${data.providedItemId}.png` : null;
+  const recipeIcon = data.machineConfig?.recipe?.icon;
+
+  // Machine Input Handles (Any + Specific Ingredients)
+  const ingredients = data.machineConfig?.recipe?.ingredients || [];
+  const handleCount = data.type === ContainerType.Machine ? 1 + ingredients.length : 1;
+
+  const getHandleTop = (index: number, total: number) => `${((index + 1) / (total + 1)) * 100}%`;
+
+  return (
+    <div
+      style={{
+        background: isVoid ? "#2a1a1a" : isSource ? "#1a2a1a" : "#242324",
+        border: `2px solid ${selected ? "#e39827" : isBottleneck ? "#ff3333" : isVoid ? "#d9614f" : isSource ? "#4CAF50" : "#3a3a3a"}`,
+        padding: "12px 10px",
+        borderRadius: "8px",
+        minWidth: "130px",
+        textAlign: "center",
+        color: "#e3e3e3",
+        boxShadow: isBottleneck
+          ? "0 0 15px rgba(255, 51, 51, 0.5)"
+          : selected
+            ? "0 0 10px rgba(227, 152, 39, 0.2)"
+            : "none",
+        transition: "all 0.2s ease-in-out",
+      }}
+    >
+      {/* INPUT HANDLES */}
+      {data.type === ContainerType.Machine ? (
+        <>
+          {/* Generic "Any" Input */}
+          <Handle
+            type="target"
+            position={Position.Left}
+            id="in-any"
+            style={{
+              top: getHandleTop(0, handleCount),
+              background: "#999",
+              border: "2px solid #242324",
+              width: 12,
+              height: 12,
+            }}
+            title="Any Input"
+          />
+
+          {/* Specific Ingredient Inputs */}
+          {ingredients.map((ing: any, i: number) => (
+            <Handle
+              key={ing.name}
+              type="target"
+              position={Position.Left}
+              id={`in-${ing.name}`}
+              style={{
+                top: getHandleTop(i + 1, handleCount),
+                background: "transparent",
+                border: "none",
+                left: -0, // Push the icon slightly outside the box
+                width: 24,
+                height: 24,
+                display: "flex",
+                alignItems: "center",
+                justifyContent: "center",
+              }}
+              title={ing.name}
+            >
+              {/* pointerEvents: 'none' ensures the Handle itself catches the drag event, not the image */}
+              <div
+                style={{
+                  pointerEvents: "none",
+                  background: "#1a1a1a",
+                  borderRadius: "50%",
+                  padding: "2px",
+                  display: "flex",
+                }}
+              >
+                <Icon iconName={`item/${ing.name}.png`} size={16} />
+              </div>
+            </Handle>
+          ))}
+        </>
+      ) : (
+        // Standard single input for chests/belts
+        <Handle
+          type="target"
+          position={Position.Left}
+          id="in-any"
+          style={{ width: 12, height: 12, background: "#4CAF50", border: "2px solid #242324" }}
+        />
+      )}
+
+      {/* NODE CONTENT */}
+      <div style={{ position: "relative", display: "inline-block" }}>
+        <Icon iconName={iconName} size={48} qualityLevel={data.machineConfig?.setup.machineQualityLevel || 0} />
+
+        {isSource && providedItemIcon && (
+          <div
+            style={{
+              position: "absolute",
+              top: -10,
+              right: -10,
+              background: "#1a1a1a",
+              borderRadius: "50%",
+              border: "2px solid #4CAF50",
+            }}
+          >
+            <Icon iconName={providedItemIcon} size={24} />
+          </div>
+        )}
+
+        {isVoid && (
+          <div
+            style={{
+              position: "absolute",
+              top: -10,
+              right: -10,
+              background: "#d9614f",
+              color: "#fff",
+              borderRadius: "50%",
+              width: 24,
+              height: 24,
+              fontSize: 16,
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+            }}
+            title="Void (Destroys Items)"
+          >
+            🗑️
+          </div>
+        )}
+
+        {/* Recipe Badge */}
+        {recipeIcon && (
+          <div
+            style={{
+              position: "absolute",
+              bottom: -10,
+              right: -10,
+            }}
+          >
+            <Icon iconName={recipeIcon} size={24} />
+          </div>
+        )}
+      </div>
+
+      <div style={{ fontSize: "12px", marginTop: "14px", fontWeight: 500 }}>{data.name}</div>
+
+      {/* OUTPUT HANDLE */}
+      <Handle
+        type="source"
+        position={Position.Right}
+        id="out-any"
+        style={{ width: 12, height: 12, background: "#F44336", border: "2px solid #242324" }}
+      />
+    </div>
+  );
+}

+ 39 - 0
src/engine/Topology/model.ts

@@ -0,0 +1,39 @@
+import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
+import type { MachineSetup, CalculatedTimings, BatchPlan, BeaconGroup } from "../model";
+import type { ContainerType } from "../simulator";
+
+export interface ValidationReport {
+  demandedCrafts: number;
+  maxCrafts: number;
+  inputs: Record<string, number>;
+  outputs: Record<string, number>;
+}
+export interface TopologyNode {
+  id: string;
+  name: string; // User-friendly name (e.g., "Copper Smelter A")
+  type: ContainerType;
+  machineConfig?: {
+    setup: Omit<MachineSetup, "beacons"> & { beacons: Array<BeaconGroup & { id: string }> };
+    recipe: Recipe | null;
+    multiplier: number;
+    timings?: CalculatedTimings;
+    batch?: BatchPlan;
+  };
+  isBottleneck?: boolean;
+  validationReport?: ValidationReport;
+}
+
+export interface TopologyEdge {
+  id: string;
+  sourceId: string;
+  destinationId: string;
+  sourceHandle?: string | null;
+  targetHandle?: string | null;
+  itemId?: string;
+  filters?: string[];
+  stackSize: number;
+  inserterName?: string;
+  computedThroughput?: number; // What the machine actually demands/produces
+  maxThroughput?: number; // The physical limit (120 or 80)
+  isBottleneck?: boolean;
+}

+ 121 - 114
src/engine/batch.test.ts

@@ -1,22 +1,22 @@
-import { describe, it, expect } from 'vitest';
-import { calculateOptimalBatch } from './batch';
-import type { Recipe } from '../../scripts/factorio-dump/helpers/recipes.helper';
-import type { CalculatedTimings } from './types';
+import { describe, it, expect } from "vitest";
+import { calculateOptimalBatch } from "./batch";
+import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type { CalculatedTimings } from "./model";
 
 
-describe('Batch Calculator', () => {
-  it('calculates optimal batch for Copper Cable (1 plate -> 2 cables)', () => {
+describe("Batch Calculator", () => {
+  it("calculates optimal batch for Copper Cable (1 plate -> 2 cables)", () => {
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
       energy_required: 0.5,
       energy_required: 0.5,
-      ingredients: [{ type: 'item', name: 'copper-plate', amount: 1 }],
-      results: [{ type: 'item', name: 'copper-cable', amount: 2 }]
+      ingredients: [{ type: "item", name: "copper-plate", amount: 1 }],
+      results: [{ type: "item", name: "copper-cable", amount: 2 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
       actualCraftingSpeed: 1,
       actualCraftingSpeed: 1,
       productivityBonus: 0,
       productivityBonus: 0,
       singleCraftTicks: 30,
       singleCraftTicks: 30,
       craftsPerSecond: 2,
       craftsPerSecond: 2,
-      overloadMultiplier: 8
+      overloadMultiplier: 8,
     };
     };
 
 
     // With a stack size of 16:
     // With a stack size of 16:
@@ -27,48 +27,49 @@ describe('Batch Calculator', () => {
 
 
     expect(batch.craftsPerCycle).toBe(16);
     expect(batch.craftsPerCycle).toBe(16);
     expect(batch.durationTicks).toBe(16 * 30); // 480 ticks
     expect(batch.durationTicks).toBe(16 * 30); // 480 ticks
-    expect(batch.inputs['copper-plate'].totalAmount).toBe(16); // 1 full stack
-    expect(batch.outputs['copper-cable'].totalAmount).toBe(32); // 2 full stacks
+    expect(batch.inputs["copper-plate"].totalAmount).toBe(16); // 1 full stack
+    expect(batch.outputs["copper-cable"].totalAmount).toBe(32); // 2 full stacks
   });
   });
 
 
-  it('factors in productivity yields correctly', () => {
+  it("factors in productivity yields correctly", () => {
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
-      ingredients: [{ type: 'item', name: 'iron-plate', amount: 2 }],
-      results: [{ type: 'item', name: 'iron-gear-wheel', amount: 1 }]
+      ingredients: [{ type: "item", name: "iron-plate", amount: 2 }],
+      results: [{ type: "item", name: "iron-gear-wheel", amount: 1 }],
     };
     };
-    
+
     // 50% productivity bonus means 1 craft yields 1.5 items
     // 50% productivity bonus means 1 craft yields 1.5 items
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
       actualCraftingSpeed: 1,
       actualCraftingSpeed: 1,
-      productivityBonus: 0.5, 
+      productivityBonus: 0.5,
       singleCraftTicks: 30,
       singleCraftTicks: 30,
       craftsPerSecond: 2,
       craftsPerSecond: 2,
-      overloadMultiplier: 8
+      overloadMultiplier: 8,
     };
     };
 
 
     const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
     const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
 
 
     expect(batch.craftsPerCycle).toBe(32);
     expect(batch.craftsPerCycle).toBe(32);
-    expect(batch.inputs['iron-plate'].totalAmount).toBe(64); // 4 input stacks
-    expect(batch.outputs['iron-gear-wheel'].totalAmount).toBe(48); // 3 output stacks
+    expect(batch.inputs["iron-plate"].totalAmount).toBe(64); // 4 input stacks
+    expect(batch.outputs["iron-gear-wheel"].totalAmount).toBe(48); // 3 output stacks
   });
   });
-  it('ignores fluid ingredients and results when calculating LCM', () => {
+  it("ignores fluid ingredients and results when calculating LCM", () => {
     // e.g. Battery recipe: 1 Iron Plate, 1 Copper Plate, 20 Sulfuric Acid (Fluid) -> 1 Battery
     // e.g. Battery recipe: 1 Iron Plate, 1 Copper Plate, 20 Sulfuric Acid (Fluid) -> 1 Battery
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
       energy_required: 4,
       energy_required: 4,
       ingredients: [
       ingredients: [
-        { type: 'item', name: 'iron-plate', amount: 1 },
-        { type: 'item', name: 'copper-plate', amount: 1 },
-        { type: 'fluid', name: 'sulfuric-acid', amount: 20 } // FLUID
+        { type: "item", name: "iron-plate", amount: 1 },
+        { type: "item", name: "copper-plate", amount: 1 },
+        { type: "fluid", name: "sulfuric-acid", amount: 20 }, // FLUID
       ],
       ],
-      results: [
-        { type: 'item', name: 'battery', amount: 1 }
-      ]
+      results: [{ type: "item", name: "battery", amount: 1 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
-      actualCraftingSpeed: 1, productivityBonus: 0, 
-      singleCraftTicks: 240, craftsPerSecond: 0.25, overloadMultiplier: 8
+      actualCraftingSpeed: 1,
+      productivityBonus: 0,
+      singleCraftTicks: 240,
+      craftsPerSecond: 0.25,
+      overloadMultiplier: 8,
     };
     };
 
 
     const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
     const batch = calculateOptimalBatch(mockRecipe as Recipe, timings);
@@ -76,103 +77,109 @@ describe('Batch Calculator', () => {
     // If fluids were not ignored, GCD/LCM math would break trying to divide 20 fluid by 16 items.
     // If fluids were not ignored, GCD/LCM math would break trying to divide 20 fluid by 16 items.
     // It should safely calculate LCM based ONLY on the item amounts (all are 1, so LCM = 16).
     // It should safely calculate LCM based ONLY on the item amounts (all are 1, so LCM = 16).
     expect(batch.craftsPerCycle).toBe(16);
     expect(batch.craftsPerCycle).toBe(16);
-    expect(batch.inputs['iron-plate']).toBeDefined();
-    expect(batch.inputs['sulfuric-acid']).toBeUndefined(); // Fluids should not be scheduled for inserters
+    expect(batch.inputs["iron-plate"]).toBeDefined();
+    expect(batch.inputs["sulfuric-acid"]).toBeUndefined(); // Fluids should not be scheduled for inserters
   });
   });
 
 
-  it('calculates the output block limit correctly (bottlenecked by overload multiplier)', () => {
+  it("calculates the output block limit correctly (bottlenecked by overload multiplier)", () => {
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
-      ingredients: [{ type: 'item', name: 'copper-plate', amount: 1 }],
-      results: [{ type: 'item', name: 'copper-cable', amount: 2 }]
+      ingredients: [{ type: "item", name: "copper-plate", amount: 1 }],
+      results: [{ type: "item", name: "copper-cable", amount: 2 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
-      actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 30, craftsPerSecond: 2,
-      overloadMultiplier: 4 // Very slow machine, low overload limit
+      actualCraftingSpeed: 1,
+      productivityBonus: 0,
+      singleCraftTicks: 30,
+      craftsPerSecond: 2,
+      overloadMultiplier: 4, // Very slow machine, low overload limit
     };
     };
 
 
     // We pass a max stack size of 200 for the cable.
     // We pass a max stack size of 200 for the cable.
-    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'copper-cable': 200 });
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { "copper-cable": 200 });
 
 
     // Output block limit is Math.min(maxStack, overloadMultiplier * resultAmount)
     // Output block limit is Math.min(maxStack, overloadMultiplier * resultAmount)
     // Math.min(200, 4 * 2) = 8
     // Math.min(200, 4 * 2) = 8
-    expect(batch.outputs['copper-cable'].outputBlockLimit).toBe(8);
+    expect(batch.outputs["copper-cable"].outputBlockLimit).toBe(8);
   });
   });
 
 
-  it('calculates the output block limit correctly (bottlenecked by max stack size)', () => {
+  it("calculates the output block limit correctly (bottlenecked by max stack size)", () => {
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
-      ingredients: [{ type: 'item', name: 'iron-plate', amount: 1 }],
-      results: [{ type: 'item', name: 'iron-gear-wheel', amount: 1 }]
+      ingredients: [{ type: "item", name: "iron-plate", amount: 1 }],
+      results: [{ type: "item", name: "iron-gear-wheel", amount: 1 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
-      actualCraftingSpeed: 10, productivityBonus: 0, singleCraftTicks: 3, craftsPerSecond: 20,
-      overloadMultiplier: 100 // Very fast machine, massive overload limit
+      actualCraftingSpeed: 10,
+      productivityBonus: 0,
+      singleCraftTicks: 3,
+      craftsPerSecond: 20,
+      overloadMultiplier: 100, // Very fast machine, massive overload limit
     };
     };
 
 
     // Iron gear wheels stack to 100 in Factorio
     // Iron gear wheels stack to 100 in Factorio
-    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'iron-gear-wheel': 100 });
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { "iron-gear-wheel": 100 });
 
 
     // Output block limit is Math.min(maxStack, overloadMultiplier * resultAmount)
     // Output block limit is Math.min(maxStack, overloadMultiplier * resultAmount)
     // Math.min(100, 100 * 1) = 100
     // Math.min(100, 100 * 1) = 100
-    expect(batch.outputs['iron-gear-wheel'].outputBlockLimit).toBe(100);
-    expect(batch.outputs['iron-gear-wheel'].totalAmount).toBe(96);
+    expect(batch.outputs["iron-gear-wheel"].outputBlockLimit).toBe(100);
+    expect(batch.outputs["iron-gear-wheel"].totalAmount).toBe(96);
   });
   });
-  it('handles complex multi-input recipes (Advanced Circuit) with productivity', () => {
+  it("handles complex multi-input recipes (Advanced Circuit) with productivity", () => {
     // Advanced Circuit: 2 Plastic, 4 Copper Cable, 2 Green Chips -> 1 Red Chip
     // Advanced Circuit: 2 Plastic, 4 Copper Cable, 2 Green Chips -> 1 Red Chip
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
       ingredients: [
       ingredients: [
-        { type: 'item', name: 'plastic-bar', amount: 2 },
-        { type: 'item', name: 'copper-cable', amount: 4 },
-        { type: 'item', name: 'electronic-circuit', amount: 2 }
+        { type: "item", name: "plastic-bar", amount: 2 },
+        { type: "item", name: "copper-cable", amount: 4 },
+        { type: "item", name: "electronic-circuit", amount: 2 },
       ],
       ],
-      results: [{ type: 'item', name: 'advanced-circuit', amount: 1 }]
+      results: [{ type: "item", name: "advanced-circuit", amount: 1 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
       actualCraftingSpeed: 84,
       actualCraftingSpeed: 84,
-      productivityBonus: 1.75, 
-      singleCraftTicks: 4.2857142857142865, 
+      productivityBonus: 1.75,
+      singleCraftTicks: 4.2857142857142865,
       craftsPerSecond: 14,
       craftsPerSecond: 14,
-      overloadMultiplier: 17
+      overloadMultiplier: 17,
     };
     };
 
 
-    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'advanced-circuit': 200 });
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { "advanced-circuit": 200 });
 
 
     expect(batch.craftsPerCycle).toBe(64);
     expect(batch.craftsPerCycle).toBe(64);
     expect(batch.durationTicks).toBe(275);
     expect(batch.durationTicks).toBe(275);
     // 64 crafts * 2 per craft = 128 (8 inserter swings of 16)
     // 64 crafts * 2 per craft = 128 (8 inserter swings of 16)
-    expect(batch.inputs['plastic-bar'].totalAmount).toBe(128); 
-    
+    expect(batch.inputs["plastic-bar"].totalAmount).toBe(128);
+
     // 64 crafts * 4 per craft = 256 (16 inserter swings of 16)
     // 64 crafts * 4 per craft = 256 (16 inserter swings of 16)
-    expect(batch.inputs['copper-cable'].totalAmount).toBe(256); 
-    
+    expect(batch.inputs["copper-cable"].totalAmount).toBe(256);
+
     // 64 crafts * 2 per craft = 128 (8 inserter swings of 16)
     // 64 crafts * 2 per craft = 128 (8 inserter swings of 16)
-    expect(batch.inputs['electronic-circuit'].totalAmount).toBe(128); 
-    
+    expect(batch.inputs["electronic-circuit"].totalAmount).toBe(128);
+
     // 64 crafts * 2.75 yield = 176 (11 inserter swings of 16)
     // 64 crafts * 2.75 yield = 176 (11 inserter swings of 16)
-    expect(batch.outputs['advanced-circuit'].totalAmount).toBe(176); 
-    expect(batch.outputs['advanced-circuit'].outputBlockLimit).toBe(17);
+    expect(batch.outputs["advanced-circuit"].totalAmount).toBe(176);
+    expect(batch.outputs["advanced-circuit"].outputBlockLimit).toBe(17);
   });
   });
-  it('handles prime-number fractions in extreme productivity scenarios (The Direct-Insertion Anchor)', () => {
+  it("handles prime-number fractions in extreme productivity scenarios (The Direct-Insertion Anchor)", () => {
     // Productivity Module 1: 5 Advanced Circuit, 5 Electronic Circuit -> 1 Prod Module
     // Productivity Module 1: 5 Advanced Circuit, 5 Electronic Circuit -> 1 Prod Module
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
       ingredients: [
       ingredients: [
-        { type: 'item', name: 'advanced-circuit', amount: 5 },
-        { type: 'item', name: 'electronic-circuit', amount: 5 }
+        { type: "item", name: "advanced-circuit", amount: 5 },
+        { type: "item", name: "electronic-circuit", amount: 5 },
       ],
       ],
-      results: [{ type: 'item', name: 'productivity-module', amount: 1 }]
+      results: [{ type: "item", name: "productivity-module", amount: 1 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
       actualCraftingSpeed: 5,
       actualCraftingSpeed: 5,
       productivityBonus: 1.15, // +115% yield (Yields 2.15 items per craft)
       productivityBonus: 1.15, // +115% yield (Yields 2.15 items per craft)
-      singleCraftTicks: 60, 
+      singleCraftTicks: 60,
       craftsPerSecond: 1,
       craftsPerSecond: 1,
-      overloadMultiplier: 4 
+      overloadMultiplier: 4,
     };
     };
 
 
-    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'productivity-module': 50 });
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { "productivity-module": 50 });
 
 
     // --- The Math Breakdown ---
     // --- The Math Breakdown ---
     // 1. Productivity Yield:
     // 1. Productivity Yield:
@@ -191,37 +198,37 @@ describe('Batch Calculator', () => {
     //    LCM(16, 320) = 320 crafts per cycle.
     //    LCM(16, 320) = 320 crafts per cycle.
 
 
     expect(batch.craftsPerCycle).toBe(320);
     expect(batch.craftsPerCycle).toBe(320);
-    
+
     // Inputs: 320 crafts * 5 per craft = 1600.
     // Inputs: 320 crafts * 5 per craft = 1600.
     // 1600 / 16 = Exactly 100 perfect inserter swings per cycle!
     // 1600 / 16 = Exactly 100 perfect inserter swings per cycle!
-    expect(batch.inputs['advanced-circuit'].totalAmount).toBe(1600); 
-    expect(batch.inputs['electronic-circuit'].totalAmount).toBe(1600); 
-    
+    expect(batch.inputs["advanced-circuit"].totalAmount).toBe(1600);
+    expect(batch.inputs["electronic-circuit"].totalAmount).toBe(1600);
+
     // Outputs: 320 crafts * 2.15 yield = 688 items.
     // Outputs: 320 crafts * 2.15 yield = 688 items.
     // 688 / 16 = Exactly 43 perfect inserter swings per cycle!
     // 688 / 16 = Exactly 43 perfect inserter swings per cycle!
-    expect(batch.outputs['productivity-module'].totalAmount).toBe(688); 
+    expect(batch.outputs["productivity-module"].totalAmount).toBe(688);
   });
   });
-  it('safely caps batch scaling on high-volume output recipes to prevent buffer overflow', () => {
+  it("safely caps batch scaling on high-volume output recipes to prevent buffer overflow", () => {
     // Copper Cable: 1 Copper Plate -> 2 Copper Cable
     // Copper Cable: 1 Copper Plate -> 2 Copper Cable
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
-      ingredients: [{ type: 'item', name: 'copper-plate', amount: 1 }],
-      results: [{ type: 'item', name: 'copper-cable', amount: 2 }]
+      ingredients: [{ type: "item", name: "copper-plate", amount: 1 }],
+      results: [{ type: "item", name: "copper-cable", amount: 2 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
       actualCraftingSpeed: 10,
       actualCraftingSpeed: 10,
       productivityBonus: 2.5, // +250% (Total yield = 2 * 3.5 = 7 cables per craft)
       productivityBonus: 2.5, // +250% (Total yield = 2 * 3.5 = 7 cables per craft)
-      singleCraftTicks: 6, 
+      singleCraftTicks: 6,
       craftsPerSecond: 10,
       craftsPerSecond: 10,
-      overloadMultiplier: 50 // Machine can hold 50 * 1 plate = 50 plates buffer
+      overloadMultiplier: 50, // Machine can hold 50 * 1 plate = 50 plates buffer
     };
     };
 
 
     // We restrict the cable output buffer to max stack (200)
     // We restrict the cable output buffer to max stack (200)
-    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'copper-cable': 200 });
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { "copper-cable": 200 });
 
 
     // --- The Math Breakdown ---
     // --- The Math Breakdown ---
     // Output Yield = 7 cables per craft.
     // Output Yield = 7 cables per craft.
-    // Base LCM: 
+    // Base LCM:
     // Inputs (1): 16 / gcd(1, 16) = 16 crafts.
     // Inputs (1): 16 / gcd(1, 16) = 16 crafts.
     // Outputs (yield 7): Denominator (16) / gcd(7, 16) = 16 crafts.
     // Outputs (yield 7): Denominator (16) / gcd(7, 16) = 16 crafts.
     // baseOptimalN = 16 crafts.
     // baseOptimalN = 16 crafts.
@@ -233,33 +240,33 @@ describe('Batch Calculator', () => {
     //
     //
     // WAIT! maxSafeCrafts (14) is LESS than baseOptimalN (16).
     // WAIT! maxSafeCrafts (14) is LESS than baseOptimalN (16).
     // The engine CANNOT safely scale down below the base LCM, or else we lose perfect 16-stack swings.
     // The engine CANNOT safely scale down below the base LCM, or else we lose perfect 16-stack swings.
-    // It should fallback to baseOptimalN (16) and rely on the Timeline generator's 
+    // It should fallback to baseOptimalN (16) and rely on the Timeline generator's
     // drip-feed logic to keep the machine from jamming.
     // drip-feed logic to keep the machine from jamming.
 
 
     expect(batch.craftsPerCycle).toBe(16); // Remains at the absolute mathematical minimum
     expect(batch.craftsPerCycle).toBe(16); // Remains at the absolute mathematical minimum
-    expect(batch.inputs['copper-plate'].totalAmount).toBe(16);
-    expect(batch.outputs['copper-cable'].totalAmount).toBe(112); // 16 * 7
+    expect(batch.inputs["copper-plate"].totalAmount).toBe(16);
+    expect(batch.outputs["copper-cable"].totalAmount).toBe(112); // 16 * 7
   });
   });
-  it('handles Legendary Max-Out (+100% Prod) with a highly restrictive overload limit', () => {
+  it("handles Legendary Max-Out (+100% Prod) with a highly restrictive overload limit", () => {
     // Chemical Science Pack: 2 Engine, 3 Adv Circuit, 1 Sulfur -> 2 Chemical Science Packs
     // Chemical Science Pack: 2 Engine, 3 Adv Circuit, 1 Sulfur -> 2 Chemical Science Packs
     const mockRecipe: Partial<Recipe> = {
     const mockRecipe: Partial<Recipe> = {
       ingredients: [
       ingredients: [
-        { type: 'item', name: 'engine-unit', amount: 2 },
-        { type: 'item', name: 'advanced-circuit', amount: 3 },
-        { type: 'item', name: 'sulfur', amount: 1 }
+        { type: "item", name: "engine-unit", amount: 2 },
+        { type: "item", name: "advanced-circuit", amount: 3 },
+        { type: "item", name: "sulfur", amount: 1 },
       ],
       ],
-      results: [{ type: 'item', name: 'chemical-science-pack', amount: 2 }]
+      results: [{ type: "item", name: "chemical-science-pack", amount: 2 }],
     };
     };
-    
+
     const timings: CalculatedTimings = {
     const timings: CalculatedTimings = {
       actualCraftingSpeed: 10,
       actualCraftingSpeed: 10,
       productivityBonus: 1.0, // +100% (4 Legendary Prod 3 Modules)
       productivityBonus: 1.0, // +100% (4 Legendary Prod 3 Modules)
-      singleCraftTicks: 14.4, 
+      singleCraftTicks: 14.4,
       craftsPerSecond: 4.16,
       craftsPerSecond: 4.16,
-      overloadMultiplier: 3 // Extremely restrictive! Machine will stall almost instantly.
+      overloadMultiplier: 3, // Extremely restrictive! Machine will stall almost instantly.
     };
     };
 
 
-    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { 'chemical-science-pack': 200 });
+    const batch = calculateOptimalBatch(mockRecipe as Recipe, timings, { "chemical-science-pack": 200 });
 
 
     // --- The Math Breakdown ---
     // --- The Math Breakdown ---
     // 1. Productivity: Yield is 2 * 2.0 = 4 items per craft. (prod fraction = 2/1)
     // 1. Productivity: Yield is 2 * 2.0 = 4 items per craft. (prod fraction = 2/1)
@@ -276,28 +283,28 @@ describe('Batch Calculator', () => {
     //
     //
     // 4. Base LCM = LCM(16, 4) = 16 crafts.
     // 4. Base LCM = LCM(16, 4) = 16 crafts.
     //
     //
-    // 5. Buffer Limits (maxSafeCrafts): 
+    // 5. Buffer Limits (maxSafeCrafts):
     //    Output limit = min(200, 3 (overload) * 2 (amount)) = 6 items max in output buffer!
     //    Output limit = min(200, 3 (overload) * 2 (amount)) = 6 items max in output buffer!
-    //    Yield per craft is 4. 
+    //    Yield per craft is 4.
     //    Max safe crafts = Math.floor(6 / 4) = 1 craft!
     //    Max safe crafts = Math.floor(6 / 4) = 1 craft!
     //
     //
-    
+
     expect(batch.craftsPerCycle).toBe(16);
     expect(batch.craftsPerCycle).toBe(16);
-    
+
     // Inputs:
     // Inputs:
     // Engine: 16 * 2 = 32 (Exactly 2 swings of 16)
     // Engine: 16 * 2 = 32 (Exactly 2 swings of 16)
-    expect(batch.inputs['engine-unit'].totalAmount).toBe(32);
-    
+    expect(batch.inputs["engine-unit"].totalAmount).toBe(32);
+
     // Adv Circuit: 16 * 3 = 48 (Exactly 3 swings of 16)
     // Adv Circuit: 16 * 3 = 48 (Exactly 3 swings of 16)
-    expect(batch.inputs['advanced-circuit'].totalAmount).toBe(48);
-    
+    expect(batch.inputs["advanced-circuit"].totalAmount).toBe(48);
+
     // Sulfur: 16 * 1 = 16 (Exactly 1 swing of 16)
     // Sulfur: 16 * 1 = 16 (Exactly 1 swing of 16)
-    expect(batch.inputs['sulfur'].totalAmount).toBe(16);
-    
+    expect(batch.inputs["sulfur"].totalAmount).toBe(16);
+
     // Output: 16 * 4 yield = 64 items (Exactly 4 swings of 16)
     // Output: 16 * 4 yield = 64 items (Exactly 4 swings of 16)
-    expect(batch.outputs['chemical-science-pack'].totalAmount).toBe(64); 
-    
+    expect(batch.outputs["chemical-science-pack"].totalAmount).toBe(64);
+
     // Validates that the engine properly identified the output bottleneck
     // Validates that the engine properly identified the output bottleneck
-    expect(batch.outputs['chemical-science-pack'].outputBlockLimit).toBe(6); 
+    expect(batch.outputs["chemical-science-pack"].outputBlockLimit).toBe(6);
   });
   });
-});
+});

+ 13 - 15
src/engine/batch.ts

@@ -1,8 +1,7 @@
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
-import type { CalculatedTimings, BatchPlan } from "./types";
+import type { CalculatedTimings, BatchPlan } from "./model";
 import { gcd, lcm, floatToFraction } from "./math";
 import { gcd, lcm, floatToFraction } from "./math";
 
 
-
 function getMinimumCraftsForOutput(amount: number, prodRatio: { num: number; den: number }, stackSize: number): number {
 function getMinimumCraftsForOutput(amount: number, prodRatio: { num: number; den: number }, stackSize: number): number {
   const numerator = amount * prodRatio.num;
   const numerator = amount * prodRatio.num;
   const denominator = stackSize * prodRatio.den;
   const denominator = stackSize * prodRatio.den;
@@ -11,7 +10,7 @@ function getMinimumCraftsForOutput(amount: number, prodRatio: { num: number; den
 /**
 /**
  * Calculates the optimal number of crafts (N) to group into one clock cycle.
  * Calculates the optimal number of crafts (N) to group into one clock cycle.
  * Ensures that all inputs and outputs move in exact multiples of the inserter stack size.
  * Ensures that all inputs and outputs move in exact multiples of the inserter stack size.
- * 
+ *
  * @param recipe The recipe to process.
  * @param recipe The recipe to process.
  * @param timings Calculated timings from computeMachineStats.
  * @param timings Calculated timings from computeMachineStats.
  * @param itemStackSizes A map of the maximum inventory stack size for specific items.
  * @param itemStackSizes A map of the maximum inventory stack size for specific items.
@@ -26,32 +25,30 @@ export function calculateOptimalBatch(
   const baselineStack = 16; // Standard fully researched stack size
   const baselineStack = 16; // Standard fully researched stack size
   // Process Solid Ingredients
   // Process Solid Ingredients
   const solidIngredients = (recipe.ingredients || []).filter((ing) => ing.type === "item");
   const solidIngredients = (recipe.ingredients || []).filter((ing) => ing.type === "item");
-  const hasIngredients = solidIngredients.length>0
+  const hasIngredients = solidIngredients.length > 0;
   for (const ing of solidIngredients) {
   for (const ing of solidIngredients) {
     const requiredN = baselineStack / gcd(ing.amount, baselineStack);
     const requiredN = baselineStack / gcd(ing.amount, baselineStack);
     optimalN = lcm(optimalN, requiredN);
     optimalN = lcm(optimalN, requiredN);
   }
   }
-  let maxSafeCrafts = hasIngredients? timings.overloadMultiplier : Infinity;
-  
+  let maxSafeCrafts = hasIngredients ? timings.overloadMultiplier : Infinity;
+
   // Process Solid Products
   // Process Solid Products
   const solidResults = (recipe.results || []).filter((res) => res.type === "item");
   const solidResults = (recipe.results || []).filter((res) => res.type === "item");
   for (const res of solidResults) {
   for (const res of solidResults) {
-    const amount = res.amount ?? res.amount_min ?? 1
-    const requiredN = getMinimumCraftsForOutput(amount,prodMultiplier,baselineStack)
+    const amount = res.amount ?? res.amount_min ?? 1;
+    const requiredN = getMinimumCraftsForOutput(amount, prodMultiplier, baselineStack);
     optimalN = lcm(optimalN, requiredN);
     optimalN = lcm(optimalN, requiredN);
 
 
     const maxStack = itemStackSizes[res.name] ?? 50;
     const maxStack = itemStackSizes[res.name] ?? 50;
-    const outputBlockQuantity = hasIngredients
-      ? Math.min(maxStack, timings.overloadMultiplier * amount) 
-      : maxStack;
-      
+    const outputBlockQuantity = hasIngredients ? Math.min(maxStack, timings.overloadMultiplier * amount) : maxStack;
+
     const yieldPerCraft = (amount * prodMultiplier.num) / prodMultiplier.den;
     const yieldPerCraft = (amount * prodMultiplier.num) / prodMultiplier.den;
     maxSafeCrafts = Math.min(maxSafeCrafts, Math.floor(outputBlockQuantity / yieldPerCraft));
     maxSafeCrafts = Math.min(maxSafeCrafts, Math.floor(outputBlockQuantity / yieldPerCraft));
   }
   }
 
 
   if (maxSafeCrafts !== Infinity && maxSafeCrafts > optimalN) {
   if (maxSafeCrafts !== Infinity && maxSafeCrafts > optimalN) {
     const scaleFactor = Math.floor(maxSafeCrafts / optimalN);
     const scaleFactor = Math.floor(maxSafeCrafts / optimalN);
-    optimalN *= scaleFactor; 
+    optimalN *= scaleFactor;
   }
   }
   // Compile final totals
   // Compile final totals
   const inputs: BatchPlan["inputs"] = {};
   const inputs: BatchPlan["inputs"] = {};
@@ -66,7 +63,8 @@ export function calculateOptimalBatch(
 
 
     const maxItemStack = itemStackSizes[res.name] ?? 50;
     const maxItemStack = itemStackSizes[res.name] ?? 50;
     // Factorio Output Block Logic: machine stalls if buffer exceeds min(StackSize, OverloadMultiplier * ResultAmount)
     // Factorio Output Block Logic: machine stalls if buffer exceeds min(StackSize, OverloadMultiplier * ResultAmount)
-    const outputBlockQuantity = recipe.ingredients && recipe.ingredients.length > 0
+    const outputBlockQuantity =
+      recipe.ingredients && recipe.ingredients.length > 0
         ? Math.min(maxItemStack, timings.overloadMultiplier * amount)
         ? Math.min(maxItemStack, timings.overloadMultiplier * amount)
         : maxItemStack;
         : maxItemStack;
 
 
@@ -85,4 +83,4 @@ export function calculateOptimalBatch(
     inputs,
     inputs,
     outputs,
     outputs,
   };
   };
-}
+}

+ 74 - 50
src/engine/timeline.test.ts → src/engine/clockGenerator.test.ts

@@ -1,46 +1,52 @@
-import { describe, it, expect } from 'vitest';
-import { generateAdvancedClock } from './timeline';
-import type { BatchPlan, ClockConfig } from './types';
+import { describe, it, expect } from "vitest";
+import { generateAdvancedClock } from "./clockGenerator";
+import type { BatchPlan, ClockConfig } from "./model";
 
 
 function assertBlocksWithinBounds(timeline: ReturnType<typeof generateAdvancedClock>) {
 function assertBlocksWithinBounds(timeline: ReturnType<typeof generateAdvancedClock>) {
-  timeline.blocks.forEach(block => {
+  timeline.blocks.forEach((block) => {
     expect(block.start).toBeGreaterThanOrEqual(0);
     expect(block.start).toBeGreaterThanOrEqual(0);
     expect(block.start).toBeLessThan(timeline.duration);
     expect(block.start).toBeLessThan(timeline.duration);
   });
   });
 }
 }
 
 
-describe('Timeline Generator', () => {
-  it('schedules simple 1:1 recipes perfectly (Input anticipates, Output extracts after craft)', () => {
+describe("Timeline Generator", () => {
+  it("schedules simple 1:1 recipes perfectly (Input anticipates, Output extracts after craft)", () => {
     // A slow machine doing a 1-to-1 craft. 16 crafts = 960 ticks.
     // A slow machine doing a 1-to-1 craft. 16 crafts = 960 ticks.
     const mockBatch: BatchPlan = {
     const mockBatch: BatchPlan = {
       craftsPerCycle: 16,
       craftsPerCycle: 16,
       durationTicks: 960, // 16 crafts * 60 ticks
       durationTicks: 960, // 16 crafts * 60 ticks
-      timings: { actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 60, craftsPerSecond: 1, overloadMultiplier: 2 },
-      inputs: { 'iron-plate': { totalAmount: 16, baseAmount: 1 } },
-      outputs: { 'iron-gear-wheel': { totalAmount: 16, baseAmount: 1, yieldPerCraft: 1, outputBlockLimit: 2 } }
+      timings: {
+        actualCraftingSpeed: 1,
+        productivityBonus: 0,
+        singleCraftTicks: 60,
+        craftsPerSecond: 1,
+        overloadMultiplier: 2,
+      },
+      inputs: { "iron-plate": { totalAmount: 16, baseAmount: 1 } },
+      outputs: { "iron-gear-wheel": { totalAmount: 16, baseAmount: 1, yieldPerCraft: 1, outputBlockLimit: 2 } },
     };
     };
 
 
     const mockConfig: ClockConfig = {
     const mockConfig: ClockConfig = {
-      inputs: { 'iron-plate': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'in' } },
-      outputs: { 'iron-gear-wheel': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'out' } },
-      machineCount: 1
+      inputs: { "iron-plate": { presetId: "chest", stackSize: 16, swingTicks: 8, inserterId: "in" } },
+      outputs: { "iron-gear-wheel": { presetId: "chest", stackSize: 16, swingTicks: 8, inserterId: "out" } },
+      machineCount: 1,
     };
     };
 
 
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
 
 
     expect(timeline.duration).toBe(960);
     expect(timeline.duration).toBe(960);
-    
+
     // Should generate exactly 1 input block and 1 output block (1 swing of 16 each)
     // Should generate exactly 1 input block and 1 output block (1 swing of 16 each)
     expect(timeline.blocks.length).toBe(2);
     expect(timeline.blocks.length).toBe(2);
-    const inBlock = timeline.blocks.find(b => b.rowId === 'row-in');
-    const outBlock = timeline.blocks.find(b => b.rowId === 'row-out');
+    const inBlock = timeline.blocks.find((b) => b.rowId === "row-in");
+    const outBlock = timeline.blocks.find((b) => b.rowId === "row-out");
 
 
     expect(inBlock).toBeDefined();
     expect(inBlock).toBeDefined();
     expect(outBlock).toBeDefined();
     expect(outBlock).toBeDefined();
 
 
-    // Input math: currentArrivalTick = 0. startTick = 0 - 8(swing) = -8. 
+    // Input math: currentArrivalTick = 0. startTick = 0 - 8(swing) = -8.
     // Wrapped to cycle: 960 - 8 = 952.
     // Wrapped to cycle: 960 - 8 = 952.
-    // This is perfect: the inserter starts swinging 8 ticks BEFORE the cycle technically starts, 
+    // This is perfect: the inserter starts swinging 8 ticks BEFORE the cycle technically starts,
     // so the items hit the machine exactly at Tick 0.
     // so the items hit the machine exactly at Tick 0.
     expect(inBlock!.start).toBe(952);
     expect(inBlock!.start).toBe(952);
     expect(inBlock!.repeat).toBe(1);
     expect(inBlock!.repeat).toBe(1);
@@ -53,32 +59,38 @@ describe('Timeline Generator', () => {
 
 
     assertBlocksWithinBounds(timeline);
     assertBlocksWithinBounds(timeline);
   });
   });
-  it('schedules mixed belts on the same row without overlapping', () => {
+  it("schedules mixed belts on the same row without overlapping", () => {
     const mockBatch: BatchPlan = {
     const mockBatch: BatchPlan = {
       craftsPerCycle: 16,
       craftsPerCycle: 16,
       durationTicks: 400,
       durationTicks: 400,
-      timings: { actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 25, craftsPerSecond: 2.4, overloadMultiplier: 100 },
+      timings: {
+        actualCraftingSpeed: 1,
+        productivityBonus: 0,
+        singleCraftTicks: 25,
+        craftsPerSecond: 2.4,
+        overloadMultiplier: 100,
+      },
       inputs: {
       inputs: {
-        'iron-plate': { totalAmount: 16, baseAmount: 1 },
-        'copper-plate': { totalAmount: 16, baseAmount: 1 }
+        "iron-plate": { totalAmount: 16, baseAmount: 1 },
+        "copper-plate": { totalAmount: 16, baseAmount: 1 },
       },
       },
-      outputs: {}
+      outputs: {},
     };
     };
 
 
     // Both inputs assigned to "in-1" (Mixed Belt)
     // Both inputs assigned to "in-1" (Mixed Belt)
     const mockConfig: ClockConfig = {
     const mockConfig: ClockConfig = {
       inputs: {
       inputs: {
-        'iron-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' },
-        'copper-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' }
+        "iron-plate": { presetId: "belt", stackSize: 16, swingTicks: 12, inserterId: "in-1" },
+        "copper-plate": { presetId: "belt", stackSize: 16, swingTicks: 12, inserterId: "in-1" },
       },
       },
-      outputs: {}
+      outputs: {},
     };
     };
 
 
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
 
 
     // Should only create 1 input row since they share 'in-1'
     // Should only create 1 input row since they share 'in-1'
     expect(timeline.rows.length).toBe(1);
     expect(timeline.rows.length).toBe(1);
-    expect(timeline.rows[0].id).toBe('row-in-1');
+    expect(timeline.rows[0].id).toBe("row-in-1");
     expect(timeline.rows[0].signals).toHaveLength(2); // Row has both iron and copper signals
     expect(timeline.rows[0].signals).toHaveLength(2); // Row has both iron and copper signals
 
 
     // Should create 2 blocks (one for iron, one for copper)
     // Should create 2 blocks (one for iron, one for copper)
@@ -87,65 +99,77 @@ describe('Timeline Generator', () => {
     // Verify they do not overlap
     // Verify they do not overlap
     const block1 = timeline.blocks[0];
     const block1 = timeline.blocks[0];
     const block2 = timeline.blocks[1];
     const block2 = timeline.blocks[1];
-    
+
     const block1End = block1.start + block1.duration;
     const block1End = block1.start + block1.duration;
     // Block 2 must start AFTER Block 1 finishes its swing
     // Block 2 must start AFTER Block 1 finishes its swing
     expect(block2.start).toBeGreaterThanOrEqual(block1End);
     expect(block2.start).toBeGreaterThanOrEqual(block1End);
     assertBlocksWithinBounds(timeline);
     assertBlocksWithinBounds(timeline);
   });
   });
-  it('groups swings into massive bursts when the overload limit is huge', () => {
+  it("groups swings into massive bursts when the overload limit is huge", () => {
     // Fast machine, massive buffer (100). Batch cycle is 4 full swings of 16 (64 total).
     // Fast machine, massive buffer (100). Batch cycle is 4 full swings of 16 (64 total).
     const mockBatch: BatchPlan = {
     const mockBatch: BatchPlan = {
       craftsPerCycle: 64,
       craftsPerCycle: 64,
       durationTicks: 640, // 64 crafts * 10 ticks
       durationTicks: 640, // 64 crafts * 10 ticks
-      timings: { actualCraftingSpeed: 6, productivityBonus: 0, singleCraftTicks: 10, craftsPerSecond: 6, overloadMultiplier: 100 },
-      inputs: { 'iron-plate': { totalAmount: 64, baseAmount: 1 } },
-      outputs: { 'iron-gear-wheel': { totalAmount: 64, baseAmount: 1, yieldPerCraft: 1, outputBlockLimit: 100 } }
+      timings: {
+        actualCraftingSpeed: 6,
+        productivityBonus: 0,
+        singleCraftTicks: 10,
+        craftsPerSecond: 6,
+        overloadMultiplier: 100,
+      },
+      inputs: { "iron-plate": { totalAmount: 64, baseAmount: 1 } },
+      outputs: { "iron-gear-wheel": { totalAmount: 64, baseAmount: 1, yieldPerCraft: 1, outputBlockLimit: 100 } },
     };
     };
 
 
     const mockConfig: ClockConfig = {
     const mockConfig: ClockConfig = {
-      inputs: { 'iron-plate': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'in' } },
-      outputs: { 'iron-gear-wheel': { presetId: 'chest', stackSize: 16, swingTicks: 8, inserterId: 'out' } },
-      machineCount: 1
+      inputs: { "iron-plate": { presetId: "chest", stackSize: 16, swingTicks: 8, inserterId: "in" } },
+      outputs: { "iron-gear-wheel": { presetId: "chest", stackSize: 16, swingTicks: 8, inserterId: "out" } },
+      machineCount: 1,
     };
     };
 
 
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
 
 
-    const inBlock = timeline.blocks.find(b => b.rowId === 'row-in');
-    
+    const inBlock = timeline.blocks.find((b) => b.rowId === "row-in");
+
     // Because the overload multiplier is 100, the machine can hold all 64 items at once.
     // Because the overload multiplier is 100, the machine can hold all 64 items at once.
     // The generator should condense all 4 swings into a single Block with repeat = 4!
     // The generator should condense all 4 swings into a single Block with repeat = 4!
-    expect(timeline.blocks.filter(b => b.rowId === 'row-in').length).toBe(1);
+    expect(timeline.blocks.filter((b) => b.rowId === "row-in").length).toBe(1);
     expect(inBlock!.repeat).toBe(4);
     expect(inBlock!.repeat).toBe(4);
-    
+
     assertBlocksWithinBounds(timeline);
     assertBlocksWithinBounds(timeline);
   });
   });
-  it('schedules mixed belts safely without overlapping swings', () => {
+  it("schedules mixed belts safely without overlapping swings", () => {
     const mockBatch: BatchPlan = {
     const mockBatch: BatchPlan = {
       craftsPerCycle: 16,
       craftsPerCycle: 16,
       durationTicks: 400,
       durationTicks: 400,
-      timings: { actualCraftingSpeed: 1, productivityBonus: 0, singleCraftTicks: 25, craftsPerSecond: 2.4, overloadMultiplier: 100 },
+      timings: {
+        actualCraftingSpeed: 1,
+        productivityBonus: 0,
+        singleCraftTicks: 25,
+        craftsPerSecond: 2.4,
+        overloadMultiplier: 100,
+      },
       inputs: {
       inputs: {
-        'iron-plate': { totalAmount: 16, baseAmount: 1 },
-        'copper-plate': { totalAmount: 16, baseAmount: 1 }
+        "iron-plate": { totalAmount: 16, baseAmount: 1 },
+        "copper-plate": { totalAmount: 16, baseAmount: 1 },
       },
       },
-      outputs: {}
+      outputs: {},
     };
     };
 
 
     // Both inputs assigned to "in-1" (Mixed Belt)
     // Both inputs assigned to "in-1" (Mixed Belt)
     const mockConfig: ClockConfig = {
     const mockConfig: ClockConfig = {
       inputs: {
       inputs: {
-        'iron-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' },
-        'copper-plate': { presetId: 'belt', stackSize: 16, swingTicks: 12, inserterId: 'in-1' }
+        "iron-plate": { presetId: "belt", stackSize: 16, swingTicks: 12, inserterId: "in-1" },
+        "copper-plate": { presetId: "belt", stackSize: 16, swingTicks: 12, inserterId: "in-1" },
       },
       },
-      outputs: {}
+      outputs: {},
     };
     };
 
 
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
     const timeline = generateAdvancedClock(mockBatch, mockConfig);
 
 
     // Should only create 1 input row since they share 'in-1'
     // Should only create 1 input row since they share 'in-1'
     expect(timeline.rows.length).toBe(1);
     expect(timeline.rows.length).toBe(1);
-    expect(timeline.rows[0].id).toBe('row-in-1');
+    expect(timeline.rows[0].id).toBe("row-in-1");
     expect(timeline.rows[0].signals).toHaveLength(2); // Row has both iron and copper signals
     expect(timeline.rows[0].signals).toHaveLength(2); // Row has both iron and copper signals
 
 
     // Should create 2 blocks (one for iron, one for copper)
     // Should create 2 blocks (one for iron, one for copper)
@@ -154,11 +178,11 @@ describe('Timeline Generator', () => {
     // Verify they do not overlap
     // Verify they do not overlap
     const block1 = timeline.blocks[0];
     const block1 = timeline.blocks[0];
     const block2 = timeline.blocks[1];
     const block2 = timeline.blocks[1];
-    
-    const block1End = block1.start + (block1.duration * block1.repeat);
+
+    const block1End = block1.start + block1.duration * block1.repeat;
     // Block 2 must start AFTER Block 1 finishes its swing, proving the busy-wait tracker works!
     // Block 2 must start AFTER Block 1 finishes its swing, proving the busy-wait tracker works!
     expect(block2.start).toBeGreaterThanOrEqual(block1End);
     expect(block2.start).toBeGreaterThanOrEqual(block1End);
 
 
     assertBlocksWithinBounds(timeline);
     assertBlocksWithinBounds(timeline);
   });
   });
-});
+});

+ 8 - 8
src/engine/timeline.ts → src/engine/clockGenerator.ts

@@ -1,9 +1,9 @@
 import { defaultClockSignal } from "../store/useClockStore";
 import { defaultClockSignal } from "../store/useClockStore";
-import type { BatchPlan, ClockConfig } from "./types";
+import type { BatchPlan, ClockConfig } from "./model";
 
 
 /**
 /**
  * Translates an optimized batch plan into a precise sequence of inserter swings (ClockBlocks).
  * Translates an optimized batch plan into a precise sequence of inserter swings (ClockBlocks).
- * It simulates the machine's virtual inventory to group swings into bursts safely, 
+ * It simulates the machine's virtual inventory to group swings into bursts safely,
  * and uses a busy-wait tracker to schedule multiple ingredients safely onto mixed belts.
  * and uses a busy-wait tracker to schedule multiple ingredients safely onto mixed belts.
  */
  */
 export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
 export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
@@ -55,20 +55,20 @@ export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
     while (swingsLeft > 0) {
     while (swingsLeft > 0) {
       const burst = Math.min(swingsLeft, maxBurst);
       const burst = Math.min(swingsLeft, maxBurst);
       let startTick = Math.round(currentArrivalTick - cfg.swingTicks);
       let startTick = Math.round(currentArrivalTick - cfg.swingTicks);
-      
+
       // Ensure positive tick space before tracking busy state
       // Ensure positive tick space before tracking busy state
       startTick = ((startTick % batch.durationTicks) + batch.durationTicks) % batch.durationTicks;
       startTick = ((startTick % batch.durationTicks) + batch.durationTicks) % batch.durationTicks;
 
 
       // Mixed Belt Scheduling: Wait if the inserter is busy
       // Mixed Belt Scheduling: Wait if the inserter is busy
       const busyUntil = inserterBusyUntil[inserterId] || 0;
       const busyUntil = inserterBusyUntil[inserterId] || 0;
       if (startTick < busyUntil) {
       if (startTick < busyUntil) {
-        startTick = busyUntil; 
+        startTick = busyUntil;
       }
       }
 
 
       const duration = cfg.swingTicks; // FIXED: No more +1
       const duration = cfg.swingTicks; // FIXED: No more +1
-      
+
       // Track chronological busy time (can exceed durationTicks chronologically)
       // Track chronological busy time (can exceed durationTicks chronologically)
-      inserterBusyUntil[inserterId] = startTick + (burst * duration); 
+      inserterBusyUntil[inserterId] = startTick + burst * duration;
 
 
       const safeStartTick = startTick % batch.durationTicks;
       const safeStartTick = startTick % batch.durationTicks;
 
 
@@ -76,7 +76,7 @@ export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
         id: `block-in-${itemId}-${blockIndex++}`,
         id: `block-in-${itemId}-${blockIndex++}`,
         rowId,
         rowId,
         presetId: cfg.presetId,
         presetId: cfg.presetId,
-        start: safeStartTick, 
+        start: safeStartTick,
         duration,
         duration,
         count: cfg.stackSize,
         count: cfg.stackSize,
         repeat: burst,
         repeat: burst,
@@ -137,4 +137,4 @@ export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig) {
     rows: Object.values(rowMap),
     rows: Object.values(rowMap),
     blocks,
     blocks,
   };
   };
-}
+}

+ 2 - 2
src/engine/index.ts

@@ -1,5 +1,5 @@
-export * from "./types";
+export * from "./model";
 export { gcd, lcm, floatToFraction } from "./math";
 export { gcd, lcm, floatToFraction } from "./math";
 export { computeMachineStats, getQualityMultiplier, getBeaconOverlapPenalty, getTransmissionStrength } from "./stats";
 export { computeMachineStats, getQualityMultiplier, getBeaconOverlapPenalty, getTransmissionStrength } from "./stats";
 export { calculateOptimalBatch } from "./batch";
 export { calculateOptimalBatch } from "./batch";
-export { generateAdvancedClock } from "./timeline";
+export { generateAdvancedClock } from "./clockGenerator";

+ 14 - 13
src/engine/types.ts → src/engine/model.ts

@@ -2,10 +2,10 @@ import type { Beacon, Machine, Module } from "../../scripts/factorio-dump/proces
 
 
 export interface InserterConfig {
 export interface InserterConfig {
   /** Used to group items onto mixed belts (e.g., assigning "in-1" to both Iron and Copper) */
   /** Used to group items onto mixed belts (e.g., assigning "in-1" to both Iron and Copper) */
-  inserterId?: string; 
-  presetId: string; 
-  swingTicks: number; 
-  stackSize: number; 
+  inserterId?: string;
+  presetId: string;
+  swingTicks: number;
+  stackSize: number;
 }
 }
 
 
 export interface ClockConfig {
 export interface ClockConfig {
@@ -16,17 +16,18 @@ export interface ClockConfig {
   /** Target number of machines to scale the final inserter blueprint */
   /** Target number of machines to scale the final inserter blueprint */
   machineCount?: number;
   machineCount?: number;
 }
 }
-
+export interface BeaconGroup {
+  id?: string;
+  beacon: Beacon;
+  beaconQualityLevel?: number;
+  count: number;
+  modules: Array<{ module: Module; qualityLevel: number }>;
+}
 export interface MachineSetup {
 export interface MachineSetup {
   machine: Machine;
   machine: Machine;
-  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 4 = Legendary
+  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 5 = Legendary
   machineModules: Array<{ module: Module; qualityLevel: number }>;
   machineModules: Array<{ module: Module; qualityLevel: number }>;
-  beacons: Array<{
-    beacon: Beacon;
-    beaconQualityLevel?: number;
-    count: number;
-    modules: Array<{ module: Module; qualityLevel: number }>;
-  }>;
+  beacons: Array<BeaconGroup>;
 }
 }
 
 
 export interface CalculatedTimings {
 export interface CalculatedTimings {
@@ -44,4 +45,4 @@ export interface BatchPlan {
   timings: CalculatedTimings;
   timings: CalculatedTimings;
   inputs: Record<string, { totalAmount: number; baseAmount: number }>;
   inputs: Record<string, { totalAmount: number; baseAmount: number }>;
   outputs: Record<string, { totalAmount: number; baseAmount: number; yieldPerCraft: number; outputBlockLimit: number }>;
   outputs: Record<string, { totalAmount: number; baseAmount: number; yieldPerCraft: number; outputBlockLimit: number }>;
-}
+}

+ 0 - 0
src/engine/simulator.next.ts


+ 141 - 62
src/engine/simulator.test.ts

@@ -9,9 +9,10 @@ import {
   Belt,
   Belt,
   FilterableInserterSimulator,
   FilterableInserterSimulator,
 } from "./simulator";
 } from "./simulator";
-import type { MachineSetup } from "./types";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Machine } from "../../scripts/factorio-dump/process-data.models";
 import type { Machine } from "../../scripts/factorio-dump/process-data.models";
+import type { ClockBlock, ClockRow } from "../assets/ClockTimeline/model";
+import type { MachineSetup } from "./model";
 
 
 describe("Simulator Engine", () => {
 describe("Simulator Engine", () => {
   const mockMachineSetup: MachineSetup = {
   const mockMachineSetup: MachineSetup = {
@@ -42,22 +43,26 @@ describe("Simulator Engine", () => {
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0);
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0);
 
 
     // Fast forward 28 ticks (29 total)
     // Fast forward 28 ticks (29 total)
-    for (let i = 0; i < 28; i++) machine.tick();
+    for (let i = 0; i < 29; i++) machine.tick();
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0); // Not done yet
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(0); // Not done yet
 
 
     // Tick 30: Craft completes!
     // Tick 30: Craft completes!
     machine.tick();
     machine.tick();
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(1);
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(1);
+    expect(machine.inputBuffer["iron-plate"]).toBe(0);
 
 
     machine.tick();
     machine.tick();
     // Tick 31: It should immediately start the next craft in the same tick if buffer allows
     // Tick 31: It should immediately start the next craft in the same tick if buffer allows
     expect(machine.isCrafting).toBe(true);
     expect(machine.isCrafting).toBe(true);
     expect(machine.inputBuffer["iron-plate"]).toBe(0);
     expect(machine.inputBuffer["iron-plate"]).toBe(0);
+
+    for (let i = 0; i < 29; i++) machine.tick();
+    expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
   });
   });
 
 
   it("Machine Simulator: Processes multiple crafts in a single tick if speed is extreme", () => {
   it("Machine Simulator: Processes multiple crafts in a single tick if speed is extreme", () => {
     const fastSetup: MachineSetup = {
     const fastSetup: MachineSetup = {
-      machine: { crafting_speed: 60 } as Machine, // 1 craft per tick!
+      machine: { crafting_speed: 60 } as Machine,
       machineModules: [],
       machineModules: [],
       beacons: [],
       beacons: [],
       machineQualityLevel: 0,
       machineQualityLevel: 0,
@@ -69,7 +74,7 @@ describe("Simulator Engine", () => {
     // Tick 1: Should complete 2 crafts instantly (60 speed / 30 ticks required = 2 per tick)
     // Tick 1: Should complete 2 crafts instantly (60 speed / 30 ticks required = 2 per tick)
     machine.tick();
     machine.tick();
 
 
-    expect(machine.inputBuffer["iron-plate"]).toBe(16); // 20 - 4
+    expect(machine.inputBuffer["iron-plate"]).toBe(14); // 20 - 6 (2 first craft plus next tick craft)
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
     expect(machine.outputBuffer["iron-gear-wheel"]).toBe(2);
   });
   });
 
 
@@ -161,20 +166,31 @@ describe("Factorio Strict Phase Orchestrator", () => {
     orchestrator.registerInserter(outputInserter);
     orchestrator.registerInserter(outputInserter);
     orchestrator.registerMachine(machine);
     orchestrator.registerMachine(machine);
 
 
+    expect(orchestrator.currentTick).toBe(0);
+
+    orchestrator.tickUntil(() => inputInserter.state == InserterState.Dropping);
+    expect(orchestrator.currentTick).toBe(4);
+
+    orchestrator.tickUntil(() => (machine.outputBuffer["iron-gear-wheel"] || 0) >= 1);
+    expect(orchestrator.currentTick).toBe(35);
+
+    orchestrator.tickUntil(() => outputInserter.heldItems >= 1);
+    expect(orchestrator.currentTick).toBe(36);
+
     orchestrator.tickUntil(() => (sinkChest.receivedCounts["iron-gear-wheel"] || 0) >= 1);
     orchestrator.tickUntil(() => (sinkChest.receivedCounts["iron-gear-wheel"] || 0) >= 1);
 
 
     // EXACT TIMELINE TRACE:
     // EXACT TIMELINE TRACE:
-    // T1: Input Picks.
-    // T2-T4: Input Swings.
+    // T0: Input Picks.
+    // T1-T4: Input Swings.
     // T5 (Inserter Phase): Input Drops item into machine. Output is asleep.
     // T5 (Inserter Phase): Input Drops item into machine. Output is asleep.
     // T5 (Machine Phase): Machine sees item, crafts (1/30).
     // T5 (Machine Phase): Machine sees item, crafts (1/30).
     // ...
     // ...
     // T34 (Machine Phase): Craft finishes (30/30). Item is output!
     // T34 (Machine Phase): Craft finishes (30/30). Item is output!
-    // T35 (Inserter Phase): Output finally sees item, wakes up, Picks. (1 TICK DELAY PROVEN!)
-    // T36-38: Output Swings.
-    // T39: Output Drops item into chest.
+    // T36 (Inserter Phase): Output finally sees item, wakes up, Picks.
+    // T37-39: Output Swings.
+    // T40: Output Drops item into chest.
 
 
-    expect(orchestrator.currentTick).toBe(39);
+    expect(orchestrator.currentTick).toBe(40);
   });
   });
 
 
   it("Round-Robin: Two inserters compete for same output and share perfectly", () => {
   it("Round-Robin: Two inserters compete for same output and share perfectly", () => {
@@ -257,7 +273,7 @@ describe("Factorio Strict Phase Orchestrator", () => {
     orchestrator.registerMachine(machine);
     orchestrator.registerMachine(machine);
 
 
     // Run until 4 items are produced and extracted
     // Run until 4 items are produced and extracted
-    orchestrator.tickUntil(() => false, 4 + 30 * 4 + 1);
+    orchestrator.tickUntil(() => false, 5 + 30 * 4 + 1);
 
 
     // If build-order was absolute, A would have 4 and B would have 0.
     // If build-order was absolute, A would have 4 and B would have 0.
     // Because of the Round-Robin queue, they should have exactly 2 each!
     // Because of the Round-Robin queue, they should have exactly 2 each!
@@ -504,8 +520,9 @@ describe("Factorio Strict Phase Orchestrator", () => {
     const sink = new Chest();
     const sink = new Chest();
 
 
     // Register Clock Rows (43-tick cycle)
     // Register Clock Rows (43-tick cycle)
-    orchestrator.registerRow("row_input", [{ start: 0, end: 8 }], 43);
-    orchestrator.registerRow("row_output", [{ start: 35, end: 43 }], 43);
+
+    orchestrator.registerClockRow({ id: "row_input" } as ClockRow, [{ start: 0, duration: 8 } as ClockBlock], 43);
+    orchestrator.registerClockRow({ id: "row_output" } as ClockRow, [{ start: 35, duration: 8 } as ClockBlock], 43);
 
 
     // Create Inserters
     // Create Inserters
     const inputInserter = new InserterSimulator(2, source, machine, "iron-plate");
     const inputInserter = new InserterSimulator(2, source, machine, "iron-plate");
@@ -615,6 +632,10 @@ describe("Factorio Strict Phase Orchestrator", () => {
 
 
   it("Clocked Baseline: 3:2 setup with staggered shared-inserter rows and exact 8-tick windows", () => {
   it("Clocked Baseline: 3:2 setup with staggered shared-inserter rows and exact 8-tick windows", () => {
     const orchestrator = new FactorioEngineOrchestrator();
     const orchestrator = new FactorioEngineOrchestrator();
+    const stackSizes = {
+      "copper-cable": 200,
+      "electronic-circuit": 200,
+    };
 
 
     const copperCableRecipe: Recipe = {
     const copperCableRecipe: Recipe = {
       name: "copper-cable",
       name: "copper-cable",
@@ -634,18 +655,18 @@ describe("Factorio Strict Phase Orchestrator", () => {
     } as Recipe;
     } as Recipe;
 
 
     const assemblerSetup: MachineSetup = {
     const assemblerSetup: MachineSetup = {
-      machine: { name: "assembling-machine-2", crafting_speed: 1 } as Machine,
+      machine: { name: "assembling-machine-2", crafting_speed: 10 } as Machine,
       machineModules: [],
       machineModules: [],
       beacons: [],
       beacons: [],
       machineQualityLevel: 0,
       machineQualityLevel: 0,
     };
     };
 
 
-    const cop1 = new MachineSimulator(assemblerSetup, copperCableRecipe, { "copper-cable": 200 });
-    const cop2 = new MachineSimulator(assemblerSetup, copperCableRecipe, { "copper-cable": 200 });
-    const cop3 = new MachineSimulator(assemblerSetup, copperCableRecipe, { "copper-cable": 200 });
+    const cop1 = new MachineSimulator(assemblerSetup, copperCableRecipe, stackSizes);
+    const cop2 = new MachineSimulator(assemblerSetup, copperCableRecipe, stackSizes);
+    const cop3 = new MachineSimulator(assemblerSetup, copperCableRecipe, stackSizes);
 
 
-    const circ1 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, { "electronic-circuit": 200 });
-    const circ2 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, { "electronic-circuit": 200 });
+    const circ1 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, stackSizes);
+    const circ2 = new MachineSimulator(assemblerSetup, greenCircuitRecipe, stackSizes);
 
 
     const sourceCopper = new Chest("copper-plate");
     const sourceCopper = new Chest("copper-plate");
     const sourceIron = new Chest("iron-plate");
     const sourceIron = new Chest("iron-plate");
@@ -655,27 +676,49 @@ describe("Factorio Strict Phase Orchestrator", () => {
     // Cycle: 480 ticks. 16 crafts per cycle.
     // Cycle: 480 ticks. 16 crafts per cycle.
 
 
     // Inputs (Copper/Iron): 16 items needed = 1 swing per cycle.
     // Inputs (Copper/Iron): 16 items needed = 1 swing per cycle.
-    orchestrator.registerRow("row_inputs", [{ start: 0, end: 8 }], 480);
+    const rowInputs: ClockRow = { id: "row_inputs", name: "Inputs", signals: [], stackSize: 16, inserterCount: 5 };
+    const rowMidOuter: ClockRow = {
+      id: "row_mid_outer",
+      name: "Mid Outer",
+      signals: [],
+      stackSize: 16,
+      inserterCount: 2,
+    };
+    const rowMidInner: ClockRow = {
+      id: "row_mid_inner",
+      name: "Mid Inner",
+      signals: [],
+      stackSize: 16,
+      inserterCount: 2,
+    };
+    const rowOutputs: ClockRow = { id: "row_outputs", name: "Outputs", signals: [], stackSize: 16, inserterCount: 2 };
+
+    orchestrator.registerClockRow(
+      rowInputs,
+      [{ id: "b1", rowId: "row_inputs", presetId: "custom", start: 1, duration: 8, count: 16 }],
+      480,
+    );
 
 
     // Outer Mid Inserters (cop1 -> circ1, cop3 -> circ2): 32 cables generated = 2 swings per cycle.
     // Outer Mid Inserters (cop1 -> circ1, cop3 -> circ2): 32 cables generated = 2 swings per cycle.
-    orchestrator.registerRow(
-      "row_mid_outer",
+    orchestrator.registerClockRow(
+      rowMidOuter,
       [
       [
-        { start: 200, end: 208 },
-        { start: 400, end: 408 },
+        { id: "b2", rowId: "row_mid_outer", presetId: "custom", start: 1, duration: 8, count: 16 },
+        { id: "b3", rowId: "row_mid_outer", presetId: "custom", start: 321, duration: 8, count: 16 },
       ],
       ],
       480,
       480,
     );
     );
+    orchestrator.registerClockRow(
+      rowMidInner,
+      [{ id: "b4", rowId: "row_mid_inner", presetId: "custom", start: 161, duration: 8, count: 16 }],
+      480,
+    );
 
 
-    // Inner Mid Inserter A (cop2 -> circ1): 16 cables = 1 swing per cycle. Staggered to tick 120.
-    orchestrator.registerRow("row_mid_inner_A", [{ start: 120, end: 128 }], 480);
-
-    // Inner Mid Inserter B (cop2 -> circ2): 16 cables = 1 swing per cycle. Staggered to tick 360.
-    // Because A and B are perfectly staggered, they will never fight over cop2's buffer!
-    orchestrator.registerRow("row_mid_inner_B", [{ start: 360, end: 368 }], 480);
-
-    // Outputs (Green Chips): 16 items generated = 1 swing per cycle.
-    orchestrator.registerRow("row_outputs", [{ start: 460, end: 468 }], 480);
+    orchestrator.registerClockRow(
+      rowOutputs,
+      [{ id: "b5", rowId: "row_outputs", presetId: "custom", start: 0, duration: 16, count: 16 }],
+      480,
+    );
 
 
     // Instantiate Inserters (Stack Size 16)
     // Instantiate Inserters (Stack Size 16)
     const inCop1 = new InserterSimulator(16, sourceCopper, cop1, "copper-plate");
     const inCop1 = new InserterSimulator(16, sourceCopper, cop1, "copper-plate");
@@ -692,11 +735,10 @@ describe("Factorio Strict Phase Orchestrator", () => {
     const outCirc1 = new InserterSimulator(16, circ1, sinkGreenChips, "electronic-circuit");
     const outCirc1 = new InserterSimulator(16, circ1, sinkGreenChips, "electronic-circuit");
     const outCirc2 = new InserterSimulator(16, circ2, sinkGreenChips, "electronic-circuit");
     const outCirc2 = new InserterSimulator(16, circ2, sinkGreenChips, "electronic-circuit");
 
 
-    // Bind to Pub/Sub Rows
     [inCop1, inCop2, inCop3, inIron1, inIron2].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_inputs"));
     [inCop1, inCop2, inCop3, inIron1, inIron2].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_inputs"));
     [mid1, mid3].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_mid_outer"));
     [mid1, mid3].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_mid_outer"));
-    orchestrator.bindInserterToRow(mid2a, "row_mid_inner_A");
-    orchestrator.bindInserterToRow(mid2b, "row_mid_inner_B");
+    orchestrator.bindInserterToRow(mid2a, "row_mid_inner");
+    orchestrator.bindInserterToRow(mid2b, "row_mid_inner");
     [outCirc1, outCirc2].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_outputs"));
     [outCirc1, outCirc2].forEach((ins) => orchestrator.bindInserterToRow(ins, "row_outputs"));
 
 
     const allInserters = [inCop1, inCop2, inCop3, inIron1, inIron2, mid1, mid2a, mid2b, mid3, outCirc1, outCirc2];
     const allInserters = [inCop1, inCop2, inCop3, inIron1, inIron2, mid1, mid2a, mid2b, mid3, outCirc1, outCirc2];
@@ -704,34 +746,21 @@ describe("Factorio Strict Phase Orchestrator", () => {
     [cop1, cop2, cop3, circ1, circ2].forEach((m) => orchestrator.registerMachine(m));
     [cop1, cop2, cop3, circ1, circ2].forEach((m) => orchestrator.registerMachine(m));
 
 
     // --- PHASE 1: STABILIZATION ---
     // --- PHASE 1: STABILIZATION ---
-    // Let the factory run for 3 full cycles (1440 ticks) so all machine buffers fill,
-    // the pipeline finishes, and steady-state clocked rhythm is established.
     orchestrator.tickUntil(() => false, 5000);
     orchestrator.tickUntil(() => false, 5000);
-
-    // Reset counts for the true measurement
     allInserters.forEach((ins) => (ins.swingCount = 0));
     allInserters.forEach((ins) => (ins.swingCount = 0));
     const startTarget = orchestrator.currentTick;
     const startTarget = orchestrator.currentTick;
 
 
     // --- PHASE 2: MEASUREMENT WINDOW ---
     // --- PHASE 2: MEASUREMENT WINDOW ---
-    // Measure exactly 3 cycles (1440 ticks).
     orchestrator.tickUntil(() => false, startTarget + 1440);
     orchestrator.tickUntil(() => false, startTarget + 1440);
 
 
     // --- VALIDATION ---
     // --- VALIDATION ---
-    // Inputs: exactly 3 swings (1 per cycle)
     expect(inCop1.swingCount).toBe(3);
     expect(inCop1.swingCount).toBe(3);
     expect(inCop2.swingCount).toBe(3);
     expect(inCop2.swingCount).toBe(3);
     expect(inIron1.swingCount).toBe(3);
     expect(inIron1.swingCount).toBe(3);
-
-    // Mid Outer (cop1, cop3): exactly 6 swings (2 per cycle)
     expect(mid1.swingCount).toBe(6);
     expect(mid1.swingCount).toBe(6);
     expect(mid3.swingCount).toBe(6);
     expect(mid3.swingCount).toBe(6);
-
-    // Mid Inner (cop2): exactly 3 swings EACH (1 per cycle).
-    // They did not fight because they were staggered by the clock!
     expect(mid2a.swingCount).toBe(3);
     expect(mid2a.swingCount).toBe(3);
     expect(mid2b.swingCount).toBe(3);
     expect(mid2b.swingCount).toBe(3);
-
-    // Outputs: exactly 3 swings (1 per cycle)
     expect(outCirc1.swingCount).toBe(3);
     expect(outCirc1.swingCount).toBe(3);
     expect(outCirc2.swingCount).toBe(3);
     expect(outCirc2.swingCount).toBe(3);
 
 
@@ -746,32 +775,26 @@ describe("Factorio Strict Phase Orchestrator", () => {
   it("Advanced Filtering: Filter Inserter dynamically changes target on a mixed belt", () => {
   it("Advanced Filtering: Filter Inserter dynamically changes target on a mixed belt", () => {
     const orchestrator = new FactorioEngineOrchestrator();
     const orchestrator = new FactorioEngineOrchestrator();
 
 
-    // 1. Setup a Mixed Belt (simulated using our Belt class holding both)
     const mixedBelt = new Belt("iron-plate", "copper-plate");
     const mixedBelt = new Belt("iron-plate", "copper-plate");
     const sortingChest = new Chest();
     const sortingChest = new Chest();
-
-    // 2. Setup the Filter Inserter
     const filterInserter = new FilterableInserterSimulator(16, mixedBelt, sortingChest);
     const filterInserter = new FilterableInserterSimulator(16, mixedBelt, sortingChest);
-
-    // Configure it to use Circuit Network filters
     filterInserter.useDynamicFilters = true;
     filterInserter.useDynamicFilters = true;
 
 
-    // 3. Fake the Circuit Network Pub/Sub (Forcing Copper initially)
     filterInserter.updateDynamicFilters(["copper-plate"]);
     filterInserter.updateDynamicFilters(["copper-plate"]);
 
 
     orchestrator.registerInserter(filterInserter);
     orchestrator.registerInserter(filterInserter);
+    orchestrator.tick();
 
 
-    // --- PHASE 1: Pull Copper ---
+    expect(filterInserter.currentTargetItem).toBe("copper-plate");
+    expect(filterInserter.state).toBe(1);
+    expect(filterInserter.heldItems).toBe(4);
     orchestrator.tickUntil(() => false, 100);
     orchestrator.tickUntil(() => false, 100);
-
-    // It should have extracted Copper, but completely ignored the Iron!
     expect(mixedBelt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
     expect(mixedBelt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
     expect(mixedBelt.extractedCounts["iron-plate"]).toBeUndefined();
     expect(mixedBelt.extractedCounts["iron-plate"]).toBeUndefined();
     expect(sortingChest.receivedCounts["copper-plate"]).toBeGreaterThan(0);
     expect(sortingChest.receivedCounts["copper-plate"]).toBeGreaterThan(0);
     expect(sortingChest.receivedCounts["iron-plate"]).toBeUndefined();
     expect(sortingChest.receivedCounts["iron-plate"]).toBeUndefined();
 
 
-    // --- PHASE 2: The Combinator Flips! ---
-    // The circuit network sends a new signal, changing the filter to Iron.
+    // The Combinator Flips! ---
     filterInserter.updateDynamicFilters(["iron-plate"]);
     filterInserter.updateDynamicFilters(["iron-plate"]);
 
 
     // Run for another 100 ticks
     // Run for another 100 ticks
@@ -841,8 +864,64 @@ describe("Factorio Strict Phase Orchestrator", () => {
 
 
     // Run the simulation
     // Run the simulation
     // It takes 300 ticks for a single craft.
     // It takes 300 ticks for a single craft.
-    // We run it for 800 ticks, which gives enough time for 2 complete crafts and inserter swings.
-    const targetTick = orchestrator.currentTick + 800;
+    const targetTick = orchestrator.currentTick + 300 * 5;
+    orchestrator.tickUntil(() => false, targetTick);
+
+    // --- VALIDATION ---
+
+    // The inserter MUST have extracted both items from the mixed belt.
+    // If it had locked up trying to stuff infinite copper, the gear count would be undefined/0.
+    expect(mixedBelt.extractedCounts["copper-plate"]).toBeGreaterThan(0);
+    expect(mixedBelt.extractedCounts["iron-gear-wheel"]).toBeGreaterThan(0);
+
+    expect(sinkChest.receivedCounts["automation-science-pack"]).toBeGreaterThanOrEqual(2);
+
+    // The extracted copper should not wildly exceed the extracted gears,
+    // proving the machine's buffer choked the copper input and forced the inserter to switch.
+    // They should be reasonably close to each other (within 1 hand size margin)
+    const copperPulled = mixedBelt.extractedCounts["copper-plate"];
+    const gearsPulled = mixedBelt.extractedCounts["iron-gear-wheel"];
+    expect(Math.abs(copperPulled - gearsPulled)).toBeLessThanOrEqual(4);
+  });
+  it("Advanced dynamic Filtering: Single multi-filter inserter autonomously feeds a Red Science machine", () => {
+    const orchestrator = new FactorioEngineOrchestrator();
+
+    // The Red Science Recipe
+    const redScienceRecipe: Recipe = {
+      name: "automation-science-pack",
+      energy_required: 5, // 300 ticks per craft at Speed 1
+      ingredients: [
+        { name: "copper-plate", amount: 1, type: "item" },
+        { name: "iron-gear-wheel", amount: 1, type: "item" },
+      ],
+      results: [{ type: "item", name: "automation-science-pack", amount: 1 }],
+    } as Recipe;
+
+    const assemblerSetup: MachineSetup = {
+      machine: { name: "assembling-machine-2", crafting_speed: 1 } as Machine,
+      machineModules: [],
+      beacons: [],
+      machineQualityLevel: 0,
+    };
+
+    // Setup the Machine and the Mixed Input Belt
+    const machine = new MachineSimulator(assemblerSetup, redScienceRecipe, { "automation-science-pack": 100 });
+    const mixedBelt = new Belt("copper-plate", "iron-gear-wheel");
+    const sinkChest = new Chest();
+
+    // A single Filter Inserter configured to grab BOTH ingredients!
+    // We use a small hand size (4) to force it to make multiple trips.
+    const smartInput = new FilterableInserterSimulator(4, mixedBelt, machine);
+    const output = new InserterSimulator(4, machine, sinkChest, "automation-science-pack");
+
+    orchestrator.registerInserter(smartInput);
+    orchestrator.registerMachine(machine);
+    orchestrator.registerInserter(output);
+    smartInput.updateDynamicFilters(["copper-plate", "iron-gear-wheel"]);
+    smartInput.useDynamicFilters = true;
+    // Run the simulation
+    // It takes 300 ticks for a single craft.
+    const targetTick = orchestrator.currentTick + 300 * 5;
     orchestrator.tickUntil(() => false, targetTick);
     orchestrator.tickUntil(() => false, targetTick);
 
 
     // --- VALIDATION ---
     // --- VALIDATION ---

+ 49 - 31
src/engine/simulator.ts

@@ -1,6 +1,7 @@
 import { computeMachineStats } from "./stats";
 import { computeMachineStats } from "./stats";
-import type { MachineSetup, CalculatedTimings } from "./types";
+import type { MachineSetup, CalculatedTimings } from "./model";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type { ClockBlock, ClockRow } from "../assets/ClockTimeline/model";
 
 
 export enum ContainerType {
 export enum ContainerType {
   Chest = "Chest",
   Chest = "Chest",
@@ -102,34 +103,40 @@ export class MachineSimulator implements IContainer {
   public tick() {
   public tick() {
     let progressRemaining = this.progressPerTick;
     let progressRemaining = this.progressPerTick;
 
 
-    while (progressRemaining > 0) {
-      if (!this.isCrafting) {
-        if (this.hasEnoughInputs()) {
-          this.consumeInputs();
-          this.isCrafting = true;
-        } else {
-          break;
-        }
+    if (!this.isCrafting) {
+      if (this.hasEnoughInputs()) {
+        this.consumeInputs();
+        this.isCrafting = true;
+      } else {
+        return;
       }
       }
+    }
+    while (progressRemaining > 0) {
+      const toFinish = 1.0 - this.craftProgress;
 
 
-      if (this.isCrafting) {
-        const progressToNextFinish = 1.0 - this.craftProgress;
-        const step = Math.min(progressRemaining, progressToNextFinish);
+      if (progressRemaining < toFinish) {
+        this.craftProgress += progressRemaining;
+        this.prodProgress += progressRemaining * this.timings.productivityBonus;
+        return; // Tick exhausted, exit.
+      }
 
 
-        this.craftProgress += step;
-        progressRemaining -= step;
-        this.prodProgress += step * this.timings.productivityBonus;
+      // The craft finishes THIS tick.
+      progressRemaining -= toFinish;
+      this.prodProgress += toFinish * this.timings.productivityBonus;
+      this.addResults(1);
+      this.craftProgress = 0.0;
 
 
-        if (this.craftProgress >= 0.99999) {
-          this.addResults(1);
-          this.craftProgress = 0;
-          this.isCrafting = false;
-        }
+      if (this.prodProgress >= 0.99999) {
+        const prodCrafts = Math.floor(this.prodProgress + 1e-5);
+        this.addResults(prodCrafts);
+        this.prodProgress -= prodCrafts;
+      }
 
 
-        while (this.prodProgress >= 0.99999) {
-          this.addResults(1);
-          this.prodProgress -= 1.0;
-        }
+      if (this.hasEnoughInputs()) {
+        this.consumeInputs();
+      } else {
+        this.isCrafting = false;
+        return;
       }
       }
     }
     }
   }
   }
@@ -238,7 +245,7 @@ export class Belt implements IContainer {
   }
   }
 
 
   public extract(itemId: string, maxAmount: number): number {
   public extract(itemId: string, maxAmount: number): number {
-    if (this.getAvailable(itemId)) {
+    if (this.getAvailable(itemId) > 0) {
       const amount = Math.min(maxAmount, 4);
       const amount = Math.min(maxAmount, 4);
       this.extractedCounts[itemId] = (this.extractedCounts[itemId] || 0) + amount;
       this.extractedCounts[itemId] = (this.extractedCounts[itemId] || 0) + amount;
       return amount;
       return amount;
@@ -302,7 +309,7 @@ export class InserterSimulator {
     switch (this.state) {
     switch (this.state) {
       case InserterState.Picking:
       case InserterState.Picking:
         if (this.isActive && this.toPick > 0) {
         if (this.isActive && this.toPick > 0) {
-          const picked = this.source.extract(this.targetItemId, this.toPick);
+          const picked = this.source.extract(this.currentTargetItem, this.toPick);
 
 
           if (picked > 0) {
           if (picked > 0) {
             this.heldItems += picked;
             this.heldItems += picked;
@@ -326,7 +333,7 @@ export class InserterSimulator {
 
 
       case InserterState.Dropping:
       case InserterState.Dropping:
         if (this.ticksInState >= this.dropTicks) {
         if (this.ticksInState >= this.dropTicks) {
-          this.destination.insert(this.targetItemId, this.heldItems);
+          this.destination.insert(this.currentTargetItem, this.heldItems);
 
 
           this.heldItems = 0;
           this.heldItems = 0;
           this.swingCount++;
           this.swingCount++;
@@ -368,14 +375,12 @@ export class FilterableInserterSimulator extends InserterSimulator {
     this.dynamicFilters = filters;
     this.dynamicFilters = filters;
 
 
     // FACTORIO RULE: Partial Hand Eviction on Filter Change
     // FACTORIO RULE: Partial Hand Eviction on Filter Change
-    if (this.state === InserterState.Picking && this.useDynamicFilters) {
+    if ((this.state === InserterState.Picking || this.state == InserterState.Idle) && this.useDynamicFilters) {
       if (!this.dynamicFilters.includes(this.currentTargetItem)) {
       if (!this.dynamicFilters.includes(this.currentTargetItem)) {
         if (this.heldItems > 0) {
         if (this.heldItems > 0) {
-          // Force it to swing forward with the partial hand
           this.needed = 0;
           this.needed = 0;
           this.toPick = 0;
           this.toPick = 0;
         } else {
         } else {
-          // If empty, abort the pick and return to Idle
           this.state = InserterState.Idle;
           this.state = InserterState.Idle;
           this.ticksInState = 0;
           this.ticksInState = 0;
         }
         }
@@ -522,7 +527,20 @@ export class FactorioEngineOrchestrator {
   public registerInserter(inserter: InserterSimulator) {
   public registerInserter(inserter: InserterSimulator) {
     this.inserters.push(inserter);
     this.inserters.push(inserter);
   }
   }
-  public registerRow(rowId: string, blocks: { start: number; end: number }[], cycleDuration: number) {
+  public registerClockRow(row: ClockRow, blocks: ClockBlock[], cycleLength: number) {
+    const windows: { start: number; end: number }[] = [];
+
+    for (const block of blocks) {
+      const repeats = Math.max(1, block.repeat || 1);
+      const startTick = block.start;
+      const endTick = startTick + repeats * block.duration;
+      windows.push({ start: startTick, end: endTick });
+    }
+
+    this.registerRow(row.id, windows, cycleLength);
+  }
+
+  private registerRow(rowId: string, blocks: { start: number; end: number }[], cycleDuration: number) {
     const row = new OptimizedClockRow(rowId, blocks, cycleDuration);
     const row = new OptimizedClockRow(rowId, blocks, cycleDuration);
     this.rows.set(rowId, row);
     this.rows.set(rowId, row);
     this.rowsArray.push(row);
     this.rowsArray.push(row);

+ 3 - 10
src/engine/stats.test.ts

@@ -1,15 +1,8 @@
 import { describe, it, expect } from "vitest";
 import { describe, it, expect } from "vitest";
-import {
-  getQualityMultiplier,
-  getBeaconOverlapPenalty,
-  computeMachineStats,
-} from "./stats";
-import type { MachineSetup } from "./types";
+import { getQualityMultiplier, getBeaconOverlapPenalty, computeMachineStats } from "./stats";
+import type { MachineSetup } from "./model";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
-import type {
-  Beacon,
-  Machine,
-} from "../../scripts/factorio-dump/process-data.models";
+import type { Beacon, Machine } from "../../scripts/factorio-dump/process-data.models";
 
 
 describe("getQualityMultiplier", () => {
 describe("getQualityMultiplier", () => {
   it("returns correct quality multipliers", () => {
   it("returns correct quality multipliers", () => {

+ 4 - 4
src/engine/stats.ts

@@ -1,6 +1,6 @@
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Beacon } from "../../scripts/factorio-dump/process-data.models";
 import type { Beacon } from "../../scripts/factorio-dump/process-data.models";
-import type { MachineSetup, CalculatedTimings } from "./types";
+import type { MachineSetup, CalculatedTimings } from "./model";
 
 
 /**
 /**
  * Returns the Factorio 2.0 stat multiplier for a given quality level.
  * Returns the Factorio 2.0 stat multiplier for a given quality level.
@@ -16,7 +16,7 @@ export function getQualityMultiplier(level: number): number {
  * @param overlapCount Total number of beacons of this type affecting the machine.
  * @param overlapCount Total number of beacons of this type affecting the machine.
  */
  */
 export function getBeaconOverlapPenalty(beacon: Beacon, overlapCount: number): number {
 export function getBeaconOverlapPenalty(beacon: Beacon, overlapCount: number): number {
-  if (beacon.profile && (beacon.profile.length >= overlapCount)) {
+  if (beacon.profile && beacon.profile.length >= overlapCount) {
     return beacon.profile[overlapCount - 1];
     return beacon.profile[overlapCount - 1];
   }
   }
   // Default formula if profile array is exceeded or missing
   // Default formula if profile array is exceeded or missing
@@ -46,7 +46,7 @@ export function computeMachineStats(setup: MachineSetup, recipe: Recipe): Calcul
   // Machine Internal Modules
   // Machine Internal Modules
   setup.machineModules.forEach(({ module, qualityLevel }) => {
   setup.machineModules.forEach(({ module, qualityLevel }) => {
     const modQualityMultiplier = getQualityMultiplier(qualityLevel);
     const modQualityMultiplier = getQualityMultiplier(qualityLevel);
-    if (module.effect?.speed) speedBonus += module.effect.speed * modQualityMultiplier;
+    if (module.effect?.speed) speedBonus += module.effect.speed * (module.effect.speed > 0 ? modQualityMultiplier : 1);
     if (module.effect?.productivity) productivityBonus += module.effect.productivity * modQualityMultiplier;
     if (module.effect?.productivity) productivityBonus += module.effect.productivity * modQualityMultiplier;
   });
   });
 
 
@@ -88,4 +88,4 @@ export function computeMachineStats(setup: MachineSetup, recipe: Recipe): Calcul
     craftsPerSecond: 1 / craftTimeSeconds,
     craftsPerSecond: 1 / craftTimeSeconds,
     overloadMultiplier,
     overloadMultiplier,
   };
   };
-}
+}

+ 77 - 45
src/hooks/useQualityScroller.ts

@@ -1,31 +1,30 @@
-import { useEffect, useRef, useState, useMemo } from "react";
+import { useEffect, useRef, useState, useMemo, useCallback } from "react";
 import data from "../assets/data/2.0/data.json";
 import data from "../assets/data/2.0/data.json";
 
 
 export type Quality = {
 export type Quality = {
-  type: "quality";
+  type: string;
   name: string;
   name: string;
   level: number;
   level: number;
-  color: {
-    r: number;
-    g: number;
-    b: number;
-  };
+  color:
+    | {
+        r: number;
+        g: number;
+        b: number;
+      }
+    | number[];
   order: string;
   order: string;
-  next_probability: number;
-  subgroup: "qualities";
-  hidden: boolean;
+  next_probability?: number;
+  subgroup: string;
+  hidden?: boolean;
   icon: string;
   icon: string;
-  draw_sprite_by_default: boolean;
-  next: string;
+  draw_sprite_by_default?: boolean;
+  next?: string;
 };
 };
 
 
+export const qualityShortcut = "Shift + scroll";
 //  Precompute the exact sequence by traversing the 'next' linked list
 //  Precompute the exact sequence by traversing the 'next' linked list
-const qualityMap = new Map<string, Quality>(
-  data.qualityLevels.map((q: Quality) => [q.name, q]),
-);
-const baseQuality =
-  data.qualityLevels.find((q: Quality) => q.level === 0) ||
-  data.qualityLevels[0];
+const qualityMap = new Map<string, Quality>(data.qualityLevels.map((q: Quality) => [q.name, q]));
+const baseQuality = data.qualityLevels.find((q: Quality) => q.level === 0) || data.qualityLevels[0];
 
 
 export const orderedQualities: Quality[] = [];
 export const orderedQualities: Quality[] = [];
 let current: Quality | undefined = baseQuality;
 let current: Quality | undefined = baseQuality;
@@ -37,31 +36,49 @@ while (current) {
 
 
 export function useQualityScroller(
 export function useQualityScroller(
   initialQualityName: string = baseQuality.name,
   initialQualityName: string = baseQuality.name,
+  controlledQualityLevel?: number, // Accept the integer level from the parent
   onChange?: (quality: Quality) => void,
   onChange?: (quality: Quality) => void,
-  persistKey?: string 
+  persistKey?: string,
 ) {
 ) {
-  const [qualityName, setQualityName] = useState(() => {
-    if (persistKey) {
+  const isControlled = controlledQualityLevel !== undefined;
+
+  // Determine controlled name from the injected level
+  const controlledName = useMemo(() => {
+    if (!isControlled) return undefined;
+    return orderedQualities.find((q) => q.level === controlledQualityLevel)?.name;
+  }, [controlledQualityLevel, isControlled]);
+
+  const [internalQualityName, setInternalQualityName] = useState(() => {
+    if (persistKey && !isControlled) {
       try {
       try {
         const saved = localStorage.getItem(persistKey);
         const saved = localStorage.getItem(persistKey);
-        // Ensure the saved quality still exists in the data
         if (saved && orderedQualities.some((q) => q.name === saved)) {
         if (saved && orderedQualities.some((q) => q.name === saved)) {
           return saved;
           return saved;
         }
         }
-      } catch (e) {
-        return "normal";
-      }
+      } catch (e) {}
     }
     }
     return initialQualityName;
     return initialQualityName;
   });
   });
 
 
+  // The single source of truth for the current render
+  const effectiveName = isControlled ? controlledName || baseQuality.name : internalQualityName;
+
+  // Sync to local storage ONLY if we are operating uncontrolled
   useEffect(() => {
   useEffect(() => {
-    if (persistKey) {
+    if (persistKey && !isControlled) {
       try {
       try {
-        localStorage.setItem(persistKey, qualityName);
+        localStorage.setItem(persistKey, effectiveName);
       } catch (e) {}
       } catch (e) {}
     }
     }
-  }, [qualityName, persistKey]);
+  }, [effectiveName, persistKey, isControlled]);
+
+  // Keep a stable ref of the current effective name for the wheel event
+  // so we don't have to detach/reattach the event listener on every single tick
+  const effectiveNameRef = useRef(effectiveName);
+  effectiveNameRef.current = effectiveName;
+
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
 
 
   const scrollRef = useRef<HTMLDivElement>(null);
   const scrollRef = useRef<HTMLDivElement>(null);
 
 
@@ -71,32 +88,47 @@ export function useQualityScroller(
 
 
     const handleWheel = (e: WheelEvent) => {
     const handleWheel = (e: WheelEvent) => {
       if (!e.shiftKey) return;
       if (!e.shiftKey) return;
-      e.preventDefault(); 
+      e.preventDefault();
 
 
-      setQualityName((prevName) => {
-        const currentIndex = orderedQualities.findIndex((q) => q.name === prevName);
-        if (currentIndex === -1) return prevName;
+      const currentName = effectiveNameRef.current;
+      const currentIndex = orderedQualities.findIndex((q) => q.name === currentName);
+      if (currentIndex === -1) return;
 
 
-        const direction = -Math.sign(e.deltaY);
-        let nextIndex = currentIndex - direction;
-        nextIndex = Math.max(0, Math.min(orderedQualities.length - 1, nextIndex));
+      const direction = Math.sign(e.deltaY);
+      let nextIndex = currentIndex - direction;
+      nextIndex = Math.max(0, Math.min(orderedQualities.length - 1, nextIndex));
 
 
-        if (nextIndex !== currentIndex) {
-          const newQuality = orderedQualities[nextIndex];
-          onChange?.(newQuality);
-          return newQuality.name;
+      if (nextIndex !== currentIndex) {
+        const newQuality = orderedQualities[nextIndex];
+
+        if (!isControlled) {
+          setInternalQualityName(newQuality.name);
         }
         }
-        return prevName;
-      });
+
+        onChangeRef.current?.(newQuality);
+      }
     };
     };
 
 
+    // Bind wheel
     el.addEventListener("wheel", handleWheel, { passive: false });
     el.addEventListener("wheel", handleWheel, { passive: false });
-    return () => el.removeEventListener("wheel", handleWheel);
-  }, [onChange]);
+
+    return () => {
+      el.removeEventListener("wheel", handleWheel);
+    };
+  }, [isControlled]);
 
 
   const activeQuality = useMemo(
   const activeQuality = useMemo(
-    () => orderedQualities.find((q) => q.name === qualityName) || baseQuality,
-    [qualityName]
+    () => orderedQualities.find((q) => q.name === effectiveName) || baseQuality,
+    [effectiveName],
+  );
+
+  const setQualityName = useCallback(
+    (name: string) => {
+      if (!isControlled) setInternalQualityName(name);
+      const q = orderedQualities.find((x) => x.name === name);
+      if (q) onChangeRef.current?.(q);
+    },
+    [isControlled],
   );
   );
 
 
   return { scrollRef, activeQuality, setQualityName };
   return { scrollRef, activeQuality, setQualityName };

+ 14 - 10
src/hooks/useTimelineDrag.ts

@@ -1,9 +1,10 @@
 import { useRef, useCallback } from "react";
 import { useRef, useCallback } from "react";
 import { useClockStore } from "../store/useClockStore";
 import { useClockStore } from "../store/useClockStore";
-import type { ClockBlock } from "../assets/types";
+import type { ClockBlock } from "../assets/ClockTimeline/model";
 
 
 export function useTimelineDrag() {
 export function useTimelineDrag() {
-  const { duration, blocks, moveBlocks, updateBlock, selectBlocks, selectedBlockIds, setAlignmentTick } = useClockStore();
+  const { duration, blocks, moveBlocks, updateBlock, selectBlocks, selectedBlockIds, setAlignmentTick } =
+    useClockStore();
 
 
   const dragState = useRef<{
   const dragState = useRef<{
     blockId: string;
     blockId: string;
@@ -16,21 +17,24 @@ export function useTimelineDrag() {
     origDuration: number;
     origDuration: number;
   } | null>(null);
   } | null>(null);
 
 
-  const pxToTick = useCallback((laneWidth: number, px: number) => 
-    Math.round((px / laneWidth) * duration), 
-  [duration]);
+  const pxToTick = useCallback((laneWidth: number, px: number) => Math.round((px / laneWidth) * duration), [duration]);
 
 
-  const onPointerDownBlock = (e: React.PointerEvent, block: ClockBlock, mode: "move" | "resize", laneElement: HTMLElement | null) => {
+  const onPointerDownBlock = (
+    e: React.PointerEvent,
+    block: ClockBlock,
+    mode: "move" | "resize",
+    laneElement: HTMLElement | null,
+  ) => {
     e.stopPropagation();
     e.stopPropagation();
     if (!laneElement) return;
     if (!laneElement) return;
     (e.target as HTMLElement).setPointerCapture(e.pointerId);
     (e.target as HTMLElement).setPointerCapture(e.pointerId);
 
 
     const wasSelected = selectedBlockIds.has(block.id);
     const wasSelected = selectedBlockIds.has(block.id);
     const groupIds = mode === "move" && wasSelected ? selectedBlockIds : new Set([block.id]);
     const groupIds = mode === "move" && wasSelected ? selectedBlockIds : new Set([block.id]);
-    
+
     // Convert object to map for quick lookup
     // Convert object to map for quick lookup
     const blockValues = Object.values(blocks);
     const blockValues = Object.values(blocks);
-    
+
     dragState.current = {
     dragState.current = {
       blockId: block.id,
       blockId: block.id,
       mode,
       mode,
@@ -46,7 +50,7 @@ export function useTimelineDrag() {
   const onPointerMove = (e: React.PointerEvent) => {
   const onPointerMove = (e: React.PointerEvent) => {
     const drag = dragState.current;
     const drag = dragState.current;
     if (!drag) return;
     if (!drag) return;
-    
+
     const deltaTicks = pxToTick(drag.laneWidth, e.clientX - drag.startX);
     const deltaTicks = pxToTick(drag.laneWidth, e.clientX - drag.startX);
 
 
     if (drag.mode === "resize") {
     if (drag.mode === "resize") {
@@ -100,4 +104,4 @@ export function useTimelineDrag() {
   };
   };
 
 
   return { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick };
   return { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick };
-}
+}

+ 57 - 5
src/index.css

@@ -73,7 +73,7 @@ input:focus {
     inset 0px -9px 2px -8px #000,
     inset 0px -9px 2px -8px #000,
     0px 0px 4px 0px #000;
     0px 0px 4px 0px #000;
   position: relative;
   position: relative;
-  margin-right: 14px;
+  margin-right: 8px;
   cursor: pointer;
   cursor: pointer;
   -webkit-user-select: none;
   -webkit-user-select: none;
   -moz-user-select: none;
   -moz-user-select: none;
@@ -84,8 +84,8 @@ input:focus {
 .button.icon {
 .button.icon {
   height: fit-content;
   height: fit-content;
   min-width: auto;
   min-width: auto;
-  padding: 0;
-  margin: 0;
+  padding: 6px;
+  margin: 2px;
 }
 }
 .flex-column .button {
 .flex-column .button {
   margin-right: 0;
   margin-right: 0;
@@ -149,6 +149,11 @@ input:focus {
     0px 0px 4px 0px #000;
     0px 0px 4px 0px #000;
   filter: none;
   filter: none;
 }
 }
+.button.small {
+  padding: 3px 8px;
+  min-width: 80px;
+  height: 30px;
+}
 .button-green {
 .button-green {
   background-color: #5eb663;
   background-color: #5eb663;
   padding: 10px 12px 10px 12px;
   padding: 10px 12px 10px 12px;
@@ -434,6 +439,23 @@ input:focus {
     0px 0px 4px 0px #000;
     0px 0px 4px 0px #000;
   filter: none;
   filter: none;
 }
 }
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.field label {
+  font-size: 11px;
+  color: #999;
+}
+.field.horizontal {
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+}
+select,
+.customSelectTrigger,
+.customNumberInput,
 input[type="text"],
 input[type="text"],
 input[type="password"],
 input[type="password"],
 input[type="email"],
 input[type="email"],
@@ -444,11 +466,11 @@ textarea {
   font-family: inherit;
   font-family: inherit;
   line-height: 1.2;
   line-height: 1.2;
   font-size: 105%;
   font-size: 105%;
-  height: 36px;
+  height: 32px;
   max-width: 100%;
   max-width: 100%;
   background: #8e8e8e;
   background: #8e8e8e;
   border-radius: 4px;
   border-radius: 4px;
-  padding: 6px;
+  padding: 5px 8px;
   border: none;
   border: none;
   box-shadow:
   box-shadow:
     inset 0px 4px 1px -2px #000,
     inset 0px 4px 1px -2px #000,
@@ -458,6 +480,10 @@ textarea {
     inset 0px -2px 2px 0px #5f5f5f,
     inset 0px -2px 2px 0px #5f5f5f,
     0px 0px 4px 1px #2e2521;
     0px 0px 4px 1px #2e2521;
 }
 }
+select:focus,
+.customNumberInput:focus,
+.customSelectTrigger:focus,
+.customSelectTrigger.isOpen,
 input[type="text"]:focus,
 input[type="text"]:focus,
 input[type="password"]:focus,
 input[type="password"]:focus,
 input[type="email"]:focus,
 input[type="email"]:focus,
@@ -475,6 +501,32 @@ textarea:focus {
     inset -2px 0px 2px 0px #a6885c,
     inset -2px 0px 2px 0px #a6885c,
     0px 0px 4px 1px #2e2521;
     0px 0px 4px 1px #2e2521;
 }
 }
+
+input.dark,
+select.dark,
+.customNumberInput.dark,
+.customSelectTrigger.dark {
+  background-color: #242324;
+  border: 1px solid #646464;
+  color: #ffe6c0;
+}
+input:focus.dark,
+select:focus.dark,
+.customNumberInput.dark:focus,
+.customSelectTrigger.dark:focus,
+.customSelectTrigger.dark.isOpen {
+  outline: 2px solid #f1be64;
+  outline-offset: 1px;
+}
+.customSelectTrigger {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  user-select: none;
+  text-align: left;
+  width: 100%;
+}
 textarea {
 textarea {
   width: 100%;
   width: 100%;
   height: 8.1em;
   height: 8.1em;

+ 2 - 0
src/main.tsx

@@ -8,6 +8,7 @@ import Layout from "./Layout.tsx";
 import ClockBuilder from "./ClockBuilder.tsx";
 import ClockBuilder from "./ClockBuilder.tsx";
 import ClockWizard from "./assets/components/ClockWizard.tsx";
 import ClockWizard from "./assets/components/ClockWizard.tsx";
 import { Simulator } from "./assets/Simulator.tsx";
 import { Simulator } from "./assets/Simulator.tsx";
+import TopologyBuilder from "./engine/Topology/TopologyBuilder.tsx";
 
 
 const root = document.getElementById("root");
 const root = document.getElementById("root");
 
 
@@ -21,6 +22,7 @@ ReactDOM.createRoot(root as HTMLElement).render(
           <Route path="tests" element={<ComponentTests />} />
           <Route path="tests" element={<ComponentTests />} />
           <Route path="InputConfigurator" element={<ClockWizard />} />
           <Route path="InputConfigurator" element={<ClockWizard />} />
           <Route path="simulator" element={<Simulator />} />
           <Route path="simulator" element={<Simulator />} />
+          <Route path="topology" element={<TopologyBuilder />} />
         </Route>
         </Route>
       </Routes>
       </Routes>
     </BrowserRouter>
     </BrowserRouter>

+ 10 - 13
src/store/useClockStore.ts

@@ -1,6 +1,5 @@
 import { create } from "zustand";
 import { create } from "zustand";
-import type { ClockBlock, ClockRow } from "../assets/types";
-import type { Signal } from "../assets/SelectSignal";
+import type { ClockBlock, ClockRow, Signal } from "../assets/ClockTimeline/model";
 
 
 export const defaultClockSignal: Signal = {
 export const defaultClockSignal: Signal = {
   type: "virtual-signal",
   type: "virtual-signal",
@@ -12,8 +11,11 @@ export const defaultClockSignal: Signal = {
 interface ClockState {
 interface ClockState {
   // Global Settings
   // Global Settings
   duration: number;
   duration: number;
-  clockSignal: Signal;
+  pixelsPerTick: number;
   setDuration: (duration: number) => void;
   setDuration: (duration: number) => void;
+  setZoom: (val: number) => void;
+
+  clockSignal: Signal;
   setClockSignal: (signal: Signal | null) => void;
   setClockSignal: (signal: Signal | null) => void;
 
 
   // Normalized Data Models
   // Normalized Data Models
@@ -42,14 +44,13 @@ interface ClockState {
   removeBlocks: (ids: string[]) => void;
   removeBlocks: (ids: string[]) => void;
 
 
   // Selection
   // Selection
-  selectBlocks: (
-    ids: Set<string> | ((prev: Set<string>) => Set<string>),
-  ) => void;
+  selectBlocks: (ids: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
   clearSelection: () => void;
   clearSelection: () => void;
 }
 }
 
 
 export const useClockStore = create<ClockState>((set) => ({
 export const useClockStore = create<ClockState>((set) => ({
   duration: 256,
   duration: 256,
+  pixelsPerTick: 4,
   clockSignal: defaultClockSignal, // Set your default signal here
   clockSignal: defaultClockSignal, // Set your default signal here
 
 
   rows: {},
   rows: {},
@@ -62,6 +63,7 @@ export const useClockStore = create<ClockState>((set) => ({
   loadState: (state: Partial<ClockState>) => set(() => ({ ...state })),
   loadState: (state: Partial<ClockState>) => set(() => ({ ...state })),
 
 
   setDuration: (duration) => set({ duration }),
   setDuration: (duration) => set({ duration }),
+  setZoom: (val) => set({ pixelsPerTick: val }),
   setClockSignal: (clockSignal) => {
   setClockSignal: (clockSignal) => {
     if (clockSignal) set({ clockSignal });
     if (clockSignal) set({ clockSignal });
   },
   },
@@ -87,9 +89,7 @@ export const useClockStore = create<ClockState>((set) => ({
       delete newRows[id];
       delete newRows[id];
 
 
       // Also clean up orphan blocks
       // Also clean up orphan blocks
-      const newBlocks = Object.fromEntries(
-        Object.entries(state.blocks).filter(([_, block]) => block.rowId !== id),
-      );
+      const newBlocks = Object.fromEntries(Object.entries(state.blocks).filter(([_, block]) => block.rowId !== id));
 
 
       return {
       return {
         rows: newRows,
         rows: newRows,
@@ -139,10 +139,7 @@ export const useClockStore = create<ClockState>((set) => ({
 
 
   selectBlocks: (idsOrUpdater) =>
   selectBlocks: (idsOrUpdater) =>
     set((state) => ({
     set((state) => ({
-      selectedBlockIds:
-        typeof idsOrUpdater === "function"
-          ? idsOrUpdater(state.selectedBlockIds)
-          : idsOrUpdater,
+      selectedBlockIds: typeof idsOrUpdater === "function" ? idsOrUpdater(state.selectedBlockIds) : idsOrUpdater,
     })),
     })),
 
 
   clearSelection: () => set({ selectedBlockIds: new Set() }),
   clearSelection: () => set({ selectedBlockIds: new Set() }),

+ 11 - 0
src/utils.ts

@@ -0,0 +1,11 @@
+export function downloadJSON(payload: any, filename: string) {
+  const blob = new Blob([JSON.stringify(payload, null, 2)], {
+    type: "application/json",
+  });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = filename;
+  a.click();
+  URL.revokeObjectURL(url);
+}