426 lines
No EOL
15 KiB
TypeScript
426 lines
No EOL
15 KiB
TypeScript
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;
|