diff --git a/src/App.tsx b/src/App.tsx index 4ca19c2..65f6f63 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,7 @@ const Bezier = lazy(() => import("./pages/Bezier")); const Percolation = lazy(() => import("./pages/Percolation")); const Epidemic = lazy(() => import("./pages/Epidemic")); const Descent = lazy(() => import("./pages/Descent")); +const Tsp = lazy(() => import("./pages/Tsp")); const NotFound = lazy(() => import("./pages/NotFound")); const App = () => ( @@ -564,6 +565,19 @@ const App = () => ( } /> + + + + + } + /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} ({ + x: margin + Math.random() * (w - margin * 2), + y: margin + Math.random() * (h - margin * 2), + })); +} + +function dist(a: City, b: City): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +export function tourLength(cities: City[], tour: number[]): number { + let total = 0; + for (let i = 0; i < tour.length; i++) { + total += dist(cities[tour[i]], cities[tour[(i + 1) % tour.length]]); + } + return total; +} + +export type TspEvent = + | { type: "extend"; tour: number[] } // nearest-neighbor grew the tour + | { type: "swap"; tour: number[]; improvement: number } // 2-opt improved + | { type: "phase"; phase: "construct" | "improve" | "done" }; + +/** Nearest-neighbor construction, one city per yield. */ +export function* nearestNeighbor(cities: City[]): Generator { + yield { type: "phase", phase: "construct" }; + const n = cities.length; + const visited = new Uint8Array(n); + const tour = [0]; + visited[0] = 1; + while (tour.length < n) { + const last = cities[tour[tour.length - 1]]; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < n; i++) { + if (visited[i]) continue; + const d = dist(last, cities[i]); + if (d < bestD) { bestD = d; best = i; } + } + visited[best] = 1; + tour.push(best); + yield { type: "extend", tour: [...tour] }; + } + return tour; +} + +/** + * 2-opt improvement: repeatedly reverse tour segments that shorten the + * route, yielding after each improving swap, until a full pass finds none. + */ +export function* twoOpt(cities: City[], initial: number[]): Generator { + yield { type: "phase", phase: "improve" }; + const tour = [...initial]; + const n = tour.length; + let improvedInPass = true; + + while (improvedInPass) { + improvedInPass = false; + for (let i = 0; i < n - 1; i++) { + for (let j = i + 2; j < n; j++) { + if (i === 0 && j === n - 1) continue; // same edge + const a = cities[tour[i]]; + const b = cities[tour[i + 1]]; + const c = cities[tour[j]]; + const d = cities[tour[(j + 1) % n]]; + const before = dist(a, b) + dist(c, d); + const after = dist(a, c) + dist(b, d); + if (after < before - 1e-9) { + // Reverse the segment between i+1 and j + let lo = i + 1, hi = j; + while (lo < hi) { + [tour[lo], tour[hi]] = [tour[hi], tour[lo]]; + lo++; hi--; + } + improvedInPass = true; + yield { type: "swap", tour: [...tour], improvement: before - after }; + } + } + } + } + yield { type: "phase", phase: "done" }; + return tour; +} + +/** Full pipeline: construct then improve. */ +export function* solveTsp(cities: City[]): Generator { + const construction = nearestNeighbor(cities); + let tour: number[] = []; + for (const ev of construction) { + if (ev.type === "extend") tour = ev.tour; + yield ev; + } + yield* twoOpt(cities, tour); +} diff --git a/src/pages/Tsp.tsx b/src/pages/Tsp.tsx new file mode 100644 index 0000000..42bbbc4 --- /dev/null +++ b/src/pages/Tsp.tsx @@ -0,0 +1,259 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { + City, + TspEvent, + randomCities, + tourLength, + solveTsp, +} from "../features/tsp/tour"; + +const CITY_COUNTS = [20, 40, 80]; + +type Phase = "idle" | "construct" | "improve" | "done"; + +export default function Tsp() { + const canvasRef = useRef(null); + const citiesRef = useRef([]); + const tourRef = useRef([]); + const solverRef = useRef | null>(null); + const phaseRef = useRef("idle"); + const speedRef = useRef(4); + + const [cityCount, setCityCount] = useState(40); + const [phase, setPhase] = useState("idle"); + const [length, setLength] = useState(0); + const [nnLength, setNnLength] = useState(0); + const [speed, setSpeed] = useState(4); + + const setPhaseBoth = useCallback((p: Phase) => { + phaseRef.current = p; + setPhase(p); + }, []); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const cities = citiesRef.current; + const tour = tourRef.current; + + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Tour edges + if (tour.length > 1) { + const closed = phaseRef.current === "improve" || phaseRef.current === "done"; + ctx.strokeStyle = phaseRef.current === "done" ? "#00ff41" : "rgba(0,255,65,0.6)"; + ctx.lineWidth = phaseRef.current === "done" ? 2 : 1.5; + ctx.beginPath(); + tour.forEach((cityIdx, i) => { + const c = cities[cityIdx]; + if (i === 0) ctx.moveTo(c.x, c.y); + else ctx.lineTo(c.x, c.y); + }); + if (closed) ctx.closePath(); + ctx.stroke(); + } + + // Cities + for (const c of cities) { + ctx.beginPath(); + ctx.arc(c.x, c.y, 4, 0, Math.PI * 2); + ctx.fillStyle = "#00cfff"; + ctx.fill(); + } + // Start city + if (cities.length > 0 && tour.length > 0) { + const s = cities[tour[0]]; + ctx.beginPath(); + ctx.arc(s.x, s.y, 6, 0, Math.PI * 2); + ctx.strokeStyle = "#ffe066"; + ctx.lineWidth = 2; + ctx.stroke(); + } + }, []); + + const updateLength = useCallback(() => { + if (tourRef.current.length > 1) { + setLength(tourLength(citiesRef.current, tourRef.current)); + } else { + setLength(0); + } + }, []); + + const scatter = useCallback((n: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + citiesRef.current = randomCities(n, canvas.width, canvas.height); + tourRef.current = []; + solverRef.current = null; + setPhaseBoth("idle"); + setLength(0); + setNnLength(0); + draw(); + }, [draw, setPhaseBoth]); + + const solve = useCallback(() => { + if (citiesRef.current.length < 3) return; + solverRef.current = solveTsp(citiesRef.current); + tourRef.current = []; + setNnLength(0); + setPhaseBoth("construct"); + }, [setPhaseBoth]); + + // Stepping loop + useEffect(() => { + const id = setInterval(() => { + const solver = solverRef.current; + if (!solver) return; + const phase = phaseRef.current; + if (phase !== "construct" && phase !== "improve") return; + + for (let i = 0; i < speedRef.current; i++) { + const r = solver.next(); + if (r.done) break; + const ev = r.value; + if (ev.type === "extend" || ev.type === "swap") { + tourRef.current = ev.tour; + } else if (ev.type === "phase") { + setPhaseBoth(ev.phase); + if (ev.phase === "improve") { + // NN construction just finished — record its length + setNnLength(tourLength(citiesRef.current, tourRef.current)); + } + if (ev.phase === "done") break; + } + } + updateLength(); + draw(); + }, 30); + return () => clearInterval(id); + }, [draw, setPhaseBoth, updateLength]); + + // Init + resize + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const resize = () => { + const rect = canvas.parentElement!.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + scatter(40); + }; + resize(); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [scatter]); + + const onClick = (e: React.MouseEvent) => { + if (phaseRef.current === "construct" || phaseRef.current === "improve") return; + const canvas = canvasRef.current!; + const rect = canvas.getBoundingClientRect(); + citiesRef.current = [ + ...citiesRef.current, + { x: e.clientX - rect.left, y: e.clientY - rect.top }, + ]; + tourRef.current = []; + solverRef.current = null; + setPhaseBoth("idle"); + setLength(0); + setNnLength(0); + draw(); + }; + + const improvementPct = nnLength > 0 && length > 0 + ? ((nnLength - length) / nnLength) * 100 + : 0; + + return ( +
+ {/* Header */} +
+
+ + ← home + + | + traveling salesman +
+
+ tour {length > 0 ? Math.round(length).toLocaleString() : "—"} + {phase === "done" && nnLength > 0 && ( + 2-opt saved {improvementPct.toFixed(1)}% + )} + + {phase === "construct" && "building greedy tour..."} + {phase === "improve" && "untangling with 2-opt..."} + {phase === "done" && "✓ local optimum"} + +
+
+ + {/* Controls */} +
+
+ cities + {CITY_COUNTS.map((n) => ( + + ))} +
+ +
+ speed + { + const v = Number(e.target.value); + speedRef.current = v; + setSpeed(v); + }} + className="w-24 accent-primary" + /> +
+ +
+ + +
+
+ + {/* Canvas */} +
+ +
+ click to add cities · greedy nearest-neighbor first, then 2-opt removes the crossings +
+
+
+ ); +}