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.
This commit is contained in:
parent
d8b7b55505
commit
5b97091902
32 changed files with 1535 additions and 133 deletions
|
|
@ -1,4 +1,5 @@
|
|||
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,
|
||||
|
|
@ -7,38 +8,38 @@ import * as React from 'react';
|
|||
|
||||
const W = 10;
|
||||
const H = 18;
|
||||
const GAP = 3.4;
|
||||
const HALF = 1.05;
|
||||
const DEFAULT_GAP = 3.4;
|
||||
const BASE_HALF = 1.05;
|
||||
const INSET = 0.45;
|
||||
|
||||
const hSeg = (y: number, x1: number, x2: number): string => {
|
||||
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}`;
|
||||
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 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}`;
|
||||
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),
|
||||
};
|
||||
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]>> = {
|
||||
|
|
@ -85,6 +86,8 @@ const CHARS: Record<string, Seg14[]> = {
|
|||
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'],
|
||||
|
|
@ -96,17 +99,25 @@ const CHARS: Record<string, Seg14[]> = {
|
|||
' ': [],
|
||||
};
|
||||
|
||||
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
|
||||
const ALL_SEGMENTS = Object.keys({ ...buildPoly(BASE_HALF), ...DIAG }) as Seg14[];
|
||||
|
||||
export interface AlphaDisplayProps {
|
||||
/** Text: A–Z, 0–9, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
|
||||
/** Text: A–Z, 0–9, 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. */
|
||||
|
|
@ -116,6 +127,8 @@ export interface AlphaDisplayProps {
|
|||
/** 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;
|
||||
}
|
||||
|
|
@ -129,20 +142,29 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
chars,
|
||||
align = 'left',
|
||||
height = 22,
|
||||
color = '#ffb84d',
|
||||
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;
|
||||
const cellCount = text.replace(/[.]/g, '').length;
|
||||
if (cellCount < chars) {
|
||||
const pad = ' '.repeat(chars - cellCount);
|
||||
text = align === 'right' ? pad + text : text + pad;
|
||||
|
|
@ -164,19 +186,43 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
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} />,
|
||||
<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;
|
||||
}
|
||||
const lit = new Set(CHARS[ch] ?? []);
|
||||
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 on = lit.has(seg);
|
||||
if (!on && ghostOpacity <= 0) continue;
|
||||
const opacity = on ? 1 : ghostOpacity;
|
||||
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={color} opacity={opacity} />);
|
||||
parts.push(<polygon key={seg} points={poly} fill={lit} opacity={opacity} />);
|
||||
} else {
|
||||
const [x1, y1, x2, y2] = DIAG[seg]!;
|
||||
parts.push(
|
||||
|
|
@ -186,8 +232,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
stroke={lit}
|
||||
strokeWidth={diagWidth}
|
||||
strokeLinecap="butt"
|
||||
opacity={opacity}
|
||||
/>,
|
||||
|
|
@ -199,10 +245,10 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
{parts}
|
||||
</g>,
|
||||
);
|
||||
x += W + GAP;
|
||||
x += W + gap;
|
||||
}
|
||||
|
||||
const contentW = Math.max(x - GAP, 0);
|
||||
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;
|
||||
|
|
@ -213,7 +259,8 @@ export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
|
|||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={className}
|
||||
style={style}
|
||||
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
role="img"
|
||||
aria-label={value}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,33 @@
|
|||
import * as React from 'react';
|
||||
import { formatForDisplay, renderSegmentText } from './segments';
|
||||
import { useKnobTheme } from '../core/theme';
|
||||
import { DisplayZone, formatForDisplay, renderSegmentText, zoneColorForValue } from './segments';
|
||||
|
||||
export interface SegmentDisplayProps {
|
||||
value: number | string;
|
||||
/** Number of digit cells (decimal points don't count). Default: 4. */
|
||||
/** Number of digit cells (points/colons don't count). Default: 4. */
|
||||
digits?: number;
|
||||
decimals?: number;
|
||||
/** Digit height in px. Default: 28. */
|
||||
/** Digit height in px. Default: 28. Below ~11px, bump `weight` to stay legible. */
|
||||
height?: number;
|
||||
/** Lit color. Defaults to the theme's `ledGreen`. `valueColor`/`zones` win over it. */
|
||||
color?: string;
|
||||
/**
|
||||
* Color the readout from its numeric value — e.g. green/amber/red by load.
|
||||
* Takes precedence over `color`/`zones`. Ignored for string values.
|
||||
*/
|
||||
valueColor?: (value: number) => string;
|
||||
/**
|
||||
* Value-space color zones (thresholds are values, not normalized), e.g.
|
||||
* `[{upTo: 75, color: green}, {upTo: 90, color: amber}, {upTo: Infinity, color: red}]`.
|
||||
*/
|
||||
zones?: readonly DisplayZone[];
|
||||
/**
|
||||
* Segment thickness multiplier — thickens strokes so the display holds up at
|
||||
* small heights. Default: 1.
|
||||
*/
|
||||
weight?: number;
|
||||
/** Inter-cell spacing. Default: 3.2 — raise it to space crowded readouts. */
|
||||
gap?: number;
|
||||
/** Panel background. Set to 'none' to disable. */
|
||||
background?: string;
|
||||
/** Opacity of unlit segments. Default: 0.09. */
|
||||
|
|
@ -18,6 +37,8 @@ export interface SegmentDisplayProps {
|
|||
/** LED glow strength (0 disables). Default: 1.6. */
|
||||
glow?: number;
|
||||
padding?: number;
|
||||
/** Dim the display uniformly (matches disabled controls). */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
|
@ -31,22 +52,37 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
|||
digits = 4,
|
||||
decimals = 0,
|
||||
height = 28,
|
||||
color = '#3df2ad',
|
||||
color,
|
||||
valueColor,
|
||||
zones,
|
||||
weight = 1,
|
||||
gap,
|
||||
background = '#0a0d0c',
|
||||
ghostOpacity = 0.09,
|
||||
skew = 6,
|
||||
glow = 1.6,
|
||||
padding = 6,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const theme = useKnobTheme();
|
||||
const filterId = React.useId();
|
||||
const numeric = typeof value === 'number' ? value : NaN;
|
||||
const resolved =
|
||||
(valueColor && !Number.isNaN(numeric) ? valueColor(numeric) : undefined) ??
|
||||
(zones && !Number.isNaN(numeric) ? zoneColorForValue(zones, numeric) : undefined) ??
|
||||
color ??
|
||||
theme.ledGreen;
|
||||
|
||||
const text =
|
||||
typeof value === 'number' ? formatForDisplay(value, digits, decimals) : value;
|
||||
const { nodes, width, height: cellH } = renderSegmentText(text, {
|
||||
color,
|
||||
color: resolved,
|
||||
ghostOpacity,
|
||||
skew,
|
||||
weight,
|
||||
gap,
|
||||
});
|
||||
|
||||
const scale = height / cellH;
|
||||
|
|
@ -59,7 +95,8 @@ export const SegmentDisplay: React.FC<SegmentDisplayProps> = ({
|
|||
height={h}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={className}
|
||||
style={style}
|
||||
style={{ ...(disabled ? { opacity: 0.45 } : undefined), ...style }}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
role="img"
|
||||
aria-label={typeof value === 'number' ? String(value) : value}
|
||||
>
|
||||
|
|
|
|||
46
packages/dreamknob/src/digital/segments.test.ts
Normal file
46
packages/dreamknob/src/digital/segments.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { formatForDisplay, zoneColorForValue } from './segments';
|
||||
|
||||
describe('formatForDisplay', () => {
|
||||
it('right-aligns within the digit width', () => {
|
||||
// 2 digits (3,5) fill a 2-cell width — the point is extra, no leading pad.
|
||||
expect(formatForDisplay(3.5, 2, 1)).toBe('3.5');
|
||||
expect(formatForDisplay(42, 3, 0)).toBe(' 42');
|
||||
});
|
||||
|
||||
it('overflows to dashes when it does not fit', () => {
|
||||
expect(formatForDisplay(12345, 3, 0)).toBe('---');
|
||||
});
|
||||
|
||||
it('does not count the decimal point toward the digit budget', () => {
|
||||
// 3 digits (1,2,3) + a point still fits digits=3.
|
||||
expect(formatForDisplay(12.3, 3, 1)).toBe('12.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoneColorForValue', () => {
|
||||
const zones = [
|
||||
{ upTo: 74, color: 'green' },
|
||||
{ upTo: 89, color: 'amber' },
|
||||
{ upTo: Infinity, color: 'red' },
|
||||
];
|
||||
|
||||
it('picks the zone whose upper bound the value falls under', () => {
|
||||
expect(zoneColorForValue(zones, 40)).toBe('green');
|
||||
expect(zoneColorForValue(zones, 80)).toBe('amber');
|
||||
expect(zoneColorForValue(zones, 99)).toBe('red');
|
||||
});
|
||||
|
||||
it('is inclusive at the boundary', () => {
|
||||
expect(zoneColorForValue(zones, 74)).toBe('green');
|
||||
expect(zoneColorForValue(zones, 89)).toBe('amber');
|
||||
});
|
||||
|
||||
it('sorts unordered zones before resolving', () => {
|
||||
const unordered = [
|
||||
{ upTo: Infinity, color: 'red' },
|
||||
{ upTo: 74, color: 'green' },
|
||||
];
|
||||
expect(zoneColorForValue(unordered, 10)).toBe('green');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,9 @@ import * as React from 'react';
|
|||
|
||||
const DIGIT_W = 10;
|
||||
const DIGIT_H = 18;
|
||||
const GAP = 3.2; // spacing between digit cells (leaves room for decimal points)
|
||||
const DEFAULT_GAP = 3.2; // spacing between digit cells (leaves room for points)
|
||||
const BASE_HALF = 1.1; // half segment thickness at weight 1
|
||||
const INSET = 0.45; // gap between adjacent segments
|
||||
|
||||
type SegmentKey = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g';
|
||||
|
||||
|
|
@ -57,30 +59,30 @@ const CHAR_SEGMENTS: Record<string, SegmentKey[]> = {
|
|||
const segmentsFor = (ch: string): SegmentKey[] =>
|
||||
CHAR_SEGMENTS[ch] ?? CHAR_SEGMENTS[ch.toUpperCase()] ?? CHAR_SEGMENTS[ch.toLowerCase()] ?? [];
|
||||
|
||||
const H_HALF = 1.1; // half thickness
|
||||
const INSET = 0.45; // gap between adjacent segments
|
||||
|
||||
const hSegment = (y: number): string => {
|
||||
const hSegment = (y: number, half: 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}`;
|
||||
return `${x1},${y} ${x1 + half},${y - half} ${x2 - half},${y - half} ${x2},${y} ${x2 - half},${y + half} ${x1 + half},${y + half}`;
|
||||
};
|
||||
|
||||
const vSegment = (x: number, y1: number, y2: number): string => {
|
||||
const vSegment = (x: number, y1: number, y2: number, half: 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}`;
|
||||
return `${x},${a} ${x + half},${a + half} ${x + half},${b - half} ${x},${b} ${x - half},${b - half} ${x - half},${a + 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),
|
||||
};
|
||||
/** Build the seven segment polygons for a given half-thickness (weight). */
|
||||
const buildSegments = (half: number): Record<SegmentKey, string> => ({
|
||||
a: hSegment(1, half),
|
||||
g: hSegment(DIGIT_H / 2, half),
|
||||
d: hSegment(DIGIT_H - 1, half),
|
||||
f: vSegment(1, 1, DIGIT_H / 2, half),
|
||||
b: vSegment(DIGIT_W - 1, 1, DIGIT_H / 2, half),
|
||||
e: vSegment(1, DIGIT_H / 2, DIGIT_H - 1, half),
|
||||
c: vSegment(DIGIT_W - 1, DIGIT_H / 2, DIGIT_H - 1, half),
|
||||
});
|
||||
|
||||
const SEGMENT_ORDER: SegmentKey[] = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
|
||||
|
||||
export interface SegmentRenderOptions {
|
||||
color: string;
|
||||
|
|
@ -88,40 +90,69 @@ export interface SegmentRenderOptions {
|
|||
ghostOpacity: number;
|
||||
/** Negative skew for the classic italic LCD look, in degrees. */
|
||||
skew: number;
|
||||
/**
|
||||
* Segment thickness multiplier — thickens the strokes so a display holds
|
||||
* up at small heights (like a font weight). Default: 1.
|
||||
*/
|
||||
weight?: number;
|
||||
/** Inter-cell spacing override. Default: 3.2. */
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `text` (digits, '-', '.', ' ') as seven-segment polygons in local
|
||||
* coordinates (digit height 18). Returns the nodes plus the total width.
|
||||
* Render `text` (digits, '-', '.', ':', ' ') as seven-segment polygons in
|
||||
* local coordinates (digit height 18). The decimal point and colon are drawn
|
||||
* as bold cells with their own width so they hold up at small sizes rather
|
||||
* than vanishing between digits. Returns the nodes plus the total width.
|
||||
*/
|
||||
export const renderSegmentText = (
|
||||
text: string,
|
||||
{ color, ghostOpacity, skew }: SegmentRenderOptions,
|
||||
{ color, ghostOpacity, skew, weight = 1, gap = DEFAULT_GAP }: SegmentRenderOptions,
|
||||
): { nodes: React.ReactNode; width: number; height: number } => {
|
||||
const half = BASE_HALF * weight;
|
||||
const segPoints = buildSegments(half);
|
||||
const cells: React.ReactNode[] = [];
|
||||
let x = 0;
|
||||
let key = 0;
|
||||
|
||||
// Bold point: a rounded cell, not a hairline. Sized to the segment weight.
|
||||
const dot = half * 1.35;
|
||||
|
||||
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} />,
|
||||
<rect
|
||||
key={key++}
|
||||
x={x - dot}
|
||||
y={DIGIT_H - dot * 2}
|
||||
width={dot * 2}
|
||||
height={dot * 2}
|
||||
rx={dot * 0.5}
|
||||
fill={color}
|
||||
/>,
|
||||
);
|
||||
// A little breathing room so the following digit doesn't crowd the point.
|
||||
x += dot * 2 + Math.min(gap * 0.6, 2.4);
|
||||
continue;
|
||||
}
|
||||
if (ch === ':') {
|
||||
const cx = x + dot;
|
||||
cells.push(
|
||||
<g key={key++}>
|
||||
<rect x={cx - dot} y={DIGIT_H * 0.3 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
|
||||
<rect x={cx - dot} y={DIGIT_H * 0.72 - dot} width={dot * 2} height={dot * 2} rx={dot * 0.5} fill={color} />
|
||||
</g>,
|
||||
);
|
||||
x += dot * 2 + gap;
|
||||
continue;
|
||||
}
|
||||
const lit = new Set(segmentsFor(ch));
|
||||
const digit: React.ReactNode[] = [];
|
||||
(Object.keys(SEGMENT_POINTS) as SegmentKey[]).forEach(seg => {
|
||||
SEGMENT_ORDER.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}
|
||||
/>,
|
||||
<polygon key={seg} points={segPoints[seg]} fill={color} opacity={on ? 1 : ghostOpacity} />,
|
||||
);
|
||||
});
|
||||
cells.push(
|
||||
|
|
@ -129,18 +160,26 @@ export const renderSegmentText = (
|
|||
{digit}
|
||||
</g>,
|
||||
);
|
||||
x += DIGIT_W + GAP;
|
||||
x += DIGIT_W + gap;
|
||||
}
|
||||
|
||||
const width = Math.max(x - GAP, 0);
|
||||
const nodes = skew ? (
|
||||
<g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g>
|
||||
) : (
|
||||
<>{cells}</>
|
||||
);
|
||||
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 };
|
||||
};
|
||||
|
||||
export interface DisplayZone {
|
||||
/** Upper bound (inclusive) of this zone in value space. */
|
||||
upTo: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Resolve a value to its zone color (value-space thresholds, not normalized). */
|
||||
export const zoneColorForValue = (zones: readonly DisplayZone[], value: number): string => {
|
||||
const sorted = [...zones].sort((a, b) => a.upTo - b.upTo);
|
||||
return (sorted.find(z => value <= z.upTo + 1e-9) ?? sorted[sorted.length - 1]).color;
|
||||
};
|
||||
|
||||
/** Format a number for a fixed-width display, e.g. (3.5, 2, 1) -> " 3.5". */
|
||||
export const formatForDisplay = (
|
||||
value: number,
|
||||
|
|
@ -148,7 +187,7 @@ export const formatForDisplay = (
|
|||
decimals: number,
|
||||
): string => {
|
||||
let text = value.toFixed(decimals);
|
||||
const cellCount = (s: string) => s.replace(/\./g, '').length;
|
||||
const cellCount = (s: string) => s.replace(/[.:]/g, '').length;
|
||||
// Doesn't fit: dashes, the hardware overflow convention. All-8s would read
|
||||
// as a legitimate value.
|
||||
if (cellCount(text) > digits) text = ''.padStart(digits, '-');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue