187 lines
7.5 KiB
TypeScript
187 lines
7.5 KiB
TypeScript
import React, { useState } from 'react';
|
|
|
|
type Relationship = 'none' | 'vertical' | 'linear' | 'corresponding' | 'alt-interior' | 'same-side';
|
|
|
|
const InteractiveTransversal: React.FC = () => {
|
|
const [activeRel, setActiveRel] = useState<Relationship>('same-side');
|
|
|
|
// SVG Config
|
|
const width = 500;
|
|
const height = 300;
|
|
|
|
const line1Y = 100;
|
|
const line2Y = 200;
|
|
|
|
// Transversal passes through (200, 100) and (300, 200)
|
|
// Slope is 1 (45 degrees down-right)
|
|
const intersection1 = { x: 200, y: 100 };
|
|
const intersection2 = { x: 300, y: 200 };
|
|
|
|
// Angle Definitions (SVG y-down coordinates)
|
|
// 0 deg = Right, 90 deg = Down, 180 deg = Left, 270 deg = Up
|
|
// Transversal vector is (1, 1), angle is 45 deg.
|
|
// Opposite ray is 225 deg.
|
|
|
|
const angles = [
|
|
// Intersection 1 (Top)
|
|
{ id: 1, cx: intersection1.x, cy: intersection1.y, start: 180, end: 225, labelPos: 202.5, quadrant: 'TL' }, // Top-Left (Acute)
|
|
{ id: 2, cx: intersection1.x, cy: intersection1.y, start: 225, end: 360, labelPos: 292.5, quadrant: 'TR' }, // Top-Right (Obtuse)
|
|
{ id: 3, cx: intersection1.x, cy: intersection1.y, start: 0, end: 45, labelPos: 22.5, quadrant: 'BR' }, // Bottom-Right (Acute)
|
|
{ id: 4, cx: intersection1.x, cy: intersection1.y, start: 45, end: 180, labelPos: 112.5, quadrant: 'BL' }, // Bottom-Left (Obtuse)
|
|
|
|
// Intersection 2 (Bottom)
|
|
{ id: 5, cx: intersection2.x, cy: intersection2.y, start: 180, end: 225, labelPos: 202.5, quadrant: 'TL' },
|
|
{ id: 6, cx: intersection2.x, cy: intersection2.y, start: 225, end: 360, labelPos: 292.5, quadrant: 'TR' },
|
|
{ id: 7, cx: intersection2.x, cy: intersection2.y, start: 0, end: 45, labelPos: 22.5, quadrant: 'BR' },
|
|
{ id: 8, cx: intersection2.x, cy: intersection2.y, start: 45, end: 180, labelPos: 112.5, quadrant: 'BL' },
|
|
];
|
|
|
|
const getArcPath = (cx: number, cy: number, r: number, startDeg: number, endDeg: number) => {
|
|
// Convert to radians
|
|
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);
|
|
|
|
const largeArc = (endDeg - startDeg) > 180 ? 1 : 0;
|
|
|
|
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
|
};
|
|
|
|
const getLabelPos = (cx: number, cy: number, r: number, angleDeg: number) => {
|
|
const rad = (angleDeg * Math.PI) / 180;
|
|
return {
|
|
x: cx + r * Math.cos(rad),
|
|
y: cy + r * Math.sin(rad)
|
|
};
|
|
};
|
|
|
|
const getStyles = (id: number) => {
|
|
const base = { fill: 'transparent', stroke: 'transparent', label: 'text-slate-400' };
|
|
const highlightBlue = { fill: 'rgba(99, 102, 241, 0.3)', stroke: '#4f46e5', label: 'text-indigo-600 font-bold' };
|
|
const highlightPink = { fill: 'rgba(244, 63, 94, 0.3)', stroke: '#e11d48', label: 'text-rose-600 font-bold' };
|
|
|
|
switch (activeRel) {
|
|
case 'vertical':
|
|
// 1 & 3 are equal
|
|
if ([1, 3].includes(id)) return highlightBlue;
|
|
return base;
|
|
case 'linear':
|
|
// 1 & 2 are supplementary
|
|
if (id === 1) return highlightBlue;
|
|
if (id === 2) return highlightPink;
|
|
return base;
|
|
case 'corresponding':
|
|
// 2 & 6 are equal
|
|
if ([2, 6].includes(id)) return highlightBlue;
|
|
return base;
|
|
case 'alt-interior':
|
|
// 4 & 6 are equal
|
|
if ([4, 6].includes(id)) return highlightBlue;
|
|
return base;
|
|
case 'same-side':
|
|
// 3 & 6 are supplementary
|
|
if (id === 3) return highlightBlue;
|
|
if (id === 6) return highlightPink;
|
|
return base;
|
|
default:
|
|
return base;
|
|
}
|
|
};
|
|
|
|
const getDescription = () => {
|
|
switch (activeRel) {
|
|
case 'vertical': return "Vertical Angles are equal (e.g. ∠1 = ∠3)";
|
|
case 'linear': return "Linear Pairs sum to 180° (e.g. ∠1 + ∠2 = 180°)";
|
|
case 'corresponding': return "Corresponding Angles are equal (e.g. ∠2 = ∠6)";
|
|
case 'alt-interior': return "Alternate Interior Angles are equal (e.g. ∠4 = ∠6)";
|
|
case 'same-side': return "Same-Side Interior sum to 180° (e.g. ∠3 + ∠6 = 180°)";
|
|
default: return "Select a relationship to highlight";
|
|
}
|
|
};
|
|
|
|
const buttons: { id: Relationship, label: string }[] = [
|
|
{ id: 'vertical', label: 'Vertical Angles' },
|
|
{ id: 'linear', label: 'Linear Pair' },
|
|
{ id: 'corresponding', label: 'Corresponding' },
|
|
{ id: 'alt-interior', label: 'Alt. Interior' },
|
|
{ id: 'same-side', label: 'Same-Side Interior' },
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col items-center bg-white p-8 rounded-xl shadow-sm border border-slate-200">
|
|
<div className="flex flex-wrap gap-2 justify-center mb-8">
|
|
{buttons.map(btn => (
|
|
<button
|
|
key={btn.id}
|
|
onClick={() => setActiveRel(activeRel === btn.id ? 'none' : btn.id)}
|
|
className={`px-4 py-2 rounded-full text-sm font-bold transition-all border ${
|
|
activeRel === btn.id
|
|
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:border-slate-400 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{btn.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="relative w-full flex justify-center">
|
|
<div className="absolute top-0 left-0 w-full text-center">
|
|
<p className="text-slate-500 font-medium">{getDescription()}</p>
|
|
</div>
|
|
|
|
<svg width={width} height={height} className="mt-8 select-none">
|
|
<defs>
|
|
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="4" orient="auto">
|
|
<path d="M0,0 L0,8 L12,4 z" fill="#64748b" />
|
|
</marker>
|
|
</defs>
|
|
|
|
{/* Parallel Lines */}
|
|
<line x1="50" y1={line1Y} x2="450" y2={line1Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
|
|
<line x1="450" y1={line1Y} x2="50" y2={line1Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
|
|
|
|
<line x1="50" y1={line2Y} x2="450" y2={line2Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
|
|
<line x1="450" y1={line2Y} x2="50" y2={line2Y} stroke="#64748b" strokeWidth="3" markerEnd="url(#arrow)" />
|
|
|
|
{/* Transversal (infinite line simulation) */}
|
|
<line x1="100" y1="0" x2="400" y2="300" stroke="#0f172a" strokeWidth="3" strokeLinecap="round" />
|
|
|
|
{/* Angles */}
|
|
{angles.map((angle) => {
|
|
const styles = getStyles(angle.id);
|
|
const r = 35;
|
|
const labelPos = getLabelPos(angle.cx, angle.cy, r + 15, angle.labelPos);
|
|
|
|
return (
|
|
<g key={angle.id}>
|
|
<path
|
|
d={getArcPath(angle.cx, angle.cy, r, angle.start, angle.end)}
|
|
fill={styles.fill}
|
|
stroke={styles.stroke}
|
|
strokeWidth={styles.stroke === 'transparent' ? 0 : 2}
|
|
/>
|
|
<text
|
|
x={labelPos.x}
|
|
y={labelPos.y}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
className={`text-sm select-none ${styles.label}`}
|
|
>
|
|
{angle.id}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InteractiveTransversal;
|