Files
edbridge-scholars/src/components/lessons/InteractiveTriangle.tsx
2026-03-01 20:24:14 +06:00

237 lines
9.7 KiB
TypeScript

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;