import { Box, Paper, Typography, TextField } from "@mui/material"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import type { Symbol } from "../types"; import { generateStatePattern, calculateL, calculateEdgeChain, calculateForwardEdges, } from "../utils/ansLogic"; import { clearCanvas, drawStateBox, drawEdge, getBoxAtPosition, type RenderConfig, } from "../utils/canvasRenderer"; interface StateGridProps { symbols: Symbol[]; } export default function StateGrid({ symbols }: StateGridProps) { const canvasRef = useRef(null); const [containerElement, setContainerElement] = useState(null); const [boxSize, setBoxSize] = useState(60); const [boxesPerRow, setBoxesPerRow] = useState(0); const [hoveredState, setHoveredState] = useState(null); const [numLeadingAs, setNumLeadingAs] = useState(0); const [supportsHover, setSupportsHover] = useState(true); // Detect if device supports hover (mouse) vs touch-only useEffect(() => { const hoverQuery = window.matchMedia("(hover: hover)"); setSupportsHover(hoverQuery.matches); const handleChange = (e: MediaQueryListEvent) => { setSupportsHover(e.matches); }; hoverQuery.addEventListener("change", handleChange); return () => { hoverQuery.removeEventListener("change", handleChange); }; }, []); const L = useMemo(() => calculateL(symbols), [symbols]); const maxStates = 5000; // Show first 5000 states const states = useMemo( () => generateStatePattern(symbols, maxStates), [symbols, maxStates] ); // Calculate edge chain on demand for hovered state const edgeChain = useMemo(() => { if (hoveredState === null) return []; return calculateEdgeChain(hoveredState, symbols, numLeadingAs); }, [hoveredState, symbols, numLeadingAs]); // Calculate forward edges on demand for hovered state const forwardEdges = useMemo(() => { if (hoveredState === null) return []; return calculateForwardEdges(hoveredState, symbols, maxStates); }, [hoveredState, symbols, maxStates]); // Calculate canvas dimensions const canvasWidth = useMemo(() => { if (!containerElement || boxesPerRow === 0) return 0; return containerElement.clientWidth; }, [containerElement, boxesPerRow]); const canvasHeight = useMemo(() => { if (boxesPerRow === 0) return 0; const numRows = Math.ceil(states.length / boxesPerRow); const boxHeight = Math.floor(boxSize * 0.6); return numRows * boxHeight; }, [states.length, boxesPerRow, boxSize]); // Update layout based on container size useEffect(() => { const updateLayout = () => { if (!containerElement) return; const containerWidth = containerElement.clientWidth; if (containerWidth === 0) return; // Container not ready yet const desiredBoxSize = 24; const gapSize = 6; // Space between groups // Calculate how many complete groups can fit // Each group has L boxes, and each group (except the last on a row) has a gap after it let numGroups = 1; while (true) { const totalWidth = numGroups * L * desiredBoxSize + (numGroups - 1) * gapSize; if (totalWidth > containerWidth) break; numGroups++; } numGroups = Math.max(1, numGroups - 1); const adjustedBoxesPerRow = numGroups * L; // Calculate actual box size to fill the width perfectly const totalGapWidth = (numGroups - 1) * gapSize; const availableWidthForBoxes = containerWidth - totalGapWidth; const actualBoxSize = Math.floor( availableWidthForBoxes / adjustedBoxesPerRow ); setBoxesPerRow(adjustedBoxesPerRow); setBoxSize(actualBoxSize); }; updateLayout(); window.addEventListener("resize", updateLayout); return () => { window.removeEventListener("resize", updateLayout); }; }, [L, containerElement]); // Render canvas whenever state changes useEffect(() => { const canvas = canvasRef.current; if (!canvas || boxesPerRow === 0) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // Set canvas resolution const dpr = window.devicePixelRatio || 1; canvas.width = canvasWidth * dpr; canvas.height = canvasHeight * dpr; canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`; ctx.scale(dpr, dpr); // Clear canvas clearCanvas(ctx, canvasWidth, canvasHeight); // Create render config const config: RenderConfig = { boxSize, boxesPerRow, L, canvasWidth, canvasHeight, }; // Draw all state boxes states.forEach((state) => { const isHovered = state.index === hoveredState; drawStateBox(ctx, state, config, isHovered); }); // Draw backward edges (incoming) - black with arrow pointing to hovered state if (edgeChain.length > 1) { edgeChain.slice(0, -1).forEach((fromState, idx) => { const toState = edgeChain[idx + 1]; // Draw from toState to fromState so arrow points to hovered drawEdge(ctx, toState, fromState, config, "rgba(0, 0, 0, 0.85)", 3); }); } // Draw forward edges (outgoing) - blue with arrow pointing away from hovered state if (forwardEdges.length > 0 && hoveredState !== null) { forwardEdges.forEach((edge) => { drawEdge( ctx, hoveredState, edge.toState, config, "rgba(33, 150, 243, 0.85)", 3 ); }); } }, [ states, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, hoveredState, edgeChain, forwardEdges, ]); // Handle mouse move on canvas (only on devices with hover support) const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!supportsHover) return; // Skip hover on touch devices const canvas = canvasRef.current; if (!canvas || boxesPerRow === 0) return; const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const config: RenderConfig = { boxSize, boxesPerRow, L, canvasWidth, canvasHeight, }; const boxIndex = getBoxAtPosition( mouseX, mouseY, states.length, config ); setHoveredState(boxIndex); }, [supportsHover, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length] ); // Handle mouse leave (only on devices with hover support) const handleMouseLeave = useCallback(() => { if (!supportsHover) return; // Skip on touch devices setHoveredState(null); }, [supportsHover]); // Handle click on canvas (for touch devices and desktop) const handleClick = useCallback( (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas || boxesPerRow === 0) return; const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; const config: RenderConfig = { boxSize, boxesPerRow, L, canvasWidth, canvasHeight, }; const boxIndex = getBoxAtPosition( clickX, clickY, states.length, config ); if (!supportsHover) { // On touch devices: toggle selection setHoveredState(prevState => prevState === boxIndex ? null : boxIndex); } // On desktop with hover: do nothing (hover already handles it) }, [supportsHover, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length] ); if (symbols.length === 0) { return ( Add at least one symbol to see the state visualization. ); } if (boxesPerRow === 0) { return ( State Visualization setContainerElement(elmt)} sx={{ border: "1px solid", borderColor: "divider", backgroundColor: "grey.50", minHeight: 100, display: "flex", alignItems: "center", justifyContent: "center", }} > Loading state visualization... ); } return ( State Visualization Each box represents a state (integer). { const value = parseInt(e.target.value, 10); if (!isNaN(value) && value >= 0) { setNumLeadingAs(value); } }} size="small" inputProps={{ min: 0, step: 1 }} sx={{ width: 200 }} /> {hoveredState !== null ? ( State: {hoveredState} {" | "} Encoded sequence: {edgeChain .slice() .reverse() .map((stateIdx) => states[stateIdx]?.symbol.name || "?") .join(" → ")} ) : ( {supportsHover ? "Hover over a state to see its details" : "Click on a state to see its details"} )} setContainerElement(elmt)} sx={{ border: "1px solid", borderColor: "divider", backgroundColor: "grey.50", position: "relative", }} > ); }