interactives/src/components/GridTilingPuzzle.tsx

426 lines
No EOL
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle, XCircle, Info, ChevronDown, ChevronUp } from 'lucide-react';
import SocialShare from '@/components/SocialShare';
interface Rectangle {
id: number;
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
interface GridTilingPuzzleProps {
showSocialShare?: boolean;
}
const GridTilingPuzzle: React.FC<GridTilingPuzzleProps> = ({ showSocialShare = true }) => {
const [gridSize, setGridSize] = useState<number>(9);
const [rectangles, setRectangles] = useState<Rectangle[]>([]);
const [isDrawing, setIsDrawing] = useState(false);
const [startCell, setStartCell] = useState<{ row: number; col: number } | null>(null);
const [hoverCell, setHoverCell] = useState<{ row: number; col: number } | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
const [validationResult, setValidationResult] = useState<{
isValid: boolean;
message: string;
uncoveredCount: number;
} | null>(null);
const specialSizes = [4, 9, 25];
// Different shades of blue for rectangles - starting very dark and getting lighter
const blueShades = [
'bg-blue-900',
'bg-blue-800',
'bg-blue-700',
'bg-blue-600',
'bg-blue-500',
'bg-blue-400',
'bg-blue-300',
'bg-blue-200'
];
const resetPuzzle = () => {
setRectangles([]);
setIsDrawing(false);
setStartCell(null);
setHoverCell(null);
setIsValidating(false);
setValidationResult(null);
};
const isCellCovered = (row: number, col: number): boolean => {
return rectangles.some(rect =>
row >= rect.startRow && row <= rect.endRow &&
col >= rect.startCol && col <= rect.endCol
);
};
const isCellInRectangle = (row: number, col: number, rect: Rectangle): boolean => {
return row >= rect.startRow && row <= rect.endRow &&
col >= rect.startCol && col <= rect.endCol;
};
const getCellClass = (row: number, col: number): string => {
let classes = 'border border-gray-300 cursor-pointer transition-colors';
// Check if cell is covered by any rectangle
const coveredRect = rectangles.find(rect => isCellInRectangle(row, col, rect));
if (coveredRect) {
const rectIndex = rectangles.findIndex(rect => rect.id === coveredRect.id);
const blueShade = blueShades[rectIndex % blueShades.length];
classes += ` ${blueShade} hover:${blueShade.replace('bg-', 'bg-').replace('-', '-')}`;
return classes;
}
// Check if this is the start cell
if (startCell && startCell.row === row && startCell.col === col) {
classes += ' bg-green-500 hover:bg-green-600';
return classes;
}
// Check if this would be a valid completion (no overlap)
if (startCell && hoverCell && !isCellCovered(row, col)) {
const minRow = Math.min(startCell.row, hoverCell.row);
const maxRow = Math.max(startCell.row, hoverCell.row);
const minCol = Math.min(startCell.col, hoverCell.col);
const maxCol = Math.max(startCell.col, hoverCell.col);
if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol) {
// Check if this preview rectangle would overlap with any existing rectangles
const wouldOverlap = rectangles.some(existingRect => {
return !(maxRow < existingRect.startRow ||
minRow > existingRect.endRow ||
maxCol < existingRect.startCol ||
minCol > existingRect.endCol);
});
if (!wouldOverlap) {
classes += ' bg-yellow-300 hover:bg-yellow-400';
return classes;
}
}
}
classes += ' bg-white hover:bg-gray-100';
return classes;
};
const handleCellClick = (row: number, col: number) => {
if (isCellCovered(row, col)) return;
if (!isDrawing) {
// Start drawing a rectangle
setIsDrawing(true);
setStartCell({ row, col });
} else {
// Complete the rectangle
if (startCell) {
const newRect: Rectangle = {
id: Date.now(),
startRow: Math.min(startCell.row, row),
startCol: Math.min(startCell.col, col),
endRow: Math.max(startCell.row, row),
endCol: Math.max(startCell.col, col)
};
// Check if the new rectangle would overlap with any existing rectangles
const wouldOverlap = rectangles.some(existingRect => {
return !(newRect.endRow < existingRect.startRow ||
newRect.startRow > existingRect.endRow ||
newRect.endCol < existingRect.startCol ||
newRect.startCol > existingRect.endCol);
});
if (wouldOverlap) {
// Don't place the rectangle if it would overlap
setIsDrawing(false);
setStartCell(null);
setHoverCell(null);
return;
}
setRectangles(prev => [...prev, newRect]);
setIsDrawing(false);
setStartCell(null);
setHoverCell(null);
}
}
};
const handleCellHover = (row: number, col: number) => {
if (isDrawing && startCell) {
setHoverCell({ row, col });
}
};
const handleCellLeave = () => {
setHoverCell(null);
};
const validateSolution = () => {
setIsValidating(true);
// Check if each row and column has exactly one uncovered square
let uncoveredCount = 0;
const rowUncovered = new Array(gridSize).fill(0);
const colUncovered = new Array(gridSize).fill(0);
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
if (!isCellCovered(row, col)) {
uncoveredCount++;
rowUncovered[row]++;
colUncovered[col]++;
}
}
}
const isValid = rowUncovered.every(count => count === 1) &&
colUncovered.every(count => count === 1);
let message = '';
if (isValid) {
message = `Perfect! Your solution is valid. You used ${rectangles.length} rectangles.`;
} else {
const invalidRows = rowUncovered.map((count, i) => count !== 1 ? i + 1 : null).filter(Boolean);
const invalidCols = colUncovered.map((count, i) => count !== 1 ? i + 1 : null).filter(Boolean);
message = `Invalid solution. Rows ${invalidRows.join(', ')} and columns ${invalidCols.join(', ')} don't have exactly one uncovered square.`;
}
setValidationResult({ isValid, message, uncoveredCount });
setIsValidating(false);
};
const removeRectangle = (rectId: number) => {
setRectangles(prev => prev.filter(rect => rect.id !== rectId));
setValidationResult(null);
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<Card className="bg-gradient-to-r from-primary/10 to-secondary/10 border-primary/20">
<CardHeader className="text-center">
<CardTitle className="text-3xl font-bold bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
Grid Tiling Puzzle
</CardTitle>
<p className="text-muted-foreground mt-2">
This is P6 on IMO 2025. The given grid size was 2025 in the original problem.
</p>
</CardHeader>
<CardContent>
<div className="text-center space-y-4">
<p className="text-lg text-muted-foreground">
Place rectangular tiles on the grid so that each row and each column has exactly one uncovered square. What's the smallest number of rectangles you can use?
</p>
<div className="flex flex-wrap justify-center gap-4 items-center">
<div className="flex items-center space-x-4">
<label className="text-sm font-medium">Grid Size: {gridSize}×{gridSize}</label>
<div className="flex items-center space-x-2">
<span className="text-xs text-muted-foreground">3</span>
<input
type="range"
min="3"
max="25"
value={gridSize}
onChange={(e) => {
setGridSize(parseInt(e.target.value));
resetPuzzle();
}}
className={`w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${
specialSizes.includes(gridSize) ? 'slider-special' : 'slider-normal'
}`}
style={{
'--thumb-color': specialSizes.includes(gridSize) ? '#10b981' : '#6b7280'
} as React.CSSProperties}
/>
<span className="text-xs text-muted-foreground">25</span>
</div>
</div>
<Button onClick={resetPuzzle} variant="outline" size="sm">
Reset Puzzle
</Button>
<Button
onClick={() => {
setRectangles(prev => prev.slice(0, -1));
setValidationResult(null);
}}
variant="outline"
size="sm"
disabled={rectangles.length === 0}
>
Undo Last Move
</Button>
</div>
<div className="flex justify-center items-center gap-4">
<Badge variant="outline" className="text-sm">
Rectangles: {rectangles.length}
</Badge>
<Badge
variant={specialSizes.includes(gridSize) ? "default" : "outline"}
className={`text-sm ${specialSizes.includes(gridSize) ? 'bg-green-500 hover:bg-green-600' : ''}`}
>
Grid: {gridSize}×{gridSize}
{specialSizes.includes(gridSize) && <span className="ml-1">⭐</span>}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Instructions */}
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowInstructions(!showInstructions)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Instructions</CardTitle>
{showInstructions ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</CardHeader>
{showInstructions && (
<CardContent className="pt-0">
<div className="space-y-4">
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Click on any empty square to start drawing a rectangle</li>
<li>Click on another square to complete the rectangle</li>
<li>Each row and column must have exactly one uncovered square</li>
<li>Rectangles cannot overlap</li>
<li>Try to use the minimum number of rectangles possible</li>
</ul>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Info className="w-4 h-4" />
<span>Legend: Green = Start, Yellow = Preview, Blue shades = Placed Rectangles</span>
</div>
</div>
</CardContent>
)}
</Card>
{/* Grid */}
<Card>
<CardContent className="p-6">
<div className="flex justify-center">
<div
className="grid gap-0 border-2 border-gray-400"
style={{
gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`,
width: `${Math.min(600, 50 * gridSize)}px`,
height: `${Math.min(600, 50 * gridSize)}px`
}}
>
{Array.from({ length: gridSize * gridSize }, (_, index) => {
const row = Math.floor(index / gridSize);
const col = index % gridSize;
return (
<div
key={index}
className={getCellClass(row, col)}
style={{ width: '100%', height: '100%' }}
onClick={() => handleCellClick(row, col)}
onMouseEnter={() => handleCellHover(row, col)}
onMouseLeave={handleCellLeave}
/>
);
})}
</div>
</div>
</CardContent>
</Card>
{/* Validation */}
<div className="flex justify-center">
<Button
onClick={validateSolution}
disabled={isValidating || rectangles.length === 0}
size="lg"
className="px-8"
>
{isValidating ? 'Validating...' : 'Validate Solution'}
</Button>
</div>
{/* Validation Result */}
{validationResult && (
<Card className={validationResult.isValid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}>
<CardContent className="p-6">
<div className="flex items-center gap-3">
{validationResult.isValid ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<XCircle className="w-6 h-6 text-red-600" />
)}
<div>
<p className={`font-medium ${validationResult.isValid ? 'text-green-800' : 'text-red-800'}`}>
{validationResult.message}
</p>
<p className="text-sm text-muted-foreground mt-1">
Total uncovered squares: {validationResult.uncoveredCount}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Rectangle List */}
{rectangles.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Placed Rectangles</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{rectangles.map((rect, index) => (
<div key={rect.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="text-sm">
<span className="font-medium">Rectangle {index + 1}:</span>
<br />
<span className="text-muted-foreground">
({rect.startRow + 1},{rect.startCol + 1}) to ({rect.endRow + 1},{rect.endCol + 1})
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeRectangle(rect.id)}
className="text-red-600 hover:text-red-700"
>
Remove
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Social Share */}
{showSocialShare && (
<SocialShare
title="Grid Tiling Puzzle - IMO 2025"
description="Place rectangular tiles on a grid so that each row and column has exactly one uncovered square. Based on IMO 2025 Problem P6."
url="/contest-problems/grid-tiling"
/>
)}
</div>
);
};
export default GridTilingPuzzle;