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

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