fix(manifold): make particle outputs interactive
This commit is contained in:
parent
a7b6230b8b
commit
5ed84cbf00
5 changed files with 130 additions and 28 deletions
2
MAP.md
2
MAP.md
|
|
@ -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/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
| 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
|
||||
CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins
|
||||
|
|
|
|||
|
|
@ -1074,7 +1074,13 @@ export function ConsoleApp() {
|
|||
</div>
|
||||
</div>
|
||||
) : outputMode === 'particles' ? (
|
||||
<ParticleStage pos={pos} onMove={onMove} />
|
||||
<ParticleStage
|
||||
pos={pos}
|
||||
onMove={onMove}
|
||||
params={displayedParams}
|
||||
values={displayedValues}
|
||||
onChange={setParam}
|
||||
/>
|
||||
) : (
|
||||
<CompositeStage
|
||||
split={split}
|
||||
|
|
|
|||
|
|
@ -232,18 +232,6 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu
|
|||
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 && (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@
|
|||
* churn React state every frame.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useEngine } from '../engine';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import type { MFParam } from './model';
|
||||
import {
|
||||
FlowFieldVisualizer,
|
||||
N_VISUAL_OUTPUTS,
|
||||
|
|
@ -30,17 +32,96 @@ import {
|
|||
export interface ParticleStageProps {
|
||||
pos: [number, number];
|
||||
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const vizRef = useRef<FlowFieldVisualizer | null>(null);
|
||||
const barsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const outputsRef = useRef<Float32Array | null>(null);
|
||||
const valuesRef = useRef(values);
|
||||
const hoverRef = useRef<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(() => {
|
||||
const canvas = canvasRef.current;
|
||||
|
|
@ -53,16 +134,17 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
|
|||
const outputs = engine?.getOutputs();
|
||||
if (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).
|
||||
for (let i = 0; i < N_VISUAL_OUTPUTS; 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.
|
||||
const h = hoverRef.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();
|
||||
|
|
@ -80,6 +162,12 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
|
|||
};
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clampTimer.current) clearTimeout(clampTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}>
|
||||
{/* Main view — the flow-field particle system */}
|
||||
|
|
@ -122,11 +210,16 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
|
|||
{VISUAL_PARAM_NAMES.map((name, i) => (
|
||||
<div
|
||||
key={name}
|
||||
title={name}
|
||||
onPointerEnter={() => {
|
||||
hoverRef.current = i;
|
||||
setHover(i);
|
||||
}}
|
||||
role="slider"
|
||||
aria-label={name}
|
||||
aria-valuemin={0}
|
||||
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={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
|
|
@ -134,7 +227,8 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
|
|||
height: 16,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
cursor: 'ew-resize',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -150,6 +244,20 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
|
|||
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>
|
||||
|
|
@ -159,9 +267,9 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
|
|||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 28,
|
||||
left: 8,
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x,
|
||||
top: tooltipPos.y,
|
||||
zIndex: 25,
|
||||
padding: '4px 10px',
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
|
|
|
|||
Loading…
Reference in a new issue