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

106 lines
3.3 KiB
TypeScript

import { Component } from 'solid-js';
import { Slider } from './Slider';
import { PillToggle } from './PillToggle';
import { CURVE_NAMES, type CurveName } from '../output/curves';
import styles from './ParamEditor.module.css';
/**
* A single parameter definition (subset of mode schema's param entry,
* shared between modes for the editor).
*/
export interface ParamDef {
name: string;
label: string;
/** Hard min/max for the parameter (used as override clamp limits). */
hardMin: number;
hardMax: number;
/** Authoring default. */
default: number;
/** Default curve from schema (overrides may change it). */
curve: CurveName;
group?: string;
}
/** Override applied between MLP output and parameter destination. */
export interface ParamOverride {
min: number;
max: number;
curve: CurveName;
curveParam?: number;
muted: boolean;
pinned: boolean;
fixedValue: number;
}
export interface ParamEditorProps {
param: ParamDef;
override: () => ParamOverride;
onChange: (next: ParamOverride) => void;
/** Compact rendering for inline use. */
compact?: boolean;
}
export const ParamEditor: Component<ParamEditorProps> = (props) => {
const update = (patch: Partial<ParamOverride>) => {
props.onChange({ ...props.override(), ...patch });
};
return (
<div class={styles.editor} classList={{ [styles.compact]: !!props.compact }}>
<header class={styles.header}>
<strong class={styles.name}>{props.param.label}</strong>
<span class={styles.id}>{props.param.name}</span>
</header>
<div class={styles.toggles}>
<PillToggle
options={[
{ value: 'live', label: 'Live' },
{ value: 'muted', label: 'Mute' },
{ value: 'pinned', label: 'Pin' },
]}
value={() => props.override().pinned ? 'pinned' : props.override().muted ? 'muted' : 'live'}
onChange={(v) => {
if (v === 'live') update({ muted: false, pinned: false });
else if (v === 'muted') update({ muted: true, pinned: false });
else update({ muted: false, pinned: true });
}}
ariaLabel="param mode"
/>
</div>
<div class={styles.fields}>
<Slider
label="min"
min={props.param.hardMin}
max={props.param.hardMax}
value={props.override().min}
onChange={(v) => update({ min: Math.min(v, props.override().max) })}
/>
<Slider
label="max"
min={props.param.hardMin}
max={props.param.hardMax}
value={props.override().max}
onChange={(v) => update({ max: Math.max(v, props.override().min) })}
/>
<label class={styles.curveRow}>
<span class={styles.fieldLabel}>curve</span>
<select
class={styles.select}
value={props.override().curve}
onChange={(e) => update({ curve: e.currentTarget.value as CurveName })}
>
{CURVE_NAMES.map((c) => <option value={c}>{c}</option>)}
</select>
</label>
<Slider
label="fixed value"
min={props.param.hardMin}
max={props.param.hardMax}
value={props.override().fixedValue}
onChange={(v) => update({ fixedValue: v })}
disabled={!props.override().muted}
/>
</div>
</div>
);
};