memlnaut-nisps/playground/src/primitives/ProgressRing.tsx
w1n5t0n c3fb63a113 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 15:38:55 +03:00

73 lines
2.1 KiB
TypeScript

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>
);
};