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

109 lines
3.3 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, createSignal } from 'solid-js';
import styles from './XYPad.module.css';
export interface XYPadProps {
onMove: (x: number, y: number) => void;
position?: () => readonly [number, number];
disabled?: boolean;
/** Width and height in px. Default 240. */
size?: number;
/** Show internal grid lines. */
showGrid?: boolean;
ariaLabel?: string;
onRelease?: () => void;
onGrab?: () => void;
}
export const XYPad: Component<XYPadProps> = (props) => {
const size = () => props.size ?? 240;
const showGrid = () => props.showGrid ?? true;
const [internalPos, setInternalPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
const [dragging, setDragging] = createSignal(false);
let containerEl: HTMLDivElement | undefined;
const pos = () => props.position?.() ?? internalPos();
const updateFromEvent = (e: PointerEvent) => {
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height));
if (props.position === undefined) setInternalPos([x, y]);
props.onMove(x, y);
};
const onPointerDown = (e: PointerEvent) => {
if (props.disabled) return;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
setDragging(true);
props.onGrab?.();
updateFromEvent(e);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging()) return;
updateFromEvent(e);
};
const onPointerUp = (e: PointerEvent) => {
if (!dragging()) return;
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
setDragging(false);
props.onRelease?.();
};
const onKey = (e: KeyboardEvent) => {
if (props.disabled) return;
const step = e.shiftKey ? 0.05 : 0.01;
let [x, y] = pos();
let handled = true;
switch (e.key) {
case 'ArrowLeft': x -= step; break;
case 'ArrowRight': x += step; break;
case 'ArrowUp': y += step; break;
case 'ArrowDown': y -= step; break;
case 'Home': x = 0.5; y = 0.5; break;
default: handled = false;
}
if (handled) {
e.preventDefault();
x = Math.max(0, Math.min(1, x));
y = Math.max(0, Math.min(1, y));
if (props.position === undefined) setInternalPos([x, y]);
props.onMove(x, y);
}
};
const dotStyle = createMemo(() => {
const [x, y] = pos();
const px = x * size();
const py = (1 - y) * size();
return `transform: translate(${px}px, ${py}px) translate(-50%, -50%);`;
});
return (
<div
ref={containerEl}
class={styles.pad}
classList={{ [styles.disabled]: !!props.disabled, [styles.dragging]: dragging() }}
style={{ width: `${size()}px`, height: `${size()}px` }}
role="application"
tabIndex={props.disabled ? -1 : 0}
aria-label={props.ariaLabel ?? 'XY pad'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKey}
>
{showGrid() && (
<div class={styles.grid} aria-hidden="true">
<div class={styles.gridH} />
<div class={styles.gridV} />
</div>
)}
<div class={styles.dot} style={dotStyle()} aria-hidden="true" />
</div>
);
};