{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cross-trainer",
  "title": "Cross trainer",
  "description": "A crank and rocker whose footpad rides the coupler: the stride and the shape of the foot path are what the link lengths produce, not a traced ellipse.",
  "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/gym-geometry.json"
  ],
  "files": [
    {
      "path": "src/components/ui/cross-trainer.tsx",
      "content": "\"use client\"\n\n/**\n * cross-trainer — a crank and a rocker, and the closed curve their coupler\n * draws.\n *\n * The foot does not follow an ellipse somebody traced. The pedal arm is the\n * **coupler** of a four-bar: its rear end is pinned to the crank, its front end\n * hangs off the swing arm, and the footpad is a point rigidly fixed on it. The\n * path that point draws is a coupler curve — closed, egg-shaped, and distinctly\n * not an ellipse. `stride` is the horizontal extent of that curve, measured off\n * the solved loop over a whole revolution, so changing the crank radius or any\n * link length changes both the number and the shape of the path.\n *\n * The grip is the same swing arm carried on past its pivot, so arms and feet\n * cannot drift out of phase: they are one linkage. The two sides run half a\n * revolution apart, which is the crank, not an offset anyone added.\n *\n * The mechanism is solved in `src/lib/robocn/gym.ts` over `solveFourBar` from\n * `linkage.ts` — pure, no React, tested on its own. The drawing only reads the\n * pose it produces.\n *\n * Illustrated: there is no resistance model here at all. The flywheel is drawn,\n * not spun — nothing computes inertia, brake torque or the effort the path\n * costs. `rowing-erg` is the machine in this family that does dynamics.\n *\n * Drawn once in the profile elevation and pushed through `robotCamera`, so all\n * four views are the same geometry rather than four drawings that drift apart.\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { clamp, toDegrees, type Vec2 } from \"@/lib/robocn/kinematics\"\nimport {\n  defaultTrainerGeometry,\n  solveTrainer,\n  trainerFootPath,\n  type TrainerGeometry,\n} from \"@/lib/robocn/gym\"\nimport {\n  boxCorners,\n  elevationDraft,\n  fitFrame,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\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 machine does with nobody driving it. Always includes `static`. */\nexport type CrossTrainerBehavior = \"stride\" | \"sprint\" | \"coast\" | \"static\"\n\nconst VIEW_WIDTH = 230\nconst VIEW_HEIGHT = 250\nconst NATIVE_VIEW: RobotView = \"profile\"\n\n/** Where the crank shaft sits in the drawing. The solver works about it. */\nconst CRANK: Vec2 = { x: -44, y: 128 }\n/** Half the track between the two pedal arms, out of the drawing plane. */\nconst PEDAL_TRACK = 21\nconst BASE_Y = 11\n\n/** World units. x along the drawing, y up from the ground, z out of the plane. */\nconst ENVELOPE = boxCorners({ x: -36, y: 0, z: -118 }, { x: 36, y: 240, z: 92 })\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\nconst wrap360 = (value: number) =>\n  Number.isFinite(value) ? ((value % 360) + 360) % 360 : 0\n\n/**\n * Where the crank shaft lands in the viewBox from one camera. Recomputed inside\n * the drag handler so the only thing it closes over is the view.\n */\nfunction crankHub(view: RobotView): Vec2 {\n  const camera = robotCamera(view)\n  const frame = fitFrame(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT)\n  return frame.toViewBox(camera.project(0, CRANK.y, -CRANK.x))\n}\n\n/**\n * Crank angle at `clock`, in degrees and **unwrapped**, so the easing never has\n * to cross a seam and rewind a revolution. Every behaviour is a pure function\n * of the clock, exported so motion can be tested by sampling it rather than by\n * faking animation frames.\n */\nexport function crossTrainerCrank(behavior: CrossTrainerBehavior, clock: number): number {\n  if (behavior === \"static\" || !Number.isFinite(clock)) return 210\n  switch (behavior) {\n    // Pushing hard, then riding it: fast through the drive, slower coming round.\n    case \"sprint\":\n      return clock * 720 + Math.sin(clock * Math.PI * 2) * 70\n    // Letting it run down: the turns get shorter and never quite stop.\n    case \"coast\":\n      return 900 * (1 - Math.exp(-clock * 0.6))\n    default:\n      return clock * 360\n  }\n}\n\nexport interface CrossTrainerProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Controlled crank angle in degrees. Supplying it stops the loop. */\n  crankAngle?: number\n  onCrankAngleChange?: (crankAngle: number) => void\n  behavior?: CrossTrainerBehavior\n  /** The driven link. Longer crank, longer stride — but not twice it. */\n  crank?: number\n  /** The pedal arm between the crank pin and the swing arm. */\n  coupler?: number\n  /** How far along the pedal arm the footpad is fixed. */\n  padAlong?: number\n  /** Draw the closed path the footpad actually follows. */\n  showPath?: boolean\n  showGround?: boolean\n  /** Where the camera stands. Defaults to the view the machine was drawn in. */\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 CrossTrainer({\n  crankAngle,\n  onCrankAngleChange,\n  behavior = \"stride\",\n  crank = defaultTrainerGeometry.crank,\n  coupler = defaultTrainerGeometry.coupler,\n  padAlong = defaultTrainerGeometry.padAlong,\n  showPath = true,\n  showGround = true,\n  view = NATIVE_VIEW,\n  speed = 0.3,\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}: CrossTrainerProps) {\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 = crankAngle !== undefined\n\n  const hold = controlled ? (Number.isFinite(crankAngle) ? (crankAngle as number) : 0) : held\n  const goal = React.useCallback((clock: number) => crossTrainerCrank(behavior, clock), [behavior])\n  const motion = useRobotScalar(goal, {\n    // Fast enough to keep up with its own goal, or the crank lags a revolution\n    // behind and the pedal arms stop matching the wheel.\n    rate: Math.max(260, Math.abs(speed) * 900),\n    hold,\n    speed,\n    paused,\n    phase,\n    animate: animate && !controlled && behavior !== \"static\",\n  })\n  const turn = wrap360(motion.value)\n\n  const geometry: TrainerGeometry = React.useMemo(\n    () => ({\n      ...defaultTrainerGeometry,\n      crank: Math.abs(Number.isFinite(crank) ? crank : defaultTrainerGeometry.crank),\n      coupler: Math.abs(Number.isFinite(coupler) ? coupler : defaultTrainerGeometry.coupler),\n      padAlong: Number.isFinite(padAlong) ? padAlong : defaultTrainerGeometry.padAlong,\n    }),\n    [crank, coupler, padAlong],\n  )\n\n  // The path and its stride belong to the linkage, not to this frame, so they\n  // are solved once per geometry rather than once per frame.\n  const track = React.useMemo(() => trainerFootPath(geometry, 72), [geometry])\n  const near = solveTrainer(turn, geometry)\n  const far = solveTrainer(turn + 180, geometry)\n\n  const camera = robotCamera(view)\n  const frame = fitFrame(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT).transform\n  const { point: to, path: line, solid, box, bar, disc } = elevationDraft(camera, \"profile\")\n\n  const apply = React.useCallback(\n    (next: number) => {\n      setHeld(next)\n      onCrankAngleChange?.(wrap360(next))\n    },\n    [onCrankAngleChange],\n  )\n  // The pointer's bearing about the crank centre is the crank angle: grabbing\n  // the machine turns the wheel by hand.\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback(\n      (unit: Vec2) => {\n        const hub = crankHub(view)\n        const x = unit.x * VIEW_WIDTH - hub.x\n        const y = unit.y * VIEW_HEIGHT - hub.y\n        // Dead zone at the hub, where a bearing is all noise.\n        if (Math.hypot(x, y) < 4) return\n        // Screen y runs down, so the bearing is negated to come out as a\n        // counter-clockwise crank angle like everything else in the set.\n        apply(wrap360(toDegrees(Math.atan2(-y, x))))\n      },\n      [apply, view],\n    ),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n\n  /* ------------------------------------------------ the linkage, in place */\n\n  const at = React.useCallback(\n    (point: Vec2): Vec2 => ({ x: CRANK.x + point.x, y: CRANK.y + point.y }),\n    [],\n  )\n  const mast = at(near.rockerPivot)\n  const sides = [\n    { pose: near, depth: PEDAL_TRACK, key: \"near\" },\n    { pose: far, depth: -PEDAL_TRACK, key: \"far\" },\n  ]\n  const path = track.path.map(at)\n\n  const stride = Math.round(track.stride * 10) / 10\n  const rise = Math.round(track.rise * 10) / 10\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Cross trainer, crank at ${px(turn)} degrees, stride ${stride} by ${rise} off a crank of ${px(geometry.crank)}, ${viewNames[view] ?? viewNames.profile}`\n      }\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? 360 : undefined}\n      aria-valuenow={interactive ? px(turn) : undefined}\n      aria-valuetext={interactive ? `${px(turn)} degrees, stride ${stride}` : 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, 6, 30)\n        if (delta !== 0) apply(turn + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(180)\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      {variant === \"blueprint\" && (\n        <path\n          d={`M 10 ${VIEW_HEIGHT - 24} H ${VIEW_WIDTH - 10}`}\n          fill=\"none\"\n          stroke={palette.grid}\n          strokeWidth={0.5}\n          strokeDasharray=\"3 4\"\n          opacity={0.4}\n        />\n      )}\n\n      <g data-view={view} transform={frame || undefined}>\n        {showGround && (\n          <>\n            <path\n              data-ground\n              d={solid([{ x: -88, y: 0 }, { x: 116, y: 0 }], 36)}\n              fill={palette.dark}\n              opacity={0.12}\n            />\n            <path\n              d={line([{ x: -88, y: 0 }, { x: 116, y: 0 }])}\n              fill=\"none\"\n              stroke={palette.dark}\n              strokeWidth={1}\n              opacity={0.5}\n            />\n          </>\n        )}\n\n        <g data-frame>\n          <path d={box(-86, 0, 114, BASE_Y, 32)} {...cast} />\n          {/* The shroud the crank runs inside, and the mast the arms hang off. */}\n          <path d={bar({ x: CRANK.x, y: BASE_Y }, CRANK, 13, 15)} {...shell} />\n          <path d={bar({ x: mast.x, y: BASE_Y }, mast, 7, 9)} {...shell} />\n          <path d={box(mast.x - 12, mast.y + 6, mast.x + 12, mast.y + 20, 10)} {...cast} />\n        </g>\n\n        {/* The path the footpad draws. Solved over a whole turn, not traced. */}\n        {showPath && (\n          <path\n            data-foot-path\n            data-stride={px(track.stride)}\n            d={line(path, 0, true)}\n            fill=\"none\"\n            stroke={palette.glow}\n            strokeWidth={1.2}\n            strokeDasharray=\"4 3\"\n            opacity={0.5}\n          />\n        )}\n\n        {/* The flywheel on the crank shaft. Drawn, not spun. */}\n        <g data-flywheel>\n          <path d={disc(CRANK, geometry.crank + 10, 4, -6)} {...machined} />\n          <path d={disc(CRANK, geometry.crank - 4, 4.6, -6)} {...cast} />\n          {[0, 60, 120].map((spoke) => {\n            const radians = ((turn + spoke) * Math.PI) / 180\n            const reach = geometry.crank + 6\n            return (\n              <path\n                key={spoke}\n                d={line(\n                  [\n                    { x: CRANK.x - Math.cos(radians) * reach, y: CRANK.y - Math.sin(radians) * reach },\n                    { x: CRANK.x + Math.cos(radians) * reach, y: CRANK.y + Math.sin(radians) * reach },\n                  ],\n                  -10,\n                )}\n                fill=\"none\"\n                stroke={palette.metal}\n                strokeWidth={1.4}\n                opacity={0.8}\n              />\n            )\n          })}\n          <path d={disc(CRANK, 7, 6, -6)} {...cast} />\n        </g>\n\n        {/* Both sides of the linkage, half a revolution apart. */}\n        {sides.map(({ pose, depth, key }) => {\n          const pin = at(pose.crankPin)\n          const couplerPin = at(pose.couplerPin)\n          const foot = at(pose.foot)\n          const grip = at(pose.grip)\n          const dim = key === \"far\" ? 0.55 : 1\n          return (\n            <g key={key} data-side={key} opacity={dim}>\n              {/* Swing arm: the rocker, carried on past its pivot to the grip. */}\n              <g data-swing-arm>\n                <path d={bar(grip, couplerPin, 3.4, 3.4, depth * 0.7)} {...machined} />\n                <path d={disc(grip, 5.5, 5, depth * 0.7)} {...shell} />\n                <path d={disc(grip, 2.2, 6, depth * 0.7)} fill={palette.accent} stroke=\"none\" />\n                <path d={disc(mast, 5, 5, depth * 0.7)} {...cast} />\n              </g>\n\n              {/* Pedal arm: the coupler, with the footpad rigid on it. */}\n              <g data-pedal-arm>\n                <path d={bar(pin, foot, 3.6, 3.6, depth)} {...machined} />\n                <path d={disc(pin, 4.4, 3.4, depth)} {...cast} />\n                <path d={disc(couplerPin, 4, 3.4, depth)} {...cast} />\n              </g>\n\n              <g data-footpad data-foot-x={px(pose.foot.x)}>\n                <path\n                  d={bar(\n                    { x: foot.x - 15, y: foot.y + 3 },\n                    { x: foot.x + 15, y: foot.y + 3 },\n                    4,\n                    9,\n                    depth,\n                  )}\n                  {...shell}\n                />\n              </g>\n\n              {/* The crank throw, so the two sides visibly oppose each other. */}\n              <path d={bar(CRANK, pin, 3, 3, depth * 0.45)} {...cast} />\n            </g>\n          )\n        })}\n\n        {variant === \"blueprint\" && (\n          <text\n            x={px(to({ x: 52, y: 78 }).x)}\n            y={px(to({ x: 52, y: 78 }).y)}\n            textAnchor=\"middle\"\n            fontFamily=\"ui-monospace, monospace\"\n            fontSize={7}\n            fill={palette.foreground}\n          >\n            {`stride ${stride}`}\n          </text>\n        )}\n      </g>\n\n      {label && (\n        <text\n          x={VIEW_WIDTH / 2}\n          y={VIEW_HEIGHT - 7}\n          textAnchor=\"middle\"\n          fontFamily=\"ui-monospace, monospace\"\n          fontSize={6}\n          fill={palette.foreground}\n        >\n          {label}\n        </text>\n      )}\n    </svg>\n  )\n}\n\nexport { CrossTrainer }\n",
      "type": "registry:ui",
      "target": "@ui/cross-trainer.tsx"
    }
  ],
  "categories": [
    "robotics",
    "gym"
  ],
  "type": "registry:ui"
}