feat(manifold): improve output controls and particle input
This commit is contained in:
parent
b5891a6ab9
commit
7e308a4e62
7 changed files with 266 additions and 46 deletions
5
MAP.md
5
MAP.md
|
|
@ -47,7 +47,8 @@ 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; ParticleStage has interactive cursor-labelled heatmap sliders), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
|
rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders, an adjustable
|
||||||
|
joystick, and double-click whole-screen follow-mouse input), `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
|
||||||
|
|
@ -60,7 +61,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
|
||||||
Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth →
|
Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth →
|
||||||
4→[10,10,14]→33).
|
4→[10,10,14]→33).
|
||||||
- `manifold/src/dock/` — `OutputControlRow` (editable names, corner delete, cycling off/fixed/live status, mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`,
|
- `manifold/src/dock/` — `OutputControlRow` (editable names, corner delete, cycling off/fixed/live status, mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`,
|
||||||
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; the centered add-card control lives below the rows).
|
`OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; centered prepend/append add-card controls live above and below the rows).
|
||||||
- `manifold/src/backends/` — `OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
|
- `manifold/src/backends/` — `OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts`
|
||||||
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts`
|
(WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts`
|
||||||
(`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire
|
(`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire
|
||||||
|
|
|
||||||
|
|
@ -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) + interactive output heatmap sliders with cursor tooltips + corner joystick. |
|
| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + a larger, explicitly adjustable/repositionable joystick; double-click anywhere in the stage enters whole-screen follow-mouse mode. |
|
||||||
|
|
||||||
`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
|
||||||
|
|
@ -157,8 +157,9 @@ for the narrow pane.
|
||||||
surface there rather than inventing one. The panel renders "no training run yet" when the
|
surface there rather than inventing one. The panel renders "no training run yet" when the
|
||||||
core has no history; it never synthesises a curve.
|
core has no history; it never synthesises a curve.
|
||||||
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
|
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
|
||||||
- **route** (label "Outputs") — per-output control matrix + per-backend config; at expanded depth
|
- **route** (label "Outputs") — per-output control matrix + per-backend config; centered prepend/append `+`
|
||||||
the output rows fill the remaining drawer height and scroll independently.
|
controls are available in both condensed and expanded views, and at expanded depth the output rows fill the
|
||||||
|
remaining drawer height and scroll independently. The orange drawer tab uses `<` to expand and `>` to condense.
|
||||||
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
|
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
|
||||||
- **help** — keymap pills + loop explanation.
|
- **help** — keymap pills + loop explanation.
|
||||||
- `src/dock/` holds the output-routing internals used by the `route` drawer:
|
- `src/dock/` holds the output-routing internals used by the `route` drawer:
|
||||||
|
|
@ -168,7 +169,8 @@ for the narrow pane.
|
||||||
- `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling
|
- `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling
|
||||||
off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode
|
off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode
|
||||||
adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared
|
adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared
|
||||||
`MFParam` store via `onChange`.** The centered `+` control follows the last visible card.
|
`MFParam` store via `onChange`.** The Outputs drawer has centered `+` controls that prepend before the first
|
||||||
|
visible card or append after the last.
|
||||||
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI
|
- `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI
|
||||||
port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`.
|
port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -737,13 +737,11 @@ export function ConsoleApp() {
|
||||||
setOutputCounts((counts) => ({ ...counts, [outputMode]: activeCards.length }));
|
setOutputCounts((counts) => ({ ...counts, [outputMode]: activeCards.length }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addOutput = () => {
|
const addOutput = (placement: 'prepend' | 'append' = 'append') => {
|
||||||
const active = params.slice(0, displayOutputCount);
|
const active = params.slice(0, displayOutputCount);
|
||||||
const next = params[displayOutputCount] ?? createOutputParam(params.length);
|
const next = params[displayOutputCount] ?? createOutputParam(params.length);
|
||||||
const spares = params
|
const spares = params.filter((param) => param.id !== next.id && !active.some((p) => p.id === param.id));
|
||||||
.slice(displayOutputCount + (params[displayOutputCount] ? 1 : 0))
|
applyOutputCards(placement === 'prepend' ? [next, ...active] : [...active, next], spares);
|
||||||
.filter((param) => param.id !== next.id);
|
|
||||||
applyOutputCards([...active, next], spares);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteOutput = (index: number) => {
|
const deleteOutput = (index: number) => {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import {
|
||||||
EditorIcon,
|
EditorIcon,
|
||||||
SandwichIcon,
|
SandwichIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
ExpandIcon,
|
|
||||||
GLYPH_FALLBACK,
|
GLYPH_FALLBACK,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
import type { IconProps } from './icons';
|
import type { IconProps } from './icons';
|
||||||
|
|
@ -309,14 +308,14 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
|
||||||
top: '50%',
|
top: '50%',
|
||||||
transform: 'translateY(-50%)',
|
transform: 'translateY(-50%)',
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 56,
|
height: 40,
|
||||||
borderRadius: 'var(--r-2) 0 0 var(--r-2)',
|
borderRadius: 'var(--r-2) 0 0 var(--r-2)',
|
||||||
border: '1px solid var(--glass-line)',
|
border: '1px solid var(--glass-line)',
|
||||||
borderRight: 'none',
|
borderRight: 'none',
|
||||||
background: 'var(--glass)',
|
background: 'var(--glass)',
|
||||||
backdropFilter: 'blur(14px)',
|
backdropFilter: 'blur(14px)',
|
||||||
WebkitBackdropFilter: 'blur(14px)',
|
WebkitBackdropFilter: 'blur(14px)',
|
||||||
color: 'var(--fg-mute)',
|
color: 'var(--accent)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -324,7 +323,9 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExpandIcon size={12} />
|
<span aria-hidden="true" style={{ fontSize: 22, lineHeight: 1, fontFamily: 'var(--font-mono)' }}>
|
||||||
|
{expanded ? '>' : '<'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Inner scroll wrapper — holds header + content, scrolls independently. */}
|
{/* Inner scroll wrapper — holds header + content, scrolls independently. */}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,30 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
|
|
||||||
const expanded = depth === 'expanded';
|
const expanded = depth === 'expanded';
|
||||||
const rows = expanded ? activeParams : activeParams.slice(0, 6);
|
const rows = expanded ? activeParams : activeParams.slice(0, 6);
|
||||||
|
const addButton = (placement: 'prepend' | 'append') => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={placement === 'append' ? '+ output' : '+ output (prepend)'}
|
||||||
|
title={placement === 'append' ? 'Add parameter after the last card' : 'Add parameter before the first card'}
|
||||||
|
onClick={() => ctx.addOutput(placement)}
|
||||||
|
style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: 30,
|
||||||
|
height: 26,
|
||||||
|
margin: '4px 0 2px',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--r-1)',
|
||||||
|
background: 'var(--bg-2)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
|
@ -774,6 +798,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{addButton('prepend')}
|
||||||
{rows.map((p) => {
|
{rows.map((p) => {
|
||||||
const i = ctx.params.indexOf(p);
|
const i = ctx.params.indexOf(p);
|
||||||
const labelled = { ...p, name: nameFor(p) };
|
const labelled = { ...p, name: nameFor(p) };
|
||||||
|
|
@ -789,28 +814,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
{addButton('append')}
|
||||||
type="button"
|
|
||||||
aria-label="+ output"
|
|
||||||
title="Add parameter"
|
|
||||||
onClick={ctx.addOutput}
|
|
||||||
style={{
|
|
||||||
alignSelf: 'center',
|
|
||||||
width: 30,
|
|
||||||
height: 26,
|
|
||||||
margin: '4px 0 2px',
|
|
||||||
border: '1px solid var(--line)',
|
|
||||||
borderRadius: 'var(--r-1)',
|
|
||||||
background: 'var(--bg-2)',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: 18,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{!expanded && activeParams.length > 6 && (
|
{!expanded && activeParams.length > 6 && (
|
||||||
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more — expand to edit</span>
|
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more — expand to edit</span>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
* value — the same `.heatmap-strip` the a-immersive app shows above the
|
* value — the same `.heatmap-strip` the a-immersive app shows above the
|
||||||
* flow field (NOT the Boldness/Memory/Precision macro axes, which the
|
* flow field (NOT the Boldness/Memory/Precision macro axes, which the
|
||||||
* deployed a-immersive never used);
|
* deployed a-immersive never used);
|
||||||
* • a small circular pad in the bottom-left corner that drives the 2D input
|
* • a large circular pad that drives the 2D input (engine.setInput) — the
|
||||||
* (engine.setInput) — the "joystick" of the immersive app.
|
* "joystick" of the immersive app; its explicit edit handles reposition
|
||||||
|
* and resize it without making normal input gestures destructive.
|
||||||
*
|
*
|
||||||
* 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. The
|
* inferences; only the *field* parameters change when the MLP outputs do. The
|
||||||
|
|
@ -18,7 +19,7 @@
|
||||||
* 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 type { MouseEvent as ReactMouseEvent, 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 type { MFParam } from './model';
|
||||||
|
|
@ -49,6 +50,22 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
|
||||||
const [hover, setHover] = useState<number | null>(null);
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 });
|
const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 });
|
||||||
const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null);
|
const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null);
|
||||||
|
const [followMouse, setFollowMouse] = useState(false);
|
||||||
|
const onMoveRef = useRef(onMove);
|
||||||
|
const [padSize, setPadSize] = useState(200);
|
||||||
|
const [padPosition, setPadPosition] = useState(() => ({
|
||||||
|
left: 24,
|
||||||
|
top: typeof window === 'undefined' ? 24 : Math.max(24, window.innerHeight - 224),
|
||||||
|
}));
|
||||||
|
const [padEdit, setPadEdit] = useState(false);
|
||||||
|
const padGesture = useRef<{
|
||||||
|
kind: 'move' | 'resize';
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
} | null>(null);
|
||||||
const clampTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const clampTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({
|
const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({
|
||||||
i: -1,
|
i: -1,
|
||||||
|
|
@ -57,6 +74,49 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
|
||||||
el: null,
|
el: null,
|
||||||
});
|
});
|
||||||
valuesRef.current = values;
|
valuesRef.current = values;
|
||||||
|
onMoveRef.current = onMove;
|
||||||
|
|
||||||
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||||
|
|
||||||
|
const beginPadGesture = (e: ReactPointerEvent<HTMLElement>, kind: 'move' | 'resize') => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
padGesture.current = {
|
||||||
|
kind,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
left: padPosition.left,
|
||||||
|
top: padPosition.top,
|
||||||
|
size: padSize,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const movePadGesture = (e: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
const gesture = padGesture.current;
|
||||||
|
if (!gesture) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (gesture.kind === 'move') {
|
||||||
|
setPadPosition({
|
||||||
|
left: clamp(gesture.left + e.clientX - gesture.startX, 8, Math.max(8, window.innerWidth - padSize - 56)),
|
||||||
|
top: clamp(gesture.top + e.clientY - gesture.startY, 8, Math.max(8, window.innerHeight - padSize - 8)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const maxSize = Math.max(140, Math.min(window.innerWidth - gesture.left - 56, window.innerHeight - gesture.top - 8, 360));
|
||||||
|
setPadSize(clamp(gesture.size + e.clientX - gesture.startX, 140, maxSize));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endPadGesture = (e: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
padGesture.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFollowMouse = (e?: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
setFollowMouse((active) => !active);
|
||||||
|
};
|
||||||
|
|
||||||
const updateHover = (i: number, e: ReactPointerEvent<HTMLDivElement>) => {
|
const updateHover = (i: number, e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
hoverRef.current = i;
|
hoverRef.current = i;
|
||||||
|
|
@ -168,8 +228,35 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!followMouse) return;
|
||||||
|
const onWinMove = (e: PointerEvent) => {
|
||||||
|
const w = window.innerWidth || 1;
|
||||||
|
const h = window.innerHeight || 1;
|
||||||
|
onMoveRef.current(clamp(e.clientX / w, 0, 1), clamp(1 - e.clientY / h, 0, 1));
|
||||||
|
};
|
||||||
|
const onWinKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setFollowMouse(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onWinMove);
|
||||||
|
window.addEventListener('keydown', onWinKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
|
window.removeEventListener('keydown', onWinKey);
|
||||||
|
};
|
||||||
|
}, [followMouse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}>
|
<div
|
||||||
|
onDoubleClick={toggleFollowMouse}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
cursor: followMouse ? 'none' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Main view — the flow-field particle system */}
|
{/* Main view — the flow-field particle system */}
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
|
@ -227,7 +314,7 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
|
||||||
height: 16,
|
height: 16,
|
||||||
background: 'rgba(255,255,255,0.04)',
|
background: 'rgba(255,255,255,0.04)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
cursor: 'ew-resize',
|
cursor: followMouse ? 'none' : 'ew-resize',
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -285,9 +372,136 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom-left circular pad — drives the 2D input */}
|
{followMouse && (
|
||||||
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}>
|
<div
|
||||||
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" />
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 30,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 26,
|
||||||
|
padding: '4px 10px',
|
||||||
|
border: '1px solid var(--accent)',
|
||||||
|
borderRadius: 'var(--r-pill)',
|
||||||
|
background: 'rgba(0,0,0,0.78)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
FOLLOW MOUSE · ESC OR DOUBLE-CLICK TO EXIT
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adjustable circular pad — explicit edit handles keep repositioning/resizing deliberate. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: padPosition.left,
|
||||||
|
top: padPosition.top,
|
||||||
|
width: padSize,
|
||||||
|
height: padSize,
|
||||||
|
zIndex: 20,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VirtualJoystick
|
||||||
|
size={padSize}
|
||||||
|
position={pos}
|
||||||
|
onMove={onMove}
|
||||||
|
disabled={padEdit || followMouse}
|
||||||
|
ariaLabel="particle input pad"
|
||||||
|
style={{ pointerEvents: padEdit || followMouse ? 'none' : 'auto' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={padEdit ? 'Finish adjusting joystick' : 'Adjust joystick size and position'}
|
||||||
|
title={padEdit ? 'Finish adjusting joystick' : 'Adjust joystick size and position'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPadEdit((editing) => !editing);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
zIndex: 3,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
border: '1px solid var(--line-strong)',
|
||||||
|
borderRadius: 'var(--r-1)',
|
||||||
|
background: 'rgba(0,0,0,0.72)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{padEdit ? '✓' : '⋮'}
|
||||||
|
</button>
|
||||||
|
{padEdit && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Move joystick"
|
||||||
|
title="Drag to move joystick"
|
||||||
|
onPointerDown={(e) => beginPadGesture(e, 'move')}
|
||||||
|
onPointerMove={movePadGesture}
|
||||||
|
onPointerUp={endPadGesture}
|
||||||
|
onPointerCancel={endPadGesture}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
left: 6,
|
||||||
|
zIndex: 3,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
border: '1px solid var(--accent)',
|
||||||
|
borderRadius: 'var(--r-1)',
|
||||||
|
background: 'rgba(0,0,0,0.72)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
cursor: 'move',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✥
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Resize joystick"
|
||||||
|
title="Drag to resize joystick"
|
||||||
|
onPointerDown={(e) => beginPadGesture(e, 'resize')}
|
||||||
|
onPointerMove={movePadGesture}
|
||||||
|
onPointerUp={endPadGesture}
|
||||||
|
onPointerCancel={endPadGesture}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 4,
|
||||||
|
bottom: 4,
|
||||||
|
zIndex: 3,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
border: '1px solid var(--accent)',
|
||||||
|
borderRadius: 'var(--r-1)',
|
||||||
|
background: 'rgba(0,0,0,0.72)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
cursor: 'nwse-resize',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↘
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export interface ConsoleCtx {
|
||||||
params: MFParam[];
|
params: MFParam[];
|
||||||
/** Patch one output row in the shared store (drives stage + dock in sync). */
|
/** Patch one output row in the shared store (drives stage + dock in sync). */
|
||||||
setParam: (i: number, patch: Partial<MFParam>) => void;
|
setParam: (i: number, patch: Partial<MFParam>) => void;
|
||||||
addOutput: () => void;
|
addOutput: (placement?: 'prepend' | 'append') => void;
|
||||||
deleteOutput: (i: number) => void;
|
deleteOutput: (i: number) => void;
|
||||||
/** Active backend outputs currently presented by the stage + routing rows. */
|
/** Active backend outputs currently presented by the stage + routing rows. */
|
||||||
displayOutputCount: number;
|
displayOutputCount: number;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue