/ concept-collection / ans-visualizer
Sign in
concept-collection / ans-visualizer
ans-visualizer / src / components / StateGrid.tsx
370 lines · 10.8 KBBlameHistoryRaw
1import { Box, Paper, Typography, TextField } from "@mui/material";
2import { useState, useEffect, useMemo, useRef, useCallback } from "react";
3import type { Symbol } from "../types";
4import {
5 generateStatePattern,
6 calculateL,
7 calculateEdgeChain,
8 calculateForwardEdges,
9} from "../utils/ansLogic";
10import {
11 clearCanvas,
12 drawStateBox,
13 drawEdge,
14 getBoxAtPosition,
15 type RenderConfig,
16} from "../utils/canvasRenderer";
18interface StateGridProps {
19 symbols: Symbol[];
22export default function StateGrid({ symbols }: StateGridProps) {
23 const canvasRef = useRef<HTMLCanvasElement>(null);
24 const [containerElement, setContainerElement] =
25 useState<HTMLDivElement | null>(null);
26 const [boxSize, setBoxSize] = useState(60);
27 const [boxesPerRow, setBoxesPerRow] = useState(0);
28 const [hoveredState, setHoveredState] = useState<number | null>(null);
29 const [numLeadingAs, setNumLeadingAs] = useState(0);
30 const [supportsHover, setSupportsHover] = useState(true);
32 // Detect if device supports hover (mouse) vs touch-only
33 useEffect(() => {
34 const hoverQuery = window.matchMedia("(hover: hover)");
35 setSupportsHover(hoverQuery.matches);
37 const handleChange = (e: MediaQueryListEvent) => {
38 setSupportsHover(e.matches);
39 };
41 hoverQuery.addEventListener("change", handleChange);
42 return () => {
43 hoverQuery.removeEventListener("change", handleChange);
44 };
45 }, []);
47 const L = useMemo(() => calculateL(symbols), [symbols]);
48 const maxStates = 5000; // Show first 5000 states
49 const states = useMemo(
50 () => generateStatePattern(symbols, maxStates),
51 [symbols, maxStates]
52 );
55 // Calculate edge chain on demand for hovered state
56 const edgeChain = useMemo(() => {
57 if (hoveredState === null) return [];
58 return calculateEdgeChain(hoveredState, symbols, numLeadingAs);
59 }, [hoveredState, symbols, numLeadingAs]);
61 // Calculate forward edges on demand for hovered state
62 const forwardEdges = useMemo(() => {
63 if (hoveredState === null) return [];
64 return calculateForwardEdges(hoveredState, symbols, maxStates);
65 }, [hoveredState, symbols, maxStates]);
67 // Calculate canvas dimensions
68 const canvasWidth = useMemo(() => {
69 if (!containerElement || boxesPerRow === 0) return 0;
70 return containerElement.clientWidth;
71 }, [containerElement, boxesPerRow]);
73 const canvasHeight = useMemo(() => {
74 if (boxesPerRow === 0) return 0;
75 const numRows = Math.ceil(states.length / boxesPerRow);
76 const boxHeight = Math.floor(boxSize * 0.6);
77 return numRows * boxHeight;
78 }, [states.length, boxesPerRow, boxSize]);
80 // Update layout based on container size
81 useEffect(() => {
82 const updateLayout = () => {
83 if (!containerElement) return;
85 const containerWidth = containerElement.clientWidth;
86 if (containerWidth === 0) return; // Container not ready yet
88 const desiredBoxSize = 24;
89 const gapSize = 6; // Space between groups
91 // Calculate how many complete groups can fit
92 // Each group has L boxes, and each group (except the last on a row) has a gap after it
93 let numGroups = 1;
94 while (true) {
95 const totalWidth =
96 numGroups * L * desiredBoxSize + (numGroups - 1) * gapSize;
97 if (totalWidth > containerWidth) break;
98 numGroups++;
99 }
100 numGroups = Math.max(1, numGroups - 1);
102 const adjustedBoxesPerRow = numGroups * L;
104 // Calculate actual box size to fill the width perfectly
105 const totalGapWidth = (numGroups - 1) * gapSize;
106 const availableWidthForBoxes = containerWidth - totalGapWidth;
107 const actualBoxSize = Math.floor(
108 availableWidthForBoxes / adjustedBoxesPerRow
109 );
111 setBoxesPerRow(adjustedBoxesPerRow);
112 setBoxSize(actualBoxSize);
113 };
115 updateLayout();
117 window.addEventListener("resize", updateLayout);
118 return () => {
119 window.removeEventListener("resize", updateLayout);
120 };
121 }, [L, containerElement]);
123 // Render canvas whenever state changes
124 useEffect(() => {
125 const canvas = canvasRef.current;
126 if (!canvas || boxesPerRow === 0) return;
128 const ctx = canvas.getContext("2d");
129 if (!ctx) return;
131 // Set canvas resolution
132 const dpr = window.devicePixelRatio || 1;
133 canvas.width = canvasWidth * dpr;
134 canvas.height = canvasHeight * dpr;
135 canvas.style.width = `${canvasWidth}px`;
136 canvas.style.height = `${canvasHeight}px`;
137 ctx.scale(dpr, dpr);
139 // Clear canvas
140 clearCanvas(ctx, canvasWidth, canvasHeight);
142 // Create render config
143 const config: RenderConfig = {
144 boxSize,
145 boxesPerRow,
146 L,
147 canvasWidth,
148 canvasHeight,
149 };
151 // Draw all state boxes
152 states.forEach((state) => {
153 const isHovered = state.index === hoveredState;
154 drawStateBox(ctx, state, config, isHovered);
155 });
157 // Draw backward edges (incoming) - black with arrow pointing to hovered state
158 if (edgeChain.length > 1) {
159 edgeChain.slice(0, -1).forEach((fromState, idx) => {
160 const toState = edgeChain[idx + 1];
161 // Draw from toState to fromState so arrow points to hovered
162 drawEdge(ctx, toState, fromState, config, "rgba(0, 0, 0, 0.85)", 3);
163 });
164 }
166 // Draw forward edges (outgoing) - blue with arrow pointing away from hovered state
167 if (forwardEdges.length > 0 && hoveredState !== null) {
168 forwardEdges.forEach((edge) => {
169 drawEdge(
170 ctx,
171 hoveredState,
172 edge.toState,
173 config,
174 "rgba(33, 150, 243, 0.85)",
175 3
176 );
177 });
178 }
179 }, [
180 states,
181 boxSize,
182 boxesPerRow,
183 L,
184 canvasWidth,
185 canvasHeight,
186 hoveredState,
187 edgeChain,
188 forwardEdges,
189 ]);
191 // Handle mouse move on canvas (only on devices with hover support)
192 const handleMouseMove = useCallback(
193 (e: React.MouseEvent<HTMLCanvasElement>) => {
194 if (!supportsHover) return; // Skip hover on touch devices
196 const canvas = canvasRef.current;
197 if (!canvas || boxesPerRow === 0) return;
199 const rect = canvas.getBoundingClientRect();
200 const mouseX = e.clientX - rect.left;
201 const mouseY = e.clientY - rect.top;
203 const config: RenderConfig = {
204 boxSize,
205 boxesPerRow,
206 L,
207 canvasWidth,
208 canvasHeight,
209 };
211 const boxIndex = getBoxAtPosition(
212 mouseX,
213 mouseY,
214 states.length,
215 config
216 );
217 setHoveredState(boxIndex);
218 },
219 [supportsHover, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length]
220 );
222 // Handle mouse leave (only on devices with hover support)
223 const handleMouseLeave = useCallback(() => {
224 if (!supportsHover) return; // Skip on touch devices
225 setHoveredState(null);
226 }, [supportsHover]);
228 // Handle click on canvas (for touch devices and desktop)
229 const handleClick = useCallback(
230 (e: React.MouseEvent<HTMLCanvasElement>) => {
231 const canvas = canvasRef.current;
232 if (!canvas || boxesPerRow === 0) return;
234 const rect = canvas.getBoundingClientRect();
235 const clickX = e.clientX - rect.left;
236 const clickY = e.clientY - rect.top;
238 const config: RenderConfig = {
239 boxSize,
240 boxesPerRow,
241 L,
242 canvasWidth,
243 canvasHeight,
244 };
246 const boxIndex = getBoxAtPosition(
247 clickX,
248 clickY,
249 states.length,
250 config
251 );
253 if (!supportsHover) {
254 // On touch devices: toggle selection
255 setHoveredState(prevState => prevState === boxIndex ? null : boxIndex);
256 }
257 // On desktop with hover: do nothing (hover already handles it)
258 },
259 [supportsHover, boxSize, boxesPerRow, L, canvasWidth, canvasHeight, states.length]
260 );
262 if (symbols.length === 0) {
263 return (
264 <Paper elevation={2} sx={{ p: 3 }}>
265 <Typography color="text.secondary">
266 Add at least one symbol to see the state visualization.
267 </Typography>
268 </Paper>
269 );
270 }
272 if (boxesPerRow === 0) {
273 return (
274 <Paper elevation={2} sx={{ p: 3 }}>
275 <Typography variant="h6" gutterBottom>
276 State Visualization
277 </Typography>
278 <Box
279 ref={(elmt: HTMLDivElement | null) => setContainerElement(elmt)}
280 sx={{
281 border: "1px solid",
282 borderColor: "divider",
283 backgroundColor: "grey.50",
284 minHeight: 100,
285 display: "flex",
286 alignItems: "center",
287 justifyContent: "center",
288 }}
289 >
290 <Typography color="text.secondary" sx={{ p: 2 }}>
291 Loading state visualization...
292 </Typography>
293 </Box>
294 </Paper>
295 );
296 }
298 return (
299 <Paper elevation={2} sx={{ p: 2 }}>
300 <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600 }}>
301 State Visualization
302 </Typography>
303 <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
304 Each box represents a state (integer).
305 </Typography>
307 <Box sx={{ mb: 1.5 }}>
308 <TextField
309 label="Number of Leading A's"
310 type="number"
311 value={numLeadingAs}
312 onChange={(e) => {
313 const value = parseInt(e.target.value, 10);
314 if (!isNaN(value) && value >= 0) {
315 setNumLeadingAs(value);
316 }
317 }}
318 size="small"
319 inputProps={{ min: 0, step: 1 }}
320 sx={{ width: 200 }}
321 />
322 </Box>
324 <Box sx={{ mb: 1.5, p: 1.5, backgroundColor: "grey.100", borderRadius: 1, height: 60, overflow: "hidden" }}>
325 {hoveredState !== null ? (
326 <Typography variant="body2">
327 <Box component="span">
328 <span style={{ fontWeight: "bold" }}>State:</span> {hoveredState}
329 </Box>{" | "}
330 <Box
331 component="span"
332 sx={{ fontWeight: "medium", fontFamily: "monospace" }}
333 >
334 <span style={{ fontWeight: "bold" }}>Encoded sequence:</span> {edgeChain
335 .slice()
336 .reverse()
337 .map((stateIdx) => states[stateIdx]?.symbol.name || "?")
338 .join(" → ")}
339 </Box>
340 </Typography>
341 ) : (
342 <Typography variant="body2" color="text.secondary" sx={{ fontStyle: "italic" }}>
343 {supportsHover ? "Hover over a state to see its details" : "Click on a state to see its details"}
344 </Typography>
345 )}
346 </Box>
348 <Box
349 ref={(elmt: HTMLDivElement | null) => setContainerElement(elmt)}
350 sx={{
351 border: "1px solid",
352 borderColor: "divider",
353 backgroundColor: "grey.50",
354 position: "relative",
355 }}
356 >
357 <canvas
358 ref={canvasRef}
359 onMouseMove={handleMouseMove}
360 onMouseLeave={handleMouseLeave}
361 onClick={handleClick}
362 style={{
363 display: "block",
364 cursor: "pointer",
365 }}
366 />
367 </Box>
368 </Paper>
369 );
moveopenescclose