{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-hopper",
  "title": "Spring hopper",
  "description": "A single-legged hopping rig whose helical spring really compresses: the stance and the flight are solved from the drop height and the spring rate, the coil count never changes, and it cannot be crushed past its own solid height. Drag it down to load it and let go.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/hopper-dynamics.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/spring-hopper.tsx",
      "content": "\"use client\"\n\n/**\n * spring-hopper — a single-legged hopping rig on a real spring.\n *\n * The set jumps (`robot-frog`) but nothing in it bounces: a jump is a scripted\n * arc, and a bounce is a contact. Here the ground is part of the mechanism.\n * Flight is a parabola and stance is a mass on a linear spring, and `solveHop`\n * in `@/lib/robocn/hopper` decides which one the machine is in — including how\n * long each lasts, which is a consequence of the drop height and the spring\n * rate rather than a duty knob.\n *\n * The spring is drawn as a spring: a sampled helix whose coil count and radius\n * never change, because a real one compresses by twisting its wire. It cannot\n * pass through its own solid height, and when it lands there the machine stops\n * sinking — which you can reach from the demo by softening it under a full-\n * height drop.\n *\n * Solved: the bounce, the stroke, and the coil pitch. Illustrated: the leg\n * swing and the reaction wheel that answers it — a free machine really does\n * turn a wheel against its limb to aim the next landing, but the ratio here is\n * drawn, not an inertia model. Nothing travels across the frame.\n *\n * Design note: docs/bouncing-machines.md\n */\n\nimport * as React from \"react\"\n\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport {\n  hopTimings,\n  solveHop,\n  springCoils,\n  type HopState,\n} from \"@/lib/robocn/hopper\"\nimport { clamp, toRadians, type Vec2 } from \"@/lib/robocn/kinematics\"\nimport {\n  boxCorners,\n  elevationDraft,\n  fitTransform,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotSurface,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\nexport type SpringHopperBehavior = \"hop\" | \"bound\" | \"pump\" | \"static\"\n\nconst VIEW_WIDTH = 210\nconst VIEW_HEIGHT = 250\n/** Drawn from the side: a hopper leans to steer, and lean reads in profile. */\nconst NATIVE_VIEW: RobotView = \"profile\"\n\n/** Drawing units per hop unit. One hop unit is the machine's own apex scale. */\nconst HOP_SCALE = 70\n/** The leg at rest: pad, free spring, then the gimbal pin above it. */\nconst PAD_THICK = 9\nconst SPRING_FREE = 56\nconst HIP_RISE = 14\nconst BODY_RISE = 16\nconst COILS = 6\nconst COIL_RADIUS = 9\nconst WIRE = 2.4\n/** How far the leg swings in the air at full amplitude, in degrees. */\nconst SWING = 17\n/** The reaction wheel turns much further than the limb it answers. */\nconst WHEEL_GEAR = -7\n\n/** The box the machine moves inside, so the framing cannot breathe as it hops. */\nconst ENVELOPE = boxCorners(\n  { x: -24, y: 0, z: -46 },\n  { x: 24, y: HIP_RISE + SPRING_FREE + PAD_THICK + BODY_RISE + 46 + HOP_SCALE, z: 46 },\n)\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\nexport interface SpringHopperProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Where the camera stands. One rig, four projections. */\n  view?: RobotView\n  /** What it does when `compression` is not supplied. */\n  behavior?: SpringHopperBehavior\n  /**\n   * Controlled spring load, 0 free to 1 at the deepest this machine goes.\n   * Supplying it stops the loop and plants the machine: you cannot drive it\n   * into the air, because on the ground is the only place a spring is loaded.\n   */\n  compression?: number\n  onCompressionChange?: (compression: number) => void\n  /** Apex of the hop in hop units, 0–1. */\n  height?: number\n  /** Spring rate in weights per hop unit, 4–400. Sets the contact time. */\n  stiffness?: number\n  /** Hops per second. */\n  speed?: number\n  /** Seconds of offset, so a row of them breaks step. */\n  phase?: number\n  animate?: boolean\n  paused?: boolean\n  /** Drag down to load the spring; let go and it hops. */\n  interactive?: boolean\n  /** Mast lamp: neutral, accent, or shell. */\n  signal?: \"idle\" | \"ready\" | \"warning\"\n  size?: RobotSize | number\n  variant?: RobotVariant\n  showGround?: boolean\n  label?: string\n}\n\nconst finiteClamp = (value: number | undefined, min: number, max: number, fallback: number) =>\n  typeof value === \"number\" && Number.isFinite(value) ? clamp(value, min, max) : fallback\n\n/** A stroke the same colour as the part, through whatever the variant paints. */\nconst strokeOf = (surface: RobotSurface) =>\n  surface.fill === \"none\" ? surface.stroke : surface.fill\n\n/** Turn a leg point about the hip. Rotating in drawing units, before the camera. */\nconst swung = (point: Vec2, hip: Vec2, degrees: number): Vec2 => {\n  if (degrees === 0) return point\n  const angle = toRadians(degrees)\n  const cos = Math.cos(angle)\n  const sin = Math.sin(angle)\n  const dx = point.x - hip.x\n  const dy = point.y - hip.y\n  return { x: hip.x + dx * cos - dy * sin, y: hip.y + dx * sin + dy * cos }\n}\n\nfunction SpringHopper({\n  view = NATIVE_VIEW,\n  behavior = \"hop\",\n  compression,\n  onCompressionChange,\n  height = 0.5,\n  stiffness = 40,\n  speed = 0.8,\n  phase = 0,\n  animate = true,\n  paused = false,\n  interactive = false,\n  signal = \"ready\",\n  size = \"md\",\n  variant = \"solid\",\n  showGround = true,\n  label,\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}: SpringHopperProps) {\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 controlled = compression !== undefined\n  const [held, setHeld] = React.useState<number | null>(null)\n\n  const apex = finiteClamp(height, 0, 1, 0.5)\n  const rate = finiteClamp(stiffness, 4, 400, 40)\n  const load = controlled ? finiteClamp(compression, 0, 1, 0) : held\n\n  // One scalar: how much of the drawing the person's hand owns. Pinned at 1\n  // while they hold it, eased back to 0 on release while the clock runs on\n  // underneath — which is what makes letting go read as a machine resuming.\n  const motion = useRobotScalar(RELEASED, {\n    rate: 2.4,\n    hold: load === null ? null : 1,\n    speed,\n    phase,\n    paused,\n    animate: animate && !controlled,\n  })\n  const grip = clamp(motion.value, 0, 1)\n\n  const scripted = springHopperPose(behavior, motion.clock, { height: apex, stiffness: rate })\n  const pose = grip > 0 ? blend(scripted, stanceAt(load ?? 0, apex, rate), grip) : scripted\n\n  const apply = React.useCallback(\n    (next: number) => {\n      // Rounded: this is a reported value, and 0.25000000000000006 is noise.\n      const bounded = Math.round(clamp(Number.isFinite(next) ? next : 0, 0, 1) * 1000) / 1000\n      setHeld(bounded)\n      onCompressionChange?.(bounded)\n    },\n    [onCompressionChange],\n  )\n\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    // Down the frame loads the spring: the gesture a person tries on one.\n    onDrag: React.useCallback((unit: Vec2) => apply((unit.y - 0.3) / 0.5), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  const timings = hopTimings({ height: apex, stiffness: rate })\n  const swing = springHopperSwing(behavior, pose) * (1 - grip)\n  const rise = Math.max(0, pose.altitude) * HOP_SCALE\n  const coil = springCoils({\n    length: SPRING_FREE - pose.compression * HOP_SCALE,\n    turns: COILS,\n    radius: COIL_RADIUS,\n    wire: WIRE,\n  })\n\n  const hip: Vec2 = { x: 0, y: PAD_THICK + coil.length + HIP_RISE + rise }\n  const bodyY = hip.y + BODY_RISE\n  const springTop: Vec2 = { x: 0, y: hip.y - HIP_RISE }\n  const springBottom: Vec2 = { x: 0, y: springTop.y - coil.length }\n  const padTop = springBottom.y\n  const leg = (point: Vec2) => swung(point, hip, swing)\n\n  const camera = robotCamera(view)\n  const frame = fitTransform(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT)\n  const { path: line, solid, box, bar, disc } = elevationDraft(camera, \"profile\")\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n  const lamp = signal === \"warning\" ? palette.shell : signal === \"idle\" ? palette.metal : palette.accent\n\n  // The helix runs up the leg's own axis, so it is built along y and then swung.\n  const coilPoints = coil.points.map((point) =>\n    leg({ x: point.x, y: springBottom.y + point.y }),\n  )\n  const pad = [\n    { x: -19, y: padTop },\n    { x: 19, y: padTop },\n    { x: 15, y: padTop - PAD_THICK },\n    { x: -15, y: padTop - PAD_THICK },\n  ].map(leg)\n\n  // Reaction wheel: the spokes are what make the turn visible at all.\n  const wheelCentre: Vec2 = { x: 17, y: bodyY + 2 }\n  const wheelAngle = swing * WHEEL_GEAR + motion.clock * 24\n  const spokes = Array.from({ length: 3 }, (_, index) => {\n    const turn = wheelAngle + index * 60\n    const a = swung({ x: wheelCentre.x + 8, y: wheelCentre.y }, wheelCentre, turn)\n    const b = swung({ x: wheelCentre.x - 8, y: wheelCentre.y }, wheelCentre, turn)\n    return line([a, b], 9)\n  }).join(\" \")\n\n  const readout = Math.round((grip > 0 ? (load ?? 0) : pose.squeeze) * 100)\n  const state = pose.contact ? \"stance\" : \"flight\"\n  const shadow = 1 - Math.min(0.55, (rise / HOP_SCALE) * 0.9)\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Spring hopper, ${state}, spring ${readout} percent loaded, ${viewNames[view] ?? viewNames.profile}`\n      }\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? 100 : undefined}\n      aria-valuenow={interactive ? readout : undefined}\n      aria-valuetext={interactive ? `spring ${readout} percent loaded` : 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, event.shiftKey ? 0.15 : 0.05, 0.25)\n        if (delta !== 0) apply((load ?? pose.squeeze) + 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-hopper data-view={view} data-contact={state} transform={frame || undefined}>\n        {showGround && (\n          <g data-ground>\n            <path\n              d={solid([{ x: -30 * shadow, y: 0 }, { x: 30 * shadow, y: 0 }], 20 * shadow)}\n              fill={palette.dark}\n              opacity={px(0.18 * shadow)}\n            />\n            {/* A horizon from any tilted camera; straight down it parts around\n                the machine rather than running through it. */}\n            <path\n              d={\n                camera.lift > 0.02\n                  ? line([{ x: -46, y: 0 }, { x: 46, y: 0 }])\n                  : `${line([{ x: -46, y: 0 }, { x: -26, y: 0 }])} ${line([{ x: 26, y: 0 }, { x: 46, y: 0 }])}`\n              }\n              fill=\"none\"\n              stroke={palette.grid}\n              strokeWidth={0.9}\n            />\n          </g>\n        )}\n\n        {variant === \"blueprint\" && (\n          <g fill=\"none\" stroke={palette.grid} strokeWidth={0.5} opacity={0.5} strokeDasharray=\"3 4\">\n            {/* The two lengths that bound the stroke: free, and solid. */}\n            <path d={line([{ x: -34, y: PAD_THICK + SPRING_FREE }, { x: 34, y: PAD_THICK + SPRING_FREE }])} />\n            <path d={line([{ x: -26, y: PAD_THICK + coil.solid }, { x: 26, y: PAD_THICK + coil.solid }])} />\n            <path d={line([{ x: 0, y: 0 }, { x: 0, y: ENVELOPE[1].y }])} />\n          </g>\n        )}\n\n        {/* Leg: pad, spring and shaft, all hung off the hip and swung with it. */}\n        <g data-hip data-swing={px(swing)}>\n          <path data-shaft d={bar(leg({ x: 0, y: hip.y }), leg({ x: 0, y: padTop + 4 }), 3.6, 3.6)} {...machined} />\n          <path\n            data-spring\n            data-bottomed={coil.bottomedOut ? \"true\" : \"false\"}\n            d={line(coilPoints, 0)}\n            fill=\"none\"\n            stroke={strokeOf(machined)}\n            strokeWidth={WIRE}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n          {/* Spring seats: the cups the coil actually pushes against. */}\n          <path d={bar(leg({ x: -12, y: springTop.y }), leg({ x: 12, y: springTop.y }), 2.4, 11)} {...cast} />\n          <path d={bar(leg({ x: -13, y: springBottom.y }), leg({ x: 13, y: springBottom.y }), 2.6, 12)} {...cast} />\n          <path data-foot d={solid(pad, 13)} {...cast} />\n          <path d={line([pad[3], pad[2]], 13)} fill=\"none\" stroke={palette.metal} strokeWidth={1.2} opacity={0.7} />\n        </g>\n\n        {/* Hip gimbal: the pin the leg swings about, and the only bearing here. */}\n        <path d={disc(hip, 6.5, 14)} {...cast} />\n        <path d={disc(hip, 2.6, 15)} {...machined} />\n\n        <g data-body data-height={px(bodyY)}>\n          <path d={box(-31, bodyY - 12, 31, bodyY + 14, 16)} {...shell} />\n          {/* Guide sleeve: the shaft slides into this, which is the stroke. */}\n          <path d={box(-7, bodyY - 17, 7, bodyY - 4, 8)} {...machined} />\n          <path d={line([{ x: -24, y: bodyY + 6 }, { x: 24, y: bodyY + 6 }], 16)} fill=\"none\" stroke={palette.dark} strokeWidth={1} opacity={0.45} />\n          {/* Grille: three slots, and the bolts that hold the deck down. */}\n          {[-14, -6, 2].map((x) => (\n            <path key={x} d={line([{ x, y: bodyY - 6 }, { x, y: bodyY + 2 }], 17)} fill=\"none\" stroke={palette.dark} strokeWidth={1.4} opacity={0.5} />\n          ))}\n          {[-26, 26].map((x) => (\n            <path key={x} d={disc({ x, y: bodyY + 11 }, 1.8, 17)} {...machined} />\n          ))}\n          <path d={box(-18, bodyY + 14, 18, bodyY + 19, 13)} {...machined} />\n\n          <g data-gyro-hub>\n            <path d={disc(wheelCentre, 10, 8)} {...machined} />\n            <path d={disc(wheelCentre, 3, 10)} {...cast} />\n            <path\n              data-gyro\n              data-angle={px(wheelAngle)}\n              d={spokes}\n              fill=\"none\"\n              stroke={palette.dark}\n              strokeWidth={1.3}\n              opacity={0.75}\n            />\n          </g>\n\n          <g data-mast>\n            <path d={bar({ x: -20, y: bodyY + 19 }, { x: -20, y: bodyY + 38 }, 1.8, 1.8)} {...machined} />\n            <path d={disc({ x: -20, y: bodyY + 40 }, 3.4, 3.4)} fill={lamp} stroke=\"none\" />\n            <path d={disc({ x: -20, y: bodyY + 40 }, 5.6, 3.4)} fill={palette.glow} opacity={0.35} stroke=\"none\" />\n          </g>\n        </g>\n      </g>\n\n      {label && (\n        <text\n          x={VIEW_WIDTH / 2}\n          y={VIEW_HEIGHT - 8}\n          textAnchor=\"middle\"\n          fontFamily=\"ui-monospace, monospace\"\n          fontSize={6}\n          fill={palette.foreground}\n        >\n          {label}\n          {variant === \"blueprint\" ? ` · duty ${Math.round(timings.duty * 100)}%` : \"\"}\n        </text>\n      )}\n    </svg>\n  )\n}\n\n/** Nothing held: the goal the released value eases back to. */\nconst RELEASED = () => 0\n\n/** Sat on the ground with the spring loaded to `fraction` of its deepest. */\nfunction stanceAt(fraction: number, height: number, stiffness: number): HopState {\n  const squeeze = clamp(Number.isFinite(fraction) ? fraction : 0, 0, 1)\n  const depth = hopTimings({ height, stiffness }).depth\n  const compression = squeeze * depth\n  return {\n    altitude: -compression,\n    compression,\n    squeeze,\n    contact: true,\n    velocity: 0,\n    load: stiffness * compression,\n    bounce: 0,\n    resting: false,\n  }\n}\n\n/** Hand-held pose over behaviour, by however much of it the hand still owns. */\nfunction blend(free: HopState, gripped: HopState, amount: number): HopState {\n  const mix = (a: number, b: number) => a + (b - a) * amount\n  return {\n    altitude: mix(free.altitude, gripped.altitude),\n    compression: mix(free.compression, gripped.compression),\n    squeeze: mix(free.squeeze, gripped.squeeze),\n    contact: amount > 0.5 ? gripped.contact : free.contact,\n    velocity: mix(free.velocity, gripped.velocity),\n    load: mix(free.load, gripped.load),\n    bounce: free.bounce,\n    resting: false,\n  }\n}\n\n/**\n * What the machine does with no hand on it — a pure function of the clock in\n * cycles. `hop` and `bound` are the solved bounce at two drop heights; `pump`\n * never leaves the ground, which is what a person on a pogo stick does before\n * they commit.\n */\nexport function springHopperPose(\n  behavior: SpringHopperBehavior,\n  clock: number,\n  { height = 0.5, stiffness = 40 }: { height?: number; stiffness?: number } = {},\n): HopState {\n  const time = Number.isFinite(clock) ? clock : 0\n  switch (behavior) {\n    case \"bound\":\n      return solveHop({ phase: time, height: Math.min(1, height * 1.5), stiffness })\n    case \"pump\": {\n      const depth = hopTimings({ height, stiffness }).depth\n      const cycle = ((time % 1) + 1) % 1\n      const squeeze = 0.75 * (1 - Math.cos(2 * Math.PI * cycle)) * 0.5\n      const compression = squeeze * depth\n      return {\n        altitude: -compression,\n        compression,\n        squeeze,\n        contact: true,\n        velocity: -0.75 * depth * Math.PI * Math.sin(2 * Math.PI * cycle),\n        load: stiffness * compression,\n        bounce: 0,\n        resting: false,\n      }\n    }\n    case \"static\": {\n      const sag = 1 / stiffness\n      const depth = hopTimings({ height, stiffness }).depth\n      return {\n        altitude: -sag,\n        compression: sag,\n        squeeze: depth > 0 ? sag / depth : 0,\n        contact: true,\n        velocity: 0,\n        load: 1,\n        bounce: 0,\n        resting: true,\n      }\n    }\n    default:\n      return solveHop({ phase: time, height, stiffness })\n  }\n}\n\n/**\n * Leg swing, in degrees, forward positive. It is the vertical velocity: the\n * leg trails as the machine rises and reaches out before it lands, and it is\n * pinned at zero on the ground because a planted foot does not move.\n */\nexport function springHopperSwing(behavior: SpringHopperBehavior, pose: HopState): number {\n  if (pose.contact || !Number.isFinite(pose.velocity)) return 0\n  const amplitude = behavior === \"bound\" ? 1 : behavior === \"hop\" ? 0.45 : 0\n  return clamp(pose.velocity * 12, -SWING, SWING) * amplitude\n}\n\nexport { SpringHopper }\n",
      "type": "registry:ui",
      "target": "@ui/spring-hopper.tsx"
    }
  ],
  "categories": [
    "robotics",
    "droids"
  ],
  "type": "registry:ui"
}