fix(manifold): make particle outputs interactive

This commit is contained in:
monkey-w1n5t0n 2026-07-25 15:30:35 +02:00
parent a7b6230b8b
commit 5ed84cbf00
5 changed files with 130 additions and 28 deletions

2
MAP.md
View file

@ -47,7 +47,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
- `manifold/src/primitives/` — the 7 design primitives as typed React (Badge, Button, PillToggle, Slider, Switch, VirtualJoystick, XYPad). Five unused ones were deleted in the 2026-07 sweep (L22). - `manifold/src/primitives/` — the 7 design primitives as typed React (Badge, Button, PillToggle, Slider, Switch, VirtualJoystick, XYPad). Five unused ones were deleted in the 2026-07 sweep (L22).
- `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible - `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas, with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
rect↔circular + feedback markers), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
(Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and (Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and
expanded Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from expanded Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from
`nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at `nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at

View file

@ -109,7 +109,7 @@ Manifold ships a single "composite" altitude. Selection is now a plain three-way
|---|---|---|---| |---|---|---|---|
| CompositeStage | `CompositeStage.tsx` | **default / hero** | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. | | CompositeStage | `CompositeStage.tsx` | **default / hero** | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. |
| SandwichStage | `SandwichStage.tsx` | `sandwich===true` (wins over the others) | Three-pane layout: `Manifold` input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact `OutputStage` right. | | SandwichStage | `SandwichStage.tsx` | `sandwich===true` (wins over the others) | Three-pane layout: `Manifold` input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact `OutputStage` right. |
| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + macro-axis bar + corner joystick. | | ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + corner joystick. |
`Manifold.tsx` and `OutputStage.tsx` are no longer top-level stages — they are panes composed by `Manifold.tsx` and `OutputStage.tsx` are no longer top-level stages — they are panes composed by
CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins

View file

@ -1074,7 +1074,13 @@ export function ConsoleApp() {
</div> </div>
</div> </div>
) : outputMode === 'particles' ? ( ) : outputMode === 'particles' ? (
<ParticleStage pos={pos} onMove={onMove} /> <ParticleStage
pos={pos}
onMove={onMove}
params={displayedParams}
values={displayedValues}
onChange={setParam}
/>
) : ( ) : (
<CompositeStage <CompositeStage
split={split} split={split}

View file

@ -232,18 +232,6 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu
opacity: dim ? 0.4 : 0.9, opacity: dim ? 0.4 : 0.9,
}} }}
/> />
{p.status === 'live' && (
<div
style={{
position: 'absolute',
left: 1,
right: 1,
bottom: `${p.val * 100}%`,
height: 0,
borderTop: '1px dashed rgba(255,255,255,0.35)',
}}
/>
)}
{clampMarker?.id === p.id && ( {clampMarker?.id === p.id && (
<div <div
style={{ style={{

View file

@ -18,8 +18,10 @@
* churn React state every frame. * churn React state every frame.
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useEngine } from '../engine'; import { useEngine } from '../engine';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import type { MFParam } from './model';
import { import {
FlowFieldVisualizer, FlowFieldVisualizer,
N_VISUAL_OUTPUTS, N_VISUAL_OUTPUTS,
@ -30,17 +32,96 @@ import {
export interface ParticleStageProps { export interface ParticleStageProps {
pos: [number, number]; pos: [number, number];
onMove: (x: number, y: number) => void; onMove: (x: number, y: number) => void;
params: MFParam[];
values: number[];
onChange: (i: number, patch: Partial<MFParam>) => void;
} }
export function ParticleStage({ pos, onMove }: ParticleStageProps) { export function ParticleStage({ pos, onMove, params, values, onChange }: ParticleStageProps) {
const engine = useEngine(); const engine = useEngine();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const vizRef = useRef<FlowFieldVisualizer | null>(null); const vizRef = useRef<FlowFieldVisualizer | null>(null);
const barsRef = useRef<(HTMLDivElement | null)[]>([]); const barsRef = useRef<(HTMLDivElement | null)[]>([]);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const outputsRef = useRef<Float32Array | null>(null); const outputsRef = useRef<Float32Array | null>(null);
const valuesRef = useRef(values);
const hoverRef = useRef<number | null>(null); const hoverRef = useRef<number | null>(null);
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 });
const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null);
const clampTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({
i: -1,
moved: false,
startX: 0,
el: null,
});
valuesRef.current = values;
const updateHover = (i: number, e: ReactPointerEvent<HTMLDivElement>) => {
hoverRef.current = i;
setHover(i);
setTooltipPos({
x: Math.min(e.clientX + 12, Math.max(12, window.innerWidth - 190)),
y: Math.min(e.clientY + 12, Math.max(28, window.innerHeight - 32)),
});
const output = valuesRef.current[i] ?? outputsRef.current?.[i] ?? 0;
if (tooltipRef.current) tooltipRef.current.textContent = `${VISUAL_PARAM_NAMES[i]}: ${output.toFixed(3)}`;
};
const showClampMarker = (i: number, value: number) => {
if (clampTimer.current) clearTimeout(clampTimer.current);
setClampMarker({ i, value });
clampTimer.current = setTimeout(() => {
setClampMarker(null);
clampTimer.current = null;
}, 700);
};
const setSliderValue = (i: number, raw: number) => {
const param = params[i];
if (!param) return;
const min = Math.max(0, Math.min(1, Math.min(param.min, param.max)));
const max = Math.max(0, Math.min(1, Math.max(param.min, param.max)));
const value = Math.max(min, Math.min(max, raw));
if (value !== raw) showClampMarker(i, value);
else setClampMarker(null);
const span = max - min;
const fixedValue = span > 0 ? (value - min) / span : 0;
const temporary = param.manualOverride || param.status === 'live';
onChange(i, {
status: 'fixed',
val: fixedValue,
manualOverride: temporary ? true : undefined,
});
};
const valueFromEvent = (el: HTMLDivElement, clientX: number) => {
const r = el.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - r.left) / r.width));
};
const down = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
updateHover(i, e);
drag.current = { i, moved: false, startX: e.clientX, el: e.currentTarget };
};
const move = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
updateHover(i, e);
const d = drag.current;
if (d.i !== i || !d.el) return;
if (Math.abs(e.clientX - d.startX) > 3) d.moved = true;
if (d.moved) setSliderValue(i, valueFromEvent(d.el, e.clientX));
};
const up = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
const d = drag.current;
if (d.i !== i) return;
if (!d.moved && d.el) setSliderValue(i, valueFromEvent(d.el, e.clientX));
drag.current = { i: -1, moved: false, startX: 0, el: null };
};
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@ -53,16 +134,17 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
const outputs = engine?.getOutputs(); const outputs = engine?.getOutputs();
if (outputs) { if (outputs) {
outputsRef.current = outputs; outputsRef.current = outputs;
viz.setParams(outputs); const displayOutputs = valuesRef.current.length >= N_VISUAL_OUTPUTS ? valuesRef.current : outputs;
viz.setParams(displayOutputs);
// Drive the heatmap bar widths imperatively (cheap; no React churn). // Drive the heatmap bar widths imperatively (cheap; no React churn).
for (let i = 0; i < N_VISUAL_OUTPUTS; i++) { for (let i = 0; i < N_VISUAL_OUTPUTS; i++) {
const bar = barsRef.current[i]; const bar = barsRef.current[i];
if (bar) bar.style.width = `${Math.max(0, Math.min(1, outputs[i] ?? 0)) * 100}%`; if (bar) bar.style.width = `${Math.max(0, Math.min(1, displayOutputs[i] ?? 0)) * 100}%`;
} }
// Keep the tooltip value live while hovering a cell. // Keep the tooltip value live while hovering a cell.
const h = hoverRef.current; const h = hoverRef.current;
if (h != null && tooltipRef.current) { if (h != null && tooltipRef.current) {
tooltipRef.current.textContent = `${VISUAL_PARAM_NAMES[h]}: ${(outputs[h] ?? 0).toFixed(3)}`; tooltipRef.current.textContent = `${VISUAL_PARAM_NAMES[h]}: ${(displayOutputs[h] ?? 0).toFixed(3)}`;
} }
} }
viz.draw(); viz.draw();
@ -80,6 +162,12 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
}; };
}, [engine]); }, [engine]);
useEffect(() => {
return () => {
if (clampTimer.current) clearTimeout(clampTimer.current);
};
}, []);
return ( return (
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}> <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}>
{/* Main view — the flow-field particle system */} {/* Main view — the flow-field particle system */}
@ -122,11 +210,16 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
{VISUAL_PARAM_NAMES.map((name, i) => ( {VISUAL_PARAM_NAMES.map((name, i) => (
<div <div
key={name} key={name}
title={name} role="slider"
onPointerEnter={() => { aria-label={name}
hoverRef.current = i; aria-valuemin={0}
setHover(i); aria-valuemax={1}
}} aria-valuenow={values[i] ?? 0}
onPointerEnter={(e) => updateHover(i, e)}
onPointerDown={(e) => down(e, i)}
onPointerMove={(e) => move(e, i)}
onPointerUp={(e) => up(e, i)}
onPointerCancel={(e) => up(e, i)}
style={{ style={{
position: 'relative', position: 'relative',
flex: 1, flex: 1,
@ -134,7 +227,8 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
height: 16, height: 16,
background: 'rgba(255,255,255,0.04)', background: 'rgba(255,255,255,0.04)',
overflow: 'hidden', overflow: 'hidden',
cursor: 'pointer', cursor: 'ew-resize',
touchAction: 'none',
}} }}
> >
<div <div
@ -150,6 +244,20 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
pointerEvents: 'none', pointerEvents: 'none',
}} }}
/> />
{clampMarker?.i === i && (
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${clampMarker.value * 100}%`,
borderLeft: '1px dashed #ff4466',
boxShadow: '0 0 5px rgba(255,68,102,0.8)',
zIndex: 3,
pointerEvents: 'none',
}}
/>
)}
</div> </div>
))} ))}
</div> </div>
@ -159,9 +267,9 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
<div <div
ref={tooltipRef} ref={tooltipRef}
style={{ style={{
position: 'absolute', position: 'fixed',
top: 28, left: tooltipPos.x,
left: 8, top: tooltipPos.y,
zIndex: 25, zIndex: 25,
padding: '4px 10px', padding: '4px 10px',
background: 'rgba(0,0,0,0.85)', background: 'rgba(0,0,0,0.85)',