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) {