DreamKnob/packages/dreamknob/src/digital/AlphaDisplay.tsx
Dreamodus e9e52e43ed 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
2026-07-12 17:47:20 -07:00

240 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};