DreamKnob/packages/dreamknob/src/digital/AlphaDisplay.tsx
Dreamodus 5b97091902 v1.1.1: second integration-feedback release
Readout legibility (the #1 ask):
- bold own-cell decimal point + colon in seven/fourteen-seg (not hairlines)
- weight prop thickens strokes; gap controls inter-cell kerning

Display colors:
- SegmentDisplay defaults to theme.ledGreen, AlphaDisplay to theme.ledAmber
- valueColor fn + value-space zones on SegmentDisplay (self-coloring readouts)
- charset: colon in both, $ and % in 14-seg; disabled + data-disabled

Knob captions: sublabel (second line) + labelSize (independent of dial size).

New components:
- ToggleSwitch (rocker/slider boolean, role=switch)
- Gauge (read-only radial arc meter, round sibling of Meter)
- TransportButton (play/stop/record/pause/panic, record blinks)
- LampRow (console-print lamp bank), LabeledField, Rack (panel chrome)

Responsiveness: fill on Meter + Fader (stretch to container).
Niceties: PushButton leadingIcon; theme ledGreen/ledAmber/panel tokens.

54 -> 63 exports. 38 unit tests. Browser-verified.
2026-07-13 19:20:23 -07:00

287 lines
8.6 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';
import { useKnobTheme } from '../core/theme';
// 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 DEFAULT_GAP = 3.4;
const BASE_HALF = 1.05;
const INSET = 0.45;
const hSeg = (y: number, x1: number, x2: number, half: 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, half: 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 buildPoly = (half: number): Partial<Record<Seg14, string>> => ({
a: hSeg(1, 1, 9, half),
d: hSeg(17, 1, 9, half),
g1: hSeg(9, 1, 5, half),
g2: hSeg(9, 5, 9, half),
f: vSeg(1, 1, 9, half),
b: vSeg(9, 1, 9, half),
e: vSeg(1, 9, 17, half),
c: vSeg(9, 9, 17, half),
i: vSeg(5, 1, 9, half),
l: vSeg(5, 9, 17, half),
});
/** 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'],
// '$' is an S with the center verticals lit through it.
$: ['a', 'c', 'd', 'f', 'g1', 'g2', 'i', 'l'],
'-': ['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({ ...buildPoly(BASE_HALF), ...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;
/** Lit color. Defaults to the theme's `ledAmber`. */
color?: string;
/**
* Segment thickness multiplier — thickens strokes so the display holds up at
* small heights. Default: 1.
*/
weight?: number;
/** Inter-cell spacing. Default: 3.4. */
gap?: number;
/** 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;
/** Dim the display uniformly (matches disabled controls). */
disabled?: boolean;
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,
weight = 1,
gap = DEFAULT_GAP,
background = '#0a0d0c',
ghostOpacity = 0.08,
skew = 6,
glow = 1.4,
padding = 6,
disabled = false,
className,
style,
}) => {
const theme = useKnobTheme();
const lit = color ?? theme.ledAmber;
const filterId = React.useId();
const half = BASE_HALF * weight;
const POLY = buildPoly(half);
const dot = half * 1.35;
const diagWidth = 1.5 * weight;
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(
<rect key={key++} x={x - dot} y={H - dot * 2} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />,
);
x += dot * 2 + Math.min(gap * 0.6, 2.6);
continue;
}
if (ch === ':') {
const cx = x + dot;
cells.push(
<g key={key++}>
<rect x={cx - dot} y={H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
<rect x={cx - dot} y={H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={lit} />
</g>,
);
x += dot * 2 + gap;
continue;
}
if (ch === '%') {
// Slash with a dot at each end — the percent idiom.
cells.push(
<g key={key++} transform={`translate(${x} 0)`}>
<line x1={2} y1={16} x2={8} y2={2} stroke={lit} strokeWidth={diagWidth} strokeLinecap="butt" />
<circle cx={2.8} cy={4} r={dot * 1.05} fill={lit} />
<circle cx={7.2} cy={14} r={dot * 1.05} fill={lit} />
</g>,
);
x += W + gap;
continue;
}
const on = new Set(CHARS[ch] ?? []);
const parts: React.ReactNode[] = [];
for (const seg of ALL_SEGMENTS) {
const isOn = on.has(seg);
if (!isOn && ghostOpacity <= 0) continue;
const opacity = isOn ? 1 : ghostOpacity;
const poly = POLY[seg];
if (poly) {
parts.push(<polygon key={seg} points={poly} fill={lit} opacity={opacity} />);
} else {
const [x1, y1, x2, y2] = DIAG[seg]!;
parts.push(
<line
key={seg}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={lit}
strokeWidth={diagWidth}
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={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
data-disabled={disabled ? '' : undefined}
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>
);
};