feat(lessons): add lessons from client db
This commit is contained in:
233
src/components/lessons/UnitCircleWidget.tsx
Normal file
233
src/components/lessons/UnitCircleWidget.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user