136 lines
4.1 KiB
TypeScript
136 lines
4.1 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type {
|
|
DigitalDisplayMode,
|
|
DigitalDisplayProps,
|
|
} from "./digital-display.types.js";
|
|
|
|
const DEFAULT_WIDTH_PX = 120;
|
|
const DEFAULT_HEIGHT_PX = 32;
|
|
const DEFAULT_SPEED = 30;
|
|
const DEFAULT_WINDOW = 8;
|
|
|
|
function formatNumeric(
|
|
value: number,
|
|
precision: number | undefined,
|
|
units: string | undefined
|
|
): string {
|
|
const p = precision ?? 0;
|
|
const body = Number.isFinite(value) ? value.toFixed(p) : "—";
|
|
return units !== undefined && units !== "" ? `${body} ${units}` : body;
|
|
}
|
|
|
|
function renderBody(
|
|
mode: DigitalDisplayMode,
|
|
value: number,
|
|
widthPx: number,
|
|
heightPx: number
|
|
): ReactNode {
|
|
if (mode.kind === "numeric") {
|
|
return (
|
|
<span data-mode="numeric" data-units={mode.units ?? ""}>
|
|
{formatNumeric(value, mode.precision, mode.units)}
|
|
</span>
|
|
);
|
|
}
|
|
if (mode.kind === "text") {
|
|
return <span data-mode="text">{mode.text}</span>;
|
|
}
|
|
const window = mode.windowChars ?? DEFAULT_WINDOW;
|
|
const speed = mode.speedPxPerSec ?? DEFAULT_SPEED;
|
|
const overflowing = mode.text.length > window;
|
|
if (!overflowing) {
|
|
return (
|
|
<span data-mode="scrolling" data-overflow="false">
|
|
{mode.text}
|
|
</span>
|
|
);
|
|
}
|
|
const pixelDistance = mode.text.length * (heightPx * 0.55);
|
|
const durationSec = pixelDistance / speed;
|
|
const animationName = "cs-display-scroll";
|
|
return (
|
|
<>
|
|
<style>{`
|
|
@keyframes ${animationName} {
|
|
from { transform: translateX(0); }
|
|
to { transform: translateX(-${pixelDistance}px); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
[data-cs-scroll="true"] { animation: none !important; }
|
|
}
|
|
`}</style>
|
|
<span
|
|
data-mode="scrolling"
|
|
data-overflow="true"
|
|
data-cs-scroll="true"
|
|
style={{
|
|
display: "inline-block",
|
|
whiteSpace: "nowrap",
|
|
animation: `${animationName} ${durationSec}s linear infinite`,
|
|
width: widthPx,
|
|
}}
|
|
>
|
|
{mode.text}
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read-only 7-segment / LCD-style display. Three modes:
|
|
* `numeric` (renders `value.toFixed(precision)` with optional units),
|
|
* `text` (static string), `scrolling` (overflow → CSS keyframe scroll
|
|
* with `prefers-reduced-motion` honoured).
|
|
*/
|
|
export function DigitalDisplay(props: DigitalDisplayProps): ReactNode {
|
|
const {
|
|
mode,
|
|
value,
|
|
widthPx = DEFAULT_WIDTH_PX,
|
|
heightPx = DEFAULT_HEIGHT_PX,
|
|
label,
|
|
ariaLabel,
|
|
disabled,
|
|
className,
|
|
} = props;
|
|
|
|
const ariaContent =
|
|
mode.kind === "numeric"
|
|
? formatNumeric(value, mode.precision, mode.units)
|
|
: mode.text;
|
|
|
|
return (
|
|
<output
|
|
className={className}
|
|
aria-live="polite"
|
|
aria-label={ariaLabel ?? label ?? "Display"}
|
|
aria-disabled={disabled ? "true" : "false"}
|
|
data-mode={mode.kind}
|
|
data-cs-display="true"
|
|
style={{
|
|
display: "inline-block",
|
|
width: widthPx,
|
|
height: heightPx,
|
|
overflow: "hidden",
|
|
whiteSpace: "nowrap",
|
|
fontFamily:
|
|
"var(--cs-display-font, ui-monospace, SFMono-Regular, monospace)",
|
|
color: "var(--cs-display-color, #7fff7f)",
|
|
background: "var(--cs-display-bg, #0a0a0a)",
|
|
padding: "0 8px",
|
|
boxSizing: "border-box",
|
|
lineHeight: `${heightPx}px`,
|
|
textAlign: "right",
|
|
}}
|
|
>
|
|
{renderBody(mode, value, widthPx, heightPx)}
|
|
<span
|
|
data-cs-display-sr
|
|
style={{ position: "absolute", left: "-9999px" }}
|
|
>
|
|
{ariaContent}
|
|
</span>
|
|
</output>
|
|
);
|
|
}
|