interactives/src/components/BulgarianSolitaire.tsx
gpt-engineer-app[bot] ddb1c17cb5 Code edited in Lovable Code Editor
Edited UI in Lovable
2025-08-22 11:26:07 +00:00

580 lines
No EOL
21 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import SocialShare from '@/components/SocialShare';
import { toast } from 'sonner';
interface BulgarianSolitaireProps {
showSocialShare?: boolean;
}
interface Box {
id: number;
columnIndex: number | null; // null means it's in the container
}
const BulgarianSolitaire: React.FC<BulgarianSolitaireProps> = ({ showSocialShare }) => {
const [n, setN] = useState(15);
const [boxes, setBoxes] = useState<Box[]>([]);
const [columns, setColumns] = useState<number[][]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [draggedBox, setDraggedBox] = useState<number | null>(null);
const [step, setStep] = useState(0);
const [isComplete, setIsComplete] = useState(false);
const [highlightedBoxes, setHighlightedBoxes] = useState<Set<number>>(new Set());
const [isAnimating, setIsAnimating] = useState(false);
const [animationPhase, setAnimationPhase] = useState<'idle' | 'fade-to-red' | 'red-pause' | 'moving' | 'fade-from-red' | 'sorting'>('idle');
const [lastUsedColumn, setLastUsedColumn] = useState<number | null>(null);
const [selectedBoxForMove, setSelectedBoxForMove] = useState<number | null>(null);
const [showInstructions, setShowInstructions] = useState(false);
const pauseRef = useRef(false);
const totalBoxes = n;
// Initialize boxes and columns when n changes
useEffect(() => {
const newBoxes: Box[] = [];
for (let i = 0; i < totalBoxes; i++) {
newBoxes.push({ id: i, columnIndex: null });
}
setBoxes(newBoxes);
setColumns(Array.from({ length: totalBoxes }, () => []));
setStep(0);
setIsComplete(false);
}, [n, totalBoxes]);
const handleDragStart = (e: React.DragEvent, boxId: number) => {
setDraggedBox(boxId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, columnIndex: number) => {
e.preventDefault();
if (draggedBox === null) return;
const box = boxes.find(b => b.id === draggedBox);
if (!box || box.columnIndex === columnIndex) return;
// Remove from previous location
if (box.columnIndex !== null) {
setColumns(prev => prev.map((col, idx) =>
idx === box.columnIndex ? col.filter(id => id !== draggedBox) : col
));
}
// Add to new column
setColumns(prev => prev.map((col, idx) =>
idx === columnIndex ? [...col, draggedBox] : col
));
// Update box location
setBoxes(prev => prev.map(b =>
b.id === draggedBox ? { ...b, columnIndex } : b
));
// Track last used column
setLastUsedColumn(columnIndex);
setDraggedBox(null);
};
const handleDropToContainer = (e: React.DragEvent) => {
e.preventDefault();
if (draggedBox === null) return;
const box = boxes.find(b => b.id === draggedBox);
if (!box || box.columnIndex === null) return;
// Remove from column
setColumns(prev => prev.map((col, idx) =>
idx === box.columnIndex ? col.filter(id => id !== draggedBox) : col
));
// Return to container
setBoxes(prev => prev.map(b =>
b.id === draggedBox ? { ...b, columnIndex: null } : b
));
setDraggedBox(null);
};
const handleBoxClick = (boxId: number) => {
const box = boxes.find(b => b.id === boxId);
if (!box || box.columnIndex !== null || isPlaying) return;
// Select box for moving
setSelectedBoxForMove(boxId);
};
const handleColumnClick = (e: React.MouseEvent, columnIndex: number) => {
e.stopPropagation();
if (selectedBoxForMove === null || isPlaying) return;
// Move selected box to this column
setColumns(prev => prev.map((col, idx) =>
idx === columnIndex ? [...col, selectedBoxForMove] : col
));
// Update box location
setBoxes(prev => prev.map(b =>
b.id === selectedBoxForMove ? { ...b, columnIndex } : b
));
// Track last used column and clear selection
setLastUsedColumn(columnIndex);
setSelectedBoxForMove(null);
};
const handleDoubleClick = (boxId: number) => {
const box = boxes.find(b => b.id === boxId);
if (!box) return;
// If box is in container, move to last used column
if (box.columnIndex === null) {
if (lastUsedColumn !== null) {
// Add to last used column
setColumns(prev => prev.map((col, idx) =>
idx === lastUsedColumn ? [...col, boxId] : col
));
// Update box location
setBoxes(prev => prev.map(b =>
b.id === boxId ? { ...b, columnIndex: lastUsedColumn } : b
));
}
return;
}
// If box is in a column, remove from current column
setColumns(prev => prev.map((col, idx) =>
idx === box.columnIndex ? col.filter(id => id !== boxId) : col
));
// Move to last used column or container if no last used column
if (lastUsedColumn !== null && lastUsedColumn !== box.columnIndex) {
// Add to last used column
setColumns(prev => prev.map((col, idx) =>
idx === lastUsedColumn ? [...col, boxId] : col
));
// Update box location
setBoxes(prev => prev.map(b =>
b.id === boxId ? { ...b, columnIndex: lastUsedColumn } : b
));
} else {
// Return to container if no last used column or same as current
setBoxes(prev => prev.map(b =>
b.id === boxId ? { ...b, columnIndex: null } : b
));
}
};
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const waitForUnpause = async () => {
while (pauseRef.current) {
await sleep(100);
}
};
const handlePause = () => {
pauseRef.current = true;
setIsPaused(true);
};
const handleResume = () => {
pauseRef.current = false;
setIsPaused(false);
};
const isTriangularConfiguration = (cols: number[][]) => {
const nonEmptyCols = cols.filter(col => col.length > 0).sort((a, b) => a.length - b.length);
// Check if n is a triangular number and if columns form the staircase pattern
// Find k such that k*(k+1)/2 = n
const k = Math.floor((-1 + Math.sqrt(1 + 8 * n)) / 2);
if (k * (k + 1) / 2 !== n) return false; // n is not a triangular number
if (nonEmptyCols.length !== k) return false;
for (let i = 0; i < k; i++) {
if (nonEmptyCols[i].length !== i + 1) return false;
}
return true;
};
const simulateBulgarianSolitaire = async () => {
if (boxes.filter(b => b.columnIndex === null).length > 0) {
toast.error("Please place all boxes in columns before playing!");
return;
}
setIsPlaying(true);
setStep(0);
let currentColumns = [...columns];
let stepCount = 0;
while (!isTriangularConfiguration(currentColumns) && stepCount < 50) {
stepCount++;
setStep(stepCount);
// Highlight top boxes that will be moved
const topBoxes = new Set<number>();
currentColumns.forEach(col => {
if (col.length > 0) {
topBoxes.add(col[col.length - 1]);
}
});
setHighlightedBoxes(topBoxes);
// Phase 1: Fade to red
setAnimationPhase('fade-to-red');
setIsAnimating(true);
await sleep(500); // Fade to red animation
await waitForUnpause();
// Phase 2: Red pause
setAnimationPhase('red-pause');
await sleep(1000); // Pause for 1 second with red blocks
await waitForUnpause();
// Phase 3: Move blocks
setAnimationPhase('moving');
// Create new pile from non-empty columns
const newPile: number[] = [];
const updatedColumns = currentColumns.map(col => {
if (col.length > 0) {
const boxToMove = col[col.length - 1]; // Take from top
newPile.push(boxToMove);
return col.slice(0, -1); // Remove from column
}
return col;
});
// Add the new pile
const emptyColumnIndex = updatedColumns.findIndex(col => col.length === 0);
if (emptyColumnIndex !== -1) {
updatedColumns[emptyColumnIndex] = newPile;
} else {
// This shouldn't happen in a properly configured game
console.error("No empty column found for new pile");
break;
}
currentColumns = updatedColumns;
setColumns(currentColumns);
// Update box positions
setBoxes(prev => prev.map(box => {
for (let i = 0; i < currentColumns.length; i++) {
if (currentColumns[i].includes(box.id)) {
return { ...box, columnIndex: i };
}
}
return box;
}));
await sleep(500); // Show move
await waitForUnpause();
// Phase 4: Sort columns while boxes are still red
setAnimationPhase('sorting');
// Sort columns by height (tallest to left) with smooth transition
const columnData = currentColumns.map((col, index) => ({ col, originalIndex: index }))
.filter(item => item.col.length > 0)
.sort((a, b) => b.col.length - a.col.length);
const sortedColumns = Array.from({ length: updatedColumns.length }, () => []);
columnData.forEach((item, index) => {
sortedColumns[index] = item.col;
});
currentColumns = sortedColumns;
setColumns(currentColumns);
// Update box positions
setBoxes(prev => prev.map(box => {
for (let i = 0; i < currentColumns.length; i++) {
if (currentColumns[i].includes(box.id)) {
return { ...box, columnIndex: i };
}
}
return box;
}));
await sleep(800); // Wait for sorting animation to complete
await waitForUnpause();
// Phase 5: Fade from red to normal
setAnimationPhase('fade-from-red');
await sleep(500); // Fade from red animation
await waitForUnpause();
// Clear highlighting and animation
setHighlightedBoxes(new Set());
setIsAnimating(false);
setAnimationPhase('idle');
await waitForUnpause(); // Check for pause again
}
setIsComplete(isTriangularConfiguration(currentColumns));
setIsPlaying(false);
if (isTriangularConfiguration(currentColumns)) {
toast.success(`Reached triangular configuration in ${stepCount} steps!`);
} else {
toast.error("Maximum steps reached without convergence");
}
};
const reset = () => {
pauseRef.current = false;
setIsPaused(false);
setIsPlaying(false);
setDraggedBox(null);
setLastUsedColumn(null);
setSelectedBoxForMove(null);
const newBoxes: Box[] = [];
for (let i = 0; i < totalBoxes; i++) {
newBoxes.push({ id: i, columnIndex: null });
}
setBoxes(newBoxes);
setColumns(Array.from({ length: totalBoxes }, () => []));
setStep(0);
setIsComplete(false);
setHighlightedBoxes(new Set());
setIsAnimating(false);
setAnimationPhase('idle');
};
const randomStart = () => {
// Reset first
reset();
// Randomly distribute all boxes into columns
const newBoxes: Box[] = [];
const newColumns = Array.from({ length: totalBoxes }, () => []);
for (let i = 0; i < totalBoxes; i++) {
// Pick a random column index (ensure we use at least some columns)
const columnIndex = Math.floor(Math.random() * Math.min(totalBoxes, 8)); // Limit to 8 columns for better visualization
newBoxes.push({ id: i, columnIndex });
newColumns[columnIndex].push(i);
}
setBoxes(newBoxes);
setColumns(newColumns);
};
const containerBoxes = boxes.filter(box => box.columnIndex === null);
const handleBackgroundClick = () => {
if (selectedBoxForMove !== null) {
setSelectedBoxForMove(null);
}
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-8" onClick={handleBackgroundClick}>
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-foreground">Bulgarian Solitaire</h1>
<p className="text-muted-foreground text-base max-w-3xl mx-auto">
Choose a number n, then drag the {totalBoxes} boxes into columns. <br />
Note that one column can hold multiple boxes. <br />
Click "Play" to simulate the Bulgarian Solitaire process: <br />
here one box is taken from each non-empty column to form a new column.
<br />
<span className="border-t border-muted-foreground/30 block w-16 mx-auto my-4"></span>
<br />
Will this process always converge?
</p>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-8">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Total boxes = {n}</label>
<Slider
value={[n]}
onValueChange={(value) => setN(value[0])}
min={2}
max={45}
step={1}
className="w-32"
disabled={isPlaying}
/>
</div>
<Button onClick={reset} variant="outline" disabled={isPlaying && !isPaused}>
Reset
</Button>
<Button onClick={randomStart} variant="default" disabled={isPlaying && !isPaused}>
Random Start
</Button>
</div>
{/* Container */}
<div className="flex justify-center">
<div
className="border-2 border-dashed border-muted-foreground rounded-lg p-4 min-h-[120px] bg-muted/20"
onDragOver={handleDragOver}
onDrop={handleDropToContainer}
>
<div className="text-center text-sm text-muted-foreground mb-2">Container</div>
<div className="flex flex-wrap gap-2 justify-center max-w-md">
{containerBoxes.map((box) => (
<div
key={box.id}
draggable={!isPlaying}
onDragStart={(e) => handleDragStart(e, box.id)}
onDoubleClick={() => handleDoubleClick(box.id)}
onClick={() => handleBoxClick(box.id)}
className={`w-8 h-8 bg-primary rounded cursor-pointer hover:bg-primary/80 transition-colors ${
selectedBoxForMove === box.id ? 'ring-2 ring-blue-500 ring-offset-2' : ''
}`}
>
</div>
))}
</div>
</div>
</div>
{/* Columns */}
<div className="space-y-4">
<div className="text-center">
<h3 className="text-lg font-semibold">Columns</h3>
{step > 0 && (
<p className="text-sm text-muted-foreground">Step: {step}</p>
)}
</div>
<div className="flex justify-center gap-1 flex-wrap max-w-full mx-auto">
{columns.map((column, index) => (
<div
key={index}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
onClick={(e) => handleColumnClick(e, index)}
className={`border rounded-lg min-h-[100px] w-12 p-1 bg-background/50 flex flex-col-reverse gap-1 transition-all duration-700 ease-in-out ${
selectedBoxForMove !== null ? 'border-blue-500 border-2 cursor-pointer hover:bg-blue-50' : 'border-muted-foreground'
} ${animationPhase === 'sorting' ? 'transform-gpu' : ''}`}
style={{ flexBasis: `calc(${100/15}% - 0.25rem)`, minWidth: '48px' }}
>
<div className="text-xs text-center text-muted-foreground mb-1 transform rotate-0">
{index + 1}
</div>
{column.map((boxId, boxIndex) => (
<div
key={boxId}
draggable={!isPlaying}
onDragStart={(e) => handleDragStart(e, boxId)}
onDoubleClick={() => handleDoubleClick(boxId)}
className={`w-8 h-8 rounded cursor-move transition-all duration-300 flex items-center justify-center text-xs font-medium mx-auto ${
highlightedBoxes.has(boxId) && animationPhase === 'fade-to-red'
? 'animate-fade-to-red shadow-lg text-white'
: highlightedBoxes.has(boxId) && (animationPhase === 'red-pause' || animationPhase === 'moving' || animationPhase === 'sorting')
? 'bg-red-500 text-white shadow-lg'
: highlightedBoxes.has(boxId) && animationPhase === 'fade-from-red'
? 'animate-fade-from-red shadow-lg'
: 'bg-primary text-primary-foreground hover:bg-primary/80'
}`}
title="Drag to move or double-click to move to last-used column"
>
</div>
))}
</div>
))}
</div>
</div>
{/* Play Button */}
<div className="flex justify-center">
<Button
onClick={isPlaying ? (isPaused ? handleResume : handlePause) : simulateBulgarianSolitaire}
disabled={!isPlaying && containerBoxes.length > 0}
size="lg"
className="px-12"
>
{isPlaying ? (isPaused ? 'Continue' : 'Pause') : 'Play'}
</Button>
</div>
{/* Status */}
{isComplete && (
<div className="text-center p-4 bg-success/10 border border-success/20 rounded-lg">
<h3 className="text-lg font-semibold text-success">Triangular Configuration Reached! 🎉</h3>
<p className="text-sm text-muted-foreground">
The Bulgarian Solitaire process converged to the stable triangular configuration in {step} steps.
</p>
</div>
)}
{/* Instructions */}
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-6 space-y-4">
<button
onClick={() => setShowInstructions(!showInstructions)}
className="text-lg font-semibold text-primary hover:underline cursor-pointer text-left"
>
Click here for instructions.
</button>
{showInstructions && (
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Choose a value for n (2-45) using the slider</li>
<li>Drag the {totalBoxes} boxes from the container into any of the columns</li>
<li>Drag boxes between columns to rearrange them as needed</li>
<li>Drag boxes back to the container or double-click any box in a column to move it to the last-used column</li>
<li>Once all boxes are placed in columns, click "Play" to start the simulation</li>
<li>Watch as the algorithm takes one box from each non-empty column to form a new column</li>
<li>Spoiler: For triangular numbers, the process continues until reaching the staircase configuration: columns of sizes 1, 2, 3, ..., n.</li>
</ul>
)}
</div>
{/* Credits */}
<div className="bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg p-6 space-y-4 flex flex-col justify-center">
<p className="text-sm text-muted-foreground">
Find out more about Bulgarian Solitaire on{" "}
<a href="https://en.wikipedia.org/wiki/Bulgarian_solitaire" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Wikipedia
</a>
. I learned of this game from{" "}
<a href="https://sites.google.com/view/kumarakash/home" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Akash Kumar
</a>
, who very kindly walked me through the argument found in{" "}
<a href="https://math.dartmouth.edu/~pw/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Peter Winkler's
</a>
{" "}beautiful puzzle book,{" "}
<a href="https://math.dartmouth.edu/news-resources/electronic/puzzlebook/book/book.pdf" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
available here
</a>
. The game was introduced by{" "}
<a href="https://en.wikipedia.org/wiki/Martin_Gardner" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Martin Gardner
</a>
.
</p>
</div>
{showSocialShare && (
<SocialShare
title="Bulgarian Solitaire - Interactive Mathematical Puzzle"
description={`Try this fascinating Bulgarian Solitaire puzzle! Watch as ${totalBoxes} boxes rearrange themselves into the perfect triangular configuration through mathematical iteration.`}
/>
)}
</div>
);
};
export default BulgarianSolitaire;