interactives/src/components/SunnyLinesPuzzle.tsx

605 lines
No EOL
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;