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
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
42
README.md
Normal file
42
README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# dreamknob
|
||||||
|
|
||||||
|
A feature-full, studio-grade **React + TypeScript knob & fader library** with a headless
|
||||||
|
interaction core — plus a showcase/documentation site.
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install # install everything
|
||||||
|
pnpm dev # run the showcase at http://localhost:5173
|
||||||
|
pnpm test # library unit tests
|
||||||
|
pnpm build # build library + docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `packages/dreamknob` | The library. Zero runtime dependencies, ESM + CJS + `.d.ts`. |
|
||||||
|
| `apps/docs` | Vite showcase: gallery, live playground with code generation, API docs. |
|
||||||
|
|
||||||
|
## The library in 10 seconds
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { MetalKnob, LEDKnob, Fader, logTaper } from 'dreamknob'
|
||||||
|
|
||||||
|
<MetalKnob size={90} min={0} max={100} step={0.5} defaultValue={64} label="Gain" />
|
||||||
|
<LEDKnob min={20} max={20000} taper={logTaper} defaultValue={632} label="Freq" />
|
||||||
|
<Fader min={-60} max={12} step={0.5} unit=" dB" defaultValue={-6} label="Out" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Feel**: grab-and-rotate rotary tracking (rc-knob style), or relative vertical /
|
||||||
|
horizontal drag (DAW plugin style), or absolute track dragging for faders. Scroll-wheel
|
||||||
|
nudge, full keyboard support, Shift for fine control, double-click reset, touch via
|
||||||
|
pointer capture, proper `role="slider"` accessibility.
|
||||||
|
- **Numbers**: any range (negative, fractional, huge), decimal `step` with float-drift-safe
|
||||||
|
rounding, discrete `values` lists, evenly spaced detents (`steps`), linear / log /
|
||||||
|
power / custom tapers.
|
||||||
|
- **Styles**: `FlatKnob`, `MetalKnob`, `RubberKnob`, `VintageKnob`, `LEDKnob`, `NeonKnob`,
|
||||||
|
`SteppedKnob`, `Fader`, `LEDFader`, `SegmentDisplay` — every color themeable per instance.
|
||||||
|
- **Composable**: `<Knob>` + primitives (`Arc`, `Pointer`, `Ticks`, `Face`, `KnobValue`,
|
||||||
|
`KnobLabel`) for custom designs, or go fully headless with `useKnob`.
|
||||||
|
|
||||||
|
See `packages/dreamknob/README.md` and the docs app for the full API.
|
||||||
22
apps/docs/index.html
Normal file
22
apps/docs/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>dreamknob — studio-grade React knobs & faders</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%2318191f' stroke='%234cc2ff' stroke-width='6'/%3E%3Cline x1='50' y1='50' x2='50' y2='16' stroke='%234cc2ff' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
apps/docs/package.json
Normal file
25
apps/docs/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dreamknob": "workspace:*",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/docs/src/App.tsx
Normal file
42
apps/docs/src/App.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ApiDocs } from './sections/ApiDocs';
|
||||||
|
import { Console } from './sections/Console';
|
||||||
|
import { Gallery } from './sections/Gallery';
|
||||||
|
import { Hero } from './sections/Hero';
|
||||||
|
import { Playground } from './sections/Playground';
|
||||||
|
|
||||||
|
const Logo: React.FC = () => (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="42" fill="#18191f" stroke="#4cc2ff" strokeWidth="7" />
|
||||||
|
<line x1="50" y1="50" x2="50" y2="15" stroke="#4cc2ff" strokeWidth="9" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const App: React.FC = () => (
|
||||||
|
<>
|
||||||
|
<nav className="nav">
|
||||||
|
<div className="container nav-inner">
|
||||||
|
<a className="nav-logo" href="#">
|
||||||
|
<Logo />
|
||||||
|
dreamknob
|
||||||
|
</a>
|
||||||
|
<div className="nav-links">
|
||||||
|
<a href="#gallery">Gallery</a>
|
||||||
|
<a href="#console">Demo</a>
|
||||||
|
<a href="#playground">Playground</a>
|
||||||
|
<a href="#docs">Docs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<Hero />
|
||||||
|
<Gallery />
|
||||||
|
<Console />
|
||||||
|
<Playground />
|
||||||
|
<ApiDocs />
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="container">
|
||||||
|
dreamknob · MIT · built with a headless core, SVG and zero runtime dependencies
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
43
apps/docs/src/components/CodeBlock.tsx
Normal file
43
apps/docs/src/components/CodeBlock.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/** Tiny JSX-ish token colorizer — good enough for docs snippets. */
|
||||||
|
const highlight = (code: string): React.ReactNode[] => {
|
||||||
|
const pattern =
|
||||||
|
/(\/\/[^\n]*)|("[^"]*"|'[^']*'|`[^`]*`)|(\{[^{}]*\})|(<\/?[A-Za-z][\w.]*)|(\b\d+(?:\.\d+)?\b)|([a-zA-Z-]+)(?==)/g;
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let key = 0;
|
||||||
|
while ((m = pattern.exec(code))) {
|
||||||
|
if (m.index > last) out.push(code.slice(last, m.index));
|
||||||
|
const [full, com, str, expr, tag, num, attr] = m;
|
||||||
|
if (com) out.push(<span key={key++} className="tok-com">{full}</span>);
|
||||||
|
else if (str) out.push(<span key={key++} className="tok-str">{full}</span>);
|
||||||
|
else if (expr) out.push(<span key={key++} className="tok-num">{full}</span>);
|
||||||
|
else if (tag) out.push(<span key={key++} className="tok-tag">{full}</span>);
|
||||||
|
else if (num) out.push(<span key={key++} className="tok-num">{full}</span>);
|
||||||
|
else if (attr) out.push(<span key={key++} className="tok-key">{full}</span>);
|
||||||
|
last = m.index + full.length;
|
||||||
|
}
|
||||||
|
out.push(code.slice(last));
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeBlock: React.FC<{ code: string }> = ({ code }) => {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<div className="codeblock">
|
||||||
|
<button
|
||||||
|
className="copy"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? 'copied ✓' : 'copy'}
|
||||||
|
</button>
|
||||||
|
<pre>{highlight(code)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
10
apps/docs/src/main.tsx
Normal file
10
apps/docs/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
148
apps/docs/src/sections/ApiDocs.tsx
Normal file
148
apps/docs/src/sections/ApiDocs.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { CodeBlock } from '../components/CodeBlock';
|
||||||
|
|
||||||
|
const Row: React.FC<{ name: string; type: string; def?: string; desc: string }> = ({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
def,
|
||||||
|
desc,
|
||||||
|
}) => (
|
||||||
|
<tr>
|
||||||
|
<td>{name}</td>
|
||||||
|
<td>{type}</td>
|
||||||
|
<td>{def ?? '—'}</td>
|
||||||
|
<td>{desc}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Table: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<table className="api-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ApiDocs: React.FC = () => (
|
||||||
|
<section className="block" id="docs">
|
||||||
|
<div className="container prose">
|
||||||
|
<div className="section-kicker">Documentation</div>
|
||||||
|
<h2 className="section-title">API reference</h2>
|
||||||
|
<p className="section-sub">
|
||||||
|
Everything is built on one interaction core. The props below are shared by{' '}
|
||||||
|
<code>useKnob</code>, <code><Knob></code>, every prebuilt skin and{' '}
|
||||||
|
<code><Fader></code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="api-h">Core value & interaction props</h3>
|
||||||
|
<Table>
|
||||||
|
<Row name="value" type="number" desc="Controlled value. Pair with onChange." />
|
||||||
|
<Row name="defaultValue" type="number" desc="Uncontrolled initial value; also the double-click reset target." />
|
||||||
|
<Row name="min / max" type="number" def="0 / 100" desc="Value range. Any numbers, including negative and fractional." />
|
||||||
|
<Row name="step" type="number" def="0 (continuous)" desc="Snap increment, e.g. 1, 0.5, 0.001. Decimal steps set display precision automatically." />
|
||||||
|
<Row name="decimals" type="number" def="from step" desc="Decimal places of emitted values (float-drift-safe rounding)." />
|
||||||
|
<Row name="values" type="number[]" desc="Restrict to a discrete list, e.g. [0.25, 0.5, 1, 2, 4]. Overrides step." />
|
||||||
|
<Row name="steps" type="number" desc="Number of evenly spaced detents across the travel (selector knobs)." />
|
||||||
|
<Row name="taper" type="Taper" def="linearTaper" desc="Travel curve: linearTaper, logTaper (frequencies), powTaper(n) (gain), or your own." />
|
||||||
|
<Row name="interaction" type="InteractionMode" def="'rotary'" desc="'rotary' (grab & turn), 'vertical', 'horizontal', 'both', or absolute 'track-*' modes used by faders." />
|
||||||
|
<Row name="dragSensitivity" type="number" def="200" desc="Pixels of relative drag for full travel (vertical/horizontal modes)." />
|
||||||
|
<Row name="fineMultiplier" type="number" def="0.1" desc="Speed multiplier while Shift is held during drags and wheel." />
|
||||||
|
<Row name="angleOffset / angleRange" type="number" def="225 / 270" desc="Where travel starts and how far it sweeps, in degrees clockwise from 12 o'clock." />
|
||||||
|
<Row name="enableWheel" type="boolean" def="true" desc="Scroll-wheel nudging (one step per notch)." />
|
||||||
|
<Row name="doubleClickReset" type="boolean" def="auto" desc="Double-click resets to defaultValue." />
|
||||||
|
<Row name="onChange" type="(v: number) => void" desc="Fires for every distinct snapped value." />
|
||||||
|
<Row name="onChangeStart / onChangeEnd" type="(v: number) => void" desc="Gesture boundaries — ideal for undo history or parameter automation." />
|
||||||
|
<Row name="disabled / readOnly" type="boolean" def="false" desc="Disable interaction (readOnly stays focusable)." />
|
||||||
|
<Row name="aria-label / getAriaValueText" type="string / fn" desc="Accessibility. Every knob is a proper role='slider' with full keyboard support." />
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<h3 className="api-h">Keyboard & gestures</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
<code>↑/→</code> and <code>↓/←</code> step · <code>Shift</code>+arrows jump 10×
|
||||||
|
· <code>PageUp/PageDown</code> move 10% · <code>Home/End</code> jump to min/max ·
|
||||||
|
scroll wheel nudges (Shift = fine) · double-click resets · touch works via pointer
|
||||||
|
capture.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="api-h">Prebuilt skins</h3>
|
||||||
|
<Table>
|
||||||
|
<Row name="<FlatKnob />" type="2D" desc="Modern arc knob. color, trackColor, faceColor, pointerColor, textColor, arcFrom ('min' | 'center'), arcThickness." />
|
||||||
|
<Row name="<MetalKnob />" type="3D" desc="Brushed aluminum. tone ('silver' | 'dark'), color, tickCount, showArc, indicatorColor." />
|
||||||
|
<Row name="<RubberKnob />" type="3D" desc="Soft-touch synth knob with glow halo. color, trackColor, arcFrom." />
|
||||||
|
<Row name="<VintageKnob />" type="analog" desc="Chicken-head pointer. bodyColor (cream/bakelite/any), scaleColor, scaleLabels." />
|
||||||
|
<Row name="<LEDKnob />" type="digital" desc="Segmented LED ring + seven-segment readout. color, offColor, segments, digits, displayDecimals." />
|
||||||
|
<Row name="<NeonKnob />" type="2D" desc="Glowing arc + dot pointer. color, trackColor, arcFrom." />
|
||||||
|
<Row name="<SteppedKnob />" type="selector" desc="Detented switch. positions (named) or steps (numeric), color, faceColor." />
|
||||||
|
<Row name="<Fader />" type="linear" desc="Channel fader. orientation, length, breadth, color, tickCount, showFill, unit, format." />
|
||||||
|
<Row name="<LEDFader />" type="linear · digital" desc="Segmented LED meter-fader. orientation, length, segments, color or zones ([{upTo, color}]), glow, digits." />
|
||||||
|
<Row name="<SegmentDisplay />" type="digital" desc="Standalone seven-segment display. digits, decimals, color, glow, skew, ghostOpacity." />
|
||||||
|
</Table>
|
||||||
|
<p className="api-note">
|
||||||
|
All skins also take <code>label</code>, <code>showValue</code>, <code>unit</code>,{' '}
|
||||||
|
<code>format</code>, <code>className</code>, <code>style</code> plus every core prop
|
||||||
|
above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="api-h">Compose your own knob</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
<code><Knob></code> wires up the interaction and provides a render context;
|
||||||
|
primitives draw into it. Use the built-ins or drop in raw SVG.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { Knob, Arc, Pointer, Ticks, KnobValue } from 'dreamknob'
|
||||||
|
|
||||||
|
<Knob size={96} min={-24} max={24} step={0.5} defaultValue={0} aria-label="Trim">
|
||||||
|
<Ticks count={25} activeColor="#ffd23e" />
|
||||||
|
<Arc radius={36} thickness={3} color="#ffd23e" from="center" />
|
||||||
|
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
|
||||||
|
<KnobValue unit=" dB" />
|
||||||
|
</Knob>
|
||||||
|
|
||||||
|
// Or render-prop style for full control:
|
||||||
|
<Knob size={96} defaultValue={30} aria-label="Custom">
|
||||||
|
{({ normalized, angle, value, center }) => (
|
||||||
|
<circle cx={center} cy={center} r={30 + normalized * 8} fill="#4cc2ff33" />
|
||||||
|
)}
|
||||||
|
</Knob>`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="api-h">Fully headless</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
Skip our rendering entirely — <code>useKnob</code> gives you the state and the
|
||||||
|
event bindings, you bring the DOM (or canvas, or WebGL).
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
code={`import { useKnob } from 'dreamknob'
|
||||||
|
|
||||||
|
function MyKnob() {
|
||||||
|
const { value, angle, isDragging, bind } = useKnob({
|
||||||
|
min: 0, max: 11, step: 0.1, defaultValue: 11,
|
||||||
|
interaction: 'rotary',
|
||||||
|
'aria-label': 'Volume',
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div {...bind} style={{ ...bind.style, width: 80, height: 80 }}>
|
||||||
|
<div style={{ transform: \`rotate(\${angle}deg)\` }}>▲</div>
|
||||||
|
{isDragging && <span>{value}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="api-h">Precision & number handling</h3>
|
||||||
|
<p className="api-note">
|
||||||
|
Values are snapped to <code>step</code> (anchored at <code>min</code>) and rounded
|
||||||
|
with a float-drift-safe algorithm, so <code>step=0.1</code> never produces{' '}
|
||||||
|
<code>0.30000000000000004</code>. Ranges can be negative, tiny (<code>1e-7</code>{' '}
|
||||||
|
steps) or huge; <code>decimals</code> caps emitted precision, and{' '}
|
||||||
|
<code>format</code> controls display independently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
75
apps/docs/src/sections/Console.tsx
Normal file
75
apps/docs/src/sections/Console.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Fader, FlatKnob, SegmentDisplay } from 'dreamknob';
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ name: 'Kick', color: '#4cc2ff', level: -4 },
|
||||||
|
{ name: 'Snare', color: '#3df2ad', level: -8 },
|
||||||
|
{ name: 'Bass', color: '#ff9640', level: -6 },
|
||||||
|
{ name: 'Keys', color: '#e44cff', level: -12 },
|
||||||
|
{ name: 'Vox', color: '#ffd23e', level: -3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Channel: React.FC<{ name: string; color: string; level: number }> = ({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
level,
|
||||||
|
}) => {
|
||||||
|
const [gain, setGain] = React.useState(level);
|
||||||
|
return (
|
||||||
|
<div className="channel">
|
||||||
|
<span className="ch-name">{name}</span>
|
||||||
|
<FlatKnob
|
||||||
|
size={56}
|
||||||
|
defaultValue={0}
|
||||||
|
min={-50}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
arcFrom="center"
|
||||||
|
color={color}
|
||||||
|
showValue={false}
|
||||||
|
label="Pan"
|
||||||
|
aria-label={`${name} pan`}
|
||||||
|
/>
|
||||||
|
<FlatKnob
|
||||||
|
size={56}
|
||||||
|
defaultValue={20}
|
||||||
|
step={1}
|
||||||
|
color={color}
|
||||||
|
showValue={false}
|
||||||
|
label="Send"
|
||||||
|
aria-label={`${name} send`}
|
||||||
|
/>
|
||||||
|
<Fader
|
||||||
|
length={150}
|
||||||
|
breadth={38}
|
||||||
|
value={gain}
|
||||||
|
onChange={setGain}
|
||||||
|
min={-60}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
color={color}
|
||||||
|
format={v => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
|
||||||
|
aria-label={`${name} level`}
|
||||||
|
/>
|
||||||
|
<SegmentDisplay value={gain} digits={4} decimals={1} height={13} color={color} ghostOpacity={0.06} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Console: React.FC = () => (
|
||||||
|
<section className="block" id="console">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-kicker">In context</div>
|
||||||
|
<h2 className="section-title">Build whole consoles</h2>
|
||||||
|
<p className="section-sub">
|
||||||
|
Controlled or uncontrolled, the components compose into full instrument panels.
|
||||||
|
This mixer is ~40 lines of JSX.
|
||||||
|
</p>
|
||||||
|
<div className="console">
|
||||||
|
{CHANNELS.map(ch => (
|
||||||
|
<Channel key={ch.name} {...ch} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
149
apps/docs/src/sections/Gallery.tsx
Normal file
149
apps/docs/src/sections/Gallery.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Arc,
|
||||||
|
Fader,
|
||||||
|
FlatKnob,
|
||||||
|
Knob,
|
||||||
|
KnobValue,
|
||||||
|
LEDFader,
|
||||||
|
LEDKnob,
|
||||||
|
MetalKnob,
|
||||||
|
NeonKnob,
|
||||||
|
Pointer,
|
||||||
|
RubberKnob,
|
||||||
|
SegmentDisplay,
|
||||||
|
SteppedKnob,
|
||||||
|
Ticks,
|
||||||
|
VintageKnob,
|
||||||
|
logTaper,
|
||||||
|
} from 'dreamknob';
|
||||||
|
|
||||||
|
const Card: React.FC<{
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
tag: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ title, desc, tag, children }) => (
|
||||||
|
<div className="card">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="card-desc">{desc}</div>
|
||||||
|
<div className="demo">{children}</div>
|
||||||
|
<span className="tag">{tag}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Gallery: React.FC = () => (
|
||||||
|
<section className="block" id="gallery">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-kicker">Gallery</div>
|
||||||
|
<h2 className="section-title">Every style in the studio</h2>
|
||||||
|
<p className="section-sub">
|
||||||
|
Nine prebuilt skins — every color themeable per instance. All of them share the
|
||||||
|
same interaction core: rotary or drag adjustment, scroll-wheel nudge, keyboard,
|
||||||
|
fine control and double-click reset.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid">
|
||||||
|
<Card title="Flat" desc="Clean 2D arc knob for modern plugin UIs." tag="<FlatKnob />">
|
||||||
|
<FlatKnob size={86} defaultValue={64} label="Level" aria-label="Flat demo" />
|
||||||
|
<FlatKnob size={86} defaultValue={0} min={-50} max={50} arcFrom="center" color="#3df2ad" label="Pan" aria-label="Pan demo" />
|
||||||
|
<FlatKnob size={86} defaultValue={30} color="#ffd23e" trackColor="rgba(255,210,62,0.15)" label="Send" aria-label="Send demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Metal" desc="Brushed aluminum with a knurled rim — 3D hi-fi feel." tag="<MetalKnob />">
|
||||||
|
<MetalKnob size={92} defaultValue={40} label="Input" aria-label="Metal demo" />
|
||||||
|
<MetalKnob size={92} defaultValue={75} tone="dark" color="#ff9640" label="Drive" aria-label="Dark metal demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Rubber" desc="Soft-touch synth knob with a glowing halo." tag="<RubberKnob />">
|
||||||
|
<RubberKnob size={92} defaultValue={72} label="Cutoff" aria-label="Rubber demo" />
|
||||||
|
<RubberKnob size={92} defaultValue={30} color="#4cc2ff" label="Res" aria-label="Rubber resonance demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Vintage" desc="Chicken-head bakelite over a printed scale." tag="<VintageKnob />">
|
||||||
|
<VintageKnob size={96} defaultValue={7} min={0} max={10} step={0.5} label="Volume" scaleLabels={['0', '', '', '', '', '5', '', '', '', '', '10']} aria-label="Vintage demo" />
|
||||||
|
<VintageKnob size={96} defaultValue={4} min={0} max={10} bodyColor="#26221f" label="Tone" aria-label="Bakelite demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="LED" desc="Segmented ring + true seven-segment readout." tag="<LEDKnob />">
|
||||||
|
<LEDKnob size={96} defaultValue={120} min={40} max={240} step={1} label="BPM" aria-label="LED demo" />
|
||||||
|
<LEDKnob size={96} defaultValue={-12.5} min={-60} max={0} step={0.5} digits={4} color="#ff4d6b" label="Thresh" aria-label="Threshold demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Neon" desc="A glowing arc for dark, futuristic interfaces." tag="<NeonKnob />">
|
||||||
|
<NeonKnob size={90} defaultValue={35} label="Space" aria-label="Neon demo" />
|
||||||
|
<NeonKnob size={90} defaultValue={80} color="#3df2ad" label="Shine" aria-label="Neon green demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Stepped" desc="Detented selector with named or numeric positions." tag="<SteppedKnob />">
|
||||||
|
<SteppedKnob size={92} positions={['LP', 'BP', 'HP', 'NT']} defaultValue={0} label="Filter" aria-label="Filter type demo" />
|
||||||
|
<SteppedKnob size={92} min={-24} max={24} steps={9} color="#4cc2ff" defaultValue={0} label="Semi" aria-label="Semitones demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Fader" desc="Absolute-position linear control, both orientations." tag="<Fader />">
|
||||||
|
<Fader length={130} defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" label="Main" aria-label="Fader demo" />
|
||||||
|
<Fader orientation="horizontal" length={150} defaultValue={35} step={1} color="#e44cff" label="X-Fade" aria-label="Crossfade demo" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="LED fader" desc="Segmented meter-style fader with color zones." tag="<LEDFader />">
|
||||||
|
<LEDFader
|
||||||
|
length={130}
|
||||||
|
defaultValue={70}
|
||||||
|
step={1}
|
||||||
|
zones={[
|
||||||
|
{ upTo: 0.6, color: '#3df2ad' },
|
||||||
|
{ upTo: 0.85, color: '#ffd23e' },
|
||||||
|
{ upTo: 1, color: '#ff4d6b' },
|
||||||
|
]}
|
||||||
|
label="Level"
|
||||||
|
aria-label="LED fader demo"
|
||||||
|
/>
|
||||||
|
<LEDFader
|
||||||
|
orientation="horizontal"
|
||||||
|
length={140}
|
||||||
|
defaultValue={40}
|
||||||
|
step={1}
|
||||||
|
color="#4cc2ff"
|
||||||
|
label="Pos"
|
||||||
|
aria-label="Horizontal LED fader demo"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Segment display" desc="The seven-segment engine, standalone." tag="<SegmentDisplay />">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' }}>
|
||||||
|
<SegmentDisplay value={128.5} digits={4} decimals={1} />
|
||||||
|
<SegmentDisplay value={-42} digits={4} color="#ff4d6b" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Log taper" desc="Audio-true frequency sweep: equal ratios per turn." tag="taper={logTaper}">
|
||||||
|
<FlatKnob
|
||||||
|
size={92}
|
||||||
|
min={20}
|
||||||
|
max={20000}
|
||||||
|
defaultValue={632}
|
||||||
|
taper={logTaper}
|
||||||
|
decimals={0}
|
||||||
|
color="#3df2ad"
|
||||||
|
format={v => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`)}
|
||||||
|
label="Freq"
|
||||||
|
aria-label="Frequency demo"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Compose your own" desc="Primitives + headless core = any design you want." tag="<Knob> + primitives">
|
||||||
|
<Knob size={96} defaultValue={42} aria-label="Composed demo">
|
||||||
|
<Ticks count={28} radius={47} length={5} width={1.5} color="rgba(255,255,255,0.12)" activeColor="#ffd23e" />
|
||||||
|
<Arc radius={36} thickness={2.5} color="#ffd23e" trackColor="rgba(255,255,255,0.08)" cap="butt" />
|
||||||
|
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
|
||||||
|
<KnobValue color="#fff" fontSize={15} />
|
||||||
|
</Knob>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Vertical-drag mode" desc="Prefer the up/down plugin feel? One prop." tag="interaction='vertical'">
|
||||||
|
<RubberKnob size={92} defaultValue={50} interaction="vertical" color="#3df2ad" label="Depth" showValue aria-label="Vertical drag demo" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
74
apps/docs/src/sections/Hero.tsx
Normal file
74
apps/docs/src/sections/Hero.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Fader,
|
||||||
|
FlatKnob,
|
||||||
|
LEDFader,
|
||||||
|
LEDKnob,
|
||||||
|
MetalKnob,
|
||||||
|
NeonKnob,
|
||||||
|
RubberKnob,
|
||||||
|
SteppedKnob,
|
||||||
|
VintageKnob,
|
||||||
|
} from 'dreamknob';
|
||||||
|
|
||||||
|
export const Hero: React.FC = () => {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<header className="hero">
|
||||||
|
<div className="container">
|
||||||
|
<h1>
|
||||||
|
Knobs that feel like <span className="grad">hardware.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="tagline">
|
||||||
|
<b>dreamknob</b> is a feature-full React + TypeScript knob & fader library.
|
||||||
|
Rotary, linear, 2D, 3D, digital and analog — with a headless core, any number
|
||||||
|
range, any decimal precision, and colors you fully control.
|
||||||
|
</p>
|
||||||
|
<div className="install">
|
||||||
|
<span>pnpm add dreamknob</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText('pnpm add dreamknob');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1400);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? '✓' : 'copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rack">
|
||||||
|
<div className="rack-screws">
|
||||||
|
<span /><span /><span /><span />
|
||||||
|
</div>
|
||||||
|
<div className="rack-row">
|
||||||
|
<MetalKnob size={96} label="Gain" defaultValue={64} color="#4cc2ff" aria-label="Gain" />
|
||||||
|
<VintageKnob size={96} label="Drive" defaultValue={7} min={0} max={10} step={0.1} aria-label="Drive" />
|
||||||
|
<RubberKnob size={96} label="Cutoff" defaultValue={72} color="#ff9640" aria-label="Cutoff" />
|
||||||
|
<LEDKnob size={96} label="Rate" defaultValue={120} min={40} max={240} step={1} color="#3df2ad" aria-label="Rate" />
|
||||||
|
<NeonKnob size={96} label="Space" defaultValue={35} color="#e44cff" aria-label="Space" />
|
||||||
|
<SteppedKnob size={96} label="Mode" positions={['LP', 'BP', 'HP', 'NT']} defaultValue={0} aria-label="Mode" />
|
||||||
|
<FlatKnob size={96} label="Mix" defaultValue={50} unit="%" step={1} arcFrom="center" aria-label="Mix" />
|
||||||
|
<Fader length={128} breadth={40} label="Out" defaultValue={-6} min={-60} max={12} step={0.5} unit=" dB" aria-label="Output level" />
|
||||||
|
<LEDFader
|
||||||
|
length={128}
|
||||||
|
label="Level"
|
||||||
|
defaultValue={70}
|
||||||
|
step={1}
|
||||||
|
zones={[
|
||||||
|
{ upTo: 0.6, color: '#3df2ad' },
|
||||||
|
{ upTo: 0.85, color: '#ffd23e' },
|
||||||
|
{ upTo: 1, color: '#ff4d6b' },
|
||||||
|
]}
|
||||||
|
aria-label="Level meter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rack-hint">
|
||||||
|
drag to rotate · scroll to nudge · <kbd>⇧</kbd> for fine control · double-click to
|
||||||
|
reset · arrow keys work too
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
275
apps/docs/src/sections/Playground.tsx
Normal file
275
apps/docs/src/sections/Playground.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Fader,
|
||||||
|
FlatKnob,
|
||||||
|
LEDFader,
|
||||||
|
LEDKnob,
|
||||||
|
MetalKnob,
|
||||||
|
NeonKnob,
|
||||||
|
RubberKnob,
|
||||||
|
SteppedKnob,
|
||||||
|
VintageKnob,
|
||||||
|
logTaper,
|
||||||
|
powTaper,
|
||||||
|
type InteractionMode,
|
||||||
|
} from 'dreamknob';
|
||||||
|
import { CodeBlock } from '../components/CodeBlock';
|
||||||
|
|
||||||
|
type SkinId =
|
||||||
|
| 'flat'
|
||||||
|
| 'metal'
|
||||||
|
| 'rubber'
|
||||||
|
| 'vintage'
|
||||||
|
| 'led'
|
||||||
|
| 'neon'
|
||||||
|
| 'stepped'
|
||||||
|
| 'fader'
|
||||||
|
| 'ledfader';
|
||||||
|
|
||||||
|
const SKINS: Record<SkinId, string> = {
|
||||||
|
flat: 'FlatKnob',
|
||||||
|
metal: 'MetalKnob',
|
||||||
|
rubber: 'RubberKnob',
|
||||||
|
vintage: 'VintageKnob',
|
||||||
|
led: 'LEDKnob',
|
||||||
|
neon: 'NeonKnob',
|
||||||
|
stepped: 'SteppedKnob',
|
||||||
|
fader: 'Fader',
|
||||||
|
ledfader: 'LEDFader',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
skin: SkinId;
|
||||||
|
size: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
interaction: InteractionMode;
|
||||||
|
angleRange: number;
|
||||||
|
taper: 'linear' | 'log' | 'pow2';
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
showValue: boolean;
|
||||||
|
arcFrom: 'min' | 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: Config = {
|
||||||
|
skin: 'flat',
|
||||||
|
size: 160,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 0.5,
|
||||||
|
interaction: 'rotary',
|
||||||
|
angleRange: 270,
|
||||||
|
taper: 'linear',
|
||||||
|
color: '#4cc2ff',
|
||||||
|
label: 'Level',
|
||||||
|
unit: '',
|
||||||
|
showValue: true,
|
||||||
|
arcFrom: 'min',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCode = (c: Config, controlled: boolean): string => {
|
||||||
|
const props: string[] = [];
|
||||||
|
if (c.skin === 'fader' || c.skin === 'ledfader') props.push(`length={${c.size * 1.4}}`);
|
||||||
|
else props.push(`size={${c.size}}`);
|
||||||
|
if (controlled) props.push('value={value}', 'onChange={setValue}');
|
||||||
|
else props.push(`defaultValue={${(c.min + c.max) / 2}}`);
|
||||||
|
if (c.min !== 0) props.push(`min={${c.min}}`);
|
||||||
|
if (c.max !== 100) props.push(`max={${c.max}}`);
|
||||||
|
if (c.step > 0) props.push(`step={${c.step}}`);
|
||||||
|
if (c.taper === 'log') props.push('taper={logTaper}');
|
||||||
|
if (c.taper === 'pow2') props.push('taper={powTaper(2)}');
|
||||||
|
if (c.interaction !== 'rotary') props.push(`interaction="${c.interaction}"`);
|
||||||
|
if (c.angleRange !== 270) props.push(`angleRange={${c.angleRange}}`);
|
||||||
|
props.push(`color="${c.color}"`);
|
||||||
|
if (c.label) props.push(`label="${c.label}"`);
|
||||||
|
if (c.unit) props.push(`unit="${c.unit}"`);
|
||||||
|
if (c.skin === 'flat' && c.arcFrom !== 'min') props.push(`arcFrom="center"`);
|
||||||
|
if (c.skin === 'stepped') props.push(`steps={5}`);
|
||||||
|
const name = SKINS[c.skin];
|
||||||
|
const body = props.map(p => ` ${p}`).join('\n');
|
||||||
|
const imports = [name, c.taper === 'log' ? 'logTaper' : '', c.taper === 'pow2' ? 'powTaper' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return `import { ${imports} } from 'dreamknob'\n\n<${name}\n${body}\n/>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (v: string, fallback: number) => {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Playground: React.FC = () => {
|
||||||
|
const [cfg, setCfg] = React.useState<Config>(DEFAULTS);
|
||||||
|
const [value, setValue] = React.useState(50);
|
||||||
|
const [endValue, setEndValue] = React.useState<number | null>(null);
|
||||||
|
const set = <K extends keyof Config>(key: K, v: Config[K]) => setCfg(p => ({ ...p, [key]: v }));
|
||||||
|
|
||||||
|
const taper = cfg.taper === 'log' ? logTaper : cfg.taper === 'pow2' ? powTaper(2) : undefined;
|
||||||
|
const logInvalid = cfg.taper === 'log' && cfg.min <= 0;
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
value,
|
||||||
|
onChange: setValue,
|
||||||
|
onChangeEnd: setEndValue,
|
||||||
|
min: cfg.min,
|
||||||
|
max: cfg.max,
|
||||||
|
step: cfg.step > 0 ? cfg.step : undefined,
|
||||||
|
taper: logInvalid ? undefined : taper,
|
||||||
|
interaction: cfg.interaction,
|
||||||
|
angleRange: cfg.angleRange,
|
||||||
|
color: cfg.color,
|
||||||
|
label: cfg.label || undefined,
|
||||||
|
unit: cfg.unit || undefined,
|
||||||
|
showValue: cfg.showValue,
|
||||||
|
'aria-label': cfg.label || 'playground knob',
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = (() => {
|
||||||
|
switch (cfg.skin) {
|
||||||
|
case 'flat':
|
||||||
|
return <FlatKnob size={cfg.size} arcFrom={cfg.arcFrom} {...common} />;
|
||||||
|
case 'metal':
|
||||||
|
return <MetalKnob size={cfg.size} {...common} />;
|
||||||
|
case 'rubber':
|
||||||
|
return <RubberKnob size={cfg.size} {...common} />;
|
||||||
|
case 'vintage':
|
||||||
|
return <VintageKnob size={cfg.size} {...common} />;
|
||||||
|
case 'led':
|
||||||
|
return <LEDKnob size={cfg.size} {...common} />;
|
||||||
|
case 'neon':
|
||||||
|
return <NeonKnob size={cfg.size} {...common} />;
|
||||||
|
case 'stepped':
|
||||||
|
return <SteppedKnob size={cfg.size} steps={5} {...common} />;
|
||||||
|
case 'fader':
|
||||||
|
return <Fader length={cfg.size * 1.4} breadth={48} {...common} />;
|
||||||
|
case 'ledfader':
|
||||||
|
return <LEDFader length={cfg.size * 1.4} breadth={40} {...common} />;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block" id="playground">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-kicker">Playground</div>
|
||||||
|
<h2 className="section-title">Turn it up. Then copy the code.</h2>
|
||||||
|
<p className="section-sub">
|
||||||
|
Every prop is live. The generated JSX below always matches what you're touching.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="playground">
|
||||||
|
<div className="controls">
|
||||||
|
<div className="control">
|
||||||
|
<label>Component</label>
|
||||||
|
<select value={cfg.skin} onChange={e => set('skin', e.target.value as SkinId)}>
|
||||||
|
{Object.entries(SKINS).map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Size · {cfg.size}px</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={60}
|
||||||
|
max={260}
|
||||||
|
value={cfg.size}
|
||||||
|
onChange={e => set('size', +e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<div className="control">
|
||||||
|
<label>Min</label>
|
||||||
|
<input type="number" value={cfg.min} onChange={e => set('min', num(e.target.value, 0))} />
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Max</label>
|
||||||
|
<input type="number" value={cfg.max} onChange={e => set('max', num(e.target.value, 100))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<div className="control">
|
||||||
|
<label>Step</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min={0}
|
||||||
|
value={cfg.step}
|
||||||
|
onChange={e => set('step', num(e.target.value, 0))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Sweep °</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={45}
|
||||||
|
max={360}
|
||||||
|
value={cfg.angleRange}
|
||||||
|
onChange={e => set('angleRange', num(e.target.value, 270))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Drag feel</label>
|
||||||
|
<select
|
||||||
|
value={cfg.interaction}
|
||||||
|
onChange={e => set('interaction', e.target.value as InteractionMode)}
|
||||||
|
>
|
||||||
|
<option value="rotary">rotary (grab & turn)</option>
|
||||||
|
<option value="vertical">vertical drag</option>
|
||||||
|
<option value="horizontal">horizontal drag</option>
|
||||||
|
<option value="both">vertical + horizontal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Taper</label>
|
||||||
|
<select value={cfg.taper} onChange={e => set('taper', e.target.value as Config['taper'])}>
|
||||||
|
<option value="linear">linear</option>
|
||||||
|
<option value="log">logarithmic (min > 0)</option>
|
||||||
|
<option value="pow2">power (x²)</option>
|
||||||
|
</select>
|
||||||
|
{logInvalid && (
|
||||||
|
<span style={{ fontSize: 11, color: '#ff9640' }}>log taper needs min > 0 — using linear</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Accent color</label>
|
||||||
|
<input type="color" value={cfg.color} onChange={e => set('color', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<div className="control">
|
||||||
|
<label>Label</label>
|
||||||
|
<input type="text" value={cfg.label} onChange={e => set('label', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label>Unit</label>
|
||||||
|
<input type="text" value={cfg.unit} placeholder="dB, %, Hz…" onChange={e => set('unit', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cfg.showValue}
|
||||||
|
onChange={e => set('showValue', e.target.checked)}
|
||||||
|
/>
|
||||||
|
show value readout
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage">
|
||||||
|
<div className="preview">{preview}</div>
|
||||||
|
<div className="readout">
|
||||||
|
<span>onChange → <b>{value}</b></span>
|
||||||
|
<span>onChangeEnd → <b>{endValue ?? '—'}</b></span>
|
||||||
|
</div>
|
||||||
|
<CodeBlock code={buildCode(cfg, true)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
521
apps/docs/src/styles.css
Normal file
521
apps/docs/src/styles.css
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0a0b0e;
|
||||||
|
--bg-raised: #101218;
|
||||||
|
--panel: #13151c;
|
||||||
|
--panel-2: #171a22;
|
||||||
|
--line: rgba(255, 255, 255, 0.07);
|
||||||
|
--line-strong: rgba(255, 255, 255, 0.13);
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--text-dim: #9aa0ad;
|
||||||
|
--text-faint: #5d6370;
|
||||||
|
--accent: #4cc2ff;
|
||||||
|
--accent-2: #3df2ad;
|
||||||
|
--accent-3: #ff9640;
|
||||||
|
--accent-4: #e44cff;
|
||||||
|
--font-ui: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-padding-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(76, 194, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- nav ---- */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
background: rgba(10, 11, 14, 0.75);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
.nav-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-logo svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- hero ---- */
|
||||||
|
.hero {
|
||||||
|
padding: 84px 0 40px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -40% -20% auto;
|
||||||
|
height: 130%;
|
||||||
|
background:
|
||||||
|
radial-gradient(600px 300px at 30% 20%, rgba(76, 194, 255, 0.12), transparent 70%),
|
||||||
|
radial-gradient(600px 300px at 70% 10%, rgba(228, 76, 255, 0.08), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(40px, 6vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 1.05;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero h1 .grad {
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
.hero p.tagline {
|
||||||
|
margin: 18px auto 0;
|
||||||
|
max-width: 640px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 17px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero .install {
|
||||||
|
margin: 26px auto 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero .install button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.hero .install button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- rack panel (hero demo) ---- */
|
||||||
|
.rack {
|
||||||
|
margin: 48px auto 0;
|
||||||
|
max-width: 980px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 40%),
|
||||||
|
linear-gradient(180deg, #191b22, #101116);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 80px rgba(0, 0, 0, 0.55),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 30px 34px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.rack-screws span {
|
||||||
|
position: absolute;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #6a6d78, #23252c 75%);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.rack-screws span:nth-child(1) { top: 12px; left: 12px; }
|
||||||
|
.rack-screws span:nth-child(2) { top: 12px; right: 12px; }
|
||||||
|
.rack-screws span:nth-child(3) { bottom: 12px; left: 12px; }
|
||||||
|
.rack-screws span:nth-child(4) { bottom: 12px; right: 12px; }
|
||||||
|
.rack-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rack-hint {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.rack-hint kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- sections ---- */
|
||||||
|
section.block {
|
||||||
|
padding: 72px 0 12px;
|
||||||
|
}
|
||||||
|
.section-kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
h2.section-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin: 6px 0 8px;
|
||||||
|
}
|
||||||
|
.section-sub {
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 640px;
|
||||||
|
margin-bottom: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- gallery ---- */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 22px;
|
||||||
|
transition: border-color 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.card .card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 2px 0 18px;
|
||||||
|
}
|
||||||
|
.card .demo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 22px;
|
||||||
|
min-height: 130px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-radius: 10px;
|
||||||
|
background:
|
||||||
|
radial-gradient(400px 160px at 50% 0%, rgba(255, 255, 255, 0.03), transparent),
|
||||||
|
var(--bg-raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 18px 10px;
|
||||||
|
}
|
||||||
|
.card .tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- console demo ---- */
|
||||||
|
.console {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: linear-gradient(180deg, #16181f, #0e0f14);
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 26px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.channel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
min-width: 128px;
|
||||||
|
}
|
||||||
|
.channel .ch-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- playground ---- */
|
||||||
|
.playground {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.playground {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
position: sticky;
|
||||||
|
top: 76px;
|
||||||
|
}
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.control > label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.control input[type='text'],
|
||||||
|
.control input[type='number'],
|
||||||
|
.control select {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.control input:focus,
|
||||||
|
.control select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.control-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.control input[type='range'] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.control input[type='color'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
padding: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.control .check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
background:
|
||||||
|
radial-gradient(500px 240px at 50% 10%, rgba(76, 194, 255, 0.05), transparent),
|
||||||
|
var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
.readout {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.readout b {
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- code ---- */
|
||||||
|
.codeblock {
|
||||||
|
position: relative;
|
||||||
|
background: #0d0e12;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.codeblock pre {
|
||||||
|
padding: 18px 20px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #c8cede;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.codeblock .copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.codeblock .copy:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tok-key { color: #7dd3fc; }
|
||||||
|
.tok-str { color: #86efac; }
|
||||||
|
.tok-num { color: #fca5a5; }
|
||||||
|
.tok-tag { color: #c4b5fd; }
|
||||||
|
.tok-com { color: #565d6b; font-style: italic; }
|
||||||
|
|
||||||
|
/* ---- docs / api ---- */
|
||||||
|
.api-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13.5px;
|
||||||
|
margin: 14px 0 30px;
|
||||||
|
}
|
||||||
|
.api-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
.api-table td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.api-table td:first-child {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.api-table td:nth-child(2) {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-3);
|
||||||
|
}
|
||||||
|
.api-table code,
|
||||||
|
.prose code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
h3.api-h {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 38px 0 4px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.api-note {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 80px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 30px 0 40px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
21
apps/docs/tsconfig.json
Normal file
21
apps/docs/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"dreamknob": ["../../packages/dreamknob/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
13
apps/docs/vite.config.ts
Normal file
13
apps/docs/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// Use library sources directly for instant HMR while developing.
|
||||||
|
dreamknob: fileURLToPath(new URL('../../packages/dreamknob/src/index.ts', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "dreamknob-monorepo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm --filter dreamknob build && pnpm --filter docs build",
|
||||||
|
"dev": "pnpm --filter docs dev",
|
||||||
|
"test": "pnpm --filter dreamknob test",
|
||||||
|
"typecheck": "pnpm -r typecheck"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.11.0"
|
||||||
|
}
|
||||||
110
packages/dreamknob/README.md
Normal file
110
packages/dreamknob/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# dreamknob
|
||||||
|
|
||||||
|
Studio-grade React knobs & faders. Rotary, linear, 2D, 3D, digital and analog — with a
|
||||||
|
headless core, any number range, any decimal precision, and colors you fully control.
|
||||||
|
Zero runtime dependencies.
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install dreamknob
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FlatKnob } from 'dreamknob'
|
||||||
|
|
||||||
|
function Volume() {
|
||||||
|
const [value, setValue] = useState(64)
|
||||||
|
return (
|
||||||
|
<FlatKnob
|
||||||
|
size={90}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
label="Volume"
|
||||||
|
unit="%"
|
||||||
|
aria-label="Volume"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uncontrolled works too: pass `defaultValue` instead of `value`/`onChange`
|
||||||
|
(it also becomes the double-click reset target).
|
||||||
|
|
||||||
|
## Interaction model
|
||||||
|
|
||||||
|
Every component shares one core:
|
||||||
|
|
||||||
|
| Gesture | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| Drag | `rotary` (grab & turn, follows the pointer angle), `vertical`, `horizontal`, `both`, or absolute `track-*` (faders) |
|
||||||
|
| Scroll wheel | one `step` per notch, `Shift` = fine |
|
||||||
|
| `↑ → / ↓ ←` | ± one step (`Shift` = 10×) |
|
||||||
|
| `PageUp/PageDown` | ± 10 % of range |
|
||||||
|
| `Home/End` | min / max |
|
||||||
|
| Double-click | reset to `defaultValue` |
|
||||||
|
| Touch / pen | pointer capture, `touch-action: none` |
|
||||||
|
|
||||||
|
All knobs render `role="slider"` with `aria-valuemin/max/now/text`.
|
||||||
|
|
||||||
|
## Value handling
|
||||||
|
|
||||||
|
- `min` / `max` — any numbers (negative, fractional, huge).
|
||||||
|
- `step` — decimal snap increments (`0.5`, `0.001`, `1e-7`…) with float-drift-safe
|
||||||
|
rounding; display precision inferred automatically, overridable via `decimals`.
|
||||||
|
- `values={[0.25, 0.5, 1, 2, 4]}` — discrete allowed values.
|
||||||
|
- `steps={5}` — evenly spaced detents (selector knobs).
|
||||||
|
- `taper` — `linearTaper` (default), `logTaper` (frequencies), `powTaper(n)` (gain),
|
||||||
|
or your own `{ toNormalized, fromNormalized }`.
|
||||||
|
- `onChange` / `onChangeStart` / `onChangeEnd` — gesture-aware callbacks.
|
||||||
|
|
||||||
|
## Prebuilt skins
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
FlatKnob, // clean 2D arc knob (arcFrom="center" for pan knobs)
|
||||||
|
MetalKnob, // 3D brushed aluminum, knurled rim (tone="silver" | "dark")
|
||||||
|
RubberKnob, // 3D soft-touch synth knob with glow halo
|
||||||
|
VintageKnob, // chicken-head bakelite over a printed scale
|
||||||
|
LEDKnob, // segmented LED ring + true seven-segment readout
|
||||||
|
NeonKnob, // glowing arc for dark UIs
|
||||||
|
SteppedKnob, // detented selector (positions={['LP','BP','HP']})
|
||||||
|
Fader, // linear channel fader, vertical or horizontal
|
||||||
|
LEDFader, // segmented LED meter-fader with color zones
|
||||||
|
SegmentDisplay, // standalone seven-segment numeric display
|
||||||
|
} from 'dreamknob'
|
||||||
|
```
|
||||||
|
|
||||||
|
Every skin takes the core props plus `size`, `label`, `showValue`, `unit`, `format`,
|
||||||
|
and per-part color props (`color`, `trackColor`, `faceColor`, `bodyColor`, …).
|
||||||
|
|
||||||
|
## Compose your own
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Knob, Arc, Pointer, Ticks, KnobValue } from 'dreamknob'
|
||||||
|
|
||||||
|
<Knob size={96} min={-24} max={24} step={0.5} defaultValue={0} aria-label="Trim">
|
||||||
|
<Ticks count={25} activeColor="#ffd23e" />
|
||||||
|
<Arc radius={36} thickness={3} color="#ffd23e" from="center" />
|
||||||
|
<Pointer type="triangle" radius={33} length={9} width={8} color="#ffd23e" />
|
||||||
|
<KnobValue unit=" dB" />
|
||||||
|
</Knob>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<Knob>` also accepts a render function: `{(ctx) => <YourSVG angle={ctx.angle} />}`.
|
||||||
|
|
||||||
|
## Fully headless
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useKnob } from 'dreamknob'
|
||||||
|
|
||||||
|
const { value, normalized, angle, isDragging, bind, setValue } = useKnob({
|
||||||
|
min: 0, max: 11, step: 0.1, defaultValue: 11, 'aria-label': 'Volume',
|
||||||
|
})
|
||||||
|
return <div {...bind}>…your own rendering…</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
MIT
|
||||||
51
packages/dreamknob/package.json
Normal file
51
packages/dreamknob/package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"name": "dreamknob",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A feature-full, studio-grade React knob & fader library. Rotary, linear, 2D, 3D, digital and analog styles with a headless core.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
|
||||||
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"react",
|
||||||
|
"knob",
|
||||||
|
"fader",
|
||||||
|
"slider",
|
||||||
|
"rotary",
|
||||||
|
"audio",
|
||||||
|
"synth",
|
||||||
|
"dial",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tsup": "^8.1.0",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vitest": "^2.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
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";
|
||||||
19
packages/dreamknob/tsconfig.json
Normal file
19
packages/dreamknob/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1953
pnpm-lock.yaml
generated
Normal file
1953
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
packages:
|
||||||
|
- packages/*
|
||||||
|
- apps/*
|
||||||
Loading…
Add table
Add a link
Reference in a new issue