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 = ({ 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>(); 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 ? ( ) : ( ), ); } return (
{showValue && (
)} {label && ( {label} )}
); };