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

99 lines
3.1 KiB
TypeScript

import { Component, createSignal, For, Show } from 'solid-js';
import { Slider } from './Slider';
import type { CurveName } from '../output/curves';
import styles from './SliderBank.module.css';
export interface SliderConfig {
/** Stable id used as React-style key. */
id: string;
label: string;
min: number;
max: number;
step?: number;
curve?: CurveName;
curveParam?: number;
unit?: string;
/** Optional section header to start a new collapsible group. */
section?: string;
}
export interface SliderBankProps {
sliders: ReadonlyArray<SliderConfig>;
values: () => ReadonlyArray<number>;
onChange: (id: string, v: number, index: number) => void;
/** Title rendered at the top of the bank (above all sections). */
title?: string;
/** Default-collapsed sections by name. */
collapsedSections?: ReadonlyArray<string>;
}
interface Section {
name: string;
items: Array<{ cfg: SliderConfig; index: number }>;
}
function group(configs: ReadonlyArray<SliderConfig>): Section[] {
const sections: Section[] = [];
let current: Section | null = null;
configs.forEach((cfg, index) => {
const sectionName = cfg.section ?? '';
if (!current || current.name !== sectionName) {
current = { name: sectionName, items: [] };
sections.push(current);
}
current.items.push({ cfg, index });
});
return sections;
}
export const SliderBank: Component<SliderBankProps> = (props) => {
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>(
Object.fromEntries((props.collapsedSections ?? []).map((s) => [s, true])),
);
const sections = () => group(props.sliders);
const toggle = (name: string) => {
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }));
};
return (
<div class={styles.bank}>
<Show when={props.title}>
<h3 class={styles.title}>{props.title}</h3>
</Show>
<For each={sections()}>{(section) => (
<section class={styles.section}>
<Show when={section.name}>
<button
type="button"
class={styles.sectionHeader}
onClick={() => toggle(section.name)}
aria-expanded={!collapsed()[section.name]}
>
<span class={styles.caret}>{collapsed()[section.name] ? '▸' : '▾'}</span>
<span class={styles.sectionName}>{section.name}</span>
<span class={styles.sectionCount}>{section.items.length}</span>
</button>
</Show>
<Show when={!collapsed()[section.name]}>
<div class={styles.list}>
<For each={section.items}>{({ cfg, index }) => (
<Slider
value={props.values()[index] ?? 0}
onChange={(v) => props.onChange(cfg.id, v, index)}
min={cfg.min}
max={cfg.max}
step={cfg.step}
curve={cfg.curve}
curveParam={cfg.curveParam}
label={cfg.label}
unit={cfg.unit}
/>
)}</For>
</div>
</Show>
</section>
)}</For>
</div>
);
};