feat(lessons): add lessons from client db
This commit is contained in:
236
src/components/lessons/InteractiveTriangle.tsx
Normal file
236
src/components/lessons/InteractiveTriangle.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// Helper to convert radians to degrees
|
||||
const toDeg = (rad: number) => (rad * 180) / Math.PI;
|
||||
|
||||
const InteractiveTriangle: React.FC = () => {
|
||||
// Vertex B state (the draggable top vertex)
|
||||
// Default position forming a nice scalene triangle
|
||||
const [bPos, setBPos] = useState({ x: 120, y: 50 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showProof, setShowProof] = useState(false);
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
// Fixed vertices
|
||||
const A = { x: 50, y: 250 };
|
||||
const C = { x: 300, y: 250 };
|
||||
const D = { x: 450, y: 250 }; // Extension of base AC
|
||||
|
||||
// Colors
|
||||
const colors = {
|
||||
A: { text: "text-indigo-600", stroke: "#4f46e5", fill: "rgba(79, 70, 229, 0.2)" },
|
||||
B: { text: "text-emerald-600", stroke: "#059669", fill: "rgba(5, 150, 105, 0.2)" },
|
||||
Ext: { text: "text-rose-600", stroke: "#e11d48", fill: "rgba(225, 29, 72, 0.2)" }
|
||||
};
|
||||
|
||||
// Drag logic
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging || !svgRef.current) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
let x = e.clientX - rect.left;
|
||||
let y = e.clientY - rect.top;
|
||||
|
||||
// Constraints
|
||||
x = Math.max(20, Math.min(x, 380));
|
||||
y = Math.max(20, Math.min(y, 230)); // Keep B above the base (y < 250)
|
||||
|
||||
setBPos({ x, y });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// Calculations
|
||||
// SVG Coordinate system: Y is Down.
|
||||
// We use atan2(dy, dx) to get angles.
|
||||
// Angle of vector (dx, dy).
|
||||
|
||||
// Angle of AB
|
||||
const angleAB_rad = Math.atan2(bPos.y - A.y, bPos.x - A.x);
|
||||
const angleAB_deg = toDeg(angleAB_rad); // usually negative (e.g. -60)
|
||||
|
||||
// Angle of AC is 0.
|
||||
// Angle A (magnitude)
|
||||
const valA = Math.abs(angleAB_deg);
|
||||
|
||||
// Angle of CB
|
||||
const angleCB_rad = Math.atan2(bPos.y - C.y, bPos.x - C.x);
|
||||
const angleCB_deg = toDeg(angleCB_rad); // usually negative (e.g. -120)
|
||||
|
||||
// Angle of CA is 180.
|
||||
// Angle C Interior (magnitude) = 180 - abs(angleCB_deg) if y < C.y (which it is).
|
||||
const valC = 180 - Math.abs(angleCB_deg);
|
||||
|
||||
// Angle B (Interior)
|
||||
const valB = 180 - valA - valC;
|
||||
|
||||
// Exterior Angle (magnitude)
|
||||
// Between CD (0) and CB (angleCB_deg).
|
||||
// Ext = abs(angleCB_deg).
|
||||
const valExt = Math.abs(angleCB_deg);
|
||||
|
||||
// Arc Generation Helper
|
||||
const getArcPath = (cx: number, cy: number, r: number, startDeg: number, endDeg: number) => {
|
||||
// SVG standard: degrees clockwise from X-axis.
|
||||
// Our atan2 returns degrees relative to X-axis (clockwise positive if Y down).
|
||||
// so we can use them directly.
|
||||
|
||||
const startRad = (startDeg * Math.PI) / 180;
|
||||
const endRad = (endDeg * Math.PI) / 180;
|
||||
|
||||
const x1 = cx + r * Math.cos(startRad);
|
||||
const y1 = cy + r * Math.sin(startRad);
|
||||
const x2 = cx + r * Math.cos(endRad);
|
||||
const y2 = cy + r * Math.sin(endRad);
|
||||
|
||||
// Sweep flag: 0 if counter-clockwise, 1 if clockwise.
|
||||
// We want to draw from start to end.
|
||||
// If we go from negative angle (AB) to 0 (AC), difference is positive.
|
||||
|
||||
const largeArc = Math.abs(endDeg - startDeg) > 180 ? 1 : 0;
|
||||
const sweep = endDeg > startDeg ? 1 : 0;
|
||||
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} ${sweep} ${x2} ${y2} Z`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center select-none">
|
||||
<div className="w-full flex justify-between items-center mb-4 px-2">
|
||||
<h3 className="font-bold text-slate-700">Interactive Triangle</h3>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:bg-slate-50 p-2 rounded transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showProof}
|
||||
onChange={(e) => setShowProof(e.target.checked)}
|
||||
className="rounded text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="font-medium text-slate-600">Show Proof (Parallel Line)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<svg ref={svgRef} width="500" height="300" className="cursor-default">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#94a3b8" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Base Line Extension */}
|
||||
<line x1={C.x} y1={C.y} x2={D.x} y2={D.y} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="6,6" />
|
||||
<text x={D.x} y={D.y + 20} fontSize="12" fill="#94a3b8">D</text>
|
||||
|
||||
{/* Angle Arcs */}
|
||||
{/* Angle A: from angleAB to 0 */}
|
||||
<path
|
||||
d={getArcPath(A.x, A.y, 40, angleAB_deg, 0)}
|
||||
fill={colors.A.fill} stroke={colors.A.stroke} strokeWidth="1"
|
||||
/>
|
||||
<text x={A.x + 50} y={A.y - 10} className={`text-xs font-bold ${colors.A.text}`} style={{opacity: 0.8}}>{Math.round(valA)}°</text>
|
||||
|
||||
{/* Angle B: from angle of BA to angle of BC */}
|
||||
{/* Angle of BA is angleAB + 180. Angle of BC is angleCB + 180. */}
|
||||
{/* Wait, B is center. */}
|
||||
{/* Vector BA: A - B. Angle = atan2(Ay - By, Ax - Bx). */}
|
||||
{/* Vector BC: C - B. Angle = atan2(Cy - By, Cx - Bx). */}
|
||||
<path
|
||||
d={getArcPath(bPos.x, bPos.y, 40, Math.atan2(A.y - bPos.y, A.x - bPos.x) * 180/Math.PI, Math.atan2(C.y - bPos.y, C.x - bPos.x) * 180/Math.PI)}
|
||||
fill={colors.B.fill} stroke={colors.B.stroke} strokeWidth="1"
|
||||
/>
|
||||
{/* Label B slightly above vertex */}
|
||||
<text x={bPos.x} y={bPos.y - 15} textAnchor="middle" className={`text-xs font-bold ${colors.B.text}`} style={{opacity: 0.8}}>{Math.round(valB)}°</text>
|
||||
|
||||
|
||||
{/* Exterior Angle: at C, from angleCB to 0 */}
|
||||
{/* If showing proof, split it */}
|
||||
{!showProof && (
|
||||
<path
|
||||
d={getArcPath(C.x, C.y, 50, angleCB_deg, 0)}
|
||||
fill={colors.Ext.fill} stroke={colors.Ext.stroke} strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Proof Visuals */}
|
||||
{showProof && (
|
||||
<>
|
||||
{/* Parallel Line CE. Angle same as AB: angleAB_deg */}
|
||||
<line
|
||||
x1={C.x} y1={C.y}
|
||||
x2={C.x + 100 * Math.cos(angleAB_rad)} y2={C.y + 100 * Math.sin(angleAB_rad)}
|
||||
stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4"
|
||||
/>
|
||||
<text x={C.x + 110 * Math.cos(angleAB_rad)} y={C.y + 110 * Math.sin(angleAB_rad)} fontSize="12" fill="#94a3b8">E</text>
|
||||
|
||||
{/* Lower part of Ext (Corresponding to A) - From angleAB_deg to 0 */}
|
||||
<path
|
||||
d={getArcPath(C.x, C.y, 50, angleAB_deg, 0)}
|
||||
fill={colors.A.fill} stroke={colors.A.stroke} strokeWidth="1"
|
||||
/>
|
||||
<text x={C.x + 60} y={C.y - 10} className={`text-xs font-bold ${colors.A.text}`}>{Math.round(valA)}°</text>
|
||||
|
||||
{/* Upper part of Ext (Alt Interior to B) - From angleCB_deg to angleAB_deg */}
|
||||
<path
|
||||
d={getArcPath(C.x, C.y, 50, angleCB_deg, angleAB_deg)}
|
||||
fill={colors.B.fill} stroke={colors.B.stroke} strokeWidth="1"
|
||||
/>
|
||||
<text x={C.x + 35} y={C.y - 50} className={`text-xs font-bold ${colors.B.text}`}>{Math.round(valB)}°</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Label Ext if not split or just general label */}
|
||||
{!showProof && (
|
||||
<text x={C.x + 60} y={C.y - 30} className={`text-sm font-bold ${colors.Ext.text}`}>Ext {Math.round(valExt)}°</text>
|
||||
)}
|
||||
|
||||
|
||||
{/* Triangle Lines */}
|
||||
<path d={`M ${A.x} ${A.y} L ${bPos.x} ${bPos.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#1e293b" strokeWidth="2" strokeLinejoin="round" />
|
||||
|
||||
{/* Vertices */}
|
||||
<circle cx={A.x} cy={A.y} r="4" fill="#1e293b" />
|
||||
<text x={A.x - 15} y={A.y + 5} fontSize="14" fontWeight="bold" fill="#334155">A</text>
|
||||
|
||||
<circle cx={C.x} cy={C.y} r="4" fill="#1e293b" />
|
||||
<text x={C.x + 5} y={C.y + 20} fontSize="14" fontWeight="bold" fill="#334155">C</text>
|
||||
|
||||
{/* Draggable B */}
|
||||
<g
|
||||
onMouseDown={() => setIsDragging(true)}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<circle cx={bPos.x} cy={bPos.y} r="12" fill="transparent" /> {/* Hit area */}
|
||||
<circle cx={bPos.x} cy={bPos.y} r="6" fill="#4f46e5" stroke="white" strokeWidth="2" className="shadow-sm" />
|
||||
<text x={bPos.x} y={bPos.y - 20} textAnchor="middle" fontSize="14" fontWeight="bold" fill="#334155">B</text>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
||||
<div className="w-full mt-4 p-4 bg-slate-50 rounded-lg border border-slate-100 flex flex-col items-center">
|
||||
<div className="flex items-center gap-4 text-lg font-mono">
|
||||
<span className={colors.Ext.text}>Ext ({Math.round(valExt)}°)</span>
|
||||
<span className="text-slate-400">=</span>
|
||||
<span className={colors.A.text}>A ({Math.round(valA)}°)</span>
|
||||
<span className="text-slate-400">+</span>
|
||||
<span className={colors.B.text}>B ({Math.round(valB)}°)</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
{showProof
|
||||
? "Notice how the parallel line 'transports' angle A and B to the exterior?"
|
||||
: "Drag vertex B to see the values update."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveTriangle;
|
||||
Reference in New Issue
Block a user