first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
# `<JogWheel>` — endless rotary with velocity + inertia
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<JogWheel>` 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 (
<JogWheel onChange={(d) => 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 (
<JogWheel
onChange={(delta) => 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.