Basketball
Eight panels cut by two great circles and one wavy seam, on a ball that bounces by closed-form restitution rather than a tween: every apex is the last one times e squared.
Open in workbenchview
variant
motion
seams
- restitution
- 0.76
- second apex
- 58% of the first
- third apex
- 33% of the first
- picked up to
- running
Take drop and watch the ladder run down: every apex is the last one times e², exactly, and the gaps shorten with it. Drag up and down to pick the ball up — let go and it falls back into whatever the bounce has moved on to.
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/robot-basketball.jsonNotes
- Solved: the panels, which are three closed curves on the surface rather than artwork — two orthogonal great circles cut four lunes, and one wavy curve splits each of them. And the bounce, which is the restitution ladder in closed form: apex n is `drop·e^2n` and flight n is `e` times the one before, so the ball can be asked where it is at any instant without having run the instants before it.
- Illustrated: the squash at contact, which is impact speed against a reference rather than a contact-patch model; and the energy hand-back in `dribble` and `travel`, where each bounce comes back to the same apex. `dribble` at least draws the paddle doing it; `drop` is the one that takes nothing back.
- Nothing here spins for a reason — the ball turns at a rate, it is not solved from the contact. `robot-soccer-ball` is the one that ties its turn to its travel.
Usage
import { RobotBasketball } from "@/components/ui/robot-basketball"
// The whole decay, played out over one cycle.
<RobotBasketball behavior="drop" />
// Or pick the ball up, which stops the loop.
<RobotBasketball height={0.8} onHeightChange={setHeight} interactive />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| height | number | — | Height off the floor, 0 down and 1 at the apex. Supplying it stops the loop. |
| onHeightChange | (height: number) => void | — | Fires while it is dragged or keyed, so interaction works in controlled mode too. |
| behavior | "dribble" | "travel" | "drop" | "spin" | "static" | "dribble" | What it does with nobody driving it. `drop` runs the whole restitution ladder down; `dribble` is the periodic version with a paddle putting the energy back. |
| interactive | boolean | false | Hand it to a person: drag up and down to pick the ball up, or focus it and use the arrow keys. Let go and it falls back into the bounce. |
| seams | boolean | true | Draw the three curves that cut the eight panels, culled to the half the camera can see. |
| showGround | boolean | true | Draw the floor line and the contact shadow, which tightens as the ball comes down. |
| label | string | — | Optional technical caption under the drawing. |
| view | "plan" | "front" | "profile" | "iso" | "profile" | Where the camera stands. One ball, four projections: straight down, straight on, side elevation, or three-quarter from above. |
| speed | number | 0.9 | Bounces per second. |
| animate | boolean | true | Off parks the machine at phase and stops rendering. A reduced-motion preference does the same. |
| paused | boolean | false | Freeze where it stands. |
| phase | number | 0 | Seconds of offset, so a row of machines breaks step. |
| variant | "solid" | "outline" | "blueprint" | "wire" | "solid" | How the machine is painted. Geometry never changes between variants. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | number | "md" | Rendered width in pixels, or a step on the scale. |
| color | string | var(--robot-shell) | Body panels — the colour the machine reads as. |
| accent | string | var(--robot-accent) | Status colour: tip light, live tool, readouts. |
| metal | string | var(--robot-metal) | Bare machined parts: collars, bolts, tool bodies. |
| dark | string | var(--robot-dark) | Cast joints, base, shadow side. |
| palette | Partial<RobotPalette> | — | Override any subset of roles at once, including glow and grid. |
Source
src/components/ui/robot-basketball.tsx
"use client"
/**
* robot-basketball — eight panels, and a bounce that is arithmetic.
*
* The panels are cut the way a real one is: two orthogonal great circles divide
* the sphere into four lunes, and one wavy closed curve — `lat = A·sin(2·lon)` —
* enters each lune on one meridian and leaves it on the other, splitting it.
* Four lunes, eight panels, out of three curves, all of them lying on the
* surface, so turning the ball takes them round the back instead of sliding
* them across the front.
*
* The bounce is the restitution ladder, closed form: every apex is the last one
* times `e²` and every flight is the last one times `e`, so the ball can be
* asked where it is at any instant without having run the instants before it.
* `drop` plays the whole decay; `dribble` is the periodic version, where a
* paddle hands back exactly what the floor took — that hand-back is the one
* thing here that is not physics, and it is drawn as a machine so you can see
* it happening.
*
* The maths is in `src/lib/robocn/sport.ts` — pure, no React, tested on its
* own. Illustrated, and only illustrated: the squash at contact. A real ball's
* contact patch comes from its inflation pressure and its dwell time, neither
* of which is modelled — this is impact speed against a reference, so it reads
* as heavier when it lands harder.
*/
import * as React from "react"
import { arrowStep, useRobotClock, useRobotDrag } from "@/hooks/use-robot-motion"
import { clamp, type Vec2, type Vec3 } from "@/lib/robocn/kinematics"
import {
basketballSeams,
bounceAt,
bounceDuration,
contactSquash,
dribbleAt,
rollTurns,
sphereSilhouette,
spinFrame,
surfaceCurve,
visibleRuns,
} from "@/lib/robocn/sport"
import {
boxCorners,
fitTransform,
px,
resolveRobotPalette,
resolveRobotSize,
robotCamera,
robotSurface,
type RobotCamera,
type RobotPaletteProps,
type RobotSize,
type RobotVariant,
type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"
/** What the ball does with nobody driving it. Always includes `static`. */
export type BasketballBehavior = "dribble" | "travel" | "drop" | "spin" | "static"
const VIEW_WIDTH = 190
const VIEW_HEIGHT = 230
const NATIVE_VIEW: RobotView = "profile"
/** The bottom strip the readout sits in; the drawing is fitted above it. */
const TEXT_ROOM = 30
const RADIUS = 23
const APEX = 112
/** The drop plays out over one cycle, so the ladder is visible end to end. */
const RESTITUTION = 0.76
const GRAVITY = 900
/** How far a travelling ball goes each bounce, and how far it may stray. */
const DRIFT = 58
const SPIN_TURNS = 0.7
/** The landing speed a full squash is measured against, in these units. */
const IMPACT_REFERENCE = 460
/**
* The floor is a *plane*, so it is drawn as one. A line along a single world
* axis collapses to a stick the moment the camera looks down that axis — which
* is exactly what a side elevation does — where a patch foreshortens into a
* parallelogram from every angle and never degenerates.
*/
const FLOOR_PATCH: Vec2[] = [
{ x: -84, y: 44 },
{ x: 84, y: 44 },
{ x: 84, y: -44 },
{ x: -84, y: -44 },
]
/** What every camera has to fit: the floor, the ball's apex, and the paddle. */
const ENVELOPE = boxCorners({ x: -84, y: 0, z: -44 }, { x: 84, y: 172, z: 44 })
const bounceOptions = { drop: APEX, restitution: RESTITUTION, gravity: GRAVITY, contact: 0.04 }
const SETTLE = bounceDuration(bounceOptions)
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
const descriptions: Record<BasketballBehavior, string> = {
dribble: "dribbling on a paddle",
travel: "bouncing across the floor",
drop: "dropped, and running down its restitution ladder",
spin: "held up and turning",
static: "held still",
}
/** Which way the camera lies from the ball: the gradient of its own depth. */
function viewDirection(camera: RobotCamera): Vec3 {
return { x: camera.depth(1, 0, 0), y: camera.depth(0, 1, 0), z: camera.depth(0, 0, 1) }
}
export interface BasketballPose {
/** Bottom of the ball above the floor. */
height: number
/** Revolutions turned, for the panels to follow. */
turns: number
/** 1 at an impact, decaying to 0 — what the squash is drawn from. */
contact: number
/** Speed of the impact that contact belongs to. */
impact: number
/** Where the paddle is, or null when nothing is driving the ball. */
paddle: number | null
/** How far across the floor the ball has strayed. */
drift: number
}
const REST: BasketballPose = { height: 0, turns: 0, contact: 0, impact: 0, paddle: null, drift: 0 }
/**
* Every behaviour is a pure function of the clock, exported so motion is tested
* by sampling it rather than by faking animation frames.
*/
export function basketballPose(behavior: BasketballBehavior, clock: number): BasketballPose {
if (!Number.isFinite(clock)) return REST
switch (behavior) {
case "dribble": {
const state = dribbleAt(clock, { apex: APEX, reach: APEX * 1.22 })
return {
height: state.height,
turns: clock * SPIN_TURNS,
contact: state.contact,
impact: Math.abs(state.velocity),
paddle: state.paddle,
drift: 0,
}
}
case "travel": {
const state = dribbleAt(clock, { apex: APEX * 0.78, reach: APEX })
// It rolls on every contact, so the spin it leaves with is the distance
// it covered over its own circumference.
const drift = DRIFT * Math.sin((Math.PI * clock) / 2)
return {
height: state.height,
turns: rollTurns(drift, RADIUS),
contact: state.contact,
impact: Math.abs(state.velocity),
paddle: null,
drift,
}
}
case "drop": {
const state = bounceAt((((clock % 1) + 1) % 1) * SETTLE, bounceOptions)
return {
height: state.height,
turns: clock * SPIN_TURNS * 0.5,
contact: state.contact,
impact: state.impact,
paddle: null,
drift: 0,
}
}
case "spin":
return { ...REST, height: APEX * 0.42, turns: clock * SPIN_TURNS * 1.6 }
default:
return REST
}
}
export interface RobotBasketballProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
/** Controlled height off the floor, 0 down to 1 at the apex. Stops the loop. */
height?: number
onHeightChange?: (height: number) => void
behavior?: BasketballBehavior
showGround?: boolean
seams?: boolean
/** Where the camera stands. Defaults to the view it was drawn in. */
view?: RobotView
speed?: number
phase?: number
paused?: boolean
animate?: boolean
interactive?: boolean
label?: string
size?: RobotSize | number
variant?: RobotVariant
}
function RobotBasketball({
height,
onHeightChange,
behavior = "dribble",
showGround = true,
seams = true,
view = NATIVE_VIEW,
speed = 0.9,
phase = 0,
paused = false,
animate = true,
interactive = false,
label,
size = "md",
variant = "solid",
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
role,
tabIndex,
onKeyDown,
onBlur,
"aria-label": ariaLabel,
...props
}: RobotBasketballProps) {
const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
const width = resolveRobotSize(size)
const svgRef = React.useRef<SVGSVGElement>(null)
const [held, setHeld] = React.useState<number | null>(null)
const controlled = height !== undefined
const clock = useRobotClock({
speed,
animate: animate && !controlled && held === null && behavior !== "static",
paused,
phase,
})
const running = basketballPose(behavior, clock)
const pinned = controlled
? Number.isFinite(height) ? clamp(height as number, 0, 1) : 0
: held
// Picking the ball up stops the bounce: there is no impact to squash from.
const pose: BasketballPose =
pinned === null
? running
: { ...REST, height: pinned * APEX, turns: running.turns }
const apply = React.useCallback(
(next: number) => {
const bounded = Math.round(clamp(next, 0, 1) * 1000) / 1000
setHeld(bounded)
onHeightChange?.(bounded)
},
[onHeightChange],
)
const dragging = useRobotDrag(svgRef, {
enabled: interactive,
onDrag: React.useCallback((unit: Vec2) => apply(1 - unit.y), [apply]),
onDragEnd: React.useCallback(() => setHeld(null), []),
})
const camera = robotCamera(view)
const fitted = fitTransform(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT - TEXT_ROOM)
const look = viewDirection(camera)
const to = (point: Vec3): Vec2 => camera.project(point.x, point.y, point.z)
const line = (points: readonly Vec2[], close = false) =>
`${points.map((p, i) => `${i ? "L" : "M"} ${px(p.x)} ${px(p.y)}`).join(" ")}${close ? " Z" : ""}`
const squash = contactSquash(pose.contact, pose.impact, IMPACT_REFERENCE)
// Squashed, the ball keeps its footprint on the floor rather than sinking.
const centre: Vec3 = {
x: pose.drift,
y: pose.height + RADIUS * (1 - squash),
z: 0,
}
const place = (point: Vec3): Vec3 => ({
x: centre.x + point.x * (1 + squash * 0.5),
y: centre.y + point.y * (1 - squash),
z: centre.z + point.z * (1 + squash * 0.5),
})
const frame = spinFrame({ x: 1, y: 0.22, z: 0 }, pose.turns)
const outline = sphereSilhouette(RADIUS, look, 52).map((p) => to(place(p)))
const ribs = SEAMS.map((seam) => surfaceCurve(frame, RADIUS, seam, look))
const shell = robotSurface("shell", variant, palette)
const machined = robotSurface("metal", variant, palette)
const seamPaint = {
fill: "none",
stroke: variant === "wire" ? palette.grid : palette.dark,
strokeWidth: variant === "solid" ? 2 : 1.1,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
}
// Bound out of the pose so the narrowing survives into the map below.
const paddle = pose.paddle
const readout = `${Math.round((pose.height / APEX) * 100)}%`
const settled = pose.height < 0.5 && pose.contact < 0.02
return (
<svg
ref={svgRef}
role={role ?? (interactive ? "slider" : "img")}
aria-label={
ariaLabel ??
`Robot basketball, ${descriptions[behavior] ?? descriptions.static}, ${readout} of the apex, ${viewNames[view] ?? viewNames.profile}`
}
aria-valuemin={interactive ? 0 : undefined}
aria-valuemax={interactive ? 1 : undefined}
aria-valuenow={interactive ? px(clamp(pose.height / APEX, 0, 1)) : undefined}
aria-valuetext={interactive ? `${readout} of the apex` : undefined}
tabIndex={tabIndex ?? (interactive ? 0 : undefined)}
onKeyDown={(event) => {
onKeyDown?.(event)
if (!interactive || event.defaultPrevented) return
const delta = arrowStep(event.key, 0.05, 0.25)
if (delta !== 0) apply(clamp(pose.height / APEX, 0, 1) + delta)
else if (event.key === "Home") apply(0)
else if (event.key === "End") apply(1)
else return
event.preventDefault()
}}
onBlur={(event) => {
onBlur?.(event)
if (!dragging) setHeld(null)
}}
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
width={width}
height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}
className={cn(
"max-w-full select-none",
interactive &&
"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]",
dragging && "cursor-grabbing",
className,
)}
style={{ color: palette.foreground, ...style }}
{...props}
>
<g data-view={view} data-behavior={behavior} transform={fitted || undefined}>
{showGround && (
<>
<path
data-ground
d={line(FLOOR_PATCH.map((corner) => to({ x: corner.x, y: 0, z: corner.y })), true)}
fill={variant === "solid" ? palette.dark : "none"}
fillOpacity={0.07}
stroke={palette.dark}
strokeWidth={1}
opacity={0.45}
/>
{/* The shadow tightens as the ball comes down, which is the only
cue for height once the camera tips toward plan. */}
<ellipse
data-shadow
cx={px(to({ x: pose.drift, y: 0, z: 0 }).x)}
cy={px(to({ x: pose.drift, y: 0, z: 0 }).y)}
rx={px(RADIUS * (0.55 + 0.45 * clamp(1 - pose.height / APEX, 0, 1)))}
ry={px(2 + (RADIUS - 4) * camera.flatten)}
fill={palette.dark}
opacity={px(clamp(0.22 - (pose.height / APEX) * 0.14, 0.05, 0.22))}
/>
</>
)}
{paddle !== null && (
<g data-paddle>
<path
data-plate
d={line(
PADDLE_PLATE.map((corner) =>
to({ x: pose.drift + corner.x, y: paddle, z: corner.y }),
),
true,
)}
{...machined}
/>
<path
data-stem
d={line([
to({ x: pose.drift, y: paddle, z: 0 }),
to({ x: pose.drift, y: paddle + 26, z: 0 }),
])}
fill="none"
stroke={palette.metal}
strokeWidth={3}
strokeLinecap="round"
/>
</g>
)}
<g data-ball>
<path data-shell d={line(outline, true)} {...shell} />
{seams &&
ribs.map((rib, index) =>
visibleRuns(rib).map((run, part) => (
<path
key={`${index}-${part}`}
data-seam={index}
d={line(run.map((mark) => to(place(mark.point))))}
{...seamPaint}
/>
)),
)}
{variant === "solid" && (
<ellipse
data-highlight
cx={px(to(place({ x: -RADIUS * 0.34, y: RADIUS * 0.46, z: RADIUS * 0.5 })).x)}
cy={px(to(place({ x: -RADIUS * 0.34, y: RADIUS * 0.46, z: RADIUS * 0.5 })).y)}
rx={7}
ry={4.6}
fill={palette.glow}
opacity={0.15}
/>
)}
</g>
</g>
<g fontFamily="ui-monospace, monospace" textAnchor="middle" fill={palette.foreground}>
<text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 15} fontSize={5.5}>
{`${behavior.toUpperCase()} / e ${RESTITUTION} / ${settled ? "DOWN" : readout}`}
</text>
{label && (
<text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 6} fontSize={4.8} opacity={0.75}>
{label}
</text>
)}
</g>
</svg>
)
}
/** The driving plate, in the floor's own plane so it reads from every camera. */
const PADDLE_PLATE: Vec2[] = [
{ x: -19, y: 11 },
{ x: 19, y: 11 },
{ x: 19, y: -11 },
{ x: -19, y: -11 },
]
/** Cut once: every basketball ever drawn has the same three curves on it. */
const SEAMS = basketballSeams(32, 80)
export { RobotBasketball }