Initial release: dreamknob library + showcase

React + TypeScript knob/fader library with a headless interaction core
(rotary, relative-drag and track modes, wheel, keyboard, a11y), SVG
primitives, nine prebuilt skins, seven-segment display engine, and a
Vite showcase with gallery, live playground and API docs.
This commit is contained in:
Dreamodus 2026-07-12 14:26:52 -07:00
commit 60a02cb1db
46 changed files with 6186 additions and 0 deletions

View file

@ -0,0 +1,88 @@
import * as React from 'react';
import { formatForDisplay, renderSegmentText } from './segments';
export interface SegmentDisplayProps {
value: number | string;
/** Number of digit cells (decimal points don't count). Default: 4. */
digits?: number;
decimals?: number;
/** Digit height in px. Default: 28. */
height?: number;
color?: string;
/** Panel background. Set to 'none' to disable. */
background?: string;
/** Opacity of unlit segments. Default: 0.09. */
ghostOpacity?: number;
/** Italic skew in degrees. Default: 6. */
skew?: number;
/** LED glow strength (0 disables). Default: 1.6. */
glow?: number;
padding?: number;
className?: string;
style?: React.CSSProperties;
}
/**
* A standalone seven-segment LED/LCD numeric display for readouts, meters
* and digital panels.
*/
export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
value,
digits = 4,
decimals = 0,
height = 28,
color = '#3df2ad',
background = '#0a0d0c',
ghostOpacity = 0.09,
skew = 6,
glow = 1.6,
padding = 6,
className,
style,
}) => {
const filterId = React.useId();
const text =
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
const { nodes, width, height: cellH } = renderSegmentText(text, {
color,
ghostOpacity,
skew,
});
const scale = height / cellH;
const w = width * scale + padding * 2 + height * 0.15; // skew headroom
const h = height + padding * 2;
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
className={className}
style={style}
role="img"
aria-label={typeof value === 'number' ? String(value) : 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}
>
{nodes}
</g>
</svg>
);
};

View file

@ -0,0 +1,122 @@
import * as React from 'react';
// Seven-segment geometry in a local 10 x 18 digit cell.
// Segments: a top, b top-right, c bottom-right, d bottom, e bottom-left,
// f top-left, g middle.
const DIGIT_W = 10;
const DIGIT_H = 18;
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
'1': ['b', 'c'],
'2': ['a', 'b', 'g', 'e', 'd'],
'3': ['a', 'b', 'g', 'c', 'd'],
'4': ['f', 'g', 'b', 'c'],
'5': ['a', 'f', 'g', 'c', 'd'],
'6': ['a', 'f', 'g', 'e', 'c', 'd'],
'7': ['a', 'b', 'c'],
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
'9': ['a', 'b', 'c', 'd', 'f', 'g'],
'-': ['g'],
' ': [],
};
const H_HALF = 1.1; // half thickness
const INSET = 0.45; // gap between adjacent segments
const hSegment = (y: number): string => {
const x1 = 1 + INSET;
const x2 = DIGIT_W - 1 - INSET;
return `${x1},${y} ${x1 + H_HALF},${y - H_HALF} ${x2 - H_HALF},${y - H_HALF} ${x2},${y} ${x2 - H_HALF},${y + H_HALF} ${x1 + H_HALF},${y + H_HALF}`;
};
const vSegment = (x: number, y1: number, y2: number): string => {
const a = y1 + INSET;
const b = y2 - INSET;
return `${x},${a} ${x + H_HALF},${a + H_HALF} ${x + H_HALF},${b - H_HALF} ${x},${b} ${x - H_HALF},${b - H_HALF} ${x - H_HALF},${a + H_HALF}`;
};
const SEGMENT_POINTS: Record<SegmentKey, string> = {
a: hSegment(1),
g: hSegment(DIGIT_H / 2),
d: hSegment(DIGIT_H - 1),
f: vSegment(1, 1, DIGIT_H / 2),
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2),
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1),
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1),
};
export interface SegmentRenderOptions {
color: string;
/** Opacity of unlit "ghost" segments. 0 disables them. */
ghostOpacity: number;
/** Negative skew for the classic italic LCD look, in degrees. */
skew: number;
}
/**
* Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local
* coordinates (digit height 18). Returns the nodes plus the total width.
*/
export const renderSegmentText = (
text: string,
{ color, ghostOpacity, skew }: SegmentRenderOptions,
): { nodes: React.ReactNode; width: number; height: number } => {
const cells: React.ReactNode[] = [];
let x = 0;
let key = 0;
for (const ch of text) {
if (ch === '.') {
// Decimal point sits in the gap after the previous digit.
cells.push(
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={DIGIT_H - 1.2} r={1.2} fill={color} />,
);
continue;
}
const lit = new Set(CHAR_SEGMENTS[ch] ?? []);
const digit: React.ReactNode[] = [];
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
const on = lit.has(seg);
if (!on && ghostOpacity <= 0) return;
digit.push(
<polygon
key={seg}
points={SEGMENT_POINTS[seg]}
fill={color}
opacity={on ? 1 : ghostOpacity}
/>,
);
});
cells.push(
<g key={key++} transform={`translate(${x} 0)`}>
{digit}
</g>,
);
x += DIGIT_W + GAP;
}
const width = Math.max(x - GAP, 0);
const nodes = skew ? (
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
) : (
<>{cells}</>
);
return { nodes, width, height: DIGIT_H };
};
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
export const formatForDisplay = (
value: number,
digits: number,
decimals: number,
): string => {
let text = value.toFixed(decimals);
const cellCount = (s: string) => s.replace(/\./g, '').length;
if (cellCount(text) > digits) text = ''.padStart(digits, '8'); // overflow
return text.padStart(digits + (text.includes('.') ? 1 : 0), ' ');
};