Files
cabrits-math/components/explorers/cartesian-grid.tsx
2026-07-05 11:12:45 -04:00

182 lines
5.7 KiB
TypeScript

"use client";
import { useId } from "react";
export interface GridPoint {
x: number;
y: number;
label?: string;
tone?: "solid" | "target" | "wrong" | "muted";
}
export interface GridSegment {
from: { x: number; y: number };
to: { x: number; y: number };
}
export interface GridArrow {
from: { x: number; y: number };
to: { x: number; y: number };
dashed?: boolean;
}
interface CartesianGridProps {
points?: GridPoint[];
segments?: GridSegment[];
arrows?: GridArrow[];
/** Draws dashed guide lines from this point to the axes. `axis` picks which (default both). */
highlight?: { x: number; y: number; axis?: "x" | "y" | "both" };
onGridClick?: (x: number, y: number) => void;
}
const MIN = -10;
const MAX = 10;
const UNIT = 20;
const MARGIN = 20;
const SIZE = MARGIN * 2 + (MAX - MIN) * UNIT; // 440
const sx = (x: number) => MARGIN + (x - MIN) * UNIT;
const sy = (y: number) => MARGIN + (MAX - y) * UNIT;
const toneColor: Record<NonNullable<GridPoint["tone"]>, string> = {
solid: "var(--unit-7)",
target: "var(--correct)",
wrong: "var(--incorrect)",
muted: "var(--muted)",
};
export function CartesianGrid({
points = [],
segments = [],
arrows = [],
highlight,
onGridClick,
}: CartesianGridProps) {
const arrowHeadId = useId();
const axisArrowId = useId();
const ticks: number[] = [];
for (let v = MIN; v <= MAX; v++) ticks.push(v);
const labelTicks = ticks.filter((v) => v !== 0 && v % 2 === 0);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGridClick) return;
const rect = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * SIZE;
const py = ((e.clientY - rect.top) / rect.height) * SIZE;
const x = Math.round((px - MARGIN) / UNIT + MIN);
const y = Math.round(MAX - (py - MARGIN) / UNIT);
if (x < MIN || x > MAX || y < MIN || y > MAX) return;
onGridClick(x, y);
}
return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={`h-auto w-full max-w-md ${onGridClick ? "cursor-crosshair" : ""}`}
onClick={handleClick}
role="img"
aria-label="Cartesian coordinate grid"
>
<defs>
<marker id={arrowHeadId} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 z" fill="var(--unit-7-dark)" />
</marker>
<marker id={axisArrowId} markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
<path d="M0 0 L9 4.5 L0 9 z" fill="var(--foreground)" />
</marker>
</defs>
{/* Light gridlines */}
{ticks.map((v) => (
<g key={`grid-${v}`}>
<line x1={sx(v)} y1={sy(MIN)} x2={sx(v)} y2={sy(MAX)} stroke="var(--border)" strokeWidth={1} />
<line x1={sx(MIN)} y1={sy(v)} x2={sx(MAX)} y2={sy(v)} stroke="var(--border)" strokeWidth={1} />
</g>
))}
{/* Axes */}
<line x1={12} y1={sy(0)} x2={SIZE - 8} y2={sy(0)} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
<line x1={sx(0)} y1={SIZE - 8} x2={sx(0)} y2={12} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
<text x={SIZE - 12} y={sy(0) - 8} fontSize={14} fontWeight={700} fill="var(--foreground)" textAnchor="end">x</text>
<text x={sx(0) + 10} y={18} fontSize={14} fontWeight={700} fill="var(--foreground)">y</text>
{/* Axis labels */}
{labelTicks.map((v) => (
<text key={`xl-${v}`} x={sx(v)} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="middle">
{v}
</text>
))}
{labelTicks.map((v) => (
<text key={`yl-${v}`} x={sx(0) - 7} y={sy(v) + 3.5} fontSize={10} fill="var(--muted)" textAnchor="end">
{v}
</text>
))}
<text x={sx(0) - 7} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="end">0</text>
{/* Guide lines */}
{highlight && (
<g>
{(highlight.axis ?? "both") !== "y" && (
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(highlight.x)} y2={sy(0)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
)}
{(highlight.axis ?? "both") !== "x" && (
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(0)} y2={sy(highlight.y)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
)}
</g>
)}
{/* Segments */}
{segments.map((s, i) => (
<line
key={`seg-${i}`}
x1={sx(s.from.x)}
y1={sy(s.from.y)}
x2={sx(s.to.x)}
y2={sy(s.to.y)}
stroke="var(--unit-7)"
strokeWidth={2.5}
strokeLinecap="round"
/>
))}
{/* Movement arrows */}
{arrows.map((a, i) => (
<line
key={`arr-${i}`}
x1={sx(a.from.x)}
y1={sy(a.from.y)}
x2={sx(a.to.x)}
y2={sy(a.to.y)}
stroke="var(--unit-7-dark)"
strokeWidth={2.5}
strokeLinecap="round"
strokeDasharray={a.dashed ? "5 4" : undefined}
markerEnd={`url(#${arrowHeadId})`}
/>
))}
{/* Points */}
{points.map((p, i) => {
const color = toneColor[p.tone ?? "solid"];
return (
<g key={`pt-${i}`}>
<circle cx={sx(p.x)} cy={sy(p.y)} r={5} fill={color} stroke="white" strokeWidth={1.5} />
{p.label && (
<text
x={sx(p.x) + 8}
y={sy(p.y) - 8}
fontSize={12}
fontWeight={700}
fill={color}
>
{p.label}
</text>
)}
</g>
);
})}
</svg>
);
}