diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e68bf4e..3943351 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,13 +37,10 @@ If you are unsure where your change belongs, start here: ## Terminology -Praxis now distinguishes between several kinds of interactive modules: - 1. `Algorithm`: a runnable algorithm implementation such as BFS, A*, Minimax, or Simulated Annealing. -2. `Game`: an interactive problem module with game-specific rendering and controls, such as Tic-Tac-Toe or Maze Game. -3. `Sandbox`: a general-purpose experimentation surface, such as Graph Sandbox. -4. `Lab`: a structured educational module, currently used most heavily in local search. -5. `Discovery item`: any surfaced module shown on the home Playgrounds tab or in module search. +2. `Playground`: the editor for one algorithm family — the single user-facing concept for every interactive module. A playground lets you set up a custom problem and step through it. We used to split these into "game", "sandbox", and "lab"; that distinction was cosmetic and has been removed. `DiscoveryItem.kind` is always `'playground'`, and the home page surfaces every module with one uniform badge. +3. `Family`: the internal code grouping a playground belongs to — `search`, `maze`, `game-playing`, `local-search`, `planning`, or `constraint-satisfaction`. Families are an implementation detail (each has its own `src/problems//` directory and registry), not a user-facing category. +4. `Discovery item`: any surfaced playground shown on the home Playgrounds tab or in module search. ## Architecture Map @@ -190,9 +187,9 @@ The local-search page is generic. It expects each lab to provide: 6. `createDefaultProblem()` 7. `normalizeImportedProblem(problem)` 8. `randomizeProblem(problem)` -9. `renderSetupSection(context)` -10. `renderBoardTab(context)` -11. `renderNeighborhoodTab(context)` +9. `renderSetupSection(context)` — the lab-specific setup section appended to the page's config sidebar +10. `renderTabs(context)` — the unified tab contract (Problem View, Neighborhood, Objective, Trajectory), shared with every other family's page shell so each page calls `activeLab.renderTabs(context)` the same way +11. `renderBoardTab(context)` / `renderNeighborhoodTab(context)` — lab-specific views composed by `renderTabs`; objective and trajectory are family-wide These are defined in `src/problems/local-search/labs.ts` and implemented in `src/problems/local-search/lab-modules.tsx`. @@ -289,7 +286,7 @@ These contracts live in `src/problems/game-playing/lab-modules.tsx` and `src/pro ## Adding Or Updating Maze Game Entries -Maze is treated as a game surfaced in the discovery layer, but it currently still uses its own dedicated page implementation. +Maze now goes through a registry module (`MAZE_LAB_MODULE` in `src/problems/maze/lab-modules.tsx`): `MazePage` is a thin shell that owns the maze store, local config state, and effects, builds a `MazeLabContext`, and delegates rendering to the module's `renderConfigPanel` / `renderTabs` / `renderTitleActions`. Discovery entries still live in `src/problems/maze/labs.ts`. ### Files You Will Usually Touch @@ -320,7 +317,7 @@ Do not edit `src/lib/discovery-items.ts` directly for Maze-specific entries. Reg ## Adding Or Updating Graph Sandbox Entries -Graph Sandbox is the general-purpose graph experimentation interface for search algorithms. It is not a game. +Graph Sandbox is the general-purpose graph experimentation playground for search algorithms. It deliberately remains a single cohesive page (`SearchPage.tsx`) rather than a registry module: it is a single-instance editor with ~7 local state hooks plus the editor store, so threading all of that through a pure-render module context would be messier than the page it replaces. It still renders through the shared `AlgorithmPage` shell, so it is consistent for users. ### Files You Will Usually Touch diff --git a/src/components/visualization/TicTacToeLab.tsx b/src/components/visualization/TicTacToeLab.tsx index 3a696df..641af4c 100644 --- a/src/components/visualization/TicTacToeLab.tsx +++ b/src/components/visualization/TicTacToeLab.tsx @@ -152,7 +152,7 @@ export default function TicTacToeLab({ problem, step, onSetCell }: TicTacToeLabP

Evaluated Moves

- {traceState?.evaluatedMoves.length ?? 0} scored + {traceState?.evaluatedMoves?.length ?? 0} scored
{traceState?.evaluatedMoves?.length ? ( @@ -186,7 +186,7 @@ export default function TicTacToeLab({ problem, step, onSetCell }: TicTacToeLabP

Recursion Stack

- {traceState?.recursionStack.length ?? 0} frames + {traceState?.recursionStack?.length ?? 0} frames
{traceState?.recursionStack?.length ? ( diff --git a/src/components/visualization/local-search/GraphColoringLab.tsx b/src/components/visualization/local-search/GraphColoringLab.tsx index 0d3788e..43fc4e3 100644 --- a/src/components/visualization/local-search/GraphColoringLab.tsx +++ b/src/components/visualization/local-search/GraphColoringLab.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/cn'; import { Graph, type GraphColoringProblem } from '@/types/problem'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; -import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared'; +import { CandidateList, TraceNotes } from './LocalSearchShared'; import { normalizeGraphNodes } from '@/problems/local-search/graph-coloring'; const PALETTE = ['#F2C94C', '#58A6FF', '#53C880', '#FF7B72', '#D2A8FF', '#56D4DD', '#FFA657', '#7EE787']; @@ -153,7 +153,6 @@ export function GraphColoringBoardTab({ problem, step, onCycleNode, onUpdateGrap return (
-
diff --git a/src/components/visualization/local-search/LandscapeLab.tsx b/src/components/visualization/local-search/LandscapeLab.tsx index 829283a..acb4967 100644 --- a/src/components/visualization/local-search/LandscapeLab.tsx +++ b/src/components/visualization/local-search/LandscapeLab.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/cn'; import type { LandscapeProblem, LandscapeState } from '@/types/problem'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; import { evaluateLandscape } from '@/problems/local-search/landscape'; -import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared'; +import { CandidateList, TraceNotes } from './LocalSearchShared'; interface LandscapeLabProps { problem: LandscapeProblem; @@ -144,7 +144,6 @@ export function LandscapeBoardTab({ problem, step, onSetInitialState }: Landscap return (
-
- {cards.map(card => ( -
-

{card.label}

-

{card.value}

-
- ))} -
- ); -} - export function CandidateList({ candidates, acceptedMove, diff --git a/src/components/visualization/local-search/NPuzzleLab.tsx b/src/components/visualization/local-search/NPuzzleLab.tsx index 2e3c20a..0400e7b 100644 --- a/src/components/visualization/local-search/NPuzzleLab.tsx +++ b/src/components/visualization/local-search/NPuzzleLab.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/cn'; import type { NPuzzleProblem } from '@/types/problem'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; -import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared'; +import { CandidateList, TraceNotes } from './LocalSearchShared'; interface NPuzzleLabProps { problem: NPuzzleProblem; @@ -72,7 +72,6 @@ export function NPuzzleBoardTab({ problem, step, onMoveTile }: NPuzzleLabProps) return (
-
diff --git a/src/components/visualization/local-search/NQueensLab.tsx b/src/components/visualization/local-search/NQueensLab.tsx index 2c0dc74..c3cece9 100644 --- a/src/components/visualization/local-search/NQueensLab.tsx +++ b/src/components/visualization/local-search/NQueensLab.tsx @@ -1,7 +1,7 @@ import { cn } from '@/lib/cn'; import type { NQueensProblem } from '@/types/problem'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; -import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared'; +import { CandidateList, TraceNotes } from './LocalSearchShared'; interface SharedProps { problem: NQueensProblem; @@ -102,8 +102,6 @@ export function NQueensBoardTab({ problem, step, onSetQueen }: SharedProps) { return (
- -
diff --git a/src/components/visualization/local-search/TspLab.tsx b/src/components/visualization/local-search/TspLab.tsx index db32659..774b274 100644 --- a/src/components/visualization/local-search/TspLab.tsx +++ b/src/components/visualization/local-search/TspLab.tsx @@ -1,6 +1,6 @@ import type { TspProblem } from '@/types/problem'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; -import { CandidateList, SummaryCards, TraceNotes } from './LocalSearchShared'; +import { CandidateList, TraceNotes } from './LocalSearchShared'; import SurfaceCard from '@/components/shared/SurfaceCard'; interface TspLabProps { @@ -124,7 +124,6 @@ export function TspBoardTab({ problem, step, onRegenerate, onUpdateCities }: Tsp return (
-
diff --git a/src/hooks/useAlgorithmPage.ts b/src/hooks/useAlgorithmPage.ts index 1d22540..b23321d 100644 --- a/src/hooks/useAlgorithmPage.ts +++ b/src/hooks/useAlgorithmPage.ts @@ -1,7 +1,7 @@ import { useMemo, useEffect } from 'react'; import { registry } from '@/algorithms/core/registry'; import type { ExecutionLoadContext } from '@/store/execution.store'; -import { useExecutionStore } from '@/store/execution.store'; +import { useCurrentStep, useExecutionStore } from '@/store/execution.store'; import { usePlayback } from '@/hooks/usePlayback'; import type { AlgorithmRunner, AlgorithmStep } from '@/types'; @@ -41,7 +41,7 @@ export function useAlgorithmPage( // eslint-disable-next-line react-hooks/exhaustive-deps }, [algorithmId, problem, runner, context]); - const step = useExecutionStore(state => state.currentStep as AlgorithmStep | null); + const step = useCurrentStep(algorithmId); const loadError = useExecutionStore(state => state.loadError); const loadWarning = useExecutionStore(state => state.loadWarning); diff --git a/src/lib/discovery-items.ts b/src/lib/discovery-items.ts index a0105bb..1eb8a41 100644 --- a/src/lib/discovery-items.ts +++ b/src/lib/discovery-items.ts @@ -7,7 +7,14 @@ import { PLANNING_LAB_DEFINITIONS } from '@/problems/planning/labs'; import { SEARCH_LAB_DEFINITIONS } from '@/problems/search/labs'; export type DiscoveryItemStatus = 'live' | 'coming-soon'; -export type DiscoveryItemKind = 'game' | 'sandbox' | 'lab'; + +/** + * Every interactive module is a "playground": the editor for one algorithm + * family where you set up a custom problem and step through it. We used to split + * these into game / sandbox / lab, but that distinction was cosmetic — there is + * one concept, surfaced uniformly. Keep it that way. + */ +export type DiscoveryItemKind = 'playground'; export interface DiscoveryItem { id: string; @@ -20,75 +27,48 @@ export interface DiscoveryItem { export type DiscoveryItemsByCategory = Partial>; +interface SourceDefinition { + id: string; + name: string; + description: string; + path?: string; + status?: DiscoveryItemStatus; +} + +/** + * Uniform mapping from a family's lab registry to discovery items. The aggregator + * stays dumb: every family is surfaced the same way, with the same `playground` + * kind. The only per-family knob is an optional id suffix used to disambiguate + * module ids from their underlying lab ids. + */ +function toDiscoveryItems( + defs: SourceDefinition[], + options: { idSuffix?: string } = {}, +): DiscoveryItem[] { + const { idSuffix = '' } = options; + return defs.map((def) => ({ + id: `${def.id}${idSuffix}`, + name: def.name, + description: def.description, + path: def.path, + status: def.status ?? 'live', + kind: 'playground' as const, + })); +} + export const DISCOVERY_ITEMS_BY_CATEGORY: DiscoveryItemsByCategory = { 'uninformed-search': [ - ...MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search').map((entry) => ({ - id: entry.id, - name: entry.name, - description: entry.description, - path: entry.path, - status: entry.status, - kind: 'game' as const, - })), - ...SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search').map((entry) => ({ - id: entry.id, - name: entry.name, - description: entry.description, - path: entry.path, - status: entry.status, - kind: 'sandbox' as const, - })), + ...toDiscoveryItems(MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search')), + ...toDiscoveryItems(SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'uninformed-search')), ], 'informed-search': [ - ...MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search').map((entry) => ({ - id: entry.id, - name: entry.name, - description: entry.description, - path: entry.path, - status: entry.status, - kind: 'game' as const, - })), - ...SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search').map((entry) => ({ - id: entry.id, - name: entry.name, - description: entry.description, - path: entry.path, - status: entry.status, - kind: 'sandbox' as const, - })), + ...toDiscoveryItems(MAZE_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search')), + ...toDiscoveryItems(SEARCH_LAB_DEFINITIONS.filter((entry) => entry.category === 'informed-search')), ], - 'game-playing': GAME_PLAYING_LAB_DEFINITIONS.map((entry) => ({ - id: `${entry.id}-lab`, - name: entry.name, - description: entry.description, - path: entry.path, - status: entry.status, - kind: 'game' as const, - })), - 'local-search': LOCAL_SEARCH_LAB_DEFINITIONS.map((entry) => ({ - id: `${entry.id}-lab`, - name: `${entry.name} Lab`, - description: entry.description, - path: entry.path, - status: 'live' as const, - kind: 'lab' as const, - })), - planning: PLANNING_LAB_DEFINITIONS.map((entry) => ({ - id: `${entry.id}-lab`, - name: entry.name, - description: entry.description, - path: entry.path, - status: 'live' as const, - kind: 'lab' as const, - })), - 'constraint-satisfaction': CSP_LAB_DEFINITIONS.map((entry) => ({ - id: `${entry.id}-lab`, - name: entry.name, - description: entry.description, - path: entry.path, - status: 'live' as const, - kind: 'lab' as const, - })), + 'game-playing': toDiscoveryItems(GAME_PLAYING_LAB_DEFINITIONS, { idSuffix: '-lab' }), + 'local-search': toDiscoveryItems(LOCAL_SEARCH_LAB_DEFINITIONS, { idSuffix: '-lab' }), + planning: toDiscoveryItems(PLANNING_LAB_DEFINITIONS, { idSuffix: '-lab' }), + 'constraint-satisfaction': toDiscoveryItems(CSP_LAB_DEFINITIONS, { idSuffix: '-lab' }), }; export function getDiscoveryItemsForCategory(category: AlgorithmCategory): DiscoveryItem[] { diff --git a/src/pages/CspPage.tsx b/src/pages/CspPage.tsx index b3125ac..9f76ef3 100644 --- a/src/pages/CspPage.tsx +++ b/src/pages/CspPage.tsx @@ -4,7 +4,7 @@ import AlgorithmPage from '@/components/module/AlgorithmPage'; import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator'; import PresetPickerDialog from '@/components/shared/PresetPickerDialog'; import Select from '@/components/shared/Select'; -import { useExecutionStore } from '@/store/execution.store'; +import { useCurrentStep, useExecutionStore } from '@/store/execution.store'; import type { CspProblem } from '@/types/problem'; import type { CspStep } from '@/algorithms/csp/types'; import { createExecutionProblemKey } from '@/lib/execution-problem-key'; @@ -35,7 +35,7 @@ export default function CspPage() { const resolvedLab = supportsCspAlgorithm(labParam, algo) ? labParam : fallbackLab; const [problem, setProblem] = useState(() => createDefaultCspProblem(resolvedLab)); const [problemKey, setProblemKey] = useState(`csp:${resolvedLab}:default`); - const step = useExecutionStore((state) => state.currentStep as CspStep | null); + const step = useCurrentStep(algo); const currentIndex = useExecutionStore((state) => state.currentIndex); const resetExecution = useExecutionStore((state) => state.reset); const activeLab = getCspLabModule(problem.lab); diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 45b8b6d..dc1d076 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Navigate, useParams } from 'react-router-dom'; import type { AlgorithmStep } from '@/types/step'; import type { GameProblem } from '@/types/problem'; -import { useExecutionStore } from '@/store/execution.store'; +import { useCurrentStep, useExecutionStore } from '@/store/execution.store'; import AlgorithmPage from '@/components/module/AlgorithmPage'; import { buildGamePlayingRoute, @@ -24,7 +24,7 @@ export default function GamePage() { const [problem, setProblem] = useState(() => activeLab.createDefaultProblem()); const [problemKey, setProblemKey] = useState(`game:${resolvedLabId}:default`); const [demoDialogOpen, setDemoDialogOpen] = useState(false); - const step = useExecutionStore(state => state.currentStep as AlgorithmStep | null); + const step = useCurrentStep>(algo); const clearExecution = useExecutionStore((state) => state.clear); useEffect(() => { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 10d3ec3..dfd4e81 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -18,7 +18,7 @@ import { DISCOVERY_ITEMS_BY_CATEGORY, getDiscoveryItemsForCategory } from '@/lib const HOME_TABS = [ { id: 'algorithms', label: 'Algorithms', icon: Search, hint: 'Browse registered algorithms' }, - { id: 'games', label: 'Playgrounds', icon: Gamepad2, hint: 'Open games, sandboxes, and labs' }, + { id: 'games', label: 'Playgrounds', icon: Gamepad2, hint: 'Open an algorithm playground' }, { id: 'graph', label: 'Relationship Graph', icon: Network, hint: 'Explore algorithm families visually' }, ] as const; @@ -277,9 +277,9 @@ export default function HomePage() {

Playgrounds

-

Games, Sandboxes, and Labs by Category

+

Playgrounds by Category

- Maze is treated as a game, Graph Sandbox is the editable testing surface, and local-search modules stay grouped as labs. + Each playground is the editor for one algorithm family — set up a custom problem, then step through it.

@@ -307,7 +307,7 @@ export default function HomePage() { to={item.path} title={item.name} description={item.description} - badge={item.kind === 'game' ? 'Game' : item.kind === 'sandbox' ? 'Sandbox' : 'Lab'} + badge="Playground" badgeTone="success" /> ) : ( @@ -315,13 +315,7 @@ export default function HomePage() {

{item.name}

- {item.status === 'coming-soon' - ? 'COMING SOON' - : item.kind === 'game' - ? 'GAME' - : item.kind === 'sandbox' - ? 'SANDBOX' - : 'LAB'} + {item.status === 'coming-soon' ? 'COMING SOON' : 'PLAYGROUND'}

{item.description}

diff --git a/src/pages/LocalSearchPage.tsx b/src/pages/LocalSearchPage.tsx index 22b6062..1e29d3a 100644 --- a/src/pages/LocalSearchPage.tsx +++ b/src/pages/LocalSearchPage.tsx @@ -12,11 +12,10 @@ import { DEFAULT_PHEROMONE_INFLUENCE, DEFAULT_POPULATION, } from '@/algorithms/local-search/core'; -import { renderLocalSearchObjectiveTab, renderLocalSearchTrajectoryTab } from '@/problems/local-search/lab-modules'; import { Dice5 } from '@/components/shared/Icons'; import { TitleBarActionButton, TitleBarActionGroup } from '@/components/shared/TitleBarAction'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; -import { useExecutionStore } from '@/store/execution.store'; +import { useCurrentStep, useExecutionStore } from '@/store/execution.store'; import { LOCAL_SEARCH_LAB_DEFINITIONS, createDefaultLocalSearchProblem, @@ -75,7 +74,7 @@ export default function LocalSearchPage() { const labParam = isLocalSearchLabKind(rawLabParam) ? rawLabParam : 'n-queens'; const [problem, setProblem] = useState(() => createDefaultLocalSearchProblem(labParam)); const [problemKey, setProblemKey] = useState(`local:${labParam}:default`); - const step = useExecutionStore(state => state.currentStep as LocalSearchStep | null); + const step = useCurrentStep(algo); const currentIndex = useExecutionStore(state => state.currentIndex); const resetExecution = useExecutionStore(state => state.reset); const activeLab = getLocalSearchLabModule(problem.kind); @@ -204,12 +203,7 @@ export default function LocalSearchPage() { setProblem(normalized); setProblemKey(createLocalProblemKey(`local:${normalized.kind}:import`)); }} - tabs={[ - { id: 'board', label: 'Problem View', content: activeLab.renderBoardTab(labContext) }, - { id: 'neighborhood', label: 'Neighborhood', content: activeLab.renderNeighborhoodTab(labContext) }, - { id: 'objective', label: 'Objective', content: renderLocalSearchObjectiveTab() }, - { id: 'trajectory', label: 'Trajectory', content: renderLocalSearchTrajectoryTab(problem, step) }, - ]} + tabs={activeLab.renderTabs(labContext)} buildAlgorithmRoute={(algorithmId) => `/local/${algorithmId}?lab=${problem.kind}`} executionContext={executionContext} titleActions={ diff --git a/src/pages/MazePage.tsx b/src/pages/MazePage.tsx index fb49447..fc58a19 100644 --- a/src/pages/MazePage.tsx +++ b/src/pages/MazePage.tsx @@ -1,35 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import AlgorithmPage from '@/components/module/AlgorithmPage'; -import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator'; import PresetPickerDialog from '@/components/shared/PresetPickerDialog'; -import type { TabDefinition } from '@/components/module/AlgorithmPage'; -import SVGAutoCanvas from '@/components/visualization/SVGAutoCanvas'; -import MazeEditor from '@/components/visualization/MazeEditor'; import { registry } from '@/algorithms/core/registry'; -import { INFORMED_HEURISTICS, getHeuristicDefinition } from '@/algorithms/search/informed/types'; import { useMazeStore } from '@/store/maze.store'; -import { useExecutionStore } from '@/store/execution.store'; -import type { HeuristicId, MazeProblem } from '@/types/problem'; -import { algorithmStepToMazeOverlay, mazeToGraphProblem } from '@/visualizations/adapters/maze.adapter'; -import { buildSearchTreeElements } from '@/visualizations/adapters/search-tree.adapter'; +import { useCurrentStep } from '@/store/execution.store'; +import { mazeToGraphProblem } from '@/visualizations/adapters/maze.adapter'; import { deserializeMazeReplay, serializeMazeReplay } from '@/problems/maze/maze'; -import { MAZE_STRATEGY_LABELS } from '@/problems/maze/strategies'; import { MAZE_DEMOS, buildMazeDemo } from '@/problems/maze/demos'; -import { Copy, Dice5 } from '@/components/shared/Icons'; -import { TitleBarActionButton, TitleBarActionGroup } from '@/components/shared/TitleBarAction'; -import EmptyState from '@/components/shared/EmptyState'; -import InfoCard from '@/components/shared/InfoCard'; -import HeuristicConfigSection from '@/components/shared/HeuristicConfigSection'; -import { evaluationFormula } from '@/lib/evaluationFormula'; +import { MAZE_LAB_MODULE, type MazeLabContext } from '@/problems/maze/lab-modules'; import { toAbsoluteAppUrl } from '@/lib/app-paths'; import { createExecutionProblemKey } from '@/lib/execution-problem-key'; -function isMazeProblem(value: unknown): value is MazeProblem { - if (!value || typeof value !== 'object') return false; - return (value as MazeProblem).kind === 'maze'; -} - function createMazeProblemKey(prefix: string): string { return `${prefix}:${Date.now()}`; } @@ -52,14 +34,10 @@ export default function MazePage() { const [weightedAStarWeight, setWeightedAStarWeight] = useState(1.5); const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); const importedReplayRef = useRef(null); - const heuristicId = (mazeProblem.heuristic?.id ?? 'manhattan-distance') as HeuristicId; - const heuristicScale = Number(mazeProblem.heuristic?.params?.scale ?? 1); - const heuristicDefinition = useMemo(() => getHeuristicDefinition(heuristicId), [heuristicId]); const runner = useMemo(() => registry.get(algo)?.runner ?? null, [algo]); - const runnerTags = useMemo(() => new Set(runner?.meta.tags ?? []), [runner]); - const isInformedAlgorithm = runner?.meta.category === 'informed-search'; - const supportsInflationWeight = runnerTags.has('inflation-weight'); + const category = runner?.meta.category ?? 'uninformed-search'; + // Hydrate a shared maze from a ?m= replay token. useEffect(() => { const replay = searchParams.get('m'); if (!replay) return; @@ -71,6 +49,7 @@ export default function MazePage() { } }, [searchParams, setMazeProblem]); + // Debounce edits so dragging the brush doesn't reload the engine every frame. const [debouncedProblem, setDebouncedProblem] = useState(mazeProblem); useEffect(() => { const timer = setTimeout(() => setDebouncedProblem(mazeProblem), 220); @@ -88,64 +67,7 @@ export default function MazePage() { return base; }, [debouncedProblem, algo, depthLimit, weightedAStarWeight]); - const step = useExecutionStore(s => s.currentStep); - - const treeElements = useMemo(() => { - if (!step) return []; - - const st = step.state as Record; - const pathMap = st.pathMap instanceof Map - ? st.pathMap as Map - : new Map(); - - const foundPath = Array.isArray(st.foundPath) ? st.foundPath as string[] : null; - const gCosts = (st.gCosts instanceof Map ? st.gCosts : st.costs instanceof Map ? st.costs : undefined) as Map | undefined; - const hCosts = (st.hCosts instanceof Map ? st.hCosts : undefined) as Map | undefined; - const fCosts = (st.fCosts instanceof Map ? st.fCosts : undefined) as Map | undefined; - - const highlight = step.highlight as { - frontierNodes?: Set; - exploredNodes?: Set; - currentNode?: string | null; - pathEdges?: string[] | null; - }; - - const labelMap = new Map( - graphProblem.graph.nodes.map(n => [n.id, n.label ?? n.id]), - ); - - return buildSearchTreeElements(pathMap, highlight, foundPath, { - startNode: graphProblem.startNode, - goalNode: graphProblem.goalNode, - labelMap, - gCosts, - hCosts, - fCosts, - }); - }, [graphProblem, step]); - - const overlay = useMemo(() => algorithmStepToMazeOverlay(step), [step]); - - const tabs: TabDefinition[] = useMemo(() => [ - { - id: 'maze-board', - label: 'Problem View', - content: , - }, - { - id: 'tree', - label: 'Search Tree', - content: treeElements.length > 0 - ?
- : ( - - ), - }, - ], [overlay, treeElements]); + const step = useCurrentStep(algo); const copyReplayLink = useCallback(async () => { const token = serializeMazeReplay(mazeProblem); @@ -159,209 +81,18 @@ export default function MazePage() { window.setTimeout(() => setCopyStatus('idle'), 1200); }, [algo, mazeProblem]); - const titleActions = useMemo(() => ( - - { - setSeed(Date.now()); - generateMaze(); - setProblemKey(createMazeProblemKey('maze:random')); - }} - icon={} - label="Randomize" - title="Generate a new maze seed" - /> - } - label={copyStatus === 'copied' ? 'Copied' : copyStatus === 'error' ? 'Copy Failed' : 'Copy Replay'} - title="Copy replay link" - /> - - ), [copyReplayLink, copyStatus, generateMaze, setSeed]); - - const configPanel = useMemo(() => ( - - {isInformedAlgorithm && ( - - { - const params = (nextId !== 'manual-node' && nextId !== 'zero' && heuristicScale !== 1) - ? { scale: heuristicScale } - : undefined; - setMazeProblem({ - ...mazeProblem, - heuristic: { id: nextId as HeuristicId, params }, - }); - }} - heuristicOptions={INFORMED_HEURISTICS.map(h => ({ value: h.id, label: h.label }))} - description={heuristicDefinition.description} - heuristicScale={heuristicScale} - onHeuristicScaleChange={(nextScale) => { - setMazeProblem({ - ...mazeProblem, - heuristic: { - id: heuristicId, - params: nextScale === 1 ? undefined : { scale: nextScale }, - }, - }); - }} - beforeSelect={supportsInflationWeight ? ( -
-

Inflation Weight (w)

- setWeightedAStarWeight(Math.max(1, Number(e.target.value) || 1))} - className="ui-input w-full px-2 py-1.5 font-mono" - /> -

w=1 -> optimal (A*). Higher = faster but suboptimal.

-
- ) : null} - afterSelect={heuristicId === 'manual-node' ? ( -
-

Per-Cell h(n) Table

-
-
- Cell - h(n) -
-
- {[...graphProblem.graph.nodes] - .sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)) - .map((node) => ( -
- - {node.label ?? node.id} - - { - const raw = e.target.value; - const nextManual = { ...(mazeProblem.manualHeuristicValues ?? {}) }; - if (raw.trim() === '') { - delete nextManual[node.id]; - } else { - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return; - nextManual[node.id] = parsed; - } - setMazeProblem({ - ...mazeProblem, - manualHeuristicValues: nextManual, - heuristic: { id: 'manual-node' }, - }); - }} - className="ui-input w-full px-1.5 py-0.5 text-right font-mono" - /> -
- ))} -
-
-
- ) : null} - footer={( - -
-

g(n) Path cost from start

-

h(n) Estimate to goal

-

{evaluationFormula(algo)}

-
-
- )} - /> -
- )} - - {algo === 'dls' && ( - -

Depth Limit

- setDepthLimit(Math.max(1, Number(e.target.value) || 1))} - className="ui-input w-full px-2 py-1.5 font-mono" - /> -
- )} - - -
-
-

Dimensions

-
- - -
-
- -
-

Generation Strategy

-

{MAZE_STRATEGY_LABELS[strategy]}

-
-
-
- - -
- {MAZE_DEMOS.map((demo) => ( - - ))} -
-
-
- ), [algo, depthLimit, heuristicId, heuristicScale, heuristicDefinition.description, isInformedAlgorithm, mazeProblem, setDimensions, setMazeProblem, setStrategy, strategy, supportsInflationWeight, weightedAStarWeight, graphProblem.graph.nodes]); + const markProblemChanged = useCallback((reason: string) => { + setProblemKey(createMazeProblemKey(`maze:${reason}`)); + }, []); const handleImport = useCallback((imported: unknown) => { - if (isMazeProblem(imported)) { - setMazeProblem(imported); + const normalized = MAZE_LAB_MODULE.normalizeImportedProblem(imported); + if (normalized) { + setMazeProblem(normalized); setProblemKey(createMazeProblemKey('maze:import')); } }, [setMazeProblem]); + const executionProblemKey = useMemo( () => `${problemKey}:${createExecutionProblemKey(graphProblem)}`, [problemKey, graphProblem], @@ -375,18 +106,38 @@ export default function MazePage() { preservePosition: true, }), [executionProblemKey, searchParams]); + const mazeContext: MazeLabContext = { + algorithmId: algo, + problem: mazeProblem, + graphProblem, + step, + setProblem: setMazeProblem, + setSeed, + generateMaze, + strategy, + setStrategy, + setDimensions, + depthLimit, + setDepthLimit, + weightedAStarWeight, + setWeightedAStarWeight, + markProblemChanged, + copyReplayLink, + copyStatus, + }; + return ( <> setDemoDialogOpen(true)} /> diff --git a/src/pages/PlanningPage.tsx b/src/pages/PlanningPage.tsx index c8e4dda..c5e9e96 100644 --- a/src/pages/PlanningPage.tsx +++ b/src/pages/PlanningPage.tsx @@ -4,7 +4,7 @@ import AlgorithmPage from '@/components/module/AlgorithmPage'; import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator'; import PresetPickerDialog from '@/components/shared/PresetPickerDialog'; import Select from '@/components/shared/Select'; -import { useExecutionStore } from '@/store/execution.store'; +import { useCurrentStep, useExecutionStore } from '@/store/execution.store'; import type { PlanningProblem } from '@/types/problem'; import type { PlanningStep } from '@/algorithms/planning/types'; import { applyAction, createGroundedProblem, isActionApplicable } from '@/problems/planning/core'; @@ -36,7 +36,7 @@ export default function PlanningPage() { const resolvedLab = supportsPlanningAlgorithm(labParam, algo) ? labParam : fallbackLab; const [problem, setProblem] = useState(() => createDefaultPlanningProblem(resolvedLab)); const [problemKey, setProblemKey] = useState(`planning:${resolvedLab}:default`); - const step = useExecutionStore((state) => state.currentStep as PlanningStep | null); + const step = useCurrentStep(algo); const currentIndex = useExecutionStore((state) => state.currentIndex); const resetExecution = useExecutionStore((state) => state.reset); const activeLab = getPlanningLabModule(problem.lab); diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index d3e5658..9f61bee 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -3,7 +3,7 @@ import { Navigate, useParams } from 'react-router-dom'; import { cn } from '@/lib/cn'; import { registry } from '@/algorithms/core/registry'; import { useEditorStore } from '@/store/useEditorStore'; -import { useExecutionStore } from '@/store/execution.store'; +import { useCurrentStep } from '@/store/execution.store'; import AlgorithmPage from '@/components/module/AlgorithmPage'; import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator'; import type { TabDefinition } from '@/components/module/AlgorithmPage'; @@ -57,7 +57,9 @@ export default function SearchPage() { const heuristicDefinition = useMemo(() => getHeuristicDefinition(heuristicId), [heuristicId]); // ── Execution store read (step needed for visualization memos) ─────── - const step = useExecutionStore(s => s.currentStep) as AlgorithmStep | null; + // Gated by algorithm id so a foreign step from a previous page can never leak + // into the graph-search visualization during cross-page navigation. + const step = useCurrentStep(algo); // ── Editor store subscriptions ─────────────────────────────────────── const editorNodes = useEditorStore(s => s.nodes); diff --git a/src/problems/game-playing/lab-modules.tsx b/src/problems/game-playing/lab-modules.tsx index 42d48cd..0b438f1 100644 --- a/src/problems/game-playing/lab-modules.tsx +++ b/src/problems/game-playing/lab-modules.tsx @@ -269,7 +269,7 @@ export function renderGameLabPresetPicker( open={open} onOpenChange={onOpenChange} title={`Choose a ${lab.name} Demo`} - subtitle={`Load a preset scenario for ${lab.name.toLowerCase().replace(' lab', '')}`} + subtitle={`Load a preset scenario for ${lab.name.toLowerCase()}`} items={lab.presets} onSelect={onSelect} /> diff --git a/src/problems/local-search/lab-modules.tsx b/src/problems/local-search/lab-modules.tsx index af74d99..c5a483b 100644 --- a/src/problems/local-search/lab-modules.tsx +++ b/src/problems/local-search/lab-modules.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; -import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator'; +import { ConfigSection } from '@/components/module/ProblemConfigurator'; +import type { TabDefinition } from '@/components/module/AlgorithmPage'; import Select from '@/components/shared/Select'; import { GraphColoringBoardTab, GraphColoringNeighborhoodTab, GraphColoringMiniature } from '@/components/visualization/local-search/GraphColoringLab'; import { ObjectiveTab, TrajectoryTab, ViewOverlay } from '@/components/visualization/local-search/LocalSearchShared'; @@ -193,7 +194,7 @@ function renderNPuzzleSetup(context: LocalSearchLabContext) { ); } -export const LOCAL_SEARCH_LAB_MODULES: LocalSearchLabModule[] = [ +const LOCAL_SEARCH_LAB_MODULE_DEFS: Array> = [ { id: 'n-queens', name: 'N-Queens', @@ -461,6 +462,28 @@ export const LOCAL_SEARCH_LAB_MODULES: LocalSearchLabModule[] = [ }, ]; +/** + * Shared tab set for every local-search lab. Each lab supplies its own board and + * neighborhood views; objective and trajectory are family-wide. This lets the + * page call `activeLab.renderTabs(context)` exactly like the other families. + */ +function buildLocalSearchTabs( + module: Omit, + context: LocalSearchLabContext, +): TabDefinition[] { + return [ + { id: 'board', label: 'Problem View', content: module.renderBoardTab(context) }, + { id: 'neighborhood', label: 'Neighborhood', content: module.renderNeighborhoodTab(context) }, + { id: 'objective', label: 'Objective', content: renderLocalSearchObjectiveTab() }, + { id: 'trajectory', label: 'Trajectory', content: renderLocalSearchTrajectoryTab(context.problem, context.step) }, + ]; +} + +export const LOCAL_SEARCH_LAB_MODULES: LocalSearchLabModule[] = LOCAL_SEARCH_LAB_MODULE_DEFS.map((module) => ({ + ...module, + renderTabs: (context: LocalSearchLabContext) => buildLocalSearchTabs(module, context), +})); + export function renderLocalSearchObjectiveTab() { return ; } diff --git a/src/problems/local-search/labs.ts b/src/problems/local-search/labs.ts index b01b1c3..59ede89 100644 --- a/src/problems/local-search/labs.ts +++ b/src/problems/local-search/labs.ts @@ -1,6 +1,7 @@ import type { Dispatch, ReactNode, SetStateAction } from 'react'; import type { LocalSearchStep } from '@/algorithms/local-search/types'; import type { GraphColoringProblem, LandscapeProblem, LocalSearchProblem, NPuzzleProblem, NQueensProblem, TspProblem } from '@/types/problem'; +import type { TabDefinition } from '@/components/module/AlgorithmPage'; import { LOCAL_SEARCH_LAB_MODULES, } from './lab-modules'; @@ -27,6 +28,8 @@ export interface LocalSearchLabModule extends LocalSearchLabDefinition { normalizeImportedProblem: (problem: unknown) => LocalSearchProblem; randomizeProblem: (problem: LocalSearchProblem) => LocalSearchProblem; renderSetupSection: (context: LocalSearchLabContext) => ReactNode; + /** Unified tab contract shared with every other family's page shell. */ + renderTabs: (context: LocalSearchLabContext) => TabDefinition[]; renderBoardTab: (context: LocalSearchLabContext) => ReactNode; renderNeighborhoodTab: (context: LocalSearchLabContext) => ReactNode; renderMiniature?: (state: any, problem: any) => ReactNode; diff --git a/src/problems/maze/lab-modules.tsx b/src/problems/maze/lab-modules.tsx new file mode 100644 index 0000000..1d2f790 --- /dev/null +++ b/src/problems/maze/lab-modules.tsx @@ -0,0 +1,344 @@ +import type { ReactNode } from 'react'; +import ProblemConfigurator, { ConfigSection } from '@/components/module/ProblemConfigurator'; +import type { TabDefinition } from '@/components/module/AlgorithmPage'; +import SVGAutoCanvas from '@/components/visualization/SVGAutoCanvas'; +import MazeEditor from '@/components/visualization/MazeEditor'; +import EmptyState from '@/components/shared/EmptyState'; +import InfoCard from '@/components/shared/InfoCard'; +import HeuristicConfigSection from '@/components/shared/HeuristicConfigSection'; +import { TitleBarActionButton, TitleBarActionGroup } from '@/components/shared/TitleBarAction'; +import { Copy, Dice5 } from '@/components/shared/Icons'; +import { registry } from '@/algorithms/core/registry'; +import { INFORMED_HEURISTICS, getHeuristicDefinition } from '@/algorithms/search/informed/types'; +import { MAZE_STRATEGY_LABELS, type MazeGenerationStrategyId } from '@/problems/maze/strategies'; +import { MAZE_DEMOS, buildMazeDemo } from '@/problems/maze/demos'; +import { algorithmStepToMazeOverlay } from '@/visualizations/adapters/maze.adapter'; +import { buildSearchTreeElements } from '@/visualizations/adapters/search-tree.adapter'; +import { evaluationFormula } from '@/lib/evaluationFormula'; +import type { AlgorithmStep } from '@/types/step'; +import type { GraphProblem, HeuristicId, MazeProblem } from '@/types/problem'; + +/** + * Context the Maze playground page threads into the module's render functions. + * The page owns all stateful concerns (the maze store, local config state, and + * effects); the module stays a set of pure render functions, exactly like the + * game-playing / planning / csp / local-search families. + */ +export interface MazeLabContext { + algorithmId: string; + /** Live maze problem (from the maze store). */ + problem: MazeProblem; + /** Debounced maze-as-graph problem used for the search tree + heuristic table. */ + graphProblem: GraphProblem; + step: AlgorithmStep | null; + setProblem: (problem: MazeProblem) => void; + setSeed: (seed: number) => void; + generateMaze: () => void; + strategy: MazeGenerationStrategyId; + setStrategy: (strategy: MazeGenerationStrategyId) => void; + setDimensions: (rows: number, cols: number) => void; + depthLimit: number; + setDepthLimit: (value: number) => void; + weightedAStarWeight: number; + setWeightedAStarWeight: (value: number) => void; + /** Bump the execution problem key so the trace reloads. `reason` is a short tag. */ + markProblemChanged: (reason: string) => void; + copyReplayLink: () => void; + copyStatus: 'idle' | 'copied' | 'error'; +} + +export interface MazeLabModule { + id: string; + name: string; + defaultAlgorithmId: string; + normalizeImportedProblem: (problem: unknown) => MazeProblem | null; + renderConfigPanel: (context: MazeLabContext) => ReactNode; + renderTabs: (context: MazeLabContext) => TabDefinition[]; + renderTitleActions: (context: MazeLabContext) => ReactNode; +} + +function isMazeProblem(value: unknown): value is MazeProblem { + if (!value || typeof value !== 'object') return false; + return (value as MazeProblem).kind === 'maze'; +} + +function buildMazeTreeElements(graphProblem: GraphProblem, step: AlgorithmStep | null) { + if (!step) return []; + + const st = step.state as Record; + const pathMap = st.pathMap instanceof Map + ? st.pathMap as Map + : new Map(); + + const foundPath = Array.isArray(st.foundPath) ? st.foundPath as string[] : null; + const gCosts = (st.gCosts instanceof Map ? st.gCosts : st.costs instanceof Map ? st.costs : undefined) as Map | undefined; + const hCosts = (st.hCosts instanceof Map ? st.hCosts : undefined) as Map | undefined; + const fCosts = (st.fCosts instanceof Map ? st.fCosts : undefined) as Map | undefined; + + const highlight = step.highlight as { + frontierNodes?: Set; + exploredNodes?: Set; + currentNode?: string | null; + pathEdges?: string[] | null; + }; + + const labelMap = new Map( + graphProblem.graph.nodes.map(n => [n.id, n.label ?? n.id]), + ); + + return buildSearchTreeElements(pathMap, highlight, foundPath, { + startNode: graphProblem.startNode, + goalNode: graphProblem.goalNode, + labelMap, + gCosts, + hCosts, + fCosts, + }); +} + +function renderMazeTabs(context: MazeLabContext): TabDefinition[] { + const overlay = algorithmStepToMazeOverlay(context.step); + const treeElements = buildMazeTreeElements(context.graphProblem, context.step); + + return [ + { + id: 'maze-board', + label: 'Problem View', + content: , + }, + { + id: 'tree', + label: 'Search Tree', + content: treeElements.length > 0 + ?
+ : ( + + ), + }, + ]; +} + +function renderMazeTitleActions(context: MazeLabContext): ReactNode { + return ( + + { + context.setSeed(Date.now()); + context.generateMaze(); + context.markProblemChanged('random'); + }} + icon={} + label="Randomize" + title="Generate a new maze seed" + /> + } + label={context.copyStatus === 'copied' ? 'Copied' : context.copyStatus === 'error' ? 'Copy Failed' : 'Copy Replay'} + title="Copy replay link" + /> + + ); +} + +function renderMazeConfigPanel(context: MazeLabContext): ReactNode { + const { algorithmId, problem, graphProblem, depthLimit, weightedAStarWeight } = context; + + const heuristicId = (problem.heuristic?.id ?? 'manhattan-distance') as HeuristicId; + const heuristicScale = Number(problem.heuristic?.params?.scale ?? 1); + const heuristicDefinition = getHeuristicDefinition(heuristicId); + const runner = registry.get(algorithmId)?.runner ?? null; + const runnerTags = new Set(runner?.meta.tags ?? []); + const isInformedAlgorithm = runner?.meta.category === 'informed-search'; + const supportsInflationWeight = runnerTags.has('inflation-weight'); + + return ( + + {isInformedAlgorithm && ( + + { + const params = (nextId !== 'manual-node' && nextId !== 'zero' && heuristicScale !== 1) + ? { scale: heuristicScale } + : undefined; + context.setProblem({ + ...problem, + heuristic: { id: nextId as HeuristicId, params }, + }); + }} + heuristicOptions={INFORMED_HEURISTICS.map(h => ({ value: h.id, label: h.label }))} + description={heuristicDefinition.description} + heuristicScale={heuristicScale} + onHeuristicScaleChange={(nextScale) => { + context.setProblem({ + ...problem, + heuristic: { + id: heuristicId, + params: nextScale === 1 ? undefined : { scale: nextScale }, + }, + }); + }} + beforeSelect={supportsInflationWeight ? ( +
+

Inflation Weight (w)

+ context.setWeightedAStarWeight(Math.max(1, Number(e.target.value) || 1))} + className="ui-input w-full px-2 py-1.5 font-mono" + /> +

w=1 -> optimal (A*). Higher = faster but suboptimal.

+
+ ) : null} + afterSelect={heuristicId === 'manual-node' ? ( +
+

Per-Cell h(n) Table

+
+
+ Cell + h(n) +
+
+ {[...graphProblem.graph.nodes] + .sort((a, b) => (a.label ?? a.id).localeCompare(b.label ?? b.id)) + .map((node) => ( +
+ + {node.label ?? node.id} + + { + const raw = e.target.value; + const nextManual = { ...(problem.manualHeuristicValues ?? {}) }; + if (raw.trim() === '') { + delete nextManual[node.id]; + } else { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + nextManual[node.id] = parsed; + } + context.setProblem({ + ...problem, + manualHeuristicValues: nextManual, + heuristic: { id: 'manual-node' }, + }); + }} + className="ui-input w-full px-1.5 py-0.5 text-right font-mono" + /> +
+ ))} +
+
+
+ ) : null} + footer={( + +
+

g(n) Path cost from start

+

h(n) Estimate to goal

+

{evaluationFormula(algorithmId)}

+
+
+ )} + /> +
+ )} + + {algorithmId === 'dls' && ( + +

Depth Limit

+ context.setDepthLimit(Math.max(1, Number(e.target.value) || 1))} + className="ui-input w-full px-2 py-1.5 font-mono" + /> +
+ )} + + +
+
+

Dimensions

+
+ + +
+
+ +
+

Generation Strategy

+

{MAZE_STRATEGY_LABELS[context.strategy]}

+
+
+
+ + +
+ {MAZE_DEMOS.map((demo) => ( + + ))} +
+
+
+ ); +} + +export const MAZE_LAB_MODULE: MazeLabModule = { + id: 'maze', + name: 'Maze', + defaultAlgorithmId: 'bfs', + normalizeImportedProblem: (problem) => (isMazeProblem(problem) ? problem : null), + renderConfigPanel: renderMazeConfigPanel, + renderTabs: renderMazeTabs, + renderTitleActions: renderMazeTitleActions, +}; diff --git a/src/problems/maze/labs.ts b/src/problems/maze/labs.ts index eec95c0..fb17eae 100644 --- a/src/problems/maze/labs.ts +++ b/src/problems/maze/labs.ts @@ -14,7 +14,7 @@ export interface MazeLabDefinition { export const MAZE_LAB_DEFINITIONS: MazeLabDefinition[] = [ { id: 'maze-lab', - name: 'Maze Game', + name: 'Maze', description: 'Design mazes, tune terrain costs, and watch frontier growth step-by-step.', category: 'uninformed-search', status: 'live', @@ -24,7 +24,7 @@ export const MAZE_LAB_DEFINITIONS: MazeLabDefinition[] = [ }, { id: 'heuristic-maze', - name: 'Maze Game', + name: 'Maze', description: 'Play the maze with heuristic-guided search such as A* and Greedy Best-First.', category: 'informed-search', status: 'live', diff --git a/src/problems/search/labs.ts b/src/problems/search/labs.ts index 5ac7553..152de77 100644 --- a/src/problems/search/labs.ts +++ b/src/problems/search/labs.ts @@ -13,7 +13,7 @@ export interface SearchLabDefinition { export const SEARCH_LAB_DEFINITIONS: SearchLabDefinition[] = [ { id: 'graph-sandbox', - name: 'Graph Sandbox', + name: 'Graph', description: 'Build custom graphs and inspect the resulting search tree.', category: 'uninformed-search', status: 'live', @@ -22,7 +22,7 @@ export const SEARCH_LAB_DEFINITIONS: SearchLabDefinition[] = [ }, { id: 'astar-sandbox', - name: 'Graph Sandbox', + name: 'Graph', description: 'Test informed search on editable graphs with live g(n), h(n), and f(n) annotations.', category: 'informed-search', status: 'live', diff --git a/src/store/execution.store.ts b/src/store/execution.store.ts index 1dcce8a..9ac1271 100644 --- a/src/store/execution.store.ts +++ b/src/store/execution.store.ts @@ -309,3 +309,22 @@ export const useExecutionStore = create()( clearLogs: () => set(state => { state.logs = []; }), })) ); + +/** + * Returns the current step ONLY if it belongs to the requested algorithm. + * + * The execution store is global and a step outlives navigation: when you move + * from one page to another, `currentStep` still holds the previous algorithm's + * step until the new (async) load completes. Handing that foreign step to a + * different family's renderer — e.g. a game board receiving a search step — + * crashes. Gating on the loaded `algorithmId` guarantees a page only ever sees + * its own step (or `null` while the new trace loads). Algorithm ids never + * collide across step-incompatible families, so id matching is sufficient. + */ +export function useCurrentStep(algorithmId: string | null | undefined): T | null { + return useExecutionStore((state) => + algorithmId != null && state.algorithmId === algorithmId + ? (state.currentStep as unknown as T | null) + : null, + ); +}