feat(manifold): parameter-sandwich centre-stage toggle
Fuse the /learn parameter-landscape sandbox into the main console: - New SandwichStage: ports the 3D landscape renderer to React, driven by the REAL EngineApi (inferBatch samples each output channel over the 2D input grid; getOutputs feeds the live probe). Re-samples on engine version bumps; orbit camera (top-down = flat 2D, tilt to reveal the stack), centred + grows to fill. - Dock: new bottom toggle (SandwichIcon) → ctx.sandwich. - ConsoleApp: when on, the sandwich replaces the centre stage in a 3-zone flex — shrunken Manifold (left) · sandwich (centre, fills) · compact OutputStage (right) — reusing pos/markers/verdicts. Opening it auto-closes the drawer so the output zone is visible. Layers = the mode's params (capped 8). typecheck + build green; verified headless (toggle on/off, sample, tilt).
This commit is contained in:
parent
3952c65d75
commit
453047612a
4 changed files with 302 additions and 3 deletions
|
|
@ -28,6 +28,7 @@ import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model';
|
||||||
import type { MFParam } from './model';
|
import type { MFParam } from './model';
|
||||||
import { CompositeStage } from './CompositeStage';
|
import { CompositeStage } from './CompositeStage';
|
||||||
import { ParticleStage } from './ParticleStage';
|
import { ParticleStage } from './ParticleStage';
|
||||||
|
import { SandwichStage } from './SandwichStage';
|
||||||
import { SplitStage } from './SplitStage';
|
import { SplitStage } from './SplitStage';
|
||||||
import { OutputStage } from './OutputStage';
|
import { OutputStage } from './OutputStage';
|
||||||
import { InputMini } from './InputMini';
|
import { InputMini } from './InputMini';
|
||||||
|
|
@ -107,6 +108,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
const [rev, setRev] = useState(1);
|
const [rev, setRev] = useState(1);
|
||||||
const [active, setActive] = useState<DrawerKey | null>('learn');
|
const [active, setActive] = useState<DrawerKey | null>('learn');
|
||||||
const [depth, setDepth] = useState<DrawerDepth>('peek');
|
const [depth, setDepth] = useState<DrawerDepth>('peek');
|
||||||
|
// Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon.
|
||||||
|
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 = "Explore and place"; default solo = "Mask gradients".
|
||||||
|
|
@ -771,7 +774,55 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{outputMode === 'particles' ? (
|
{sandwich ? (
|
||||||
|
// Sandwich centre-stage: shrunken input (left) · landscape stack
|
||||||
|
// (centre, fills) · compact outputs (right). Replaces the Mode stage.
|
||||||
|
<div style={{ position: 'absolute', inset: 0, display: 'flex' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 'clamp(200px, 24%, 320px)',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
position: 'relative',
|
||||||
|
borderRight: '1px solid var(--line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Manifold
|
||||||
|
pos={pos}
|
||||||
|
onMove={onMove}
|
||||||
|
noiseCap={noiseCap}
|
||||||
|
pins={pins}
|
||||||
|
markers={markers}
|
||||||
|
variant={inputMapVariant}
|
||||||
|
frozen={false}
|
||||||
|
follow={follow}
|
||||||
|
onLongPress={addPin}
|
||||||
|
picking={picking}
|
||||||
|
onPickLocation={onPickLocation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
|
||||||
|
{engine && (
|
||||||
|
<SandwichStage
|
||||||
|
engine={engine}
|
||||||
|
version={version}
|
||||||
|
pos={pos}
|
||||||
|
layerCount={Math.min(params.length, 8)}
|
||||||
|
names={params.map((p) => p.name)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 'clamp(200px, 24%, 320px)',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
position: 'relative',
|
||||||
|
borderLeft: '1px solid var(--line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OutputStage params={params} values={values} onChange={setParam} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : outputMode === 'particles' ? (
|
||||||
<ParticleStage pos={pos} onMove={onMove} />
|
<ParticleStage pos={pos} onMove={onMove} />
|
||||||
) : focus === 'composite' ? (
|
) : focus === 'composite' ? (
|
||||||
<CompositeStage
|
<CompositeStage
|
||||||
|
|
@ -951,7 +1002,19 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dock ctx={ctx} active={active} setActive={setActive} depth={depth} setDepth={setDepth} />
|
<Dock
|
||||||
|
ctx={ctx}
|
||||||
|
active={active}
|
||||||
|
setActive={setActive}
|
||||||
|
depth={depth}
|
||||||
|
setDepth={setDepth}
|
||||||
|
sandwich={sandwich}
|
||||||
|
setSandwich={(v) => {
|
||||||
|
setSandwich(v);
|
||||||
|
// reveal the full 3-zone layout: a drawer would overlay the output zone
|
||||||
|
if (v) setActive(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
OscIcon,
|
OscIcon,
|
||||||
SynthIcon,
|
SynthIcon,
|
||||||
EditorIcon,
|
EditorIcon,
|
||||||
|
SandwichIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
ExpandIcon,
|
ExpandIcon,
|
||||||
GLYPH_FALLBACK,
|
GLYPH_FALLBACK,
|
||||||
|
|
@ -173,11 +174,14 @@ export interface DockProps {
|
||||||
setActive: (k: DrawerKey | null) => void;
|
setActive: (k: DrawerKey | null) => void;
|
||||||
depth: DrawerDepth;
|
depth: DrawerDepth;
|
||||||
setDepth: (d: DrawerDepth) => void;
|
setDepth: (d: DrawerDepth) => void;
|
||||||
|
/** Sandwich (parameter-landscape) centre-stage toggle. */
|
||||||
|
sandwich: boolean;
|
||||||
|
setSandwich: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORDER: DrawerKey[] = ['learn', 'inputs', 'route', 'settings', 'help'];
|
const ORDER: DrawerKey[] = ['learn', 'inputs', 'route', 'settings', 'help'];
|
||||||
|
|
||||||
export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSandwich }: DockProps) {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const mono = settings.monochromeIcons;
|
const mono = settings.monochromeIcons;
|
||||||
const restColour = unfocusedIconCss(settings.unfocusedIconColour);
|
const restColour = unfocusedIconCss(settings.unfocusedIconColour);
|
||||||
|
|
@ -368,6 +372,27 @@ export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
|
||||||
>
|
>
|
||||||
{ORDER.map(iconBtn)}
|
{ORDER.map(iconBtn)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Sandwich toggle — pinned at the BOTTOM of the rail. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Parameter sandwich (layer view)"
|
||||||
|
onClick={() => setSandwich(!sandwich)}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 'var(--r-2)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${sandwich ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
background: sandwich ? 'rgba(255,106,0,0.14)' : 'transparent',
|
||||||
|
color: sandwich ? 'var(--accent)' : mono ? restColour : 'var(--fg-mute)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background var(--dur-fast), color var(--dur-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mono ? <SandwichIcon /> : '≣'}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
198
manifold/src/console/SandwichStage.tsx
Normal file
198
manifold/src/console/SandwichStage.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/**
|
||||||
|
* SandwichStage — the N-dimensional "parameter sandwich" as a centre stage,
|
||||||
|
* driven by the REAL engine (the same `EngineApi` instance the rest of the
|
||||||
|
* console uses). Ported from the standalone /learn renderer.
|
||||||
|
*
|
||||||
|
* Each layer is one output channel of the MLP, sampled across the 2-D input
|
||||||
|
* grid via `engine.inferBatch` → that surface IS the parameter's landscape.
|
||||||
|
* The grid is re-sampled only when the net changes (`version`); a per-frame
|
||||||
|
* rAF reads `engine.getOutputs()` for the live probe at the current input.
|
||||||
|
*
|
||||||
|
* Three-zone metaphor (input → MLP stack → outputs): this component owns the
|
||||||
|
* MIDDLE zone. ConsoleApp docks a shrunken Manifold (left) and OutputStage
|
||||||
|
* (right) around it. Drag the canvas to orbit; it lands top-down (a flat 2-D
|
||||||
|
* map) and tilts into the 3-D stack. The stack is centred and grows to fill
|
||||||
|
* the available height as more parameters are shown.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { EngineApi } from '../engine';
|
||||||
|
|
||||||
|
const GRID = 12;
|
||||||
|
const N = GRID;
|
||||||
|
const SX = 300, SZ = 300, LAYER_GAP = 120, ZAMP = 92, MARGIN = 34;
|
||||||
|
// Reference scale: each layer adds a fixed ~px gap (so the stack GROWS with the
|
||||||
|
// layer count) until it would overflow, at which point the fit scale caps it.
|
||||||
|
const REF_SCALE = 0.8;
|
||||||
|
|
||||||
|
export interface SandwichStageProps {
|
||||||
|
engine: EngineApi;
|
||||||
|
/** Engine version counter — re-sample the landscape grid when it bumps. */
|
||||||
|
version: number;
|
||||||
|
/** Current 2-D input (drives the probe column). */
|
||||||
|
pos: [number, number];
|
||||||
|
/** How many output channels to render as layers (top→down). */
|
||||||
|
layerCount: number;
|
||||||
|
/** Per-layer labels (the mode's param names). */
|
||||||
|
names: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SandwichStage({ engine, version, pos, layerCount, names }: SandwichStageProps) {
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const gridRef = useRef<Float32Array | null>(null);
|
||||||
|
const posRef = useRef(pos);
|
||||||
|
posRef.current = pos;
|
||||||
|
const Kref = useRef(layerCount);
|
||||||
|
Kref.current = Math.max(1, layerCount);
|
||||||
|
const namesRef = useRef(names);
|
||||||
|
namesRef.current = names;
|
||||||
|
const yaw = useRef(0);
|
||||||
|
const pitch = useRef(Math.PI / 2); // top-down (looks 2-D) until you tilt it
|
||||||
|
const view = useRef({ cw: 800, ch: 600, scale: 1, ceny: 0 });
|
||||||
|
|
||||||
|
const OUT = engine.architecture.outputSize;
|
||||||
|
|
||||||
|
// ---- re-sample the landscape grid whenever the net changes ----
|
||||||
|
useEffect(() => {
|
||||||
|
const pts: [number, number][] = [];
|
||||||
|
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) pts.push([i / N, j / N]);
|
||||||
|
try {
|
||||||
|
gridRef.current = engine.inferBatch(pts);
|
||||||
|
} catch {
|
||||||
|
gridRef.current = null;
|
||||||
|
}
|
||||||
|
}, [engine, version]);
|
||||||
|
|
||||||
|
// ---- render loop ----
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||||
|
|
||||||
|
const computeView = () => {
|
||||||
|
const v = view.current;
|
||||||
|
const K = Kref.current;
|
||||||
|
v.ceny = -(K - 1) * LAYER_GAP / 2 + ZAMP / 2; // centroid (centre vertically)
|
||||||
|
let R = 1;
|
||||||
|
for (let k = 0; k < K; k++) for (const z of [0, 1]) for (const gx of [-0.5, 0.5]) for (const gy of [-0.5, 0.5]) {
|
||||||
|
const X = gx * SX, Y = (-k * LAYER_GAP + z * ZAMP) - v.ceny, Z = gy * SZ;
|
||||||
|
const d = Math.sqrt(X * X + Y * Y + Z * Z);
|
||||||
|
if (d > R) R = d;
|
||||||
|
}
|
||||||
|
const fit = (Math.min(v.cw, v.ch) / 2 - MARGIN) / R;
|
||||||
|
v.scale = Math.min(REF_SCALE, fit); // grow with K, then cap to fit
|
||||||
|
};
|
||||||
|
const resize = () => {
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
view.current.cw = Math.max(160, Math.round(r.width));
|
||||||
|
view.current.ch = Math.max(160, Math.round(r.height));
|
||||||
|
canvas.width = view.current.cw * DPR;
|
||||||
|
canvas.height = view.current.ch * DPR;
|
||||||
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||||
|
computeView();
|
||||||
|
};
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(wrap);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const project = (gx: number, gy: number, zv: number, k: number) => {
|
||||||
|
const v = view.current;
|
||||||
|
const X = gx * SX, Y = (-k * LAYER_GAP + zv * ZAMP) - v.ceny, Z = -gy * SZ;
|
||||||
|
const cyA = Math.cos(yaw.current), syA = Math.sin(yaw.current);
|
||||||
|
const x1 = X * cyA - Z * syA, z1 = X * syA + Z * cyA;
|
||||||
|
const cpA = Math.cos(pitch.current), spA = Math.sin(pitch.current);
|
||||||
|
const y2 = Y * cpA - z1 * spA, z2 = Y * spA + z1 * cpA;
|
||||||
|
return { x: v.cw / 2 + x1 * v.scale, y: v.ch / 2 - y2 * v.scale, depth: z2, wx: X, wy: Y, wz: Z };
|
||||||
|
};
|
||||||
|
const hueOf = (k: number, K: number) => (K > 1 ? 26 + (196 - 26) * k / (K - 1) : 26);
|
||||||
|
const colCss = (k: number, K: number, a: number) => `hsla(${hueOf(k, K).toFixed(0)},85%,60%,${a})`;
|
||||||
|
const LIGHT = (() => { const v = [0.35, 0.86, 0.36]; const m = Math.hypot(v[0], v[1], v[2]); return [v[0] / m, v[1] / m, v[2] / m]; })();
|
||||||
|
const clamp = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
const draw = () => {
|
||||||
|
const v = view.current;
|
||||||
|
const K = Kref.current;
|
||||||
|
const grid = gridRef.current;
|
||||||
|
computeView();
|
||||||
|
ctx.clearRect(0, 0, v.cw, v.ch);
|
||||||
|
const H = (k: number, i: number, j: number) => (grid ? grid[(j * (N + 1) + i) * OUT + k] : 0.5);
|
||||||
|
|
||||||
|
const quads: { a: any; b: any; d: any; e: any; hue: number; L: number; depth: number }[] = [];
|
||||||
|
const labels: { x: number; y: number; hue: number; name: string }[] = [];
|
||||||
|
for (let k = 0; k < K; k++) {
|
||||||
|
const hue = hueOf(k, K);
|
||||||
|
const P: { x: number; y: number; depth: number; wx: number; wy: number; wz: number }[][] = [];
|
||||||
|
for (let j = 0; j <= N; j++) { P[j] = []; for (let i = 0; i <= N; i++) P[j][i] = project(i / N - 0.5, j / N - 0.5, H(k, i, j), k); }
|
||||||
|
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
|
||||||
|
const a = P[j][i], b = P[j][i + 1], d = P[j + 1][i + 1], e = P[j + 1][i];
|
||||||
|
const ux = d.wx - a.wx, uy = d.wy - a.wy, uz = d.wz - a.wz, vx = b.wx - e.wx, vy = b.wy - e.wy, vz = b.wz - e.wz;
|
||||||
|
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx, nm = Math.hypot(nx, ny, nz) || 1;
|
||||||
|
const L = clamp(0.32 + 0.62 * Math.abs((nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]) / nm));
|
||||||
|
quads.push({ a, b, d, e, hue, L, depth: (a.depth + b.depth + d.depth + e.depth) / 4 });
|
||||||
|
}
|
||||||
|
const lp = project(-0.5, 0.5, H(k, 0, N), k);
|
||||||
|
labels.push({ x: lp.x, y: lp.y, hue, name: namesRef.current[k] || `p${k}` });
|
||||||
|
}
|
||||||
|
quads.sort((p, q) => q.depth - p.depth);
|
||||||
|
for (const Q of quads) {
|
||||||
|
ctx.fillStyle = `hsla(${Q.hue},80%,${(26 + Q.L * 44).toFixed(0)}%,0.66)`;
|
||||||
|
ctx.strokeStyle = `hsla(${Q.hue},80%,82%,0.18)`; ctx.lineWidth = 0.6;
|
||||||
|
ctx.beginPath(); ctx.moveTo(Q.a.x, Q.a.y); ctx.lineTo(Q.b.x, Q.b.y); ctx.lineTo(Q.d.x, Q.d.y); ctx.lineTo(Q.e.x, Q.e.y); ctx.closePath();
|
||||||
|
ctx.fill(); ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.font = '11px var(--font-mono, monospace)'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||||||
|
for (const Lb of labels) { ctx.fillStyle = `hsla(${Lb.hue},85%,74%,0.95)`; ctx.fillText(Lb.name, Lb.x - 6, Lb.y); }
|
||||||
|
|
||||||
|
// probe column at the current input, marker on each slab from the LIVE output
|
||||||
|
const out = engine.getOutputs();
|
||||||
|
const gx = posRef.current[0] - 0.5, gy = posRef.current[1] - 0.5;
|
||||||
|
const top = project(gx, gy, 1.18, 0), bot = project(gx, gy, -0.18, K - 1);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(top.x, top.y); ctx.lineTo(bot.x, bot.y); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
for (let k = 0; k < K; k++) {
|
||||||
|
const val = clamp(out[k] ?? 0.5);
|
||||||
|
const p = project(gx, gy, val, k), fl = project(gx, gy, 0, k);
|
||||||
|
ctx.strokeStyle = colCss(k, K, 0.5); ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(fl.x, fl.y); ctx.stroke();
|
||||||
|
ctx.fillStyle = colCss(k, K, 1); ctx.shadowColor = colCss(k, K, 1); ctx.shadowBlur = 12;
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = 'var(--bg, #0d0d0d)'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.2, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(draw);
|
||||||
|
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
||||||
|
}, [engine, OUT]);
|
||||||
|
|
||||||
|
// ---- drag to orbit (yaw + pitch; pitch up to π/2 = exact top-down) ----
|
||||||
|
const rotating = useRef(false);
|
||||||
|
const last = useRef({ x: 0, y: 0 });
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', cursor: 'grab', touchAction: 'none' }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
rotating.current = true;
|
||||||
|
(e.currentTarget as HTMLCanvasElement).setPointerCapture?.(e.pointerId);
|
||||||
|
last.current = { x: e.clientX, y: e.clientY };
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (!rotating.current) return;
|
||||||
|
yaw.current += (e.clientX - last.current.x) * 0.01;
|
||||||
|
pitch.current = Math.max(0.06, Math.min(Math.PI / 2, pitch.current + (e.clientY - last.current.y) * 0.008));
|
||||||
|
last.current = { x: e.clientX, y: e.clientY };
|
||||||
|
}}
|
||||||
|
onPointerUp={() => { rotating.current = false; }}
|
||||||
|
onPointerCancel={() => { rotating.current = false; }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', top: 10, left: '50%', transform: 'translateX(-50%)', fontSize: 9, letterSpacing: '0.12em', color: 'var(--fg-dim)', pointerEvents: 'none', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
PARAMETER SANDWICH · drag to rotate · top-down = the flat 2-D map
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,19 @@ export function ModeIcon({ size = 18, style }: IconProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sandwich — stacked parameter-landscape slabs (the layer toggle). */
|
||||||
|
export function SandwichIcon({ size = 18, style }: IconProps) {
|
||||||
|
return svg(
|
||||||
|
size,
|
||||||
|
style,
|
||||||
|
<>
|
||||||
|
<path d="M3 6.5 12 3l9 3.5L12 10 3 6.5Z" />
|
||||||
|
<path d="M3 12 12 15.5 21 12" />
|
||||||
|
<path d="M3 17 12 20.5 21 17" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Learning — a brain-ish node graph. */
|
/** Learning — a brain-ish node graph. */
|
||||||
export function LearningIcon({ size = 18, style }: IconProps) {
|
export function LearningIcon({ size = 18, style }: IconProps) {
|
||||||
return svg(
|
return svg(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue