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:
parent
572102c4c0
commit
60220e9176
3 changed files with 130 additions and 56 deletions
|
|
@ -719,12 +719,7 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
|||
}}
|
||||
>
|
||||
{outputMode === 'particles' ? (
|
||||
<ParticleStage
|
||||
pos={pos}
|
||||
onMove={onMove}
|
||||
axes={axes}
|
||||
setAxis={(k, v) => setAxes((s) => ({ ...s, [k]: v }))}
|
||||
/>
|
||||
<ParticleStage pos={pos} onMove={onMove} />
|
||||
) : focus === 'composite' ? (
|
||||
<CompositeStage
|
||||
split={split}
|
||||
|
|
|
|||
|
|
@ -4,32 +4,43 @@
|
|||
* Mirrors the a-immersive playground layout:
|
||||
* • a full-bleed Canvas2D flow-field particle system (the main view), driven
|
||||
* by the live model outputs (first 20) read each animation frame;
|
||||
* • a horizontal macro-axis slider bar across the top (Boldness / Memory /
|
||||
* Precision), the same compound axes the rest of the console uses;
|
||||
* • a thin horizontal heatmap strip across the top, one colored bar per
|
||||
* 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
|
||||
* (engine.setInput) — the "joystick" of the immersive app.
|
||||
*
|
||||
* 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 { ControlAxis } from '../primitives/ControlAxis';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import { FlowFieldVisualizer } from './flow-field';
|
||||
import type { Axes } from './types';
|
||||
import {
|
||||
FlowFieldVisualizer,
|
||||
N_VISUAL_OUTPUTS,
|
||||
VISUAL_PARAM_COLORS,
|
||||
VISUAL_PARAM_NAMES,
|
||||
} from './flow-field';
|
||||
|
||||
export interface ParticleStageProps {
|
||||
pos: [number, number];
|
||||
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 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 hoverRef = useRef<number | null>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
|
|
@ -40,7 +51,20 @@ export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps
|
|||
let raf = 0;
|
||||
const tick = () => {
|
||||
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();
|
||||
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' }}
|
||||
/>
|
||||
|
||||
{/* Top horizontal macro-axis slider bar (Boldness / Memory / Precision) */}
|
||||
{/* Top heatmap strip — one colored bar per visual output, width = value */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
|
@ -73,51 +97,86 @@ export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps
|
|||
right: 0,
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
gap: 'var(--sp-2)',
|
||||
padding: 'var(--sp-2) var(--sp-3)',
|
||||
alignItems: 'center',
|
||||
background: 'var(--glass)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
WebkitBackdropFilter: 'blur(14px)',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
alignItems: 'flex-end',
|
||||
height: 22,
|
||||
padding: '2px 4px',
|
||||
background: 'var(--glass, rgba(13,13,13,0.5))',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
hoverRef.current = null;
|
||||
setHover(null);
|
||||
}}
|
||||
>
|
||||
<strong
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--fs-md)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
whiteSpace: 'nowrap',
|
||||
paddingRight: 'var(--sp-2)',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
gap: 1,
|
||||
height: 16,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
MEMLNaut
|
||||
</strong>
|
||||
<ControlAxis
|
||||
label="Boldness"
|
||||
endpoints={['Caution', 'Bold']}
|
||||
value={axes.boldness}
|
||||
onChange={(v) => setAxis('boldness', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ControlAxis
|
||||
label="Memory"
|
||||
endpoints={['Amnesia', 'Elephant']}
|
||||
value={axes.memory}
|
||||
onChange={(v) => setAxis('memory', v)}
|
||||
accent="var(--accent-2)"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ControlAxis
|
||||
label="Precision"
|
||||
endpoints={['Raw', 'Precise']}
|
||||
value={axes.precision}
|
||||
onChange={(v) => setAxis('precision', v)}
|
||||
accent="var(--ok, var(--accent))"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{VISUAL_PARAM_NAMES.map((name, i) => (
|
||||
<div
|
||||
key={name}
|
||||
title={name}
|
||||
onPointerEnter={() => {
|
||||
hoverRef.current = i;
|
||||
setHover(i);
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 16,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
barsRef.current[i] = el;
|
||||
}}
|
||||
style={{
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}>
|
||||
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" />
|
||||
|
|
|
|||
|
|
@ -69,6 +69,26 @@ function noise2D(x: number, y: number): number {
|
|||
|
||||
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 {
|
||||
x: number;
|
||||
y: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue