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:
commit
60a02cb1db
46 changed files with 6186 additions and 0 deletions
122
packages/dreamknob/src/core/math.test.ts
Normal file
122
packages/dreamknob/src/core/math.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue