Add Sunny Lines Puzzle - IMO 2025 P6 interactive with grid snapping and line extension
This commit is contained in:
parent
df2b2d6b9e
commit
2ccd2fec3a
8 changed files with 1212 additions and 7 deletions
|
|
@ -44,6 +44,8 @@ import Structures from "./pages/theme-pages/discrete-math/Structures";
|
|||
import Numbers from "./pages/theme-pages/discrete-math/Numbers";
|
||||
import Graphs from "./pages/theme-pages/discrete-math/Graphs";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import GridTilingPuzzlePage from './pages/GridTilingPuzzlePage';
|
||||
import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
|
@ -77,6 +79,8 @@ const App = () => (
|
|||
<Route path="/themes/puzzles" element={<Puzzles />} />
|
||||
<Route path="/themes/miscellany" element={<Miscellany />} />
|
||||
<Route path="/themes/contest-problems" element={<ContestProblems />} />
|
||||
<Route path="/contest-problems/grid-tiling" element={<GridTilingPuzzlePage />} />
|
||||
<Route path="/contest-problems/sunny-lines" element={<SunnyLinesPuzzlePage />} />
|
||||
|
||||
{/* Direct interactive routes */}
|
||||
<Route path="/binary-number-game" element={<BinaryNumberGamePage />} />
|
||||
|
|
|
|||
416
src/components/GridTilingPuzzle.tsx
Normal file
416
src/components/GridTilingPuzzle.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
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';
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridTilingPuzzle;
|
||||
|
|
@ -153,6 +153,24 @@ const allInteractives: Interactive[] = [
|
|||
path: '/games/nim',
|
||||
theme: 'Games',
|
||||
dateAdded: '2024-07-21'
|
||||
},
|
||||
{
|
||||
id: 'grid-tiling-puzzle',
|
||||
title: 'Grid Tiling Puzzle',
|
||||
description: 'Based on IMO 2025: Place rectangular tiles on a grid so that each row and column has exactly one uncovered square.',
|
||||
tags: ['puzzle', 'geometry', 'optimization', 'imo', 'contest-problems'],
|
||||
path: '/contest-problems/grid-tiling',
|
||||
theme: 'Contest Problems',
|
||||
dateAdded: '2024-12-22'
|
||||
},
|
||||
{
|
||||
id: 'sunny-lines-puzzle',
|
||||
title: 'Sunny Lines Puzzle',
|
||||
description: 'Based on IMO 2025 P6: Place lines to cover points in a triangle. A line is "sunny" if it\'s not parallel to x-axis, y-axis, or x+y=0.',
|
||||
tags: ['puzzle', 'geometry', 'lines', 'imo', 'contest-problems'],
|
||||
path: '/contest-problems/sunny-lines',
|
||||
theme: 'Contest Problems',
|
||||
dateAdded: '2024-12-22'
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
605
src/components/SunnyLinesPuzzle.tsx
Normal file
605
src/components/SunnyLinesPuzzle.tsx
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
import React, { useState, useCallback, useRef } 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, Undo2 } from 'lucide-react';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
id: number;
|
||||
start: Point;
|
||||
end: Point;
|
||||
isSunny: boolean;
|
||||
}
|
||||
|
||||
interface SunnyLinesPuzzleProps {
|
||||
showSocialShare?: boolean;
|
||||
}
|
||||
|
||||
const SunnyLinesPuzzle: React.FC<SunnyLinesPuzzleProps> = ({ showSocialShare = true }) => {
|
||||
const [n, setN] = useState<number>(5);
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [startPoint, setStartPoint] = useState<Point | null>(null);
|
||||
const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const gridSize = n + 2;
|
||||
const canvasSize = 600;
|
||||
const cellSize = canvasSize / gridSize;
|
||||
|
||||
// Check if a line is sunny (not parallel to x-axis, y-axis, or x+y=0)
|
||||
const isSunnyLine = (start: Point, end: Point): boolean => {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
|
||||
if (dx === 0) return false; // Parallel to y-axis
|
||||
if (dy === 0) return false; // Parallel to x-axis
|
||||
if (dx === -dy) return false; // Parallel to x+y=0 (slope = -1)
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Convert canvas coordinates to grid coordinates
|
||||
const canvasToGrid = (canvasX: number, canvasY: number): Point => {
|
||||
const x = canvasX / cellSize;
|
||||
const y = (canvasSize - canvasY) / cellSize; // Flip Y axis
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// Convert grid coordinates to canvas coordinates
|
||||
const gridToCanvas = (gridX: number, gridY: number): Point => {
|
||||
const x = gridX * cellSize;
|
||||
const y = canvasSize - (gridY * cellSize); // Flip Y axis
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// Snap a point to the nearest grid point or midpoint
|
||||
const snapToGrid = (point: Point): Point => {
|
||||
return {
|
||||
x: Math.round(point.x * 2) / 2, // Snap to 0, 0.5, 1, 1.5, 2, etc.
|
||||
y: Math.round(point.y * 2) / 2 // Snap to 0, 0.5, 1, 1.5, 2, etc.
|
||||
};
|
||||
};
|
||||
|
||||
// Extend a line to grid boundaries
|
||||
const extendLineToBoundaries = (start: Point, end: Point): { start: Point; end: Point } => {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
|
||||
if (dx === 0) {
|
||||
// Vertical line - extend to top and bottom boundaries
|
||||
return {
|
||||
start: { x: start.x, y: 0 },
|
||||
end: { x: end.x, y: gridSize }
|
||||
};
|
||||
}
|
||||
|
||||
if (dy === 0) {
|
||||
// Horizontal line - extend to left and right boundaries
|
||||
return {
|
||||
start: { x: 0, y: start.y },
|
||||
end: { x: gridSize, y: end.y }
|
||||
};
|
||||
}
|
||||
|
||||
// Diagonal line - extend to grid boundaries
|
||||
const slope = dy / dx;
|
||||
|
||||
// Find intersection with left boundary (x = 0)
|
||||
const leftY = start.y - slope * start.x;
|
||||
const leftPoint = { x: 0, y: leftY };
|
||||
|
||||
// Find intersection with right boundary (x = gridSize)
|
||||
const rightY = start.y + slope * (gridSize - start.x);
|
||||
const rightPoint = { x: gridSize, y: rightY };
|
||||
|
||||
// Find intersection with bottom boundary (y = 0)
|
||||
const bottomX = start.x - start.y / slope;
|
||||
const bottomPoint = { x: bottomX, y: 0 };
|
||||
|
||||
// Find intersection with top boundary (y = gridSize)
|
||||
const topX = start.x + (gridSize - start.y) / slope;
|
||||
const topPoint = { x: topX, y: gridSize };
|
||||
|
||||
// Find the two boundary points that are actually on the grid
|
||||
const boundaryPoints = [];
|
||||
|
||||
if (leftY >= 0 && leftY <= gridSize) boundaryPoints.push(leftPoint);
|
||||
if (rightY >= 0 && rightY <= gridSize) boundaryPoints.push(rightPoint);
|
||||
if (bottomX >= 0 && bottomX <= gridSize) boundaryPoints.push(bottomPoint);
|
||||
if (topX >= 0 && topX <= gridSize) boundaryPoints.push(topPoint);
|
||||
|
||||
// Return the two points that are furthest apart
|
||||
if (boundaryPoints.length >= 2) {
|
||||
let maxDistance = 0;
|
||||
let bestPair = { start: boundaryPoints[0], end: boundaryPoints[1] };
|
||||
|
||||
for (let i = 0; i < boundaryPoints.length; i++) {
|
||||
for (let j = i + 1; j < boundaryPoints.length; j++) {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(boundaryPoints[i].x - boundaryPoints[j].x, 2) +
|
||||
Math.pow(boundaryPoints[i].y - boundaryPoints[j].y, 2)
|
||||
);
|
||||
if (dist > maxDistance) {
|
||||
maxDistance = dist;
|
||||
bestPair = { start: boundaryPoints[i], end: boundaryPoints[j] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestPair;
|
||||
}
|
||||
|
||||
// Fallback to original points if boundary calculation fails
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
// Check if a point (a,b) is in the triangle a+b <= n+1
|
||||
const isInTriangle = (a: number, b: number): boolean => {
|
||||
return a >= 1 && b >= 1 && a + b <= n + 1;
|
||||
};
|
||||
|
||||
// Check if a point is covered by any line
|
||||
const isPointCovered = (a: number, b: number): boolean => {
|
||||
return lines.some(line => {
|
||||
const dx = line.end.x - line.start.x;
|
||||
const dy = line.end.y - line.start.y;
|
||||
|
||||
// Check if point (a,b) lies on the line
|
||||
const t1 = (a - line.start.x) / dx;
|
||||
const t2 = (b - line.start.y) / dy;
|
||||
|
||||
// If line is vertical or horizontal, check differently
|
||||
if (dx === 0) {
|
||||
return Math.abs(a - line.start.x) < 0.01;
|
||||
}
|
||||
if (dy === 0) {
|
||||
return Math.abs(b - line.start.y) < 0.01;
|
||||
}
|
||||
|
||||
return Math.abs(t1 - t2) < 0.01 && t1 >= 0 && t1 <= 1;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasClick = (event: React.MouseEvent) => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const canvasX = event.clientX - rect.left;
|
||||
const canvasY = event.clientY - rect.top;
|
||||
const gridPoint = snapToGrid(canvasToGrid(canvasX, canvasY));
|
||||
|
||||
if (!isDrawing) {
|
||||
setStartPoint(gridPoint);
|
||||
setIsDrawing(true);
|
||||
} else {
|
||||
if (startPoint) {
|
||||
const extendedLine = extendLineToBoundaries(startPoint, gridPoint);
|
||||
const newLine: Line = {
|
||||
id: Date.now(),
|
||||
start: extendedLine.start,
|
||||
end: extendedLine.end,
|
||||
isSunny: isSunnyLine(extendedLine.start, extendedLine.end)
|
||||
};
|
||||
|
||||
setLines(prev => [...prev, newLine]);
|
||||
setIsDrawing(false);
|
||||
setStartPoint(null);
|
||||
setHoverPoint(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (event: React.MouseEvent) => {
|
||||
if (!canvasRef.current || !isDrawing) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const canvasX = event.clientX - rect.left;
|
||||
const canvasY = event.clientY - rect.top;
|
||||
const gridPoint = snapToGrid(canvasToGrid(canvasX, canvasY));
|
||||
|
||||
setHoverPoint(gridPoint);
|
||||
};
|
||||
|
||||
const handleCanvasMouseLeave = () => {
|
||||
setHoverPoint(null);
|
||||
};
|
||||
|
||||
const resetPuzzle = () => {
|
||||
setLines([]);
|
||||
setIsDrawing(false);
|
||||
setStartPoint(null);
|
||||
setHoverPoint(null);
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const undoLastLine = () => {
|
||||
setLines(prev => prev.slice(0, -1));
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const calculateResults = () => {
|
||||
setShowResults(true);
|
||||
};
|
||||
|
||||
const getLineColor = (line: Line): string => {
|
||||
return line.isSunny ? '#10b981' : '#ef4444';
|
||||
};
|
||||
|
||||
const renderGrid = () => {
|
||||
const cells = [];
|
||||
|
||||
// Render grid cells for reference - include both integer points and midpoints
|
||||
for (let x = 0; x <= gridSize * 2; x++) {
|
||||
for (let y = 0; y <= gridSize * 2; y++) {
|
||||
const gridX = x / 2;
|
||||
const gridY = y / 2;
|
||||
const canvasPoint = gridToCanvas(gridX, gridY);
|
||||
|
||||
// Make integer points slightly larger and midpoints smaller
|
||||
const isIntegerPoint = x % 2 === 0 && y % 2 === 0;
|
||||
const size = isIntegerPoint ? 2 : 1;
|
||||
const opacity = isIntegerPoint ? 0.8 : 0.4;
|
||||
|
||||
cells.push(
|
||||
<div
|
||||
key={`cell-${x}-${y}`}
|
||||
className="absolute border border-gray-200"
|
||||
style={{
|
||||
left: canvasPoint.x - size,
|
||||
top: canvasPoint.y - size,
|
||||
width: size * 2,
|
||||
height: size * 2,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `rgba(243, 244, 246, ${opacity})`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
};
|
||||
|
||||
const renderTriangle = () => {
|
||||
const trianglePoints = [];
|
||||
|
||||
// Render blue dots at integer lattice points where a + b <= n + 1
|
||||
for (let a = 1; a <= n; a++) {
|
||||
for (let b = 1; b <= n; b++) {
|
||||
if (a + b <= n + 1) {
|
||||
const canvasPoint = gridToCanvas(a, b);
|
||||
trianglePoints.push(
|
||||
<circle
|
||||
key={`triangle-${a}-${b}`}
|
||||
cx={canvasPoint.x}
|
||||
cy={canvasPoint.y}
|
||||
r="4"
|
||||
fill="rgba(59, 130, 246, 0.8)"
|
||||
stroke="rgba(59, 130, 246, 1)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trianglePoints;
|
||||
};
|
||||
|
||||
const renderCoveredPoints = () => {
|
||||
const coveredPoints = [];
|
||||
|
||||
// Check only integer lattice points in the triangle for coverage
|
||||
for (let a = 1; a <= n; a++) {
|
||||
for (let b = 1; b <= n; b++) {
|
||||
if (a + b <= n + 1 && isPointCovered(a, b)) {
|
||||
const canvasPoint = gridToCanvas(a, b);
|
||||
coveredPoints.push(
|
||||
<circle
|
||||
key={`covered-${a}-${b}`}
|
||||
cx={canvasPoint.x}
|
||||
cy={canvasPoint.y}
|
||||
r="4"
|
||||
fill="rgba(34, 197, 94, 0.8)"
|
||||
stroke="rgba(34, 197, 94, 1)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coveredPoints;
|
||||
};
|
||||
|
||||
const renderLines = () => {
|
||||
return lines.map(line => {
|
||||
const startCanvas = gridToCanvas(line.start.x, line.start.y);
|
||||
const endCanvas = gridToCanvas(line.end.x, line.end.y);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={line.id}
|
||||
x1={startCanvas.x}
|
||||
y1={startCanvas.y}
|
||||
x2={endCanvas.x}
|
||||
y2={endCanvas.y}
|
||||
stroke={getLineColor(line)}
|
||||
strokeWidth={3}
|
||||
strokeDasharray={line.isSunny ? "none" : "5,5"}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderPreviewLine = () => {
|
||||
if (!isDrawing || !startPoint || !hoverPoint) return null;
|
||||
|
||||
const extendedLine = extendLineToBoundaries(startPoint, hoverPoint);
|
||||
const startCanvas = gridToCanvas(extendedLine.start.x, extendedLine.start.y);
|
||||
const endCanvas = gridToCanvas(extendedLine.end.x, extendedLine.end.y);
|
||||
|
||||
const isSunny = isSunnyLine(extendedLine.start, extendedLine.end);
|
||||
|
||||
return (
|
||||
<line
|
||||
x1={startCanvas.x}
|
||||
y1={startCanvas.y}
|
||||
x2={endCanvas.x}
|
||||
y2={endCanvas.y}
|
||||
stroke={isSunny ? '#10b981' : '#ef4444'}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="10,5"
|
||||
opacity={0.7}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const countSunnyLines = () => lines.filter(line => line.isSunny).length;
|
||||
const countNonSunnyLines = () => lines.filter(line => !line.isSunny).length;
|
||||
|
||||
const allTrianglePointsCovered = () => {
|
||||
// Check only integer lattice points where a + b <= n + 1
|
||||
for (let a = 1; a <= n; a++) {
|
||||
for (let b = 1; b <= n; b++) {
|
||||
if (a + b <= n + 1 && !isPointCovered(a, b)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
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">
|
||||
Sunny Lines Puzzle
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Based on IMO 2025 Problem P6
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Place lines to cover all blue dots in the triangle where a + b ≤ n + 1.
|
||||
A line is "sunny" if it's not parallel to the x-axis, y-axis, or x + y = 0.
|
||||
</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">n = {n}</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-muted-foreground">3</span>
|
||||
<input
|
||||
type="range"
|
||||
min="3"
|
||||
max="10"
|
||||
value={n}
|
||||
onChange={(e) => {
|
||||
setN(parseInt(e.target.value));
|
||||
resetPuzzle();
|
||||
}}
|
||||
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={resetPuzzle} variant="outline" size="sm">
|
||||
Reset Puzzle
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={undoLastLine}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={lines.length === 0}
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Lines: {lines.length}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Sunny: {countSunnyLines()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
Non-sunny: {countNonSunnyLines()}
|
||||
</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 anywhere to start drawing a line</li>
|
||||
<li>Click again to complete the line</li>
|
||||
<li>Cover all blue dots in the triangle (a + b ≤ n + 1)</li>
|
||||
<li>Green lines are "sunny" (not parallel to x-axis, y-axis, or x + y = 0)</li>
|
||||
<li>Red dashed lines are non-sunny</li>
|
||||
<li>Try to minimize the number of lines used</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="w-4 h-4" />
|
||||
<span>Legend: Blue dots = Points to cover, Green dots = Covered points, Green lines = Sunny, Red dashed = Non-sunny</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Canvas */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative border-2 border-gray-400 cursor-crosshair"
|
||||
style={{ width: canvasSize, height: canvasSize }}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseLeave={handleCanvasMouseLeave}
|
||||
>
|
||||
<svg
|
||||
width={canvasSize}
|
||||
height={canvasSize}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{renderLines()}
|
||||
{renderPreviewLine()}
|
||||
{renderTriangle()}
|
||||
{renderCoveredPoints()}
|
||||
</svg>
|
||||
{renderGrid()}
|
||||
|
||||
{/* Coordinate labels */}
|
||||
<div className="absolute bottom-0 left-0 text-xs text-gray-600">
|
||||
(0,0)
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 text-xs text-gray-600">
|
||||
({gridSize},{gridSize})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calculate Results */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={calculateResults}
|
||||
disabled={lines.length === 0}
|
||||
size="lg"
|
||||
className="px-8"
|
||||
>
|
||||
Calculate Results
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{showResults && (
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-800">
|
||||
Results for n = {n}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
k = {countSunnyLines()} (number of sunny lines)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-800">Coverage Status:</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
{allTrianglePointsCovered() ?
|
||||
"✅ All triangle points are covered" :
|
||||
"❌ Some triangle points are not covered"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-800">Line Summary:</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
Total: {lines.length} | Sunny: {countSunnyLines()} | Non-sunny: {countNonSunnyLines()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Line List */}
|
||||
{lines.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Placed Lines</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{lines.map((line, index) => (
|
||||
<div key={line.id} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Line {index + 1}:</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
({line.start.x.toFixed(1)}, {line.start.y.toFixed(1)}) to ({line.end.x.toFixed(1)}, {line.end.y.toFixed(1)})
|
||||
</span>
|
||||
<br />
|
||||
<span className={`text-xs ${line.isSunny ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{line.isSunny ? '☀️ Sunny' : '🌧️ Non-sunny'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SunnyLinesPuzzle;
|
||||
|
|
@ -132,3 +132,38 @@ All colors MUST be HSL.
|
|||
@apply font-heading;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom slider styling */
|
||||
.slider-special::-webkit-slider-thumb,
|
||||
.slider-normal::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-special::-webkit-slider-thumb {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.slider-normal::-webkit-slider-thumb {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.slider-special::-moz-range-thumb,
|
||||
.slider-normal::-moz-range-thumb {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-special::-moz-range-thumb {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.slider-normal::-moz-range-thumb {
|
||||
background: #6b7280;
|
||||
}
|
||||
11
src/pages/GridTilingPuzzlePage.tsx
Normal file
11
src/pages/GridTilingPuzzlePage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import GridTilingPuzzle from '@/components/GridTilingPuzzle';
|
||||
|
||||
const GridTilingPuzzlePage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<GridTilingPuzzle showSocialShare={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridTilingPuzzlePage;
|
||||
12
src/pages/SunnyLinesPuzzlePage.tsx
Normal file
12
src/pages/SunnyLinesPuzzlePage.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import SunnyLinesPuzzle from '@/components/SunnyLinesPuzzle';
|
||||
|
||||
const SunnyLinesPuzzlePage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<SunnyLinesPuzzle showSocialShare={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SunnyLinesPuzzlePage;
|
||||
|
|
@ -1,16 +1,120 @@
|
|||
import ComingSoon from "@/components/ComingSoon";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
import Layout from "@/components/Layout";
|
||||
import InteractiveCard from "@/components/InteractiveCard";
|
||||
import GridTilingPuzzle from "@/components/GridTilingPuzzle";
|
||||
import SunnyLinesPuzzle from "@/components/SunnyLinesPuzzle";
|
||||
|
||||
interface Interactive {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
component: React.ComponentType;
|
||||
greenScreenPath: string;
|
||||
}
|
||||
|
||||
const interactives: Interactive[] = [
|
||||
{
|
||||
id: "grid-tiling-puzzle",
|
||||
title: "Grid Tiling Puzzle",
|
||||
description: "Based on IMO 2025: Place rectangular tiles on a grid so that each row and column has exactly one uncovered square.",
|
||||
tags: ["puzzle", "geometry", "optimization", "imo", "contest-problems"],
|
||||
component: GridTilingPuzzle,
|
||||
greenScreenPath: "/contest-problems/grid-tiling"
|
||||
},
|
||||
{
|
||||
id: "sunny-lines-puzzle",
|
||||
title: "Sunny Lines Puzzle",
|
||||
description: "Based on IMO 2025 P6: Place lines to cover points in a triangle. A line is 'sunny' if it's not parallel to x-axis, y-axis, or x+y=0.",
|
||||
tags: ["puzzle", "geometry", "lines", "imo", "contest-problems"],
|
||||
component: SunnyLinesPuzzle,
|
||||
greenScreenPath: "/contest-problems/sunny-lines"
|
||||
}
|
||||
];
|
||||
|
||||
const ContestProblems = () => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedInteractive, setSelectedInteractive] = useState<Interactive | null>(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 (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setSelectedInteractive(null)}
|
||||
className="text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
← Back to Contest Problems
|
||||
</button>
|
||||
|
||||
<Link to="/themes" className="text-primary hover:text-primary/80 font-medium">
|
||||
Back to Themes →
|
||||
</Link>
|
||||
</div>
|
||||
<InteractiveComponent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ComingSoon
|
||||
title="Contest Problems"
|
||||
description="Practice with problems from programming competitions, mathematical olympiads, and algorithmic challenges with interactive solutions and explanations."
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/themes" className="text-primary hover:text-primary/80 font-medium">
|
||||
← Back to Themes
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground">Contest Problems</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Practice with problems from programming competitions, mathematical olympiads, and algorithmic challenges with interactive solutions and explanations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search interactives..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-outline rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredInteractives.map(interactive => (
|
||||
<InteractiveCard
|
||||
key={interactive.id}
|
||||
title={interactive.title}
|
||||
description={interactive.description}
|
||||
tags={interactive.tags}
|
||||
onClick={() => setSelectedInteractive(interactive)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredInteractives.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
No interactives found matching "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue