fix(manifold): particle top bar = a-immersive heatmap strip, not macro axes

ParticleStage showed the Boldness/Memory/Precision macro sliders across the
top, but the a-immersive design this mode clones uses a thin per-output
heatmap strip (one colored bar per visual output, width = live value). The
deployed a-immersive never used the macro-axis surface.

- flow-field.ts: export VISUAL_PARAM_NAMES/COLORS + N_VISUAL_OUTPUTS (verbatim
  from a-app.js:46/51)
- ParticleStage: replace macro-slider bar with the 22px heatmap strip; bar
  widths updated imperatively in the existing rAF loop; hover tooltip with
  live value; drop unused axes props
- ConsoleApp: update call site
This commit is contained in:
monkey-w1n5t0n 2026-06-28 20:34:34 +02:00
parent 572102c4c0
commit 60220e9176
3 changed files with 130 additions and 56 deletions

View file

@ -719,12 +719,7 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
}} }}
> >
{outputMode === 'particles' ? ( {outputMode === 'particles' ? (
<ParticleStage <ParticleStage pos={pos} onMove={onMove} />
pos={pos}
onMove={onMove}
axes={axes}
setAxis={(k, v) => setAxes((s) => ({ ...s, [k]: v }))}
/>
) : focus === 'composite' ? ( ) : focus === 'composite' ? (
<CompositeStage <CompositeStage
split={split} split={split}

View file

@ -4,32 +4,43 @@
* Mirrors the a-immersive playground layout: * Mirrors the a-immersive playground layout:
* a full-bleed Canvas2D flow-field particle system (the main view), driven * a full-bleed Canvas2D flow-field particle system (the main view), driven
* by the live model outputs (first 20) read each animation frame; * by the live model outputs (first 20) read each animation frame;
* a horizontal macro-axis slider bar across the top (Boldness / Memory / * a thin horizontal heatmap strip across the top, one colored bar per
* Precision), the same compound axes the rest of the console uses; * visual output (Flow / Scale / Speed / ), each bar's width = its live
* value the same `.heatmap-strip` the a-immersive app shows above the
* flow field (NOT the Boldness/Memory/Precision macro axes, which the
* deployed a-immersive never used);
* a small circular pad in the bottom-left corner that drives the 2D input * a small circular pad in the bottom-left corner that drives the 2D input
* (engine.setInput) the "joystick" of the immersive app. * (engine.setInput) the "joystick" of the immersive app.
* *
* The canvas animates on its own rAF clock so particles keep flowing between * The canvas animates on its own rAF clock so particles keep flowing between
* inferences; only the *field* parameters change when the MLP outputs do. * inferences; only the *field* parameters change when the MLP outputs do. The
* heatmap bar widths are driven imperatively from the same loop so we don't
* churn React state every frame.
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useEngine } from '../engine'; import { useEngine } from '../engine';
import { ControlAxis } from '../primitives/ControlAxis';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { FlowFieldVisualizer } from './flow-field'; import {
import type { Axes } from './types'; FlowFieldVisualizer,
N_VISUAL_OUTPUTS,
VISUAL_PARAM_COLORS,
VISUAL_PARAM_NAMES,
} from './flow-field';
export interface ParticleStageProps { export interface ParticleStageProps {
pos: [number, number]; pos: [number, number];
onMove: (x: number, y: number) => void; onMove: (x: number, y: number) => void;
axes: Axes;
setAxis: (k: keyof Axes, v: number) => void;
} }
export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps) { export function ParticleStage({ pos, onMove }: 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 tooltipRef = useRef<HTMLDivElement>(null);
const outputsRef = useRef<Float32Array | null>(null);
const hoverRef = useRef<number | null>(null);
const [hover, setHover] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@ -40,7 +51,20 @@ export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps
let raf = 0; let raf = 0;
const tick = () => { const tick = () => {
const outputs = engine?.getOutputs(); const outputs = engine?.getOutputs();
if (outputs) viz.setParams(outputs); if (outputs) {
outputsRef.current = outputs;
viz.setParams(outputs);
// 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}%`;
}
// 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)}`;
}
}
viz.draw(); viz.draw();
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
}; };
@ -64,7 +88,7 @@ export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/> />
{/* Top horizontal macro-axis slider bar (Boldness / Memory / Precision) */} {/* Top heatmap strip — one colored bar per visual output, width = value */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -73,51 +97,86 @@ export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps
right: 0, right: 0,
zIndex: 20, zIndex: 20,
display: 'flex', display: 'flex',
gap: 'var(--sp-2)', alignItems: 'flex-end',
padding: 'var(--sp-2) var(--sp-3)', height: 22,
alignItems: 'center', padding: '2px 4px',
background: 'var(--glass)', background: 'var(--glass, rgba(13,13,13,0.5))',
backdropFilter: 'blur(14px)', backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(8px)',
borderBottom: '1px solid var(--line)', }}
onPointerLeave={() => {
hoverRef.current = null;
setHover(null);
}} }}
> >
<strong <div
style={{ style={{
color: 'var(--accent)', display: 'flex',
fontSize: 'var(--fs-md)', flex: 1,
fontFamily: 'var(--font-mono)', gap: 1,
whiteSpace: 'nowrap', height: 16,
paddingRight: 'var(--sp-2)', borderRadius: 3,
overflow: 'hidden',
}} }}
> >
MEMLNaut {VISUAL_PARAM_NAMES.map((name, i) => (
</strong> <div
<ControlAxis key={name}
label="Boldness" title={name}
endpoints={['Caution', 'Bold']} onPointerEnter={() => {
value={axes.boldness} hoverRef.current = i;
onChange={(v) => setAxis('boldness', v)} setHover(i);
style={{ flex: 1 }} }}
/> style={{
<ControlAxis position: 'relative',
label="Memory" flex: 1,
endpoints={['Amnesia', 'Elephant']} minWidth: 0,
value={axes.memory} height: 16,
onChange={(v) => setAxis('memory', v)} background: 'rgba(255,255,255,0.04)',
accent="var(--accent-2)" overflow: 'hidden',
style={{ flex: 1 }} cursor: 'pointer',
/> }}
<ControlAxis >
label="Precision" <div
endpoints={['Raw', 'Precise']} ref={(el) => {
value={axes.precision} barsRef.current[i] = el;
onChange={(v) => setAxis('precision', v)} }}
accent="var(--ok, var(--accent))" style={{
style={{ flex: 1 }} height: '100%',
/> width: '30%',
background: VISUAL_PARAM_COLORS[i],
borderRadius: '0 1px 1px 0',
filter: hover === i ? 'brightness(1.3)' : 'none',
pointerEvents: 'none',
}}
/>
</div>
))}
</div>
</div> </div>
{/* Hover tooltip (name + live value) */}
<div
ref={tooltipRef}
style={{
position: 'absolute',
top: 28,
left: 8,
zIndex: 25,
padding: '4px 10px',
background: 'rgba(0,0,0,0.85)',
border: '1px solid var(--line, rgba(255,255,255,0.12))',
borderRadius: 6,
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--fg, #eee)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
opacity: hover != null ? 1 : 0,
transition: 'opacity 0.15s',
}}
/>
{/* Bottom-left circular pad — drives the 2D input */} {/* Bottom-left circular pad — drives the 2D input */}
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}> <div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}>
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" /> <VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" />

View file

@ -69,6 +69,26 @@ function noise2D(x: number, y: number): number {
const TWO_PI = Math.PI * 2; const TWO_PI = Math.PI * 2;
/**
* The 20 visual output params, in output order (p0..p19). Names and colours are
* verbatim from the a-immersive original (`VISUAL_PARAM_NAMES` /
* `VISUAL_PARAM_COLORS`, a-app.js:46/51) so the heatmap strip matches it 1:1.
*/
export const VISUAL_PARAM_NAMES = [
'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb',
'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn',
'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate',
] as const;
export const VISUAL_PARAM_COLORS = [
'#ff6a00', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00',
'#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff',
'#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f',
'#ffc15f', '#ff8a3d',
] as const;
export const N_VISUAL_OUTPUTS = VISUAL_PARAM_NAMES.length;
interface Particle { interface Particle {
x: number; x: number;
y: number; y: number;