From 25a0a23d00b068dc5a6f2b52127acafc72765dd5 Mon Sep 17 00:00:00 2001 From: Rahul Mahale Date: Sat, 13 Jun 2026 16:29:32 +0530 Subject: [PATCH] feat: add HandOfStraights visualization with interactive step-by-step explanation and test cases --- .agents/skills/visualization/SKILL.md | 3 +- next-env.d.ts | 2 +- .../HandOfStraightsVisualization.tsx | 323 ++++++++++++++++++ src/utils/visualizationMapping.tsx | 5 + 4 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/components/visualizations/algorithms/HandOfStraightsVisualization.tsx diff --git a/.agents/skills/visualization/SKILL.md b/.agents/skills/visualization/SKILL.md index 718288c7..aecf96e2 100644 --- a/.agents/skills/visualization/SKILL.md +++ b/.agents/skills/visualization/SKILL.md @@ -30,8 +30,7 @@ When creating or updating the visualization, rigidly follow these rules: 10. **Use AnimatedCodeEditor:** Always use the `AnimatedCodeEditor` component when an implementation code block is required within the visualization. 11. **Minimal Code Editor Container:** Do NOT include a redundant `div` wrapper, "Implementation" heading, or extra layout containers (like `flex-1 overflow-auto`) within the `Card` that encloses the `AnimatedCodeEditor`. The editor should be the primary child of its container card. 12. **Test Case Selection:** Only implement multiple test cases (and the selection UI) if explicitly requested by the user. If not requested, one robust default case is sufficient. When multiple cases ARE requested, provide a premium, visually stunning UI (e.g., an animated sliding toggle with icons from `lucide-react`) to switch between them. Switching cases MUST reset the execution state (`currentStepIndex` back to 0, `isPlaying` to false). Place these controls at the top, typically next to or just below the `StepControls`. -13. **Pedagogical Layout:** Ensure that the descriptive commentary box is placed **above** the `VariablePanel` to provide immediate context for the current state. - +13. **Pedagogical Layout:** Ensure that the descriptive commentary box is placed **at the bottom** of the visualization to provide immediate context for the current state, and the `VariablePanel` MUST be placed **below** the commentary box. ### 3. Verification - Double-check that there are absolutely no comments inside the code block string. - Verify array/pointer/variable states correspond exactly to the active execution step. diff --git a/next-env.d.ts b/next-env.d.ts index 2d5420eb..0c7fad71 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/visualizations/algorithms/HandOfStraightsVisualization.tsx b/src/components/visualizations/algorithms/HandOfStraightsVisualization.tsx new file mode 100644 index 00000000..6d54f7a0 --- /dev/null +++ b/src/components/visualizations/algorithms/HandOfStraightsVisualization.tsx @@ -0,0 +1,323 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { CheckCircle2, XCircle } from 'lucide-react'; +import { AnimatedCodeEditor } from "../shared/AnimatedCodeEditor"; +import { StepControls } from '../shared/StepControls'; +import { VariablePanel } from '../shared/VariablePanel'; + +interface CardObj { + id: number; + val: number; + used: boolean; +} + +interface Step { + availableCards: CardObj[]; + groups: number[][]; + currentGroup: number[]; + activeCardVal: number | null; + explanation: string; + lineNumber: number; + variables: Record; +} + +interface TestCase { + id: string; + name: string; + hand: number[]; + groupSize: number; + expected: boolean; +} + +const TEST_CASES: TestCase[] = [ + { id: 'ex1', name: 'Valid Hand', hand: [1, 2, 3, 6, 2, 3, 4, 7, 8], groupSize: 3, expected: true }, + { id: 'ex2', name: 'Invalid Hand (Divisible)', hand: [1, 2, 3, 1, 2, 4], groupSize: 3, expected: false }, + { id: 'ex3', name: 'Invalid Hand (Not Divisible)', hand: [1, 2, 3, 4, 5], groupSize: 4, expected: false } +]; + +export const HandOfStraightsVisualization = () => { + const [selectedTestCaseId, setSelectedTestCaseId] = useState(TEST_CASES[0].id); + const selectedTestCase = TEST_CASES.find(t => t.id === selectedTestCaseId) || TEST_CASES[0]; + + const [steps, setSteps] = useState([]); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const intervalRef = useRef(null); + + const code = `function isNStraightHand(hand: number[], groupSize: number): boolean { + if (hand.length % groupSize !== 0) { + return false; + } + + const count = new Map(); + for (const num of hand) { + count.set(num, (count.get(num) || 0) + 1); + } + + const sortedKeys = Array.from(count.keys()).sort((a, b) => a - b); + + for (const first of sortedKeys) { + while ((count.get(first) || 0) > 0) { + for (let i = first; i < first + groupSize; i++) { + const freq = count.get(i) || 0; + + if (freq === 0) { + return false; + } + + count.set(i, freq - 1); + } + } + } + + return true; +}`; + + const generateSteps = useCallback(() => { + const newSteps: Step[] = []; + const hand = selectedTestCase.hand; + const groupSize = selectedTestCase.groupSize; + let availableCards: CardObj[] = hand.map((val, idx) => ({ id: idx, val, used: false })).sort((a, b) => a.val - b.val); + + let groups: number[][] = []; + let currentGroup: number[] = []; + + const addStep = (explanation: string, lineNumber: number, variables: Record, activeCardVal: number | null = null) => { + newSteps.push({ + availableCards: availableCards.map(c => ({ ...c })), + groups: [...groups.map(g => [...g])], + currentGroup: [...currentGroup], + activeCardVal, + explanation, + lineNumber, + variables: { ...variables } + }); + }; + + let vars: Record = { hand: `[${hand.join(', ')}]`, groupSize }; + + // Line 2 + addStep(`Let's help Alice check if she can group her ${hand.length} cards into groups of ${groupSize}. Are there enough cards?`, 2, vars); + + if (hand.length % groupSize !== 0) { + addStep(`Oh no! ${hand.length} is not perfectly divisible by ${groupSize}. We can't form the groups. Return false.`, 3, vars); + setSteps(newSteps); + setCurrentStepIndex(0); + return; + } + + // Line 6 + addStep("Let's create a magic box to count how many of each card Alice has.", 6, vars); + + const count = new Map(); + vars.count = "{}"; + + // Line 7 + addStep("We'll look at each card in Alice's hand and count them one by one.", 7, vars); + + for (const num of hand) { + count.set(num, (count.get(num) || 0) + 1); + vars.count = JSON.stringify(Object.fromEntries(count)); + addStep(`Counting the cards! We found a '${num}'.`, 8, vars, num); + } + + addStep("We've counted all of Alice's cards! Now we know exactly what she has.", 9, vars); + + // Line 11 + const sortedKeys = Array.from(count.keys()).sort((a, b) => a - b); + vars.sortedKeys = JSON.stringify(sortedKeys); + addStep("To build groups of consecutive cards, we must always start with the smallest cards. So we sort our unique cards from smallest to largest.", 11, vars); + + // Line 13 + for (const first of sortedKeys) { + vars.first = first; + addStep(`Let's look at the smallest available card we haven't finished with: ${first}.`, 13, vars, first); + + // Line 14 + while ((count.get(first) || 0) > 0) { + addStep(`We have at least one '${first}', so let's try to build a group of ${groupSize} consecutive cards starting with it!`, 14, vars, first); + currentGroup = []; + + // Line 15 + for (let i = first; i < first + groupSize; i++) { + vars.i = i; + addStep(`We need the card '${i}' to continue our consecutive sequence.`, 15, vars, i); + + const freq = count.get(i) || 0; + vars.freq = freq; + + // Line 16 + addStep(`Checking how many '${i}' cards we have... We have ${freq}.`, 16, vars, i); + + // Line 18 + if (freq === 0) { + addStep(`Oh no! We need a '${i}' to make the cards consecutive, but we don't have any left. Alice can't finish her groups!`, 18, vars, i); + addStep(`Return false.`, 19, vars, i); + setSteps(newSteps); + setCurrentStepIndex(0); + return; + } + + // Line 22 + count.set(i, freq - 1); + vars.count = JSON.stringify(Object.fromEntries(count)); + + // Mark one physical card as used visually + const cardIndex = availableCards.findIndex(c => c.val === i && !c.used); + if (cardIndex !== -1) { + availableCards[cardIndex].used = true; + } + + currentGroup.push(i); + + addStep(`Awesome! We use one '${i}' card and add it to our current group.`, 22, vars, i); + } + groups.push([...currentGroup]); + currentGroup = []; + addStep(`Yay! We successfully built a group of ${groupSize} consecutive cards!`, 23, vars); + } + addStep(`We ran out of '${first}' cards. Time to move to the next smallest card.`, 24, vars, first); + } + + addStep(`All of Alice's cards have been perfectly placed into consecutive groups! Return true.`, 27, vars); + + setSteps(newSteps); + setCurrentStepIndex(0); + }, [selectedTestCase]); + + useEffect(() => { + generateSteps(); + }, [generateSteps]); + + useEffect(() => { + if (isPlaying && currentStepIndex < steps.length - 1) { + intervalRef.current = setInterval(() => { + setCurrentStepIndex(prev => { + if (prev >= steps.length - 1) { + setIsPlaying(false); + return prev; + } + return prev + 1; + }); + }, 1000 / speed); + } else { + if (intervalRef.current) clearInterval(intervalRef.current); + } + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [isPlaying, currentStepIndex, steps.length, speed]); + + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleStepForward = () => currentStepIndex < steps.length - 1 && setCurrentStepIndex(prev => prev + 1); + const handleStepBack = () => currentStepIndex > 0 && setCurrentStepIndex(prev => prev - 1); + const handleReset = () => { + setCurrentStepIndex(0); + setIsPlaying(false); + generateSteps(); + }; + + if (steps.length === 0 || !Array.isArray(steps[0].availableCards)) return null; + + const currentStep = steps[currentStepIndex]; + + return ( +
+
+

+ Test Cases +

+
+ {TEST_CASES.map(tc => ( + + ))} +
+
+ + + +
+
+
+

+ ✨ Alice's Cards ✨ +

+
+ {currentStep.availableCards.map((cardObj) => ( +
+
+ {cardObj.val} +
+
+ ))} +
+ + {(currentStep.groups.length > 0 || currentStep.currentGroup.length > 0) && ( +
+

🌟 Consecutive Groups Built 🌟

+
+ {currentStep.groups.map((group, gIdx) => ( +
+ {group.map((c, cIdx) => ( +
+ {c} +
+ ))} +
+ ))} + {currentStep.currentGroup.length > 0 && ( +
+ {currentStep.currentGroup.map((c, cIdx) => ( +
+ {c} +
+ ))} +
+ )} +
+
+ )} +
+ +
+

{currentStep.explanation}

+
+ + +
+ + +
+
+ ); +}; diff --git a/src/utils/visualizationMapping.tsx b/src/utils/visualizationMapping.tsx index ef3f78a0..5868086d 100644 --- a/src/utils/visualizationMapping.tsx +++ b/src/utils/visualizationMapping.tsx @@ -140,6 +140,7 @@ export const visualizationMap: Record = { 'subset-generation-bits': dynamic(() => import('@/components/visualizations/algorithms/SubsetBitsVisualization').then(m => m.SubsetBitsVisualization), { ssr: false }), // Scheduling / Greedy + 'hand-of-straights': dynamic(() => import('@/components/visualizations/algorithms/HandOfStraightsVisualization').then(m => m.HandOfStraightsVisualization), { ssr: false }), 'task-scheduler': dynamic(() => import('@/components/visualizations/algorithms/TaskSchedulerVisualization').then(m => m.TaskSchedulerVisualization), { ssr: false }), // Heap/Priority Queue @@ -301,6 +302,10 @@ export const visualizationMetadataMap: Record