diff --git a/src/App.tsx b/src/App.tsx index 5b3da99..0f42d15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,7 @@ import SubtractionGamePage from './pages/SubtractionGamePage'; import StackingBlocksPage from './pages/StackingBlocksPage'; import ParityBitsGamePage from './pages/ParityBitsGamePage'; import CrapsGamePage from './pages/CrapsGamePage'; +import NeighborSumAvoidancePage from './pages/NeighborSumAvoidancePage'; const queryClient = new QueryClient(); @@ -81,6 +82,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/NeighborSumAvoidance.tsx b/src/components/NeighborSumAvoidance.tsx new file mode 100644 index 0000000..fbcd5b4 --- /dev/null +++ b/src/components/NeighborSumAvoidance.tsx @@ -0,0 +1,373 @@ +import { useState, useEffect, useRef } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Timer, RotateCcw } from 'lucide-react'; +import confetti from 'canvas-confetti'; +import { toast } from 'sonner'; +import SocialShare from '@/components/SocialShare'; + +interface NeighborSumAvoidanceProps { + showSocialShare?: boolean; +} + +const NeighborSumAvoidance = ({ showSocialShare = false }: NeighborSumAvoidanceProps) => { + const [mode, setMode] = useState<'default' | 'guided'>('default'); + const [isActive, setIsActive] = useState(false); + const [slots, setSlots] = useState<(number | null)[]>(Array(9).fill(null)); + const [availableNumbers, setAvailableNumbers] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9]); + const [draggedNumber, setDraggedNumber] = useState(null); + const [draggedFrom, setDraggedFrom] = useState(null); + const [draggedSlot, setDraggedSlot] = useState(null); + const [time, setTime] = useState(0); + const [swaps, setSwaps] = useState(0); + const [isComplete, setIsComplete] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + if (isActive && !isComplete) { + timerRef.current = setInterval(() => { + setTime(t => t + 1); + }, 1000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isActive, isComplete]); + + const isDivisible = (a: number, b: number) => { + const sum = a + b; + return sum % 3 === 0 || sum % 5 === 0 || sum % 7 === 0; + }; + + const getViolations = () => { + const violations: number[] = []; + for (let i = 0; i < 9; i++) { + const current = slots[i]; + const next = slots[(i + 1) % 9]; + if (current !== null && next !== null && isDivisible(current, next)) { + violations.push(i); + } + } + return violations; + }; + + const checkWin = () => { + if (slots.every(s => s !== null) && getViolations().length === 0) { + setIsComplete(true); + confetti({ + particleCount: 100, + spread: 70, + origin: { y: 0.6 } + }); + toast.success(`Congratulations! Completed in ${formatTime(time)}${mode === 'guided' ? ` with ${swaps} swaps` : ''}!`); + } + }; + + useEffect(() => { + if (isActive) { + checkWin(); + } + }, [slots, isActive]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const startGame = (selectedMode: 'default' | 'guided') => { + setMode(selectedMode); + setIsActive(true); + setTime(0); + setSwaps(0); + setIsComplete(false); + + if (selectedMode === 'default') { + setSlots(Array(9).fill(null)); + setAvailableNumbers([1, 2, 3, 4, 5, 6, 7, 8, 9]); + } else { + // Guided mode: random arrangement + const shuffled = [...Array(9)].map((_, i) => i + 1).sort(() => Math.random() - 0.5); + setSlots(shuffled); + setAvailableNumbers([]); + } + }; + + const restart = () => { + setIsActive(false); + setIsComplete(false); + setSlots(Array(9).fill(null)); + setAvailableNumbers([1, 2, 3, 4, 5, 6, 7, 8, 9]); + setTime(0); + setSwaps(0); + }; + + const handleDragStart = (num: number, fromSlot: number | null) => { + setDraggedNumber(num); + setDraggedFrom(fromSlot); + }; + + const handleDrop = (toSlot: number) => { + if (draggedNumber === null) return; + + if (mode === 'default') { + // Default mode: place number in slot + const newSlots = [...slots]; + newSlots[toSlot] = draggedNumber; + setSlots(newSlots); + setAvailableNumbers(availableNumbers.filter(n => n !== draggedNumber)); + } else { + // Guided mode: swap numbers + if (draggedFrom !== null && draggedFrom !== toSlot) { + const newSlots = [...slots]; + const temp = newSlots[toSlot]; + newSlots[toSlot] = newSlots[draggedFrom]; + newSlots[draggedFrom] = temp; + setSlots(newSlots); + setSwaps(s => s + 1); + } + } + + setDraggedNumber(null); + setDraggedFrom(null); + }; + + const handleSlotClick = (slotIndex: number) => { + if (mode === 'default') { + // Remove from slot in default mode + if (slots[slotIndex] !== null) { + setAvailableNumbers([...availableNumbers, slots[slotIndex]!].sort((a, b) => a - b)); + const newSlots = [...slots]; + newSlots[slotIndex] = null; + setSlots(newSlots); + } + } else if (mode === 'guided') { + // Swap in guided mode + if (draggedSlot === null) { + setDraggedSlot(slotIndex); + } else if (draggedSlot !== slotIndex) { + const newSlots = [...slots]; + const temp = newSlots[slotIndex]; + newSlots[slotIndex] = newSlots[draggedSlot]; + newSlots[draggedSlot] = temp; + setSlots(newSlots); + setSwaps(s => s + 1); + setDraggedSlot(null); + } else { + setDraggedSlot(null); + } + } + }; + + const renderCircle = () => { + const radius = 150; + const centerX = 200; + const centerY = 200; + const violations = getViolations(); + + return ( + + {/* Draw circle */} + + + {/* Draw violation arcs */} + {violations.map(i => { + const angle1 = (i * 360 / 9 - 90) * Math.PI / 180; + const angle2 = ((i + 1) * 360 / 9 - 90) * Math.PI / 180; + const x1 = centerX + radius * Math.cos(angle1); + const y1 = centerY + radius * Math.sin(angle1); + const x2 = centerX + radius * Math.cos(angle2); + const y2 = centerY + radius * Math.sin(angle2); + + return ( + + ); + })} + + {/* Draw edges in guided mode */} + {mode === 'guided' && slots.every(s => s !== null) && slots.map((num1, i) => { + return slots.slice(i + 1).map((num2, j) => { + const idx2 = i + j + 1; + if (num1 !== null && num2 !== null && !isDivisible(num1, num2)) { + const angle1 = (i * 360 / 9 - 90) * Math.PI / 180; + const angle2 = (idx2 * 360 / 9 - 90) * Math.PI / 180; + const x1 = centerX + radius * Math.cos(angle1); + const y1 = centerY + radius * Math.sin(angle1); + const x2 = centerX + radius * Math.cos(angle2); + const y2 = centerY + radius * Math.sin(angle2); + + // Check if this forms part of the cycle (adjacent positions on circle) + const isAdjacentOnCircle = Math.abs(i - idx2) === 1 || Math.abs(i - idx2) === 8; + + return ( + + ); + } + return null; + }); + })} + + {/* Draw slots */} + {slots.map((num, i) => { + const angle = (i * 360 / 9 - 90) * Math.PI / 180; + const x = centerX + radius * Math.cos(angle); + const y = centerY + radius * Math.sin(angle); + + const isSelected = mode === 'guided' && draggedSlot === i; + + return ( + + e.preventDefault()} + onDrop={() => handleDrop(i)} + onClick={() => handleSlotClick(i)} + className="cursor-pointer" + /> + {num !== null && ( + + {num} + + )} + + ); + })} + + ); + }; + + return ( +
+ + + Neighbor Sum Avoidance + + Can you arrange the numbers 1-9 in a circle so that the sum of two neighbors is never divisible by 3, 5, or 7? + + + + {!isActive ? ( +
+
+ + setMode(checked ? 'guided' : 'default')} + /> + +
+ +
+
+ {mode === 'default' ? ( + <> +

Drag numbers from below into the circle slots.

+

Adjacent numbers that sum to a multiple of 3, 5, or 7 will show a red arc.

+

Click a filled slot to remove the number.

+ + ) : ( + <> +

Numbers are connected by edges if their sum is NOT divisible by 3, 5, or 7.

+

Click two slots to swap their numbers and form a cycle on the circle's edge.

+ + )} +
+ +
+
+ ) : ( +
+
+
+ + {formatTime(time)} +
+ {mode === 'guided' && ( +
+ Swaps: {swaps} +
+ )} + +
+ + {renderCircle()} + + {mode === 'default' && availableNumbers.length > 0 && ( +
+ {availableNumbers.map(num => ( +
handleDragStart(num, null)} + className="w-12 h-12 rounded-full border-2 border-primary bg-background flex items-center justify-center text-lg font-bold cursor-move hover:scale-110 transition-transform" + > + {num} +
+ ))} +
+ )} + + {isComplete && ( +
+

+ 🎉 Congratulations! You solved it in {formatTime(time)} + {mode === 'guided' && ` with ${swaps} swaps`}! +

+ +
+ )} +
+ )} +
+
+ {showSocialShare && ( +
+ +
+ )} +
+ ); +}; + +export default NeighborSumAvoidance; diff --git a/src/pages/NeighborSumAvoidancePage.tsx b/src/pages/NeighborSumAvoidancePage.tsx new file mode 100644 index 0000000..3a14633 --- /dev/null +++ b/src/pages/NeighborSumAvoidancePage.tsx @@ -0,0 +1,11 @@ +import NeighborSumAvoidance from "@/components/NeighborSumAvoidance"; + +const NeighborSumAvoidancePage = () => { + return ( +
+ +
+ ); +}; + +export default NeighborSumAvoidancePage; diff --git a/src/pages/theme-pages/discrete-math/Graphs.tsx b/src/pages/theme-pages/discrete-math/Graphs.tsx index f6a9ea6..c033615 100644 --- a/src/pages/theme-pages/discrete-math/Graphs.tsx +++ b/src/pages/theme-pages/discrete-math/Graphs.tsx @@ -1,11 +1,115 @@ -import ComingSoon from "@/components/ComingSoon"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import Layout from '@/components/Layout'; +import { useState } from 'react'; +import NeighborSumAvoidance from '@/components/NeighborSumAvoidance'; + +interface Interactive { + id: string; + title: string; + description: string; + tags: string[]; + component: React.ComponentType<{ showSocialShare?: boolean }>; +} + +const interactives: Interactive[] = [ + { + id: 'neighbor-sum-avoidance', + title: 'Neighbor Sum Avoidance', + description: 'Arrange numbers 1-9 in a circle so that the sum of two neighbors is never divisible by 3, 5, or 7. Two modes: default and guided!', + tags: ['graphs', 'cycles', 'constraints', 'puzzle', 'graph-theory'], + component: NeighborSumAvoidance + }, +]; const Graphs = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedInteractive, setSelectedInteractive] = useState(null); + + const filteredInteractives = interactives.filter(interactive => + interactive.title.toLowerCase().includes(searchTerm.toLowerCase()) || + interactive.description.toLowerCase().includes(searchTerm.toLowerCase()) || + interactive.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) + ); + + if (selectedInteractive) { + const InteractiveComponent = selectedInteractive.component; + return ( +
+
+
+ +
+ +
+
+ ); + } + return ( - + +
+
+
+

Graphs

+

+ Discover graph theory, algorithms on graphs, trees, and network analysis through interactive graph manipulation and visualization tools. +

+
+ + {/* Search bar */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Gallery */} +
+ {filteredInteractives.map(interactive => ( + setSelectedInteractive(interactive)} + > + + {interactive.title} + + {interactive.description} + + + +
+ {interactive.tags.map(tag => ( + + {tag} + + ))} +
+
+
+ ))} +
+ + {filteredInteractives.length === 0 && ( +
+

No interactives found matching your search.

+
+ )} +
+
+
); };