{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "robot-baseball",
  "title": "Baseball",
  "description": "The ball as a sphere with a real seam: the figure-eight is a closed curve lying exactly on the surface, so spin carries it round the back instead of sliding it across the front.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/use-robot-motion.json",
    "https://robocn.dev/r/sport-geometry.json"
  ],
  "files": [
    {
      "path": "src/components/ui/robot-baseball.tsx",
      "content": "\"use client\"\n\n/**\n * robot-baseball — a pitched ball, with the seam and the break both solved.\n *\n * The ball is a sphere, which is the easy part; what makes a baseball a\n * baseball is the seam, and that is a closed figure-eight lying *exactly* on\n * the surface — `x = a·cos t + b·cos 3t`, `y = a·sin t − b·sin 3t`,\n * `z = 2√(ab)·sin 2t`, whose radius works out to `a + b` for every `t`. So the\n * ball turning carries the seam round the back and brings it out the other\n * side, and the far half is culled rather than painted over the near one.\n *\n * The flight is `a = g + (S/m)(ω × v)`. Each pitch is a spin rate and an axis\n * and nothing else: a four-seam fastball holds itself up on backspin, a\n * curveball's topspin drives it past gravity, a slider's axis stands up so the\n * break goes sideways, and a knuckler barely turns so nothing happens to it.\n * The dashed line is the same pitch with the spin taken out, so the gap between\n * the two *is* the break rather than a number someone typed.\n *\n * The maths is in `src/lib/robocn/sport.ts` — pure, no React, tested on its\n * own. What is illustrated here: there is no air, so nothing decays and the\n * Magnus term is held at its release value, which makes the whole flight one\n * quadratic. Over the sixty feet this is drawn at, that is smaller than the\n * seam it is drawn with.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotClock, useRobotDrag } from \"@/hooks/use-robot-motion\"\nimport { clamp, type Vec2, type Vec3 } from \"@/lib/robocn/kinematics\"\nimport {\n  baseballSeam,\n  flightAt,\n  pitchNames,\n  pitchSpin,\n  sphereSilhouette,\n  spinFrame,\n  surfaceCurve,\n  visibleRuns,\n  type PitchName,\n} from \"@/lib/robocn/sport\"\nimport {\n  boxCorners,\n  fitTransform,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\n  type RobotCamera,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\n/** What the ball does with nobody driving it. Always includes `static`. */\nexport type BaseballBehavior = PitchName | \"spin\" | \"static\"\n\nconst VIEW_WIDTH = 220\nconst VIEW_HEIGHT = 170\n/** The bottom strip the readout sits in; the drawing is fitted above it. */\nconst TEXT_ROOM = 30\nconst NATIVE_VIEW: RobotView = \"profile\"\n\n/** World units from the hand to the plate, and how long the ball takes. */\nconst RANGE = 92\nconst FLIGHT_TIME = 0.45\nconst BALL_RADIUS = 15\n/**\n * Gravity and the Magnus coefficient in *these* units. The shape of every curve\n * belongs to the equation; only its size on the page belongs to the drawing,\n * and both are set so a drag-free sag and a real ball's break come out at about\n * the share of the flight they do over sixty feet.\n */\nconst GRAVITY = 210\nconst MAGNUS = 0.0024\n/** Released this far nose-up, which is roughly what gravity takes back. */\nconst LAUNCH = 13\nconst GROUND = -34\n/**\n * The turf is a *plane*, so it is drawn as one. A line along a single world\n * axis collapses to a stick the moment the camera looks down that axis — which\n * is exactly what the plan view does to a line drawn down the flight.\n */\nconst TURF_PATCH: Vec2[] = [\n  { x: -26, y: 16 },\n  { x: 26, y: 16 },\n  { x: 26, y: -112 },\n  { x: -26, y: -112 },\n]\n/** The box every camera has to fit: the whole flight, the ball, and the turf. */\nconst ENVELOPE = boxCorners({ x: -28, y: GROUND, z: -112 }, { x: 28, y: 34, z: 20 })\n/** The drawing is never blown up past this, so the ball keeps a readable size. */\nconst MAX_SCALE = 1.45\n/** Revolutions per second of clock when the ball is just turning on the spot. */\nconst SPIN_TURNS = 0.9\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation, the batter's view\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\nconst descriptions: Record<BaseballBehavior, string> = {\n  fastball: \"four-seam fastball, riding on backspin\",\n  curveball: \"curveball, diving on topspin\",\n  slider: \"slider, breaking sideways on a standing axis\",\n  sinker: \"sinker, running arm-side\",\n  knuckler: \"knuckler, barely turning\",\n  spin: \"turning on the spot\",\n  static: \"held still\",\n}\n\n/** Which way the camera lies from the ball: the gradient of its own depth. */\nfunction viewDirection(camera: RobotCamera): Vec3 {\n  return { x: camera.depth(1, 0, 0), y: camera.depth(0, 1, 0), z: camera.depth(0, 0, 1) }\n}\n\nexport interface BaseballPose {\n  /** How far along the pitch, 0 at release and 1 at the plate. */\n  along: number\n  /** Revolutions the ball has turned, for the seam to follow. */\n  turns: number\n}\n\n/**\n * Every behaviour is a pure function of the clock, exported so motion is tested\n * by sampling it rather than by faking animation frames.\n */\nexport function baseballPose(behavior: BaseballBehavior, clock: number): BaseballPose {\n  if (!Number.isFinite(clock)) return { along: 0, turns: 0 }\n  if (behavior === \"spin\") return { along: 0, turns: clock * SPIN_TURNS }\n  if (!pitchNames.includes(behavior as PitchName)) return { along: 0, turns: 0 }\n  const along = ((clock % 1) + 1) % 1\n  return { along, turns: pitchSpin(behavior as PitchName).rate * along * FLIGHT_TIME }\n}\n\n/**\n * The pitch a behaviour throws. The two that are not pitches hold a fastball,\n * and so does anything that is not in the union at all — a stale prop from a\n * consumer should degrade, not draw nonsense.\n */\nexport const baseballPitch = (behavior: BaseballBehavior): PitchName =>\n  pitchNames.includes(behavior as PitchName) ? (behavior as PitchName) : \"fastball\"\n\nexport interface RobotBaseballProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Controlled position along the pitch, 0 to 1. Supplying it stops the loop. */\n  along?: number\n  onAlongChange?: (along: number) => void\n  behavior?: BaseballBehavior\n  /**\n   * Draw the solved flight, and the same pitch with the spin taken out. The\n   * two behaviours that throw nothing — `spin` and `static` — never draw one.\n   */\n  showPath?: boolean\n  showGround?: boolean\n  seam?: boolean\n  /** Where the camera stands. `front` is the batter's view, where break reads. */\n  view?: RobotView\n  speed?: number\n  phase?: number\n  paused?: boolean\n  animate?: boolean\n  interactive?: boolean\n  label?: string\n  size?: RobotSize | number\n  variant?: RobotVariant\n}\n\nfunction RobotBaseball({\n  along,\n  onAlongChange,\n  behavior = \"fastball\",\n  showPath = true,\n  showGround = true,\n  seam = true,\n  view = NATIVE_VIEW,\n  speed = 0.5,\n  phase = 0,\n  paused = false,\n  animate = true,\n  interactive = false,\n  label,\n  size = \"md\",\n  variant = \"solid\",\n  color,\n  accent,\n  metal,\n  dark,\n  glow,\n  grid,\n  palette: paletteOverride,\n  className,\n  style,\n  role,\n  tabIndex,\n  onKeyDown,\n  onBlur,\n  \"aria-label\": ariaLabel,\n  ...props\n}: RobotBaseballProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n  const controlled = along !== undefined\n\n  const clock = useRobotClock({\n    speed,\n    animate: animate && !controlled && held === null && behavior !== \"static\",\n    paused,\n    phase,\n  })\n  const running = baseballPose(behavior, clock)\n  const pinned = controlled\n    ? Number.isFinite(along) ? clamp(along as number, 0, 1) : 0\n    : held\n  const pose: BaseballPose =\n    pinned === null\n      ? running\n      : { along: pinned, turns: pitchSpin(baseballPitch(behavior)).rate * pinned * FLIGHT_TIME }\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = Math.round(clamp(next, 0, 1) * 1000) / 1000\n      setHeld(bounded)\n      onAlongChange?.(bounded)\n    },\n    [onAlongChange],\n  )\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback((unit: Vec2) => apply(unit.x), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  const pitch = baseballPitch(behavior)\n  const spin = pitchSpin(pitch)\n  const options = {\n    speed: RANGE / FLIGHT_TIME,\n    launch: LAUNCH,\n    gravity: GRAVITY,\n    magnus: MAGNUS,\n    spin,\n  }\n  const flight = flightAt(pose.along * FLIGHT_TIME, options)\n\n  const camera = robotCamera(view)\n  const frame = fitTransform(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT - TEXT_ROOM, 8, MAX_SCALE)\n  const look = viewDirection(camera)\n  const to = (point: Vec3): Vec2 => camera.project(point.x, point.y, point.z)\n  const line = (points: readonly Vec2[], close = false) =>\n    `${points.map((p, i) => `${i ? \"L\" : \"M\"} ${px(p.x)} ${px(p.y)}`).join(\" \")}${close ? \" Z\" : \"\"}`\n  const shift = (point: Vec3): Vec3 => ({\n    x: point.x + flight.position.x,\n    y: point.y + flight.position.y,\n    z: point.z + flight.position.z,\n  })\n\n  // The seam turns about the pitch's own spin axis, so what the camera sees of\n  // it is a consequence of the pitch rather than a second animation.\n  const attitude = spinFrame(spin.axis, pose.turns)\n  const outline = sphereSilhouette(BALL_RADIUS, look, 48).map((p) => to(shift(p)))\n  const stitching = surfaceCurve(attitude, BALL_RADIUS, SEAM, look)\n  /** Where the flight actually ends, which is where the plate belongs. */\n  const plate = flightAt(FLIGHT_TIME, options).position\n\n  const sample = (withSpin: boolean) =>\n    Array.from({ length: 33 }, (_, index) =>\n      to(\n        flightAt((index / 32) * FLIGHT_TIME, {\n          ...options,\n          spin: withSpin ? spin : { rate: 0, axis: spin.axis },\n        }).position,\n      ),\n    )\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const seamPaint = {\n    fill: \"none\",\n    stroke: variant === \"wire\" ? palette.grid : palette.accent,\n    strokeWidth: variant === \"solid\" ? 1.9 : 1.1,\n    strokeLinecap: \"round\" as const,\n    strokeLinejoin: \"round\" as const,\n  }\n\n  const broke = Math.round(flight.break * 10) / 10\n  // `spin` turns on the spot and `static` does nothing, so neither of them gets\n  // to quote a pitch's spin rate or its break.\n  const thrown = behavior === \"spin\" || behavior === \"static\" ? behavior : pitch\n  const caption =\n    behavior === \"static\"\n      ? \"STATIC / HELD\"\n      : behavior === \"spin\"\n        ? `SPIN / ${SPIN_TURNS} REV·S`\n        : `${thrown.toUpperCase()} / ${Math.round(spin.rate)} REV·S / ${broke} BREAK`\n  const percent = Math.round(pose.along * 100)\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Robot baseball, ${descriptions[behavior] ?? descriptions.static}, ${percent} percent to the plate, ${viewNames[view] ?? viewNames.profile}`\n      }\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? 1 : undefined}\n      aria-valuenow={interactive ? px(pose.along) : undefined}\n      aria-valuetext={interactive ? `${percent} percent to the plate` : undefined}\n      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!interactive || event.defaultPrevented) return\n        const delta = arrowStep(event.key, 0.04, 0.2)\n        if (delta !== 0) apply(pose.along + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(1)\n        else return\n        event.preventDefault()\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        if (!dragging) setHeld(null)\n      }}\n      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}\n      width={width}\n      height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}\n      className={cn(\n        \"max-w-full select-none\",\n        interactive &&\n          \"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\",\n        dragging && \"cursor-grabbing\",\n        className,\n      )}\n      style={{ color: palette.foreground, ...style }}\n      {...props}\n    >\n      <g data-view={view} data-pitch={pitch} transform={frame || undefined}>\n        {showGround && (\n          <>\n            <path\n              data-ground\n              d={line(TURF_PATCH.map((corner) => to({ x: corner.x, y: GROUND, z: corner.y })), true)}\n              fill={variant === \"solid\" ? palette.dark : \"none\"}\n              fillOpacity={0.07}\n              stroke={palette.dark}\n              strokeWidth={1}\n              opacity={0.45}\n            />\n            <path\n              data-plate\n              d={line(PLATE.map((p) => to({ x: p.x, y: GROUND, z: plate.z + p.y })), true)}\n              fill={palette.dark}\n              opacity={0.28}\n            />\n          </>\n        )}\n\n        {/* Neither of the two behaviours that are not pitches has a flight, so\n            neither gets one drawn beside it. */}\n        {showPath && behavior !== \"spin\" && behavior !== \"static\" && (\n          <>\n            {/* The same pitch with the spin taken out: the gap is the break. */}\n            <path\n              data-datum\n              d={line(sample(false))}\n              fill=\"none\"\n              stroke={palette.grid}\n              strokeWidth={0.8}\n              strokeDasharray=\"3 3\"\n              opacity={0.75}\n            />\n            <path\n              data-path\n              d={line(sample(true))}\n              fill=\"none\"\n              stroke={palette.metal}\n              strokeWidth={1.3}\n              strokeLinecap=\"round\"\n              opacity={0.85}\n            />\n          </>\n        )}\n\n        <g data-ball>\n          {showGround && (\n            <ellipse\n              data-shadow\n              cx={px(to({ x: 0, y: GROUND, z: flight.position.z }).x)}\n              cy={px(to({ x: 0, y: GROUND, z: flight.position.z }).y)}\n              rx={px(BALL_RADIUS * 0.72)}\n              ry={px(1.6 + (BALL_RADIUS - 5) * camera.flatten)}\n              fill={palette.dark}\n              opacity={0.16}\n            />\n          )}\n\n          <path data-shell d={line(outline, true)} {...shell} />\n\n          {seam &&\n            visibleRuns(stitching).map((run, part) => (\n              <path\n                key={part}\n                data-seam={part}\n                d={line(run.map((mark) => to(shift(mark.point))))}\n                {...seamPaint}\n              />\n            ))}\n\n          {variant === \"solid\" && (\n            <ellipse\n              data-highlight\n              cx={px(to(shift({ x: -BALL_RADIUS * 0.3, y: BALL_RADIUS * 0.4, z: BALL_RADIUS * 0.5 })).x)}\n              cy={px(to(shift({ x: -BALL_RADIUS * 0.3, y: BALL_RADIUS * 0.4, z: BALL_RADIUS * 0.5 })).y)}\n              rx={6}\n              ry={4}\n              fill={palette.glow}\n              opacity={0.16}\n            />\n          )}\n        </g>\n      </g>\n\n      <g fontFamily=\"ui-monospace, monospace\" textAnchor=\"middle\" fill={palette.foreground}>\n        <text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 16} fontSize={5.5}>\n          {caption}\n        </text>\n        {label && (\n          <text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 7} fontSize={4.8} opacity={0.75}>\n            {label}\n          </text>\n        )}\n      </g>\n    </svg>\n  )\n}\n\n/** Sampled once: the seam is the same curve on every ball ever drawn. */\nconst SEAM = baseballSeam(0.3, 132)\n\n/** Home plate, in the ground plane: x across, y downrange from the point. */\nconst PLATE: Vec2[] = [\n  { x: -8, y: -8 },\n  { x: 8, y: -8 },\n  { x: 8, y: 0 },\n  { x: 0, y: 8 },\n  { x: -8, y: 0 },\n]\n\nexport { RobotBaseball }\n",
      "type": "registry:ui",
      "target": "@ui/robot-baseball.tsx"
    }
  ],
  "categories": [
    "robotics",
    "sport"
  ],
  "type": "registry:ui"
}