memlnaut-nisps/playground/src/primitives/ProgressRing.tsx

74 lines
2.1 KiB
TypeScript
Raw Normal View History

feat(playground): UI primitive library + /dev/primitives showcase Sixteen primitives at src/primitives/, each with its own .module.css and a .demo.tsx that wires the primitive to live local state. All primitives are typed (strict), pointer-event based (touch + mouse), keyboard-accessible where it makes sense, and free of business logic. Primitives: - Slider, SliderBank: value sliders with optional curve mapping; collapsible sections. - VirtualJoystick, XYPad: 2D pointer input, returning [0,1]^2. - Heatmap: NxN canvas rendering with three color modes (luminance/variance/divergence) and a configurable palette. - OutputDisplay: bar chart of N output values. - TrainingControls: thumbs-up/down/undo + train/randomise + status. - Drawer: portal'd slide-in panel from left or right; ESC + scrim close. - ControlAxis: compound-axis slider (Boldness/Memory/Precision shape) with active-preset hint, endpoint labels, double-tap-to-relink. - ProgressRing: SVG circular progress with optional inline label. - PillToggle: segmented radio control. - ParamEditor: min/max/curve/mute/pin/fixedValue editor for a single param. - JoyMap: zoom minimap with adaptive grid + zoom window, vanishing trail with Catmull-Rom spline + tap-to-return, dual concentric noise rings, region pin overlays, frozen overlay. - WeightHealth: 10-bin histogram + dead/saturating/healthy status glow. - GradientFlow: per-layer bar chart with vanishing/exploding/converged color coding. - LossPlot: log-scale line chart of training loss history. App.tsx now wires the /dev/primitives route to a lazy-loaded PrimitivesShowcase that renders all sixteen demos in a responsive grid. Lazy import keeps the home page bundle ~20 kB while the showcase ships its own ~42 kB chunk. Stream 8 of the rewrite (meml-911).
2026-04-29 14:38:55 +02:00
import { Component, createMemo } from 'solid-js';
export interface ProgressRingProps {
/** 0..1 fraction. */
progress: () => number;
/** Diameter in px. Default 32. */
size?: number;
/** Stroke color. Default --accent. */
color?: string;
/** Stroke width. Default size/10. */
strokeWidth?: number;
/** Show numeric percent in middle. */
showLabel?: boolean;
ariaLabel?: string;
}
export const ProgressRing: Component<ProgressRingProps> = (props) => {
const size = () => props.size ?? 32;
const strokeWidth = () => props.strokeWidth ?? Math.max(2, Math.round(size() / 10));
const radius = () => (size() - strokeWidth()) / 2;
const circumference = () => 2 * Math.PI * radius();
const progress = () => Math.max(0, Math.min(1, props.progress()));
const offset = createMemo(() => circumference() * (1 - progress()));
const color = () => props.color ?? 'var(--accent)';
return (
<svg
width={size()}
height={size()}
viewBox={`0 0 ${size()} ${size()}`}
role="progressbar"
aria-valuenow={Math.round(progress() * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={props.ariaLabel ?? 'progress'}
>
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke="var(--bg-3)"
stroke-width={strokeWidth()}
fill="none"
/>
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke={color()}
stroke-width={strokeWidth()}
fill="none"
stroke-dasharray={`${circumference()}`}
stroke-dashoffset={`${offset()}`}
stroke-linecap="round"
transform={`rotate(-90 ${size() / 2} ${size() / 2})`}
style={{ transition: 'stroke-dashoffset 120ms linear' }}
/>
{props.showLabel && (
<text
x={size() / 2}
y={size() / 2}
dominant-baseline="central"
text-anchor="middle"
font-family="var(--font-mono)"
font-size={`${Math.round(size() / 3.5)}`}
fill="var(--fg-mute)"
>
{Math.round(progress() * 100)}
</text>
)}
</svg>
);
};