Tier 3: bubbles, animation, wrap mode, XYPad, AlphaDisplay, PushButton

- 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
This commit is contained in:
Dreamodus 2026-07-12 17:47:20 -07:00
parent 0aee48b749
commit e9e52e43ed
12 changed files with 936 additions and 8 deletions

View file

@ -0,0 +1,240 @@
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: AZ, 09, 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>
);
};