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/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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue