Initial release: dreamknob library + showcase

React + TypeScript knob/fader library with a headless interaction core
(rotary, relative-drag and track modes, wheel, keyboard, a11y), SVG
primitives, nine prebuilt skins, seven-segment display engine, and a
Vite showcase with gallery, live playground and API docs.
This commit is contained in:
Dreamodus 2026-07-12 14:26:52 -07:00
commit 60a02cb1db
46 changed files with 6186 additions and 0 deletions

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
angleFromNormalized,
angleFromPoint,
clamp,
decimalsFromStep,
describeArc,
findClosest,
linearTaper,
logTaper,
normalizedFromAngle,
powTaper,
roundTo,
snapToStep,
} from './math';
describe('clamp', () => {
it('clamps into range', () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(-1, 0, 10)).toBe(0);
expect(clamp(11, 0, 10)).toBe(10);
});
});
describe('decimalsFromStep', () => {
it('infers decimal places', () => {
expect(decimalsFromStep(1)).toBe(0);
expect(decimalsFromStep(0.5)).toBe(1);
expect(decimalsFromStep(0.25)).toBe(2);
expect(decimalsFromStep(0.001)).toBe(3);
expect(decimalsFromStep(1e-7)).toBe(7);
});
it('falls back for continuous knobs', () => {
expect(decimalsFromStep(0)).toBe(3);
expect(decimalsFromStep(NaN)).toBe(3);
});
});
describe('roundTo', () => {
it('avoids float drift', () => {
expect(roundTo(0.1 + 0.2, 2)).toBe(0.3);
expect(roundTo(1.005, 2)).toBe(1.01);
expect(roundTo(123.456789, 4)).toBe(123.4568);
expect(roundTo(5, 0)).toBe(5);
});
});
describe('snapToStep', () => {
it('snaps anchored at min', () => {
expect(snapToStep(7.3, 0.5, 0)).toBe(7.5);
expect(snapToStep(7.2, 0.5, 0)).toBe(7.0);
expect(roundTo(snapToStep(0.07, 0.02, 0.01), 2)).toBe(0.07);
});
it('ignores non-positive steps', () => {
expect(snapToStep(7.3, 0, 0)).toBe(7.3);
});
});
describe('findClosest', () => {
it('finds the nearest entry regardless of order', () => {
expect(findClosest([1, 100, 5, 50], 42)).toBe(50);
expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1);
});
});
describe('tapers', () => {
it('linear round-trips', () => {
expect(linearTaper.fromNormalized(0.5, 0, 100)).toBe(50);
expect(linearTaper.toNormalized(50, 0, 100)).toBe(0.5);
});
it('log taper maps geometric midpoints', () => {
expect(roundTo(logTaper.fromNormalized(0.5, 20, 20000), 3)).toBe(632.456);
expect(roundTo(logTaper.toNormalized(2000, 20, 20000), 4)).toBe(
roundTo(Math.log(100) / Math.log(1000), 4),
);
});
it('pow taper round-trips', () => {
const t = powTaper(2);
const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10);
expect(roundTo(n, 10)).toBe(0.3);
});
});
describe('angles', () => {
it('maps pointer position to clockwise angle from 12 o\'clock', () => {
expect(angleFromPoint(50, 0, 50, 50)).toBe(0); // above center
expect(angleFromPoint(100, 50, 50, 50)).toBe(90); // right
expect(angleFromPoint(50, 100, 50, 50)).toBe(180); // below
expect(angleFromPoint(0, 50, 50, 50)).toBe(270); // left
});
it('maps angle to normalized travel (225°/270° audio knob)', () => {
expect(normalizedFromAngle(225, 225, 270)).toBe(0);
expect(normalizedFromAngle(0, 225, 270)).toBe(0.5); // 12 o'clock is mid travel
expect(normalizedFromAngle(135, 225, 270)).toBe(1);
});
it('snaps the dead zone to the nearest end (rc-knob feel)', () => {
// Dead zone spans 135..225; below its midpoint sticks to max.
expect(normalizedFromAngle(150, 225, 270)).toBe(1);
expect(normalizedFromAngle(210, 225, 270)).toBe(0);
});
it('round-trips normalized to angle', () => {
expect(angleFromNormalized(0.5, 225, 270)).toBe(360);
expect(angleFromNormalized(0, 225, 270)).toBe(225);
});
});
describe('describeArc', () => {
it('produces a drawable path', () => {
const d = describeArc(50, 50, 40, 225, 495);
expect(d.startsWith('M ')).toBe(true);
expect(d).toContain('A 40 40');
});
it('returns empty for zero sweep', () => {
expect(describeArc(50, 50, 40, 100, 100)).toBe('');
});
it('caps at just under a full circle', () => {
expect(describeArc(50, 50, 40, 0, 720)).not.toBe('');
});
});