memlnaut-nisps/playground/src/primitives/XYPad.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

108 lines
3.3 KiB
TypeScript

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