182 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|