import { useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
import { roundToCenti } from "../../utils/rounding";
// Figma-canonical ordering: linear, the three core eases, then the expressive
// (back / snappy) family. Each maps to a GSAP ease so it round-trips cleanly.
const PRESET_GRID_EASES = [
"none",
"power2.in",
"power2.out",
"power2.inOut",
"back.in",
"back.out",
"back.inOut",
"expo.out",
] as const;
function MiniCurveSvg({
curve,
active,
}: {
curve: [number, number, number, number];
active: boolean;
}) {
const [x1, y1, x2, y2] = curve;
const s = 24;
const p = 3;
const g = s - p * 2;
const sx = (px: number) => p + g * px;
const sy = (py: number) => s - p - g * py;
const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
return (
);
}
const EasePresetGrid = function EasePresetGrid({
currentEase,
onSelect,
}: {
currentEase: string;
onSelect: (ease: string) => void;
}) {
return (
{PRESET_GRID_EASES.map((name) => {
const curve = EASE_CURVES[name];
if (!curve) return null;
const isActive = currentEase === name;
return (
);
})}
);
};
const round2 = roundToCenti;
// ── Graph geometry (Figma-style easing box) ─────────────────────────────────
// A geometrically-square unit plot ([0,1]×[0,1], equal X/Y scale so the curve
// isn't distorted), with fixed overshoot headroom above 1 and below 0 for
// back/elastic eases. The view is fixed (no per-curve zoom); handles are clamped
// to the visible range so they never drift off-screen.
const S = 184; // side of the unit (0..1) square, in viewBox units
const HR = 52; // overshoot headroom (top & bottom)
const PADH = 16; // horizontal breathing room
const SVGW = S + PADH * 2;
const SVGH = S + HR * 2;
const VMAX = 1 + HR / S; // top of visible view (progress overshoot headroom)
const VMIN = -HR / S; // bottom of visible view (undershoot headroom)
// Committed control points may extend PAST the visible view — heavy back/elastic
// presets reach ~1.55 / -0.55. Dragging clamps to this wider bound (cursor can
// leave the box via pointer capture) so those curves keep their fidelity instead
// of snapping to the view edge; the handle DOT is still clampView'd into view.
const DRAG_VMAX = 2;
const DRAG_VMIN = -1;
const ACCENT = "#3CE6AC";
type Pts = [number, number, number, number];
const xToSvg = (px: number) => PADH + S * px;
const yToSvg = (py: number) => HR + S * (1 - py);
const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py));
function cubicAt(t: number, c0: number, c1: number, c2: number, c3: number): number {
const mt = 1 - t;
return mt * mt * mt * c0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * c3;
}
export function EaseCurveSection({
ease,
duration,
onCustomEaseCommit,
}: {
ease: string;
duration?: number;
onCustomEaseCommit: (ease: string) => void;
}) {
const isCustom = ease.startsWith("custom(");
const curveFromPreset = EASE_CURVES[ease];
const customPoints = isCustom ? parseCustomEaseFromString(ease) : null;
const curve: Pts | null =
isCustom && customPoints
? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2]
: (curveFromPreset ?? null);
const [draft, setDraft] = useState(null);
const [progress, setProgress] = useState(null);
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
const draggingRef = useRef<"p1" | "p2" | null>(null);
const svgRef = useRef(null);
const rafRef = useRef(0);
const play = useCallback(() => {
const start = performance.now();
const dur = 1100;
const tick = (now: number) => {
const t = Math.min((now - start) / dur, 1);
setProgress(t);
if (t < 1) rafRef.current = requestAnimationFrame(tick);
else setTimeout(() => setProgress(null), 450);
};
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
}, []);
const active = draft ?? curve;
if (!active) return null;
const [x1, y1, x2, y2] = active;
// Anchors + control handles. Handle *display* is clamped to the view so an
// extreme loaded overshoot rides the edge instead of disappearing.
const a0 = { x: xToSvg(0), y: yToSvg(0) };
const a1 = { x: xToSvg(1), y: yToSvg(1) };
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
// Curve drawn from the true control points (so its shape is exact).
const cp1 = { x: xToSvg(x1), y: yToSvg(y1) };
const cp2 = { x: xToSvg(x2), y: yToSvg(y2) };
const curvePath = `M${a0.x},${a0.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${a1.x},${a1.y}`;
let dot: { x: number; y: number } | null = null;
if (progress !== null) {
dot = {
x: xToSvg(cubicAt(progress, 0, x1, x2, 1)),
y: yToSvg(cubicAt(progress, 0, y1, y2, 1)),
};
}
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
draggingRef.current = handle;
(e.target as SVGElement).setPointerCapture(e.pointerId);
if (!draft) setDraft([x1, y1, x2, y2]);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!draggingRef.current || !svgRef.current) return;
e.preventDefault();
const rect = svgRef.current.getBoundingClientRect();
const sx = ((e.clientX - rect.left) / rect.width) * SVGW;
const sy = ((e.clientY - rect.top) / rect.height) * SVGH;
// px is clamped to [0,1] on purpose: a cubic-bezier ease must be monotonic in
// time (handle1.x ≤ handle2.x), so handles can't pass each other or invert.
const px = Math.max(0, Math.min(1, (sx - PADH) / S));
// py uses the WIDER drag bound (not clampView), so dragging keeps overshoot
// fidelity instead of pinning the committed value to the visible view edge.
const py = Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, 1 - (sy - HR) / S));
const prev = draft ?? [x1, y1, x2, y2];
const next: Pts =
draggingRef.current === "p1"
? [round2(px), round2(py), prev[2], prev[3]]
: [prev[0], prev[1], round2(px), round2(py)];
setDraft(next);
};
const handlePointerUp = () => {
if (!draggingRef.current || !draft) return;
draggingRef.current = null;
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
onCustomEaseCommit(`custom(${path})`);
setDraft(null);
};
const top = yToSvg(1);
const bottom = yToSvg(0);
const left = xToSvg(0);
const right = xToSvg(1);
const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`;
return (
onCustomEaseCommit(name)} />
Speed curve
{/* Axis + value readout */}
{duration != null && duration > 0 ? "0s" : "start"}
time →
{duration != null && duration > 0 ? `${duration}s` : "end"}
{label}
{bezierText}
);
}