import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { X, AlertCircle, RotateCcw, ChevronDown, ChevronRight, Trash2, GripVertical } from 'lucide-react'; // ═══════════════════════════════════════════════════════════════════ // CATÁLOGO DE NODOS // Cada nodo declara: // - kind: 'gen' | 'op' | 'blend' // - params: hasta 4 floats · slots del vec4 en u.params[idx] // - controls: descriptores de UI (pueden agrupar varios params) // - body: snippet WGSL del cuerpo de la función // ═══════════════════════════════════════════════════════════════════ const MAX_NODES = 16; const ACCENT = '#5eead4'; const GEN_COLOR = '#5eead4'; const OP_COLOR = '#c4b5fd'; const BLEND_COLOR = '#fbbf24'; const NODES = { // ─── GENERADORES ─────────────────────────────────────────────── solid: { kind: 'gen', label: 'solid', desc: 'color constante', params: [ { k: 'r', d: 0.35 }, { k: 'g', d: 0.25 }, { k: 'b', d: 0.55 }, { k: '_', d: 0 }, ], controls: [ { kind: 'color', keys: ['r', 'g', 'b'], label: 'color' }, ], body: (i) => ` let p = u.params[${i}]; return vec4(p.x, p.y, p.z, 1.0);`, }, gradient: { kind: 'gen', label: 'gradient', desc: 'gradiente direccional', params: [ { k: 'angle', d: 0.8 }, { k: 'hue', d: 0.5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, ], controls: [ { kind: 'slider', key: 'angle', label: 'ángulo', min: 0, max: 6.2832, step: 0.01 }, { kind: 'slider', key: 'hue', label: 'tono', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let p = u.params[${i}]; let dir = vec2(cos(p.x), sin(p.x)); let t = dot(uv - 0.5, dir) + 0.5; let col = 0.5 + 0.5 * cos(6.28318 * (p.y + vec3(0.0, 0.33, 0.67) + t)); return vec4(col, 1.0);`, }, plasma: { kind: 'gen', label: 'plasma', desc: 'onda trigonométrica', params: [ { k: 'speed', d: 1 }, { k: 'scale', d: 2 }, { k: '_', d: 0 }, { k: '_', d: 0 }, ], controls: [ { kind: 'slider', key: 'speed', label: 'velocidad', min: 0, max: 3, step: 0.01 }, { kind: 'slider', key: 'scale', label: 'escala', min: 0.5, max: 10, step: 0.1 }, ], body: (i) => ` let p = u.params[${i}]; let col = 0.5 + 0.5 * cos(u.time * p.x + uv.xyx * p.y + vec3(0.0, 2.0, 4.0)); return vec4(col, 1.0);`, }, checker: { kind: 'gen', label: 'checker', desc: 'tablero rotando', params: [ { k: 'scale', d: 8 }, { k: 'rot', d: 0.25 }, { k: '_', d: 0 }, { k: '_', d: 0 }, ], controls: [ { kind: 'slider', key: 'scale', label: 'escala', min: 1, max: 30, step: 0.5 }, { kind: 'slider', key: 'rot', label: 'rotación', min: -2, max: 2, step: 0.01 }, ], body: (i) => ` let p = u.params[${i}]; let aspect = u.resolution.x / u.resolution.y; let q0 = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); let a = u.time * p.y; let rm = mat2x2(cos(a), -sin(a), sin(a), cos(a)); let q = rm * q0 * p.x; let chk = (floor(q.x) + floor(q.y)) - 2.0 * floor((floor(q.x) + floor(q.y)) * 0.5); return vec4(vec3(chk), 1.0);`, }, circle: { kind: 'gen', label: 'circle', desc: 'sdf de círculo', params: [ { k: 'cx', d: 0 }, { k: 'cy', d: 0 }, { k: 'radius', d: 0.35 }, { k: 'soft', d: 0.01 }, ], controls: [ { kind: 'xy', keys: ['cx', 'cy'], label: 'centro', min: -0.8, max: 0.8, step: 0.01 }, { kind: 'slider', key: 'radius', label: 'radio', min: 0, max: 1, step: 0.01 }, { kind: 'slider', key: 'soft', label: 'suavidad', min: 0.001, max: 0.1, step: 0.001 }, ], body: (i) => ` let p = u.params[${i}]; let aspect = u.resolution.x / u.resolution.y; let pos = vec2((uv.x - 0.5) * aspect - p.x, uv.y - 0.5 - p.y); let d = length(pos) - p.z; let fill = smoothstep(p.w, -p.w, d); return mix(c, vec4(1.0), fill);`, }, stripes: { kind: 'gen', label: 'stripes', desc: 'rayas animadas', params: [ { k: 'freq', d: 20 }, { k: 'speed', d: 1 }, { k: 'angle', d: 0.5 }, { k: '_', d: 0 }, ], controls: [ { kind: 'slider', key: 'freq', label: 'frecuencia', min: 1, max: 80, step: 0.5 }, { kind: 'slider', key: 'speed', label: 'velocidad', min: -5, max: 5, step: 0.05 }, { kind: 'slider', key: 'angle', label: 'ángulo', min: 0, max: 3.1416, step: 0.01 }, ], body: (i) => ` let p = u.params[${i}]; let dir = vec2(cos(p.z), sin(p.z)); let x = dot(uv, dir); let v = 0.5 + 0.5 * sin(x * p.x + u.time * p.y); return vec4(vec3(v), 1.0);`, }, noise: { kind: 'gen', label: 'noise', desc: 'hash pseudo-aleatorio', params: [ { k: 'scale', d: 80 }, { k: 'seed', d: 7 }, { k: 'anim', d: 0 }, { k: '_', d: 0 }, ], controls: [ { kind: 'slider', key: 'scale', label: 'escala', min: 1, max: 200, step: 1 }, { kind: 'slider', key: 'seed', label: 'seed', min: 0, max: 100, step: 1 }, { kind: 'slider', key: 'anim', label: 'animar', min: 0, max: 10, step: 0.1 }, ], body: (i) => ` let p = u.params[${i}]; let q = floor(uv * p.x + p.y + u.time * p.z); let h = fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); return vec4(vec3(h), 1.0);`, }, // ─── OPERADORES (toman c, devuelven c') ──────────────────────── invert: { kind: 'op', label: 'invert', desc: '1 − rgb', params: [{ k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [], body: () => ` return vec4(1.0 - c.rgb, c.a);`, }, gamma: { kind: 'op', label: 'gamma', desc: 'pow(rgb, γ)', params: [{ k: 'g', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [{ kind: 'slider', key: 'g', label: 'γ', min: 0.1, max: 5, step: 0.01 }], body: (i) => ` let g = max(0.01, u.params[${i}].x); return vec4(pow(max(c.rgb, vec3(0.0)), vec3(g)), c.a);`, }, contrast: { kind: 'op', label: 'contrast', desc: '(rgb − 0.5)·k + 0.5', params: [{ k: 'k', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [{ kind: 'slider', key: 'k', label: 'k', min: 0, max: 3, step: 0.01 }], body: (i) => ` return vec4((c.rgb - vec3(0.5)) * u.params[${i}].x + vec3(0.5), c.a);`, }, saturate: { kind: 'op', label: 'saturate', desc: 'lerp(luma, rgb, s)', params: [{ k: 's', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [{ kind: 'slider', key: 's', label: 's', min: 0, max: 2, step: 0.01 }], body: (i) => ` let luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); return vec4(mix(vec3(luma), c.rgb, u.params[${i}].x), c.a);`, }, hueShift: { kind: 'op', label: 'hue shift', desc: 'rotar matiz', params: [{ k: 'h', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [{ kind: 'slider', key: 'h', label: 'h', min: 0, max: 1, step: 0.01 }], body: (i) => ` let p = u.params[${i}]; let k = vec3(0.57735); let ca = cos(p.x * 6.2832); let sa = sin(p.x * 6.2832); let rot = c.rgb * ca + cross(k, c.rgb) * sa + k * dot(k, c.rgb) * (1.0 - ca); return vec4(rot, c.a);`, }, tint: { kind: 'op', label: 'tint', desc: 'rgb × color', params: [{ k: 'r', d: 1 }, { k: 'g', d: 0.8 }, { k: 'b', d: 0.7 }, { k: '_', d: 0 }], controls: [{ kind: 'color', keys: ['r', 'g', 'b'], label: 'tinte' }], body: (i) => ` let p = u.params[${i}]; return vec4(c.rgb * vec3(p.x, p.y, p.z) * 2.0, c.a);`, }, posterize: { kind: 'op', label: 'posterize', desc: 'cuantizar a N niveles', params: [{ k: 'levels', d: 5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [{ kind: 'slider', key: 'levels', label: 'niveles', min: 2, max: 16, step: 1 }], body: (i) => ` let n = max(2.0, u.params[${i}].x); return vec4(floor(c.rgb * n) / n, c.a);`, }, vignette: { kind: 'op', label: 'vignette', desc: 'oscurecer bordes', params: [{ k: 'cx', d: 0 }, { k: 'cy', d: 0 }, { k: 'strength', d: 1 }, { k: 'radius', d: 0.5 }], controls: [ { kind: 'xy', keys: ['cx', 'cy'], label: 'centro', min: -0.5, max: 0.5, step: 0.01 }, { kind: 'slider', key: 'strength', label: 'fuerza', min: 0, max: 2, step: 0.01 }, { kind: 'slider', key: 'radius', label: 'radio', min: 0, max: 1.4, step: 0.01 }, ], body: (i) => ` let p = u.params[${i}]; let d = length((uv - vec2(0.5)) - vec2(p.x, p.y)); let v = 1.0 - smoothstep(p.w, p.w + 0.3, d) * p.z; return vec4(c.rgb * v, c.a);`, }, ripple: { kind: 'op', label: 'ripple', desc: 'ondas radiales', params: [{ k: 'freq', d: 30 }, { k: 'amp', d: 0.2 }, { k: 'speed', d: 2 }, { k: '_', d: 0 }], controls: [ { kind: 'slider', key: 'freq', label: 'frecuencia', min: 1, max: 100, step: 1 }, { kind: 'slider', key: 'amp', label: 'amplitud', min: 0, max: 1, step: 0.01 }, { kind: 'slider', key: 'speed', label: 'velocidad', min: -5, max: 5, step: 0.05 }, ], body: (i) => ` let p = u.params[${i}]; let d = length(uv - vec2(0.5)); let w = sin(d * p.x - u.time * p.z) * p.y; return vec4(c.rgb * (1.0 + w), c.a);`, }, pulse: { kind: 'op', label: 'pulse', desc: 'onda temporal', params: [{ k: 'freq', d: 2 }, { k: 'amount', d: 0.3 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'slider', key: 'freq', label: 'frecuencia', min: 0, max: 10, step: 0.05 }, { kind: 'slider', key: 'amount', label: 'cantidad', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let p = u.params[${i}]; return vec4(c.rgb * (1.0 + p.y * sin(u.time * p.x)), c.a);`, }, // ─── BLENDS (fan-in: toman a + b + amount) ───────────────────── blend_mix: { kind: 'blend', label: 'mix', desc: 'interpolación · mix(a, b, t)', params: [{ k: 't', d: 0.5 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 't', label: 't', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` return mix(a, b, u.params[${i}].x);`, }, blend_multiply: { kind: 'blend', label: 'multiply', desc: 'a · b', params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let r = vec4(a.rgb * b.rgb, a.a); return mix(a, r, u.params[${i}].x);`, }, blend_screen: { kind: 'blend', label: 'screen', desc: '1 − (1−a)(1−b)', params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let r = vec4(1.0 - (1.0 - a.rgb) * (1.0 - b.rgb), a.a); return mix(a, r, u.params[${i}].x);`, }, blend_add: { kind: 'blend', label: 'add', desc: 'clamp(a + b)', params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let r = vec4(min(a.rgb + b.rgb, vec3(1.0)), a.a); return mix(a, r, u.params[${i}].x);`, }, blend_difference: { kind: 'blend', label: 'difference', desc: '|a − b|', params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let r = vec4(abs(a.rgb - b.rgb), a.a); return mix(a, r, u.params[${i}].x);`, }, blend_darken: { kind: 'blend', label: 'darken', desc: 'min(a, b)', params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let r = vec4(min(a.rgb, b.rgb), a.a); return mix(a, r, u.params[${i}].x);`, }, blend_lighten: { kind: 'blend', label: 'lighten', desc: 'max(a, b)', params: [{ k: 'amount', d: 1 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b' }, { kind: 'slider', key: 'amount', label: 'amount', min: 0, max: 1, step: 0.01 }, ], body: (i) => ` let r = vec4(max(a.rgb, b.rgb), a.a); return mix(a, r, u.params[${i}].x);`, }, blend_mask: { kind: 'blend', label: 'mask', desc: 'usa luma(b) como alpha', params: [{ k: 'invert', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }, { k: '_', d: 0 }], controls: [ { kind: 'source', label: 'source b (máscara)' }, { kind: 'select', key: 'invert', label: 'invertir', options: ['no', 'sí'] }, ], body: (i) => ` let luma = dot(b.rgb, vec3(0.2126, 0.7152, 0.0722)); let m = select(luma, 1.0 - luma, u.params[${i}].x > 0.5); return vec4(a.rgb * m, a.a);`, }, }; const NODES_BY_KIND = { gen: Object.entries(NODES).filter(([, v]) => v.kind === 'gen') .map(([k, v]) => ({ name: k, ...v })), op: Object.entries(NODES).filter(([, v]) => v.kind === 'op') .map(([k, v]) => ({ name: k, ...v })), blend: Object.entries(NODES).filter(([, v]) => v.kind === 'blend').map(([k, v]) => ({ name: k, ...v })), }; // ═══════════════════════════════════════════════════════════════════ // Compilador: DAG → WGSL (con soporte para blends / fan-in) // ═══════════════════════════════════════════════════════════════════ function compileDagToWGSL(pipeline) { const safe = pipeline.slice(0, MAX_NODES); const fns = safe.map((step, idx) => { const def = NODES[step.name]; const sig = def.kind === 'blend' ? '(a: vec4, b: vec4, uv: vec2)' : '(c: vec4, uv: vec2)'; return `fn node_${idx}${sig} -> vec4 {${def.body(idx)} }`; }).join('\n\n'); let chain; if (safe.length === 0) { chain = ' var c = vec4(0.04, 0.04, 0.06, 1.0);'; } else { const lines = safe.map((step, idx) => { const def = NODES[step.name]; const prev = idx === 0 ? 'vec4(0.0, 0.0, 0.0, 1.0)' : `out_${idx - 1}`; if (def.kind === 'blend') { // resolver source por id (robusto ante reordenamientos) let srcIdx = -1; const srcId = step.meta?.sourceId; if (srcId) { srcIdx = safe.findIndex(s => s.id === srcId); } if (srcIdx < 0 || srcIdx >= idx) srcIdx = Math.max(0, idx - 2); // fallback: el de hace dos const src = idx === 0 ? prev : `out_${Math.min(srcIdx, idx - 1)}`; return ` let out_${idx} = node_${idx}(${prev}, ${src}, uv);`; } return ` let out_${idx} = node_${idx}(${prev}, uv);`; }); chain = lines.join('\n'); } const finalExpr = safe.length === 0 ? 'c' : `out_${safe.length - 1}`; return `struct Uniforms { time: f32, _pad: f32, resolution: vec2, params: array, ${MAX_NODES}>, }; @group(0) @binding(0) var u: Uniforms; @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { let p = array, 3>( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0) ); return vec4(p[i], 0.0, 1.0); } ${fns} @fragment fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { let uv = pos.xy / u.resolution; ${chain} return ${finalExpr}; } `; } // ═══════════════════════════════════════════════════════════════════ // Uniform buffer // ═══════════════════════════════════════════════════════════════════ const UNIFORM_FLOATS = 4 + MAX_NODES * 4; const UNIFORM_BYTES = UNIFORM_FLOATS * 4; function writeUniforms(device, buffer, time, width, height, pipeline) { const data = new Float32Array(UNIFORM_FLOATS); data[0] = time; data[1] = 0; data[2] = width; data[3] = height; for (let i = 0; i < Math.min(pipeline.length, MAX_NODES); i++) { const step = pipeline[i]; const def = NODES[step.name]; const offset = 4 + i * 4; for (let j = 0; j < 4; j++) { const p = def.params[j]; data[offset + j] = p && p.k !== '_' ? (step.params[p.k] ?? p.d) : (p ? p.d : 0); } } device.queue.writeBuffer(buffer, 0, data); } // ═══════════════════════════════════════════════════════════════════ // Hook WebGPU + DAG // ═══════════════════════════════════════════════════════════════════ function useWebGPUDag(canvasRef, pipeline, topologyKey) { const gpu = useRef({ device: null, context: null, format: null, pipeline: null, bindGroup: null, uniformBuffer: null, startTime: 0, }); const pipelineRef = useRef(pipeline); useEffect(() => { pipelineRef.current = pipeline; }, [pipeline]); const [status, setStatus] = useState('init'); const [shaderError, setShaderError] = useState(null); const [fps, setFps] = useState(0); useEffect(() => { let cancelled = false; (async () => { try { if (!navigator.gpu) { setStatus('unsupported'); return; } const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { setStatus('unsupported'); return; } const device = await adapter.requestDevice(); if (cancelled) return; const canvas = canvasRef.current; if (!canvas) return; canvas.width = 400; canvas.height = 400; const context = canvas.getContext('webgpu'); const format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: 'premultiplied' }); const uniformBuffer = device.createBuffer({ size: UNIFORM_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); gpu.current = { ...gpu.current, device, context, format, uniformBuffer, startTime: performance.now() }; device.lost.then(() => setStatus('error')); setStatus('ready'); } catch (e) { console.error(e); setShaderError(String(e.message || e)); setStatus('error'); } })(); return () => { cancelled = true; }; }, [canvasRef]); const compileShader = useCallback(async (wgsl) => { const { device, format, uniformBuffer } = gpu.current; if (!device) return; device.pushErrorScope('validation'); const module = device.createShaderModule({ code: wgsl }); const info = await module.getCompilationInfo(); const errors = info.messages.filter(m => m.type === 'error'); if (errors.length > 0) { setShaderError(errors.map(m => `línea ${m.lineNum}: ${m.message}`).join('\n')); await device.popErrorScope(); return; } try { const p = device.createRenderPipeline({ layout: 'auto', vertex: { module, entryPoint: 'vs' }, fragment: { module, entryPoint: 'fs', targets: [{ format }] }, primitive:{ topology: 'triangle-list' }, }); const bg = device.createBindGroup({ layout: p.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniformBuffer } }], }); gpu.current.pipeline = p; gpu.current.bindGroup = bg; setShaderError(null); } catch (e) { setShaderError(String(e.message || e)); } const err = await device.popErrorScope(); if (err) setShaderError(err.message); }, []); useEffect(() => { if (status !== 'ready') return; const wgsl = compileDagToWGSL(pipelineRef.current); compileShader(wgsl); }, [topologyKey, status, compileShader]); useEffect(() => { if (status !== 'ready') return; let running = true; let frames = 0; let lastFpsSample = performance.now(); const loop = () => { if (!running) return; const { device, context, pipeline: gpuPipe, bindGroup, uniformBuffer, startTime } = gpu.current; const canvas = canvasRef.current; if (device && context && gpuPipe && bindGroup && canvas) { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = Math.max(1, Math.floor(canvas.clientWidth * dpr)); const h = Math.max(1, Math.floor(canvas.clientHeight * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } const t = (performance.now() - startTime) / 1000; writeUniforms(device, uniformBuffer, t, canvas.width, canvas.height, pipelineRef.current); const encoder = device.createCommandEncoder(); const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store', }], }); pass.setPipeline(gpuPipe); pass.setBindGroup(0, bindGroup); pass.draw(3); pass.end(); device.queue.submit([encoder.finish()]); frames++; const now = performance.now(); if (now - lastFpsSample >= 500) { setFps(Math.round((frames * 1000) / (now - lastFpsSample))); frames = 0; lastFpsSample = now; } } requestAnimationFrame(loop); }; requestAnimationFrame(loop); return () => { running = false; }; }, [status, canvasRef]); const resetTime = useCallback(() => { gpu.current.startTime = performance.now(); }, []); return { status, shaderError, fps, resetTime }; } // ═══════════════════════════════════════════════════════════════════ // Utilidades // ═══════════════════════════════════════════════════════════════════ let _uid = 0; const uid = () => `n${++_uid}_${Date.now().toString(36)}`; function defaultParams(name) { const d = NODES[name]; const obj = {}; for (const p of d.params) if (p.k !== '_') obj[p.k] = p.d; return obj; } // ═══════════════════════════════════════════════════════════════════ // Controles UI // ═══════════════════════════════════════════════════════════════════ function ParamSlider({ ctrl, value, onChange, color }) { const display = ctrl.step >= 1 ? Math.round(value) : Number(value).toFixed(ctrl.step < 0.01 ? 3 : 2); return (
{ctrl.label} onChange(Number(e.target.value))} onPointerDown={(e) => e.stopPropagation()} className="flex-1" style={{ accentColor: color, minWidth: '60px' }} /> {display}
); } function XYPad({ ctrl, xValue, yValue, onChange, color }) { const ref = useRef(null); const [dragging, setDragging] = useState(false); const xNorm = (xValue - ctrl.min) / (ctrl.max - ctrl.min); const yNorm = (yValue - ctrl.min) / (ctrl.max - ctrl.min); const update = useCallback((clientX, clientY) => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const nx = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const ny = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height)); const xv = ctrl.min + nx * (ctrl.max - ctrl.min); const yv = ctrl.min + ny * (ctrl.max - ctrl.min); onChange(xv, yv); }, [ctrl.min, ctrl.max, onChange]); useEffect(() => { if (!dragging) return; const onMove = (e) => update(e.clientX, e.clientY); const onUp = () => setDragging(false); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); return () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }; }, [dragging, update]); return (
{ctrl.label} {xValue.toFixed(2)} , {yValue.toFixed(2)}
{ e.stopPropagation(); e.preventDefault(); setDragging(true); update(e.clientX, e.clientY); }} className="relative rounded cursor-crosshair select-none" style={{ width: '100%', height: '70px', background: `${color}0a`, border: `1px solid ${color}30`, }} >
); } function ColorPicker({ ctrl, r, g, b, onChange, color }) { const toHex = () => { const h = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0'); return `#${h(r)}${h(g)}${h(b)}`; }; const fromHex = (hex) => { const rr = parseInt(hex.slice(1, 3), 16) / 255; const gg = parseInt(hex.slice(3, 5), 16) / 255; const bb = parseInt(hex.slice(5, 7), 16) / 255; return [rr, gg, bb]; }; return (
{ctrl.label}