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
72
packages/dreamknob/src/components/Knob.tsx
Normal file
72
packages/dreamknob/src/components/Knob.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import * as React from 'react';
|
||||
import { KnobContextProvider, type KnobRenderContext } from '../core/context';
|
||||
import type { KnobCoreProps } from '../core/types';
|
||||
import { useKnob } from '../hooks/useKnob';
|
||||
|
||||
export interface KnobProps extends KnobCoreProps {
|
||||
/** Square canvas size in px. Default: 80. */
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
|
||||
children?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
|
||||
/** Extra content rendered inside the wrapper but outside the SVG (e.g. HTML labels). */
|
||||
overlay?: React.ReactNode | ((ctx: KnobRenderContext) => React.ReactNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless-but-visual knob container: wires up the full interaction model
|
||||
* (rotary/relative/track drag, wheel, keyboard, double-click reset, a11y)
|
||||
* and provides a render context for composable SVG primitives.
|
||||
*/
|
||||
export const Knob: React.FC<KnobProps> = ({
|
||||
size = 80,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
overlay,
|
||||
...core
|
||||
}) => {
|
||||
const knob = useKnob(core);
|
||||
const ctx: KnobRenderContext = {
|
||||
value: knob.value,
|
||||
normalized: knob.normalized,
|
||||
angle: knob.angle,
|
||||
isDragging: knob.isDragging,
|
||||
min: knob.min,
|
||||
max: knob.max,
|
||||
decimals: knob.decimals,
|
||||
angleOffset: knob.angleOffset,
|
||||
angleRange: knob.angleRange,
|
||||
size,
|
||||
center: size / 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<KnobContextProvider value={ctx}>
|
||||
<div
|
||||
{...knob.bind}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
width: size,
|
||||
height: size,
|
||||
...knob.bind.style,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{ display: 'block', overflow: 'visible' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{typeof children === 'function' ? children(ctx) : children}
|
||||
</svg>
|
||||
{typeof overlay === 'function' ? overlay(ctx) : overlay}
|
||||
</div>
|
||||
</KnobContextProvider>
|
||||
);
|
||||
};
|
||||
23
packages/dreamknob/src/core/context.ts
Normal file
23
packages/dreamknob/src/core/context.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from 'react';
|
||||
import type { KnobState } from './types';
|
||||
|
||||
export interface KnobRenderContext extends KnobState {
|
||||
/** Square canvas size in px. */
|
||||
size: number;
|
||||
/** Center coordinate (size / 2). */
|
||||
center: number;
|
||||
}
|
||||
|
||||
const Ctx = React.createContext<KnobRenderContext | null>(null);
|
||||
|
||||
export const KnobContextProvider = Ctx.Provider;
|
||||
|
||||
export function useKnobContext(): KnobRenderContext {
|
||||
const ctx = React.useContext(Ctx);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'dreamknob: this component must be rendered inside a <Knob> (or a prebuilt knob).',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
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('');
|
||||
});
|
||||
});
|
||||
156
packages/dreamknob/src/core/math.ts
Normal file
156
packages/dreamknob/src/core/math.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/** Clamp `value` into the inclusive range [min, max]. */
|
||||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, value));
|
||||
|
||||
/**
|
||||
* Infer the number of decimal places implied by a step size,
|
||||
* e.g. 0.25 -> 2, 1 -> 0, 1e-5 -> 5.
|
||||
*/
|
||||
export const decimalsFromStep = (step: number): number => {
|
||||
if (!Number.isFinite(step) || step <= 0) return 3;
|
||||
const s = step.toString();
|
||||
const exp = s.indexOf('e-');
|
||||
if (exp !== -1) return parseInt(s.slice(exp + 2), 10);
|
||||
const dot = s.indexOf('.');
|
||||
return dot === -1 ? 0 : s.length - dot - 1;
|
||||
};
|
||||
|
||||
/** Round to a fixed number of decimal places without float drift (0.1 + 0.2 style). */
|
||||
export const roundTo = (value: number, decimals: number): number => {
|
||||
const d = clamp(Math.trunc(decimals), 0, 15);
|
||||
return Number(`${Math.round(Number(`${value}e${d}`))}e-${d}`);
|
||||
};
|
||||
|
||||
/** Snap `value` to the nearest multiple of `step`, anchored at `min`. */
|
||||
export const snapToStep = (value: number, step: number, min: number): number => {
|
||||
if (!Number.isFinite(step) || step <= 0) return value;
|
||||
return min + Math.round((value - min) / step) * step;
|
||||
};
|
||||
|
||||
/** Find the entry of `values` closest to `value`. */
|
||||
export const findClosest = (values: readonly number[], value: number): number => {
|
||||
let best = values[0];
|
||||
let bestDelta = Infinity;
|
||||
for (const v of values) {
|
||||
const delta = Math.abs(v - value);
|
||||
if (delta < bestDelta) {
|
||||
best = v;
|
||||
bestDelta = delta;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tapers — map between value space and the normalized [0, 1] travel of the
|
||||
// control. Audio parameters are often logarithmic (frequency) or power-law
|
||||
// (gain), so the travel-to-value curve is pluggable.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Taper {
|
||||
/** value in [min, max] -> normalized position in [0, 1] */
|
||||
toNormalized(value: number, min: number, max: number): number;
|
||||
/** normalized position in [0, 1] -> value in [min, max] */
|
||||
fromNormalized(n: number, min: number, max: number): number;
|
||||
}
|
||||
|
||||
export const linearTaper: Taper = {
|
||||
toNormalized: (value, min, max) => (max === min ? 0 : (value - min) / (max - min)),
|
||||
fromNormalized: (n, min, max) => min + (max - min) * n,
|
||||
};
|
||||
|
||||
/**
|
||||
* Logarithmic taper (equal ratios per travel). Requires min and max to be
|
||||
* non-zero and share a sign — ideal for frequency ranges like 20..20000 Hz.
|
||||
*/
|
||||
export const logTaper: Taper = {
|
||||
toNormalized: (value, min, max) => {
|
||||
if (min === max) return 0;
|
||||
return Math.log(value / min) / Math.log(max / min);
|
||||
},
|
||||
fromNormalized: (n, min, max) => min * Math.pow(max / min, n),
|
||||
};
|
||||
|
||||
/**
|
||||
* Power-law taper. `exponent > 1` gives finer resolution near min (good for
|
||||
* gain), `exponent < 1` near max.
|
||||
*/
|
||||
export const powTaper = (exponent: number): Taper => ({
|
||||
toNormalized: (value, min, max) =>
|
||||
max === min ? 0 : Math.pow((value - min) / (max - min), 1 / exponent),
|
||||
fromNormalized: (n, min, max) => min + (max - min) * Math.pow(n, exponent),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Angles. Convention: degrees measured CLOCKWISE from 12 o'clock.
|
||||
// A typical audio knob starts at 225° (7:30) and sweeps 270° to 135° (4:30).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
export const polarToCartesian = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
angleDeg: number,
|
||||
): { x: number; y: number } => ({
|
||||
x: cx + radius * Math.sin(angleDeg * DEG),
|
||||
y: cy - radius * Math.cos(angleDeg * DEG),
|
||||
});
|
||||
|
||||
/**
|
||||
* SVG path for a clockwise arc from `startAngle` to `endAngle`
|
||||
* (degrees clockwise from 12 o'clock). Sweeps of >= 360° are capped just
|
||||
* short of a full circle so the path stays drawable.
|
||||
*/
|
||||
export const describeArc = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): string => {
|
||||
const sweep = Math.min(endAngle - startAngle, 359.999);
|
||||
if (sweep <= 0) return '';
|
||||
const start = polarToCartesian(cx, cy, radius, startAngle);
|
||||
const end = polarToCartesian(cx, cy, radius, startAngle + sweep);
|
||||
const largeArc = sweep > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Angle of the pointer position relative to a center, in degrees clockwise
|
||||
* from 12 o'clock, normalized to [0, 360).
|
||||
*/
|
||||
export const angleFromPoint = (
|
||||
px: number,
|
||||
py: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
): number => {
|
||||
const deg = Math.atan2(px - cx, cy - py) / DEG;
|
||||
return (deg + 360) % 360;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map an absolute pointer angle to a normalized position given the knob's
|
||||
* travel (angleOffset..angleOffset+angleRange). Angles inside the dead zone
|
||||
* snap to the nearest end — this is the classic rc-knob grab-and-rotate feel.
|
||||
*/
|
||||
export const normalizedFromAngle = (
|
||||
angle: number,
|
||||
angleOffset: number,
|
||||
angleRange: number,
|
||||
): number => {
|
||||
const rel = (((angle - angleOffset) % 360) + 360) % 360;
|
||||
if (rel <= angleRange) return clamp(rel / angleRange, 0, 1);
|
||||
// Dead zone: snap to whichever end of the travel is angularly closer.
|
||||
return rel - angleRange < (360 - angleRange) / 2 ? 1 : 0;
|
||||
};
|
||||
|
||||
/** Normalized position -> absolute display angle (deg clockwise from 12 o'clock). */
|
||||
export const angleFromNormalized = (
|
||||
n: number,
|
||||
angleOffset: number,
|
||||
angleRange: number,
|
||||
): number => angleOffset + n * angleRange;
|
||||
82
packages/dreamknob/src/core/types.ts
Normal file
82
packages/dreamknob/src/core/types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { Taper } from './math';
|
||||
|
||||
/** How pointer dragging maps to value changes. */
|
||||
export type InteractionMode =
|
||||
/** Track the pointer's absolute angle around the knob center (grab-and-rotate, rc-knob feel). */
|
||||
| 'rotary'
|
||||
/** Relative vertical drag: up increases (classic DAW plugin feel). */
|
||||
| 'vertical'
|
||||
/** Relative horizontal drag: right increases. */
|
||||
| 'horizontal'
|
||||
/** Combined vertical + horizontal relative drag. */
|
||||
| 'both'
|
||||
/** Absolute position along the element's main axis (fader/slider feel). */
|
||||
| 'track-vertical'
|
||||
| 'track-horizontal';
|
||||
|
||||
export interface KnobCoreProps {
|
||||
/** Controlled value. Provide together with `onChange` for controlled mode. */
|
||||
value?: number;
|
||||
/** Initial value for uncontrolled mode; also the double-click reset target. */
|
||||
defaultValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/**
|
||||
* Snap emitted values to multiples of `step` (anchored at `min`).
|
||||
* May be fractional, e.g. 0.01. Omit (or 0) for continuous values.
|
||||
*/
|
||||
step?: number;
|
||||
/**
|
||||
* Number of decimal places for emitted values. Defaults to the precision
|
||||
* implied by `step`, or 3 for continuous knobs.
|
||||
*/
|
||||
decimals?: number;
|
||||
/** Restrict values to a discrete list (e.g. [0.25, 0.5, 1, 2, 4]). Overrides `step`. */
|
||||
values?: readonly number[];
|
||||
/** Number of evenly spaced detents across the travel. Overrides `step` snapping in normalized space. */
|
||||
steps?: number;
|
||||
/** Travel-to-value curve. Defaults to linear. Use `logTaper` / `powTaper(n)` for audio params. */
|
||||
taper?: Taper;
|
||||
/** Drag behaviour. Default: 'rotary'. */
|
||||
interaction?: InteractionMode;
|
||||
/** Pixels of relative drag for full travel in vertical/horizontal modes. Default: 200. */
|
||||
dragSensitivity?: number;
|
||||
/** Multiplier applied while Shift is held during relative drags / wheel. Default: 0.1. */
|
||||
fineMultiplier?: number;
|
||||
/** Angle (deg clockwise from 12 o'clock) where travel begins. Default: 225. */
|
||||
angleOffset?: number;
|
||||
/** Total sweep in degrees. Default: 270. */
|
||||
angleRange?: number;
|
||||
/** Enable mouse-wheel adjustment. Default: true. */
|
||||
enableWheel?: boolean;
|
||||
/** Reset to `defaultValue` on double click. Default: true when `defaultValue` is set. */
|
||||
doubleClickReset?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
/** Fired with each (rounded, snapped) value change. */
|
||||
onChange?: (value: number) => void;
|
||||
/** Fired when an adjustment gesture ends (pointer up, wheel settle, key release). */
|
||||
onChangeEnd?: (value: number) => void;
|
||||
/** Fired when a drag gesture starts. */
|
||||
onChangeStart?: (value: number) => void;
|
||||
/** Accessible name for the slider role. */
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
/** Custom text for screen readers, e.g. `v => `${v} dB``. */
|
||||
getAriaValueText?: (value: number) => string;
|
||||
}
|
||||
|
||||
export interface KnobState {
|
||||
/** Current (snapped, rounded) value. */
|
||||
value: number;
|
||||
/** Normalized travel position in [0, 1] (taper space). */
|
||||
normalized: number;
|
||||
/** Display angle in degrees clockwise from 12 o'clock. */
|
||||
angle: number;
|
||||
isDragging: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
decimals: number;
|
||||
angleOffset: number;
|
||||
angleRange: number;
|
||||
}
|
||||
88
packages/dreamknob/src/digital/SegmentDisplay.tsx
Normal file
88
packages/dreamknob/src/digital/SegmentDisplay.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import * as React from 'react';
|
||||
import { formatForDisplay, renderSegmentText } from './segments';
|
||||
|
||||
export interface SegmentDisplayProps {
|
||||
value: number | string;
|
||||
/** Number of digit cells (decimal points don't count). Default: 4. */
|
||||
digits?: number;
|
||||
decimals?: number;
|
||||
/** Digit height in px. Default: 28. */
|
||||
height?: number;
|
||||
color?: string;
|
||||
/** Panel background. Set to 'none' to disable. */
|
||||
background?: string;
|
||||
/** Opacity of unlit segments. Default: 0.09. */
|
||||
ghostOpacity?: number;
|
||||
/** Italic skew in degrees. Default: 6. */
|
||||
skew?: number;
|
||||
/** LED glow strength (0 disables). Default: 1.6. */
|
||||
glow?: number;
|
||||
padding?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standalone seven-segment LED/LCD numeric display — for readouts, meters
|
||||
* and digital panels.
|
||||
*/
|
||||
export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
||||
value,
|
||||
digits = 4,
|
||||
decimals = 0,
|
||||
height = 28,
|
||||
color = '#3df2ad',
|
||||
background = '#0a0d0c',
|
||||
ghostOpacity = 0.09,
|
||||
skew = 6,
|
||||
glow = 1.6,
|
||||
padding = 6,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const filterId = React.useId();
|
||||
const text =
|
||||
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
|
||||
const { nodes, width, height: cellH } = renderSegmentText(text, {
|
||||
color,
|
||||
ghostOpacity,
|
||||
skew,
|
||||
});
|
||||
|
||||
const scale = height / cellH;
|
||||
const w = width * scale + padding * 2 + height * 0.15; // skew headroom
|
||||
const h = height + padding * 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={className}
|
||||
style={style}
|
||||
role="img"
|
||||
aria-label={typeof value === 'number' ? String(value) : value}
|
||||
>
|
||||
{glow > 0 && (
|
||||
<defs>
|
||||
<filter id={filterId} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation={glow} result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
)}
|
||||
{background !== 'none' && (
|
||||
<rect x={0} y={0} width={w} height={h} rx={4} fill={background} />
|
||||
)}
|
||||
<g
|
||||
transform={`translate(${padding + height * 0.12} ${padding}) scale(${scale})`}
|
||||
filter={glow > 0 ? `url(#${filterId})` : undefined}
|
||||
>
|
||||
{nodes}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
122
packages/dreamknob/src/digital/segments.tsx
Normal file
122
packages/dreamknob/src/digital/segments.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from 'react';
|
||||
|
||||
// Seven-segment geometry in a local 10 x 18 digit cell.
|
||||
// Segments: a top, b top-right, c bottom-right, d bottom, e bottom-left,
|
||||
// f top-left, g middle.
|
||||
|
||||
const DIGIT_W = 10;
|
||||
const DIGIT_H = 18;
|
||||
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
|
||||
|
||||
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
|
||||
|
||||
const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
|
||||
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
|
||||
'1': ['b', 'c'],
|
||||
'2': ['a', 'b', 'g', 'e', 'd'],
|
||||
'3': ['a', 'b', 'g', 'c', 'd'],
|
||||
'4': ['f', 'g', 'b', 'c'],
|
||||
'5': ['a', 'f', 'g', 'c', 'd'],
|
||||
'6': ['a', 'f', 'g', 'e', 'c', 'd'],
|
||||
'7': ['a', 'b', 'c'],
|
||||
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
|
||||
'9': ['a', 'b', 'c', 'd', 'f', 'g'],
|
||||
'-': ['g'],
|
||||
' ': [],
|
||||
};
|
||||
|
||||
const H_HALF = 1.1; // half thickness
|
||||
const INSET = 0.45; // gap between adjacent segments
|
||||
|
||||
const hSegment = (y: number): string => {
|
||||
const x1 = 1 + INSET;
|
||||
const x2 = DIGIT_W - 1 - INSET;
|
||||
return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`;
|
||||
};
|
||||
|
||||
const vSegment = (x: number, y1: number, y2: number): string => {
|
||||
const a = y1 + INSET;
|
||||
const b = y2 - INSET;
|
||||
return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`;
|
||||
};
|
||||
|
||||
const SEGMENT_POINTS: Record<SegmentKey, string> = {
|
||||
a: hSegment(1),
|
||||
g: hSegment(DIGIT_H / 2),
|
||||
d: hSegment(DIGIT_H - 1),
|
||||
f: vSegment(1, 1, DIGIT_H / 2),
|
||||
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2),
|
||||
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1),
|
||||
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1),
|
||||
};
|
||||
|
||||
export interface SegmentRenderOptions {
|
||||
color: string;
|
||||
/** Opacity of unlit "ghost" segments. 0 disables them. */
|
||||
ghostOpacity: number;
|
||||
/** Negative skew for the classic italic LCD look, in degrees. */
|
||||
skew: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local
|
||||
* coordinates (digit height 18). Returns the nodes plus the total width.
|
||||
*/
|
||||
export const renderSegmentText = (
|
||||
text: string,
|
||||
{ color, ghostOpacity, skew }: SegmentRenderOptions,
|
||||
): { nodes: React.ReactNode; width: number; height: number } => {
|
||||
const cells: React.ReactNode[] = [];
|
||||
let x = 0;
|
||||
let key = 0;
|
||||
|
||||
for (const ch of text) {
|
||||
if (ch === '.') {
|
||||
// Decimal point sits in the gap after the previous digit.
|
||||
cells.push(
|
||||
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={DIGIT_H - 1.2} r={1.2} fill={color} />,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const lit = new Set(CHAR_SEGMENTS[ch] ?? []);
|
||||
const digit: React.ReactNode[] = [];
|
||||
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
||||
const on = lit.has(seg);
|
||||
if (!on && ghostOpacity <= 0) return;
|
||||
digit.push(
|
||||
<polygon
|
||||
key={seg}
|
||||
points={SEGMENT_POINTS[seg]}
|
||||
fill={color}
|
||||
opacity={on ? 1 : ghostOpacity}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
cells.push(
|
||||
<g key={key++} transform={`translate(${x} 0)`}>
|
||||
{digit}
|
||||
</g>,
|
||||
);
|
||||
x += DIGIT_W + GAP;
|
||||
}
|
||||
|
||||
const width = Math.max(x - GAP, 0);
|
||||
const nodes = skew ? (
|
||||
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
|
||||
) : (
|
||||
<>{cells}</>
|
||||
);
|
||||
return { nodes, width, height: DIGIT_H };
|
||||
};
|
||||
|
||||
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
|
||||
export const formatForDisplay = (
|
||||
value: number,
|
||||
digits: number,
|
||||
decimals: number,
|
||||
): string => {
|
||||
let text = value.toFixed(decimals);
|
||||
const cellCount = (s: string) => s.replace(/\./g, '').length;
|
||||
if (cellCount(text) > digits) text = ''.padStart(digits, '8'); // overflow
|
||||
return text.padStart(digits + (text.includes('.') ? 1 : 0), ' ');
|
||||
};
|
||||
350
packages/dreamknob/src/hooks/useKnob.ts
Normal file
350
packages/dreamknob/src/hooks/useKnob.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import * as React from 'react';
|
||||
import {
|
||||
angleFromNormalized,
|
||||
angleFromPoint,
|
||||
clamp,
|
||||
decimalsFromStep,
|
||||
findClosest,
|
||||
linearTaper,
|
||||
normalizedFromAngle,
|
||||
roundTo,
|
||||
snapToStep,
|
||||
} from '../core/math';
|
||||
import type { KnobCoreProps, KnobState } from '../core/types';
|
||||
|
||||
export interface UseKnobResult extends KnobState {
|
||||
/** Ref for the interactive element. Required for wheel + rotary geometry. */
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
/** Spread onto the interactive element. */
|
||||
bind: {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: (e: React.PointerEvent) => void;
|
||||
onPointerCancel: (e: React.PointerEvent) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||
onDoubleClick: (e: React.MouseEvent) => void;
|
||||
role: 'slider';
|
||||
tabIndex: number;
|
||||
'aria-valuemin': number;
|
||||
'aria-valuemax': number;
|
||||
'aria-valuenow': number;
|
||||
'aria-valuetext'?: string;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
'aria-disabled'?: boolean;
|
||||
'aria-readonly'?: boolean;
|
||||
style: React.CSSProperties;
|
||||
};
|
||||
/** Imperatively set the value (snapped + clamped). */
|
||||
setValue: (value: number) => void;
|
||||
}
|
||||
|
||||
interface DragSession {
|
||||
pointerId: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rect: DOMRect;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
/** Continuous normalized position, kept un-snapped for smooth relative drags. */
|
||||
n: number;
|
||||
}
|
||||
|
||||
export function useKnob(props: KnobCoreProps): UseKnobResult {
|
||||
const {
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 0,
|
||||
values,
|
||||
steps,
|
||||
taper = linearTaper,
|
||||
interaction = 'rotary',
|
||||
dragSensitivity = 200,
|
||||
fineMultiplier = 0.1,
|
||||
angleOffset = 225,
|
||||
angleRange = 270,
|
||||
enableWheel = true,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
onChangeStart,
|
||||
getAriaValueText,
|
||||
} = props;
|
||||
|
||||
const decimals = props.decimals ?? (step > 0 ? decimalsFromStep(step) : 3);
|
||||
const doubleClickReset =
|
||||
props.doubleClickReset ?? props.defaultValue !== undefined;
|
||||
const interactive = !disabled && !readOnly;
|
||||
|
||||
/** Clamp, snap and round a raw value into an emittable one. */
|
||||
const constrain = React.useCallback(
|
||||
(raw: number): number => {
|
||||
let v = clamp(raw, Math.min(min, max), Math.max(min, max));
|
||||
if (values && values.length > 0) {
|
||||
v = findClosest(values, v);
|
||||
} else if (steps && steps > 1) {
|
||||
const n = taper.toNormalized(v, min, max);
|
||||
const snapped = Math.round(n * (steps - 1)) / (steps - 1);
|
||||
v = taper.fromNormalized(snapped, min, max);
|
||||
} else if (step > 0) {
|
||||
v = clamp(snapToStep(v, step, min), Math.min(min, max), Math.max(min, max));
|
||||
}
|
||||
return roundTo(v, decimals);
|
||||
},
|
||||
[min, max, step, values, steps, taper, decimals],
|
||||
);
|
||||
|
||||
const isControlled = props.value !== undefined;
|
||||
const [internalValue, setInternalValue] = React.useState<number>(() =>
|
||||
constrain(props.defaultValue ?? min),
|
||||
);
|
||||
const value = constrain(isControlled ? (props.value as number) : internalValue);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
const valueRef = React.useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
const emit = React.useCallback(
|
||||
(raw: number) => {
|
||||
const next = constrain(raw);
|
||||
if (next === valueRef.current) return;
|
||||
valueRef.current = next;
|
||||
if (!isControlled) setInternalValue(next);
|
||||
onChange?.(next);
|
||||
},
|
||||
[constrain, isControlled, onChange],
|
||||
);
|
||||
|
||||
const setFromNormalized = React.useCallback(
|
||||
(n: number) => emit(taper.fromNormalized(clamp(n, 0, 1), min, max)),
|
||||
[emit, taper, min, max],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pointer dragging
|
||||
// -------------------------------------------------------------------------
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const session = React.useRef<DragSession | null>(null);
|
||||
|
||||
const applyPointer = React.useCallback(
|
||||
(clientX: number, clientY: number, fine: boolean) => {
|
||||
const s = session.current;
|
||||
if (!s) return;
|
||||
switch (interaction) {
|
||||
case 'rotary': {
|
||||
const angle = angleFromPoint(clientX, clientY, s.centerX, s.centerY);
|
||||
setFromNormalized(normalizedFromAngle(angle, angleOffset % 360, angleRange));
|
||||
break;
|
||||
}
|
||||
case 'track-vertical': {
|
||||
setFromNormalized((s.rect.bottom - clientY) / s.rect.height);
|
||||
break;
|
||||
}
|
||||
case 'track-horizontal': {
|
||||
setFromNormalized((clientX - s.rect.left) / s.rect.width);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const scale = (fine ? fineMultiplier : 1) / dragSensitivity;
|
||||
let dn = 0;
|
||||
if (interaction === 'vertical' || interaction === 'both')
|
||||
dn += (s.lastY - clientY) * scale;
|
||||
if (interaction === 'horizontal' || interaction === 'both')
|
||||
dn += (clientX - s.lastX) * scale;
|
||||
s.n = clamp(s.n + dn, 0, 1);
|
||||
setFromNormalized(s.n);
|
||||
}
|
||||
}
|
||||
s.lastX = clientX;
|
||||
s.lastY = clientY;
|
||||
},
|
||||
[interaction, angleOffset, angleRange, dragSensitivity, fineMultiplier, setFromNormalized],
|
||||
);
|
||||
|
||||
const onPointerDown = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!interactive || !ref.current) return;
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
ref.current.focus({ preventScroll: true });
|
||||
ref.current.setPointerCapture(e.pointerId);
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
session.current = {
|
||||
pointerId: e.pointerId,
|
||||
rect,
|
||||
centerX: rect.left + rect.width / 2,
|
||||
centerY: rect.top + rect.height / 2,
|
||||
lastX: e.clientX,
|
||||
lastY: e.clientY,
|
||||
n: clamp(taper.toNormalized(valueRef.current, min, max), 0, 1),
|
||||
};
|
||||
setIsDragging(true);
|
||||
onChangeStart?.(valueRef.current);
|
||||
// Fader-style modes jump to the pressed position immediately.
|
||||
if (interaction === 'track-vertical' || interaction === 'track-horizontal') {
|
||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||
}
|
||||
},
|
||||
[interactive, interaction, taper, min, max, onChangeStart, applyPointer],
|
||||
);
|
||||
|
||||
const onPointerMove = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
applyPointer(e.clientX, e.clientY, e.shiftKey);
|
||||
},
|
||||
[applyPointer],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!session.current || e.pointerId !== session.current.pointerId) return;
|
||||
session.current = null;
|
||||
setIsDragging(false);
|
||||
onChangeEnd?.(valueRef.current);
|
||||
},
|
||||
[onChangeEnd],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Wheel — needs a non-passive listener so preventDefault stops page scroll.
|
||||
// -------------------------------------------------------------------------
|
||||
const wheelSettle = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const nudge = React.useCallback(
|
||||
(direction: number, multiplier = 1) => {
|
||||
const current = valueRef.current;
|
||||
if (values && values.length > 0) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const idx = sorted.indexOf(findClosest(sorted, current));
|
||||
emit(sorted[clamp(idx + direction, 0, sorted.length - 1)]);
|
||||
} else if (steps && steps > 1) {
|
||||
const n = taper.toNormalized(current, min, max);
|
||||
setFromNormalized(n + direction / (steps - 1));
|
||||
} else {
|
||||
const base = step > 0 ? step : (max - min) / 100;
|
||||
emit(current + direction * base * multiplier);
|
||||
}
|
||||
},
|
||||
[values, steps, step, min, max, taper, emit, setFromNormalized],
|
||||
);
|
||||
const nudgeRef = React.useRef(nudge);
|
||||
nudgeRef.current = nudge;
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !enableWheel || !interactive) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const direction = e.deltaY < 0 || e.deltaX > 0 ? 1 : -1;
|
||||
nudgeRef.current(direction, e.shiftKey ? fineMultiplier : 1);
|
||||
clearTimeout(wheelSettle.current);
|
||||
wheelSettle.current = setTimeout(() => onChangeEnd?.(valueRef.current), 250);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
clearTimeout(wheelSettle.current);
|
||||
};
|
||||
}, [enableWheel, interactive, fineMultiplier, onChangeEnd]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keyboard
|
||||
// -------------------------------------------------------------------------
|
||||
const keyAdjusted = React.useRef(false);
|
||||
const onKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!interactive) return;
|
||||
const span = max - min;
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
case 'ArrowRight':
|
||||
nudge(1, e.shiftKey ? 10 : 1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
nudge(-1, e.shiftKey ? 10 : 1);
|
||||
break;
|
||||
case 'PageUp':
|
||||
emit(valueRef.current + span / 10);
|
||||
break;
|
||||
case 'PageDown':
|
||||
emit(valueRef.current - span / 10);
|
||||
break;
|
||||
case 'Home':
|
||||
emit(min);
|
||||
break;
|
||||
case 'End':
|
||||
emit(max);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
keyAdjusted.current = true;
|
||||
}
|
||||
},
|
||||
[interactive, nudge, emit, min, max],
|
||||
);
|
||||
|
||||
const onKeyUp = React.useCallback(() => {
|
||||
if (keyAdjusted.current) {
|
||||
keyAdjusted.current = false;
|
||||
onChangeEnd?.(valueRef.current);
|
||||
}
|
||||
}, [onChangeEnd]);
|
||||
|
||||
const onDoubleClick = React.useCallback(() => {
|
||||
if (!interactive || !doubleClickReset || props.defaultValue === undefined) return;
|
||||
emit(props.defaultValue);
|
||||
onChangeEnd?.(valueRef.current);
|
||||
}, [interactive, doubleClickReset, props.defaultValue, emit, onChangeEnd]);
|
||||
|
||||
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
|
||||
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
|
||||
|
||||
return {
|
||||
value,
|
||||
normalized,
|
||||
angle,
|
||||
isDragging,
|
||||
min,
|
||||
max,
|
||||
decimals,
|
||||
angleOffset,
|
||||
angleRange,
|
||||
ref,
|
||||
setValue: emit,
|
||||
bind: {
|
||||
ref,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onDoubleClick,
|
||||
role: 'slider',
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
'aria-valuemin': min,
|
||||
'aria-valuemax': max,
|
||||
'aria-valuenow': value,
|
||||
'aria-valuetext': getAriaValueText ? getAriaValueText(value) : undefined,
|
||||
'aria-label': props['aria-label'],
|
||||
'aria-labelledby': props['aria-labelledby'],
|
||||
'aria-disabled': disabled || undefined,
|
||||
'aria-readonly': readOnly || undefined,
|
||||
style: {
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'grab',
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
44
packages/dreamknob/src/index.ts
Normal file
44
packages/dreamknob/src/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Core
|
||||
export { useKnob, type UseKnobResult } from './hooks/useKnob';
|
||||
export { Knob, type KnobProps } from './components/Knob';
|
||||
export { useKnobContext, type KnobRenderContext } from './core/context';
|
||||
export type { KnobCoreProps, KnobState, InteractionMode } from './core/types';
|
||||
export {
|
||||
clamp,
|
||||
roundTo,
|
||||
snapToStep,
|
||||
decimalsFromStep,
|
||||
findClosest,
|
||||
linearTaper,
|
||||
logTaper,
|
||||
powTaper,
|
||||
type Taper,
|
||||
polarToCartesian,
|
||||
describeArc,
|
||||
angleFromPoint,
|
||||
angleFromNormalized,
|
||||
normalizedFromAngle,
|
||||
} from './core/math';
|
||||
|
||||
// Composable primitives
|
||||
export { Arc, type ArcProps } from './primitives/Arc';
|
||||
export { Pointer, type PointerProps } from './primitives/Pointer';
|
||||
export { Ticks, type TicksProps } from './primitives/Ticks';
|
||||
export { Face, type FaceProps } from './primitives/Face';
|
||||
export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from './primitives/Text';
|
||||
|
||||
// Digital
|
||||
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
|
||||
|
||||
// Prebuilt skins
|
||||
export type { SkinProps } from './skins/shared';
|
||||
export { FlatKnob, type FlatKnobProps } from './skins/FlatKnob';
|
||||
export { MetalKnob, type MetalKnobProps } from './skins/MetalKnob';
|
||||
export { RubberKnob, type RubberKnobProps } from './skins/RubberKnob';
|
||||
export { VintageKnob, type VintageKnobProps } from './skins/VintageKnob';
|
||||
export { LEDKnob, type LEDKnobProps } from './skins/LEDKnob';
|
||||
export { NeonKnob, type NeonKnobProps } from './skins/NeonKnob';
|
||||
export { SteppedKnob, type SteppedKnobProps } from './skins/SteppedKnob';
|
||||
export { Fader, type FaderProps } from './skins/Fader';
|
||||
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
|
||||
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';
|
||||
70
packages/dreamknob/src/primitives/Arc.tsx
Normal file
70
packages/dreamknob/src/primitives/Arc.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { describeArc } from '../core/math';
|
||||
|
||||
export interface ArcProps {
|
||||
/** Arc radius. Defaults to (size - thickness) / 2. */
|
||||
radius?: number;
|
||||
thickness?: number;
|
||||
/** Value arc color. */
|
||||
color?: string;
|
||||
/** Background track color. Omit to skip the track. */
|
||||
trackColor?: string;
|
||||
/** Draw the value arc from the minimum ('min') or from the travel center ('center', pan-style). */
|
||||
from?: 'min' | 'center';
|
||||
cap?: 'butt' | 'round';
|
||||
/** Extra SVG props for the value arc path (e.g. filter for glow). */
|
||||
arcProps?: React.SVGProps<SVGPathElement>;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
/** Background track + value arc following the knob's travel. */
|
||||
export const Arc: React.FC<ArcProps> = ({
|
||||
radius,
|
||||
thickness = 4,
|
||||
color = 'currentColor',
|
||||
trackColor,
|
||||
from = 'min',
|
||||
cap = 'round',
|
||||
arcProps,
|
||||
opacity,
|
||||
}) => {
|
||||
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
|
||||
const r = radius ?? (size - thickness) / 2;
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (from === 'center') {
|
||||
const mid = angleOffset + angleRange / 2;
|
||||
const now = angleOffset + normalized * angleRange;
|
||||
start = Math.min(mid, now);
|
||||
end = Math.max(mid, now);
|
||||
} else {
|
||||
start = angleOffset;
|
||||
end = angleOffset + normalized * angleRange;
|
||||
}
|
||||
|
||||
return (
|
||||
<g opacity={opacity}>
|
||||
{trackColor && (
|
||||
<path
|
||||
d={describeArc(center, center, r, angleOffset, angleOffset + angleRange)}
|
||||
stroke={trackColor}
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap={cap}
|
||||
fill="none"
|
||||
/>
|
||||
)}
|
||||
{end - start > 0.0001 && (
|
||||
<path
|
||||
d={describeArc(center, center, r, start, end)}
|
||||
stroke={color}
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap={cap}
|
||||
fill="none"
|
||||
{...arcProps}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
13
packages/dreamknob/src/primitives/Face.tsx
Normal file
13
packages/dreamknob/src/primitives/Face.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
export interface FaceProps extends React.SVGProps<SVGCircleElement> {
|
||||
/** Body radius. Defaults to size/2 - 6. */
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
/** The knob body: a centered circle. Style via fill/stroke/filter props. */
|
||||
export const Face: React.FC<FaceProps> = ({ radius, ...rest }) => {
|
||||
const { size, center } = useKnobContext();
|
||||
return <circle cx={center} cy={center} r={radius ?? size / 2 - 6} {...rest} />;
|
||||
};
|
||||
35
packages/dreamknob/src/primitives/GlowFilter.tsx
Normal file
35
packages/dreamknob/src/primitives/GlowFilter.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
export interface GlowFilterProps {
|
||||
id: string;
|
||||
/** Gaussian blur radius in px. */
|
||||
blur: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* LED-style glow filter with a userSpaceOnUse region covering the whole
|
||||
* canvas. The default objectBoundingBox region collapses for thin stroked
|
||||
* paths (a straight line has a zero-area geometric bbox), which clips the
|
||||
* stroke and glow — this avoids that entirely.
|
||||
*/
|
||||
export const GlowFilter: React.FC<GlowFilterProps> = ({ id, blur }) => {
|
||||
const { size } = useKnobContext();
|
||||
const pad = size * 0.3;
|
||||
return (
|
||||
<filter
|
||||
id={id}
|
||||
filterUnits="userSpaceOnUse"
|
||||
x={-pad}
|
||||
y={-pad}
|
||||
width={size + pad * 2}
|
||||
height={size + pad * 2}
|
||||
>
|
||||
<feGaussianBlur stdDeviation={blur} result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
);
|
||||
};
|
||||
73
packages/dreamknob/src/primitives/Pointer.tsx
Normal file
73
packages/dreamknob/src/primitives/Pointer.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
export interface PointerProps {
|
||||
/** Built-in shapes. Provide children instead for a custom pointer. */
|
||||
type?: 'line' | 'circle' | 'triangle';
|
||||
/** Distance from center to the pointer's outer end. Defaults to size/2 - 4. */
|
||||
radius?: number;
|
||||
/** Length of the pointer along the radial axis. */
|
||||
length?: number;
|
||||
width?: number;
|
||||
color?: string;
|
||||
cap?: 'butt' | 'round';
|
||||
/** Extra SVG props applied to the shape. */
|
||||
shapeProps?: React.SVGProps<SVGElement>;
|
||||
/** Custom pointer content, drawn pointing at 12 o'clock and rotated for you. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Rotating indicator. Children (or the built-in shape) are authored pointing up. */
|
||||
export const Pointer: React.FC<PointerProps> = ({
|
||||
type = 'line',
|
||||
radius,
|
||||
length = 12,
|
||||
width = 4,
|
||||
color = 'currentColor',
|
||||
cap = 'round',
|
||||
shapeProps,
|
||||
children,
|
||||
}) => {
|
||||
const { size, center, angle } = useKnobContext();
|
||||
const r = radius ?? size / 2 - 4;
|
||||
|
||||
let shape: React.ReactNode = children;
|
||||
if (!shape) {
|
||||
if (type === 'line') {
|
||||
shape = (
|
||||
<line
|
||||
x1={center}
|
||||
y1={center - r}
|
||||
x2={center}
|
||||
y2={center - r + length}
|
||||
stroke={color}
|
||||
strokeWidth={width}
|
||||
strokeLinecap={cap}
|
||||
{...(shapeProps as React.SVGProps<SVGLineElement>)}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'circle') {
|
||||
shape = (
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center - r + width / 2}
|
||||
r={width}
|
||||
fill={color}
|
||||
{...(shapeProps as React.SVGProps<SVGCircleElement>)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const half = width / 2;
|
||||
const tipY = center - r;
|
||||
shape = (
|
||||
<polygon
|
||||
points={`${center},${tipY} ${center - half},${tipY + length} ${center + half},${tipY + length}`}
|
||||
fill={color}
|
||||
{...(shapeProps as React.SVGProps<SVGPolygonElement>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <g transform={`rotate(${angle} ${center} ${center})`}>{shape}</g>;
|
||||
};
|
||||
97
packages/dreamknob/src/primitives/Text.tsx
Normal file
97
packages/dreamknob/src/primitives/Text.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
|
||||
/** "64.000" -> "64", "12.50" -> "12.5"; leaves integers untouched. */
|
||||
const trimZeros = (s: string): string =>
|
||||
s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
|
||||
|
||||
export interface KnobValueProps {
|
||||
/** Fixed decimal places for display. Default: the knob's `decimals`, with trailing zeros trimmed. */
|
||||
decimals?: number;
|
||||
/** Full custom formatter — takes precedence over `decimals` / `unit`. */
|
||||
format?: (value: number) => string;
|
||||
unit?: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontFamily?: string;
|
||||
fontWeight?: number | string;
|
||||
/** Vertical offset from center. */
|
||||
dy?: number;
|
||||
textProps?: React.SVGProps<SVGTextElement>;
|
||||
}
|
||||
|
||||
/** Numeric readout in the middle of the knob. */
|
||||
export const KnobValue: React.FC<KnobValueProps> = ({
|
||||
decimals,
|
||||
format,
|
||||
unit,
|
||||
fontSize,
|
||||
color = 'currentColor',
|
||||
fontFamily,
|
||||
fontWeight = 600,
|
||||
dy = 0,
|
||||
textProps,
|
||||
}) => {
|
||||
const ctx = useKnobContext();
|
||||
const text = format
|
||||
? format(ctx.value)
|
||||
: `${
|
||||
decimals !== undefined
|
||||
? ctx.value.toFixed(decimals)
|
||||
: trimZeros(ctx.value.toFixed(ctx.decimals))
|
||||
}${unit ?? ''}`;
|
||||
return (
|
||||
<text
|
||||
x={ctx.center}
|
||||
y={ctx.center + dy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fontSize ?? ctx.size * 0.18}
|
||||
fill={color}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={fontWeight}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
{...textProps}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
export interface KnobLabelProps {
|
||||
children: React.ReactNode;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontFamily?: string;
|
||||
/** Vertical offset from center; defaults to just below the knob body. */
|
||||
dy?: number;
|
||||
textProps?: React.SVGProps<SVGTextElement>;
|
||||
}
|
||||
|
||||
/** Small caption, e.g. the parameter name. */
|
||||
export const KnobLabel: React.FC<KnobLabelProps> = ({
|
||||
children,
|
||||
fontSize,
|
||||
color = 'currentColor',
|
||||
fontFamily,
|
||||
dy,
|
||||
textProps,
|
||||
}) => {
|
||||
const { size, center } = useKnobContext();
|
||||
return (
|
||||
<text
|
||||
x={center}
|
||||
y={center + (dy ?? size * 0.38)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={fontSize ?? size * 0.12}
|
||||
fill={color}
|
||||
fontFamily={fontFamily}
|
||||
letterSpacing="0.08em"
|
||||
style={{ pointerEvents: 'none', textTransform: 'uppercase' }}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
55
packages/dreamknob/src/primitives/Ticks.tsx
Normal file
55
packages/dreamknob/src/primitives/Ticks.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from 'react';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { polarToCartesian } from '../core/math';
|
||||
|
||||
export interface TicksProps {
|
||||
count?: number;
|
||||
/** Outer radius of the ticks. Defaults to size/2. */
|
||||
radius?: number;
|
||||
length?: number;
|
||||
width?: number;
|
||||
color?: string;
|
||||
/** Color for ticks at or below the current position. Enables "lit" scales. */
|
||||
activeColor?: string;
|
||||
cap?: 'butt' | 'round';
|
||||
/** Extra props per tick line, by index. */
|
||||
getTickProps?: (index: number, active: boolean) => React.SVGProps<SVGLineElement> | undefined;
|
||||
}
|
||||
|
||||
/** Radial tick marks distributed across the knob's travel. */
|
||||
export const Ticks: React.FC<TicksProps> = ({
|
||||
count = 11,
|
||||
radius,
|
||||
length = 6,
|
||||
width = 2,
|
||||
color = 'currentColor',
|
||||
activeColor,
|
||||
cap = 'round',
|
||||
getTickProps,
|
||||
}) => {
|
||||
const { size, center, normalized, angleOffset, angleRange } = useKnobContext();
|
||||
const outer = radius ?? size / 2;
|
||||
const ticks: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count === 1 ? 0 : i / (count - 1);
|
||||
const angle = angleOffset + t * angleRange;
|
||||
const p1 = polarToCartesian(center, center, outer, angle);
|
||||
const p2 = polarToCartesian(center, center, outer - length, angle);
|
||||
const active = t <= normalized + 1e-9;
|
||||
ticks.push(
|
||||
<line
|
||||
key={i}
|
||||
x1={p1.x}
|
||||
y1={p1.y}
|
||||
x2={p2.x}
|
||||
y2={p2.y}
|
||||
stroke={active && activeColor ? activeColor : color}
|
||||
strokeWidth={width}
|
||||
strokeLinecap={cap}
|
||||
{...getTickProps?.(i, active)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return <g>{ticks}</g>;
|
||||
};
|
||||
193
packages/dreamknob/src/skins/Fader.tsx
Normal file
193
packages/dreamknob/src/skins/Fader.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import * as React from 'react';
|
||||
import { useKnob } from '../hooks/useKnob';
|
||||
import type { KnobCoreProps } from '../core/types';
|
||||
import { MONO_FONT, UI_FONT } from './shared';
|
||||
|
||||
export interface FaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/** Cross-axis size in px. Default: 44. */
|
||||
breadth?: number;
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
/** Fill the travelled part of the track. Default: true. */
|
||||
showFill?: boolean;
|
||||
tickCount?: number;
|
||||
tickColor?: string;
|
||||
label?: string;
|
||||
showValue?: boolean;
|
||||
unit?: string;
|
||||
format?: (value: number) => string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Studio channel fader: absolute-position linear control with a cap handle. */
|
||||
export const Fader: React.FC<FaderProps> = ({
|
||||
orientation = 'vertical',
|
||||
length = 160,
|
||||
breadth = 44,
|
||||
color = '#4cc2ff',
|
||||
trackColor = 'rgba(255,255,255,0.12)',
|
||||
showFill = true,
|
||||
tickCount = 9,
|
||||
tickColor = 'rgba(255,255,255,0.15)',
|
||||
label,
|
||||
showValue = true,
|
||||
unit,
|
||||
format,
|
||||
textColor = 'rgba(255,255,255,0.92)',
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const vertical = orientation === 'vertical';
|
||||
const knob = useKnob({
|
||||
...core,
|
||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||
});
|
||||
const id = React.useId();
|
||||
|
||||
const capMain = 20; // handle size along the travel axis
|
||||
const capCross = breadth * 0.62;
|
||||
const trackW = Math.max(4, breadth * 0.12);
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const mid = breadth / 2;
|
||||
|
||||
// Handle center position along the travel axis.
|
||||
const travel = length - capMain;
|
||||
const pos = vertical
|
||||
? length - capMain / 2 - knob.normalized * travel
|
||||
: capMain / 2 + knob.normalized * travel;
|
||||
|
||||
const fixed = knob.value.toFixed(knob.decimals);
|
||||
const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
|
||||
const text = format ? format(knob.value) : `${trimmed}${unit ?? ''}`;
|
||||
|
||||
const ticks: React.ReactNode[] = [];
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
const t = tickCount === 1 ? 0 : i / (tickCount - 1);
|
||||
const p = vertical ? length - capMain / 2 - t * travel : capMain / 2 + t * travel;
|
||||
ticks.push(
|
||||
vertical ? (
|
||||
<React.Fragment key={i}>
|
||||
<line x1={mid - breadth * 0.36} y1={p} x2={mid - breadth * 0.24} y2={p} stroke={tickColor} strokeWidth="1.4" />
|
||||
<line x1={mid + breadth * 0.24} y1={p} x2={mid + breadth * 0.36} y2={p} stroke={tickColor} strokeWidth="1.4" />
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key={i}>
|
||||
<line x1={p} y1={mid - breadth * 0.36} x2={p} y2={mid - breadth * 0.24} stroke={tickColor} strokeWidth="1.4" />
|
||||
<line x1={p} y1={mid + breadth * 0.24} x2={p} y2={mid + breadth * 0.36} stroke={tickColor} strokeWidth="1.4" />
|
||||
</React.Fragment>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
// Fixed width so a widening value readout never reflows the layout;
|
||||
// overflowing text spills symmetrically via the centered flex rows.
|
||||
width: w,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{showValue && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: 12,
|
||||
color: textColor,
|
||||
minHeight: '1.2em',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div {...knob.bind} style={{ ...knob.bind.style, width: w, height: h, display: 'inline-flex' }}>
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2={vertical ? '0' : '1'} y2={vertical ? '1' : '0'}>
|
||||
<stop offset="0" stopColor="#43444b" />
|
||||
<stop offset="0.45" stopColor="#26272c" />
|
||||
<stop offset="0.5" stopColor="#0c0d10" />
|
||||
<stop offset="0.55" stopColor="#26272c" />
|
||||
<stop offset="1" stopColor="#16171b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{ticks}
|
||||
{vertical ? (
|
||||
<>
|
||||
<rect x={mid - trackW / 2} y={capMain / 2} width={trackW} height={travel} rx={trackW / 2} fill={trackColor} />
|
||||
{showFill && (
|
||||
<rect x={mid - trackW / 2} y={pos} width={trackW} height={length - capMain / 2 - pos} rx={trackW / 2} fill={color} />
|
||||
)}
|
||||
<g style={{ transition: knob.isDragging ? undefined : 'transform 40ms linear' }}>
|
||||
<rect
|
||||
x={mid - capCross / 2}
|
||||
y={pos - capMain / 2}
|
||||
width={capCross}
|
||||
height={capMain}
|
||||
rx={3}
|
||||
fill={`url(#${id}-cap)`}
|
||||
stroke="rgba(0,0,0,0.6)"
|
||||
/>
|
||||
<line x1={mid - capCross / 2 + 3} y1={pos} x2={mid + capCross / 2 - 3} y2={pos} stroke={color} strokeWidth="2" />
|
||||
</g>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<rect x={capMain / 2} y={mid - trackW / 2} width={travel} height={trackW} rx={trackW / 2} fill={trackColor} />
|
||||
{showFill && (
|
||||
<rect x={capMain / 2} y={mid - trackW / 2} width={pos - capMain / 2} height={trackW} rx={trackW / 2} fill={color} />
|
||||
)}
|
||||
<g>
|
||||
<rect
|
||||
x={pos - capMain / 2}
|
||||
y={mid - capCross / 2}
|
||||
width={capMain}
|
||||
height={capCross}
|
||||
rx={3}
|
||||
fill={`url(#${id}-cap)`}
|
||||
stroke="rgba(0,0,0,0.6)"
|
||||
/>
|
||||
<line x1={pos} y1={mid - capCross / 2 + 3} x2={pos} y2={mid + capCross / 2 - 3} stroke={color} strokeWidth="2" />
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
{label && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: UI_FONT,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: labelColor,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
packages/dreamknob/src/skins/FlatKnob.tsx
Normal file
69
packages/dreamknob/src/skins/FlatKnob.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Arc } from '../primitives/Arc';
|
||||
import { Face } from '../primitives/Face';
|
||||
import { Pointer } from '../primitives/Pointer';
|
||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface FlatKnobProps extends SkinProps {
|
||||
/** Accent color for the value arc and pointer. */
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
faceColor?: string;
|
||||
pointerColor?: string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
/** Draw the value arc from 'min' or from 'center' (pan-style). */
|
||||
arcFrom?: 'min' | 'center';
|
||||
arcThickness?: number;
|
||||
}
|
||||
|
||||
/** Clean 2D knob — the modern DAW/plugin look. */
|
||||
export const FlatKnob: React.FC<FlatKnobProps> = ({
|
||||
size = 80,
|
||||
color = '#4cc2ff',
|
||||
trackColor = 'rgba(255,255,255,0.12)',
|
||||
faceColor = 'rgba(255,255,255,0.05)',
|
||||
pointerColor,
|
||||
textColor = 'rgba(255,255,255,0.92)',
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
arcFrom = 'min',
|
||||
arcThickness,
|
||||
label,
|
||||
showValue = true,
|
||||
unit,
|
||||
format,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const t = arcThickness ?? Math.max(3, size * 0.055);
|
||||
return (
|
||||
<Knob size={size} className={className} style={style} {...core}>
|
||||
<Arc thickness={t} color={color} trackColor={trackColor} from={arcFrom} />
|
||||
<Face radius={size / 2 - t - 5} fill={faceColor} />
|
||||
<Pointer
|
||||
type="line"
|
||||
radius={size / 2 - t - 7}
|
||||
length={size * 0.16}
|
||||
width={Math.max(2.5, size * 0.04)}
|
||||
color={pointerColor ?? color}
|
||||
/>
|
||||
{showValue && (
|
||||
<KnobValue
|
||||
color={textColor}
|
||||
unit={unit}
|
||||
format={format}
|
||||
fontSize={size * 0.17}
|
||||
fontFamily={MONO_FONT}
|
||||
/>
|
||||
)}
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.42} fontSize={size * 0.1}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</Knob>
|
||||
);
|
||||
};
|
||||
192
packages/dreamknob/src/skins/LEDFader.tsx
Normal file
192
packages/dreamknob/src/skins/LEDFader.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import * as React from 'react';
|
||||
import { useKnob } from '../hooks/useKnob';
|
||||
import type { KnobCoreProps } from '../core/types';
|
||||
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||
import { UI_FONT } from './shared';
|
||||
|
||||
export interface LEDFaderZone {
|
||||
/** Upper bound of the zone as a normalized position (0..1]. */
|
||||
upTo: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface LEDFaderProps extends Omit<KnobCoreProps, 'interaction'> {
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/** Cross-axis size in px. Default: 34. */
|
||||
breadth?: number;
|
||||
/** Number of LED segments. Default: 24. */
|
||||
segments?: number;
|
||||
/** LED color when no zones are given. */
|
||||
color?: string;
|
||||
/**
|
||||
* Meter-style color zones by normalized position, e.g.
|
||||
* [{ upTo: 0.6, color: '#3df2ad' }, { upTo: 0.85, color: '#ffd23e' }, { upTo: 1, color: '#ff4d6b' }]
|
||||
*/
|
||||
zones?: readonly LEDFaderZone[];
|
||||
/** Opacity of unlit segments (they keep their zone color). Default: 0.13. */
|
||||
offOpacity?: number;
|
||||
glow?: boolean;
|
||||
/** Well/panel color behind the LEDs. */
|
||||
faceColor?: string;
|
||||
label?: string;
|
||||
/** Seven-segment readout above the bar. Default: true. */
|
||||
showValue?: boolean;
|
||||
/** Digit cells in the readout. Default: 4. */
|
||||
digits?: number;
|
||||
/** Decimals in the readout (defaults to the control's decimals, capped at 1). */
|
||||
displayDecimals?: number;
|
||||
labelColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Segmented LED fader — an interactive meter-style linear control. */
|
||||
export const LEDFader: React.FC<LEDFaderProps> = ({
|
||||
orientation = 'vertical',
|
||||
length = 160,
|
||||
breadth = 34,
|
||||
segments = 24,
|
||||
color = '#3df2ad',
|
||||
zones,
|
||||
offOpacity = 0.13,
|
||||
glow = true,
|
||||
faceColor = '#0b0d0e',
|
||||
label,
|
||||
showValue = true,
|
||||
digits = 4,
|
||||
displayDecimals,
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const vertical = orientation === 'vertical';
|
||||
const knob = useKnob({
|
||||
...core,
|
||||
interaction: vertical ? 'track-vertical' : 'track-horizontal',
|
||||
});
|
||||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const pad = 5;
|
||||
const zoneList: readonly LEDFaderZone[] = zones ?? [{ upTo: 1, color }];
|
||||
const zoneFor = (t: number): string =>
|
||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||
|
||||
const lit = Math.round(knob.normalized * segments);
|
||||
const slot = (length - pad * 2) / segments;
|
||||
const gap = Math.min(3, slot * 0.35);
|
||||
const cross = breadth - pad * 2;
|
||||
|
||||
const leds: React.ReactNode[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const on = i < lit;
|
||||
const c = zoneFor((i + 1) / segments);
|
||||
const common = {
|
||||
rx: 1.5,
|
||||
fill: c,
|
||||
opacity: on ? 1 : offOpacity,
|
||||
filter: on && glow ? `url(#${glowId})` : undefined,
|
||||
};
|
||||
leds.push(
|
||||
vertical ? (
|
||||
<rect
|
||||
key={i}
|
||||
x={pad}
|
||||
y={length - pad - (i + 1) * slot + gap / 2}
|
||||
width={cross}
|
||||
height={slot - gap}
|
||||
{...common}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
key={i}
|
||||
x={pad + i * slot + gap / 2}
|
||||
y={pad}
|
||||
width={slot - gap}
|
||||
height={cross}
|
||||
{...common}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
width: w,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{showValue && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||
<SegmentDisplay
|
||||
value={knob.value}
|
||||
digits={digits}
|
||||
decimals={displayDecimals ?? Math.min(knob.decimals, 1)}
|
||||
height={12}
|
||||
color={zoneFor(knob.normalized)}
|
||||
background="none"
|
||||
ghostOpacity={0.07}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div {...knob.bind} style={{ ...knob.bind.style, width: w, height: h, display: 'inline-flex' }}>
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowId}
|
||||
filterUnits="userSpaceOnUse"
|
||||
x={-w * 0.3}
|
||||
y={-h * 0.3}
|
||||
width={w * 1.6}
|
||||
height={h * 1.6}
|
||||
>
|
||||
<feGaussianBlur stdDeviation={1.2} result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={w - 1}
|
||||
height={h - 1}
|
||||
rx={5}
|
||||
fill={faceColor}
|
||||
stroke="rgba(255,255,255,0.09)"
|
||||
/>
|
||||
{leds}
|
||||
</svg>
|
||||
</div>
|
||||
{label && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: UI_FONT,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: labelColor,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
131
packages/dreamknob/src/skins/LEDKnob.tsx
Normal file
131
packages/dreamknob/src/skins/LEDKnob.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { GlowFilter } from '../primitives/GlowFilter';
|
||||
import { KnobLabel } from '../primitives/Text';
|
||||
import { describeArc } from '../core/math';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { formatForDisplay, renderSegmentText } from '../digital/segments';
|
||||
import { UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface LEDKnobProps extends SkinProps {
|
||||
/** LED color. */
|
||||
color?: string;
|
||||
/** Unlit segment color. */
|
||||
offColor?: string;
|
||||
/** Number of LED segments around the travel. */
|
||||
segments?: number;
|
||||
/** Digit cells in the center readout. */
|
||||
digits?: number;
|
||||
/** Decimals shown in the readout (defaults to the knob's decimals, capped at 2). */
|
||||
displayDecimals?: number;
|
||||
labelColor?: string;
|
||||
faceColor?: string;
|
||||
}
|
||||
|
||||
const SegmentRing: React.FC<{ color: string; offColor: string; segments: number; glowId: string }> = ({
|
||||
color,
|
||||
offColor,
|
||||
segments,
|
||||
glowId,
|
||||
}) => {
|
||||
const { size, center: c, normalized, angleOffset, angleRange } = useKnobContext();
|
||||
const r = size / 2 - size * 0.065;
|
||||
const thickness = size * 0.065;
|
||||
const lit = Math.round(normalized * segments);
|
||||
const gapDeg = Math.min(3, (angleRange / segments) * 0.3);
|
||||
const segDeg = angleRange / segments - gapDeg;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{Array.from({ length: segments }, (_, i) => {
|
||||
const start = angleOffset + (i * angleRange) / segments + gapDeg / 2;
|
||||
const on = i < lit;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={describeArc(c, c, r, start, start + segDeg)}
|
||||
stroke={on ? color : offColor}
|
||||
strokeWidth={thickness}
|
||||
fill="none"
|
||||
filter={on ? `url(#${glowId})` : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const CenterDisplay: React.FC<{
|
||||
color: string;
|
||||
digits: number;
|
||||
decimals: number;
|
||||
glowId: string;
|
||||
}> = ({ color, digits, decimals, glowId }) => {
|
||||
const { size, center: c, value } = useKnobContext();
|
||||
const text = formatForDisplay(value, digits, decimals);
|
||||
const { nodes, width, height } = renderSegmentText(text, {
|
||||
color,
|
||||
ghostOpacity: 0.1,
|
||||
skew: 6,
|
||||
});
|
||||
const targetW = size * 0.46;
|
||||
const scale = width > 0 ? Math.min(targetW / width, (size * 0.24) / height) : 1;
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${c - (width * scale) / 2} ${c - (height * scale) / 2}) scale(${scale})`}
|
||||
filter={`url(#${glowId})`}
|
||||
>
|
||||
{nodes}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Digital knob: segmented LED ring around a real seven-segment readout. */
|
||||
export const LEDKnob: React.FC<LEDKnobProps> = ({
|
||||
size = 90,
|
||||
color = '#3df2ad',
|
||||
offColor = 'rgba(255,255,255,0.07)',
|
||||
segments = 24,
|
||||
digits = 3,
|
||||
displayDecimals,
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
faceColor = '#0b0d0e',
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
return (
|
||||
<Knob size={size} className={className} style={style} {...core}>
|
||||
{ctx => (
|
||||
<>
|
||||
<defs>
|
||||
<GlowFilter id={glowId} blur={size * 0.014} />
|
||||
</defs>
|
||||
<circle
|
||||
cx={ctx.center}
|
||||
cy={ctx.center}
|
||||
r={size / 2 - size * 0.14}
|
||||
fill={faceColor}
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<SegmentRing color={color} offColor={offColor} segments={segments} glowId={glowId} />
|
||||
<CenterDisplay
|
||||
color={color}
|
||||
digits={digits}
|
||||
decimals={displayDecimals ?? Math.min(ctx.decimals, 2)}
|
||||
glowId={glowId}
|
||||
/>
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.095}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Knob>
|
||||
);
|
||||
};
|
||||
122
packages/dreamknob/src/skins/MetalKnob.tsx
Normal file
122
packages/dreamknob/src/skins/MetalKnob.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Arc } from '../primitives/Arc';
|
||||
import { Pointer } from '../primitives/Pointer';
|
||||
import { Ticks } from '../primitives/Ticks';
|
||||
import { KnobLabel } from '../primitives/Text';
|
||||
import { UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface MetalKnobProps extends SkinProps {
|
||||
/** Accent color for the active ticks / value arc. */
|
||||
color?: string;
|
||||
/** Overall metal tone. 'silver' | 'dark' or any base hex for the cap. */
|
||||
tone?: 'silver' | 'dark';
|
||||
tickColor?: string;
|
||||
labelColor?: string;
|
||||
indicatorColor?: string;
|
||||
showArc?: boolean;
|
||||
tickCount?: number;
|
||||
}
|
||||
|
||||
/** 3D brushed-aluminum knob with a knurled rim — classic hi-fi hardware. */
|
||||
export const MetalKnob: React.FC<MetalKnobProps> = ({
|
||||
size = 90,
|
||||
color = '#4cc2ff',
|
||||
tone = 'silver',
|
||||
tickColor = 'rgba(255,255,255,0.18)',
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
indicatorColor,
|
||||
showArc = true,
|
||||
tickCount = 21,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const id = React.useId();
|
||||
const rimId = `${id}-rim`;
|
||||
const faceId = `${id}-face`;
|
||||
const shadowId = `${id}-shadow`;
|
||||
const c = size / 2;
|
||||
const rimR = size / 2 - size * 0.14;
|
||||
const faceR = rimR * 0.78;
|
||||
|
||||
const silver = tone === 'silver';
|
||||
const rimStops = silver
|
||||
? ['#f4f5f7', '#b9bbc2', '#63656d', '#33343a']
|
||||
: ['#6a6c74', '#43444b', '#232429', '#101114'];
|
||||
const faceStops = silver
|
||||
? ['#fbfcfd', '#d3d5da', '#9c9ea6']
|
||||
: ['#585a63', '#33343b', '#1b1c21'];
|
||||
const indicator = indicatorColor ?? (silver ? '#1b1c21' : color);
|
||||
|
||||
return (
|
||||
<Knob size={size} className={className} style={style} {...core}>
|
||||
<defs>
|
||||
<linearGradient id={rimId} x1="0" y1="0" x2="0.8" y2="1">
|
||||
<stop offset="0" stopColor={rimStops[0]} />
|
||||
<stop offset="0.45" stopColor={rimStops[1]} />
|
||||
<stop offset="0.8" stopColor={rimStops[2]} />
|
||||
<stop offset="1" stopColor={rimStops[3]} />
|
||||
</linearGradient>
|
||||
<radialGradient id={faceId} cx="0.35" cy="0.28" r="0.9">
|
||||
<stop offset="0" stopColor={faceStops[0]} />
|
||||
<stop offset="0.6" stopColor={faceStops[1]} />
|
||||
<stop offset="1" stopColor={faceStops[2]} />
|
||||
</radialGradient>
|
||||
<filter id={shadowId} x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy={size * 0.02}
|
||||
stdDeviation={size * 0.025}
|
||||
floodColor="#000"
|
||||
floodOpacity="0.55"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{showArc && (
|
||||
<Arc
|
||||
radius={size / 2 - 2}
|
||||
thickness={Math.max(2, size * 0.03)}
|
||||
color={color}
|
||||
trackColor="rgba(255,255,255,0.07)"
|
||||
/>
|
||||
)}
|
||||
<Ticks
|
||||
count={tickCount}
|
||||
radius={size / 2 - size * 0.055}
|
||||
length={size * 0.05}
|
||||
width={Math.max(1, size * 0.015)}
|
||||
color={tickColor}
|
||||
activeColor={color}
|
||||
/>
|
||||
|
||||
{/* cap */}
|
||||
<g filter={`url(#${shadowId})`}>
|
||||
<circle cx={c} cy={c} r={rimR} fill={`url(#${rimId})`} />
|
||||
</g>
|
||||
{/* knurled rim */}
|
||||
<circle
|
||||
cx={c}
|
||||
cy={c}
|
||||
r={rimR - size * 0.014}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.35)"
|
||||
strokeWidth={size * 0.028}
|
||||
strokeDasharray={`${size * 0.014} ${size * 0.02}`}
|
||||
/>
|
||||
<circle cx={c} cy={c} r={faceR} fill={`url(#${faceId})`} />
|
||||
{/* concentric machining lines */}
|
||||
<circle cx={c} cy={c} r={faceR * 0.72} fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="0.6" />
|
||||
<circle cx={c} cy={c} r={faceR * 0.5} fill="none" stroke="rgba(0,0,0,0.12)" strokeWidth="0.6" />
|
||||
|
||||
<Pointer radius={faceR - size * 0.02} length={faceR * 0.55} width={Math.max(2.5, size * 0.038)} color={indicator} />
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</Knob>
|
||||
);
|
||||
};
|
||||
86
packages/dreamknob/src/skins/NeonKnob.tsx
Normal file
86
packages/dreamknob/src/skins/NeonKnob.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Arc } from '../primitives/Arc';
|
||||
import { Pointer } from '../primitives/Pointer';
|
||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface NeonKnobProps extends SkinProps {
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
arcFrom?: 'min' | 'center';
|
||||
}
|
||||
|
||||
/** Minimal glowing arc knob — futuristic / dark-mode DAW style. */
|
||||
export const NeonKnob: React.FC<NeonKnobProps> = ({
|
||||
size = 84,
|
||||
color = '#e44cff',
|
||||
trackColor = 'rgba(255,255,255,0.06)',
|
||||
textColor,
|
||||
labelColor = 'rgba(255,255,255,0.4)',
|
||||
arcFrom = 'min',
|
||||
label,
|
||||
showValue = true,
|
||||
unit,
|
||||
format,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const id = React.useId();
|
||||
const glowId = `${id}-glow`;
|
||||
return (
|
||||
<Knob size={size} className={className} style={style} {...core}>
|
||||
<defs>
|
||||
<filter
|
||||
id={glowId}
|
||||
filterUnits="userSpaceOnUse"
|
||||
x={-size * 0.3}
|
||||
y={-size * 0.3}
|
||||
width={size * 1.6}
|
||||
height={size * 1.6}
|
||||
>
|
||||
<feGaussianBlur stdDeviation={size * 0.035} result="b1" />
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation={size * 0.012} result="b2" />
|
||||
<feMerge>
|
||||
<feMergeNode in="b1" />
|
||||
<feMergeNode in="b2" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<Arc
|
||||
radius={size / 2 - size * 0.09}
|
||||
thickness={Math.max(2.5, size * 0.035)}
|
||||
color={color}
|
||||
trackColor={trackColor}
|
||||
from={arcFrom}
|
||||
arcProps={{ filter: `url(#${glowId})` }}
|
||||
/>
|
||||
<Pointer
|
||||
type="circle"
|
||||
radius={size / 2 - size * 0.09}
|
||||
width={Math.max(2.5, size * 0.035)}
|
||||
color="#ffffff"
|
||||
shapeProps={{ filter: `url(#${glowId})` }}
|
||||
/>
|
||||
{showValue && (
|
||||
<KnobValue
|
||||
color={textColor ?? color}
|
||||
unit={unit}
|
||||
format={format}
|
||||
fontSize={size * 0.17}
|
||||
fontFamily={MONO_FONT}
|
||||
textProps={{ filter: `url(#${glowId})` }}
|
||||
/>
|
||||
)}
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.1}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</Knob>
|
||||
);
|
||||
};
|
||||
104
packages/dreamknob/src/skins/RubberKnob.tsx
Normal file
104
packages/dreamknob/src/skins/RubberKnob.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Arc } from '../primitives/Arc';
|
||||
import { GlowFilter } from '../primitives/GlowFilter';
|
||||
import { Pointer } from '../primitives/Pointer';
|
||||
import { KnobLabel, KnobValue } from '../primitives/Text';
|
||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface RubberKnobProps extends SkinProps {
|
||||
/** Glow/accent color. */
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
arcFrom?: 'min' | 'center';
|
||||
}
|
||||
|
||||
/** Soft-touch rubber knob with a glowing halo — modern synth hardware. */
|
||||
export const RubberKnob: React.FC<RubberKnobProps> = ({
|
||||
size = 90,
|
||||
color = '#ff9640',
|
||||
trackColor = 'rgba(255,255,255,0.08)',
|
||||
textColor = 'rgba(255,255,255,0.92)',
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
arcFrom = 'min',
|
||||
label,
|
||||
showValue = false,
|
||||
unit,
|
||||
format,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const id = React.useId();
|
||||
const bodyId = `${id}-body`;
|
||||
const glowId = `${id}-glow`;
|
||||
const c = size / 2;
|
||||
const bodyR = size / 2 - size * 0.17;
|
||||
|
||||
return (
|
||||
<Knob size={size} className={className} style={style} {...core}>
|
||||
<defs>
|
||||
<radialGradient id={bodyId} cx="0.38" cy="0.3" r="1">
|
||||
<stop offset="0" stopColor="#3a3b41" />
|
||||
<stop offset="0.55" stopColor="#232428" />
|
||||
<stop offset="1" stopColor="#0e0f12" />
|
||||
</radialGradient>
|
||||
<GlowFilter id={glowId} blur={size * 0.02} />
|
||||
</defs>
|
||||
|
||||
<Arc
|
||||
radius={size / 2 - size * 0.05}
|
||||
thickness={Math.max(3, size * 0.045)}
|
||||
color={color}
|
||||
trackColor={trackColor}
|
||||
from={arcFrom}
|
||||
arcProps={{ filter: `url(#${glowId})` }}
|
||||
/>
|
||||
|
||||
<circle cx={c} cy={c} r={bodyR} fill={`url(#${bodyId})`} stroke="#000" strokeWidth="1" />
|
||||
{/* rubber grip */}
|
||||
<circle
|
||||
cx={c}
|
||||
cy={c}
|
||||
r={bodyR - size * 0.03}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.5)"
|
||||
strokeWidth={size * 0.035}
|
||||
strokeDasharray={`${size * 0.02} ${size * 0.03}`}
|
||||
/>
|
||||
{/* top sheen */}
|
||||
<ellipse
|
||||
cx={c}
|
||||
cy={c - bodyR * 0.45}
|
||||
rx={bodyR * 0.6}
|
||||
ry={bodyR * 0.28}
|
||||
fill="rgba(255,255,255,0.07)"
|
||||
/>
|
||||
|
||||
<Pointer
|
||||
radius={bodyR - size * 0.045}
|
||||
length={bodyR * 0.5}
|
||||
width={Math.max(2.5, size * 0.04)}
|
||||
color={color}
|
||||
shapeProps={{ filter: `url(#${glowId})` }}
|
||||
/>
|
||||
{showValue && (
|
||||
<KnobValue
|
||||
color={textColor}
|
||||
unit={unit}
|
||||
format={format}
|
||||
fontSize={size * 0.15}
|
||||
fontFamily={MONO_FONT}
|
||||
dy={size * 0.02}
|
||||
/>
|
||||
)}
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.44} fontSize={size * 0.1}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</Knob>
|
||||
);
|
||||
};
|
||||
94
packages/dreamknob/src/skins/SteppedKnob.tsx
Normal file
94
packages/dreamknob/src/skins/SteppedKnob.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Pointer } from '../primitives/Pointer';
|
||||
import { Ticks } from '../primitives/Ticks';
|
||||
import { KnobLabel } from '../primitives/Text';
|
||||
import { MONO_FONT, UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface SteppedKnobProps extends SkinProps {
|
||||
/** Position names, e.g. ['LP','BP','HP']. Sets the number of detents. */
|
||||
positions?: readonly string[];
|
||||
/** Number of detents when `positions` is not given. */
|
||||
steps?: number;
|
||||
color?: string;
|
||||
tickColor?: string;
|
||||
faceColor?: string;
|
||||
textColor?: string;
|
||||
labelColor?: string;
|
||||
}
|
||||
|
||||
/** Detented selector knob — mode/range switches with hard stops. */
|
||||
export const SteppedKnob: React.FC<SteppedKnobProps> = ({
|
||||
size = 90,
|
||||
positions,
|
||||
steps,
|
||||
color = '#ffd23e',
|
||||
tickColor = 'rgba(255,255,255,0.18)',
|
||||
faceColor = '#1c1d22',
|
||||
textColor = 'rgba(255,255,255,0.92)',
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
label,
|
||||
showValue = true,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => {
|
||||
const detents = positions ? positions.length : steps ?? 5;
|
||||
const min = core.min ?? 0;
|
||||
const max = core.max ?? (positions ? positions.length - 1 : 100);
|
||||
|
||||
return (
|
||||
<Knob size={size} className={className} style={style} {...core} min={min} max={max} steps={detents}>
|
||||
{ctx => {
|
||||
const index = Math.round(ctx.normalized * (detents - 1));
|
||||
return (
|
||||
<>
|
||||
<Ticks
|
||||
count={detents}
|
||||
radius={size / 2 - 1}
|
||||
length={size * 0.07}
|
||||
width={Math.max(1.5, size * 0.022)}
|
||||
color={tickColor}
|
||||
getTickProps={i => (i === index ? { stroke: color } : undefined)}
|
||||
/>
|
||||
<circle
|
||||
cx={ctx.center}
|
||||
cy={ctx.center}
|
||||
r={size / 2 - size * 0.14}
|
||||
fill={faceColor}
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<Pointer
|
||||
type="triangle"
|
||||
radius={size / 2 - size * 0.15}
|
||||
length={size * 0.14}
|
||||
width={size * 0.09}
|
||||
color={color}
|
||||
/>
|
||||
{showValue && (
|
||||
<text
|
||||
x={ctx.center}
|
||||
y={ctx.center}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={size * (positions ? 0.15 : 0.17)}
|
||||
fill={textColor}
|
||||
fontFamily={MONO_FONT}
|
||||
fontWeight={600}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{positions ? positions[index] : ctx.value.toFixed(ctx.decimals)}
|
||||
</text>
|
||||
)}
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.45} fontSize={size * 0.095}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Knob>
|
||||
);
|
||||
};
|
||||
156
packages/dreamknob/src/skins/VintageKnob.tsx
Normal file
156
packages/dreamknob/src/skins/VintageKnob.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import * as React from 'react';
|
||||
import { Knob } from '../components/Knob';
|
||||
import { Ticks } from '../primitives/Ticks';
|
||||
import { KnobLabel } from '../primitives/Text';
|
||||
import { polarToCartesian } from '../core/math';
|
||||
import { useKnobContext } from '../core/context';
|
||||
import { UI_FONT, type SkinProps } from './shared';
|
||||
|
||||
export interface VintageKnobProps extends SkinProps {
|
||||
/** Knob body color. Classic options: cream '#efe6d0' or bakelite '#26221f'. */
|
||||
bodyColor?: string;
|
||||
/** Tick/scale color. */
|
||||
scaleColor?: string;
|
||||
/** Accent for active ticks. */
|
||||
color?: string;
|
||||
labelColor?: string;
|
||||
/** Draw numeric scale labels (0..10 style) around the knob. */
|
||||
scaleLabels?: readonly string[];
|
||||
/** Indicator line color. Defaults to a contrast pick based on bodyColor. */
|
||||
indicatorColor?: string;
|
||||
}
|
||||
|
||||
/** Rough luminance check so the indicator stays visible on any body color. */
|
||||
const isDarkColor = (color: string): boolean => {
|
||||
const m = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!m) return false;
|
||||
let h = m[1];
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
|
||||
};
|
||||
|
||||
/** Chicken-head pointer knob rotated by the current angle. */
|
||||
const ChickenHead: React.FC<{ bodyColor: string; indicatorColor: string }> = ({
|
||||
bodyColor,
|
||||
indicatorColor,
|
||||
}) => {
|
||||
const { size, center: c, angle } = useKnobContext();
|
||||
const id = React.useId();
|
||||
const bodyR = size * 0.26;
|
||||
const noseR = size * 0.42;
|
||||
|
||||
// Body circle with a pointed nose reaching up to noseR, authored at 12
|
||||
// o'clock. Two shoulder points at ±38° blend the nose into the circle.
|
||||
const left = polarToCartesian(c, c, bodyR, -38);
|
||||
const right = polarToCartesian(c, c, bodyR, 38);
|
||||
const tip = { x: c, y: c - noseR };
|
||||
|
||||
return (
|
||||
<g transform={`rotate(${angle} ${c} ${c})`} filter={`url(#${id}-s)`}>
|
||||
<defs>
|
||||
<radialGradient id={`${id}-g`} cx="0.4" cy="0.3" r="1">
|
||||
<stop offset="0" stopColor="#ffffff" stopOpacity="0.32" />
|
||||
<stop offset="0.5" stopColor="#ffffff" stopOpacity="0.05" />
|
||||
<stop offset="1" stopColor="#000000" stopOpacity="0.28" />
|
||||
</radialGradient>
|
||||
<filter id={`${id}-s`} x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy={size * 0.015} stdDeviation={size * 0.02} floodColor="#000" floodOpacity="0.5" />
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx={c} cy={c} r={bodyR} fill={bodyColor} />
|
||||
<path
|
||||
d={`M ${left.x} ${left.y} Q ${c - size * 0.055} ${c - noseR * 0.72} ${tip.x} ${tip.y} Q ${c + size * 0.055} ${c - noseR * 0.72} ${right.x} ${right.y} Z`}
|
||||
fill={bodyColor}
|
||||
/>
|
||||
<circle cx={c} cy={c} r={bodyR} fill={`url(#${id}-g)`} />
|
||||
{/* indicator line down the nose */}
|
||||
<line
|
||||
x1={c}
|
||||
y1={c - noseR + size * 0.03}
|
||||
x2={c}
|
||||
y2={c - bodyR * 0.2}
|
||||
stroke={indicatorColor}
|
||||
strokeWidth={Math.max(1.5, size * 0.018)}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* center screw */}
|
||||
<circle cx={c} cy={c} r={size * 0.045} fill="#b9b3a4" stroke="#5f594c" strokeWidth="0.8" />
|
||||
<line
|
||||
x1={c - size * 0.03}
|
||||
y1={c}
|
||||
x2={c + size * 0.03}
|
||||
y2={c}
|
||||
stroke="#5f594c"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ScaleLabels: React.FC<{ labels: readonly string[]; color: string }> = ({ labels, color }) => {
|
||||
const { size, center: c, angleOffset, angleRange } = useKnobContext();
|
||||
return (
|
||||
<g>
|
||||
{labels.map((text, i) => {
|
||||
const t = labels.length === 1 ? 0 : i / (labels.length - 1);
|
||||
const p = polarToCartesian(c, c, size / 2 - size * 0.02, angleOffset + t * angleRange);
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={size * 0.085}
|
||||
fontFamily={UI_FONT}
|
||||
fill={color}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
/** Vintage amp-style knob: chicken-head pointer over a printed tick scale. */
|
||||
export const VintageKnob: React.FC<VintageKnobProps> = ({
|
||||
size = 96,
|
||||
bodyColor = '#efe6d0',
|
||||
scaleColor = 'rgba(255,255,255,0.35)',
|
||||
color,
|
||||
labelColor = 'rgba(255,255,255,0.45)',
|
||||
scaleLabels,
|
||||
indicatorColor,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
...core
|
||||
}) => (
|
||||
<Knob size={size} className={className} style={style} {...core}>
|
||||
<Ticks
|
||||
count={11}
|
||||
radius={size / 2 - size * (scaleLabels ? 0.1 : 0.04)}
|
||||
length={size * 0.055}
|
||||
width={Math.max(1.2, size * 0.016)}
|
||||
color={scaleColor}
|
||||
activeColor={color ?? scaleColor}
|
||||
/>
|
||||
{scaleLabels && <ScaleLabels labels={scaleLabels} color={scaleColor} />}
|
||||
<ChickenHead
|
||||
bodyColor={bodyColor}
|
||||
indicatorColor={
|
||||
indicatorColor ?? (isDarkColor(bodyColor) ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.55)')
|
||||
}
|
||||
/>
|
||||
{label && (
|
||||
<KnobLabel color={labelColor} fontFamily={UI_FONT} dy={size * 0.46} fontSize={size * 0.095}>
|
||||
{label}
|
||||
</KnobLabel>
|
||||
)}
|
||||
</Knob>
|
||||
);
|
||||
23
packages/dreamknob/src/skins/shared.ts
Normal file
23
packages/dreamknob/src/skins/shared.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type * as React from 'react';
|
||||
import type { KnobCoreProps } from '../core/types';
|
||||
|
||||
/** Props common to every prebuilt knob skin. */
|
||||
export interface SkinProps extends KnobCoreProps {
|
||||
/** Square canvas size in px. */
|
||||
size?: number;
|
||||
/** Caption drawn in the travel gap at the bottom of the knob. */
|
||||
label?: string;
|
||||
/** Show the numeric readout. Default varies per skin. */
|
||||
showValue?: boolean;
|
||||
/** Unit suffix for the readout, e.g. "dB", "Hz", "%". */
|
||||
unit?: string;
|
||||
/** Custom readout formatter (takes precedence over unit/decimals). */
|
||||
format?: (value: number) => string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const MONO_FONT =
|
||||
"'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||||
export const UI_FONT =
|
||||
"'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif";
|
||||
Loading…
Add table
Add a link
Reference in a new issue