# `` — endless rotary with velocity + inertia _(no submodules)_ ## Purpose `` is an endless rotary control inspired by DJ-deck jog wheels. Unlike a knob, it does not have an absolute value — instead each pointer-drag event emits a **delta in radians** via `onChange`, which the caller integrates into whatever parameter the wheel controls (scrub position, tempo bend, parameter trim, etc.). The wheel reads pointer motion tangentially around its centre: motion that curls clockwise around the hub produces positive deltas, motion curling counter-clockwise produces negative deltas. On release, if the angular velocity exceeds `inertiaThreshold`, the wheel continues to spin under exponential friction (`inertiaFriction`) and emits deltas via `requestAnimationFrame` until the velocity falls below a small cut-off. ## Features - Tangential pointer-motion integration via 2D cross product - Pointer-capture so drags continuing off the wheel still register - Configurable sensitivity (`radiansPerPixel`) - Inertial spin after release, with caller-tunable threshold + friction - Tab focus + arrow-key navigation (±0.1 rad per arrow press) - `role="slider"` with descriptive `aria-valuetext` - `data-spinning` attribute for E2E and CSS visual feedback ## Usage: Basic ```tsx import { useState } from "react"; import { JogWheel } from "@suaveplan/react-control-surface"; function Scrubber() { const [pos, setPos] = useState(0); return ( setPos((p) => p + d)} label="Scrub" /> ); } ``` ## Usage: Advanced ```tsx import { JogWheel } from "@suaveplan/react-control-surface"; function PitchBend({ engine }: { engine: { bend: (cents: number) => void } }) { return ( engine.bend(delta * 60)} radiansPerPixel={0.04} inertiaThreshold={2} inertiaFriction={3.5} size={180} label="Pitch bend" /> ); } ``` ## API Reference | Prop | Type | Default | Description | | ------------------- | ----------------------------- | ------- | -------------------------------------------------------- | | `onChange` | `(deltaRadians: number) => void` | req | Called per drag step + inertia tick. | | `radiansPerPixel` | `number` | `0.02` | Sensitivity (≈1.15 deg / pixel by default). | | `inertiaThreshold` | `number` | `4` | Min release velocity (rad/s) to start spinning. | | `inertiaFriction` | `number` | `2.5` | Exponential decay coefficient (per second). | | `size` | `number` | `120` | Render size in CSS pixels (the wheel is square). | | `disabled` | `boolean` | `false` | When `true`, ignores all input. | | `label` | `string` | — | Visible + accessible label. | | `ariaLabel` | `string` | derived | Override the accessible label. | | `className` | `string` | — | Appended to `rcs-jog-wheel`. | The exported `decayVelocity(velocity, friction, dtMs)` helper is the pure exponential-decay primitive used internally. ## Implementation Notes The tangential pixel motion is computed as `(rx * dy - ry * dx) / radius`, i.e. the 2D cross product of the radius vector and the motion vector divided by the radius magnitude. This produces a signed scalar that is positive for clockwise motion (in standard screen-coordinates where Y points down) and negative for counter-clockwise. We guard against radius-zero starts by skipping motion samples that occur within 1 pixel of the centre. Inertia is driven by a separate `useEffect` that runs only while `spinning` is `true`. The effect captures the current `inertiaFriction` and emits a delta per frame: `step = velocity * dt`. Once the absolute velocity falls below `0.05` rad/s, the effect clears its own `spinning` flag and unmounts the RAF loop.