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).
This commit is contained in:
parent
add05ea554
commit
c3fb63a113
51 changed files with 3016 additions and 3 deletions
|
|
@ -1,13 +1,18 @@
|
|||
import { Component, createSignal, onCleanup, Show } from 'solid-js';
|
||||
import { Component, createSignal, lazy, onCleanup, Show } from 'solid-js';
|
||||
import styles from './App.module.css';
|
||||
|
||||
type Route = 'home' | 'unknown';
|
||||
type Route = 'home' | 'primitives' | 'unknown';
|
||||
|
||||
function parseRoute(path: string): Route {
|
||||
if (path === '' || path === '/' || path === '/index.html') return 'home';
|
||||
if (path === '/dev/primitives' || path === '/dev/primitives/') return 'primitives';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Lazy-loaded so the home route doesn't pay the cost of loading every
|
||||
// primitive demo. Stream 9/10 can grow the showcase freely.
|
||||
const PrimitivesShowcase = lazy(() => import('./dev/PrimitivesShowcase'));
|
||||
|
||||
const App: Component = () => {
|
||||
const [route, setRoute] = createSignal<Route>(parseRoute(window.location.pathname));
|
||||
|
||||
|
|
@ -34,12 +39,22 @@ const App: Component = () => {
|
|||
>
|
||||
home
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={route() === 'primitives' ? styles.active : ''}
|
||||
onClick={() => navigate('/dev/primitives')}
|
||||
>
|
||||
/dev/primitives
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main class={styles.main}>
|
||||
<Show when={route() === 'home'}>
|
||||
<Home />
|
||||
</Show>
|
||||
<Show when={route() === 'primitives'}>
|
||||
<PrimitivesShowcase />
|
||||
</Show>
|
||||
<Show when={route() === 'unknown'}>
|
||||
<div class={styles.notFound}>
|
||||
<h1>404</h1>
|
||||
|
|
@ -61,8 +76,11 @@ const Home: Component = () => {
|
|||
<p class={styles.tagline}>
|
||||
Interactive ML control of audio. SolidJS scaffold — modes coming online in stream 9.
|
||||
</p>
|
||||
<ul class={styles.linkList}>
|
||||
<li><a href="/dev/primitives" onClick={(e) => { e.preventDefault(); window.history.pushState({}, '', '/dev/primitives'); window.dispatchEvent(new PopStateEvent('popstate')); }}>Primitives showcase</a> — UI building blocks</li>
|
||||
</ul>
|
||||
<p class={styles.note}>
|
||||
This is a fresh scaffold. Primitives, stores, ML, WASM, and audio engines are added in subsequent commits.
|
||||
This is a fresh scaffold. ML, WASM, and audio engines are not yet wired up.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
70
playground/src/dev/PrimitivesShowcase.module.css
Normal file
70
playground/src/dev/PrimitivesShowcase.module.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
.root {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--fg-mute);
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-3);
|
||||
padding: var(--sp-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
80
playground/src/dev/PrimitivesShowcase.tsx
Normal file
80
playground/src/dev/PrimitivesShowcase.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Primitives showcase — Storybook-equivalent at /dev/primitives.
|
||||
*
|
||||
* Each section wraps one primitive's demo. Demos use realistic interactive
|
||||
* state so a quick visual scan answers "does this thing actually work?".
|
||||
*/
|
||||
|
||||
import { Component, For, JSX } from 'solid-js';
|
||||
import styles from './PrimitivesShowcase.module.css';
|
||||
|
||||
import { SliderDemo } from '../primitives/Slider.demo';
|
||||
import { SliderBankDemo } from '../primitives/SliderBank.demo';
|
||||
import { VirtualJoystickDemo } from '../primitives/VirtualJoystick.demo';
|
||||
import { XYPadDemo } from '../primitives/XYPad.demo';
|
||||
import { HeatmapDemo } from '../primitives/Heatmap.demo';
|
||||
import { OutputDisplayDemo } from '../primitives/OutputDisplay.demo';
|
||||
import { TrainingControlsDemo } from '../primitives/TrainingControls.demo';
|
||||
import { DrawerDemo } from '../primitives/Drawer.demo';
|
||||
import { ControlAxisDemo } from '../primitives/ControlAxis.demo';
|
||||
import { ProgressRingDemo } from '../primitives/ProgressRing.demo';
|
||||
import { PillToggleDemo } from '../primitives/PillToggle.demo';
|
||||
import { ParamEditorDemo } from '../primitives/ParamEditor.demo';
|
||||
import { JoyMapDemo } from '../primitives/JoyMap.demo';
|
||||
import { WeightHealthDemo } from '../primitives/WeightHealth.demo';
|
||||
import { GradientFlowDemo } from '../primitives/GradientFlow.demo';
|
||||
import { LossPlotDemo } from '../primitives/LossPlot.demo';
|
||||
|
||||
interface Section {
|
||||
name: string;
|
||||
description: string;
|
||||
Demo: Component;
|
||||
}
|
||||
|
||||
const SECTIONS: ReadonlyArray<Section> = [
|
||||
{ name: 'Slider', description: 'Single-value slider with optional curve mapping.', Demo: SliderDemo },
|
||||
{ name: 'SliderBank', description: 'Vertical stack with collapsible sections.', Demo: SliderBankDemo },
|
||||
{ name: 'VirtualJoystick', description: 'Touch + pointer 2D input, returns [0,1]^2.', Demo: VirtualJoystickDemo },
|
||||
{ name: 'XYPad', description: 'Rectangular 2D input pad.', Demo: XYPadDemo },
|
||||
{ name: 'Heatmap', description: 'NxN colored grid, three color modes.', Demo: HeatmapDemo },
|
||||
{ name: 'OutputDisplay', description: 'Bar chart of N output values.', Demo: OutputDisplayDemo },
|
||||
{ name: 'TrainingControls', description: 'RL feedback + status panel.', Demo: TrainingControlsDemo },
|
||||
{ name: 'Drawer', description: 'Slide-in panel from left/right.', Demo: DrawerDemo },
|
||||
{ name: 'ControlAxis', description: 'Compound axis slider (Boldness/Memory/Precision shape).', Demo: ControlAxisDemo },
|
||||
{ name: 'ProgressRing', description: 'Circular progress indicator.', Demo: ProgressRingDemo },
|
||||
{ name: 'PillToggle', description: 'Segmented control.', Demo: PillToggleDemo },
|
||||
{ name: 'ParamEditor', description: 'Range/curve/mute/pin for one parameter.', Demo: ParamEditorDemo },
|
||||
{ name: 'JoyMap', description: 'Zoom minimap with trail, noise rings, region pins.', Demo: JoyMapDemo },
|
||||
{ name: 'WeightHealth', description: 'Histogram with status glow.', Demo: WeightHealthDemo },
|
||||
{ name: 'GradientFlow', description: 'Per-layer gradient bars.', Demo: GradientFlowDemo },
|
||||
{ name: 'LossPlot', description: 'Training-loss line chart over time.', Demo: LossPlotDemo },
|
||||
];
|
||||
|
||||
const PrimitivesShowcase: Component = () => {
|
||||
return (
|
||||
<div class={styles.root}>
|
||||
<header class={styles.header}>
|
||||
<h1>Primitive Library</h1>
|
||||
<p class={styles.lede}>
|
||||
Each card is a live demo. Use this page as a sanity-check during the rewrite.
|
||||
Modes (stream 9) and feature parity (stream 10) compose these primitives.
|
||||
</p>
|
||||
</header>
|
||||
<div class={styles.grid}>
|
||||
<For each={SECTIONS}>{(section) => (
|
||||
<article class={styles.card}>
|
||||
<header class={styles.cardHead}>
|
||||
<h2 class={styles.cardTitle}>{section.name}</h2>
|
||||
<p class={styles.cardDesc}>{section.description}</p>
|
||||
</header>
|
||||
<div class={styles.cardBody}>
|
||||
{section.Demo({}) as unknown as JSX.Element}
|
||||
</div>
|
||||
</article>
|
||||
)}</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrimitivesShowcase;
|
||||
43
playground/src/primitives/ControlAxis.demo.tsx
Normal file
43
playground/src/primitives/ControlAxis.demo.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { ControlAxis } from './ControlAxis';
|
||||
|
||||
export const ControlAxisDemo: Component = () => {
|
||||
const [boldness, setBoldness] = createSignal(0.5);
|
||||
const [memory, setMemory] = createSignal(0.7);
|
||||
const [precision, setPrecision] = createSignal(0.3);
|
||||
const [preset, setPreset] = createSignal<string | null>('default');
|
||||
|
||||
const onAny = () => setPreset(null);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'min-width': '320px' }}>
|
||||
<ControlAxis
|
||||
label="Boldness"
|
||||
value={boldness}
|
||||
onChange={(v) => { setBoldness(v); onAny(); }}
|
||||
preset={preset}
|
||||
endpoints={['Caution', 'Bold']}
|
||||
accentColor="var(--accent)"
|
||||
onDoubleTap={() => setBoldness(0.5)}
|
||||
/>
|
||||
<ControlAxis
|
||||
label="Memory"
|
||||
value={memory}
|
||||
onChange={(v) => { setMemory(v); onAny(); }}
|
||||
preset={preset}
|
||||
endpoints={['Amnesia', 'Elephant']}
|
||||
accentColor="var(--accent-2)"
|
||||
onDoubleTap={() => setMemory(0.5)}
|
||||
/>
|
||||
<ControlAxis
|
||||
label="Precision"
|
||||
value={precision}
|
||||
onChange={(v) => { setPrecision(v); onAny(); }}
|
||||
preset={preset}
|
||||
endpoints={['Raw', 'Precise']}
|
||||
accentColor="var(--good)"
|
||||
onDoubleTap={() => setPrecision(0.3)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
playground/src/primitives/ControlAxis.module.css
Normal file
99
playground/src/primitives/ControlAxis.module.css
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
.axis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
--axis-accent: var(--accent);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preset {
|
||||
color: var(--axis-accent);
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fg-mute);
|
||||
font-size: var(--fs-xs);
|
||||
min-width: 4ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
}
|
||||
|
||||
.input::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
}
|
||||
|
||||
.input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--axis-accent);
|
||||
margin-top: -6px;
|
||||
box-shadow: 0 0 10px var(--axis-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--axis-accent);
|
||||
border: none;
|
||||
box-shadow: 0 0 10px var(--axis-accent);
|
||||
}
|
||||
|
||||
.input:focus { outline: none; }
|
||||
|
||||
.endpoints {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
73
playground/src/primitives/ControlAxis.tsx
Normal file
73
playground/src/primitives/ControlAxis.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Component, Show } from 'solid-js';
|
||||
import styles from './ControlAxis.module.css';
|
||||
|
||||
export interface ControlAxisProps {
|
||||
label: string;
|
||||
value: () => number;
|
||||
onChange: (v: number) => void;
|
||||
/** Active control preset id (rendered as subtitle when set). */
|
||||
preset?: () => string | null;
|
||||
/** Endpoint labels (left, right). E.g. ['Caution', 'Bold']. */
|
||||
endpoints?: readonly [string, string];
|
||||
/** Called when user double-taps the axis (re-link offsets). */
|
||||
onDoubleTap?: () => void;
|
||||
/** Disabled state. */
|
||||
disabled?: boolean;
|
||||
/** Visual color hint (defaults to --accent). */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export const ControlAxis: Component<ControlAxisProps> = (props) => {
|
||||
let lastTap = 0;
|
||||
|
||||
const onPointerDown = () => {
|
||||
const now = performance.now();
|
||||
if (now - lastTap < 350 && props.onDoubleTap) {
|
||||
props.onDoubleTap();
|
||||
lastTap = 0;
|
||||
} else {
|
||||
lastTap = now;
|
||||
}
|
||||
};
|
||||
|
||||
const onInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||
const v = parseFloat(e.currentTarget.value);
|
||||
if (!Number.isNaN(v)) props.onChange(Math.max(0, Math.min(1, v)));
|
||||
};
|
||||
|
||||
const accent = () => props.accentColor ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div
|
||||
class={styles.axis}
|
||||
classList={{ [styles.disabled]: !!props.disabled }}
|
||||
style={{ '--axis-accent': accent() }}
|
||||
>
|
||||
<div class={styles.head}>
|
||||
<span class={styles.label}>{props.label}</span>
|
||||
<Show when={props.preset?.()}>
|
||||
<span class={styles.preset}>{props.preset!()}</span>
|
||||
</Show>
|
||||
<span class={styles.value}>{props.value().toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
class={styles.input}
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={props.value()}
|
||||
onInput={onInput}
|
||||
onPointerDown={onPointerDown}
|
||||
disabled={props.disabled}
|
||||
aria-label={`${props.label} control axis`}
|
||||
/>
|
||||
<Show when={props.endpoints}>
|
||||
<div class={styles.endpoints}>
|
||||
<span>{props.endpoints![0]}</span>
|
||||
<span>{props.endpoints![1]}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
playground/src/primitives/Drawer.demo.tsx
Normal file
21
playground/src/primitives/Drawer.demo.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { Drawer } from './Drawer';
|
||||
|
||||
export const DrawerDemo: Component = () => {
|
||||
const [openR, setOpenR] = createSignal(false);
|
||||
const [openL, setOpenL] = createSignal(false);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px', 'flex-wrap': 'wrap' }}>
|
||||
<button type="button" onClick={() => setOpenL(true)}>Open Left</button>
|
||||
<button type="button" onClick={() => setOpenR(true)}>Open Right</button>
|
||||
<Drawer open={openL()} onClose={() => setOpenL(false)} side="left" title="Left drawer">
|
||||
<p>Slides in from the left.</p>
|
||||
<p>Press Escape, click outside, or click × to close.</p>
|
||||
</Drawer>
|
||||
<Drawer open={openR()} onClose={() => setOpenR(false)} side="right" title="Right drawer">
|
||||
<p>Slides in from the right.</p>
|
||||
<p>Mode-specific drawers will use this primitive.</p>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
playground/src/primitives/Drawer.module.css
Normal file
92
playground/src/primitives/Drawer.module.css
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-drawer);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
pointer-events: auto;
|
||||
animation: fadeIn var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.5);
|
||||
animation: slideIn var(--dur-med) var(--ease);
|
||||
}
|
||||
|
||||
.left .panel {
|
||||
left: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
border-left: 0;
|
||||
animation-name: slideInLeft;
|
||||
}
|
||||
|
||||
.right .panel {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: var(--fs-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 22px;
|
||||
color: var(--fg-mute);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--r-1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--bg-2);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
69
playground/src/primitives/Drawer.tsx
Normal file
69
playground/src/primitives/Drawer.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Component, JSX, onCleanup, onMount, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import styles from './Drawer.module.css';
|
||||
|
||||
export interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
side: 'left' | 'right';
|
||||
title?: string;
|
||||
children: JSX.Element;
|
||||
/** Optional width in px (default 360). */
|
||||
width?: number;
|
||||
/** Set to false to disable click-outside / Escape close behaviour. */
|
||||
dismissable?: boolean;
|
||||
}
|
||||
|
||||
export const Drawer: Component<DrawerProps> = (props) => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!props.open) return;
|
||||
if (props.dismissable === false) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<Portal>
|
||||
<div
|
||||
class={styles.root}
|
||||
classList={{ [styles.left]: props.side === 'left', [styles.right]: props.side === 'right' }}
|
||||
role="dialog"
|
||||
aria-label={props.title ?? 'drawer'}
|
||||
aria-modal="false"
|
||||
>
|
||||
<div
|
||||
class={styles.scrim}
|
||||
onClick={() => {
|
||||
if (props.dismissable !== false) props.onClose();
|
||||
}}
|
||||
/>
|
||||
<aside
|
||||
class={styles.panel}
|
||||
style={{ width: `${props.width ?? 360}px` }}
|
||||
>
|
||||
<header class={styles.header}>
|
||||
<span class={styles.title}>{props.title ?? ''}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.close}
|
||||
onClick={props.onClose}
|
||||
aria-label="Close drawer"
|
||||
>×</button>
|
||||
</header>
|
||||
<div class={styles.body}>{props.children}</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
36
playground/src/primitives/GradientFlow.demo.tsx
Normal file
36
playground/src/primitives/GradientFlow.demo.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Component, createMemo, createSignal } from 'solid-js';
|
||||
import { GradientFlow, type GradientStatus } from './GradientFlow';
|
||||
import { PillToggle } from './PillToggle';
|
||||
|
||||
export const GradientFlowDemo: Component = () => {
|
||||
const [pattern, setPattern] = createSignal<'healthy' | 'vanishing' | 'exploding'>('healthy');
|
||||
|
||||
const norms = createMemo<number[]>(() => {
|
||||
const p = pattern();
|
||||
if (p === 'vanishing') return [1.0, 0.4, 0.15, 0.04];
|
||||
if (p === 'exploding') return [0.4, 0.8, 1.6, 3.2];
|
||||
return [0.6, 0.55, 0.5, 0.5];
|
||||
});
|
||||
|
||||
const status = createMemo<GradientStatus[]>(() => {
|
||||
const p = pattern();
|
||||
if (p === 'vanishing') return ['healthy', 'vanishing', 'vanishing', 'vanishing'];
|
||||
if (p === 'exploding') return ['healthy', 'healthy', 'exploding', 'exploding'];
|
||||
return ['healthy', 'healthy', 'healthy', 'healthy'];
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
|
||||
<GradientFlow layerNorms={norms} status={status} />
|
||||
<PillToggle
|
||||
options={[
|
||||
{ value: 'healthy', label: 'Healthy' },
|
||||
{ value: 'vanishing', label: 'Vanishing' },
|
||||
{ value: 'exploding', label: 'Exploding' },
|
||||
]}
|
||||
value={pattern}
|
||||
onChange={(v) => setPattern(v as 'healthy' | 'vanishing' | 'exploding')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
playground/src/primitives/GradientFlow.module.css
Normal file
6
playground/src/primitives/GradientFlow.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.canvas {
|
||||
display: block;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
}
|
||||
69
playground/src/primitives/GradientFlow.tsx
Normal file
69
playground/src/primitives/GradientFlow.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Component, createEffect } from 'solid-js';
|
||||
import styles from './GradientFlow.module.css';
|
||||
|
||||
export type GradientStatus = 'vanishing' | 'exploding' | 'converged' | 'healthy';
|
||||
|
||||
export interface GradientFlowProps {
|
||||
/** Per-layer L2 norms of weight delta. */
|
||||
layerNorms: () => ReadonlyArray<number>;
|
||||
/** Per-layer status. Length must match layerNorms(). */
|
||||
status: () => ReadonlyArray<GradientStatus>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<GradientStatus, string> = {
|
||||
vanishing: '#5b9eef',
|
||||
exploding: '#ef5b5b',
|
||||
converged: '#9a9a9a',
|
||||
healthy: '#6bc26b',
|
||||
};
|
||||
|
||||
export const GradientFlow: Component<GradientFlowProps> = (props) => {
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (!canvasEl) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = (props.width ?? 240) * dpr;
|
||||
const h = (props.height ?? 80) * dpr;
|
||||
if (canvasEl.width !== w || canvasEl.height !== h) {
|
||||
canvasEl.width = w;
|
||||
canvasEl.height = h;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const norms = props.layerNorms();
|
||||
const status = props.status();
|
||||
if (norms.length === 0) return;
|
||||
|
||||
let max = 0;
|
||||
for (const n of norms) if (n > max) max = n;
|
||||
if (max <= 0) max = 1;
|
||||
|
||||
const barW = w / norms.length;
|
||||
for (let i = 0; i < norms.length; i++) {
|
||||
const bh = (norms[i]! / max) * (h - 14 * dpr);
|
||||
const s = status[i] ?? 'healthy';
|
||||
ctx.fillStyle = STATUS_COLOR[s];
|
||||
ctx.fillRect(i * barW + 2, h - bh - 2, barW - 4, bh);
|
||||
ctx.fillStyle = '#5a5a5a';
|
||||
ctx.font = `${10 * dpr}px var(--font-mono)`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`L${i}`, i * barW + barW / 2, h - 2);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasEl}
|
||||
class={styles.canvas}
|
||||
style={{ width: `${props.width ?? 240}px`, height: `${props.height ?? 80}px` }}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel ?? 'Gradient flow per layer'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
46
playground/src/primitives/Heatmap.demo.tsx
Normal file
46
playground/src/primitives/Heatmap.demo.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { Heatmap, type HeatmapColorMode } from './Heatmap';
|
||||
import { PillToggle } from './PillToggle';
|
||||
|
||||
const N = 16;
|
||||
|
||||
function generateField(seed: number): Float32Array {
|
||||
const arr = new Float32Array(N * N);
|
||||
for (let y = 0; y < N; y++) {
|
||||
for (let x = 0; x < N; x++) {
|
||||
const fx = (x - N / 2) / (N / 2);
|
||||
const fy = (y - N / 2) / (N / 2);
|
||||
const r = Math.sqrt(fx * fx + fy * fy);
|
||||
const v = 0.5 + 0.5 * Math.sin(r * 3 + seed * 0.5) * Math.cos(fx * 4 + seed);
|
||||
arr[y * N + x] = Math.max(0, Math.min(1, v));
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export const HeatmapDemo: Component = () => {
|
||||
const [mode, setMode] = createSignal<HeatmapColorMode>('luminance');
|
||||
const [tick, setTick] = createSignal(0);
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
onMount(() => {
|
||||
timer = setInterval(() => setTick((t) => t + 1), 200);
|
||||
});
|
||||
onCleanup(() => clearInterval(timer));
|
||||
|
||||
const samples = createMemo(() => generateField(tick()));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'align-items': 'center' }}>
|
||||
<Heatmap samples={samples} resolution={N} colorMode={mode()} size={240} showGrid={false} />
|
||||
<PillToggle
|
||||
options={[
|
||||
{ value: 'luminance', label: 'Luminance' },
|
||||
{ value: 'variance', label: 'Variance' },
|
||||
{ value: 'divergence', label: 'Divergence' },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as HeatmapColorMode)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
playground/src/primitives/Heatmap.module.css
Normal file
7
playground/src/primitives/Heatmap.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.canvas {
|
||||
display: block;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--bg-1);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
138
playground/src/primitives/Heatmap.tsx
Normal file
138
playground/src/primitives/Heatmap.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Component, createEffect, onCleanup } from 'solid-js';
|
||||
import styles from './Heatmap.module.css';
|
||||
|
||||
export type HeatmapColorMode = 'luminance' | 'variance' | 'divergence';
|
||||
|
||||
export interface HeatmapProps {
|
||||
/**
|
||||
* Reactive accessor returning a Float32Array. The array layout depends
|
||||
* on the color mode:
|
||||
* - luminance: N*N values (mean output magnitude per cell)
|
||||
* - variance: N*N values (variance per cell)
|
||||
* - divergence: N*N*S values where S is sample length per cell;
|
||||
* only the first N*N are used as a scalar (precomputed). For
|
||||
* arbitrary modes the caller should pre-reduce to N*N.
|
||||
*
|
||||
* If `samples` is empty the canvas is cleared.
|
||||
*/
|
||||
samples: () => Float32Array;
|
||||
/** Number of cells per side (e.g. 16 → 16×16). */
|
||||
resolution: number;
|
||||
/** Color mode (affects palette interpretation). */
|
||||
colorMode: HeatmapColorMode;
|
||||
/** Optional palette as an array of CSS color stops. Default: dark→amber→white. */
|
||||
palette?: ReadonlyArray<string>;
|
||||
/** Pixel size of the canvas. Default 240. */
|
||||
size?: number;
|
||||
/** When true, draw cell borders for clarity. */
|
||||
showGrid?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PALETTE = ['#1a0033', '#5a1f5a', '#a64f30', '#ff8030', '#ffe0a8', '#ffffff'];
|
||||
|
||||
function paletteRGB(palette: ReadonlyArray<string>): Array<[number, number, number]> {
|
||||
return palette.map((hex) => {
|
||||
const m = hex.replace('#', '');
|
||||
const r = parseInt(m.substring(0, 2), 16);
|
||||
const g = parseInt(m.substring(2, 4), 16);
|
||||
const b = parseInt(m.substring(4, 6), 16);
|
||||
return [r, g, b];
|
||||
});
|
||||
}
|
||||
|
||||
function lerpRGB(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
}
|
||||
|
||||
function sampleColor(palette: Array<[number, number, number]>, t: number): [number, number, number] {
|
||||
if (palette.length === 0) return [0, 0, 0];
|
||||
if (palette.length === 1) return palette[0]!;
|
||||
const tt = Math.max(0, Math.min(1, t));
|
||||
const seg = tt * (palette.length - 1);
|
||||
const idx = Math.min(palette.length - 2, Math.floor(seg));
|
||||
const frac = seg - idx;
|
||||
return lerpRGB(palette[idx]!, palette[idx + 1]!, frac);
|
||||
}
|
||||
|
||||
export const Heatmap: Component<HeatmapProps> = (props) => {
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (!canvasEl) return;
|
||||
const data = props.samples();
|
||||
const N = props.resolution;
|
||||
const palette = paletteRGB(props.palette ?? DEFAULT_PALETTE);
|
||||
const size = props.size ?? 240;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pixelSize = size * dpr;
|
||||
if (canvasEl.width !== pixelSize || canvasEl.height !== pixelSize) {
|
||||
canvasEl.width = pixelSize;
|
||||
canvasEl.height = pixelSize;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, pixelSize, pixelSize);
|
||||
|
||||
if (data.length === 0 || N <= 0) return;
|
||||
|
||||
const cells = N * N;
|
||||
if (data.length < cells) return;
|
||||
|
||||
// Find min/max for normalisation
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (let i = 0; i < cells; i++) {
|
||||
const v = data[i]!;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
const range = max - min;
|
||||
const cellPx = pixelSize / N;
|
||||
|
||||
for (let y = 0; y < N; y++) {
|
||||
for (let x = 0; x < N; x++) {
|
||||
const v = data[y * N + x] ?? 0;
|
||||
let t = range > 0 ? (v - min) / range : 0;
|
||||
// Color mode interpretation
|
||||
if (props.colorMode === 'variance') {
|
||||
// Map variance such that low variance → dark, high → bright
|
||||
t = Math.sqrt(t);
|
||||
} else if (props.colorMode === 'divergence') {
|
||||
// Divergence is signed-but-stored-as-magnitude — emphasize tails
|
||||
t = t * t;
|
||||
}
|
||||
const [r, g, b] = sampleColor(palette, t);
|
||||
ctx.fillStyle = `rgb(${r | 0}, ${g | 0}, ${b | 0})`;
|
||||
ctx.fillRect(Math.floor(x * cellPx), Math.floor((N - 1 - y) * cellPx), Math.ceil(cellPx + 1), Math.ceil(cellPx + 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (props.showGrid) {
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < N; i++) {
|
||||
const p = Math.floor(i * cellPx) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p, 0);
|
||||
ctx.lineTo(p, pixelSize);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, p);
|
||||
ctx.lineTo(pixelSize, p);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasEl}
|
||||
class={styles.canvas}
|
||||
style={{ width: `${props.size ?? 240}px`, height: `${props.size ?? 240}px` }}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel ?? `Heatmap (${props.colorMode})`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
52
playground/src/primitives/JoyMap.demo.tsx
Normal file
52
playground/src/primitives/JoyMap.demo.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { JoyMap, type TrailPoint, type RegionPin } from './JoyMap';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
export const JoyMapDemo: Component = () => {
|
||||
const [zoom, setZoom] = createSignal(0.5);
|
||||
const [pos, setPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
|
||||
const [trail, setTrail] = createSignal<TrailPoint[]>([]);
|
||||
const [pins] = createSignal<RegionPin[]>([
|
||||
{ id: 'p1', x: 0.6, y: 0.5, width: 0.18, height: 0.18, colorSlot: 0 },
|
||||
{ id: 'p2', x: 0.18, y: 0.18, width: 0.12, height: 0.12, colorSlot: 1 },
|
||||
]);
|
||||
const [frozen, setFrozen] = createSignal(false);
|
||||
|
||||
// Simulated wandering position
|
||||
let raf = 0;
|
||||
let t0 = performance.now();
|
||||
const animate = () => {
|
||||
const t = (performance.now() - t0) / 1000;
|
||||
const x = 0.5 + 0.4 * Math.sin(t * 0.4);
|
||||
const y = 0.5 + 0.4 * Math.cos(t * 0.65);
|
||||
setPos([x, y]);
|
||||
setTrail((prev) => {
|
||||
const next = [...prev, { x, y, t: performance.now() }];
|
||||
// Cap trail length to avoid runaway growth
|
||||
return next.slice(-200);
|
||||
});
|
||||
raf = requestAnimationFrame(animate);
|
||||
};
|
||||
onMount(() => { raf = requestAnimationFrame(animate); });
|
||||
onCleanup(() => cancelAnimationFrame(raf));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
|
||||
<JoyMap
|
||||
size={220}
|
||||
zoom={zoom}
|
||||
anchor={pos}
|
||||
position={pos}
|
||||
trail={trail}
|
||||
regionPins={pins}
|
||||
noiseRings={() => [0.18, 0.06]}
|
||||
onTrailTap={(p) => setPos([p.x, p.y])}
|
||||
frozen={frozen}
|
||||
/>
|
||||
<Slider value={zoom()} onChange={setZoom} min={0.01} max={1} label="zoom" />
|
||||
<button type="button" onClick={() => setFrozen((f) => !f)}>
|
||||
{frozen() ? 'Unfreeze' : 'Freeze'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
playground/src/primitives/JoyMap.module.css
Normal file
8
playground/src/primitives/JoyMap.module.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.canvas {
|
||||
display: block;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--bg-1);
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
309
playground/src/primitives/JoyMap.tsx
Normal file
309
playground/src/primitives/JoyMap.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { Component, createEffect, onCleanup, onMount } from 'solid-js';
|
||||
import styles from './JoyMap.module.css';
|
||||
|
||||
export interface TrailPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Time in ms since some epoch (e.g. performance.now()). */
|
||||
t: number;
|
||||
}
|
||||
|
||||
export interface RegionPin {
|
||||
id: string;
|
||||
x: number; // [0,1]
|
||||
y: number; // [0,1]
|
||||
width: number; // [0,1]
|
||||
height: number; // [0,1]
|
||||
/** 0..4 mapping to --pin-1..--pin-5 tokens. */
|
||||
colorSlot: number;
|
||||
}
|
||||
|
||||
export interface JoyMapProps {
|
||||
/** Current zoom level (0.01–1). 0.01 = frozen. */
|
||||
zoom: () => number;
|
||||
/** Anchor point for the zoom window (in [0,1]^2). */
|
||||
anchor: () => readonly [number, number];
|
||||
/** Live cursor position in [0,1]^2 (raw, not zoomed). */
|
||||
position: () => readonly [number, number];
|
||||
/** Recent trail points. */
|
||||
trail: () => ReadonlyArray<TrailPoint>;
|
||||
/** Active region pins. */
|
||||
regionPins?: () => ReadonlyArray<RegionPin>;
|
||||
/** Outer + inner ring radii [0..1] for noise visualization. */
|
||||
noiseRings?: () => readonly [number, number] | null;
|
||||
/** Called when user taps within the trail tap-hit radius of a trail point. */
|
||||
onTrailTap?: (p: { x: number; y: number; t: number }) => void;
|
||||
/** Called on long-press (for region pinning). */
|
||||
onLongPress?: () => void;
|
||||
/** Pixel size; canvas is square. */
|
||||
size?: number;
|
||||
/** Frozen overlay. */
|
||||
frozen?: () => boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const TAP_HIT_RADIUS_PX = 12;
|
||||
const TRAIL_DURATION_MS = 5000;
|
||||
const TRAIL_MIN_WIDTH = 1.5;
|
||||
const TRAIL_MAX_WIDTH = 4;
|
||||
const LONG_PRESS_MS = 600;
|
||||
|
||||
const PIN_COLORS = [
|
||||
'rgba(255, 106, 0, 0.25)',
|
||||
'rgba(0, 204, 255, 0.25)',
|
||||
'rgba(180, 100, 255, 0.25)',
|
||||
'rgba(80, 200, 120, 0.25)',
|
||||
'rgba(255, 200, 80, 0.25)',
|
||||
];
|
||||
const PIN_BORDERS = [
|
||||
'rgba(255, 106, 0, 0.7)',
|
||||
'rgba(0, 204, 255, 0.7)',
|
||||
'rgba(180, 100, 255, 0.7)',
|
||||
'rgba(80, 200, 120, 0.7)',
|
||||
'rgba(255, 200, 80, 0.7)',
|
||||
];
|
||||
|
||||
/** Centripetal Catmull-Rom spline segment. */
|
||||
function drawCatmullRom(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pts: ReadonlyArray<{ x: number; y: number }>,
|
||||
): void {
|
||||
if (pts.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)]!;
|
||||
const p1 = pts[i]!;
|
||||
const p2 = pts[i + 1]!;
|
||||
const p3 = pts[Math.min(pts.length - 1, i + 2)]!;
|
||||
// 8-step segment subdivision
|
||||
for (let s = 1; s <= 8; s++) {
|
||||
const t = s / 8;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
|
||||
const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export const JoyMap: Component<JoyMapProps> = (props) => {
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
let frame = 0;
|
||||
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const size = () => props.size ?? 200;
|
||||
|
||||
const drawAdaptiveGrid = (ctx: CanvasRenderingContext2D, dim: number, zoom: number, anchor: readonly [number, number]) => {
|
||||
// Adaptive subdivision: more lines as zoom decreases
|
||||
const subdivisions = zoom <= 0.1 ? 16 : zoom <= 0.3 ? 8 : zoom <= 0.6 ? 4 : 2;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < subdivisions; i++) {
|
||||
const p = (i / subdivisions) * dim;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p, 0);
|
||||
ctx.lineTo(p, dim);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, p);
|
||||
ctx.lineTo(dim, p);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Center crosshair
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, dim / 2);
|
||||
ctx.lineTo(dim, dim / 2);
|
||||
ctx.moveTo(dim / 2, 0);
|
||||
ctx.lineTo(dim / 2, dim);
|
||||
ctx.stroke();
|
||||
|
||||
// Zoom window
|
||||
const wx = anchor[0] - zoom * 0.5;
|
||||
const wy = anchor[1] - zoom * 0.5;
|
||||
ctx.strokeStyle = 'rgba(255, 106, 0, 0.7)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(wx * dim, (1 - wy - zoom) * dim, zoom * dim, zoom * dim);
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
if (!canvasEl) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const dim = size() * dpr;
|
||||
if (canvasEl.width !== dim || canvasEl.height !== dim) {
|
||||
canvasEl.width = dim;
|
||||
canvasEl.height = dim;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, dim, dim);
|
||||
|
||||
const zoom = Math.max(0.01, Math.min(1, props.zoom()));
|
||||
const anchor = props.anchor();
|
||||
const pos = props.position();
|
||||
const trail = props.trail();
|
||||
const pins = props.regionPins?.() ?? [];
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, dim, dim);
|
||||
|
||||
// Adaptive grid + zoom window
|
||||
drawAdaptiveGrid(ctx, dim, zoom, anchor);
|
||||
|
||||
// Region pins
|
||||
for (const pin of pins) {
|
||||
const px = pin.x * dim;
|
||||
const py = (1 - pin.y - pin.height) * dim;
|
||||
const pw = pin.width * dim;
|
||||
const ph = pin.height * dim;
|
||||
const color = PIN_COLORS[pin.colorSlot % PIN_COLORS.length]!;
|
||||
const border = PIN_BORDERS[pin.colorSlot % PIN_BORDERS.length]!;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(px, py, pw, ph);
|
||||
ctx.strokeStyle = border;
|
||||
ctx.lineWidth = 2 * dpr;
|
||||
ctx.strokeRect(px, py, pw, ph);
|
||||
}
|
||||
|
||||
// Trail (vanishing, Catmull-Rom)
|
||||
if (trail.length >= 2) {
|
||||
const now = performance.now();
|
||||
const trailWidth = TRAIL_MIN_WIDTH + (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH) * (1 - zoom);
|
||||
const points = trail
|
||||
.filter((p) => now - p.t <= TRAIL_DURATION_MS)
|
||||
.map((p) => ({ x: p.x * dim, y: (1 - p.y) * dim }));
|
||||
if (points.length >= 2) {
|
||||
ctx.lineWidth = trailWidth * dpr;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'rgba(255, 106, 0, 0.55)';
|
||||
drawCatmullRom(ctx, points);
|
||||
}
|
||||
// Trail point dots (tap targets)
|
||||
for (const p of trail) {
|
||||
const age = now - p.t;
|
||||
if (age > TRAIL_DURATION_MS) continue;
|
||||
const fade = 1 - age / TRAIL_DURATION_MS;
|
||||
ctx.fillStyle = `rgba(255, 106, 0, ${fade * 0.6})`;
|
||||
const x = p.x * dim;
|
||||
const y = (1 - p.y) * dim;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2 * dpr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor
|
||||
const cx = pos[0] * dim;
|
||||
const cy = (1 - pos[1]) * dim;
|
||||
ctx.fillStyle = '#00ccff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 4 * dpr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0, 204, 255, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 9 * dpr, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Noise rings
|
||||
const rings = props.noiseRings?.();
|
||||
if (rings) {
|
||||
const [outer, inner] = rings;
|
||||
ctx.strokeStyle = 'rgba(255, 200, 80, 0.4)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, outer * dim, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(255, 200, 80, 0.6)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, inner * dim, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Frozen overlay
|
||||
if (props.frozen?.()) {
|
||||
ctx.fillStyle = 'rgba(100, 180, 255, 0.15)';
|
||||
ctx.fillRect(0, 0, dim, dim);
|
||||
ctx.fillStyle = 'rgba(100, 180, 255, 0.85)';
|
||||
ctx.font = `${12 * dpr}px var(--font-mono)`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('FROZEN', dim / 2, dim - 12 * dpr);
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
draw();
|
||||
frame = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
frame = requestAnimationFrame(animate);
|
||||
});
|
||||
onCleanup(() => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
if (pressTimer) clearTimeout(pressTimer);
|
||||
});
|
||||
|
||||
// Re-draw whenever props change (covers cases where rAF is throttled)
|
||||
createEffect(() => {
|
||||
props.zoom();
|
||||
props.position();
|
||||
props.trail();
|
||||
draw();
|
||||
});
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (props.onLongPress) {
|
||||
pressTimer = setTimeout(() => {
|
||||
props.onLongPress?.();
|
||||
pressTimer = null;
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
// Tap-to-return: check trail
|
||||
if (canvasEl && props.onTrailTap) {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
const trail = props.trail();
|
||||
for (const p of trail) {
|
||||
const tx = p.x * rect.width;
|
||||
const ty = (1 - p.y) * rect.height;
|
||||
const dx = tx - px;
|
||||
const dy = ty - py;
|
||||
if (dx * dx + dy * dy <= TAP_HIT_RADIUS_PX * TAP_HIT_RADIUS_PX) {
|
||||
props.onTrailTap({ x: p.x, y: p.y, t: p.t });
|
||||
if (pressTimer) {
|
||||
clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (pressTimer) {
|
||||
clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasEl}
|
||||
class={styles.canvas}
|
||||
style={{ width: `${size()}px`, height: `${size()}px` }}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel ?? 'Joy map'}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
/>
|
||||
);
|
||||
};
|
||||
27
playground/src/primitives/LossPlot.demo.tsx
Normal file
27
playground/src/primitives/LossPlot.demo.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { LossPlot } from './LossPlot';
|
||||
|
||||
export const LossPlotDemo: Component = () => {
|
||||
const [history, setHistory] = createSignal<number[]>([]);
|
||||
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
let i = 0;
|
||||
onMount(() => {
|
||||
timer = setInterval(() => {
|
||||
i += 1;
|
||||
const v = 0.5 * Math.exp(-i * 0.04) + 0.005 + 0.02 * Math.random();
|
||||
setHistory((h) => {
|
||||
const next = [...h, v];
|
||||
return next.slice(-200);
|
||||
});
|
||||
}, 80);
|
||||
});
|
||||
onCleanup(() => clearInterval(timer));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
|
||||
<LossPlot history={history} log />
|
||||
<button type="button" onClick={() => { setHistory([]); i = 0; }}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
playground/src/primitives/LossPlot.module.css
Normal file
6
playground/src/primitives/LossPlot.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.canvas {
|
||||
display: block;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
}
|
||||
90
playground/src/primitives/LossPlot.tsx
Normal file
90
playground/src/primitives/LossPlot.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Component, createEffect } from 'solid-js';
|
||||
import styles from './LossPlot.module.css';
|
||||
|
||||
export interface LossPlotProps {
|
||||
history: () => ReadonlyArray<number>;
|
||||
/** Maximum points to display (older points are dropped). Default 200. */
|
||||
maxPoints?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Use logarithmic Y-axis. Default true. */
|
||||
log?: boolean;
|
||||
/** Color of plot line. */
|
||||
color?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const LossPlot: Component<LossPlotProps> = (props) => {
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (!canvasEl) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = (props.width ?? 320) * dpr;
|
||||
const h = (props.height ?? 80) * dpr;
|
||||
if (canvasEl.width !== w || canvasEl.height !== h) {
|
||||
canvasEl.width = w;
|
||||
canvasEl.height = h;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const all = props.history();
|
||||
if (all.length === 0) return;
|
||||
|
||||
const max = props.maxPoints ?? 200;
|
||||
const data = all.length > max ? all.slice(all.length - max) : all;
|
||||
const log = props.log !== false;
|
||||
|
||||
const ys = data.map((v) => (log ? Math.log(Math.max(1e-10, v) + 1) : v));
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
for (const y of ys) {
|
||||
if (y < yMin) yMin = y;
|
||||
if (y > yMax) yMax = y;
|
||||
}
|
||||
if (yMax === yMin) yMax = yMin + 1e-6;
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = (i / 4) * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Plot line
|
||||
ctx.strokeStyle = props.color ?? '#00ccff';
|
||||
ctx.lineWidth = 1.5 * dpr;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < ys.length; i++) {
|
||||
const x = (i / Math.max(1, ys.length - 1)) * w;
|
||||
const norm = (ys[i]! - yMin) / (yMax - yMin);
|
||||
const y = h - norm * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Last value
|
||||
const last = data[data.length - 1]!;
|
||||
ctx.fillStyle = '#9a9a9a';
|
||||
ctx.font = `${10 * dpr}px var(--font-mono)`;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(last.toExponential(2), w - 4 * dpr, 12 * dpr);
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasEl}
|
||||
class={styles.canvas}
|
||||
style={{ width: `${props.width ?? 320}px`, height: `${props.height ?? 80}px` }}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel ?? 'Training loss over time'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
33
playground/src/primitives/OutputDisplay.demo.tsx
Normal file
33
playground/src/primitives/OutputDisplay.demo.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { OutputDisplay } from './OutputDisplay';
|
||||
|
||||
export const OutputDisplayDemo: Component = () => {
|
||||
const [tick, setTick] = createSignal(0);
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const animate = (t: number) => {
|
||||
if (t - last > 80) {
|
||||
setTick((x) => x + 1);
|
||||
last = t;
|
||||
}
|
||||
raf = requestAnimationFrame(animate);
|
||||
};
|
||||
onMount(() => { raf = requestAnimationFrame(animate); });
|
||||
onCleanup(() => cancelAnimationFrame(raf));
|
||||
|
||||
const values = createMemo(() => {
|
||||
const t = tick();
|
||||
const arr = new Float32Array(126);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i] = 0.5 + 0.5 * Math.sin(t * 0.05 + i * 0.21);
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '6px' }}>
|
||||
<OutputDisplay values={values} width={320} height={80} />
|
||||
<OutputDisplay values={values} width={320} height={28} compact />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
10
playground/src/primitives/OutputDisplay.module.css
Normal file
10
playground/src/primitives/OutputDisplay.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.canvas {
|
||||
display: block;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
}
|
||||
|
||||
.compact {
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
67
playground/src/primitives/OutputDisplay.tsx
Normal file
67
playground/src/primitives/OutputDisplay.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Component, createEffect } from 'solid-js';
|
||||
import styles from './OutputDisplay.module.css';
|
||||
|
||||
export interface OutputDisplayProps {
|
||||
values: () => Float32Array;
|
||||
/** Optional labels per output. */
|
||||
labels?: ReadonlyArray<string>;
|
||||
/** Compact mode: thinner bars, no labels. */
|
||||
compact?: boolean;
|
||||
/** Pixel width. */
|
||||
width?: number;
|
||||
/** Pixel height. */
|
||||
height?: number;
|
||||
/** Optional bar color (default --accent). */
|
||||
color?: string;
|
||||
/** Show numeric label on hover (uses tooltip element). */
|
||||
showHover?: boolean;
|
||||
}
|
||||
|
||||
export const OutputDisplay: Component<OutputDisplayProps> = (props) => {
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
const compact = () => props.compact ?? false;
|
||||
|
||||
createEffect(() => {
|
||||
if (!canvasEl) return;
|
||||
const values = props.values();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = (props.width ?? 320) * dpr;
|
||||
const h = (props.height ?? (compact() ? 40 : 80)) * dpr;
|
||||
if (canvasEl.width !== w || canvasEl.height !== h) {
|
||||
canvasEl.width = w;
|
||||
canvasEl.height = h;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (values.length === 0) return;
|
||||
|
||||
const n = values.length;
|
||||
const barW = w / n;
|
||||
const color = props.color ?? '#ff6a00';
|
||||
ctx.fillStyle = color;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = Math.max(0, Math.min(1, values[i] ?? 0));
|
||||
const bh = v * h;
|
||||
ctx.fillRect(i * barW, h - bh, Math.max(1, barW - dpr), bh);
|
||||
}
|
||||
// Mid-line guide
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h * 0.5);
|
||||
ctx.lineTo(w, h * 0.5);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasEl}
|
||||
class={styles.canvas}
|
||||
classList={{ [styles.compact]: compact() }}
|
||||
style={{ width: `${props.width ?? 320}px`, height: `${props.height ?? (compact() ? 40 : 80)}px` }}
|
||||
role="img"
|
||||
aria-label={`Output bars (${props.values().length} channels)`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
24
playground/src/primitives/ParamEditor.demo.tsx
Normal file
24
playground/src/primitives/ParamEditor.demo.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { ParamEditor, type ParamDef, type ParamOverride } from './ParamEditor';
|
||||
|
||||
const PARAM: ParamDef = {
|
||||
name: 'osc1_freq',
|
||||
label: 'Osc 1 Freq',
|
||||
hardMin: 20,
|
||||
hardMax: 8000,
|
||||
default: 440,
|
||||
curve: 'exp',
|
||||
group: 'oscillators',
|
||||
};
|
||||
|
||||
export const ParamEditorDemo: Component = () => {
|
||||
const [override, setOverride] = createSignal<ParamOverride>({
|
||||
min: 100,
|
||||
max: 2000,
|
||||
curve: 'exp',
|
||||
muted: false,
|
||||
pinned: false,
|
||||
fixedValue: 440,
|
||||
});
|
||||
return <ParamEditor param={PARAM} override={override} onChange={setOverride} />;
|
||||
};
|
||||
66
playground/src/primitives/ParamEditor.module.css
Normal file
66
playground/src/primitives/ParamEditor.module.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: var(--sp-2);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.id {
|
||||
color: var(--fg-dim);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.toggles {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.curveRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--fg-mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.select {
|
||||
flex: 1;
|
||||
background: var(--bg-2);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-1);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
106
playground/src/primitives/ParamEditor.tsx
Normal file
106
playground/src/primitives/ParamEditor.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
30
playground/src/primitives/PillToggle.demo.tsx
Normal file
30
playground/src/primitives/PillToggle.demo.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { PillToggle } from './PillToggle';
|
||||
|
||||
export const PillToggleDemo: Component = () => {
|
||||
const [mode, setMode] = createSignal('synth');
|
||||
const [size, setSize] = createSignal('m');
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px', 'align-items': 'flex-start' }}>
|
||||
<PillToggle
|
||||
options={[
|
||||
{ value: 'visual', label: 'Visual' },
|
||||
{ value: 'synth', label: 'Synth' },
|
||||
{ value: 'midi', label: 'MIDI CC' },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
<PillToggle
|
||||
options={[
|
||||
{ value: 's', label: 'S' },
|
||||
{ value: 'm', label: 'M' },
|
||||
{ value: 'l', label: 'L' },
|
||||
{ value: 'xl', label: 'XL' },
|
||||
]}
|
||||
value={size}
|
||||
onChange={setSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
playground/src/primitives/PillToggle.module.css
Normal file
34
playground/src/primitives/PillToggle.module.css
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
.group {
|
||||
display: inline-flex;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
padding: 6px 14px;
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-mute);
|
||||
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.pill:hover:not(:disabled):not(.active) {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
41
playground/src/primitives/PillToggle.tsx
Normal file
41
playground/src/primitives/PillToggle.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Component, For } from 'solid-js';
|
||||
import styles from './PillToggle.module.css';
|
||||
|
||||
export interface PillToggleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PillToggleProps {
|
||||
options: ReadonlyArray<PillToggleOption>;
|
||||
value: () => string;
|
||||
onChange: (v: string) => void;
|
||||
/** Optional aria label for the group. */
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const PillToggle: Component<PillToggleProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={styles.group}
|
||||
classList={{ [styles.disabled]: !!props.disabled }}
|
||||
role="radiogroup"
|
||||
aria-label={props.ariaLabel ?? 'segmented control'}
|
||||
>
|
||||
<For each={props.options}>{(opt) => (
|
||||
<button
|
||||
type="button"
|
||||
class={styles.pill}
|
||||
classList={{ [styles.active]: props.value() === opt.value }}
|
||||
role="radio"
|
||||
aria-checked={props.value() === opt.value}
|
||||
onClick={() => !props.disabled && props.onChange(opt.value)}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)}</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
playground/src/primitives/ProgressRing.demo.tsx
Normal file
20
playground/src/primitives/ProgressRing.demo.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Component, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { ProgressRing } from './ProgressRing';
|
||||
|
||||
export const ProgressRingDemo: Component = () => {
|
||||
const [p, setP] = createSignal(0);
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
onMount(() => {
|
||||
timer = setInterval(() => {
|
||||
setP((q) => (q + 0.02) % 1);
|
||||
}, 50);
|
||||
});
|
||||
onCleanup(() => clearInterval(timer));
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
|
||||
<ProgressRing progress={p} size={32} />
|
||||
<ProgressRing progress={p} size={48} color="var(--accent-2)" />
|
||||
<ProgressRing progress={p} size={64} color="var(--good)" showLabel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
playground/src/primitives/ProgressRing.tsx
Normal file
73
playground/src/primitives/ProgressRing.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Component, createMemo } from 'solid-js';
|
||||
|
||||
export interface ProgressRingProps {
|
||||
/** 0..1 fraction. */
|
||||
progress: () => number;
|
||||
/** Diameter in px. Default 32. */
|
||||
size?: number;
|
||||
/** Stroke color. Default --accent. */
|
||||
color?: string;
|
||||
/** Stroke width. Default size/10. */
|
||||
strokeWidth?: number;
|
||||
/** Show numeric percent in middle. */
|
||||
showLabel?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const ProgressRing: Component<ProgressRingProps> = (props) => {
|
||||
const size = () => props.size ?? 32;
|
||||
const strokeWidth = () => props.strokeWidth ?? Math.max(2, Math.round(size() / 10));
|
||||
const radius = () => (size() - strokeWidth()) / 2;
|
||||
const circumference = () => 2 * Math.PI * radius();
|
||||
const progress = () => Math.max(0, Math.min(1, props.progress()));
|
||||
const offset = createMemo(() => circumference() * (1 - progress()));
|
||||
const color = () => props.color ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size()}
|
||||
height={size()}
|
||||
viewBox={`0 0 ${size()} ${size()}`}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(progress() * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={props.ariaLabel ?? 'progress'}
|
||||
>
|
||||
<circle
|
||||
cx={size() / 2}
|
||||
cy={size() / 2}
|
||||
r={radius()}
|
||||
stroke="var(--bg-3)"
|
||||
stroke-width={strokeWidth()}
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={size() / 2}
|
||||
cy={size() / 2}
|
||||
r={radius()}
|
||||
stroke={color()}
|
||||
stroke-width={strokeWidth()}
|
||||
fill="none"
|
||||
stroke-dasharray={`${circumference()}`}
|
||||
stroke-dashoffset={`${offset()}`}
|
||||
stroke-linecap="round"
|
||||
transform={`rotate(-90 ${size() / 2} ${size() / 2})`}
|
||||
style={{ transition: 'stroke-dashoffset 120ms linear' }}
|
||||
/>
|
||||
{props.showLabel && (
|
||||
<text
|
||||
x={size() / 2}
|
||||
y={size() / 2}
|
||||
dominant-baseline="central"
|
||||
text-anchor="middle"
|
||||
font-family="var(--font-mono)"
|
||||
font-size={`${Math.round(size() / 3.5)}`}
|
||||
fill="var(--fg-mute)"
|
||||
>
|
||||
{Math.round(progress() * 100)}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
15
playground/src/primitives/Slider.demo.tsx
Normal file
15
playground/src/primitives/Slider.demo.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
export const SliderDemo: Component = () => {
|
||||
const [linear, setLinear] = createSignal(0.5);
|
||||
const [exp, setExp] = createSignal(20);
|
||||
const [neg, setNeg] = createSignal(0);
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px', 'min-width': '280px' }}>
|
||||
<Slider value={linear()} onChange={setLinear} min={0} max={1} step={0.01} label="linear" />
|
||||
<Slider value={exp()} onChange={setExp} min={1} max={1000} curve="exp" label="exp (1..1000)" unit="hz" />
|
||||
<Slider value={neg()} onChange={setNeg} min={-50} max={50} step={1} label="bipolar (-50..50)" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
playground/src/primitives/Slider.module.css
Normal file
96
playground/src/primitives/Slider.module.css
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--fg);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--fg-mute);
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right, var(--accent) 0%, var(--accent) calc(var(--sp, 0) * 100%), var(--bg-3) 0%);
|
||||
background-color: var(--bg-3);
|
||||
}
|
||||
|
||||
.input::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
}
|
||||
|
||||
.input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-top: -6px;
|
||||
box-shadow: 0 0 8px rgba(255, 106, 0, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.input::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
box-shadow: 0 0 8px rgba(255, 106, 0, 0.4);
|
||||
}
|
||||
|
||||
.input:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.3);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--fg-mute);
|
||||
min-width: 4ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: var(--fg-dim);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
92
playground/src/primitives/Slider.tsx
Normal file
92
playground/src/primitives/Slider.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Component, createMemo, Show } from 'solid-js';
|
||||
import { applyCurve, type CurveName } from '../output/curves';
|
||||
import styles from './Slider.module.css';
|
||||
|
||||
export interface SliderProps {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
/**
|
||||
* If set, the slider position is normalised to [0,1], passed through the
|
||||
* named curve, then mapped to [min, max]. Setter receives the **mapped**
|
||||
* value. Reading `value` is also expected in the mapped range.
|
||||
*/
|
||||
curve?: CurveName;
|
||||
curveParam?: number;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
/** Optional precision for display (decimals). */
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
export const Slider: Component<SliderProps> = (props) => {
|
||||
const min = () => props.min;
|
||||
const max = () => props.max;
|
||||
const step = () => props.step ?? (max() - min()) / 100;
|
||||
const curve = () => props.curve ?? 'linear';
|
||||
const curveParam = () => props.curveParam;
|
||||
|
||||
// Display value can use a smaller step granularity than the slider itself.
|
||||
const display = createMemo(() => {
|
||||
const v = props.value;
|
||||
const d = props.decimals;
|
||||
if (d === undefined) {
|
||||
const range = max() - min();
|
||||
const decimals = range >= 100 ? 0 : range >= 10 ? 1 : range >= 1 ? 2 : 3;
|
||||
return v.toFixed(decimals);
|
||||
}
|
||||
return v.toFixed(d);
|
||||
});
|
||||
|
||||
// Slider input is always [0,1] for curve mapping, mapped to [min,max].
|
||||
const sliderPos = createMemo(() => {
|
||||
const range = max() - min();
|
||||
if (range <= 0) return 0;
|
||||
const norm = (props.value - min()) / range;
|
||||
if (curve() === 'linear') return norm;
|
||||
// Inverse: we don't have inverse for all curves. For linear we round-trip
|
||||
// exactly; for others we accept that the slider position is a *forward*
|
||||
// mapping (snaps to nearest). Use raw normalised position for simplicity.
|
||||
return norm;
|
||||
});
|
||||
|
||||
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||
const norm = parseFloat(e.currentTarget.value);
|
||||
let value: number;
|
||||
if (curve() === 'linear') {
|
||||
value = min() + norm * (max() - min());
|
||||
} else {
|
||||
const curved = applyCurve(curve(), norm, curveParam());
|
||||
value = min() + curved * (max() - min());
|
||||
}
|
||||
props.onChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<label class={styles.wrap} classList={{ [styles.disabled]: !!props.disabled }}>
|
||||
<Show when={props.label}>
|
||||
<span class={styles.label}>{props.label}</span>
|
||||
</Show>
|
||||
<div class={styles.row}>
|
||||
<input
|
||||
class={styles.input}
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={step() / Math.max(0.000001, max() - min())}
|
||||
value={sliderPos()}
|
||||
onInput={handleInput}
|
||||
disabled={props.disabled}
|
||||
aria-label={props.ariaLabel ?? props.label ?? 'slider'}
|
||||
/>
|
||||
<span class={styles.value}>
|
||||
{display()}{props.unit ? <span class={styles.unit}>{props.unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
24
playground/src/primitives/SliderBank.demo.tsx
Normal file
24
playground/src/primitives/SliderBank.demo.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { SliderBank, type SliderConfig } from './SliderBank';
|
||||
|
||||
const CFG: SliderConfig[] = [
|
||||
{ id: 'cutoff', label: 'Cutoff', min: 20, max: 20000, curve: 'exp', section: 'Filter', unit: 'Hz' },
|
||||
{ id: 'res', label: 'Resonance', min: 0, max: 1, step: 0.01, section: 'Filter' },
|
||||
{ id: 'attack', label: 'Attack', min: 0, max: 5, step: 0.01, section: 'Envelope', unit: 's' },
|
||||
{ id: 'decay', label: 'Decay', min: 0, max: 5, step: 0.01, section: 'Envelope', unit: 's' },
|
||||
{ id: 'sustain', label: 'Sustain', min: 0, max: 1, step: 0.01, section: 'Envelope' },
|
||||
{ id: 'release', label: 'Release', min: 0, max: 5, step: 0.01, section: 'Envelope', unit: 's' },
|
||||
];
|
||||
|
||||
export const SliderBankDemo: Component = () => {
|
||||
const [values, setValues] = createSignal<number[]>([8000, 0.3, 0.05, 0.4, 0.7, 1.0]);
|
||||
return (
|
||||
<SliderBank
|
||||
sliders={CFG}
|
||||
values={values}
|
||||
onChange={(_id, v, i) => setValues((prev) => prev.map((x, j) => (j === i ? v : x)))}
|
||||
title="Synth"
|
||||
collapsedSections={[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
56
playground/src/primitives/SliderBank.module.css
Normal file
56
playground/src/primitives/SliderBank.module.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
.bank {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--fs-md);
|
||||
margin: 0 0 var(--sp-2) 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
|
||||
.caret {
|
||||
width: 1ch;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sectionName {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sectionCount {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
99
playground/src/primitives/SliderBank.tsx
Normal file
99
playground/src/primitives/SliderBank.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
31
playground/src/primitives/TrainingControls.demo.tsx
Normal file
31
playground/src/primitives/TrainingControls.demo.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { TrainingControls } from './TrainingControls';
|
||||
|
||||
export const TrainingControlsDemo: Component = () => {
|
||||
const [count, setCount] = createSignal(3);
|
||||
const [loss, setLoss] = createSignal<number | null>(0.0123);
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [stack, setStack] = createSignal<number[]>([]);
|
||||
|
||||
const train = () => {
|
||||
setBusy(true);
|
||||
setTimeout(() => {
|
||||
setLoss(Math.random() * 0.05);
|
||||
setBusy(false);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
return (
|
||||
<TrainingControls
|
||||
onTrain={train}
|
||||
onRandomize={() => setLoss(0.5)}
|
||||
onThumbsUp={() => { setStack((s) => [...s, count()]); setCount((c) => c + 1); train(); }}
|
||||
onThumbsDown={() => { setStack((s) => [...s, count()]); setLoss((l) => (l ?? 0) * 1.4); }}
|
||||
onUndo={() => { const prev = [...stack()]; const v = prev.pop(); if (v !== undefined) setCount(v); setStack(prev); }}
|
||||
exampleCount={count}
|
||||
lastLoss={loss}
|
||||
busy={busy}
|
||||
canUndo={() => stack().length > 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
85
playground/src/primitives/TrainingControls.module.css
Normal file
85
playground/src/primitives/TrainingControls.module.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-3);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
font-size: var(--fs-lg);
|
||||
border-radius: var(--r-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thumbsUp {
|
||||
background: rgba(107, 194, 107, 0.16);
|
||||
border-color: rgba(107, 194, 107, 0.4);
|
||||
color: var(--good);
|
||||
}
|
||||
.thumbsUp:hover:not(:disabled) {
|
||||
background: rgba(107, 194, 107, 0.28);
|
||||
}
|
||||
|
||||
.thumbsDown {
|
||||
background: rgba(239, 91, 91, 0.16);
|
||||
border-color: rgba(239, 91, 91, 0.4);
|
||||
color: var(--bad);
|
||||
}
|
||||
.thumbsDown:hover:not(:disabled) {
|
||||
background: rgba(239, 91, 91, 0.28);
|
||||
}
|
||||
|
||||
.undo {
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-mute);
|
||||
}
|
||||
|
||||
.btnAlt {
|
||||
flex: 1;
|
||||
font-size: var(--fs-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-2);
|
||||
border-top: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.statusItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.statusValue {
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.busy {
|
||||
color: var(--accent-2);
|
||||
align-self: center;
|
||||
font-style: italic;
|
||||
}
|
||||
81
playground/src/primitives/TrainingControls.tsx
Normal file
81
playground/src/primitives/TrainingControls.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Component, Show } from 'solid-js';
|
||||
import styles from './TrainingControls.module.css';
|
||||
|
||||
export interface TrainingControlsProps {
|
||||
onTrain: () => void;
|
||||
onRandomize: () => void;
|
||||
onThumbsUp: () => void;
|
||||
onThumbsDown: () => void;
|
||||
onUndo: () => void;
|
||||
exampleCount: () => number;
|
||||
lastLoss: () => number | null;
|
||||
/** Whether training is currently in progress (disables actions). */
|
||||
busy?: () => boolean;
|
||||
/** Whether undo stack is non-empty. */
|
||||
canUndo?: () => boolean;
|
||||
}
|
||||
|
||||
export const TrainingControls: Component<TrainingControlsProps> = (props) => {
|
||||
const busy = () => props.busy?.() ?? false;
|
||||
const canUndo = () => props.canUndo?.() ?? true;
|
||||
|
||||
return (
|
||||
<div class={styles.wrap} role="toolbar" aria-label="Training controls">
|
||||
<div class={styles.row}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.btn} ${styles.thumbsDown}`}
|
||||
onClick={props.onThumbsDown}
|
||||
disabled={busy()}
|
||||
aria-label="Thumbs down (explore)"
|
||||
title="Thumbs down — explore (key: 2)"
|
||||
>−</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.btn} ${styles.thumbsUp}`}
|
||||
onClick={props.onThumbsUp}
|
||||
disabled={busy()}
|
||||
aria-label="Thumbs up (train)"
|
||||
title="Thumbs up — train (key: 1)"
|
||||
>+</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.btn} ${styles.undo}`}
|
||||
onClick={props.onUndo}
|
||||
disabled={busy() || !canUndo()}
|
||||
aria-label="Undo"
|
||||
title="Undo (key: Z)"
|
||||
>↶</button>
|
||||
</div>
|
||||
<div class={styles.row}>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.btnAlt}
|
||||
onClick={props.onTrain}
|
||||
disabled={busy()}
|
||||
>Train</button>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.btnAlt}
|
||||
onClick={props.onRandomize}
|
||||
disabled={busy()}
|
||||
>Randomise</button>
|
||||
</div>
|
||||
<div class={styles.status}>
|
||||
<span class={styles.statusItem}>
|
||||
<span class={styles.statusLabel}>examples</span>
|
||||
<span class={styles.statusValue}>{props.exampleCount()}</span>
|
||||
</span>
|
||||
<Show when={props.lastLoss() !== null}>
|
||||
<span class={styles.statusItem}>
|
||||
<span class={styles.statusLabel}>loss</span>
|
||||
<span class={styles.statusValue}>{props.lastLoss()!.toExponential(2)}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={busy()}>
|
||||
<span class={`${styles.statusItem} ${styles.busy}`}>training…</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
playground/src/primitives/VirtualJoystick.demo.tsx
Normal file
18
playground/src/primitives/VirtualJoystick.demo.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { VirtualJoystick } from './VirtualJoystick';
|
||||
|
||||
export const VirtualJoystickDemo: Component = () => {
|
||||
const [pos, setPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'center' }}>
|
||||
<VirtualJoystick
|
||||
size={200}
|
||||
position={pos}
|
||||
onMove={(x, y) => setPos([x, y])}
|
||||
/>
|
||||
<code style={{ 'font-size': '11px', color: 'var(--fg-mute)' }}>
|
||||
x={pos()[0].toFixed(3)} y={pos()[1].toFixed(3)}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
playground/src/primitives/VirtualJoystick.module.css
Normal file
71
playground/src/primitives/VirtualJoystick.module.css
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
.container {
|
||||
position: relative;
|
||||
background: var(--bg-1);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.3);
|
||||
}
|
||||
|
||||
.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boundary {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed var(--line-strong);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.hLine, .vLine {
|
||||
position: absolute;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.hLine {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.vLine {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.knob {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(255, 106, 0, 0.5);
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
124
playground/src/primitives/VirtualJoystick.tsx
Normal file
124
playground/src/primitives/VirtualJoystick.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Component, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import styles from './VirtualJoystick.module.css';
|
||||
|
||||
export interface VirtualJoystickProps {
|
||||
onMove: (x: number, y: number) => void;
|
||||
/** Optional controlled position [x, y] in [0,1]. */
|
||||
position?: () => readonly [number, number];
|
||||
disabled?: boolean;
|
||||
/** Pixel size of the square joystick area. */
|
||||
size?: number;
|
||||
/** Label for assistive tech. */
|
||||
ariaLabel?: string;
|
||||
/** Called on pointer up. */
|
||||
onRelease?: () => void;
|
||||
/** Called on pointer down (good for snapshots). */
|
||||
onGrab?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SIZE = 200;
|
||||
|
||||
export const VirtualJoystick: Component<VirtualJoystickProps> = (props) => {
|
||||
const size = () => props.size ?? DEFAULT_SIZE;
|
||||
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 = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1 - (e.clientY - rect.top) / rect.height; // y-axis up
|
||||
const cx = Math.max(0, Math.min(1, x));
|
||||
const cy = Math.max(0, Math.min(1, y));
|
||||
// Constrain to circle
|
||||
const dx = cx - 0.5;
|
||||
const dy = cy - 0.5;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
let outX = cx;
|
||||
let outY = cy;
|
||||
if (dist > 0.5 && dist > 1e-12) {
|
||||
outX = 0.5 + (dx / dist) * 0.5;
|
||||
outY = 0.5 + (dy / dist) * 0.5;
|
||||
}
|
||||
if (props.position === undefined) setInternalPos([outX, outY]);
|
||||
props.onMove(outX, outY);
|
||||
};
|
||||
|
||||
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?.();
|
||||
};
|
||||
|
||||
// Keyboard accessibility: arrow keys nudge.
|
||||
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 knobStyle = 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.container}
|
||||
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 ?? 'virtual joystick'}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<div class={styles.boundary} />
|
||||
<div class={styles.crosshair} aria-hidden="true">
|
||||
<div class={styles.hLine} />
|
||||
<div class={styles.vLine} />
|
||||
</div>
|
||||
<div class={styles.knob} style={knobStyle()} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
playground/src/primitives/WeightHealth.demo.tsx
Normal file
38
playground/src/primitives/WeightHealth.demo.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Component, createMemo, createSignal } from 'solid-js';
|
||||
import { WeightHealth, type WeightStatus } from './WeightHealth';
|
||||
import { PillToggle } from './PillToggle';
|
||||
|
||||
export const WeightHealthDemo: Component = () => {
|
||||
const [status, setStatus] = createSignal<WeightStatus>('healthy');
|
||||
|
||||
const histogram = createMemo<number[]>(() => {
|
||||
const bins = new Array(10).fill(0);
|
||||
const s = status();
|
||||
if (s === 'dead') {
|
||||
bins[0] = 80; bins[1] = 12; bins[2] = 4; bins[3] = 2;
|
||||
} else if (s === 'saturating') {
|
||||
bins[7] = 12; bins[8] = 24; bins[9] = 60;
|
||||
} else {
|
||||
// healthy bell-ish
|
||||
for (let i = 0; i < 10; i++) {
|
||||
bins[i] = 30 * Math.exp(-Math.pow((i - 4.5) / 2.5, 2));
|
||||
}
|
||||
}
|
||||
return bins;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' }}>
|
||||
<WeightHealth histogram={histogram} status={status} />
|
||||
<PillToggle
|
||||
options={[
|
||||
{ value: 'healthy', label: 'Healthy' },
|
||||
{ value: 'dead', label: 'Dead' },
|
||||
{ value: 'saturating', label: 'Saturating' },
|
||||
]}
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as WeightStatus)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
playground/src/primitives/WeightHealth.module.css
Normal file
37
playground/src/primitives/WeightHealth.module.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
background: var(--bg-2);
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
.label {
|
||||
align-self: center;
|
||||
padding: 4px 12px;
|
||||
font-size: var(--fs-xs);
|
||||
letter-spacing: 0.1em;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-2);
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
transition: box-shadow var(--dur-med) var(--ease);
|
||||
}
|
||||
|
||||
.pulsing {
|
||||
animation: pulse 1.6s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
75
playground/src/primitives/WeightHealth.tsx
Normal file
75
playground/src/primitives/WeightHealth.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Component, createEffect, createMemo } from 'solid-js';
|
||||
import styles from './WeightHealth.module.css';
|
||||
|
||||
export type WeightStatus = 'dead' | 'saturating' | 'healthy';
|
||||
|
||||
export interface WeightHealthProps {
|
||||
/** 10-bin histogram of weight magnitudes. */
|
||||
histogram: () => ReadonlyArray<number>;
|
||||
status: () => WeightStatus;
|
||||
/** Pixel width of histogram. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<WeightStatus, { glow: string; text: string }> = {
|
||||
dead: { glow: 'rgba(140, 140, 140, 0.45)', text: 'DEAD' },
|
||||
saturating: { glow: 'rgba(245, 196, 94, 0.6)', text: 'SATURATING' },
|
||||
healthy: { glow: 'rgba(107, 194, 107, 0.45)', text: 'HEALTHY' },
|
||||
};
|
||||
|
||||
export const WeightHealth: Component<WeightHealthProps> = (props) => {
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
const status = () => props.status();
|
||||
|
||||
createEffect(() => {
|
||||
if (!canvasEl) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = (props.width ?? 200) * dpr;
|
||||
const h = (props.height ?? 60) * dpr;
|
||||
if (canvasEl.width !== w || canvasEl.height !== h) {
|
||||
canvasEl.width = w;
|
||||
canvasEl.height = h;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const bins = props.histogram();
|
||||
if (bins.length === 0) return;
|
||||
let maxBin = 0;
|
||||
for (const b of bins) if (b > maxBin) maxBin = b;
|
||||
if (maxBin <= 0) maxBin = 1;
|
||||
|
||||
const colors: Record<WeightStatus, string> = {
|
||||
dead: 'rgba(140, 140, 140, 0.7)',
|
||||
saturating: 'rgba(245, 196, 94, 0.85)',
|
||||
healthy: 'rgba(107, 194, 107, 0.85)',
|
||||
};
|
||||
ctx.fillStyle = colors[status()];
|
||||
const binW = w / bins.length;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const bh = (bins[i]! / maxBin) * h;
|
||||
ctx.fillRect(i * binW + 1, h - bh, binW - 2, bh);
|
||||
}
|
||||
});
|
||||
|
||||
const glowStyle = createMemo(() => `box-shadow: 0 0 18px ${STATUS_COLORS[status()].glow};`);
|
||||
|
||||
return (
|
||||
<div class={styles.wrap}>
|
||||
<canvas
|
||||
ref={canvasEl}
|
||||
class={styles.canvas}
|
||||
classList={{ [styles.pulsing]: status() === 'saturating' }}
|
||||
style={{ width: `${props.width ?? 200}px`, height: `${props.height ?? 60}px` }}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel ?? 'Weight health histogram'}
|
||||
/>
|
||||
<div class={styles.label} style={glowStyle()}>
|
||||
{STATUS_COLORS[status()].text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
playground/src/primitives/XYPad.demo.tsx
Normal file
14
playground/src/primitives/XYPad.demo.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Component, createSignal } from 'solid-js';
|
||||
import { XYPad } from './XYPad';
|
||||
|
||||
export const XYPadDemo: Component = () => {
|
||||
const [pos, setPos] = createSignal<readonly [number, number]>([0.5, 0.5]);
|
||||
return (
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'align-items': 'center' }}>
|
||||
<XYPad size={240} position={pos} onMove={(x, y) => setPos([x, y])} />
|
||||
<code style={{ 'font-size': '11px', color: 'var(--fg-mute)' }}>
|
||||
x={pos()[0].toFixed(3)} y={pos()[1].toFixed(3)}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
playground/src/primitives/XYPad.module.css
Normal file
63
playground/src/primitives/XYPad.module.css
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
.pad {
|
||||
position: relative;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pad:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.3);
|
||||
}
|
||||
|
||||
.dragging {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gridH, .gridV {
|
||||
position: absolute;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.gridH {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.gridV {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-2);
|
||||
box-shadow: 0 0 10px rgba(0, 204, 255, 0.5);
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
108
playground/src/primitives/XYPad.tsx
Normal file
108
playground/src/primitives/XYPad.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
23
playground/src/primitives/index.ts
Normal file
23
playground/src/primitives/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Primitive library entry-point.
|
||||
*
|
||||
* Re-exports every UI primitive together with its props type. Modes and the
|
||||
* dev showcase import everything from here.
|
||||
*/
|
||||
|
||||
export { Slider, type SliderProps } from './Slider';
|
||||
export { SliderBank, type SliderBankProps, type SliderConfig } from './SliderBank';
|
||||
export { VirtualJoystick, type VirtualJoystickProps } from './VirtualJoystick';
|
||||
export { XYPad, type XYPadProps } from './XYPad';
|
||||
export { Heatmap, type HeatmapProps, type HeatmapColorMode } from './Heatmap';
|
||||
export { OutputDisplay, type OutputDisplayProps } from './OutputDisplay';
|
||||
export { TrainingControls, type TrainingControlsProps } from './TrainingControls';
|
||||
export { Drawer, type DrawerProps } from './Drawer';
|
||||
export { ControlAxis, type ControlAxisProps } from './ControlAxis';
|
||||
export { ProgressRing, type ProgressRingProps } from './ProgressRing';
|
||||
export { PillToggle, type PillToggleProps, type PillToggleOption } from './PillToggle';
|
||||
export { ParamEditor, type ParamEditorProps, type ParamDef, type ParamOverride } from './ParamEditor';
|
||||
export { JoyMap, type JoyMapProps, type TrailPoint, type RegionPin } from './JoyMap';
|
||||
export { WeightHealth, type WeightHealthProps, type WeightStatus } from './WeightHealth';
|
||||
export { GradientFlow, type GradientFlowProps, type GradientStatus } from './GradientFlow';
|
||||
export { LossPlot, type LossPlotProps } from './LossPlot';
|
||||
Loading…
Reference in a new issue