KnobsMOL
The rotary controls of an audio plugin - a synthesizer's cutoff, a compressor's attack, a mixer's gain - over a native range input that keeps the value, the keys and the accessible slider. A knob turns 270 degrees on any scale: linear, exponential (every decade of a frequency turns as far), logarithmic, a power curve, decibels on an audio taper, or a scale the page adds; the value snaps to the input's step - stepped knobs mark their steps - and its text follows a format - a metric prefix, a percentage, milliseconds and seconds, a sign, or your own. Around the value ring - the innermost, filled from the knob's origin to its value - each automation or modulation source has a ring of its own: its range, and while it runs, how far it moves the value now; the value ring then marks the effective value. Drag left or right to turn it: near the knob it turns fast, and the farther the pointer is from it, the finer the same move sets the value - the radius it turns at full speed shows while you drag. Double click the value to type it in. The knob never moves: a longer value text spills out on both sides.
On this page (13)
§Knob
A filter cutoff from 20 Hz to 20 kHz on an exponential scale: every decade turns as far, so 200 Hz, 2 kHz and 20 kHz sit at a third, two thirds and the end of the turn. Drag left or right to turn it - near the knob it follows the pointer fast; move away from it (the dashed circle is the radius it turns at full speed) and the same move turns it finer. Double click the knob to reset it to 1 kHz, double click the value to type one in (Enter sets it, Escape keeps the old one). Its two rings are the sources that modulate it: LFO 1 swings it both ways by 15 % of the turn, Envelope 2 opens it by 30 %.
§Precision
A fine tune in cents from its centre (data-origin='0'), with a small precision radius (data-radius='48') and a long travel (data-travel='400'): within 48 px of the knob a 400 px move turns it from end to end; at 96 px half as far, at 192 px a quarter - set it to a tenth of a cent by moving away first. Shift slows any drag ten times; Escape puts it back where the drag started.
§Scales
Six scales over the same turn: a mix from 0 to 1 written as a percentage (linear), a frequency (exponential, written with a metric prefix), a feedback that turns fast first and fine at the end (logarithmic), an attack time on a power curve (data-exponent='3': fine for short times, written in ms and s), a gain in decibels on an audio taper, and semitones on whole steps (step='1') that fill from 0. The value always snaps to the input's step after the scale.
§Steps
Stepped knobs move step by step - a drag snaps as it goes, an arrow key moves one step, a typed value settles on the nearest. data-ticks marks every step between the value ring and the face: octaves and semitones from their centre, voices, a waveform switch (its four steps written as names by a format the page adds). data-ticks='5' marks the quarters of a mix in 5 % steps; on an exponential delay from 1 ms to 1 s in milliseconds, data-ticks='4' marks the decades.
§Automation rings
Three sources around one cutoff: LFO 1 swings it both ways (bipolar), Envelope 1 opens it, Macro 1 closes it (a negative amount). Each ring shows its range around the value; run the sources and each ring shows how far its source moves the value now, while the value ring marks the effective value - the knob's value moved by all of them (df$.shadcn.knob.modulate returns it). Turn the knob while they run: the rings move with it.
§Sizes, colours and states
data-size='sm' and 'lg' around the default size, data-color for the value arc (a chart colour, destructive), data-variant='flat' without the face, and an input authored disabled - the knob starts in its disabled state: no drag, no keys, and a form leaves it out.
§Custom scales and formats
A page adds scales and formats by name. The crossfade turns on a smoothstep curve (data-scale='smooth'): fine at both ends, fast through the middle. The pan writes L20, C and R20 (data-format='pan'). Knobs already on a scale or a format draw again when it is added.
§A synth panel
An oscillator in a Card: octave, semitones and voices on whole steps, a fine tune and a pan that fill from their centre, a level in decibels. LFO 2 modulates the detune and the pan - one source, a ring on each knob it reaches - and Envelope 1 the level: run them and the rings move together. The row keeps a ring slot free on every knob (--knob-reserve: 1), so the knobs with a ring and without one line up.
§In a form
The input is the form control: it submits its value under its name, in the knob's units, and a reset puts the authored value back. The gain snaps to half decibels (step='0.5'), the mix to hundredths.
§States
Named states via the shared State API, bound on each .knob:
default- at rest: the pointer and the arc from the origin show the value, the readout writes itadjusting- being turned: a drag enters it and returns todefaultwhen it ends; the precision radius and the circle through the pointer show around the dialdisabled- the input is disabled: no drag, no keys, and a form leaves it out
Every state takes { value }, merged into the current config - the value in the knob's units, settled on its range and step; getState() reports the live value, also one a drag or a key set. A drag sends the input's input events and knob-change as it goes and change once when it ends.
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/knob-{state}.png.
Machine contract - verified against knob.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | number | — | — | The value in the knob's units, settled on its range and step; observed from getState().config.value. |
adjusting | boolean | true, false | false | Being turned (setState('adjusting')): the precision radius shows around the dial. |
disabled | boolean | true, false | false | The input is disabled (setState('disabled')): no drag, no keys. |
§API
Generated from knob.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type KnobState = 'default' | 'adjusting' | 'disabled' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||
|---|---|---|---|---|---|---|---|
default | At rest: the pointer and the arc from the origin show the value, the readout writes it.
| ||||||
adjusting | Being turned: the precision radius and the pointer's reach show around the dial - a drag enters it and returns to default when it ends.
| ||||||
disabled | Disabled: the input is disabled - no drag, no keys, and a form leaves it out.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends KnobState>(name: S, config?: KnobStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: KnobState; config: KnobStateConfigs[KnobState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.knobApi.setState<S extends KnobState>(el: HTMLElement, name: S, config?: KnobStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.knobApi.getState(el: HTMLElement): { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.knobApi.render(state: { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.knobApi.store(el: HTMLElement): Store<{ name: KnobState; config: KnobStateConfigs[KnobState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.knobApi.commit<S extends KnobState>(el: HTMLElement, name: S, config?: KnobStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.knobStates: KnobState[] | The declared states, 'default' first: default, adjusting, disabled. |
df$.shadcn.knob
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
value(target: string | HTMLElement, value?: number): number | The knob's value, or set it (setState with { value }: settled on the range and step, the change told).
Returns | |||||||||
position(target: string | HTMLElement): number | Where the value stands on the knob's turn.
Returns | |||||||||
reset(target: string | HTMLElement): number | Back to the default value (data-default, else the input's authored value) - what a double click does.
Returns | |||||||||
rings(target: string | HTMLElement, list?: KnobRing[]): KnobRing[] | The rings around the knob, or replace them - the live values of the sources that stay are kept.
Returns | |||||||||
modulate(target: string | HTMLElement, values: Record<string, number | null> | null): number | The live outputs of the sources - each ring shows how far its source moves the knob now, the value ring the effective value. A unipolar source runs from 0 to 1, a bipolar one from -1 to 1.
Returns | |||||||||
scale(name: string, scale: KnobScale): void | Add a scale knobs name in data-scale - the knobs on it are drawn again.
| |||||||||
format(name: string, format: (value: number, context: KnobFormatContext) => string): void | Add a format knobs name in data-format - the knobs in it write their values again.
|
Events
| Event | Description | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
knob-change | Fires when the value changes - while a drag goes on (committed: false) and when a change is done.
|
Types
| Type | Description | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
KnobChangeDetail | What knob-change carries.
| |||||||||||||||||||||
KnobConfig | The config every state takes.
| |||||||||||||||||||||
KnobFormatContext | What a format gets besides the value.
| |||||||||||||||||||||
KnobRange | What a scale maps between.
| |||||||||||||||||||||
KnobRing | A ring around the knob: an automation or modulation source that moves its value.
| |||||||||||||||||||||
KnobScale | A scale: how a position on the knob's turn (0 to 1) maps to a value, and back.
|
§CSS view file
The dial: every arc a conic-gradient masked down to a ring, painted from the positions the runtime writes (the value, the origin, each ring's range and live value, the effective value); the face, the pointer, the precision circles, sizes and colours.
/* -- Knob component ---------------------------------------------- *//* A rotary control over a native range input: the dial (drawn by the *//* runtime) turns 270 degrees from 7:30 to 4:30. Every arc is a *//* conic-gradient masked down to a ring - the value ring innermost, *//* a ring per modulation source around it - and the positions come in *//* as custom properties (0 to 1) the runtime writes on the dial. */@layer components { .knob { --knob-size: 3rem; --knob-thickness: 0.25rem; --knob-ring-thickness: 0.1875rem; --knob-ring-gap: 0.125rem; --knob-color: var(--primary); --knob-track-color: var(--muted); /* the room between the value ring and the face - the marks of the steps sit in it */ --knob-face-gap: calc(var(--knob-size) * 0.07); position: relative; display: inline-grid; justify-items: center; align-content: start; gap: 0.25rem; inline-size: max-content; color: var(--foreground); font-size: 0.75rem; line-height: 1.2; text-align: center; -webkit-user-select: none; user-select: none; &[data-size="sm"] { --knob-size: 2.25rem; --knob-thickness: 0.1875rem; --knob-ring-thickness: 0.125rem; --knob-ring-gap: 0.09375rem; font-size: 0.6875rem; } &[data-size="lg"] { --knob-size: 4.5rem; --knob-thickness: 0.3125rem; --knob-ring-thickness: 0.25rem; --knob-ring-gap: 0.125rem; font-size: 0.8125rem; } &[data-color="chart-1"] { --knob-color: var(--chart-1); } &[data-color="chart-2"] { --knob-color: var(--chart-2); } &[data-color="chart-3"] { --knob-color: var(--chart-3); } &[data-color="chart-4"] { --knob-color: var(--chart-4); } &[data-color="chart-5"] { --knob-color: var(--chart-5); } &[data-color="destructive"] { --knob-color: var(--destructive); } /* the one being turned: above its neighbours, so its precision circles are not cut */ &[data-state-name="adjusting"] { z-index: 2; } &:has(.knob-input:disabled) { opacity: 0.5; .knob-dial { cursor: not-allowed; } } &[data-ticks] { --knob-face-gap: calc(var(--knob-size) * 0.15); } } /* the input is the form control, the keyboard target and the slider assistive technology reads - drawn over by the dial once the runtime runs; without scripting it is the plain range */ @media (scripting: enabled) { .knob-input { position: absolute; inline-size: 1px; block-size: 1px; margin: -1px; padding: 0; overflow: hidden; border: 0; white-space: nowrap; clip-path: inset(50%); } } .knob-label { color: var(--muted-foreground); font-weight: 500; text-wrap: balance; } /* the value never sizes the knob: contained, a longer text ("-60.0 dB" after "0.0 dB") spills out on both sides and the dial stays where it is; a double click types the value in */ .knob-value { display: flex; justify-content: center; justify-self: stretch; contain: inline-size; font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; cursor: text; } .knob:not(:has(.knob-input:disabled)) .knob-value:hover { text-decoration: underline dotted; text-underline-offset: 3px; } /* the editor: a Number Input (xs) laid over the readout - the knob keeps its size while it is open */ .knob-editor { position: absolute; left: 50%; z-index: 3; inline-size: 6.5rem; translate: -50% -50%; } /* the dial: the knob's face, the value ring and the rings of the sources around it; geometry is physical - a knob turns clockwise in any writing direction */ .knob-dial { --knob-step: calc(var(--knob-ring-thickness) + var(--knob-ring-gap)); /* the ring slots: its rings, or as many as --knob-reserve keeps free - knobs in a row line up */ --knob-slots: max(var(--knob-rings, 0), var(--knob-reserve, 0)); --knob-dial-size: calc(var(--knob-size) + var(--knob-slots) * 2 * var(--knob-step)); --knob-radius: var(--knob-dial-size); position: relative; display: block; inline-size: var(--knob-dial-size); aspect-ratio: 1; border-radius: 50%; direction: ltr; cursor: ew-resize; touch-action: none; /* the precision of a drag: the radius (dashed) within which it turns at full speed, and the circle through the pointer - the farther out, the finer */ &::before, &::after { position: absolute; top: 50%; left: 50%; aspect-ratio: 1; border-radius: 50%; content: ""; opacity: 0; pointer-events: none; translate: -50% -50%; transition: opacity 120ms ease; } &::before { inline-size: calc(var(--knob-reach, 0px) * 2); border: 1px solid color-mix(in oklab, var(--knob-color) 45%, transparent); } &::after { inline-size: calc(var(--knob-radius) * 2); border: 1px dashed color-mix(in oklab, var(--knob-color) 70%, transparent); } } .knob[data-state-name="adjusting"] .knob-dial { &::before, &::after { opacity: 1; } } .knob:has(.knob-input:focus-visible) .knob-dial { outline: 2px solid var(--ring); outline-offset: 2px; } /* the value ring: the track, the arc from the origin to the value, and - while sources move it - a mark at the effective value */ .knob-track, .knob-ring { position: absolute; border-radius: 50%; -webkit-mask: radial-gradient( farthest-side, transparent calc(100% - var(--knob-w) - 0.5px), #000 calc(100% - var(--knob-w)) ); mask: radial-gradient( farthest-side, transparent calc(100% - var(--knob-w) - 0.5px), #000 calc(100% - var(--knob-w)) ); } .knob-track { --knob-w: var(--knob-thickness); inset: calc(var(--knob-slots) * var(--knob-step)); background: conic-gradient( from -135deg, transparent 0 calc(min(var(--knob-p, 0), var(--knob-o, 0)) * 270deg), var(--knob-color) 0 calc(max(var(--knob-p, 0), var(--knob-o, 0)) * 270deg), transparent 0 ), conic-gradient(from -135deg, var(--knob-track-color) 0 270deg, transparent 0); &::after { position: absolute; inset: 0; display: none; border-radius: 50%; background: conic-gradient( from -135deg, transparent 0 calc(var(--knob-e, 0) * 270deg - 3deg), var(--foreground) 0 calc(var(--knob-e, 0) * 270deg + 3deg), transparent 0 ); content: ""; } } .knob-dial[data-modulated] .knob-track::after { display: block; } /* a source's ring: its range around the value, and - while it runs - the arc to where it moves the value now */ .knob-ring { /* the runtime writes each ring's own colour */ --knob-ring-color: var(--chart-1); --knob-w: var(--knob-ring-thickness); inset: calc((var(--knob-slots) - var(--knob-ring, 1)) * var(--knob-step)); background: conic-gradient( from -135deg, transparent 0 calc(var(--knob-from, 0) * 270deg), color-mix(in oklab, var(--knob-ring-color) 80%, transparent) 0 calc(var(--knob-to, 0) * 270deg), transparent 0 ), conic-gradient(from -135deg, color-mix(in oklab, var(--muted) 70%, transparent) 0 270deg, transparent 0); &[data-live] { background: conic-gradient( from -135deg, transparent 0 calc(var(--knob-at-lo, 0) * 270deg), var(--knob-ring-color) 0 calc(var(--knob-at-hi, 0) * 270deg), transparent 0 ), conic-gradient( from -135deg, transparent 0 calc(var(--knob-from, 0) * 270deg), color-mix(in oklab, var(--knob-ring-color) 35%, transparent) 0 calc(var(--knob-to, 0) * 270deg), transparent 0 ), conic-gradient(from -135deg, color-mix(in oklab, var(--muted) 70%, transparent) 0 270deg, transparent 0); } } /* the knob's face and its pointer, turned to the value */ .knob-face { position: absolute; inset: calc(var(--knob-slots) * var(--knob-step) + var(--knob-thickness) + var(--knob-face-gap)); border: 1px solid var(--border); border-radius: 50%; background: linear-gradient(to bottom, color-mix(in oklab, var(--secondary) 80%, var(--card)), var(--card)); box-shadow: var(--shadow-sm); } .knob-pointer { position: absolute; inset: 0; rotate: calc(var(--knob-p, 0) * 270deg - 135deg); &::before { position: absolute; top: 10%; left: calc(50% - 1px); inline-size: 2px; block-size: 32%; border-radius: 1px; background-color: var(--foreground); content: ""; } } /* the marks of the steps (data-ticks), between the value ring and the face */ .knob-tick { position: absolute; inset: calc(var(--knob-slots) * var(--knob-step)); pointer-events: none; rotate: calc(var(--knob-t, 0) * 270deg - 135deg); &::before { position: absolute; top: calc(var(--knob-thickness) + 1px); left: calc(50% - 0.5px); inline-size: 1px; block-size: calc(var(--knob-face-gap) - 2px); background-color: var(--muted-foreground); content: ""; } } /* flat: no face - the rings and the pointer only */ .knob[data-variant="flat"] .knob-face { border-color: transparent; background: none; box-shadow: none; } .knob-rings-text { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @media (prefers-reduced-motion: reduce) { .knob-dial::before, .knob-dial::after { transition: none; } } @media (prefers-contrast: more) { .knob { --knob-track-color: var(--muted-foreground); } .knob-face { border-color: var(--foreground); } } @media (forced-colors: active) { .knob-dial { --knob-color: Highlight; --knob-track-color: GrayText; forced-color-adjust: none; } .knob-face { border-color: CanvasText; background: Canvas; } .knob-pointer::before { background: CanvasText; } .knob-tick::before { background: GrayText; } }}§JavaScript view file
The scales and formats (and the registries a page adds to), the value settled on the range and step, the rings, the drag whose speed is the radius over the distance, the keys, the events and the State API.
// -- Knob ------------------------------------------------------// A rotary control over a native <input type="range"> - the knobs of an audio plugin: the value on any scale (linear,// exponential, logarithmic, power, decibel, or one the page registers), drawn as an arc from its origin and a pointer,// rings around it for the automation and modulation sources that move it (their range and their live value), and a// drag whose precision follows the distance from the knob: within its radius a horizontal move turns it fast, farther// away slower and finer. The input stays the form control, the keyboard target and the accessible slider; the runtime// draws the dial over it.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent, textLocale } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const knobStates = ['default', 'adjusting', 'disabled'];/** The config every state takes. */export interface KnobConfig { /** the value in the knob's units, between the input's min and max and on its step; getState() reports the live one */ value?: number;}// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state. */export interface KnobStateConfigs { /** At rest: the pointer and the arc from the origin show the value, the readout writes it. */ default: KnobConfig; /** Being turned: the precision radius and the pointer's reach show around the dial - a drag enters it and returns to default when it ends. */ adjusting: KnobConfig; /** Disabled: the input is disabled - no drag, no keys, and a form leaves it out. */ disabled: KnobConfig;}/** A ring around the knob: an automation or modulation source that moves its value. */export interface KnobRing { /** unique on the knob - modulate() names the source by it */ id: string; /** the source's name ("LFO 1") - the ring's title and its description for assistive technology */ label?: string; /** how far the source moves the knob, as a share of its turn: -1 to 1 (0.25 = a quarter of the turn up) */ amount: number; /** a theme colour (chart-1 ... chart-5, primary, destructive) or a CSS colour; default the chart colour of its number */ color?: string; /** the source swings both ways around the value (an LFO), not away from it (an envelope) */ bipolar?: boolean;}/** What a scale maps between. */export interface KnobRange { /** the value at the start of the turn - the input's min */ min: number; /** the value at the end of the turn - the input's max */ max: number; /** the curve of the power and decibel scales (data-exponent) */ exponent: number;}/** A scale: how a position on the knob's turn (0 to 1) maps to a value, and back. */export interface KnobScale { /** the value at a position of the turn */ value: (position: number, range: KnobRange) => number; /** the position of a value - the inverse of value() */ position: (value: number, range: KnobRange) => number;}/** What a format gets besides the value. */export interface KnobFormatContext { /** the unit (data-unit), "" without one */ unit: string; /** the fraction digits asked for (data-decimals), null for the format's own */ decimals: number | null; /** the locale numbers are written in */ locale: string; /** the value at the start of the turn */ min: number; /** the value at the end of the turn */ max: number; /** the input's step, null for any */ step: number | null;}/** What knob-change carries. */export interface KnobChangeDetail { /** the value */ value: number; /** its position on the turn, 0 to 1 */ position: number; /** the value as the readout writes it ("1.20 kHz") */ text: string; /** true when the change is done (a drag ended, a key, a reset, setState), false while a drag goes on */ committed: boolean;}/** a drag: the pointer, where it was, the position it moves (unrounded), the value it started from */type Drag = { id: number; x: number; p: number; start: number; cx: number; cy: number; radius: number };/** per-element runtime (state lives on the element) */type Runtime = { input: HTMLInputElement; dial: HTMLElement; value: number; p: number; /** the value of the last change event - change fires when a committed value differs from it */ committed: number; rings: KnobRing[]; live: Map<string, number>; drag: Drag | null; /** set while the runtime dispatches the input's events - its own input event is not a change from outside */ writing: boolean; /** the Number Input over the readout while the value is typed in */ editor: HTMLElement | null;};/** the slowest a far drag gets, as a share of the full speed */const MIN_SPEED = 0.02;/** the keys' steps on the turn: an arrow, with Shift, a page */const KEY_STEP = 0.01;const FINE_STEP = 0.001;const PAGE_STEP = 0.1;/** the most marks a dial draws - a finer step draws none */const MAX_TICKS = 49;/** the theme colours a ring names */const THEME_COLORS = /^(chart-[1-5]|primary|destructive|foreground|muted-foreground)$/;let rings = 0;const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));const num = (v: unknown, fallback: number): number => { const n = typeof v === 'number' ? v : v === null || v === undefined || v === '' ? NaN : Number(v); return Number.isFinite(n) ? n : fallback;};const esc = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);/** no float noise in a value: 12 significant digits */const tidy = (v: number): number => Number(v.toPrecision(12));const round4 = (v: number): string => String(Math.round(v * 10000) / 10000);const sameSign = (r: KnobRange): boolean => r.min !== 0 && r.max !== 0 && Math.sign(r.min) === Math.sign(r.max);const linear: KnobScale = { value: (p, r) => r.min + p * (r.max - r.min), position: (v, r) => (r.max === r.min ? 0 : (v - r.min) / (r.max - r.min)),};/** the scales by name - a page adds its own with df$.shadcn.knob.scale() */const SCALES: Record<string, KnobScale> = { linear, // equal ratios per share of the turn - frequency, time: from 20 Hz to 20 kHz every decade turns as far exponential: { value: (p, r) => (sameSign(r) ? r.min * Math.pow(r.max / r.min, p) : linear.value(p, r)), position: (v, r) => (sameSign(r) ? Math.log(v / r.min) / Math.log(r.max / r.min) : linear.position(v, r)), }, // the mirror of exponential: fast at the start of the turn, fine at its end logarithmic: { value: (p, r) => (sameSign(r) ? r.min + r.max - r.min * Math.pow(r.max / r.min, 1 - p) : linear.value(p, r)), position: (v, r) => sameSign(r) ? 1 - Math.log((r.min + r.max - v) / r.min) / Math.log(r.max / r.min) : linear.position(v, r), }, // a curve: exponent 2 (default) is fine at the start, 0.5 fine at the end power: { value: (p, r) => r.min + (r.max - r.min) * Math.pow(p, r.exponent), position: (v, r) => Math.pow(clamp(linear.position(v, r), 0, 1), 1 / r.exponent), }, // a gain in dB on an audio taper - the amplitude grows with the turn to the power of the exponent (3, default): the // turn's start is the min (-60 dB), its end the max decibel: { value: (p, r) => { const floor = Math.pow(10, (r.min - r.max) / (20 * r.exponent)); return r.max + 20 * r.exponent * Math.log10(floor + (1 - floor) * p); }, position: (v, r) => { const floor = Math.pow(10, (r.min - r.max) / (20 * r.exponent)); return (Math.pow(10, (v - r.max) / (20 * r.exponent)) - floor) / (1 - floor); }, },};const fixed = (locale: string, digits: number) => new Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits });const three = (locale: string) => new Intl.NumberFormat(locale, { minimumSignificantDigits: 3, maximumSignificantDigits: 3 });/** the fraction digits a value needs when the knob does not say: its step's, else fewer the wider its range */const autoDecimals = (c: KnobFormatContext): number => { if (c.step !== null) return Math.min(6, (String(c.step).split('.')[1] ?? '').length); const span = Math.abs(c.max - c.min); return span >= 100 ? 0 : span >= 10 ? 1 : 2;};const withUnit = (text: string, unit: string): string => (unit ? `${text} ${unit}` : text);const SI: [number, string][] = [ [1e9, 'G'], [1e6, 'M'], [1e3, 'k'], [1, ''], [1e-3, 'm'],];/** the formats by name - a page adds its own with df$.shadcn.knob.format() */const FORMATS: Record<string, (value: number, context: KnobFormatContext) => string> = { number: (v, c) => withUnit(fixed(c.locale, c.decimals ?? autoDecimals(c)).format(v), c.unit), // with a metric prefix and three significant digits: 440 Hz, 1.20 kHz, 500 mHz si: (v, c) => { const a = Math.abs(v); const [f, prefix] = a === 0 ? [1, ''] : (SI.find(([x]) => a >= x) ?? SI[SI.length - 1]); const n = c.decimals === null ? three(c.locale) : fixed(c.locale, c.decimals); return withUnit(n.format(v / f), `${prefix}${c.unit}`); }, // a fraction as a percentage: 0.5 is 50 % percent: (v, c) => new Intl.NumberFormat(c.locale, { style: 'percent', minimumFractionDigits: c.decimals ?? 0, maximumFractionDigits: c.decimals ?? 0, }).format(v), // seconds: below one in milliseconds - 250 ms, 1.20 s time: (v, c) => { const n = c.decimals === null ? three(c.locale) : fixed(c.locale, c.decimals); return Math.abs(v) < 1 ? `${n.format(v * 1000)} ms` : `${n.format(v)} s`; }, // always with its sign: +12 st, -3.0 dB signed: (v, c) => { const d = c.decimals ?? autoDecimals(c); const text = new Intl.NumberFormat(c.locale, { minimumFractionDigits: d, maximumFractionDigits: d, signDisplay: 'exceptZero', }).format(v); return withUnit(text, c.unit); },};const runtimeOf = (el: HTMLElement): Runtime => el._knob;const inputOf = (el: Element): HTMLInputElement | undefined => dfDollar(el).find<HTMLInputElement>('input.knob-input').get(0);/** the knob's range: the input's min and max, the scale's exponent */function rangeOf(el: HTMLElement, input: HTMLInputElement): KnobRange { const scale = el.getAttribute('data-scale') ?? 'linear'; const exponent = num(el.getAttribute('data-exponent'), scale === 'decibel' ? 3 : 2); return { min: num(input.getAttribute('min'), 0), max: num(input.getAttribute('max'), 100), exponent: exponent > 0 ? exponent : 1, };}const scaleOf = (el: HTMLElement): KnobScale => SCALES[el.getAttribute('data-scale') ?? 'linear'] ?? linear;/** the value at a position of the turn, on the knob's scale - a scale that fails is linear */function valueAt(el: HTMLElement, p: number): number { const r = rangeOf(el, runtimeOf(el).input); let v = NaN; try { v = scaleOf(el).value(clamp(p, 0, 1), r); } catch { v = NaN; } return Number.isFinite(v) ? v : linear.value(clamp(p, 0, 1), r);}/** the position of a value on the turn, 0 to 1 */function positionAt(el: HTMLElement, v: number): number { const r = rangeOf(el, runtimeOf(el).input); let p = NaN; try { p = scaleOf(el).position(v, r); } catch { p = NaN; } return clamp(Number.isFinite(p) ? p : linear.position(v, r), 0, 1);}/** a value the knob can take: inside its range, on the input's step (step="any": any) */function settle(el: HTMLElement, v: number): number { const input = runtimeOf(el).input; const r = rangeOf(el, input); const lo = Math.min(r.min, r.max); const hi = Math.max(r.min, r.max); let out = clamp(Number.isFinite(v) ? v : lo, lo, hi); const step = Number(input.getAttribute('step')); if (step > 0) out = clamp(r.min + Math.round((out - r.min) / step) * step, lo, hi); return tidy(out);}/** the value as the readout writes it - the knob's format, its unit and decimals, in its locale */function textOf(el: HTMLElement, v: number): string { const input = runtimeOf(el).input; const r = rangeOf(el, input); const d = Number.parseInt(el.getAttribute('data-decimals') ?? '', 10); const context: KnobFormatContext = { unit: el.getAttribute('data-unit') ?? '', decimals: Number.isFinite(d) && d >= 0 ? Math.min(d, 20) : null, locale: textLocale(el), min: r.min, max: r.max, step: Number(input.getAttribute('step')) > 0 ? Number(input.getAttribute('step')) : null, }; const format = FORMATS[el.getAttribute('data-format') ?? 'number'] ?? FORMATS.number; try { return String(format(v, context)); } catch { return FORMATS.number(v, context); }}/** the rings as the data writes them: an id, an amount from -1 to 1, a label, a colour, bipolar */function ringsOf(raw: unknown): KnobRing[] { if (!Array.isArray(raw)) return []; const seen = new Set<string>(); return raw .filter((r) => r && typeof r === 'object' && r.id != null && !seen.has(String(r.id)) && seen.add(String(r.id))) .map((r) => ({ id: String(r.id), label: r.label == null ? String(r.id) : String(r.label), amount: clamp(num(r.amount, 0), -1, 1), ...(r.color == null ? {} : { color: String(r.color) }), bipolar: r.bipolar === true, }));}/** a ring's colour: a theme colour by name, a CSS colour the browser takes, else the chart colour of its number */function colorOf(ring: KnobRing, i: number): string { const c = ring.color ?? ''; if (THEME_COLORS.test(c)) return `var(--${c})`; if (c && typeof CSS !== 'undefined' && CSS.supports('color', c)) return c; return `var(--chart-${(i % 5) + 1})`;}/** what the rings say for assistive technology: "modulated by LFO 1 (± 25 %), Envelope (+ 40 %)" */function ringsText(list: KnobRing[]): string { if (!list.length) return ''; const share = (r: KnobRing) => `${r.bipolar ? '±' : r.amount < 0 ? '-' : '+'} ${Math.round(Math.abs(r.amount) * 100)} %`; return `Modulated by ${list.map((r) => `${r.label} (${share(r)})`).join(', ')}`;}/** the marks around the value ring (data-ticks): a mark per step of a stepped knob, or that many over the turn * (data-ticks="5": the quarters) - none for more than MAX_TICKS */function ticksOf(el: HTMLElement): number[] { const attr = el.getAttribute('data-ticks'); if (attr === null) return []; const count = Number.parseInt(attr, 10); if (count >= 2) return Array.from({ length: Math.min(count, MAX_TICKS) }, (_, i) => i / (Math.min(count, MAX_TICKS) - 1)); const input = runtimeOf(el).input; const step = Number(input.getAttribute('step')); const r = rangeOf(el, input); const steps = step > 0 ? Math.floor(Math.abs(r.max - r.min) / step + 1e-9) + 1 : Infinity; if (steps > MAX_TICKS) return []; const dir = Math.sign(r.max - r.min) || 1; return Array.from({ length: steps }, (_, i) => positionAt(el, r.min + i * step * dir));}/** Draw the dial: the rings of the sources (the first next to the value ring), the marks of the steps, the value * ring, the face and its pointer. */function renderDial(el: HTMLElement): void { const rt = runtimeOf(el); const n = rt.rings.length; const markup = rt.rings .map( (r, i) => `<span class="knob-ring" data-ring="${esc(r.id)}"${r.bipolar ? ' data-bipolar' : ''} title="${esc(r.label ?? r.id)}" style="--knob-ring: ${i + 1}; --knob-ring-color: ${esc(colorOf(r, i))}"></span>`, ) .join(''); const ticks = ticksOf(el) .map((t) => `<span class="knob-tick" style="--knob-t: ${round4(t)}"></span>`) .join(''); const id = rt.dial.getAttribute('data-describes') ?? ''; dfDollar(rt.dial).html( `${markup}${ticks}<span class="knob-track"></span><span class="knob-face"><span class="knob-pointer"></span></span><span class="knob-rings-text" id="${esc(id)}">${esc(ringsText(rt.rings))}</span>`, ); rt.dial.style.setProperty('--knob-rings', String(n)); if (el.hasAttribute('data-radius')) rt.dial.style.setProperty('--knob-radius', `${Math.max(8, num(el.getAttribute('data-radius'), 8))}px`);}/** the same value drawn again - after another scale, range, step, marks or format */function redraw(el: HTMLElement): void { renderDial(el); show(el, runtimeOf(el).value, true, false);}/** Paint the value: the pointer, the arc from the origin, each ring's range and live value, the effective value, the * readout and the input's text. */function paint(el: HTMLElement): void { const rt = runtimeOf(el); const dial = rt.dial; const origin = el.hasAttribute('data-origin') ? positionAt(el, num(el.getAttribute('data-origin'), 0)) : 0; dial.style.setProperty('--knob-p', round4(rt.p)); dial.style.setProperty('--knob-o', round4(origin)); let effective = rt.p; let modulated = false; dfDollar(dial) .children<HTMLElement>('.knob-ring') .each((i, ring) => { const r = rt.rings[i]; if (!r) return; const reach = Math.abs(r.amount); const from = r.bipolar ? rt.p - reach : Math.min(rt.p, rt.p + r.amount); const to = r.bipolar ? rt.p + reach : Math.max(rt.p, rt.p + r.amount); ring.style.setProperty('--knob-from', round4(clamp(from, 0, 1))); ring.style.setProperty('--knob-to', round4(clamp(to, 0, 1))); const out = rt.live.get(r.id); if (out === undefined) { if (ring.hasAttribute('data-live')) dfDollar(ring).attr('data-live', null); return; } const at = clamp(rt.p + r.amount * out, 0, 1); effective += r.amount * out; modulated = true; ring.style.setProperty('--knob-at-lo', round4(Math.min(rt.p, at))); ring.style.setProperty('--knob-at-hi', round4(Math.max(rt.p, at))); if (!ring.hasAttribute('data-live')) dfDollar(ring).attr('data-live', ''); }); dial.style.setProperty('--knob-e', round4(clamp(effective, 0, 1))); if (dial.hasAttribute('data-modulated') !== modulated) dfDollar(dial).attr('data-modulated', modulated ? '' : null); const text = textOf(el, rt.value); if (rt.input.getAttribute('aria-valuetext') !== text) dfDollar(rt.input).attr('aria-valuetext', text); dfDollar(el) .find<HTMLElement>('.knob-value') .each((_i, out) => { if (dfDollar(out).text() !== text) dfDollar(out).text(text); });}/** Show a value: settled on the knob's range and step, written into the input, painted - and told: the input's input * event and knob-change while it changes, its change event when a change is committed. */function show(el: HTMLElement, v: number, committed: boolean, emit = true): void { const rt = runtimeOf(el); const value = settle(el, v); const changed = value !== rt.value; rt.value = value; rt.p = positionAt(el, value); if (rt.input.value !== String(value)) rt.input.value = String(value); paint(el); if (!emit) { if (committed) rt.committed = value; return; } const done = committed && value !== rt.committed; if (!changed && !done) return; rt.writing = true; try { if (changed) rt.input.dispatchEvent(new Event('input', { bubbles: true })); if (done) { rt.committed = value; rt.input.dispatchEvent(new Event('change', { bubbles: true })); } } finally { rt.writing = false; } // Fires when the value changes - while a drag goes on (committed: false) and when a change is done. el.dispatchEvent( new CustomEvent<KnobChangeDetail>('knob-change', { bubbles: true, detail: { value, position: Math.round(rt.p * 10000) / 10000, text: textOf(el, value), committed }, }), );}/** the value a reset goes back to: data-default, else the input's authored value */const defaultOf = (el: HTMLElement): number => num(el.getAttribute('data-default'), num(runtimeOf(el).input.defaultValue, rangeOf(el, runtimeOf(el).input).min));/** the markup of a state: disabled disables the input; the other states write none */function applyMarkup(el: Element, stateName: string): void { const input = inputOf(el); if (!input) return; if (stateName === 'disabled') { if (!input.hasAttribute('disabled')) dfDollar(input).attr('disabled', ''); } else if (input.hasAttribute('disabled')) dfDollar(input).attr('disabled', null);}/** UI side of setState: the state's markup, then the value when the config names one. */function triggerStateChange(el: HTMLElement, stateName: string, incoming: Record<string, unknown>) { applyMarkup(el, stateName); const rt = runtimeOf(el); if (!rt) return undefined; if (stateName !== 'adjusting' && rt.drag) endDrag(el, false, false); if (stateName !== 'adjusting') rt.dial.style.removeProperty('--knob-reach'); if ('value' in incoming) show(el, num(incoming.value, rt.value), true); return undefined;}/** Registry-level API; pass the .knob element explicitly. Unknown names throw. */export const knobApi = componentState({ component: 'knob', states: knobStates, // the config is the value: a state change keeps it unless it names another mergeConfig: true, apply: (el, state, _previous, incoming) => triggerStateChange(el, state.name, incoming), // the user turns the knob without setState; a disabled input is the disabled state read: (el, state) => { const rt = runtimeOf(el); if (!rt) return state; const name = rt.input.disabled ? 'disabled' : state.name === 'disabled' ? 'default' : state.name; if (name === state.name && (state.config as KnobConfig).value === rt.value) return state; return { name, config: { ...state.config, value: rt.value } }; }, markup: (el, state) => applyMarkup(el, state.name), events: ['input', 'change'],});df$.knobApi = knobApi;df$.knobStates = knobStates;/** the speed of a drag at a pointer: full within the radius, the radius over the distance beyond it */function speedAt(d: Drag, e: PointerEvent): number { const dist = Math.hypot(e.clientX - d.cx, e.clientY - d.cy); return dist <= d.radius ? 1 : Math.max(MIN_SPEED, d.radius / dist);}/** the drag's precision drawn: the circle through the pointer around the knob */function reach(el: HTMLElement, e: PointerEvent): void { const rt = runtimeOf(el); if (!rt.drag) return; const dist = Math.hypot(e.clientX - rt.drag.cx, e.clientY - rt.drag.cy); rt.dial.style.setProperty('--knob-reach', `${Math.round(dist)}px`);}/** the precision radius: data-radius (px), else the dial's width - beyond it a drag slows down */const radiusOf = (el: HTMLElement, box: DOMRect): number => Math.max(8, num(el.getAttribute('data-radius'), box.width));function onPointerDown(el: HTMLElement, e: PointerEvent): void { const rt = runtimeOf(el); if (!rt || rt.input.disabled || e.button !== 0) return; e.preventDefault(); // the keys go to the knob now - without the ring a key press would show rt.input.focus({ preventScroll: true, focusVisible: false } as FocusOptions); // Ctrl / Cmd + click: back to the default, as in a plugin if (e.ctrlKey || e.metaKey) { show(el, defaultOf(el), true); return; } const box = rt.dial.getBoundingClientRect(); rt.drag = { id: e.pointerId, x: e.clientX, p: rt.p, start: rt.value, cx: box.left + box.width / 2, cy: box.top + box.height / 2, radius: radiusOf(el, box), }; rt.dial.style.setProperty('--knob-radius', `${Math.round(rt.drag.radius)}px`); try { rt.dial.setPointerCapture(e.pointerId); } catch { /* a synthetic pointer: no capture */ } el.api?.setState('adjusting'); reach(el, e);}function onPointerMove(el: HTMLElement, e: PointerEvent): void { const rt = runtimeOf(el); const d = rt?.drag; if (!d || e.pointerId !== d.id) return; const dx = e.clientX - d.x; d.x = e.clientX; const travel = Math.max(20, num(el.getAttribute('data-travel'), 200)); d.p = clamp(d.p + (dx / travel) * speedAt(d, e) * (e.shiftKey ? 0.1 : 1), 0, 1); reach(el, e); show(el, valueAt(el, d.p), false);}/** End a drag: committed where it is, or (cancel: Escape) back where it started. */function endDrag(el: HTMLElement, cancel: boolean, rest = true): void { const rt = runtimeOf(el); const d = rt.drag; if (!d) return; rt.drag = null; try { if (rt.dial.hasPointerCapture(d.id)) rt.dial.releasePointerCapture(d.id); } catch { /* already released */ } show(el, cancel ? d.start : rt.value, true); if (rest && el.dataset.stateName === 'adjusting') el.api?.setState('default');}/** The keys (the APG slider): an arrow a step - the input's step, else 1 % of the turn (Shift: 0.1 %) -, Page Up / * Down 10 %, Home / End the ends, Delete / Backspace the default, Escape ends a drag where it started. */function onKey(el: HTMLElement, e: KeyboardEvent): void { const rt = runtimeOf(el); if (!rt || rt.input.disabled || e.altKey || e.ctrlKey || e.metaKey) return; if (e.key === 'Escape') { if (!rt.drag) return; e.preventDefault(); endDrag(el, true); return; } const dir = ({ ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 } as Record<string, number>)[e.key]; const step = Number(rt.input.getAttribute('step')); let value: number | null = null; if (dir && step > 0) value = rt.value + dir * step * Math.sign(rangeOf(el, rt.input).max - rangeOf(el, rt.input).min || 1); else if (dir) value = valueAt(el, rt.p + dir * (e.shiftKey ? FINE_STEP : KEY_STEP)); else if (e.key === 'PageUp' || e.key === 'PageDown') value = valueAt(el, rt.p + (e.key === 'PageUp' ? PAGE_STEP : -PAGE_STEP)); else if (e.key === 'Home' || e.key === 'End') value = valueAt(el, e.key === 'Home' ? 0 : 1); else if (e.key === 'Delete' || e.key === 'Backspace') value = defaultOf(el); else if (e.key === 'Enter' && !rt.drag) { e.preventDefault(); openEditor(el); return; } if (value === null) return; e.preventDefault(); if (rt.drag) rt.drag.p = positionAt(el, settle(el, value)); show(el, value, true);}/** how the editor writes a value: a percent knob in percent, a time knob in seconds, any other in its unit */function editScaleOf(el: HTMLElement): { factor: number; unit: string } { const format = el.getAttribute('data-format') ?? 'number'; if (format === 'percent') return { factor: 100, unit: '%' }; if (format === 'time') return { factor: 1, unit: 's' }; return { factor: 1, unit: el.getAttribute('data-unit') ?? '' };}/** Type the value in: a Number Input (xs) over the readout - the knob's range and step, its unit -, Enter or leaving * it sets the value (settled on the range and step), Escape keeps the old one. It lies over the knob, so nothing * moves while it is open. */function openEditor(el: HTMLElement): void { const rt = runtimeOf(el); if (!rt || rt.input.disabled || rt.editor) return; const { factor, unit } = editScaleOf(el); const r = rangeOf(el, rt.input); const step = Number(rt.input.getAttribute('step')); const id = `${rt.dial.getAttribute('data-describes') ?? 'knob'}-edit`; const name = dfDollar(el).find('.knob-label').text().trim() || rt.input.getAttribute('aria-label') || 'Value'; const typed = (v: number) => String(tidy(v * factor)); const editor = dfDollar( `<div class="number-input knob-editor" data-size="xs"><input type="number" id="${esc(id)}" min="${typed(Math.min(r.min, r.max))}" max="${typed(Math.max(r.min, r.max))}" step="${step > 0 ? typed(step) : 'any'}" value="${typed(rt.value)}" aria-label="${esc(name)}" enterkeyhint="done" />${unit ? `<label class="number-input-unit" for="${esc(id)}">${esc(unit)}</label>` : ''}</div>`, ).get(0) as HTMLElement; const output = dfDollar(el).find<HTMLElement>('.knob-value').get(0); // centred on the readout (without one, just under the dial) const middle = output ? output.offsetTop + output.offsetHeight / 2 : rt.dial.offsetTop + rt.dial.offsetHeight + 16; editor.style.setProperty('top', `${middle}px`); if (output) output.style.setProperty('visibility', 'hidden'); dfDollar(el).append(editor); rt.editor = editor; const field = dfDollar(editor).find<HTMLInputElement>('input').get(0)!; const close = (commit: boolean, back: boolean) => { if (rt.editor !== editor) return; rt.editor = null; const n = field.valueAsNumber; editor.remove(); if (output) output.style.removeProperty('visibility'); if (commit && Number.isFinite(n)) show(el, n / factor, true); if (back) rt.input.focus({ preventScroll: true }); }; dfDollar(field).on('keydown', (e: KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== 'Escape') return; e.preventDefault(); e.stopPropagation(); close(e.key === 'Enter', true); }); dfDollar(field).on('blur', () => close(true, false)); field.focus({ preventScroll: true }); field.select();}function init() { dfDollar('.knob:not([data-init])') .toArray() .forEach((el: HTMLElement) => { el.dataset.init = ''; const input = inputOf(el); if (!input) return; // a range without a step snaps to whole numbers - the knob's values are as fine as its scale unless it says // (the browser has snapped the authored value already: it is read again under step="any") if (!input.hasAttribute('step')) { dfDollar(input).attr('step', 'any'); if (input.hasAttribute('value')) input.value = input.defaultValue; } let data: unknown = null; try { data = JSON.parse(dfDollar(el).children('script.knob-rings').text() || 'null'); } catch { data = null; } const id = `knob-rings-${++rings}`; const dial = dfDollar(`<span class="knob-dial" aria-hidden="true" data-describes="${id}"></span>`).get( 0, ) as HTMLElement; dfDollar(input).before(dial); const described = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean); dfDollar(input).attr('aria-describedby', [...described, id].join(' ')); const rt: Runtime = { input, dial, value: NaN, p: 0, committed: NaN, rings: ringsOf(data), live: new Map(), drag: null, writing: false, editor: null, }; el._knob = rt; renderDial(el); show(el, num(input.value, defaultOf(el)), true, false); // el.store + el.api (AGENTS.md "State through stores"): $('#cutoff').api.setState('default', { value: 440 }) bindComponent(el, knobApi, { name: input.disabled ? 'disabled' : 'default', config: { value: rt.value } }); dfDollar(dial).on('pointerdown', (e: PointerEvent) => onPointerDown(el, e)); dfDollar(dial).on('pointermove', (e: PointerEvent) => onPointerMove(el, e)); dfDollar(dial).on('pointerup', () => endDrag(el, false)); dfDollar(dial).on('pointercancel', () => endDrag(el, false)); dfDollar(dial).on('lostpointercapture', () => endDrag(el, false)); dfDollar(dial).on('dblclick', () => { if (!input.disabled) show(el, defaultOf(el), true); }); dfDollar(input).on('keydown', (e: KeyboardEvent) => onKey(el, e)); // a double click on the readout types the value in dfDollar(el).on('dblclick', (e: MouseEvent) => { if ((e.target as Element).closest?.('.knob-value')) openEditor(el); }); // a value written from outside (input.value = ... and an input event) is shown, not sent back dfDollar(input).on('input', () => { if (!rt.writing && Number(input.value) !== rt.value) show(el, num(input.value, rt.value), true, false); }); // a form reset puts the authored value back if (input.form) dfDollar(input.form).on('reset', () => { setTimeout(() => show(el, num(input.value, defaultOf(el)), true, false)); }); // another scale, range, step, marks or format: the same value drawn again const again = () => redraw(el); new MutationObserver(again).observe(el, { attributes: true, attributeFilter: [ 'data-scale', 'data-exponent', 'data-origin', 'data-unit', 'data-format', 'data-decimals', 'data-ticks', ], }); new MutationObserver(again).observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step'] }); });}/** the knob of a target: the .knob element, its input, or a selector of either */const resolve = (target: string | HTMLElement): HTMLElement | undefined => { const el = typeof target === 'string' ? dfDollar(target).get(0) : target; const knob = el?.closest?.<HTMLElement>('.knob'); return knob?._knob ? knob : undefined;};df$.knob = { /** * The knob's value, or set it (setState with { value }: settled on the range and step, the change told). * @param target - the .knob element, its input, or a selector of either * @param value - the value to set; leave it out to read * @returns the value now; NaN for an unknown element */ value: (target: string | HTMLElement, value?: number): number => { const el = resolve(target); if (!el) return NaN; if (value !== undefined) el.api?.setState(el.dataset.stateName === 'disabled' ? 'disabled' : 'default', { value }); return runtimeOf(el).value; }, /** * Where the value stands on the knob's turn. * @param target - the .knob element, its input, or a selector of either * @returns the position, 0 (the start of the turn) to 1 (its end); NaN for an unknown element */ position: (target: string | HTMLElement): number => { const el = resolve(target); return el ? runtimeOf(el).p : NaN; }, /** * Back to the default value (data-default, else the input's authored value) - what a double click does. * @param target - the .knob element, its input, or a selector of either * @returns the value now; NaN for an unknown element */ reset: (target: string | HTMLElement): number => { const el = resolve(target); if (!el) return NaN; show(el, defaultOf(el), true); return runtimeOf(el).value; }, /** * The rings around the knob, or replace them - the live values of the sources that stay are kept. * @param target - the .knob element, its input, or a selector of either * @param list - the new rings; leave it out to read * @returns a copy of the rings now */ rings: (target: string | HTMLElement, list?: KnobRing[]): KnobRing[] => { const el = resolve(target); if (!el) return []; const rt = runtimeOf(el); if (list !== undefined) { rt.rings = ringsOf(list); for (const id of rt.live.keys()) if (!rt.rings.some((r) => r.id === id)) rt.live.delete(id); renderDial(el); paint(el); } return rt.rings.map((r) => ({ ...r })); }, /** * The live outputs of the sources - each ring shows how far its source moves the knob now, the value ring the * effective value. A unipolar source runs from 0 to 1, a bipolar one from -1 to 1. * @param target - the .knob element, its input, or a selector of either * @param values - the outputs by ring id (null for a ring: no live value); null: none at all * @returns the effective value - the knob's value moved by every live source, inside its range */ modulate: (target: string | HTMLElement, values: Record<string, number | null> | null): number => { const el = resolve(target); if (!el) return NaN; const rt = runtimeOf(el); if (values === null) rt.live.clear(); else for (const [id, out] of Object.entries(values ?? {})) { const ring = rt.rings.find((r) => r.id === id); if (!ring || out === null || !Number.isFinite(Number(out))) rt.live.delete(id); else rt.live.set(id, clamp(Number(out), ring.bipolar ? -1 : 0, 1)); } paint(el); let p = rt.p; for (const r of rt.rings) p += r.amount * (rt.live.get(r.id) ?? 0); return settle(el, valueAt(el, clamp(p, 0, 1))); }, /** * Add a scale knobs name in data-scale - the knobs on it are drawn again. * @param name - the scale's name * @param scale - its value() and position(), each the inverse of the other */ scale: (name: string, scale: KnobScale): void => { if (!name || typeof scale?.value !== 'function' || typeof scale?.position !== 'function') return; SCALES[name] = scale; for (const el of dfDollar(`.knob[data-init][data-scale="${CSS.escape(name)}"]`).toArray() as HTMLElement[]) if (el._knob) redraw(el); }, /** * Add a format knobs name in data-format - the knobs in it write their values again. * @param name - the format's name * @param format - the value's text, from the value and the knob's unit, decimals, locale and range */ format: (name: string, format: (value: number, context: KnobFormatContext) => string): void => { if (!name || typeof format !== 'function') return; FORMATS[name] = format; for (const el of dfDollar(`.knob[data-init][data-format="${CSS.escape(name)}"]`).toArray() as HTMLElement[]) if (el._knob) paint(el); },};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub