233 lines
9.7 KiB
TypeScript
233 lines
9.7 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
|
|
const SPECIAL_ANGLES = [0, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330, 360];
|
|
|
|
const UnitCircleWidget: React.FC = () => {
|
|
const [angle, setAngle] = useState(45); // Degrees
|
|
const [snap, setSnap] = useState(true); // Snap to special angles
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const isDragging = useRef(false);
|
|
|
|
const radius = 140;
|
|
const center = { x: 200, y: 200 };
|
|
|
|
const handleInteraction = (clientX: number, clientY: number) => {
|
|
if (!svgRef.current) return;
|
|
const rect = svgRef.current.getBoundingClientRect();
|
|
const dx = clientX - rect.left - center.x;
|
|
const dy = clientY - rect.top - center.y;
|
|
|
|
// Calculate angle from 0 to 360
|
|
let rad = Math.atan2(-dy, dx);
|
|
if (rad < 0) rad += 2 * Math.PI;
|
|
|
|
let deg = (rad * 180) / Math.PI;
|
|
|
|
if (snap) {
|
|
const nearest = SPECIAL_ANGLES.reduce((prev, curr) =>
|
|
Math.abs(curr - deg) < Math.abs(prev - deg) ? curr : prev
|
|
);
|
|
if (Math.abs(nearest - deg) < 15) {
|
|
deg = nearest;
|
|
}
|
|
}
|
|
|
|
if (deg > 360) deg = 360;
|
|
setAngle(Math.round(deg));
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
isDragging.current = true;
|
|
handleInteraction(e.clientX, e.clientY);
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (isDragging.current) {
|
|
handleInteraction(e.clientX, e.clientY);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
isDragging.current = false;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const upHandler = () => isDragging.current = false;
|
|
window.addEventListener('mouseup', upHandler);
|
|
return () => window.removeEventListener('mouseup', upHandler);
|
|
}, []);
|
|
|
|
const rad = (angle * Math.PI) / 180;
|
|
const x = Math.cos(rad);
|
|
const y = Math.sin(rad);
|
|
|
|
const px = center.x + radius * x;
|
|
const py = center.y - radius * y;
|
|
|
|
const getExactValue = (val: number) => {
|
|
if (Math.abs(val) < 0.01) return "0";
|
|
if (Math.abs(val - 0.5) < 0.01) return "1/2";
|
|
if (Math.abs(val + 0.5) < 0.01) return "-1/2";
|
|
if (Math.abs(val - Math.sqrt(2)/2) < 0.01) return "√2/2";
|
|
if (Math.abs(val + Math.sqrt(2)/2) < 0.01) return "-√2/2";
|
|
if (Math.abs(val - Math.sqrt(3)/2) < 0.01) return "√3/2";
|
|
if (Math.abs(val + Math.sqrt(3)/2) < 0.01) return "-√3/2";
|
|
if (Math.abs(val - 1) < 0.01) return "1";
|
|
if (Math.abs(val + 1) < 0.01) return "-1";
|
|
return val.toFixed(3);
|
|
};
|
|
|
|
const getRadianLabel = (deg: number) => {
|
|
// Removed Record type annotation to prevent parsing error
|
|
const map: any = {
|
|
0: "0", 30: "π/6", 45: "π/4", 60: "π/3", 90: "π/2",
|
|
120: "2π/3", 135: "3π/4", 150: "5π/6", 180: "π",
|
|
210: "7π/6", 225: "5π/4", 240: "4π/3", 270: "3π/2",
|
|
300: "5π/3", 315: "7π/4", 330: "11π/6", 360: "2π"
|
|
};
|
|
if (map[deg]) return map[deg];
|
|
return ((deg * Math.PI) / 180).toFixed(2);
|
|
};
|
|
|
|
const cosStr = getExactValue(x);
|
|
const sinStr = getExactValue(y);
|
|
|
|
const getAngleColor = () => {
|
|
if (angle < 90) return "text-emerald-600";
|
|
if (angle < 180) return "text-indigo-600";
|
|
if (angle < 270) return "text-amber-600";
|
|
return "text-rose-600";
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col md:flex-row gap-8 select-none">
|
|
|
|
<div className="flex-shrink-0 flex flex-col items-center">
|
|
<svg
|
|
ref={svgRef}
|
|
width="400"
|
|
height="400"
|
|
className="cursor-pointer touch-none"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
>
|
|
<line x1="200" y1="20" x2="200" y2="380" stroke="#f1f5f9" strokeWidth="1" />
|
|
<line x1="20" y1="200" x2="380" y2="200" stroke="#f1f5f9" strokeWidth="1" />
|
|
<line x1="200" y1="40" x2="200" y2="360" stroke="#cbd5e1" strokeWidth="1" />
|
|
<line x1="40" y1="200" x2="360" y2="200" stroke="#cbd5e1" strokeWidth="1" />
|
|
|
|
<circle cx="200" cy="200" r={radius} fill="transparent" stroke="#e2e8f0" strokeWidth="2" />
|
|
|
|
{SPECIAL_ANGLES.map(a => {
|
|
const rTick = (a * Math.PI) / 180;
|
|
const x1 = 200 + (radius - 5) * Math.cos(rTick);
|
|
const y1 = 200 - (radius - 5) * Math.sin(rTick);
|
|
const x2 = 200 + radius * Math.cos(rTick);
|
|
const y2 = 200 - radius * Math.sin(rTick);
|
|
return <line key={a} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#94a3b8" strokeWidth="1" />;
|
|
})}
|
|
|
|
<path d={`M 200 200 L ${px} 200 L ${px} ${py} Z`} fill="rgba(224, 231, 255, 0.4)" stroke="none" />
|
|
|
|
<line x1="200" y1="200" x2={px} y2={py} stroke="#1e293b" strokeWidth="2" />
|
|
|
|
<line x1="200" y1="200" x2={px} y2="200" stroke="#4f46e5" strokeWidth="3" />
|
|
|
|
<line x1={px} y1="200" x2={px} y2={py} stroke="#e11d48" strokeWidth="3" />
|
|
|
|
{angle > 0 && (
|
|
<path
|
|
d={`M 230 200 A 30 30 0 ${angle > 180 ? 1 : 0} 0 ${200 + 30*Math.cos(rad)} ${200 - 30*Math.sin(rad)}`}
|
|
fill="none" stroke="#0f172a" strokeWidth="1.5"
|
|
/>
|
|
)}
|
|
|
|
<circle cx={px} cy={py} r="8" fill="#0f172a" stroke="white" strokeWidth="2" className="shadow-sm" />
|
|
<circle cx={px} cy={py} r="20" fill="transparent" cursor="grab" />
|
|
|
|
<text x={200 + (px - 200)/2} y={200 + (y >= 0 ? 15 : -10)} textAnchor="middle" className="text-xs font-bold fill-indigo-600">cos</text>
|
|
<text x={px + (x >= 0 ? 10 : -10)} y={200 - (200 - py)/2} textAnchor={x >= 0 ? "start" : "end"} className="text-xs font-bold fill-rose-600">sin</text>
|
|
|
|
<g transform={`translate(${x >= 0 ? 280 : 40}, ${y >= 0 ? 40 : 360})`}>
|
|
<rect x="-10" y="-20" width="130" height="40" rx="8" fill="white" stroke="#e2e8f0" className="shadow-sm" />
|
|
<text x="55" y="5" textAnchor="middle" className="font-mono text-sm font-bold fill-slate-700">
|
|
({cosStr}, {sinStr})
|
|
</text>
|
|
</g>
|
|
|
|
</svg>
|
|
|
|
<div className="flex gap-4 mt-2">
|
|
<button
|
|
onClick={() => setSnap(!snap)}
|
|
className={`text-xs px-3 py-1 rounded-full font-bold border transition-colors ${snap ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-500 border-slate-200'}`}
|
|
>
|
|
{snap ? "Snapping ON" : "Snapping OFF"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 w-full space-y-6">
|
|
<div className="bg-slate-50 p-5 rounded-xl border border-slate-200">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-bold uppercase text-slate-500">Current Angle</h3>
|
|
<div className="flex items-baseline gap-3 mt-1">
|
|
<span className={`text-4xl font-mono font-bold ${getAngleColor()}`}>{Math.round(angle)}°</span>
|
|
<span className="text-2xl font-mono text-slate-400">=</span>
|
|
<span className="text-3xl font-mono font-bold text-slate-700">{getRadianLabel(angle)}</span>
|
|
<span className="text-sm text-slate-400 ml-1">rad</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-bold text-slate-400 uppercase">Common Angles</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[0, 30, 45, 60, 90, 180, 270].map(a => (
|
|
<button
|
|
key={a}
|
|
onClick={() => setAngle(a)}
|
|
className={`w-10 h-10 rounded-lg text-sm font-bold transition-all ${
|
|
angle === a
|
|
? 'bg-indigo-600 text-white shadow-md scale-110'
|
|
: 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300 hover:text-indigo-600'
|
|
}`}
|
|
>
|
|
{a}°
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-xl">
|
|
<div className="text-xs font-bold uppercase text-indigo-800 mb-1">Cosine (x)</div>
|
|
<div className="text-3xl font-mono font-bold text-indigo-900">{cosStr}</div>
|
|
<div className="text-xs text-indigo-400 mt-1 font-mono">adj / hyp</div>
|
|
</div>
|
|
<div className="p-4 bg-rose-50 border border-rose-100 rounded-xl">
|
|
<div className="text-xs font-bold uppercase text-rose-800 mb-1">Sine (y)</div>
|
|
<div className="text-3xl font-mono font-bold text-rose-900">{sinStr}</div>
|
|
<div className="text-xs text-rose-400 mt-1 font-mono">opp / hyp</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-amber-50 border border-amber-100 rounded-xl text-center">
|
|
<div className="text-xs font-bold uppercase text-amber-800 mb-1">Tangent (sin/cos)</div>
|
|
<div className="text-2xl font-mono font-bold text-amber-900">
|
|
{Math.abs(x) < 0.001 ? "Undefined" : getExactValue(y/x)}
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-400 text-center">
|
|
Pro tip: On the SAT, memorize the values for 30°, 45°, and 60°!
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UnitCircleWidget; |