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:
commit
60a02cb1db
46 changed files with 6186 additions and 0 deletions
88
packages/dreamknob/src/digital/SegmentDisplay.tsx
Normal file
88
packages/dreamknob/src/digital/SegmentDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
122
packages/dreamknob/src/digital/segments.tsx
Normal file
122
packages/dreamknob/src/digital/segments.tsx
Normal 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), ' ');
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue