Ver código fonte

Merge branch 'master' of http://gitlab.jaquin.fr/clovis/factorio-clockmaster

clovis 1 mês atrás
pai
commit
3e58f42b67

+ 31 - 31
package-lock.json

@@ -15,7 +15,8 @@
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-router": "^8.3.0",
-        "simplebar-react": "^3.3.2"
+        "simplebar-react": "^3.3.2",
+        "zustand": "^5.0.14"
       },
       "devDependencies": {
         "@commander-js/extra-typings": "^14.0.0",
@@ -1039,9 +1040,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1059,9 +1057,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1079,9 +1074,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1099,9 +1091,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1119,9 +1108,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1139,9 +1125,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2827,9 +2810,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -2851,9 +2831,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -2875,9 +2852,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -2899,9 +2873,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -4210,6 +4181,35 @@
       "peerDependencies": {
         "zod": "^3.25.0 || ^4.0.0"
       }
+    },
+    "node_modules/zustand": {
+      "version": "5.0.14",
+      "resolved": "https://artifactory.2b82.aws.cloud.airbus.corp:443/artifactory/api/npm/r-af5d-mydef-npm-virtual/zustand/-/zustand-5.0.14.tgz",
+      "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      },
+      "peerDependencies": {
+        "@types/react": ">=18.0.0",
+        "immer": ">=9.0.6",
+        "react": ">=18.0.0",
+        "use-sync-external-store": ">=1.2.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "immer": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "use-sync-external-store": {
+          "optional": true
+        }
+      }
     }
   }
 }

+ 2 - 1
package.json

@@ -20,7 +20,8 @@
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-router": "^8.3.0",
-    "simplebar-react": "^3.3.2"
+    "simplebar-react": "^3.3.2",
+    "zustand": "^5.0.14"
   },
   "devDependencies": {
     "@commander-js/extra-typings": "^14.0.0",

+ 15 - 0
scripts/factorio-dump/helpers/recipes.helper.ts

@@ -12,11 +12,23 @@ export type Recipe = {
   icon?: string;
   subgroup: string;
   order?: string;
+    /** The [category](prototype:RecipeCategory) of this recipe. Controls which machines can craft this recipe.
+
+The built-in categories can be found [here](https://wiki.factorio.com/Data.raw#recipe-category). The base `"crafting"` category can not contain recipes with fluid ingredients or products. */
   category?: string;
   additional_categories?: string[];
+  /** The amount of time it takes to make this recipe. Must be `> 0.001`. Equals the number of seconds it takes to craft at crafting speed `1`. */
+  energy_required?: number;
+  /** Whether the recipe is allowed to have the extra inserter overload bonus applied (4 * stack inserter stack size). */
+  allow_inserter_overload?: boolean;
+  
   allow_productivity?: boolean;
   allow_quality?: boolean;
   allow_speed?: boolean;
+  /** Used to determine how many extra items are put into an assembling machine before it's considered "full enough". See [insertion limits](https://wiki.factorio.com/Inserters#Insertion_limits).
+
+If set to `0`, it instead uses the following formula: `1.166 / (energy_required / the assembler's crafting_speed)`, rounded up, and clamped to be between`2` and `100`. The numbers used in this formula can be changed by the [UtilityConstants](prototype:UtilityConstants) properties `dynamic_recipe_overload_factor`, `minimum_recipe_overload_multiplier`, and `maximum_recipe_overload_multiplier`. */
+  overload_multiplier?: number;
   ingredients?: Ingredient[];
   results?: Array<ItemProductPrototype | FluidProductPrototype>;
 };
@@ -55,6 +67,9 @@ export function parseRecipe(recipe: RecipePrototype, itemsMap: Record<string, It
     name: recipe.name,
     icon: getRecipeIcon(recipe, mainProduct),
     subgroup: getRecipeSubGroup(recipe, mainProduct),
+    energy_required:recipe.energy_required,
+    allow_inserter_overload:recipe.allow_inserter_overload,
+    overload_multiplier:recipe.overload_multiplier,
     order: recipe.order ?? mainProduct.order,
     category: recipe.category ?? "crafting",
     additional_categories: recipe.additional_categories,

+ 147 - 75
src/ClockBuilder.tsx

@@ -1,36 +1,52 @@
 import { useEffect, useMemo, useRef, useState } from "react";
-import ClockTimeline, { defaultActivationSignal } from "./assets/ClockTimeline";
+import ClockTimeline from "./assets/ClockTimeline";
 import { buildBlueprint } from "./blueprint/Blueprintbuilder";
 import { encodeBlueprintFileBrowser } from "./blueprint/parser";
-import type { ClockBlock, ClockRow } from "./assets/types";
+import type { ClockBlock } from "./assets/types";
 import styles from "./ClockBuilder.module.css";
 import SelectedBlockPanel from "./assets/components/SelectedBlockPanel";
-import SelectSignal, { type Signal } from "./assets/SelectSignal";
+import SelectSignal from "./assets/SelectSignal";
 import ExpressionInput from "./assets/components/ExpressionInpux";
+import { useClockStore } from "./store/useClockStore";
+import ClockWizard from "./assets/components/ClockWizard";
 
-let uid = 0;
-const nextId = (prefix: string) => `${prefix}-${++uid}-${Date.now()}`;
-
-const defaultClockSignal: Signal = {
-  type: "virtual-signal",
-  name: "signal-clock",
-  subgroup: "pictographs",
-  icon: "virtual-signal/signal-clock.png",
-  order: "p[clock]",
-};
 export default function ClockBuilder() {
-  const [duration, setDuration] = useState(256);
-  const [clockSignal, setClockSignal] = useState<Signal | null>(defaultClockSignal);
+  //  Subscribe to the Global Store
+  const {
+    duration,
+    setDuration,
+    clockSignal,
+    setClockSignal,
+    rows,
+    rowOrder,
+    blocks,
+    selectedBlockIds,
+    addBlocks,
+    removeBlocks,
+    loadState,
+  } = useClockStore();
 
-  const [rows, setRows] = useState<ClockRow[]>([
-    { id: nextId("row"), name: "Row 1", signals: [defaultActivationSignal], stackSize: 16, inserterCount: 1 },
-  ]);
+  //  Local UI State (Keep this in the component!)
   const [displayUnit, setDisplayUnit] = useState<"s" | "m">("s");
-  const [beltReference, setBeltReference] = useState({ name: "turbo-belt", itemsPerSecond: 240 });
-  const [blocks, setBlocks] = useState<ClockBlock[]>([]);
-  const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());
-  const selectedBlocks = blocks.filter((b) => selectedBlockIds.has(b.id));
+  const [beltReference, setBeltReference] = useState({
+    name: "turbo-belt",
+    itemsPerSecond: 240,
+  });
+  const [output, setOutput] = useState("");
+  const [status, setStatus] = useState<{ text: string; ok: boolean } | null>(
+    null,
+  );
 
+  // Derived Data for Exports/Generation
+  // We convert our normalized Objects back to Arrays on the fly for the Factorio generator
+  const rowsArray = useMemo(
+    () => rowOrder.map((id) => rows[id]),
+    [rows, rowOrder],
+  );
+  const blocksArray = useMemo(() => Object.values(blocks), [blocks]);
+  const totalCombinators = useMemo(() => rowsArray.length + 1, [rowsArray]);
+
+  // Keyboard Shortcuts (Ctrl+D and Delete)
   useEffect(() => {
     const isEditable = (el: Element | null) =>
       !!el &&
@@ -45,9 +61,11 @@ export default function ClockBuilder() {
       if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
         e.preventDefault();
         if (selectedBlockIds.size === 0) return;
+
         const clones: ClockBlock[] = [];
-        blocks.forEach((b) => {
-          if (selectedBlockIds.has(b.id)) {
+        selectedBlockIds.forEach((id) => {
+          const b = blocks[id];
+          if (b) {
             clones.push({
               ...b,
               id: `block-${Date.now()}-${Math.random().toString(36).slice(2)}`,
@@ -55,34 +73,32 @@ export default function ClockBuilder() {
             });
           }
         });
-        setBlocks((prev) => [...prev, ...clones]);
-        setSelectedBlockIds(new Set(clones.map((c) => c.id)));
+
+        // Use the new batch action
+        addBlocks(clones);
       } else if (e.key === "Delete") {
-        if (selectedBlockIds.size === 0) return;
         e.preventDefault();
-        setBlocks((prev) => prev.filter((b) => !selectedBlockIds.has(b.id)));
-        setSelectedBlockIds(new Set());
+        if (selectedBlockIds.size > 0) {
+          removeBlocks(Array.from(selectedBlockIds));
+        }
       }
     };
     window.addEventListener("keydown", handler);
     return () => window.removeEventListener("keydown", handler);
-  }, [blocks, selectedBlockIds]);
-
-  const handleChangeOne = (blockId: string, patch: Partial<ClockBlock>) => {
-    setBlocks((prev) => prev.map((b) => (b.id === blockId ? { ...b, ...patch } : b)));
-  };
-  const handleRemoveSelected = () => {
-    setBlocks((prev) => prev.filter((b) => !selectedBlockIds.has(b.id)));
-    setSelectedBlockIds(new Set());
-  };
-  const [output, setOutput] = useState("");
-  const [status, setStatus] = useState<{ text: string; ok: boolean } | null>(null);
-
-  const totalCombinators = useMemo(() => rows.length + 1, [rows]);
+  }, [blocks, selectedBlockIds, addBlocks, removeBlocks]);
 
+  // File I/O
   const handleSaveFile = () => {
-    const config = { duration, clockSignal, rows, blocks };
-    const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
+    // We save the original array format for backward compatibility
+    const config = {
+      duration,
+      clockSignal,
+      rows: rowsArray,
+      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;
@@ -99,10 +115,25 @@ export default function ClockBuilder() {
     reader.onload = () => {
       try {
         const parsed = JSON.parse(reader.result as string);
-        setDuration(parsed.duration);
-        setClockSignal(parsed.clockSignal);
-        setRows(parsed.rows);
-        setBlocks(parsed.blocks);
+
+        // Re-normalize the saved array data before injecting it into the store
+        const loadedRows = Object.fromEntries(
+          parsed.rows.map((r: any) => [r.id, r]),
+        );
+        const loadedBlocks = Object.fromEntries(
+          parsed.blocks.map((b: any) => [b.id, b]),
+        );
+        const loadedRowOrder = parsed.rows.map((r: any) => r.id);
+
+        loadState({
+          duration: parsed.duration,
+          clockSignal: parsed.clockSignal,
+          rows: loadedRows,
+          rowOrder: loadedRowOrder,
+          blocks: loadedBlocks,
+          selectedBlockIds: new Set(),
+        });
+
         setStatus({ text: "Clock loaded.", ok: true });
       } catch {
         setStatus({ text: "That file isn't a valid clock export.", ok: false });
@@ -111,16 +142,30 @@ export default function ClockBuilder() {
     reader.readAsText(file);
     e.target.value = "";
   };
+
+  // Factorio Blueprint Generation
   const handleGenerate = () => {
-    if (rows.length === 0 || blocks.length === 0) {
-      setStatus({ text: "Add at least one row and one block first.", ok: false });
+    if (rowsArray.length === 0 || blocksArray.length === 0) {
+      setStatus({
+        text: "Add at least one row and one block first.",
+        ok: false,
+      });
       return;
     }
     try {
-      const bp = buildBlueprint({ duration, clockSignal: clockSignal ?? defaultClockSignal, rows, blocks });
+      // Passes the derived arrays to the existing blueprint logic
+      const bp = buildBlueprint({
+        duration,
+        clockSignal,
+        rows: rowsArray,
+        blocks: blocksArray,
+      });
       const str = encodeBlueprintFileBrowser(bp);
       setOutput(str);
-      setStatus({ text: `Generated — ${bp.blueprint.entities.length} combinators.`, ok: true });
+      setStatus({
+        text: `Generated — ${bp.blueprint.entities.length} combinators.`,
+        ok: true,
+      });
     } catch (err) {
       setStatus({ text: `Error: ${(err as Error).message}`, ok: false });
     }
@@ -146,11 +191,17 @@ export default function ClockBuilder() {
 
         <div className={styles.field}>
           <label>Clock signal icon</label>
-          <SelectSignal value={clockSignal?.name} onSelectSignal={(_, sig) => setClockSignal(sig)} />
+          <SelectSignal
+            value={clockSignal?.name}
+            onSelectSignal={(_, sig) => setClockSignal(sig)}
+          />
         </div>
         <div className={styles.field}>
           <label>Throughput unit</label>
-          <select value={displayUnit} onChange={(e) => setDisplayUnit(e.target.value as "s" | "m")}>
+          <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>
@@ -160,36 +211,39 @@ export default function ClockBuilder() {
           <input
             type="text"
             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) => setBeltReference({ ...beltReference, itemsPerSecond: Number(e.target.value) || 0 })}
+            onChange={(e) =>
+              setBeltReference({
+                ...beltReference,
+                itemsPerSecond: Number(e.target.value) || 0,
+              })
+            }
           />
         </div>
       </div>
+      <ClockWizard />
+
+      <hr className={styles.divider} />
+      <ClockTimeline />
+
+      <SelectedBlockPanel />
 
-      <ClockTimeline
-        duration={duration}
-        rows={rows}
-        blocks={blocks}
-        onRowsChange={setRows}
-        onBlocksChange={setBlocks}
-        selectedBlockIds={selectedBlockIds}
-        onSelectBlocks={setSelectedBlockIds}
-      />
-      <SelectedBlockPanel
-        selectedBlocks={selectedBlocks}
-        onChangeOne={handleChangeOne}
-        onRemoveSelected={handleRemoveSelected}
-      />
       <div className={styles.footer}>
         <div className={styles.actions}>
           <button className={styles.generateBtn} onClick={handleGenerate}>
             Generate blueprint
           </button>
-          <button className={styles.copyBtn} onClick={handleCopy} disabled={!output}>
+          <button
+            className={styles.copyBtn}
+            onClick={handleCopy}
+            disabled={!output}
+          >
             Copy string
           </button>
           <span className={styles.count}>
@@ -198,13 +252,31 @@ export default function ClockBuilder() {
           <button className={styles.copyBtn} onClick={handleSaveFile}>
             Save clock
           </button>
-          <input ref={fileInputRef} type="file" accept="application/json" hidden onChange={handleLoadFile} />
-          <button className={styles.copyBtn} onClick={() => fileInputRef.current?.click()}>
+          <input
+            ref={fileInputRef}
+            type="file"
+            accept="application/json"
+            hidden
+            onChange={handleLoadFile}
+          />
+          <button
+            className={styles.copyBtn}
+            onClick={() => fileInputRef.current?.click()}
+          >
             Load clock
           </button>
-          {status && <span className={status.ok ? styles.statusOk : styles.statusErr}>{status.text}</span>}
+          {status && (
+            <span className={status.ok ? styles.statusOk : styles.statusErr}>
+              {status.text}
+            </span>
+          )}
         </div>
-        <textarea className={styles.output} readOnly value={output} placeholder="Blueprint string will appear here…" />
+        <textarea
+          className={styles.output}
+          readOnly
+          value={output}
+          placeholder="Blueprint string will appear here…"
+        />
       </div>
     </div>
   );

+ 2 - 0
src/Layout.tsx

@@ -4,6 +4,8 @@ import { Outlet, useLocation, useNavigate } from "react-router";
 const NAV_ITEMS = [
   { label: "Clock Builder", path: "/" },
   { label: "Component Tests", path: "/tests" },
+  { label: "Wizard Tests", path: "/Wizar" },
+  { label: "InputConfigurator", path: "/InputConfigurator" },
 ];
 
 export default function Layout() {

+ 45 - 329
src/assets/ClockTimeline.tsx

@@ -1,22 +1,12 @@
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useMemo } from "react";
 import Icon from "./icon";
-import SelectSignal, { type Signal } from "./SelectSignal";
 import styles from "./ClockTimeline.module.css";
-import { ACTION_PRESETS, expandBlockInstances, getPreset, type ClockBlock, type ClockRow } from "./types";
-import ExpressionInput from "./components/ExpressionInpux";
+import { ACTION_PRESETS } from "./types";
+import TimelineRow from "./components/TimelineRow";
+import { useClockStore } from "../store/useClockStore";
 
-type Props = {
-  duration: number;
-  rows: ClockRow[];
-  blocks: ClockBlock[];
-  selectedBlockIds: Set<string>;
-  onRowsChange: (rows: ClockRow[]) => void;
-  onBlocksChange: (blocks: ClockBlock[]) => void;
-  onSelectBlocks: (ids: Set<string>) => void;
-};
+type Props = {};
 
-const LANE_HEIGHT = 30;
-const LANE_GAP = 4;
 export const defaultActivationSignal = {
   type: "virtual-signal",
   name: "signal-check",
@@ -24,238 +14,61 @@ export const defaultActivationSignal = {
   icon: "virtual-signal/signal-check.png",
   order: "a[checked]",
 };
-let uid = 0;
-const nextId = (prefix: string) => `${prefix}-${++uid}-${Date.now()}`;
-type PackedInstance = { id: string; blockId: string; start: number; duration: number; lane: number };
-
-function packLanes(instances: { id: string; blockId: string; start: number; duration: number }[]) {
-  const sorted = [...instances].sort((a, b) => a.start - b.start);
-  const laneEnds: number[] = [];
-  const packed: PackedInstance[] = [];
-  for (const inst of sorted) {
-    let lane = laneEnds.findIndex((end) => end <= inst.start);
-    if (lane === -1) {
-      lane = laneEnds.length;
-      laneEnds.push(inst.start + inst.duration);
-    } else {
-      laneEnds[lane] = inst.start + inst.duration;
-    }
-    packed.push({ ...inst, lane });
-  }
-  return { packed, laneCount: Math.max(1, laneEnds.length) };
-}
-
-export default function ClockTimeline({
-  duration,
-  rows,
-  blocks,
-  selectedBlockIds,
-  onRowsChange,
-  onBlocksChange,
-  onSelectBlocks,
-}: Props) {
-  const laneRefs = useRef<Record<string, HTMLDivElement | null>>({});
-  const [alignmentTick, setAlignmentTick] = useState<number | null>(null);
-  const dragState = useRef<{
-    blockId: string;
-    mode: "move" | "resize";
-    startX: number;
-    laneWidth: number;
-    shiftKey: boolean;
-    wasSelected: boolean;
-    groupOrigStarts: Map<string, number>; // blockId -> original start, for every block moving together
-    origDuration: number;
-  } | null>(null);
 
-  const pxToTick = useCallback((laneWidth: number, px: number) => Math.round((px / laneWidth) * duration), [duration]);
-
-  const addRow = () => {
-    const row: ClockRow = {
-      id: nextId("row"),
-      name: `Row ${rows.length + 1}`,
-      signals: [defaultActivationSignal],
-      stackSize: 16,
-      inserterCount: 1,
-    };
-    onRowsChange([...rows, row]);
-  };
-
-  const removeRow = (rowId: string) => {
-    onRowsChange(rows.filter((r) => r.id !== rowId));
-    const removedIds = new Set(blocks.filter((b) => b.rowId === rowId).map((b) => b.id));
-    onBlocksChange(blocks.filter((b) => b.rowId !== rowId));
-    if ([...selectedBlockIds].some((id) => removedIds.has(id))) {
-      onSelectBlocks(new Set([...selectedBlockIds].filter((id) => !removedIds.has(id))));
-    }
-  };
-
-  const updateRow = (rowId: string, patch: Partial<ClockRow>) => {
-    onRowsChange(rows.map((r) => (r.id === rowId ? { ...r, ...patch } : r)));
-  };
+export default function ClockTimeline({}: Props) {
+  const { duration, rowOrder, alignmentTick, addRow } = useClockStore();
 
-  const updateRowSignal = (rowId: string, index: number, signal: Signal | null) => {
-    if (!signal) return;
-    const row = rows.find((r) => r.id === rowId);
-    if (!row) return;
-    const next = [...row.signals];
-    next[index] = signal;
-    updateRow(rowId, { signals: next });
-  };
-  const addRowSignal = (rowId: string, signal: Signal | null) => {
-    if (!signal) return;
-    const row = rows.find((r) => r.id === rowId);
-    if (!row) return;
-    updateRow(rowId, { signals: [...row.signals, signal] });
-  };
-  const removeRowSignal = (rowId: string, index: number) => {
-    const row = rows.find((r) => r.id === rowId);
-    if (!row || row.signals.length <= 1) return; // keep at least one
-    updateRow(rowId, { signals: row.signals.filter((_, i) => i !== index) });
-  };
-
-  const autoFillRow = (rowId: string, presetId: string) => {
-    const row = rows.find((r) => r.id === rowId);
-    if (!row) return;
-    const preset = getPreset(presetId);
-    const count = Math.max(1, row.inserterCount);
-    const spacing = duration / count;
-    const generated: ClockBlock[] = Array.from({ length: count }, (_, i) => ({
-      id: nextId("block"),
-      rowId,
-      presetId,
-      start: Math.round(i * spacing),
-      duration: preset.ticks + 1,
-      count: 1,
-    }));
-    onBlocksChange([...blocks.filter((b) => b.rowId !== rowId), ...generated]);
-  };
+  const majorTicks = useMemo(() => {
+    const step = Math.max(1, Math.round(duration / 10));
+    const out: number[] = [];
+    for (let t = 0; t <= duration; t += step) out.push(t);
+    return out;
+  }, [duration]);
 
   const onPaletteDragStart = (e: React.DragEvent, presetId: string) => {
     e.dataTransfer.setData("text/plain", presetId);
     e.dataTransfer.effectAllowed = "copy";
   };
 
-  const onLaneDrop = (e: React.DragEvent, rowId: string) => {
-    e.preventDefault();
-    const presetId = e.dataTransfer.getData("text/plain");
-    const preset = ACTION_PRESETS.find((p) => p.id === presetId);
-    const lane = laneRefs.current[rowId];
-    if (!preset || !lane) return;
-    const rect = lane.getBoundingClientRect();
-    const tick = Math.max(0, Math.min(duration - 1, pxToTick(rect.width, e.clientX - rect.left)));
-    const block: ClockBlock = {
-      id: nextId("block"),
-      rowId,
-      presetId: preset.id,
-      start: tick,
-      duration: preset.ticks + 1,
-      count: 1,
-    };
-    onBlocksChange([...blocks, block]);
-    onSelectBlocks(new Set([block.id]));
-  };
-  const onPointerDownBlock = (e: React.PointerEvent, block: ClockBlock, mode: "move" | "resize") => {
-    e.stopPropagation();
-    const lane = laneRefs.current[block.rowId];
-    if (!lane) return;
-    (e.target as HTMLElement).setPointerCapture(e.pointerId);
-
-    const wasSelected = selectedBlockIds.has(block.id);
-    const groupIds = mode === "move" && wasSelected ? selectedBlockIds : new Set([block.id]);
-
-    dragState.current = {
-      blockId: block.id,
-      mode,
-      startX: e.clientX,
-      laneWidth: lane.clientWidth,
-      shiftKey: e.shiftKey,
-      wasSelected,
-      groupOrigStarts: new Map(blocks.filter((b) => groupIds.has(b.id)).map((b) => [b.id, b.start])),
-      origDuration: block.duration,
-    };
-  };
-
-  const onPointerMove = (e: React.PointerEvent) => {
-    const drag = dragState.current;
-    if (!drag) return;
-    const deltaTicks = pxToTick(drag.laneWidth, e.clientX - drag.startX);
-
-    if (drag.mode === "resize") {
-      updateBlockDuration(drag.blockId, Math.max(1, drag.origDuration + deltaTicks));
-      setAlignmentTick(null);
-      return;
-    }
-
-    // Group move: apply the same delta to every block's original start,
-    // clamped as a group so none of them fall off either edge.
-    const patched = new Map<string, number>();
-    let clampDelta = deltaTicks;
-    drag.groupOrigStarts.forEach((origStart) => {
-      const proposed = origStart + clampDelta;
-      if (proposed < 0) clampDelta = Math.max(clampDelta, -origStart);
-      if (proposed > duration - 1) clampDelta = Math.min(clampDelta, duration - 1 - origStart);
+  const handleAddRow = () => {
+    addRow({
+      id: `row-${Date.now()}`,
+      name: `Row ${rowOrder.length + 1}`,
+      signals: [defaultActivationSignal],
+      stackSize: 16,
+      inserterCount: 1,
     });
-    drag.groupOrigStarts.forEach((origStart, blockId) => patched.set(blockId, origStart + clampDelta));
-
-    onBlocksChange(blocks.map((b) => (patched.has(b.id) ? { ...b, start: patched.get(b.id)! } : b)));
-
-    // Alignment guide: does the primary dragged block now share a start tick
-    // with any block outside the moving group?
-    const draggedNewStart = patched.get(drag.blockId);
-    const match = blocks.some((b) => !drag.groupOrigStarts.has(b.id) && b.start === draggedNewStart);
-    setAlignmentTick(match && draggedNewStart !== undefined ? draggedNewStart : null);
-  };
-
-  const updateBlockDuration = (blockId: string, newDuration: number) => {
-    onBlocksChange(blocks.map((b) => (b.id === blockId ? { ...b, duration: newDuration } : b)));
   };
 
-  const onPointerUp = (e: React.PointerEvent) => {
-    const drag = dragState.current;
-    dragState.current = null;
-    setAlignmentTick(null);
-    if (!drag) return;
-
-    const moved = Math.abs(e.clientX - drag.startX) >= 3;
-
-    if (drag.mode === "move" && !moved) {
-      // Tap — resolve as a selection change.
-      if (drag.shiftKey) {
-        const next = new Set(selectedBlockIds);
-        next.has(drag.blockId) ? next.delete(drag.blockId) : next.add(drag.blockId);
-        onSelectBlocks(next);
-      } else {
-        onSelectBlocks(new Set([drag.blockId]));
-      }
-    } else if (drag.mode === "move" && moved && !drag.wasSelected) {
-      // Actually dragged a block that wasn't part of the prior selection — it becomes the selection.
-      onSelectBlocks(new Set([drag.blockId]));
-    }
-  };
-
-  const majorTicks = useMemo(() => {
-    const step = Math.max(1, Math.round(duration / 10));
-    const out: number[] = [];
-    for (let t = 0; t <= duration; t += step) out.push(t);
-    return out;
-  }, [duration]);
-
   return (
     <div className={styles.wrap}>
       <div className={styles.palette}>
         {ACTION_PRESETS.map((p) => (
-          <div key={p.id} className={styles.chip} draggable onDragStart={(e) => onPaletteDragStart(e, p.id)}>
-            {p.fromItem && <Icon iconName={`item/${p.fromItem}.png`} size={20} />}
+          <div
+            key={p.id}
+            className={styles.chip}
+            draggable
+            onDragStart={(e) => onPaletteDragStart(e, p.id)}
+          >
+            {p.fromItem && (
+              <Icon iconName={`item/${p.fromItem}.png`} size={20} />
+            )}
             <span>{p.label}</span>
             <span className={styles.chipTicks}>{p.ticks}t</span>
           </div>
         ))}
-        <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 className={styles.ruler}>
         {majorTicks.map((t) => (
-          <div key={t} className={styles.rulerTick} style={{ left: `${(t / duration) * 100}%` }}>
+          <div
+            key={t}
+            className={styles.rulerTick}
+            style={{ left: `${(t / duration) * 100}%` }}
+          >
             <span>{t}</span>
           </div>
         ))}
@@ -264,114 +77,17 @@ export default function ClockTimeline({
 
       <div className={styles.rows}>
         {alignmentTick !== null && (
-          <div className={styles.alignGuide} style={{ left: `${(alignmentTick / duration) * 100}%` }} />
+          <div
+            className={styles.alignGuide}
+            style={{ left: `${(alignmentTick / duration) * 100}%` }}
+          />
         )}
 
-        {rows.map((row) => {
-          const rowBlocks = blocks.filter((b) => b.rowId === row.id);
-          const allInstances = rowBlocks.flatMap((b) => expandBlockInstances(b));
-          const { packed, laneCount } = packLanes(allInstances);
-          const laneAreaHeight = laneCount * LANE_HEIGHT + (laneCount - 1) * LANE_GAP + 8;
+        {rowOrder.map((rowId) => (
+          <TimelineRow key={rowId} rowId={rowId} />
+        ))}
 
-          return (
-            <div key={row.id} className={styles.rowBlockWrap}>
-              <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(row.id, i, sig)} />
-                      {row.signals.length > 1 && (
-                        <button className={styles.signalRemove} onClick={() => removeRowSignal(row.id, i)}>
-                          ✕
-                        </button>
-                      )}
-                    </div>
-                  ))}
-                  <SelectSignal key={row.signals.length} onSelectSignal={(_, sig) => addRowSignal(row.id, sig)} />
-                </div>
-                <div className={styles.rowStats}>
-                  <label>Stack</label>
-                  <ExpressionInput
-                    value={row.stackSize}
-                    min={1}
-                    onCommit={(v) => updateRow(row.id, { stackSize: v })}
-                  />
-                  <label>Inserters</label>
-                  <ExpressionInput
-                    value={row.inserterCount}
-                    min={1}
-                    onCommit={(v) => updateRow(row.id, { inserterCount: v })}
-                  />
-                </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>
-                <button
-                  className={styles.autoFillBtn}
-                  onClick={() => {
-                    const select = document.getElementById(`autofill-preset-${row.id}`) as HTMLSelectElement | null;
-                    autoFillRow(row.id, select?.value ?? "chest_to_belt");
-                  }}
-                >
-                  Auto-fill ({row.inserterCount})
-                </button>
-                <button className={styles.removeBtn} onClick={() => removeRow(row.id)}>
-                  ✕
-                </button>
-              </div>
-              <div
-                className={styles.lane}
-                style={{ height: laneAreaHeight }}
-                ref={(el) => (laneRefs.current[row.id] = el)}
-                onDragOver={(e) => e.preventDefault()}
-                onDrop={(e) => onLaneDrop(e, row.id)}
-                onPointerMove={onPointerMove}
-                onPointerUp={onPointerUp}
-              >
-                {packed.map((inst) => {
-                  const block = rowBlocks.find((b) => b.id === inst.blockId)!;
-                  const preset = getPreset(block.presetId);
-                  const left = (inst.start / duration) * 100;
-                  const width = (inst.duration / duration) * 100;
-                  const selected = selectedBlockIds.has(block.id);
-                  const aligned = alignmentTick !== null && inst.start === alignmentTick;
-                  return (
-                    <div
-                      key={inst.id}
-                      className={`${styles.block} ${selected ? styles.blockSelected : ""} ${aligned ? styles.blockAligned : ""}`}
-                      style={{
-                        left: `${left}%`,
-                        width: `${width}%`,
-                        top: inst.lane * (LANE_HEIGHT + LANE_GAP) + 4,
-                        height: LANE_HEIGHT,
-                      }}
-                      onPointerDown={(e) => onPointerDownBlock(e, block, "move")}
-                    >
-                      <div className={styles.blockIcons}>
-                        {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={18} />}
-                        {preset.toItem && <Icon iconName={`item/${preset.toItem}.png`} size={18} />}
-                      </div>
-                      <div
-                        className={styles.resizeHandle}
-                        onPointerDown={(e) => onPointerDownBlock(e, block, "resize")}
-                      />
-                    </div>
-                  );
-                })}
-              </div>
-            </div>
-          );
-        })}
-        <button className={styles.addRowBtn} onClick={addRow}>
+        <button className={styles.addRowBtn} onClick={handleAddRow}>
           + Add row
         </button>
       </div>

+ 111 - 28
src/assets/MachineSelector.tsx

@@ -1,65 +1,117 @@
-import React, { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
+  type CSSProperties,
+} from "react";
 import data from "../assets/data/2.0/data.json";
 import styles from "./MachineSelector.module.css";
 import Icon from "./icon";
-import { Autocomplete, Box, Popper, TextField } from "@mui/material";
+import {
+  Autocomplete,
+  Box,
+  Popper,
+  TextField,
+  InputAdornment,
+} from "@mui/material";
 import SelectMenu from "./SelectFactorioMenu";
-import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
 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 Hook
 
-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[];
+
 type MachineSelectorProps = {
   className?: string;
   style?: CSSProperties;
-  onSelectMachine?: (itemName: string) => void;
+  onChange?: (selection: {
+    machine: Machine | null;
+    qualityLevel: number;
+    recipe: Recipe | null;
+  }) => void;
 };
-function MachineSelector({ style, className, onSelectMachine }: MachineSelectorProps) {
+
+function MachineSelector({ style, className, onChange }: MachineSelectorProps) {
   const [machine, setMachine] = React.useState<Machine | null>(null);
   const [recipe, setRecipe] = useState<null | Recipe>(null);
   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
   const [showMenuRecipe, setShowMenuRecipe] = useState(false);
 
+  const { scrollRef, activeQuality, setQualityName } =
+    useQualityScroller("normal");
+
+  useEffect(() => {
+    if (onChange) {
+      onChange({ machine, qualityLevel: activeQuality.level, recipe });
+    }
+  }, [machine, activeQuality, recipe]);
+
   const machineOptions = useMemo(() => {
     if (recipe == null) return [];
     const categories = [recipe.category];
-    if (recipe.additional_categories) categories.push(...recipe.additional_categories);
-    return machines.filter((m) => m.crafting_categories.some((c) => categories.includes(c)));
+    if (recipe.additional_categories)
+      categories.push(...recipe.additional_categories);
+    return machines.filter((m) =>
+      m.crafting_categories.some((c) => categories.includes(c)),
+    );
   }, [recipe]);
+
   useEffect(() => {
-    if (machineOptions.length == 1) setMachine(machineOptions[0]);
+    if (machineOptions.length === 1) setMachine(machineOptions[0]);
     if (machine && !machineOptions.includes(machine)) setMachine(null);
-  }, [machineOptions]);
+  }, [machineOptions, machine]);
 
   const onOpenRecipe = useCallback(
     (event: React.MouseEvent<HTMLElement>) => {
       setAnchorEl(anchorEl ? null : event.currentTarget);
       setShowMenuRecipe(!showMenuRecipe);
     },
-    [setAnchorEl, setShowMenuRecipe],
-  );
-  const onSelectRecipe = useCallback(
-    (name: string) => {
-      setRecipe(recipeList.find((r) => r.name == name) ?? null);
-      setAnchorEl(null);
-      setShowMenuRecipe(false);
-    },
-    [setShowMenuRecipe, setRecipe],
+    [anchorEl, showMenuRecipe],
   );
 
+  const onSelectRecipe = useCallback((name: string) => {
+    setRecipe(recipeList.find((r) => r.name === name) ?? null);
+    setAnchorEl(null);
+    setShowMenuRecipe(false);
+  }, []);
+
   return (
-    <div className={className} style={style}>
-      <div onClick={onOpenRecipe} className={styles.machineSpecRecipe}>
+    <div
+      className={className}
+      style={{ ...style, display: "flex", gap: "12px", alignItems: "center" }}
+    >
+      {/* Recipe Trigger */}
+      <div
+        onClick={onOpenRecipe}
+        className={styles.machineSpecRecipe}
+        style={{ cursor: "pointer" }}
+      >
         {recipe == null ? (
-          <>Select recipe</>
+          <div
+            style={{
+              padding: "8px 12px",
+              border: "1px dashed #646464",
+              borderRadius: "4px",
+            }}
+          >
+            Select recipe
+          </div>
         ) : (
           <Tooltip title={recipe.name}>
-            <Icon iconName={recipe.icon ?? ""} size={30} />
+            <div>
+              <Icon iconName={recipe.icon ?? ""} size={40} />
+            </div>
           </Tooltip>
         )}
       </div>
+
       <Autocomplete
+        ref={scrollRef}
         value={machine}
         onChange={(_: any, newValue: Machine | null) => {
           setMachine(newValue);
@@ -68,26 +120,57 @@ function MachineSelector({ style, className, onSelectMachine }: MachineSelectorP
         options={machineOptions}
         sx={{ width: 300 }}
         getOptionLabel={(option) => option.name}
-        renderInput={(params: any) => <TextField {...params} label="Machine" />}
+        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}>
-              <Icon iconName={option.icon ?? ""} size={30} />
+            <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="right">
+
+      <Popper
+        open={showMenuRecipe}
+        anchorEl={anchorEl}
+        placement="bottom-start"
+        style={{ zIndex: 1300 }}
+      >
         <SelectMenu
           title="Select recipe"
           categories={data.recipeGroup}
           onSelectItem={onSelectRecipe}
           onClose={() => setShowMenuRecipe(false)}
-        ></SelectMenu>
+        />
       </Popper>
     </div>
   );
 }
+
 export default MachineSelector;

+ 0 - 16
src/assets/ModuleSelector.tsx

@@ -1,16 +0,0 @@
-import type { CSSProperties } from "@mui/material";
-import { useState } from "react";
-
-type ModuleSelectorProps = {
-  className?: string;
-  style?: CSSProperties;
-  moduleCount: number;
-  allowedEffects: string[];
-  onSelectModule?: (itemName: string) => void;
-};
-function ModuleSelector({ style, className, onSelectModule }: ModuleSelectorProps) {
-  const [selectedModules, setSelectedModule] = useState<string[]>([]);
-
-  return;
-  <div className={className} style={style}></div>;
-}

+ 74 - 27
src/assets/SelectFactorioMenu.tsx

@@ -5,6 +5,7 @@ import Tooltip from "./Tooltip";
 import SimpleBar from "simplebar-react";
 
 import data from "../assets/data/2.0/data.json";
+import { useQualityScroller } from "../hooks/useQualityScroller";
 
 const qualityLevels = data.qualityLevels;
 // Utility type definitions
@@ -44,12 +45,19 @@ const ITEM_PER_ROW = 10;
  * @param {string} search - User’s search query (space separated).
  * @returns {Category<MenuItem>} - A filtered copy of the category.
  */
-function filterCategory(category: Category<MenuItem>, search: string): Category<MenuItem> {
+function filterCategory(
+  category: Category<MenuItem>,
+  search: string,
+): Category<MenuItem> {
   const searchKeys = search.toLowerCase().split(" ");
 
   // Filter the children inside a single sub‑group
-  function filterSubgroupChildren(subgroup: SubGroup<MenuItem>): SubGroup<MenuItem> {
-    const children = subgroup.children.filter((item) => searchKeys.every((k) => item.name.includes(k)));
+  function filterSubgroupChildren(
+    subgroup: SubGroup<MenuItem>,
+  ): SubGroup<MenuItem> {
+    const children = subgroup.children.filter((item) =>
+      searchKeys.every((k) => item.name.includes(k)),
+    );
     return {
       ...subgroup,
       children,
@@ -57,7 +65,9 @@ function filterCategory(category: Category<MenuItem>, search: string): Category<
   }
   return {
     ...category,
-    subGroup: category.subGroup.map(filterSubgroupChildren).filter((s) => s.children.length > 0),
+    subGroup: category.subGroup
+      .map(filterSubgroupChildren)
+      .filter((s) => s.children.length > 0),
   };
 }
 
@@ -104,7 +114,7 @@ type SelectMenuProps = {
   categories: Category<MenuItem>[];
   title?: string;
   onClose?: () => void;
-  onSelectItem?: (itemName: string) => void;
+  onSelectItem?: (itemName: string, qualityLevel: number) => void;
 };
 /**
  * The main dropdown menu component. It renders:
@@ -120,30 +130,59 @@ type SelectMenuProps = {
  * @component
  * @param {SelectMenuProps} props
  */
-function SelectMenu({ style, className, title, categories, onClose, onSelectItem }: SelectMenuProps) {
+function SelectMenu({
+  style,
+  className,
+  title,
+  categories,
+  onClose,
+  onSelectItem,
+}: SelectMenuProps) {
   const [category, setCategory] = useState(categories[0].name);
   const [item, selectItem] = useState("");
   const [showSearch, setShowSearch] = useState(false);
   const [search, setSearch] = useState("");
-  const [quality, setQuality] = useState("normal");
+  const { scrollRef, activeQuality, setQualityName } = useQualityScroller(
+    "normal",
+    undefined,
+    "factorio-menu-quality",
+  );
 
-  const activeCategory = useMemo(() => categories.find((r) => r.name == category), [category, categories]);
-  const activeOnSelect = useMemo(() => (onSelectItem ? onSelectItem : selectItem), [onSelectItem, selectItem]);
+  const activeCategory = useMemo(
+    () => categories.find((r) => r.name == category),
+    [category, categories],
+  );
+  const handleItemSelect = (name: string) => {
+    if (onSelectItem) onSelectItem(name, activeQuality.level);
+    else selectItem(name);
+  };
   // Apply filtering only if a search is active
   const filteredContent = useMemo(
-    () => (search && activeCategory && showSearch ? filterCategory(activeCategory, search) : activeCategory),
+    () =>
+      search && activeCategory && showSearch
+        ? filterCategory(activeCategory, search)
+        : activeCategory,
     [activeCategory, search, showSearch],
   );
   const rootClassName = `${styles.selectMenu} ${className ?? ""}`;
   return (
-    <div style={style} className={rootClassName}>
+    <div style={style} className={rootClassName} ref={scrollRef}>
       {/* Header */}
       <div className={styles.selectMenuHeader}>
         <div className={styles.selectMenuHeaderTitle}>{title ?? "Title"}</div>
         <div className={styles.selectMenuHeaderSpacer} />
         <div className={styles.selectMenuHeaderAction}>
-          {showSearch && <input type="search" onChange={(evt) => setSearch(evt.target.value)} value={search} />}
-          <button className={`panel-button ${showSearch ? "active" : ""}`} onClick={() => setShowSearch(!showSearch)}>
+          {showSearch && (
+            <input
+              type="search"
+              onChange={(evt) => setSearch(evt.target.value)}
+              value={search}
+            />
+          )}
+          <button
+            className={`panel-button ${showSearch ? "active" : ""}`}
+            onClick={() => setShowSearch(!showSearch)}
+          >
             <img src="/assets/search.png" alt="Search" />
           </button>
           <button className="panel-button" onClick={onClose}>
@@ -152,22 +191,29 @@ function SelectMenu({ style, className, title, categories, onClose, onSelectItem
         </div>
       </div>
       {/* Category icons */}
-      <div className={styles.selectMenuGroup}>
-        {categories.map((g) => (
-          <div
-            key={g.name}
-            className={`${styles.selectMenuGroupIcon} ${g.name === category ? styles.active : ""}`}
-            onClick={() => setCategory(g.name)}
-          >
-            <Icon iconName={g.icon} size={62} />
-          </div>
-        ))}
-      </div>
+      {categories.length > 1 && (
+        <div className={styles.selectMenuGroup}>
+          {categories.map((g) => (
+            <div
+              key={g.name}
+              className={`${styles.selectMenuGroupIcon} ${g.name === category ? styles.active : ""}`}
+              onClick={() => setCategory(g.name)}
+            >
+              <Icon iconName={g.icon} size={62} />
+            </div>
+          ))}
+        </div>
+      )}
       {/* Scrollable content */}
       <div className={styles.selectMenuContent}>
         <SimpleBar style={{ maxHeight: 300 }}>
           {filteredContent?.subGroup.map((subgroup) => (
-            <SubGroupRow key={subgroup.name} subgroup={subgroup} selectedItem={item} onSelectItem={activeOnSelect} />
+            <SubGroupRow
+              key={subgroup.name}
+              subgroup={subgroup}
+              selectedItem={item}
+              onSelectItem={handleItemSelect}
+            />
           ))}
         </SimpleBar>
       </div>
@@ -175,8 +221,9 @@ function SelectMenu({ style, className, title, categories, onClose, onSelectItem
       <div className={styles.qualityMenu}>
         {qualityLevels.map((q) => (
           <div
-            className={`${styles.qualityMenuIcon} ${q.name === quality ? styles.active : ""}`}
-            onClick={() => setQuality(q.name)}
+            key={q.name}
+            className={`${styles.qualityMenuIcon} ${q.name === activeQuality.name ? styles.active : ""}`}
+            onClick={() => setQualityName(q.name)}
           >
             <Icon iconName={q.icon} size={20} />
           </div>

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

@@ -0,0 +1,82 @@
+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 }[];
+};
+
+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} 
+            qualityLevel={g.qualityLevel} 
+            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>
+  );
+}
+
+function BeaconIconWrapper({ beacon, qualityLevel, onChangeQuality }: any) {
+  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>
+  );
+}

+ 0 - 0
src/assets/ModuleSelector.module.css → src/assets/components/ClockWizard.module.css


+ 138 - 0
src/assets/components/ClockWizard.tsx

@@ -0,0 +1,138 @@
+import { useMemo, useState } from "react";
+import ModuleSlots from "./ModuleSlots";
+import styles from "./ClockWizard.module.css"; // (Create a simple flex-col layout for this)
+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 {
+  calculateOptimalBatch,
+  computeMachineStats,
+  generateAdvancedClock,
+} from "../../engine/factorioEngine";
+import MachineSelector from "../MachineSelector";
+import BeaconConfigurator, { type BeaconGroup } from "./BeaconConfigurator";
+
+export default function ClockWizard() {
+  const loadState = useClockStore((s) => s.loadState);
+
+  const [recipe, setRecipe] = useState<Recipe | null>(null);
+  const [machine, setMachine] = useState<Machine | null>(null);
+  const [machineQuality, setMachineQuality] = useState<number>(0);
+  
+  const [machineModules, setMachineModules] = useState<{ module: Module, qualityLevel: number }[]>([]);
+  const [beaconGroups, setBeaconGroups] = useState<BeaconGroup[]>([]);
+  const [inputConfigs, setInputConfigs] = useState<Record<string, any>>({});
+
+  // --- LIVE STATS CALCULATION ---
+  const stats = useMemo(() => {
+    if (!machine || !recipe) return null;
+    return computeMachineStats({
+      machine,
+      machineQualityLevel: machineQuality,
+      machineModules: machineModules,
+      beacons: beaconGroups.map(bg => ({
+        beacon: bg.beacon,
+        beaconQualityLevel: bg.qualityLevel,
+        count: bg.count,
+        modules: bg.modules
+      }))
+    }, recipe);
+  }, [machine, recipe, machineQuality, machineModules, beaconGroups]);
+
+
+  const handleGenerate = () => {
+    if (!recipe || !machine || !stats) return;
+
+    // 1. Math
+    const batch = calculateOptimalBatch(recipe, stats, 16);
+
+    // 2. Blueprint / Timeline Blocks
+    const clockData = generateAdvancedClock(batch, {
+      stackSize: 16,
+      //inputConfigs // (Assuming inputConfigs logic is mapped inside generator)
+    });
+
+    // 3. Update Store
+    loadState({
+      duration: clockData.duration,
+      rows: Object.fromEntries(clockData.rows.map(r => [r.id, r])),
+      rowOrder: clockData.rows.map(r => r.id),
+      blocks: Object.fromEntries(clockData.blocks.map(b => [b.id, b])),
+      selectedBlockIds: new Set()
+    });
+  };
+
+  return (
+    <div className={styles.wrap} style={{ display: "flex", flexDirection: "column", gap: "24px", padding: "20px", background: "#313031", color: "#ffe6c0", border: "1px solid #646464", borderRadius: "8px" }}>
+      
+      {/* 1. Recipe & Machine */}
+      <div style={{ display: "flex", gap: "20px", alignItems: "flex-start" }}>
+        <div style={{ flex: 1 }}>
+          <h2 style={{ fontSize: "16px", color: "#f1be64", margin: "0 0 12px 0" }}>1. Setup Machine</h2>
+          <MachineSelector 
+            onChange={(res) => { 
+              setMachine(res.machine); 
+              setMachineQuality(res.qualityLevel); 
+              setRecipe(res.recipe); 
+            }} 
+          />
+        </div>
+
+        {/* --- STATS PREVIEW DASHBOARD --- */}
+        {stats && (
+          <div style={{ flex: 1, background: "#1a1a1a", border: "1px dashed #f1be64", borderRadius: "6px", padding: "12px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
+            <div style={{ gridColumn: "span 2" }}>
+               <h3 style={{ margin: 0, fontSize: "12px", color: "#999", textTransform: "uppercase" }}>Live Capabilities</h3>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Crafting Speed</div>
+              <div style={{ fontSize: "18px", color: "#7fc98a", fontWeight: "bold" }}>{stats.actualCraftingSpeed.toFixed(2)}</div>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Productivity</div>
+              <div style={{ fontSize: "18px", color: "#7fc98a", fontWeight: "bold" }}>+{Math.round(stats.productivityBonus * 100)}%</div>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Craft Time (Ticks)</div>
+              <div style={{ fontSize: "14px", color: "#ffe6c0" }}>{stats.singleCraftTicks.toFixed(1)}t</div>
+            </div>
+            <div>
+              <div style={{ fontSize: "11px", color: "#999" }}>Overload Limit (Multiplier)</div>
+              <div style={{ fontSize: "14px", color: "#ffe6c0" }}>{stats.overloadMultiplier}x</div>
+            </div>
+          </div>
+        )}
+      </div>
+
+      {machine && recipe && (
+        <>
+          {/* 2. Modules & Beacons */}
+          <div style={{ display: "flex", gap: "40px" }}>
+            <div style={{ flex: 1 }}>
+              <h2 style={{ fontSize: "14px", color: "#f1be64", margin: "0 0 12px 0" }}>2. Machine Modules</h2>
+              <ModuleSlots 
+                maxSlots={machine.module_slots || 0} 
+                allowedEffects={machine.allowed_effects as string[]}
+                onChange={setMachineModules} 
+              />
+            </div>
+            <div style={{ flex: 2 }}>
+              <h2 style={{ fontSize: "14px", color: "#f1be64", margin: "0 0 12px 0" }}>3. Beacons</h2>
+              <BeaconConfigurator groups={beaconGroups} onChange={setBeaconGroups} />
+            </div>
+          </div>
+
+          <button 
+            onClick={handleGenerate}
+            style={{ padding: "10px 16px", background: "#f1be64", color: "#1a1300", fontWeight: "bold", border: "none", borderRadius: "4px", cursor: "pointer", alignSelf: "flex-start" }}
+          >
+            Generate Optimized Timeline
+          </button>
+        </>
+      )}
+    </div>
+  );
+}

+ 113 - 0
src/assets/components/InputConfigurator.module.css

@@ -0,0 +1,113 @@
+.wrap {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  background-color: #313031;
+  border: 1px solid #646464;
+  padding: 16px;
+  color: #ffe6c0;
+  font-size: 13px;
+  border-radius: 4px;
+}
+
+.wrap h3 {
+  margin: 0;
+  font-size: 14px;
+  color: #f1be64;
+  font-weight: 600;
+}
+
+.table {
+  width: 100%;
+  border-collapse: collapse;
+  text-align: left;
+}
+
+.table th {
+  padding: 8px;
+  color: #999;
+  font-size: 11px;
+  font-weight: 600;
+  text-transform: uppercase;
+  border-bottom: 1px solid #646464;
+}
+
+.table td {
+  padding: 8px;
+  border-bottom: 1px solid #3a3a3a;
+  vertical-align: middle;
+}
+
+.itemLabel {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-weight: 500;
+}
+
+.groupInput,
+.table 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;
+  width: 100%;
+  max-width: 140px;
+  box-sizing: border-box;
+  transition: outline 0.1s ease;
+}
+
+.groupInput:focus,
+.table select:focus {
+  outline: 2px solid #f1be64;
+  outline-offset: 1px;
+}
+
+.preview {
+  margin-top: 8px;
+  background-color: #242324;
+  border: 1px dashed #646464;
+  padding: 12px;
+  border-radius: 4px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.preview h4 {
+  margin: 0 0 4px 0;
+  font-size: 12px;
+  color: #999;
+  font-weight: 600;
+  text-transform: uppercase;
+}
+
+.previewRow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: #1a1a1a;
+  padding: 6px 10px;
+  border-radius: 4px;
+  border: 1px solid #3a3a3a;
+}
+
+.previewRow strong {
+  color: #f1be64;
+  font-size: 12.5px;
+  min-width: 80px;
+}
+
+.presetTag {
+  margin-left: auto;
+  font-family: ui-monospace, monospace;
+  font-size: 11px;
+  color: #999;
+  background: #373737;
+  padding: 2px 6px;
+  border-radius: 4px;
+  border: 1px solid #646464;
+}

+ 117 - 0
src/assets/components/InputConfigurator.tsx

@@ -0,0 +1,117 @@
+import { useState, useMemo } from "react";
+
+import styles from "./InputConfigurator.module.css";
+import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
+import Icon from "../icon";
+
+type InputConfig = {
+  itemId: string;
+  inserterId: string; // Grouping key (e.g., "1", "2")
+  source: "chest" | "belt";
+};
+
+export default function InputConfigurator({ recipe }: { recipe: Recipe }) {
+  // Default: every solid ingredient gets its own inserter and chest
+  const [configs, setConfigs] = useState<Record<string, InputConfig>>(() => {
+    const initial: Record<string, InputConfig> = {};
+    const solidIngredients =
+      recipe.ingredients?.filter((i) => i.type === "item") || [];
+    solidIngredients.forEach((ing, i) => {
+      initial[ing.itemId] = {
+        itemId: ing.itemId,
+        inserterId: `Inserter ${i + 1}`,
+        source: "chest",
+      };
+    });
+    return initial;
+  });
+
+  const updateConfig = (itemId: string, patch: Partial<InputConfig>) => {
+    setConfigs((prev) => ({
+      ...prev,
+      [itemId]: { ...prev[itemId], ...patch },
+    }));
+  };
+
+  // Group items by their assigned inserter for the preview
+  const groupedInserters = useMemo(() => {
+    const groups: Record<string, string[]> = {};
+    Object.values(configs).forEach((cfg) => {
+      if (!groups[cfg.inserterId]) groups[cfg.inserterId] = [];
+      groups[cfg.inserterId].push(cfg.itemId);
+    });
+    return groups;
+  }, [configs]);
+
+  const solidIngredients =
+    recipe.ingredients?.filter((i) => i.type === "item") || [];
+
+  return (
+    <div className={styles.wrap}>
+      <h3>Input Inserter Configuration</h3>
+      <table className={styles.table}>
+        <thead>
+          <tr>
+            <th>Ingredient</th>
+            <th>Inserter Group</th>
+            <th>Source Type</th>
+          </tr>
+        </thead>
+        <tbody>
+          {solidIngredients.map((ing) => (
+            <tr key={ing.itemId}>
+              <td>
+                <div className={styles.itemLabel}>
+                  <Icon iconName={`item/${ing.itemId}.png`} size={24} />
+                  <span>
+                    {ing.amount} {ing.itemId}
+                  </span>
+                </div>
+              </td>
+              <td>
+                {/* Typing "1" for two items puts them on the same mixed belt inserter */}
+                <input
+                  type="text"
+                  value={configs[ing.itemId].inserterId}
+                  onChange={(e) =>
+                    updateConfig(ing.itemId, { inserterId: e.target.value })
+                  }
+                  className={styles.groupInput}
+                />
+              </td>
+              <td>
+                <select
+                  value={configs[ing.itemId].source}
+                  onChange={(e) =>
+                    updateConfig(ing.itemId, {
+                      source: e.target.value as "chest" | "belt",
+                    })
+                  }
+                >
+                  <option value="chest">Chest</option>
+                  <option value="belt">Belt</option>
+                </select>
+              </td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+
+      {/* Visual sanity check for the user */}
+      <div className={styles.preview}>
+        <h4>Resulting Rows on Timeline:</h4>
+        {Object.entries(groupedInserters).map(([inserterId, items]) => (
+          <div key={inserterId} className={styles.previewRow}>
+            <strong>{inserterId}:</strong>
+            {items.map((id) => (
+              <Icon key={id} iconName={`item/${id}.png`} size={20} />
+            ))}
+            <span className={styles.presetTag}>
+              ({configs[items[0]].source} → machine)
+            </span>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 52 - 0
src/assets/components/ModuleSlots.module.css

@@ -0,0 +1,52 @@
+.slotsContainer {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.slot {
+  width: 44px;
+  height: 44px;
+  background-color: #1a1a1a;
+  border: 1px solid #454545;
+  box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.6);
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  user-select: none;
+}
+
+.slot:hover {
+  border-color: #f1be64;
+  background-color: #242424;
+}
+
+.emptySlot {
+  color: #555;
+  font-size: 20px;
+  font-weight: bold;
+}
+
+.occupiedSlot {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  justify-content: center;
+  align-items: center;
+  cursor: ns-resize; /* Indicates scrolling is possible */
+}
+.fillBtn {
+  background: #373737;
+  border: 1px solid #646464;
+  color: #ffe6c0;
+  border-radius: 4px;
+  padding: 6px 10px;
+  cursor: pointer;
+  font-size: 11px;
+}
+.fillBtn:hover {
+  border-color: #f1be64;
+}

+ 110 - 0
src/assets/components/ModuleSlots.tsx

@@ -0,0 +1,110 @@
+import React, { useState, useEffect, 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 "../SelectFactorioMenu";
+import { orderedQualities, useQualityScroller } from "../../hooks/useQualityScroller";
+import Icon from "../icon";
+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 = {
+  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";
+  const { scrollRef, activeQuality } = useQualityScroller(initialQualityName, (newQuality) => {
+    onChangeQuality(newQuality.level);
+  }, "factorio-module-quality"); // Persist module quality separately!
+
+  return (
+    <div ref={scrollRef} className={styles.occupiedSlot}>
+      <Icon iconName={module.icon ?? ""} size={34} qualityLevel={activeQuality.level} />
+    </div>
+  );
+}
+
+export default function ModuleSlots({ maxSlots, allowedEffects, onChange }: ModuleSlotsProps) {
+  const [slots, setSlots] = 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]);
+
+  const moduleCategories = useMemo(() => {
+    let validModules = data.modules as Module[];
+    if (allowedEffects && allowedEffects.length > 0) {
+      validModules = validModules.filter(m => {
+        if (!m.effect) return true;
+        return Object.keys(m.effect).every(e => {
+          const effectValue = m.effect![e as keyof typeof m.effect] ?? 0;
+          return effectValue <= 0 || allowedEffects.includes(e);
+        });
+      });
+    }
+    return [{ ...prod, subGroup: [{ ...moduleSubGroup, children: validModules as unknown as MenuItem[] }] }];
+  }, [allowedEffects]);
+
+  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];
+    newSlots[activeSlot] = { module: selectedModule, qualityLevel };
+    setSlots(newSlots);
+    setActiveSlot(null);
+    onChange(newSlots.filter(s => s !== null) as any);
+  };
+
+  const handleFillAll = () => {
+    const template = slots.find(s => s !== null);
+    if (!template) return;
+    const newSlots = slots.map(s => s || template);
+    setSlots(newSlots);
+    onChange(newSlots as any);
+  };
+
+  if (maxSlots === 0) return null;
+
+  return (
+    <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
+      <div className={styles.slotsContainer}>
+        {slots.map((slot, i) => (
+          <div 
+            key={i} 
+            className={styles.slot} 
+            onClick={(e) => { setAnchorEl(e.currentTarget); setActiveSlot(i); }}
+            onContextMenu={(e) => { e.preventDefault(); const n = [...slots]; n[i] = null; setSlots(n); onChange(n.filter(s=>s) as any); }}
+          >
+            {slot ? (
+              <OccupiedSlot module={slot.module} initialQualityLevel={slot.qualityLevel} onChangeQuality={(lvl: number) => { const n = [...slots]; n[i]!.qualityLevel = lvl; setSlots(n); onChange(n.filter(s=>s) as any); }} />
+            ) : <span className={styles.emptySlot}>+</span>}
+          </div>
+        ))}
+      </div>
+      
+      {slots.some(s => s !== null) && slots.some(s => s === null) && (
+        <button onClick={handleFillAll} className={styles.fillBtn} title="Fill empty slots with the first module">
+          Fill All
+        </button>
+      )}
+
+      <Popper open={activeSlot !== null} anchorEl={anchorEl} placement="bottom-start" style={{ zIndex: 1300 }}>
+         <SelectMenu title="Select Module" categories={moduleCategories} onSelectItem={handleSelectModule} onClose={() => setActiveSlot(null)} />
+      </Popper>
+    </div>
+  );
+}

+ 0 - 0
src/assets/components/QualityIcon.module.css


+ 53 - 0
src/assets/components/QualityIcon.tsx

@@ -0,0 +1,53 @@
+import  { useState, useEffect, useRef } from "react";
+
+import styles from "./QualityIcon.module.css";
+import data from "../../assets/data/2.0/data.json";
+import Icon from "../icon";
+
+const QUALITY_LEVELS = data.qualityLevels.map(q => q.name); // ["normal", "uncommon", "rare", "epic", "legendary"]
+
+type QualityIconProps = {
+  iconName: string;
+  size?: number;
+  initialQuality?: string;
+  onChangeQuality?: (newQuality: string) => void;
+};
+
+export default function QualityIcon({ iconName, size = 40, initialQuality = "normal", onChangeQuality }: QualityIconProps) {
+  const [quality, setQuality] = useState(initialQuality);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const handleWheel = (e: WheelEvent) => {
+      if (!e.shiftKey) return;
+      e.preventDefault(); // Prevent page scroll
+      
+      const currentIndex = QUALITY_LEVELS.indexOf(quality);
+      const direction = Math.sign(e.deltaY); // 1 for down, -1 for up
+      
+      // Shift+Scroll Up = Higher quality, Down = Lower quality
+      const nextIndex = Math.max(0, Math.min(QUALITY_LEVELS.length - 1, currentIndex - direction));
+      
+      if (nextIndex !== currentIndex) {
+        const newQuality = QUALITY_LEVELS[nextIndex];
+        setQuality(newQuality);
+        onChangeQuality?.(newQuality);
+      }
+    };
+
+    const el = containerRef.current;
+    if (el) {
+      // We must use a native event listener to reliably preventDefault on scroll
+      el.addEventListener("wheel", handleWheel, { passive: false });
+      return () => el.removeEventListener("wheel", handleWheel);
+    }
+  }, [quality, onChangeQuality]);
+
+  const qualityData = data.qualityLevels.find(q => q.name === quality);
+
+  return (
+    <div ref={containerRef} className={styles.wrap} title="Shift + Scroll to change quality">
+      <Icon iconName={iconName} size={size} qualityLevel={qualityData?.level} />
+    </div>
+  );
+}

+ 61 - 24
src/assets/components/SelectedBlockPanel.tsx

@@ -1,40 +1,58 @@
 import styles from "./SelectedBlockPanel.module.css";
-import { ACTION_PRESETS, getPreset, type ClockBlock } from "../types";
+import { ACTION_PRESETS, getPreset } from "../types";
 import Icon from "../icon";
 import ExpressionInput from "./ExpressionInpux";
+import { useClockStore } from "../../store/useClockStore";
+import { useMemo } from "react";
 
-type Props = {
-  selectedBlocks: ClockBlock[]; // 0, 1, or many
-  onChangeOne: (blockId: string, patch: Partial<ClockBlock>) => void;
-  onRemoveSelected: () => void;
-};
+export default function SelectedBlockPanel() {
+  const { selectedBlockIds, removeBlocks, blocks, updateBlock } =
+    useClockStore();
 
-export default function SelectedBlockPanel({ selectedBlocks, onChangeOne, onRemoveSelected }: Props) {
-  if (selectedBlocks.length === 0) {
-    return <div className={styles.empty}>Select a block on the timeline to edit it.</div>;
-  }
+  const blockId = useMemo(
+    () => selectedBlockIds.keys().next().value,
+    [selectedBlockIds],
+  );
+  const block = useMemo(() => {
+    if (blockId) return blocks[blockId];
+  }, [blockId]);
 
-  if (selectedBlocks.length > 1) {
+  if (selectedBlockIds.size > 1) {
     return (
       <div className={styles.wrap}>
-        <span className={styles.multiLabel}>{selectedBlocks.length} blocks selected</span>
-        <button className={styles.removeBtn} onClick={onRemoveSelected}>
+        <span className={styles.multiLabel}>
+          {selectedBlockIds.size} blocks selected
+        </span>
+        <button
+          className={styles.removeBtn}
+          onClick={() => removeBlocks(Array.from(selectedBlockIds))}
+        >
           Remove all
         </button>
       </div>
     );
   }
 
-  const block = selectedBlocks[0];
-  const preset = getPreset(block.presetId);
-  const change = (patch: Partial<ClockBlock>) => onChangeOne(block.id, patch);
+  if (!blockId || !block) {
+    return (
+      <div className={styles.empty}>
+        Select a block on the timeline to edit it.
+      </div>
+    );
+  }  
+  const preset = getPreset(block.presetId)
+
 
   return (
     <div className={styles.wrap}>
       <div className={styles.preview}>
-        {preset.fromItem && <Icon iconName={`item/${preset.fromItem}.png`} size={40} />}
+        {preset.fromItem && (
+          <Icon iconName={`item/${preset.fromItem}.png`} size={40} />
+        )}
         <span className={styles.arrow}>→</span>
-        {preset.toItem && <Icon iconName={`item/${preset.fromItem}.png`} size={40} />}
+        {preset.toItem && (
+          <Icon iconName={`item/${preset.fromItem}.png`} size={40} />
+        )}
       </div>
 
       <div className={styles.fields}>
@@ -44,7 +62,7 @@ export default function SelectedBlockPanel({ selectedBlocks, onChangeOne, onRemo
             value={block.presetId}
             onChange={(e) => {
               const p = getPreset(e.target.value);
-              change({ presetId: p.id, duration: p.ticks + 1 });
+              updateBlock(blockId, { presetId: p.id, duration: p.ticks + 1 });
             }}
           >
             {ACTION_PRESETS.map((p) => (
@@ -56,23 +74,42 @@ export default function SelectedBlockPanel({ selectedBlocks, onChangeOne, onRemo
         </div>
         <div className={styles.field}>
           <label>Start (tick)</label>
-          <ExpressionInput value={block.start} min={0} onCommit={(v) => change({ start: v })} />
+          <ExpressionInput
+            value={block.start}
+            min={0}
+            onCommit={(v) => updateBlock(blockId, { start: v })}
+          />
         </div>
         <div className={styles.field}>
           <label>Duration (ticks)</label>
-          <ExpressionInput value={block.duration} min={1} onCommit={(v) => change({ duration: v })} />
+          <ExpressionInput
+            value={block.duration}
+            min={1}
+            onCommit={(v) => updateBlock(blockId, { duration: v })}
+          />
         </div>
         <div className={styles.field}>
           <label>Count</label>
-          <ExpressionInput value={block.count} min={1} onCommit={(v) => change({ count: v })} />
+          <ExpressionInput
+            value={block.count}
+            min={1}
+            onCommit={(v) => updateBlock(blockId, { count: v })}
+          />
         </div>
         <div className={styles.field}>
           <label>Repeat ×</label>
-          <ExpressionInput value={block.repeat ?? 1} min={1} onCommit={(v) => change({ repeat: v })} />
+          <ExpressionInput
+            value={block.repeat ?? 1}
+            min={1}
+            onCommit={(v) => updateBlock(blockId, { repeat: v })}
+          />
         </div>
       </div>
 
-      <button className={styles.removeBtn} onClick={onRemoveSelected}>
+      <button
+        className={styles.removeBtn}
+        onClick={() => removeBlocks([blockId])}
+      >
         Remove block
       </button>
     </div>

+ 247 - 0
src/assets/components/TimelineRow.tsx

@@ -0,0 +1,247 @@
+import { useRef } from "react";
+import { useClockStore } from "../../store/useClockStore";
+import { useTimelineDrag } from "../../hooks/useTimelineDrag";
+import SelectSignal, { type Signal } from "../../assets/SelectSignal";
+import ExpressionInput from "../../assets/components/ExpressionInpux";
+import Icon from "../../assets/icon";
+import styles from "../ClockTimeline.module.css";
+import {
+  ACTION_PRESETS,
+  expandBlockInstances,
+  getPreset,
+  type ClockBlock,
+} from "../../assets/types";
+
+type PackedInstance = {
+  id: string;
+  blockId: string;
+  start: number;
+  duration: number;
+  lane: number;
+};
+
+let uid = 0;
+const nextId = (prefix: string) => `${prefix}-${++uid}`;
+
+function packLanes(
+  instances: { id: string; blockId: string; start: number; duration: number }[],
+) {
+  const sorted = [...instances].sort((a, b) => a.start - b.start);
+  const laneEnds: number[] = [];
+  const packed: PackedInstance[] = [];
+  for (const inst of sorted) {
+    let lane = laneEnds.findIndex((end) => end <= inst.start);
+    if (lane === -1) {
+      lane = laneEnds.length;
+      laneEnds.push(inst.start + inst.duration);
+    } else {
+      laneEnds[lane] = inst.start + inst.duration;
+    }
+    packed.push({ ...inst, lane });
+  }
+  return { packed, laneCount: Math.max(1, laneEnds.length) };
+}
+const LANE_HEIGHT = 30;
+const GAP = 4;
+export default function TimelineRow({ rowId }: { rowId: string }) {
+  const {
+    updateRow,
+    removeRow,
+    addBlocks,
+    selectBlocks,
+    blocks,
+    rows,
+    duration,
+    selectedBlockIds,
+    alignmentTick,
+  } = useClockStore();
+  const { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick } =
+    useTimelineDrag();
+
+  const laneRef = useRef<HTMLDivElement>(null);
+  const row = rows[rowId];
+  if (!row) return null;
+
+  const allInstances = Object.values(blocks)
+    .filter((b) => b.rowId === rowId)
+    .flatMap(expandBlockInstances);
+  const { packed, laneCount } = packLanes(allInstances);
+  const laneAreaHeight = laneCount * LANE_HEIGHT + (laneCount - 1) * GAP + 8;
+
+  const handleDrop = (e: React.DragEvent) => {
+    e.preventDefault();
+    const presetId = e.dataTransfer.getData("text/plain");
+    const preset = ACTION_PRESETS.find((p) => p.id === presetId);
+    if (!preset || !laneRef.current) return;
+
+    const rect = laneRef.current.getBoundingClientRect();
+    const tick = Math.max(
+      0,
+      Math.min(duration - 1, pxToTick(rect.width, e.clientX - rect.left)),
+    );
+
+    const newBlock = {
+      id: `block-${Date.now()}`,
+      rowId,
+      presetId: preset.id,
+      start: tick,
+      duration: preset.ticks + 1,
+      count: 1,
+    };
+
+    addBlocks([newBlock]);
+    selectBlocks(new Set([newBlock.id]));
+  };
+  const addRowSignal = (signal: Signal | null) => {
+    if (!signal) return;
+    updateRow(rowId, { signals: [...row.signals, signal] });
+  };
+  const updateRowSignal = (index: number, signal: Signal | null) => {
+    if (!signal) return;
+    const next = [...row.signals];
+    next[index] = signal;
+    updateRow(rowId, { signals: next });
+  };
+  const removeRowSignal = (index: number) => {
+    if (!row || row.signals.length <= 1) return;
+    updateRow(rowId, { signals: row.signals.filter((_, i) => i !== index) });
+  };
+
+  const autoFillRow = (presetId: string) => {
+    if (!row) return;
+    const preset = getPreset(presetId);
+    const count = Math.max(1, row.inserterCount);
+    const spacing = duration / count;
+    const generated: ClockBlock[] = Array.from({ length: count }, (_, i) => ({
+      id: nextId("block"),
+      rowId,
+      presetId,
+      start: Math.round(i * spacing),
+      duration: preset.ticks + 1,
+      count: 1,
+    }));
+    addBlocks(generated);
+  };
+
+  return (
+    <div className={styles.rowBlockWrap}>
+      <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
+            key={row.signals.length}
+            onSelectSignal={(_, sig) => addRowSignal(sig)}
+          />
+        </div>
+        <div className={styles.rowStats}>
+          <label>Stack</label>
+          <ExpressionInput
+            value={row.stackSize}
+            min={1}
+            onCommit={(v) => updateRow(rowId, { stackSize: v })}
+          />
+          <label>Inserters</label>
+          <ExpressionInput
+            value={row.inserterCount}
+            min={1}
+            onCommit={(v) => updateRow(rowId, { inserterCount: v })}
+          />
+        </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>
+        <button
+          className={styles.autoFillBtn}
+          onClick={() => {
+            const select = document.getElementById(
+              `autofill-preset-${row.id}`,
+            ) as HTMLSelectElement | null;
+            autoFillRow(select?.value ?? "chest_to_belt");
+          }}
+        >
+          Auto-fill ({row.inserterCount})
+        </button>
+        <button className={styles.removeBtn} onClick={() => removeRow(row.id)}>
+          ✕
+        </button>
+      </div>
+
+      <div
+        className={styles.lane}
+        style={{ height: laneAreaHeight }}
+        ref={laneRef}
+        onDragOver={(e) => e.preventDefault()}
+        onDrop={handleDrop}
+        onPointerMove={onPointerMove}
+        onPointerUp={onPointerUp}
+      >
+        {packed.map((inst) => {
+          const block = blocks[inst.blockId];
+          const preset = getPreset(block.presetId);
+          const selected = selectedBlockIds.has(block.id);
+          const aligned =
+            alignmentTick !== null && inst.start === alignmentTick;
+
+          return (
+            <div
+              key={inst.id}
+              className={`${styles.block} ${selected ? styles.blockSelected : ""} ${aligned ? styles.blockAligned : ""}`}
+              style={{
+                left: `${(inst.start / duration) * 100}%`,
+                width: `${(inst.duration / duration) * 100}%`,
+                top: inst.lane * 34 + 4,
+                height: 30,
+              }}
+              onPointerDown={(e) =>
+                onPointerDownBlock(e, block, "move", laneRef.current)
+              }
+            >
+              <div className={styles.blockIcons}>
+                {preset.fromItem && (
+                  <Icon iconName={`item/${preset.fromItem}.png`} size={18} />
+                )}
+                {preset.toItem && (
+                  <Icon iconName={`item/${preset.toItem}.png`} size={18} />
+                )}
+              </div>
+              <div
+                className={styles.resizeHandle}
+                onPointerDown={(e) =>
+                  onPointerDownBlock(e, block, "resize", laneRef.current)
+                }
+              />
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 96 - 0
src/assets/components/WizardTest.tsx

@@ -0,0 +1,96 @@
+import { useState, useMemo } from "react";
+import type { Recipe } from "../../../scripts/factorio-dump/helpers/recipes.helper";
+import { calculateOptimalBatch, generateAdvancedClock } from "../../engine/factorioEngine";
+
+const ADVANCED_CIRCUIT_RECIPE: Recipe = {
+  name: "advanced-circuit",
+  category: "electronics",
+  subgroup:"",
+  energy_required: 6, // Red chips take 6 seconds base
+  ingredients: [
+    { itemId: "electronic-circuit", amount: 2, type: "item" },
+    { itemId: "plastic-bar", amount: 2, type: "item" },
+    { itemId: "copper-cable", amount: 4, type: "item" },
+  ],
+  results: [
+    { type: "item", name: "advanced-circuit", amount: 1 }
+  ],
+};
+
+export default function WizardTest() {
+  const [productivityBonus, setProductivityBonus] = useState<number>(1.75); // +175%
+  const [actualSpeed, setActualSpeed] = useState<number>(10.0); // Fast endgame machine
+  const [stackSize, setStackSize] = useState<number>(16);
+
+  const result = useMemo(() => {
+    try {
+      const batch = calculateOptimalBatch(
+        ADVANCED_CIRCUIT_RECIPE,
+        productivityBonus,
+        actualSpeed,
+        stackSize
+      );
+      const clockData = generateAdvancedClock(batch, stackSize);
+      
+      return { batch, clockData };
+    } catch (err) {
+      return { error: (err as Error).message };
+    }
+  }, [productivityBonus, actualSpeed, stackSize]);
+
+  return (
+    <div style={{ padding: 20, fontFamily: "sans-serif", background: "#242324", color: "#ffe6c0" }}>
+      <h2>Factorio Clock Engine Sandbox</h2>
+      
+      <div style={{ display: "flex", gap: 20, marginBottom: 20 }}>
+        <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
+          <label>Productivity Bonus (e.g. 1.75 for +175%)</label>
+          <input 
+            type="number" 
+            step="0.01" 
+            value={productivityBonus} 
+            onChange={(e) => setProductivityBonus(Number(e.target.value))} 
+            style={{ padding: 4 }}
+          />
+        </div>
+
+        <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
+          <label>Actual Crafting Speed</label>
+          <input 
+            type="number" 
+            step="0.1" 
+            value={actualSpeed} 
+            onChange={(e) => setActualSpeed(Number(e.target.value))} 
+            style={{ padding: 4 }}
+          />
+        </div>
+
+        <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
+          <label>Stack Size</label>
+          <input 
+            type="number" 
+            value={stackSize} 
+            onChange={(e) => setStackSize(Number(e.target.value))} 
+            style={{ padding: 4 }}
+          />
+        </div>
+      </div>
+
+      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
+        <div>
+          <h3>1. Batch Math Result</h3>
+          <pre style={{ background: "#1a1a1a", padding: 10, borderRadius: 4, overflow: "auto" }}>
+            {JSON.stringify(result.batch, null, 2)}
+          </pre>
+        </div>
+        
+        <div>
+          <h3>2. Generated Timeline Blocks</h3>
+          <pre style={{ background: "#1a1a1a", padding: 10, borderRadius: 4, overflow: "auto" }}>
+            {JSON.stringify(result.clockData, null, 2)}
+          </pre>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 253 - 0
src/engine/factorioEngine.ts

@@ -0,0 +1,253 @@
+
+import type { Recipe } from "../../scripts/factorio-dump/helpers/recipes.helper";
+import type { ItemProductPrototype } from "../../scripts/factorio-dump/lua-api/models";
+import type { Beacon, Machine, Module } from "../../scripts/factorio-dump/process-data.models";
+
+import { defaultClockSignal } from "../store/useClockStore";
+// --- Math Helpers ---
+const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
+const lcm = (a: number, b: number): number => (a * b) / gcd(a, b);
+
+function floatToFraction(val: number) {
+  const precision = 10000;
+  const num = Math.round(val * precision);
+  const den = precision;
+  const divisor = gcd(num, den);
+  return { num: num / divisor, den: den / divisor };
+}
+
+// --- Interfaces ---
+export interface BatchPlan {
+  craftsPerCycle: number;
+  durationTicks: number;
+  inputs: Record<string, number>;
+  outputs: Record<string, number>;
+}
+
+export interface ClockConfig {
+  stackSize?: number;
+  inputPreset?: string;
+  outputPreset?: string;
+  swingTicks?: number; // e.g., 8 for chest_to_chest, 12 for chest_to_belt
+}
+export interface MachineSetup {
+  machine: Machine;
+  machineQualityLevel?: number; // 0 = Normal, 1 = Uncommon, 2 = Rare, 3 = Epic, 4 = Legendary
+  machineModules: Array<{ module: Module; qualityLevel: number }>;
+  beacons: Array<{
+    beacon: Beacon;
+    beaconQualityLevel?: number;
+    count: number;
+    modules: Array<{ module: Module; qualityLevel: number }>; 
+  }>;
+}
+export interface CalculatedTimings {
+  actualCraftingSpeed: number;
+  productivityBonus: number;
+  singleCraftTicks: number;
+  overloadMultiplier: number;
+}
+
+// Factorio 2.0 Quality Multipliers (Normal = 1x, Uncommon = 1.3x, Rare = 1.6x, Epic = 1.9x, Legendary = 2.5x)
+function getQualityMultiplier(level: number): number {
+  if (level === 4) return 2.5; // Legendary gets a bigger bump
+  return 1 + (level * 0.3);
+}
+
+export function computeMachineStats(setup: MachineSetup, recipe: Recipe): CalculatedTimings {
+  let speedBonus = 0;
+  let productivityBonus = 0;
+
+  // 1. Machine Quality (e.g. Legendary machine has +150% base speed)
+  const machineQualityMultiplier = getQualityMultiplier(setup.machineQualityLevel ?? 0);
+  const baseSpeed = setup.machine.crafting_speed * machineQualityMultiplier;
+
+  // 2. Machine Modules (Unpacking the wrapper and applying quality)
+  setup.machineModules.forEach(({ module, qualityLevel }) => {
+    const modQualityMultiplier = getQualityMultiplier(qualityLevel);
+    
+    if (module.effect?.speed) {
+      speedBonus += module.effect.speed * modQualityMultiplier;
+    }
+    if (module.effect?.productivity) {
+      productivityBonus += module.effect.productivity * modQualityMultiplier;
+    }
+  });
+
+  // 3. Beacons (Unpacking wrapper, applying beacon quality AND module quality)
+  setup.beacons.forEach(b => {
+    const beaconQualityLevel = b.beaconQualityLevel ?? 0;
+    const effectivityBonus = (b.beacon.distribution_effectivity_bonus_per_quality_level ?? 0) * beaconQualityLevel;
+    const distributionEffectivity = b.beacon.distribution_effectivity + effectivityBonus;
+
+    let beaconSpeed = 0;
+    let beaconProd = 0;
+
+    b.modules.forEach(({ module, qualityLevel }) => {
+      const modQualityMultiplier = getQualityMultiplier(qualityLevel);
+      if (module.effect?.speed) beaconSpeed += module.effect.speed * modQualityMultiplier;
+      if (module.effect?.productivity) beaconProd += module.effect.productivity * modQualityMultiplier;
+    });
+
+    speedBonus += beaconSpeed * distributionEffectivity * b.count;
+    productivityBonus += beaconProd * distributionEffectivity * b.count;
+  });
+
+  // Minimum speed multiplier in Factorio is 0.2 (-80%)
+  const effectiveSpeedMultiplier = Math.max(0.2, 1 + speedBonus);
+  const actualCraftingSpeed = baseSpeed * effectiveSpeedMultiplier;
+
+  // 4. Timing & Insertion Limits
+  const energyRequired = recipe.energy_required ?? 0.5;
+  const craftTimeSeconds = energyRequired / actualCraftingSpeed;
+  const singleCraftTicks = craftTimeSeconds * 60;
+
+  let overloadMultiplier = recipe.overload_multiplier;
+  if (!overloadMultiplier || overloadMultiplier === 0) {
+    overloadMultiplier = Math.max(2, Math.min(100, Math.ceil(1.166 / craftTimeSeconds)));
+  }
+
+  return {
+    actualCraftingSpeed,
+    productivityBonus,
+    singleCraftTicks,
+    overloadMultiplier
+  };
+}
+
+// ---  Batch Calculator ---
+export function calculateOptimalBatch(
+  recipe: Recipe,
+  productivityBonus: number, 
+  actualCraftingSpeed: number, 
+  stackSize: number = 16
+): BatchPlan {
+  const prodMultiplier = floatToFraction(1 + productivityBonus);
+  const energyRequired = recipe.energy_required ?? 0.5;
+  const singleCraftTicks = (energyRequired / actualCraftingSpeed) * 60;
+
+  let optimalN = 1;
+
+  // Process Solid Ingredients
+  const solidIngredients = (recipe.ingredients || []).filter((ing) => ing.type === "item");
+  for (const ing of solidIngredients) {
+    const requiredN = stackSize / gcd(ing.amount, stackSize);
+    optimalN = lcm(optimalN, requiredN);
+  }
+
+  // Process Solid Products
+  const solidResults = (recipe.results || []).filter(
+    (res): res is ItemProductPrototype => res.type === "item"
+  );
+  for (const res of solidResults) {
+    const amount = res.amount ?? res.amount_min ?? 1;
+    const numerator = amount * prodMultiplier.num;
+    const denominator = stackSize * prodMultiplier.den;
+    const requiredN = denominator / gcd(numerator, denominator);
+    optimalN = lcm(optimalN, requiredN);
+  }
+
+  const inputs: Record<string, number> = {};
+  solidIngredients.forEach((ing) => { inputs[ing.itemId] = ing.amount * optimalN; });
+
+  const outputs: Record<string, number> = {};
+  solidResults.forEach((res) => {
+    const amount = res.amount ?? res.amount_min ?? 1;
+    outputs[res.name] = (amount * prodMultiplier.num * optimalN) / prodMultiplier.den;
+  });
+
+  return {
+    craftsPerCycle: optimalN,
+    durationTicks: Math.ceil(singleCraftTicks * optimalN),
+    inputs,
+    outputs,
+  };
+}
+
+// ---  Timeline Generator (Drip-Feed Strategy) ---
+export function generateAdvancedClock(batch: BatchPlan, config: ClockConfig = {}) {
+  const {
+    stackSize = 16,
+    inputPreset = "chest_to_chest",
+    outputPreset = "chest_to_chest",
+    swingTicks = 8, // Chest to chest usually takes 8 ticks
+  } = config;
+
+  const rows: any[] = [];
+  const blocks: any[] = [];
+
+  // --- Process Inputs ---
+  Object.entries(batch.inputs).forEach(([itemId, totalAmount]) => {
+    const rowId = `row-in-${itemId}`;
+    const swings = totalAmount / stackSize;
+    
+    // How often does the machine consume a full stack of this item?
+    const interval = batch.durationTicks / swings; 
+
+    rows.push({
+      id: rowId,
+      name: `${itemId} In`,
+      signals: [{ type: "item", name: itemId, subgroup: "intermediate-product" }],
+      stackSize,
+      inserterCount: 1,
+    });
+
+    // Spread the blocks evenly across the timeline
+    for (let i = 0; i < swings; i++) {
+      blocks.push({
+        id: `block-in-${itemId}-${i}`,
+        rowId,
+        presetId: inputPreset,
+        start: Math.round(i * interval),
+        duration: swingTicks + 1, // Window open slightly longer than swing
+        count: stackSize,
+        repeat: 1, // Just one swing per interval
+      });
+    }
+  });
+
+  // --- Process Outputs ---
+  Object.entries(batch.outputs).forEach(([itemId, totalAmount]) => {
+    const rowId = `row-out-${itemId}`;
+    const swings = totalAmount / stackSize;
+    
+    // How often does the machine produce a full stack?
+    const interval = batch.durationTicks / swings;
+
+    rows.push({
+      id: rowId,
+      name: `${itemId} Out`,
+      signals: [{ type: "item", name: itemId, subgroup: "intermediate-product" }],
+      stackSize,
+      inserterCount: 1,
+    });
+
+    // Output is extracted exactly when a full stack is finished crafting
+    for (let i = 1; i <= swings; i++) {
+      const finishTick = i * interval;
+      // Start the swing so the inserter drops the item exactly as it's ready
+      let startTick = Math.round(finishTick - swingTicks);
+      
+      // Handle edge case where first output finishes extremely fast
+      if (startTick < 0) startTick += batch.durationTicks;
+
+      blocks.push({
+        id: `block-out-${itemId}-${i}`,
+        rowId,
+        presetId: outputPreset,
+        start: startTick,
+        duration: swingTicks + 1,
+        count: stackSize,
+        repeat: 1,
+      });
+    }
+  });
+
+  // Return exactly the shape expected by the ClockBuilder JSON parser
+  return {
+    duration: batch.durationTicks,
+    clockSignal: defaultClockSignal,
+    rows,
+    blocks
+  };
+}

+ 103 - 0
src/hooks/useQualityScroller.ts

@@ -0,0 +1,103 @@
+import { useEffect, useRef, useState, useMemo } from "react";
+import data from "../assets/data/2.0/data.json";
+
+export type Quality = {
+  type: "quality";
+  name: string;
+  level: number;
+  color: {
+    r: number;
+    g: number;
+    b: number;
+  };
+  order: string;
+  next_probability: number;
+  subgroup: "qualities";
+  hidden: boolean;
+  icon: string;
+  draw_sprite_by_default: boolean;
+  next: string;
+};
+
+//  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];
+
+export const orderedQualities: Quality[] = [];
+let current: Quality | undefined = baseQuality;
+
+while (current) {
+  orderedQualities.push(current);
+  current = current.next ? qualityMap.get(current.next) : undefined;
+}
+
+export function useQualityScroller(
+  initialQualityName: string = baseQuality.name,
+  onChange?: (quality: Quality) => void,
+  persistKey?: string 
+) {
+  const [qualityName, setQualityName] = useState(() => {
+    if (persistKey) {
+      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";
+      }
+    }
+    return initialQualityName;
+  });
+
+  useEffect(() => {
+    if (persistKey) {
+      try {
+        localStorage.setItem(persistKey, qualityName);
+      } catch (e) {}
+    }
+  }, [qualityName, persistKey]);
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const el = scrollRef.current;
+    if (!el) return;
+
+    const handleWheel = (e: WheelEvent) => {
+      if (!e.shiftKey) return;
+      e.preventDefault(); 
+
+      setQualityName((prevName) => {
+        const currentIndex = orderedQualities.findIndex((q) => q.name === prevName);
+        if (currentIndex === -1) return prevName;
+
+        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;
+        }
+        return prevName;
+      });
+    };
+
+    el.addEventListener("wheel", handleWheel, { passive: false });
+    return () => el.removeEventListener("wheel", handleWheel);
+  }, [onChange]);
+
+  const activeQuality = useMemo(
+    () => orderedQualities.find((q) => q.name === qualityName) || baseQuality,
+    [qualityName]
+  );
+
+  return { scrollRef, activeQuality, setQualityName };
+}

+ 103 - 0
src/hooks/useTimelineDrag.ts

@@ -0,0 +1,103 @@
+import { useRef, useCallback } from "react";
+import { useClockStore } from "../store/useClockStore";
+import type { ClockBlock } from "../assets/types";
+
+export function useTimelineDrag() {
+  const { duration, blocks, moveBlocks, updateBlock, selectBlocks, selectedBlockIds, setAlignmentTick } = useClockStore();
+
+  const dragState = useRef<{
+    blockId: string;
+    mode: "move" | "resize";
+    startX: number;
+    laneWidth: number;
+    shiftKey: boolean;
+    wasSelected: boolean;
+    groupOrigStarts: Map<string, number>;
+    origDuration: number;
+  } | null>(null);
+
+  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) => {
+    e.stopPropagation();
+    if (!laneElement) return;
+    (e.target as HTMLElement).setPointerCapture(e.pointerId);
+
+    const wasSelected = selectedBlockIds.has(block.id);
+    const groupIds = mode === "move" && wasSelected ? selectedBlockIds : new Set([block.id]);
+    
+    // Convert object to map for quick lookup
+    const blockValues = Object.values(blocks);
+    
+    dragState.current = {
+      blockId: block.id,
+      mode,
+      startX: e.clientX,
+      laneWidth: laneElement.clientWidth,
+      shiftKey: e.shiftKey,
+      wasSelected,
+      groupOrigStarts: new Map(blockValues.filter((b) => groupIds.has(b.id)).map((b) => [b.id, b.start])),
+      origDuration: block.duration,
+    };
+  };
+
+  const onPointerMove = (e: React.PointerEvent) => {
+    const drag = dragState.current;
+    if (!drag) return;
+    
+    const deltaTicks = pxToTick(drag.laneWidth, e.clientX - drag.startX);
+
+    if (drag.mode === "resize") {
+      updateBlock(drag.blockId, { duration: Math.max(1, drag.origDuration + deltaTicks) });
+      setAlignmentTick(null);
+      return;
+    }
+
+    // Group move clamping
+    let clampDelta = deltaTicks;
+    drag.groupOrigStarts.forEach((origStart) => {
+      const proposed = origStart + clampDelta;
+      if (proposed < 0) clampDelta = Math.max(clampDelta, -origStart);
+      if (proposed > duration - 1) clampDelta = Math.min(clampDelta, duration - 1 - origStart);
+    });
+
+    // Batch update via store
+    const updates: Record<string, { start: number }> = {};
+    drag.groupOrigStarts.forEach((origStart, blockId) => {
+      updates[blockId] = { start: origStart + clampDelta };
+    });
+    moveBlocks(updates);
+
+    // Alignment logic
+    const draggedNewStart = updates[drag.blockId].start;
+    const match = Object.values(blocks).some((b) => !drag.groupOrigStarts.has(b.id) && b.start === draggedNewStart);
+    setAlignmentTick(match ? draggedNewStart : null);
+  };
+
+  const onPointerUp = (e: React.PointerEvent) => {
+    const drag = dragState.current;
+    if (!drag) return;
+
+    setAlignmentTick(null);
+    dragState.current = null;
+
+    const moved = Math.abs(e.clientX - drag.startX) >= 3;
+    if (drag.mode === "move" && !moved) {
+      if (drag.shiftKey) {
+        selectBlocks((prev) => {
+          const next = new Set(prev);
+          next.has(drag.blockId) ? next.delete(drag.blockId) : next.add(drag.blockId);
+          return next;
+        });
+      } else {
+        selectBlocks(new Set([drag.blockId]));
+      }
+    } else if (drag.mode === "move" && moved && !drag.wasSelected) {
+      selectBlocks(new Set([drag.blockId]));
+    }
+  };
+
+  return { onPointerDownBlock, onPointerMove, onPointerUp, pxToTick };
+}

+ 4 - 0
src/main.tsx

@@ -6,6 +6,8 @@ import "./index.css";
 import ComponentTests from "./ComponentTests.tsx";
 import Layout from "./Layout.tsx";
 import ClockBuilder from "./ClockBuilder.tsx";
+import WizardTest from "./assets/components/WizardTest.tsx";
+import ClockWizard from "./assets/components/ClockWizard.tsx";
 
 const root = document.getElementById("root");
 
@@ -17,6 +19,8 @@ ReactDOM.createRoot(root as HTMLElement).render(
         <Route element={<Layout />}>
           <Route index element={<ClockBuilder />} />
           <Route path="tests" element={<ComponentTests />} />
+          <Route path="Wizar" element={<WizardTest />} />
+          <Route path="InputConfigurator" element={<ClockWizard />} />
         </Route>
       </Routes>
     </BrowserRouter>

+ 149 - 0
src/store/useClockStore.ts

@@ -0,0 +1,149 @@
+import { create } from "zustand";
+import type { ClockBlock, ClockRow } from "../assets/types";
+import type { Signal } from "../assets/SelectSignal";
+
+export const defaultClockSignal: Signal = {
+  type: "virtual-signal",
+  name: "signal-clock",
+  subgroup: "pictographs",
+  icon: "virtual-signal/signal-clock.png",
+  order: "p[clock]",
+};
+interface ClockState {
+  // Global Settings
+  duration: number;
+  clockSignal: Signal;
+  setDuration: (duration: number) => void;
+  setClockSignal: (signal: Signal | null) => void;
+
+  // Normalized Data Models
+  rows: Record<string, ClockRow>;
+  rowOrder: string[]; // Maintains vertical rendering order
+  blocks: Record<string, ClockBlock>;
+
+  // UI State
+  selectedBlockIds: Set<string>;
+  alignmentTick: number | null;
+  setAlignmentTick: (tick: number | null) => void;
+
+  // --- Actions ---
+  loadState: (state: Partial<ClockState>) => void;
+  // Row Management
+  addRow: (row: ClockRow) => void;
+  updateRow: (id: string, patch: Partial<ClockRow>) => void;
+  removeRow: (id: string) => void;
+
+  // Block Management
+  addBlocks: (newBlocks: ClockBlock[]) => void;
+  updateBlock: (id: string, patch: Partial<ClockBlock>) => void;
+
+  // High-performance batch update for dragging multiple blocks at once
+  moveBlocks: (updates: Record<string, { start: number }>) => void;
+  removeBlocks: (ids: string[]) => void;
+
+  // Selection
+  selectBlocks: (
+    ids: Set<string> | ((prev: Set<string>) => Set<string>),
+  ) => void;
+  clearSelection: () => void;
+}
+
+export const useClockStore = create<ClockState>((set) => ({
+  duration: 256,
+  clockSignal: defaultClockSignal, // Set your default signal here
+
+  rows: {},
+  rowOrder: [],
+  blocks: {},
+
+  selectedBlockIds: new Set(),
+  alignmentTick: null,
+
+  loadState: (state: Partial<ClockState>) => set(() => ({ ...state })),
+
+  setDuration: (duration) => set({ duration }),
+  setClockSignal: (clockSignal) => {
+    if (clockSignal) set({ clockSignal });
+  },
+  setAlignmentTick: (tick) => set({ alignmentTick: tick }),
+
+  addRow: (row) =>
+    set((state) => ({
+      rows: { ...state.rows, [row.id]: row },
+      rowOrder: [...state.rowOrder, row.id],
+    })),
+
+  updateRow: (id, patch) =>
+    set((state) => ({
+      rows: {
+        ...state.rows,
+        [id]: { ...state.rows[id], ...patch },
+      },
+    })),
+
+  removeRow: (id) =>
+    set((state) => {
+      const newRows = { ...state.rows };
+      delete newRows[id];
+
+      // Also clean up orphan blocks
+      const newBlocks = Object.fromEntries(
+        Object.entries(state.blocks).filter(([_, block]) => block.rowId !== id),
+      );
+
+      return {
+        rows: newRows,
+        rowOrder: state.rowOrder.filter((rowId) => rowId !== id),
+        blocks: newBlocks,
+      };
+    }),
+
+  addBlocks: (newBlocks) =>
+    set((state) => {
+      const blockMap = { ...state.blocks };
+      newBlocks.forEach((b) => {
+        blockMap[b.id] = b;
+      });
+      return { blocks: blockMap };
+    }),
+
+  updateBlock: (id, patch) =>
+    set((state) => ({
+      blocks: {
+        ...state.blocks,
+        [id]: { ...state.blocks[id], ...patch },
+      },
+    })),
+
+  moveBlocks: (updates) =>
+    set((state) => {
+      const nextBlocks = { ...state.blocks };
+      for (const [id, patch] of Object.entries(updates)) {
+        if (nextBlocks[id]) {
+          nextBlocks[id] = { ...nextBlocks[id], ...patch };
+        }
+      }
+      return { blocks: nextBlocks };
+    }),
+
+  removeBlocks: (ids) =>
+    set((state) => {
+      const newBlocks = { ...state.blocks };
+      ids.forEach((id) => delete newBlocks[id]);
+
+      const newSelection = new Set(state.selectedBlockIds);
+      ids.forEach((id) => newSelection.delete(id));
+
+      return { blocks: newBlocks, selectedBlockIds: newSelection };
+    }),
+
+  selectBlocks: (idsOrUpdater) =>
+    set((state) => ({
+      selectedBlockIds:
+        typeof idsOrUpdater === "function"
+          ? idsOrUpdater(state.selectedBlockIds)
+          : idsOrUpdater,
+    })),
+
+  clearSelection: () => set({ selectedBlockIds: new Set() }),
+}));