diff --git a/MAP.md b/MAP.md
index b5fa53c..27b547e 100644
--- a/MAP.md
+++ b/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
diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md
index 1b83dd8..9ed0d59 100644
--- a/manifold/ONBOARDING.md
+++ b/manifold/ONBOARDING.md
@@ -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
diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx
index 782cbb6..fab81c7 100644
--- a/manifold/src/console/ConsoleApp.tsx
+++ b/manifold/src/console/ConsoleApp.tsx
@@ -1074,7 +1074,13 @@ export function ConsoleApp() {
) : outputMode === 'particles' ? (
-
+
) : (
- {p.status === 'live' && (
-
- )}
{clampMarker?.id === p.id && (
void;
+ params: MFParam[];
+ values: number[];
+ onChange: (i: number, patch: Partial
) => void;
}
-export function ParticleStage({ pos, onMove }: ParticleStageProps) {
+export function ParticleStage({ pos, onMove, params, values, onChange }: ParticleStageProps) {
const engine = useEngine();
const canvasRef = useRef(null);
const vizRef = useRef(null);
const barsRef = useRef<(HTMLDivElement | null)[]>([]);
const tooltipRef = useRef(null);
const outputsRef = useRef(null);
+ const valuesRef = useRef(values);
const hoverRef = useRef(null);
const [hover, setHover] = useState(null);
+ const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 });
+ const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null);
+ const clampTimer = useRef | 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) => {
+ 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, 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, 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, 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 (
{/* Main view — the flow-field particle system */}
@@ -122,11 +210,16 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {
{VISUAL_PARAM_NAMES.map((name, i) => (
{
- 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',
}}
>
+ {clampMarker?.i === i && (
+
+ )}
))}
@@ -159,9 +267,9 @@ export function ParticleStage({ pos, onMove }: ParticleStageProps) {