Tier 2: drag feel, wheel overhaul, refs/meta/forms, Meter, gradients
- rotaryMode: 'absolute' | 'relative' (angle deltas, no grab jump) |
'pickup' (MIDI-style: engages when the pointer sweeps past the value)
- dragAcceleration: velocity gain for relative drags (Shift bypasses);
hideCursorOnDrag hides the cursor mid-gesture
- Wheel: trackpad delta accumulation (flicks can't rocket the value,
one notch per wheel click), wheelStep override, wheelRequiresFocus
opt-out of the hover scroll-trap
- onChange/onChangeStart/onChangeEnd now receive { source: 'drag' |
'wheel' | 'keyboard' | 'reset' | 'api' }
- Every component forwards a ref to its root; name prop renders a
hidden form input; SVG primitives carry data-part attributes
- Arc gradient: position-anchored color stops rendered as sliced arcs
(mixColors/sampleGradient exported, unit-tested)
- New read-only Meter: LED level meter with peak hold, zones, readout
- Fader capColor/capLength for custom handle styling
- Docs: Meter/gradient/drag-feel gallery cards, white-cap fader demo,
API rows, README
This commit is contained in:
parent
804a85cca6
commit
0aee48b749
27 changed files with 756 additions and 134 deletions
181
packages/dreamknob/src/skins/Meter.tsx
Normal file
181
packages/dreamknob/src/skins/Meter.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import * as React from 'react';
|
||||
import { clamp, linearTaper } from '../core/math';
|
||||
import type { Taper } from '../core/math';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import type { LEDFaderZone } from './LEDFader';
|
||||
import { SegmentDisplay } from '../digital/SegmentDisplay';
|
||||
|
||||
export interface MeterProps {
|
||||
/** Level to display. The meter is read-only — always controlled. */
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
taper?: Taper;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
/** Travel length in px. Default: 160. */
|
||||
length?: number;
|
||||
/** Cross-axis size in px. Default: 16. */
|
||||
breadth?: number;
|
||||
/** Number of LED segments. Default: 28. */
|
||||
segments?: number;
|
||||
/** LED color when no zones are given. */
|
||||
color?: string;
|
||||
/** Meter zones, e.g. green / yellow / red by normalized position. */
|
||||
zones?: readonly LEDFaderZone[];
|
||||
/** Opacity of unlit segments. Default: 0.1. */
|
||||
offOpacity?: number;
|
||||
/**
|
||||
* Peak hold time in ms — the highest recent segment stays lit and falls
|
||||
* back after this long. `false` disables. Default: 1200.
|
||||
*/
|
||||
peakHold?: number | false;
|
||||
/** Peak indicator color. Defaults to the peak's zone color. */
|
||||
peakColor?: string;
|
||||
/** Panel color behind the LEDs. */
|
||||
faceColor?: string;
|
||||
label?: string;
|
||||
/** Show a seven-segment readout. Default: false. */
|
||||
showValue?: boolean;
|
||||
digits?: number;
|
||||
displayDecimals?: number;
|
||||
labelColor?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only LED level meter with peak hold — the display-side sibling of
|
||||
* LEDFader for VU/output metering.
|
||||
*/
|
||||
export const Meter: React.FC<MeterProps> = ({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
taper = linearTaper,
|
||||
orientation = 'vertical',
|
||||
length = 160,
|
||||
breadth = 16,
|
||||
segments = 28,
|
||||
color = '#3df2ad',
|
||||
zones,
|
||||
offOpacity = 0.1,
|
||||
peakHold = 1200,
|
||||
peakColor,
|
||||
faceColor = '#0b0d0e',
|
||||
label,
|
||||
showValue = false,
|
||||
digits = 4,
|
||||
displayDecimals = 1,
|
||||
labelColor,
|
||||
className,
|
||||
style,
|
||||
...aria
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const vertical = orientation === 'vertical';
|
||||
const n = clamp(taper.toNormalized(clamp(value, min, max), min, max), 0, 1);
|
||||
|
||||
// Peak: track the highest recent level, fall back after `peakHold` ms.
|
||||
const [peak, setPeak] = React.useState(n);
|
||||
React.useEffect(() => {
|
||||
if (peakHold === false) return;
|
||||
if (n >= peak) {
|
||||
setPeak(n);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setPeak(n), peakHold);
|
||||
return () => clearTimeout(t);
|
||||
}, [n, peak, peakHold]);
|
||||
|
||||
const pad = 4;
|
||||
const w = vertical ? breadth : length;
|
||||
const h = vertical ? length : breadth;
|
||||
const zoneList: readonly LEDFaderZone[] = React.useMemo(
|
||||
() => (zones ? [...zones].sort((a, b) => a.upTo - b.upTo) : [{ upTo: 1, color }]),
|
||||
[zones, color],
|
||||
);
|
||||
const zoneFor = (t: number): string =>
|
||||
(zoneList.find(z => t <= z.upTo + 1e-9) ?? zoneList[zoneList.length - 1]).color;
|
||||
|
||||
const lit = Math.round(n * segments);
|
||||
const peakIdx =
|
||||
peakHold !== false && peak > 0 ? Math.min(segments - 1, Math.ceil(peak * segments) - 1) : -1;
|
||||
const slot = (length - pad * 2) / segments;
|
||||
const gap = Math.min(2.5, slot * 0.35);
|
||||
const cross = breadth - pad * 2;
|
||||
|
||||
const leds: React.ReactNode[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const zone = zoneFor((i + 1) / segments);
|
||||
const isPeak = i === peakIdx && i >= lit;
|
||||
const on = i < lit || isPeak;
|
||||
const fill = isPeak ? (peakColor ?? zone) : zone;
|
||||
const common = { rx: 1.2, fill, opacity: on ? 1 : offOpacity };
|
||||
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}
|
||||
role="meter"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={clamp(value, min, max)}
|
||||
aria-label={aria['aria-label']}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
width: w,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{showValue && (
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<SegmentDisplay
|
||||
value={clamp(value, min, max)}
|
||||
digits={digits}
|
||||
decimals={displayDecimals}
|
||||
height={11}
|
||||
color={zoneFor(n)}
|
||||
background="none"
|
||||
ghostOpacity={0.06}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }} aria-hidden="true">
|
||||
<rect x={0.5} y={0.5} width={w - 1} height={h - 1} rx={4} fill={faceColor} stroke="rgba(255,255,255,0.09)" />
|
||||
{leds}
|
||||
</svg>
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fontUI,
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: labelColor ?? theme.label,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue