- valueBubble/bubbleFormat: floating readout above knobs while dragging
(Knob-based skins + ImageKnob)
- animateChanges: rAF ease-out tween of the pointer on programmatic
value changes; user gestures snap; prefers-reduced-motion disables
- wrap: endless encoder mode — values and relative drags roll around
min↔max; pairs with angleOffset={0} angleRange={360}
- XYPad: two-parameter pad with absolute drag, arrow keys (Shift
coarse), Escape cancel, double-click reset, grid/crosshair, focus
ring, hidden form inputs, change meta
- AlphaDisplay: fourteen-segment alphanumeric LED (A-Z, 0-9, symbols,
decimal points, chars padding/align, glow/skew)
- PushButton: studio panel button with LED strip — toggle or momentary,
controlled/uncontrolled, aria-pressed, hidden form input
- Docs: five new gallery cards incl. animated preset demo, API rows,
README
240 lines
6.7 KiB
TypeScript
240 lines
6.7 KiB
TypeScript
import * as React from 'react';
|
||
|
||
// Fourteen-segment geometry in a local 10 x 18 digit cell.
|
||
// Outer ring: a top, b top-right, c bottom-right, d bottom, e bottom-left,
|
||
// f top-left. Middle bar: g1 left, g2 right. Center verticals: i top, l
|
||
// bottom. Diagonals: h TL, j TR, k BL, m BR.
|
||
|
||
const W = 10;
|
||
const H = 18;
|
||
const GAP = 3.4;
|
||
const HALF = 1.05;
|
||
const INSET = 0.45;
|
||
|
||
const hSeg = (y: number, x1: number, x2: number): string => {
|
||
const a = x1 + INSET;
|
||
const b = x2 - INSET;
|
||
return `${a},${y} ${a + HALF},${y - HALF} ${b - HALF},${y - HALF} ${b},${y} ${b - HALF},${y + HALF} ${a + HALF},${y + HALF}`;
|
||
};
|
||
|
||
const vSeg = (x: number, y1: number, y2: number): string => {
|
||
const a = y1 + INSET;
|
||
const b = y2 - INSET;
|
||
return `${x},${a} ${x + HALF},${a + HALF} ${x + HALF},${b - HALF} ${x},${b} ${x - HALF},${b - HALF} ${x - HALF},${a + HALF}`;
|
||
};
|
||
|
||
type Seg14 =
|
||
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
|
||
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
|
||
|
||
const POLY: Partial<Record<Seg14, string>> = {
|
||
a: hSeg(1, 1, 9),
|
||
d: hSeg(17, 1, 9),
|
||
g1: hSeg(9, 1, 5),
|
||
g2: hSeg(9, 5, 9),
|
||
f: vSeg(1, 1, 9),
|
||
b: vSeg(9, 1, 9),
|
||
e: vSeg(1, 9, 17),
|
||
c: vSeg(9, 9, 17),
|
||
i: vSeg(5, 1, 9),
|
||
l: vSeg(5, 9, 17),
|
||
};
|
||
|
||
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
|
||
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
|
||
h: [2.2, 2.7, 4.1, 7.2],
|
||
j: [7.8, 2.7, 5.9, 7.2],
|
||
k: [4.1, 10.8, 2.2, 15.3],
|
||
m: [5.9, 10.8, 7.8, 15.3],
|
||
};
|
||
|
||
const CHARS: Record<string, Seg14[]> = {
|
||
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
|
||
'1': ['b', 'c'],
|
||
'2': ['a', 'b', 'g1', 'g2', 'e', 'd'],
|
||
'3': ['a', 'b', 'c', 'd', 'g1', 'g2'],
|
||
'4': ['f', 'g1', 'g2', 'b', 'c'],
|
||
'5': ['a', 'f', 'g1', 'g2', 'c', 'd'],
|
||
'6': ['a', 'f', 'g1', 'g2', 'e', 'c', 'd'],
|
||
'7': ['a', 'b', 'c'],
|
||
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g1', 'g2'],
|
||
'9': ['a', 'b', 'c', 'd', 'f', 'g1', 'g2'],
|
||
A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'],
|
||
B: ['a', 'b', 'c', 'd', 'i', 'l', 'g2'],
|
||
C: ['a', 'd', 'e', 'f'],
|
||
D: ['a', 'b', 'c', 'd', 'i', 'l'],
|
||
E: ['a', 'd', 'e', 'f', 'g1', 'g2'],
|
||
F: ['a', 'e', 'f', 'g1', 'g2'],
|
||
G: ['a', 'c', 'd', 'e', 'f', 'g2'],
|
||
H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
|
||
I: ['a', 'd', 'i', 'l'],
|
||
J: ['b', 'c', 'd', 'e'],
|
||
K: ['e', 'f', 'g1', 'j', 'm'],
|
||
L: ['d', 'e', 'f'],
|
||
M: ['b', 'c', 'e', 'f', 'h', 'j'],
|
||
N: ['b', 'c', 'e', 'f', 'h', 'm'],
|
||
O: ['a', 'b', 'c', 'd', 'e', 'f'],
|
||
P: ['a', 'b', 'e', 'f', 'g1', 'g2'],
|
||
Q: ['a', 'b', 'c', 'd', 'e', 'f', 'm'],
|
||
R: ['a', 'b', 'e', 'f', 'g1', 'g2', 'm'],
|
||
S: ['a', 'c', 'd', 'f', 'g1', 'g2'],
|
||
T: ['a', 'i', 'l'],
|
||
U: ['b', 'c', 'd', 'e', 'f'],
|
||
V: ['e', 'f', 'j', 'k'],
|
||
W: ['b', 'c', 'e', 'f', 'k', 'm'],
|
||
X: ['h', 'j', 'k', 'm'],
|
||
Y: ['h', 'j', 'l'],
|
||
Z: ['a', 'd', 'j', 'k'],
|
||
'-': ['g1', 'g2'],
|
||
'_': ['d'],
|
||
'=': ['g1', 'g2', 'd'],
|
||
'+': ['g1', 'g2', 'i', 'l'],
|
||
'*': ['g1', 'g2', 'h', 'i', 'j', 'k', 'l', 'm'],
|
||
'/': ['j', 'k'],
|
||
'\\': ['h', 'm'],
|
||
'?': ['a', 'b', 'g2', 'l'],
|
||
' ': [],
|
||
};
|
||
|
||
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
|
||
|
||
export interface AlphaDisplayProps {
|
||
/** Text: A–Z, 0–9, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
|
||
value: string;
|
||
/** Fixed number of character cells; the text is padded/cropped to fit. */
|
||
chars?: number;
|
||
align?: 'left' | 'right';
|
||
/** Character height in px. Default: 22. */
|
||
height?: number;
|
||
color?: string;
|
||
/** Panel background. Set to 'none' to disable. */
|
||
background?: string;
|
||
/** Opacity of unlit segments. Default: 0.08. */
|
||
ghostOpacity?: number;
|
||
/** Italic skew in degrees. Default: 6. */
|
||
skew?: number;
|
||
/** LED glow strength (0 disables). Default: 1.4. */
|
||
glow?: number;
|
||
padding?: number;
|
||
className?: string;
|
||
style?: React.CSSProperties;
|
||
}
|
||
|
||
/**
|
||
* Fourteen-segment alphanumeric LED display — preset names, modes, messages.
|
||
* The alphanumeric sibling of `SegmentDisplay`.
|
||
*/
|
||
export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
||
value,
|
||
chars,
|
||
align = 'left',
|
||
height = 22,
|
||
color = '#ffb84d',
|
||
background = '#0a0d0c',
|
||
ghostOpacity = 0.08,
|
||
skew = 6,
|
||
glow = 1.4,
|
||
padding = 6,
|
||
className,
|
||
style,
|
||
}) => {
|
||
const filterId = React.useId();
|
||
|
||
let text = value.toUpperCase();
|
||
if (chars !== undefined) {
|
||
const cellCount = text.replace(/\./g, '').length;
|
||
if (cellCount < chars) {
|
||
const pad = ' '.repeat(chars - cellCount);
|
||
text = align === 'right' ? pad + text : text + pad;
|
||
} else if (cellCount > chars) {
|
||
let kept = 0;
|
||
let out = '';
|
||
for (const ch of text) {
|
||
if (ch !== '.' && kept === chars) break;
|
||
if (ch !== '.') kept++;
|
||
out += ch;
|
||
}
|
||
text = out;
|
||
}
|
||
}
|
||
|
||
const cells: React.ReactNode[] = [];
|
||
let x = 0;
|
||
let key = 0;
|
||
for (const ch of text) {
|
||
if (ch === '.') {
|
||
cells.push(
|
||
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={H - 1.2} r={1.15} fill={color} />,
|
||
);
|
||
continue;
|
||
}
|
||
const lit = new Set(CHARS[ch] ?? []);
|
||
const parts: React.ReactNode[] = [];
|
||
for (const seg of ALL_SEGMENTS) {
|
||
const on = lit.has(seg);
|
||
if (!on && ghostOpacity <= 0) continue;
|
||
const opacity = on ? 1 : ghostOpacity;
|
||
const poly = POLY[seg];
|
||
if (poly) {
|
||
parts.push(<polygon key={seg} points={poly} fill={color} opacity={opacity} />);
|
||
} else {
|
||
const [x1, y1, x2, y2] = DIAG[seg]!;
|
||
parts.push(
|
||
<line
|
||
key={seg}
|
||
x1={x1}
|
||
y1={y1}
|
||
x2={x2}
|
||
y2={y2}
|
||
stroke={color}
|
||
strokeWidth={1.5}
|
||
strokeLinecap="butt"
|
||
opacity={opacity}
|
||
/>,
|
||
);
|
||
}
|
||
}
|
||
cells.push(
|
||
<g key={key++} transform={`translate(${x} 0)`}>
|
||
{parts}
|
||
</g>,
|
||
);
|
||
x += W + GAP;
|
||
}
|
||
|
||
const contentW = Math.max(x - GAP, 0);
|
||
const scale = height / H;
|
||
const w = contentW * scale + padding * 2 + height * 0.15;
|
||
const h = height + padding * 2;
|
||
|
||
return (
|
||
<svg
|
||
width={w}
|
||
height={h}
|
||
viewBox={`0 0 ${w} ${h}`}
|
||
className={className}
|
||
style={style}
|
||
role="img"
|
||
aria-label={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}
|
||
>
|
||
{skew ? <g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g> : cells}
|
||
</g>
|
||
</svg>
|
||
);
|
||
};
|