- Dedicated clip LED at the hot end (default on): lights when the
signal reaches clipThreshold (default max), holds for clipHold ms or
latches until clicked ('latch'); clipColor, onClip callback,
data-clipped root attribute, data-part=clip for CSS
- clipValue prop: separate signal for clip detection so the bar can
show RMS while the LED watches sample peaks, like real meters
- Synth: analyser loop now computes sample peaks alongside RMS and
feeds them to the clip detector - overdriving the synth genuinely
trips the LED
- Gallery meter demo throws occasional hot transients to exercise it;
API docs and README updated
248 lines
7.6 KiB
TypeScript
248 lines
7.6 KiB
TypeScript
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;
|
|
/** Dedicated clip LED at the hot end of the meter. Default: true. */
|
|
showClip?: boolean;
|
|
/** Value at/above which the clip LED lights. Default: `max`. */
|
|
clipThreshold?: number;
|
|
/**
|
|
* Signal used for clip detection, when it differs from the displayed
|
|
* level — e.g. show RMS on the bar but clip on sample peaks. Defaults
|
|
* to `value`.
|
|
*/
|
|
clipValue?: number;
|
|
/**
|
|
* How long the clip LED stays lit in ms, or 'latch' to stay lit until
|
|
* clicked. Default: 1500.
|
|
*/
|
|
clipHold?: number | 'latch';
|
|
clipColor?: string;
|
|
/** Fired when the signal first crosses the clip threshold. */
|
|
onClip?: (value: number) => void;
|
|
/** 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,
|
|
showClip = true,
|
|
clipThreshold,
|
|
clipValue,
|
|
clipHold = 1500,
|
|
clipColor = '#ff2b39',
|
|
onClip,
|
|
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]);
|
|
|
|
// Clip: latch a dedicated LED when the raw (unclamped) signal reaches the
|
|
// threshold. Hold for `clipHold` ms, or until clicked in 'latch' mode.
|
|
const [clipped, setClipped] = React.useState(false);
|
|
const clipTimer = React.useRef<ReturnType<typeof setTimeout>>();
|
|
const wasOver = React.useRef(false);
|
|
const onClipRef = React.useRef(onClip);
|
|
onClipRef.current = onClip;
|
|
React.useEffect(() => {
|
|
if (!showClip) return;
|
|
const signal = clipValue ?? value;
|
|
const over = signal >= (clipThreshold ?? max) - 1e-9;
|
|
if (over) {
|
|
if (!wasOver.current) onClipRef.current?.(signal);
|
|
setClipped(true);
|
|
if (clipHold !== 'latch') {
|
|
clearTimeout(clipTimer.current);
|
|
clipTimer.current = setTimeout(() => setClipped(false), clipHold);
|
|
}
|
|
}
|
|
wasOver.current = over;
|
|
}, [value, clipValue, showClip, clipThreshold, max, clipHold]);
|
|
React.useEffect(() => () => clearTimeout(clipTimer.current), []);
|
|
|
|
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;
|
|
// Reserve room at the hot end for the clip LED (7px LED + 3px gap).
|
|
const clipLed = 7;
|
|
const clipSpan = showClip ? clipLed + 3 : 0;
|
|
const slot = (length - pad * 2 - clipSpan) / 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']}
|
|
data-clipped={clipped ? '' : undefined}
|
|
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}
|
|
{showClip && (
|
|
<rect
|
|
data-part="clip"
|
|
x={vertical ? pad : length - pad - clipLed}
|
|
y={vertical ? pad : pad}
|
|
width={vertical ? cross : clipLed}
|
|
height={vertical ? clipLed : cross}
|
|
rx={1.5}
|
|
fill={clipColor}
|
|
opacity={clipped ? 1 : 0.14}
|
|
onClick={() => setClipped(false)}
|
|
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
|
|
>
|
|
<title>{clipped ? 'Clip! Click to clear' : 'Clip indicator'}</title>
|
|
</rect>
|
|
)}
|
|
</svg>
|
|
{label && (
|
|
<span
|
|
style={{
|
|
fontFamily: theme.fontUI,
|
|
fontSize: 11,
|
|
letterSpacing: '0.08em',
|
|
textTransform: 'uppercase',
|
|
color: labelColor ?? theme.label,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|