Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
fa80a305d9
5 changed files with 238 additions and 47 deletions
|
|
@ -94,7 +94,7 @@ decision. Buffers are reused frame-to-frame; never assume a fresh array.
|
||||||
### The "convertible" Stages (one renders at a time, chosen by `focus` + `outputMode`)
|
### The "convertible" Stages (one renders at a time, chosen by `focus` + `outputMode`)
|
||||||
| Stage | File | Renders when | What it is |
|
| Stage | File | Renders when | What it is |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Manifold | `Manifold.tsx` | `focus==='in'` (default input view) | Full-bleed 2D input surface; canvas trail + pins + feedback markers; pointer → `onMove`. |
|
| Manifold | `Manifold.tsx` | `focus==='in'` (default input view) | Full-bleed 2D input surface; canvas trail + pins + feedback markers; pointer → `onMove`. **Double-click the input mark → follow-mouse mode** (self-contained state; a window `pointermove` listener maps the whole viewport onto this surface's space so the knob tracks the cursor across the entire UI; Esc / second double-click exits). |
|
||||||
| OutputStage | `OutputStage.tsx` | `focus==='out'` | Full-bleed output columns; drag bars set value; `InputMini` docked in a corner. |
|
| OutputStage | `OutputStage.tsx` | `focus==='out'` | Full-bleed output columns; drag bars set value; `InputMini` docked in a corner. |
|
||||||
| SplitStage | `SplitStage.tsx` | `focus==='split'` | Manifold left, OutputStage right, equal width. |
|
| SplitStage | `SplitStage.tsx` | `focus==='split'` | Manifold left, OutputStage right, equal width. |
|
||||||
| CompositeStage | `CompositeStage.tsx` | `focus==='composite'` (**app 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` | `focus==='composite'` (**app 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. |
|
||||||
|
|
@ -196,9 +196,10 @@ a setting → `--r-*` tokens.
|
||||||
|
|
||||||
### Feedback — `src/feedback/`
|
### Feedback — `src/feedback/`
|
||||||
- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp.
|
- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp.
|
||||||
Two modes: **explore-and-place** (default, Mode 2, positive-only — drives the C++ core's
|
Two modes: **geometric-dislike** (default, Mode 1, "Push away" — down carves the current sound
|
||||||
snapshot/scratchpad/undo lifecycle; caller accumulates anchors and trains on finalise with
|
away from what you like, directed repulsion) and **explore-and-place** (Mode 2, positive-only,
|
||||||
warm-start) and **geometric-dislike** (Mode 1, selectable). Solo/arm via per-output mask.
|
selectable — drives the C++ core's snapshot/scratchpad/undo lifecycle; caller accumulates anchors
|
||||||
|
and trains on finalise with warm-start). Solo/arm via per-output mask.
|
||||||
- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns
|
- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns
|
||||||
the stream — not bit-identical to `nisps::Rng`.
|
the stream — not bit-identical to `nisps::Rng`.
|
||||||
- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric
|
- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,9 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
const [sandwich, setSandwich] = useState(false);
|
const [sandwich, setSandwich] = useState(false);
|
||||||
|
|
||||||
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
|
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
|
||||||
// feedback mode = "Explore and place"; default solo = "Mask gradients".
|
// feedback mode = "Push away" (geometric); default solo = "Mask gradients".
|
||||||
const [feedbackMode, setFeedbackModeState] = useState<FeedbackModeUI>('explore-and-place');
|
// (Explore-and-place is selectable but the geometric push is the better default.)
|
||||||
|
const [feedbackMode, setFeedbackModeState] = useState<FeedbackModeUI>('geometric-dislike');
|
||||||
const [soloMode, setSoloMode] = useState<SoloMode>('mask-gradients');
|
const [soloMode, setSoloMode] = useState<SoloMode>('mask-gradients');
|
||||||
const [exploring, setExploring] = useState(false);
|
const [exploring, setExploring] = useState(false);
|
||||||
const [learningPaused, setLearningPaused] = useState(false);
|
const [learningPaused, setLearningPaused] = useState(false);
|
||||||
|
|
@ -542,14 +543,21 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
||||||
|
// Verdict accelerators: 1 = thumbs-down / explore, 2 = thumbs-up. These
|
||||||
|
// work everywhere, including while the manifold is in follow-mouse mode
|
||||||
|
// (the knob tracks the cursor; the keys still land the verdict).
|
||||||
const map: Record<string, DrawerKey> = {
|
const map: Record<string, DrawerKey> = {
|
||||||
'1': 'learn',
|
|
||||||
'2': 'inputs',
|
|
||||||
'3': 'route',
|
'3': 'route',
|
||||||
'4': 'settings',
|
'4': 'settings',
|
||||||
'5': 'help',
|
'5': 'help',
|
||||||
};
|
};
|
||||||
if (map[e.key]) {
|
if (e.key === '1') {
|
||||||
|
e.preventDefault();
|
||||||
|
perturb();
|
||||||
|
} else if (e.key === '2') {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
} else if (map[e.key]) {
|
||||||
setActive((a) => (a === map[e.key] ? null : map[e.key]));
|
setActive((a) => (a === map[e.key] ? null : map[e.key]));
|
||||||
setDepth('condensed');
|
setDepth('condensed');
|
||||||
} else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded'));
|
} else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded'));
|
||||||
|
|
@ -654,10 +662,14 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
onAddExample: addExample,
|
onAddExample: addExample,
|
||||||
onTrain: train,
|
onTrain: train,
|
||||||
onClear: () => {
|
onClear: () => {
|
||||||
|
// Drop the recorded training examples AND every on-map visual that
|
||||||
|
// represents them (feedback markers + placed-anchor pins). The cursor
|
||||||
|
// trail is ephemeral and self-decays, so it needs no explicit reset.
|
||||||
engine?.clearExamples();
|
engine?.clearExamples();
|
||||||
setExamples(0);
|
setExamples(0);
|
||||||
setLoss([]);
|
setLoss([]);
|
||||||
setMarkers([]);
|
setMarkers([]);
|
||||||
|
setPins([]);
|
||||||
},
|
},
|
||||||
snapshots,
|
snapshots,
|
||||||
onJump: (id) => {
|
onJump: (id) => {
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,19 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SectionLabel>Recorded examples</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Chip>
|
||||||
|
{ctx.datasetCount} example{ctx.datasetCount === 1 ? '' : 's'}
|
||||||
|
</Chip>
|
||||||
|
<Button size="sm" variant="secondary" onClick={ctx.onClear}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
|
||||||
|
forget every example & wipe the on-map marks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{depth === 'expanded' && (
|
{depth === 'expanded' && (
|
||||||
<>
|
<>
|
||||||
<SectionLabel>Solo / arm scope</SectionLabel>
|
<SectionLabel>Solo / arm scope</SectionLabel>
|
||||||
|
|
@ -706,12 +719,15 @@ function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
const KEYS: [string, string][] = [
|
const KEYS: [string, string][] = [
|
||||||
['1–5', 'open drawers'],
|
['1', 'down − / explore'],
|
||||||
['\\', 'expand drawer'],
|
['2', 'commit +'],
|
||||||
['space / ↑', 'commit +'],
|
['space / ↑', 'commit +'],
|
||||||
['↓', 'perturb / down −'],
|
['↓', 'down − / explore'],
|
||||||
|
['3–5', 'open drawers'],
|
||||||
|
['\\', 'expand drawer'],
|
||||||
['z', 'undo'],
|
['z', 'undo'],
|
||||||
['[ ] =', 'split (composite)'],
|
['[ ] =', 'split (composite)'],
|
||||||
|
['dbl-click mark', 'follow mouse (Esc exits)'],
|
||||||
];
|
];
|
||||||
function HelpDrawer() {
|
function HelpDrawer() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
*
|
*
|
||||||
* Ported faithfully from the window-global `Manifold.jsx`.
|
* Ported faithfully from the window-global `Manifold.jsx`.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } 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 type { FeedbackMarker, Pin } from './types';
|
import type { FeedbackMarker, Pin } from './types';
|
||||||
|
|
||||||
export interface ManifoldProps {
|
export interface ManifoldProps {
|
||||||
|
|
@ -55,6 +55,17 @@ export function Manifold({
|
||||||
// Transient "just placed" marker location (manifold space), for a brief flash.
|
// Transient "just placed" marker location (manifold space), for a brief flash.
|
||||||
const placedRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
const placedRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
||||||
|
|
||||||
|
// FOLLOW-MOUSE mode: double-click the input mark to glue the knob to the
|
||||||
|
// cursor, then it tracks the mouse anywhere on screen (the whole viewport maps
|
||||||
|
// onto this surface's [0,1]² space). Escape or a second double-click exits.
|
||||||
|
const [followMouse, setFollowMouse] = useState(false);
|
||||||
|
const followMouseRef = useRef(false);
|
||||||
|
followMouseRef.current = followMouse;
|
||||||
|
// onMove can change identity each render; keep a ref so the global listener
|
||||||
|
// never has to be re-installed per frame while following.
|
||||||
|
const onMoveRef = useRef(onMove);
|
||||||
|
onMoveRef.current = onMove;
|
||||||
|
|
||||||
stateRef.current = { pos, noiseCap, pins, markers, variant, frozen, follow, picking };
|
stateRef.current = { pos, noiseCap, pins, markers, variant, frozen, follow, picking };
|
||||||
|
|
||||||
// push trail point whenever pos changes
|
// push trail point whenever pos changes
|
||||||
|
|
@ -72,18 +83,25 @@ export function Manifold({
|
||||||
const el = wrapRef.current;
|
const el = wrapRef.current;
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
let x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
|
|
||||||
let y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
|
|
||||||
if (stateRef.current.variant === 'circular') {
|
if (stateRef.current.variant === 'circular') {
|
||||||
// Clamp to the unit disc centred at (0.5, 0.5).
|
// Map the pointer relative to the *inscribed circle* the canvas draws (see
|
||||||
const dx = x - 0.5;
|
// `drawRadius` below): a unit-disc vector around the centre, so the knob
|
||||||
const dy = y - 0.5;
|
// tracks the cursor inside the disc and snaps to the rim outside it. This
|
||||||
const d = Math.hypot(dx, dy);
|
// keeps the reachable area a true circle on a non-square surface (where a
|
||||||
if (d > 0.5) {
|
// [0,1]² clamp would render as an ellipse spilling past the drawn rim).
|
||||||
x = 0.5 + (dx / d) * 0.5;
|
const radius = Math.min(r.width, r.height) / 2 - 2;
|
||||||
y = 0.5 + (dy / d) * 0.5;
|
if (radius <= 0) return [0.5, 0.5];
|
||||||
|
let vx = (e.clientX - r.left - r.width / 2) / radius;
|
||||||
|
let vy = (r.height / 2 - (e.clientY - r.top)) / radius; // screen y is down; flip so up = +
|
||||||
|
const mag = Math.hypot(vx, vy);
|
||||||
|
if (mag > 1) {
|
||||||
|
vx /= mag;
|
||||||
|
vy /= mag;
|
||||||
}
|
}
|
||||||
|
return [0.5 + 0.5 * vx, 0.5 + 0.5 * vy];
|
||||||
}
|
}
|
||||||
|
const x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
|
||||||
|
const y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
|
||||||
return [x, y];
|
return [x, y];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,8 +110,65 @@ export function Manifold({
|
||||||
if (p) onMove(p[0], p[1]);
|
if (p) onMove(p[0], p[1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FOLLOW-MOUSE mapping: turn an absolute viewport coordinate (client px) into
|
||||||
|
* a normalised [0,1]² manifold position. The *whole window* maps onto this
|
||||||
|
* surface's space so the knob genuinely tracks the cursor across the entire
|
||||||
|
* UI — left edge → x=0, right edge → x=1 — regardless of where (or how small)
|
||||||
|
* this surface sits. The circular variant clamps to the inscribed unit disc.
|
||||||
|
*/
|
||||||
|
const posFromClient = (clientX: number, clientY: number): [number, number] => {
|
||||||
|
const W = window.innerWidth || 1;
|
||||||
|
const H = window.innerHeight || 1;
|
||||||
|
const nx = clientX / W;
|
||||||
|
const ny = 1 - clientY / H; // screen y is down; flip so up = +
|
||||||
|
if (stateRef.current.variant === 'circular') {
|
||||||
|
let vx = (nx - 0.5) * 2;
|
||||||
|
let vy = (ny - 0.5) * 2;
|
||||||
|
const mag = Math.hypot(vx, vy);
|
||||||
|
if (mag > 1) {
|
||||||
|
vx /= mag;
|
||||||
|
vy /= mag;
|
||||||
|
}
|
||||||
|
return [0.5 + 0.5 * vx, 0.5 + 0.5 * vy];
|
||||||
|
}
|
||||||
|
return [Math.max(0, Math.min(1, nx)), Math.max(0, Math.min(1, ny))];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Screen-pixel (client) position of the drawn input mark, for the hit-test. */
|
||||||
|
const knobClient = (r: DOMRect, p: [number, number]): [number, number] => {
|
||||||
|
if (stateRef.current.variant === 'circular') {
|
||||||
|
const radius = Math.min(r.width, r.height) / 2 - 2;
|
||||||
|
const cx = r.left + r.width / 2;
|
||||||
|
const cy = r.top + r.height / 2;
|
||||||
|
return [cx + (p[0] - 0.5) * 2 * radius, cy - (p[1] - 0.5) * 2 * radius];
|
||||||
|
}
|
||||||
|
return [r.left + p[0] * r.width, r.top + (1 - p[1]) * r.height];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double-click toggles follow-mouse mode. Entering requires the click to land
|
||||||
|
* on the input mark (within ~36px of the knob); a second double-click — the
|
||||||
|
* mark is now under the cursor — exits.
|
||||||
|
*/
|
||||||
|
const onDoubleClick = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (stateRef.current.frozen || stateRef.current.picking) return;
|
||||||
|
if (followMouseRef.current) {
|
||||||
|
setFollowMouse(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const [kx, ky] = knobClient(r, stateRef.current.pos);
|
||||||
|
if (Math.hypot(e.clientX - kx, e.clientY - ky) <= 36) setFollowMouse(true);
|
||||||
|
};
|
||||||
|
|
||||||
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
|
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (stateRef.current.frozen) return;
|
if (stateRef.current.frozen) return;
|
||||||
|
// While following the mouse, the global listener owns the knob — don't start
|
||||||
|
// a pan/long-press drag (double-click still fires to exit).
|
||||||
|
if (followMouseRef.current) return;
|
||||||
// PICK-LOCATION: when placing, this pointer-down picks the anchor location
|
// PICK-LOCATION: when placing, this pointer-down picks the anchor location
|
||||||
// (rl-feedback §2.2 §3) and does NOT start a pan/drive drag.
|
// (rl-feedback §2.2 §3) and does NOT start a pan/drive drag.
|
||||||
if (stateRef.current.picking) {
|
if (stateRef.current.picking) {
|
||||||
|
|
@ -112,6 +187,7 @@ export function Manifold({
|
||||||
}, 600);
|
}, 600);
|
||||||
};
|
};
|
||||||
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
|
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (followMouseRef.current) return;
|
||||||
if (draggingRef.current) {
|
if (draggingRef.current) {
|
||||||
setFromEvent(e);
|
setFromEvent(e);
|
||||||
if (lpTimer.current) clearTimeout(lpTimer.current);
|
if (lpTimer.current) clearTimeout(lpTimer.current);
|
||||||
|
|
@ -170,8 +246,15 @@ export function Manifold({
|
||||||
const cx = W / 2;
|
const cx = W / 2;
|
||||||
const cy = H / 2;
|
const cy = H / 2;
|
||||||
const radius = Math.min(W, H) / 2 - 2;
|
const radius = Math.min(W, H) / 2 - 2;
|
||||||
|
// Map a normalised [0,1] coord to a screen pixel. The rectangular variant
|
||||||
|
// spreads [0,1]² across the full surface; the circular variant maps the
|
||||||
|
// central unit disc onto the inscribed circle so the [0,1]² clamp lines up
|
||||||
|
// exactly with the drawn rim (and stays a true circle when W ≠ H). x and y
|
||||||
|
// map independently (the circular transform is separable).
|
||||||
|
const sx = circular ? (nx: number) => cx + (nx - 0.5) * 2 * radius : (nx: number) => nx * W;
|
||||||
|
const sy = circular ? (ny: number) => cy - (ny - 0.5) * 2 * radius : (ny: number) => (1 - ny) * H;
|
||||||
|
|
||||||
if (fl && !draggingRef.current && !fz) {
|
if (fl && !draggingRef.current && !fz && !followMouseRef.current) {
|
||||||
let [x, y] = p;
|
let [x, y] = p;
|
||||||
const d = driftRef.current;
|
const d = driftRef.current;
|
||||||
x += d.vx;
|
x += d.vx;
|
||||||
|
|
@ -180,6 +263,17 @@ export function Manifold({
|
||||||
if (y < 0.05 || y > 0.95) d.vy *= -1;
|
if (y < 0.05 || y > 0.95) d.vy *= -1;
|
||||||
x = Math.max(0.05, Math.min(0.95, x));
|
x = Math.max(0.05, Math.min(0.95, x));
|
||||||
y = Math.max(0.05, Math.min(0.95, y));
|
y = Math.max(0.05, Math.min(0.95, y));
|
||||||
|
if (circular) {
|
||||||
|
// Keep the auto-drift inside the disc too, so it never wanders past
|
||||||
|
// the drawn rim into the corners.
|
||||||
|
const dx = x - 0.5;
|
||||||
|
const dy = y - 0.5;
|
||||||
|
const dd = Math.hypot(dx, dy);
|
||||||
|
if (dd > 0.5) {
|
||||||
|
x = 0.5 + (dx / dd) * 0.5;
|
||||||
|
y = 0.5 + (dy / dd) * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
onMove(x, y);
|
onMove(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,12 +322,12 @@ export function Manifold({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const px = p[0] * W;
|
const px = sx(p[0]);
|
||||||
const py = (1 - p[1]) * H;
|
const py = sy(p[1]);
|
||||||
|
|
||||||
for (const pin of pn) {
|
for (const pin of pn) {
|
||||||
const ppx = pin.x * W;
|
const ppx = sx(pin.x);
|
||||||
const ppy = (1 - pin.y) * H;
|
const ppy = sy(pin.y);
|
||||||
ctx.fillStyle = pin.color || 'rgba(255,106,0,0.18)';
|
ctx.fillStyle = pin.color || 'rgba(255,106,0,0.18)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(ppx, ppy, 34, 0, Math.PI * 2);
|
ctx.arc(ppx, ppy, 34, 0, Math.PI * 2);
|
||||||
|
|
@ -248,8 +342,8 @@ export function Manifold({
|
||||||
// Feedback markers: positive = filled accent dot, negative = open red
|
// Feedback markers: positive = filled accent dot, negative = open red
|
||||||
// ring. Plotted at the input location each verdict was given (session).
|
// ring. Plotted at the input location each verdict was given (session).
|
||||||
for (const m of mk) {
|
for (const m of mk) {
|
||||||
const mx = m.x * W;
|
const mx = sx(m.x);
|
||||||
const my = (1 - m.y) * H;
|
const my = sy(m.y);
|
||||||
if (m.polarity === 'positive') {
|
if (m.polarity === 'positive') {
|
||||||
ctx.fillStyle = 'rgba(255,106,0,0.9)';
|
ctx.fillStyle = 'rgba(255,106,0,0.9)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
@ -280,8 +374,8 @@ export function Manifold({
|
||||||
const alpha = (1 - age / LIFE) * 0.5;
|
const alpha = (1 - age / LIFE) * 0.5;
|
||||||
ctx.strokeStyle = `rgba(0,204,255,${alpha})`;
|
ctx.strokeStyle = `rgba(0,204,255,${alpha})`;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(a.x * W, (1 - a.y) * H);
|
ctx.moveTo(sx(a.x), sy(a.y));
|
||||||
ctx.lineTo(b.x * W, (1 - b.y) * H);
|
ctx.lineTo(sx(b.x), sy(b.y));
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,8 +414,8 @@ export function Manifold({
|
||||||
placedRef.current = null;
|
placedRef.current = null;
|
||||||
} else {
|
} else {
|
||||||
const a = 1 - age / 900;
|
const a = 1 - age / 900;
|
||||||
const mx = placed.x * W;
|
const mx = sx(placed.x);
|
||||||
const my = (1 - placed.y) * H;
|
const my = sy(placed.y);
|
||||||
ctx.strokeStyle = `rgba(0,204,255,${a})`;
|
ctx.strokeStyle = `rgba(0,204,255,${a})`;
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
@ -366,6 +460,29 @@ export function Manifold({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// FOLLOW-MOUSE: while active, a window-level listener drives the knob from the
|
||||||
|
// raw cursor position (anywhere on screen), and Escape exits. Kept on `window`
|
||||||
|
// so it keeps tracking even when the cursor leaves this surface.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!followMouse) return;
|
||||||
|
const onWinMove = (e: PointerEvent) => {
|
||||||
|
if (stateRef.current.frozen) return;
|
||||||
|
const p = posFromClient(e.clientX, e.clientY);
|
||||||
|
onMoveRef.current(p[0], p[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);
|
||||||
|
};
|
||||||
|
// posFromClient/onMoveRef read live refs, so [followMouse] is the only dep.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [followMouse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapRef}
|
ref={wrapRef}
|
||||||
|
|
@ -373,15 +490,40 @@ export function Manifold({
|
||||||
onPointerMove={move}
|
onPointerMove={move}
|
||||||
onPointerUp={up}
|
onPointerUp={up}
|
||||||
onPointerCancel={up}
|
onPointerCancel={up}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
cursor: frozen ? 'not-allowed' : picking ? 'cell' : 'crosshair',
|
cursor: frozen ? 'not-allowed' : picking ? 'cell' : followMouse ? 'none' : 'crosshair',
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<canvas ref={canvasRef} style={{ display: 'block' }} />
|
<canvas ref={canvasRef} style={{ display: 'block' }} />
|
||||||
|
{followMouse && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '3px 10px',
|
||||||
|
fontSize: 'var(--fs-xs)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
background: 'rgba(13,13,13,0.7)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--r-1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
zIndex: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 8 }}>●</span> following mouse — Esc / double-click to exit
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,12 +117,17 @@ export class GamepadSource extends BaseSource {
|
||||||
for (let i = 0; i < n; i++) out[offset + i] = 0.5; // centre when absent
|
for (let i = 0; i < n; i++) out[offset + i] = 0.5; // centre when absent
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
// Left stick = axes 0,1; right stick = axes 2,3 (standard mapping).
|
// Left stick = axes 0,1; right stick = axes 2,3 (standard mapping). Each
|
||||||
out[offset] = remap(pad.axes[0] ?? 0);
|
// stick is clamped to the unit disc (not per-axis), so a full diagonal push
|
||||||
out[offset + 1] = remap(-(pad.axes[1] ?? 0)); // flip: up = 1
|
// lands ON the circular boundary rather than the square corner — matching
|
||||||
|
// the on-screen circular input area and the engine's own circular clamp.
|
||||||
|
const [lx, ly] = clampStick(pad.axes[0] ?? 0, -(pad.axes[1] ?? 0)); // flip: up = 1
|
||||||
|
out[offset] = lx;
|
||||||
|
out[offset + 1] = ly;
|
||||||
if (n === 4) {
|
if (n === 4) {
|
||||||
out[offset + 2] = remap(pad.axes[2] ?? 0);
|
const [rx, ry] = clampStick(pad.axes[2] ?? 0, -(pad.axes[3] ?? 0));
|
||||||
out[offset + 3] = remap(-(pad.axes[3] ?? 0));
|
out[offset + 2] = rx;
|
||||||
|
out[offset + 3] = ry;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
@ -186,11 +191,26 @@ export class GamepadSource extends BaseSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a [-1,1] stick axis (with radial deadzone) to [0,1]. */
|
/** Apply the per-axis deadzone, returning a signed value in [-1,1]. */
|
||||||
function remap(v: number): number {
|
function deadzoneAxis(v: number): number {
|
||||||
let x = v;
|
if (v > -DEADZONE && v < DEADZONE) return 0;
|
||||||
if (x > -DEADZONE && x < DEADZONE) x = 0;
|
const x = v > 0 ? (v - DEADZONE) / (1 - DEADZONE) : (v + DEADZONE) / (1 - DEADZONE);
|
||||||
else x = x > 0 ? (x - DEADZONE) / (1 - DEADZONE) : (x + DEADZONE) / (1 - DEADZONE);
|
return x < -1 ? -1 : x > 1 ? 1 : x;
|
||||||
const out = (x + 1) / 2;
|
}
|
||||||
return out < 0 ? 0 : out > 1 ? 1 : out;
|
|
||||||
|
/**
|
||||||
|
* Map a raw stick (rawX, rawY with y already flipped so up = +) to two [0,1]
|
||||||
|
* axes, clamping the stick *vector* to the unit disc first. This keeps full
|
||||||
|
* deflection on the circular boundary in every direction (radially symmetric),
|
||||||
|
* instead of letting a diagonal reach the square corner.
|
||||||
|
*/
|
||||||
|
function clampStick(rawX: number, rawY: number): [number, number] {
|
||||||
|
let x = deadzoneAxis(rawX);
|
||||||
|
let y = deadzoneAxis(rawY);
|
||||||
|
const mag = Math.hypot(x, y);
|
||||||
|
if (mag > 1) {
|
||||||
|
x /= mag;
|
||||||
|
y /= mag;
|
||||||
|
}
|
||||||
|
return [0.5 + 0.5 * x, 0.5 + 0.5 * y];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue