feat(lessons): add lessons from client db
This commit is contained in:
364
src/components/lessons/PowerOfPointWidget.tsx
Normal file
364
src/components/lessons/PowerOfPointWidget.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
|
||||
type Mode = 'chords' | 'secants';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const PowerOfPointWidget: React.FC = () => {
|
||||
const [mode, setMode] = useState<Mode>('chords');
|
||||
|
||||
// -- Common State --
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const isDragging = useRef<string | null>(null);
|
||||
const center = { x: 200, y: 180 };
|
||||
const radius = 100;
|
||||
|
||||
// -- Chords Mode State --
|
||||
// Store angles for points A, B, C, D on the circle
|
||||
const [chordAngles, setChordAngles] = useState({
|
||||
a: 220, b: 40, // Chord 1
|
||||
c: 140, d: 320 // Chord 2
|
||||
});
|
||||
|
||||
// -- Secants Mode State --
|
||||
// P is external point.
|
||||
// Secant 1 defined by angle theta1 (offset from center-P line)
|
||||
// Secant 2 defined by angle theta2
|
||||
const [secantState, setSecantState] = useState({
|
||||
px: 380, py: 180, // Point P
|
||||
theta1: 15, // Angle offset for secant 1
|
||||
});
|
||||
|
||||
// --- Helper Math ---
|
||||
const getPosOnCircle = (deg: number) => ({
|
||||
x: center.x + radius * Math.cos(deg * Math.PI / 180),
|
||||
y: center.y + radius * Math.sin(deg * Math.PI / 180)
|
||||
});
|
||||
|
||||
const dist = (p1: Point, p2: Point) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
|
||||
|
||||
const getIntersection = (p1: Point, p2: Point, p3: Point, p4: Point) => {
|
||||
// Line AB represented as a1x + b1y = c1
|
||||
const a1 = p2.y - p1.y;
|
||||
const b1 = p1.x - p2.x;
|
||||
const c1 = a1 * p1.x + b1 * p1.y;
|
||||
// Line CD represented as a2x + b2y = c2
|
||||
const a2 = p4.y - p3.y;
|
||||
const b2 = p3.x - p4.x;
|
||||
const c2 = a2 * p3.x + b2 * p3.y;
|
||||
const determinant = a1 * b2 - a2 * b1;
|
||||
if (Math.abs(determinant) < 0.001) return null; // Parallel
|
||||
const x = (b2 * c1 - b1 * c2) / determinant;
|
||||
const y = (a1 * c2 - a2 * c1) / determinant;
|
||||
// Check if inside circle
|
||||
if (dist({x,y}, center) > radius + 1) return null;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// --- Interaction Handlers ---
|
||||
const handleChordDrag = (e: React.MouseEvent, key: string) => {
|
||||
if (!svgRef.current) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const dx = e.clientX - rect.left - center.x;
|
||||
const dy = e.clientY - rect.top - center.y;
|
||||
let deg = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
if (deg < 0) deg += 360;
|
||||
setChordAngles(prev => ({ ...prev, [key]: deg }));
|
||||
};
|
||||
|
||||
const handleSecantDrag = (e: React.MouseEvent) => {
|
||||
if (!svgRef.current) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
if (isDragging.current === 'P') {
|
||||
// Constrain P outside
|
||||
const dx = x - center.x;
|
||||
const dy = y - center.y;
|
||||
const d = Math.sqrt(dx*dx + dy*dy);
|
||||
if (d > radius + 20) {
|
||||
setSecantState(prev => ({...prev, px: x, py: y}));
|
||||
} else {
|
||||
const ang = Math.atan2(dy, dx);
|
||||
setSecantState(prev => ({
|
||||
...prev,
|
||||
px: center.x + (radius+20)*Math.cos(ang),
|
||||
py: center.y + (radius+20)*Math.sin(ang)
|
||||
}));
|
||||
}
|
||||
} else if (isDragging.current === 'SecantEnd') {
|
||||
// Calculate angle relative to PO line
|
||||
// Vector PO
|
||||
const pdx = center.x - secantState.px;
|
||||
const pdy = center.y - secantState.py;
|
||||
const poAngle = Math.atan2(pdy, pdx);
|
||||
|
||||
// Vector PA (mouse to P)
|
||||
const mdx = x - secantState.px;
|
||||
const mdy = y - secantState.py;
|
||||
const mAngle = Math.atan2(mdy, mdx);
|
||||
|
||||
let diff = (mAngle - poAngle) * 180 / Math.PI;
|
||||
// Normalize to -180 to 180
|
||||
while (diff > 180) diff -= 360;
|
||||
while (diff < -180) diff += 360;
|
||||
|
||||
// Clamp to hit circle. Max angle is asin(R/dist)
|
||||
const distPO = Math.sqrt(pdx*pdx + pdy*pdy);
|
||||
const maxAngle = Math.asin(radius/distPO) * 180 / Math.PI;
|
||||
|
||||
// Clamp
|
||||
const clamped = Math.max(-maxAngle + 1, Math.min(maxAngle - 1, diff));
|
||||
setSecantState(prev => ({...prev, theta1: clamped}));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render Helpers ---
|
||||
const renderChords = () => {
|
||||
const A = getPosOnCircle(chordAngles.a);
|
||||
const B = getPosOnCircle(chordAngles.b);
|
||||
const C = getPosOnCircle(chordAngles.c);
|
||||
const D = getPosOnCircle(chordAngles.d);
|
||||
|
||||
const E = getIntersection(A, B, C, D);
|
||||
|
||||
const valid = !!E;
|
||||
const ae = valid ? dist(A, E) : 0;
|
||||
const eb = valid ? dist(E, B) : 0;
|
||||
const ce = valid ? dist(C, E) : 0;
|
||||
const ed = valid ? dist(E, D) : 0;
|
||||
|
||||
const points = [
|
||||
{ k: 'a', p: A, l: 'A', c: '#7c3aed' },
|
||||
{ k: 'b', p: B, l: 'B', c: '#7c3aed' },
|
||||
{ k: 'c', p: C, l: 'C', c: '#059669' },
|
||||
{ k: 'd', p: D, l: 'D', c: '#059669' }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} stroke="#7c3aed" strokeWidth="3" />
|
||||
<line x1={C.x} y1={C.y} x2={D.x} y2={D.y} stroke="#059669" strokeWidth="3" />
|
||||
|
||||
{/* Points */}
|
||||
{points.map((pt) => (
|
||||
<g key={pt.k} onMouseDown={() => isDragging.current = pt.k} className="cursor-pointer hover:scale-110 transition-transform">
|
||||
<circle cx={pt.p.x} cy={pt.p.y} r="15" fill="transparent" />
|
||||
<circle cx={pt.p.x} cy={pt.p.y} r="6" fill={pt.c} stroke="white" strokeWidth="2" />
|
||||
<text x={pt.p.x} y={pt.p.y - 12} textAnchor="middle" className="text-sm font-bold fill-slate-700">{pt.l}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{valid && (
|
||||
<>
|
||||
<circle cx={E.x} cy={E.y} r="4" fill="#0f172a" />
|
||||
<text x={E.x + 10} y={E.y} className="text-xs font-bold fill-slate-500">E</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info Panel */}
|
||||
<div className="absolute top-4 left-4 bg-white/90 p-4 rounded-xl border border-slate-200 shadow-sm backdrop-blur-sm pointer-events-none select-none">
|
||||
{!valid ? (
|
||||
<p className="text-red-500 font-bold">Chords must intersect inside!</p>
|
||||
) : (
|
||||
<div className="space-y-3 font-mono text-sm">
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-violet-600">Purple Chord</div>
|
||||
<div>{ae.toFixed(0)} × {eb.toFixed(0)} = <strong>{(ae*eb).toFixed(0)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-emerald-600">Green Chord</div>
|
||||
<div>{ce.toFixed(0)} × {ed.toFixed(0)} = <strong>{(ce*ed).toFixed(0)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-slate-200"></div>
|
||||
<p className="text-slate-500 text-xs text-center font-sans">
|
||||
AE · EB = CE · ED
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSecant = () => {
|
||||
const { px, py, theta1 } = secantState;
|
||||
// Calculate Tangent Point T (Upper)
|
||||
const dx = px - center.x;
|
||||
const dy = py - center.y;
|
||||
const distPO = Math.sqrt(dx*dx + dy*dy);
|
||||
const anglePO = Math.atan2(dy, dx);
|
||||
const angleOffset = Math.acos(radius/distPO);
|
||||
const tAngle = anglePO - angleOffset;
|
||||
const T = {
|
||||
x: center.x + radius * Math.cos(tAngle),
|
||||
y: center.y + radius * Math.sin(tAngle)
|
||||
};
|
||||
const tangentLen = Math.sqrt(distPO*distPO - radius*radius);
|
||||
|
||||
// Calculate Secant Intersection Points
|
||||
// Secant Line angle
|
||||
const secantAngle = anglePO + theta1 * Math.PI / 180;
|
||||
|
||||
const vx = px - center.x;
|
||||
const vy = py - center.y;
|
||||
const cos = Math.cos(secantAngle);
|
||||
const sin = Math.sin(secantAngle);
|
||||
// t^2 + 2(V.D)t + (V^2 - R^2) = 0
|
||||
const b = 2 * (vx * cos + vy * sin);
|
||||
const c = vx*vx + vy*vy - radius*radius;
|
||||
const det = b*b - 4*c;
|
||||
|
||||
let A = {x:0, y:0}, B = {x:0, y:0};
|
||||
let valid = false;
|
||||
|
||||
if (det > 0) {
|
||||
const tFar = (-b - Math.sqrt(det)) / 2;
|
||||
const tNear = (-b + Math.sqrt(det)) / 2;
|
||||
|
||||
// A is Near (External part)
|
||||
A = { x: px + tNear * cos, y: py + tNear * sin };
|
||||
// B is Far (Whole secant endpoint)
|
||||
B = { x: px + tFar * cos, y: py + tFar * sin };
|
||||
valid = true;
|
||||
}
|
||||
|
||||
const distPA = valid ? dist({x:px, y:py}, A) : 0;
|
||||
const distPB = valid ? dist({x:px, y:py}, B) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tangent Line */}
|
||||
<line x1={px} y1={py} x2={T.x} y2={T.y} stroke="#e11d48" strokeWidth="3" />
|
||||
<circle cx={T.x} cy={T.y} r="5" fill="#e11d48" />
|
||||
<text x={T.x} y={T.y - 10} className="text-xs font-bold fill-rose-600">T</text>
|
||||
|
||||
{/* Secant Line (Draw full segment P to B) */}
|
||||
{valid && <line x1={px} y1={py} x2={B.x} y2={B.y} stroke="#7c3aed" strokeWidth="3" />}
|
||||
{valid && (
|
||||
<>
|
||||
{/* Point A (Near/External) */}
|
||||
<circle cx={A.x} cy={A.y} r="5" fill="#7c3aed" />
|
||||
<text x={A.x + 15} y={A.y} className="text-xs font-bold fill-violet-600">A</text>
|
||||
|
||||
{/* Point B (Far/Whole) */}
|
||||
<circle cx={B.x} cy={B.y} r="5" fill="#7c3aed" />
|
||||
<text x={B.x - 15} y={B.y} className="text-xs font-bold fill-violet-600">B</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Point P */}
|
||||
<g onMouseDown={() => isDragging.current = 'P'} className="cursor-grab active:cursor-grabbing">
|
||||
<circle cx={px} cy={py} r="15" fill="transparent" />
|
||||
<circle cx={px} cy={py} r="6" fill="#0f172a" stroke="white" strokeWidth="2" />
|
||||
<text x={px + 10} y={py} className="text-sm font-bold fill-slate-800">P</text>
|
||||
</g>
|
||||
|
||||
{/* Drag Handle for Secant Angle (at B, the far end) */}
|
||||
{valid && (
|
||||
<circle
|
||||
cx={B.x} cy={B.y} r="12" fill="transparent" stroke="white" strokeWidth="2" strokeDasharray="2,2"
|
||||
className="cursor-pointer hover:stroke-violet-400"
|
||||
onMouseDown={() => isDragging.current = 'SecantEnd'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="absolute top-4 left-4 bg-white/90 p-4 rounded-xl border border-slate-200 shadow-sm backdrop-blur-sm pointer-events-none select-none">
|
||||
<div className="space-y-3 font-mono text-sm">
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-bold text-rose-600 uppercase">Tangent² (PT²)</div>
|
||||
<div>{tangentLen.toFixed(0)}² = <strong>{(tangentLen*tangentLen).toFixed(0)}</strong></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-violet-600 uppercase">Secant (PA · PB)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span title="External Part (PA)">{distPA.toFixed(0)}</span>
|
||||
<span className="text-slate-400">×</span>
|
||||
<span title="Whole Secant (PB)">{distPB.toFixed(0)}</span>
|
||||
<span className="text-slate-400">=</span>
|
||||
<strong>{(distPA*distPB).toFixed(0)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-slate-200 my-2"></div>
|
||||
<p className="text-slate-500 text-xs text-center font-sans">
|
||||
Tangent² = External × Whole
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
if (mode === 'chords') {
|
||||
// Check if dragging specific points
|
||||
if (['a','b','c','d'].includes(isDragging.current as string)) {
|
||||
handleChordDrag(e, isDragging.current as string);
|
||||
}
|
||||
} else {
|
||||
handleSecantDrag(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
|
||||
<div className="flex gap-4 mb-6 justify-center">
|
||||
<button
|
||||
onClick={() => setMode('chords')}
|
||||
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${mode === 'chords' ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
|
||||
>
|
||||
Intersecting Chords
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('secants')}
|
||||
className={`px-4 py-2 rounded-full font-bold text-sm transition-all ${mode === 'secants' ? 'bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
|
||||
>
|
||||
Tangent-Secant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-center bg-slate-50 rounded-xl border border-slate-100 overflow-hidden">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="500" height="360"
|
||||
className="select-none"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={() => isDragging.current = null}
|
||||
onMouseLeave={() => isDragging.current = null}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
{/* Circle */}
|
||||
<circle cx={center.x} cy={center.y} r={radius} fill="white" stroke="#94a3b8" strokeWidth="2" />
|
||||
<circle cx={center.x} cy={center.y} r="3" fill="#cbd5e1" />
|
||||
|
||||
{mode === 'chords' ? renderChords() : renderSecant()}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-sm text-slate-500">
|
||||
{mode === 'chords'
|
||||
? "Drag the colored points along the circle."
|
||||
: "Drag point P or the secant endpoint B."
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PowerOfPointWidget;
|
||||
Reference in New Issue
Block a user