Kaynağa Gözat

init topology builder

clovis 1 ay önce
ebeveyn
işleme
9d09be9098

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

@@ -19,6 +19,7 @@ export type Item = {
   hidden: boolean;
   subgroup: string;
   order?: string;
+  stackSize?: number;
 };
 export type Machine = {
   name: string;
@@ -82,4 +83,5 @@ export type ProcessedData = {
   recipeCategories: RecipeCategory[];
   recipeGroup: Group<Recipe>[];
   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`,
         subgroup: item.subgroup ?? (isFluid ? "fluid" : "other"),
         order: item.order,
+        stackSize: item.stack_size,
       };
     });
     return acc;
@@ -132,6 +133,7 @@ export async function processData(outputFolder: string) {
             };
         }
     }
+  const stackSizes: Record<string, number> = {};
   for (let item of Object.values(items)) {
     if (!item.hidden) {
       const signal = {
@@ -151,6 +153,7 @@ export async function processData(outputFolder: string) {
       }
       signals[k] = signal;
     }
+    if (item.name && item.stackSize && Number.isInteger(item.stackSize)) stackSizes[item.name] = item.stackSize;
   }
   for (let recipe of recipes) {
     if (!(recipe.name in signals))
@@ -174,6 +177,7 @@ export async function processData(outputFolder: string) {
     recipeCategories,
     recipeGroup,
     signalGroup,
+    stackSizes,
   };
   fs.mkdirSync(outputFolder, { recursive: true });
 

+ 1 - 0
src/Layout.tsx

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

+ 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);
+}

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

@@ -0,0 +1,109 @@
+import data from "../../assets/data/2.0/data.json"; // Adjust path if needed
+import ModuleSlots from "./ModuleSlots";
+import type { Beacon, Module } from "../../../scripts/factorio-dump/process-data.models";
+import { useQualityScroller } from "../../hooks/useQualityScroller";
+import Icon from "../icon";
+import NumberInput from "../components/NumberInput";
+import styles from "./BeaconConfigurator.module.css";
+
+const beaconsData = data.beacons as Beacon[];
+
+export type BeaconGroup = {
+  id: string;
+  beacon: Beacon;
+  qualityLevel: number;
+  count: number;
+  modules: { module: Module; qualityLevel: number }[];
+};
+
+type BeaconConfiguratorProps = {
+  groups: BeaconGroup[];
+  onChange: (g: BeaconGroup[]) => void;
+  direction?: "horizontal" | "vertical"; // NEW: control stacking direction
+};
+
+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="Alt+Scroll 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,
+        qualityLevel: 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.qualityLevel} // Pass the controlled state down!
+            onChangeQuality={(q) => updateGroup(g.id, { qualityLevel: 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 - 78
src/assets/Selector/MachineSelector.tsx

@@ -2,35 +2,54 @@ import React, { useCallback, useEffect, useMemo, useState, type CSSProperties }
 import data from "../../assets/data/2.0/data.json";
 import styles from "./MachineSelector.module.css";
 import Icon from "../icon";
-import { Autocomplete, Box, Popper, TextField, InputAdornment } from "@mui/material";
+import { Popper } from "@mui/material";
 import SelectMenu from "./SelectFactorioMenu";
 import Tooltip from "../Tooltip";
 import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
 import type { Machine } from "../../../scripts/factorio-dump/process-data.models";
 import { 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 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 = {
   className?: string;
   style?: CSSProperties;
+  placeholder?: string;
+  value?: { machine: Machine | null; qualityLevel: number; recipe: Recipe | null };
   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 [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) => {
+      // Direct, pure callback when user alt+scrolls
+      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(() => {
     if (recipe == null) return [];
@@ -38,11 +57,6 @@ function MachineSelector({ style, className, onChange }: MachineSelectorProps) {
     return machines.filter((m) => m.crafting_categories.some((c) => categories.includes(c)));
   }, [recipe]);
 
-  useEffect(() => {
-    if (machineOptions.length === 1) setMachine(machineOptions[0]);
-    if (machine && !machineOptions.includes(machine)) setMachine(null);
-  }, [machineOptions, machine]);
-
   const onOpenRecipe = useCallback(
     (event: React.MouseEvent<HTMLElement>) => {
       setAnchorEl(anchorEl ? null : event.currentTarget);
@@ -51,72 +65,78 @@ function MachineSelector({ style, className, onChange }: MachineSelectorProps) {
     [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 (
     <div className={className} style={{ ...style, display: "flex", gap: "12px", alignItems: "center" }}>
       {/* 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 (alt + scroll 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>
-
-      <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 (Alt+Scroll)" : "Select recipe first"}
-            InputProps={{
-              ...params.InputProps,
-              startAdornment: machine ? (
-                <InputAdornment position="start">
-                  <div title="Alt + 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 }}>
         <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 styles from "./ModuleSlots.module.css";
 import data from "../../assets/data/2.0/data.json";
 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 SelectMenu from "../Selector/SelectFactorioMenu";
+import SelectMenu from "./SelectFactorioMenu";
 
 // Build category structure
 const prod = data.signalGroup.find((g) => g.name === "production") as Category<MenuItem>;
 const moduleSubGroup = prod?.subGroup.find((s) => s.name === "module") as SubGroup<MenuItem>;
 
 type ModuleSlotsProps = {
+  value?: { module: Module; qualityLevel: number }[]; // Make it controllable!
   maxSlots: number;
   allowedEffects?: string[];
   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(
-    initialQualityName,
-    (newQuality) => {
+    "normal", // fallback
+    qualityLevel, // controlled level
+    (newQuality: Quality) => {
       onChangeQuality(newQuality.level);
     },
-    "factorio-module-quality",
-  ); // Persist module quality separately!
+    // Deliberately omitted persistKey so individual slots don't overwrite each other in localStorage!
+  );
 
   return (
     <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 [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(() => {
     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[] }] }];
   }, [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 selectedModule = data.modules.find((m: any) => m.name === moduleName) as Module;
     if (!selectedModule || activeSlot === null) return;
 
-    const newSlots = [...slots];
+    const newSlots = [...effectiveSlots];
     newSlots[activeSlot] = { module: selectedModule, qualityLevel };
-    setSlots(newSlots);
+
+    commitChanges(newSlots);
     setActiveSlot(null);
-    onChange(newSlots.filter((s) => s !== null) as any);
   };
 
   const handleFillAll = () => {
-    const template = slots.find((s) => s !== null);
+    const template = effectiveSlots.find((s) => s !== null);
     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;
@@ -86,7 +102,7 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
   return (
     <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
       <div className={styles.slotsContainer}>
-        {slots.map((slot, i) => (
+        {effectiveSlots.map((slot, i) => (
           <div
             key={i}
             className={styles.slot}
@@ -96,21 +112,19 @@ export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: Modu
             }}
             onContextMenu={(e) => {
               e.preventDefault();
-              const n = [...slots];
+              const n = [...effectiveSlots];
               n[i] = null;
-              setSlots(n);
-              onChange(n.filter((s) => s) as any);
+              commitChanges(n);
             }}
           >
             {slot ? (
               <OccupiedSlot
                 module={slot.module}
-                initialQualityLevel={slot.qualityLevel}
+                qualityLevel={slot.qualityLevel}
                 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>
 
-      {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">
           Fill All
         </button>

+ 6 - 1
src/assets/Selector/SelectFactorioMenu.tsx

@@ -128,7 +128,12 @@ function SelectMenu({ style, className, title, categories, showQuality, onClose,
   const [item, selectItem] = useState("");
   const [showSearch, setShowSearch] = useState(false);
   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 handleItemSelect = (name: string) => {

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

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

@@ -74,16 +74,16 @@ export default function InputConfigurator({ recipe, configs, onChange }: InputCo
         </thead>
         <tbody>
           {solidIngredients.map((ing) => {
-            const config = configs[ing.itemId];
+            const config = configs[ing.name];
             if (!config) return null;
 
             return (
-              <tr key={ing.itemId}>
+              <tr key={ing.name}>
                 <td>
                   <div className={styles.itemLabel}>
-                    <Icon iconName={`item/${ing.itemId}.png`} size={24} />
+                    <Icon iconName={`item/${ing.name}.png`} size={24} />
                     <span>
-                      {ing.amount} {ing.itemId}
+                      {ing.amount} {ing.name}
                     </span>
                   </div>
                 </td>
@@ -91,7 +91,7 @@ export default function InputConfigurator({ recipe, configs, onChange }: InputCo
                   <input
                     type="text"
                     value={config.inserterId}
-                    onChange={(e) => updateConfig(ing.itemId, { inserterId: e.target.value })}
+                    onChange={(e) => updateConfig(ing.name, { inserterId: e.target.value })}
                     className={styles.groupInput}
                     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>
                   <select
                     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="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>
+  );
+}

+ 7 - 7
src/assets/components/Select.tsx

@@ -4,19 +4,18 @@ import { createPortal } from "react-dom";
 export interface SelectOption {
   id: string;
   label: string;
-  [key: string]: any; // Allow any extra data (like icons, colors, etc.)
 }
 
-interface CustomSelectProps {
+interface CustomSelectProps<T extends SelectOption> {
   style?: CSSProperties;
   className?: string;
-  options: SelectOption[];
+  options: T[];
   value: string;
   onChange: (value: string) => void;
 
   // Custom renderers
-  renderValue?: (option: SelectOption | undefined) => ReactNode;
-  renderOption?: (option: SelectOption) => ReactNode;
+  renderValue?: (option: T | undefined) => ReactNode;
+  renderOption?: (option: T) => ReactNode;
 
   // Styling
   isDark?: boolean;
@@ -24,7 +23,7 @@ interface CustomSelectProps {
   placeholder?: string;
 }
 
-export default function Select({
+export default function Select<T extends SelectOption>({
   style,
   className,
   options,
@@ -35,7 +34,7 @@ export default function Select({
   isDark = false,
   variant = "simple",
   placeholder = "Select...",
-}: CustomSelectProps) {
+}: CustomSelectProps<T>) {
   const [isOpen, setIsOpen] = useState(false);
   const [menuCoords, setMenuCoords] = useState({ top: 0, left: 0, width: 0 });
 
@@ -94,6 +93,7 @@ export default function Select({
 
   const menuContent = isOpen ? (
     <div
+      ref={menuRef}
       className={`${styles.customSelectMenu} ${isDark ? "dark" : ""} ${variant === "simple" ? styles.menuSimple : styles.menuStacked}`}
       style={{
         position: "fixed",

+ 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
+  }
 }

+ 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>
+  );
+}

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

@@ -0,0 +1,92 @@
+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, { type BeaconGroup } from "../../assets/Selector/BeaconConfigurator";
+import { ContainerType } from "../simulator";
+
+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 as BeaconGroup[]}
+              onChange={(beacons) =>
+                updateMachineConfig({
+                  setup: { ...node.machineConfig!.setup, beacons },
+                })
+              }
+            />
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

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

@@ -0,0 +1,203 @@
+/* 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 {
+  flex-grow: 1;
+  background-color: #1a1a1a;
+  overflow-y: auto;
+  position: relative;
+}
+
+.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;
+}

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

@@ -0,0 +1,120 @@
+import { useState } 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";
+
+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 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,
+      [id]: { id, sourceId: "", destinationId: "", itemId: "", stackSize: 1 },
+    }));
+    setSelectedId(id);
+    setSelectionType("edge");
+  };
+
+  return (
+    <div className={styles.container}>
+      {/* LEFT PANE: Directory / Graph List */}
+      <div className={styles.sidebar}>
+        <div className={styles.sidebarHeader}>
+          <h3>Topology</h3>
+          <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}>Select a node or inserter to configure</div>}
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,25 @@
+import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
+import type { MachineSetup, CalculatedTimings, BatchPlan } from "../model";
+import type { ContainerType } from "../simulator";
+
+export interface TopologyNode {
+  id: string;
+  name: string; // User-friendly name (e.g., "Copper Smelter A")
+  type: ContainerType;
+  machineConfig?: {
+    setup: MachineSetup;
+    recipe: Recipe | null;
+    multiplier: number;
+    // Timings and Batch will be computed dynamically when the setup/recipe changes
+    timings?: CalculatedTimings;
+    batch?: BatchPlan;
+  };
+}
+
+export interface TopologyEdge {
+  id: string;
+  sourceId: string;
+  destinationId: string;
+  itemId: string;
+  stackSize: number;
+}

+ 1 - 1
src/engine/timeline.test.ts → src/engine/clockGenerator.test.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect } from "vitest";
-import { generateAdvancedClock } from "./timeline";
+import { generateAdvancedClock } from "./clockGenerator";
 import type { BatchPlan, ClockConfig } from "./model";
 
 function assertBlocksWithinBounds(timeline: ReturnType<typeof generateAdvancedClock>) {

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


+ 1 - 1
src/engine/index.ts

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

+ 41 - 11
src/engine/simulator.test.ts

@@ -9,9 +9,10 @@ import {
   Belt,
   FilterableInserterSimulator,
 } from "./simulator";
-import type { MachineSetup } from "./model";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 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", () => {
   const mockMachineSetup: MachineSetup = {
@@ -519,8 +520,9 @@ describe("Factorio Strict Phase Orchestrator", () => {
     const sink = new Chest();
 
     // 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
     const inputInserter = new InserterSimulator(2, source, machine, "iron-plate");
@@ -674,21 +676,49 @@ describe("Factorio Strict Phase Orchestrator", () => {
     // Cycle: 480 ticks. 16 crafts per cycle.
 
     // Inputs (Copper/Iron): 16 items needed = 1 swing per cycle.
-    orchestrator.registerRow("row_inputs", [{ start: 1, end: 9 }], 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.
-    orchestrator.registerRow(
-      "row_mid_outer",
+    orchestrator.registerClockRow(
+      rowMidOuter,
       [
-        { start: 1, end: 10 },
-        { start: 321, end: 329 },
+        { 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,
     );
+    orchestrator.registerClockRow(
+      rowMidInner,
+      [{ id: "b4", rowId: "row_mid_inner", presetId: "custom", start: 161, duration: 8, count: 16 }],
+      480,
+    );
 
-    orchestrator.registerRow("row_mid_inner", [{ start: 161, end: 169 }], 480);
-
-    orchestrator.registerRow("row_outputs", [{ start: 0, end: 16 }], 480);
+    orchestrator.registerClockRow(
+      rowOutputs,
+      [{ id: "b5", rowId: "row_outputs", presetId: "custom", start: 0, duration: 16, count: 16 }],
+      480,
+    );
 
     // Instantiate Inserters (Stack Size 16)
     const inCop1 = new InserterSimulator(16, sourceCopper, cop1, "copper-plate");

+ 15 - 1
src/engine/simulator.ts

@@ -1,6 +1,7 @@
 import { computeMachineStats } from "./stats";
 import type { MachineSetup, CalculatedTimings } from "./model";
 import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type { ClockBlock, ClockRow } from "../assets/ClockTimeline/model";
 
 export enum ContainerType {
   Chest = "Chest",
@@ -526,7 +527,20 @@ export class FactorioEngineOrchestrator {
   public registerInserter(inserter: InserterSimulator) {
     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);
     this.rows.set(rowId, row);
     this.rowsArray.push(row);

+ 56 - 25
src/hooks/useQualityScroller.ts

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, useMemo } from "react";
+import { useEffect, useRef, useState, useMemo, useCallback } from "react";
 import data from "../assets/data/2.0/data.json";
 
 export type Quality = {
@@ -33,31 +33,49 @@ while (current) {
 
 export function useQualityScroller(
   initialQualityName: string = baseQuality.name,
+  controlledQualityLevel?: number, // Accept the integer level from the parent
   onChange?: (quality: Quality) => void,
   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 {
         const saved = localStorage.getItem(persistKey);
-        // Ensure the saved quality still exists in the data
         if (saved && orderedQualities.some((q) => q.name === saved)) {
           return saved;
         }
-      } catch (e) {
-        return "normal";
-      }
+      } catch (e) {}
     }
     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(() => {
-    if (persistKey) {
+    if (persistKey && !isControlled) {
       try {
-        localStorage.setItem(persistKey, qualityName);
+        localStorage.setItem(persistKey, effectiveName);
       } 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);
 
@@ -69,30 +87,43 @@ export function useQualityScroller(
       if (!e.altKey) return;
       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];
 
-        if (nextIndex !== currentIndex) {
-          const newQuality = orderedQualities[nextIndex];
-          onChange?.(newQuality);
-          return newQuality.name;
+        // Only mutate internal state if we own it
+        if (!isControlled) {
+          setInternalQualityName(newQuality.name);
         }
-        return prevName;
-      });
+
+        // Always report changes upwards
+        onChangeRef.current?.(newQuality);
+      }
     };
 
     el.addEventListener("wheel", handleWheel, { passive: false });
     return () => el.removeEventListener("wheel", handleWheel);
-  }, [onChange]);
+  }, [isControlled]); // Rebind only if control mode changes
 
   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 };

+ 4 - 0
src/index.css

@@ -450,6 +450,7 @@ input:focus {
 }
 select,
 .customSelectTrigger,
+.customNumberInput,
 input[type="text"],
 input[type="password"],
 input[type="email"],
@@ -475,6 +476,7 @@ textarea {
     0px 0px 4px 1px #2e2521;
 }
 select:focus,
+.customNumberInput:focus,
 .customSelectTrigger:focus,
 .customSelectTrigger.isOpen,
 input[type="text"]:focus,
@@ -497,6 +499,7 @@ textarea:focus {
 
 input.dark,
 select.dark,
+.customNumberInput.dark,
 .customSelectTrigger.dark {
   background-color: #242324;
   border: 1px solid #646464;
@@ -504,6 +507,7 @@ select.dark,
 }
 input:focus.dark,
 select:focus.dark,
+.customNumberInput.dark:focus,
 .customSelectTrigger.dark:focus,
 .customSelectTrigger.dark.isOpen {
   outline: 2px solid #f1be64;

+ 2 - 0
src/main.tsx

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