import { useState, useMemo, useRef } from 'react'; import { X, Trash2, GripVertical } from 'lucide-react'; // ───────────────────────────────────────────────────────────────── // Categorías morfológicas — cada una con su identidad visual // ───────────────────────────────────────────────────────────────── const CATEGORIES = { gen: { label: 'Generadores', subtitle: 'Anamorfismos', signature: 'seed → [a]', color: '#7dd3fc', }, map: { label: 'Transformaciones', subtitle: 'Functor · map', signature: '(a→b) → [a]→[b]', color: '#c4b5fd', }, filter: { label: 'Filtros', subtitle: 'Refinamientos', signature: '[a] → [a]', color: '#fcd34d', }, scan: { label: 'Scans', subtitle: 'Folds acumulativos', signature: '[a] → [a]', color: '#86efac', }, fold: { label: 'Plegados', subtitle: 'Catamorfismos', signature: '[a] → b', color: '#fda4af', }, }; // PRNG determinista para que `random` sea reproducible function mulberry32(seed) { let a = seed; return function() { a = (a + 0x6D2B79F5) | 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ───────────────────────────────────────────────────────────────── // Catálogo de funciones disponibles // ───────────────────────────────────────────────────────────────── const FUNCTIONS = { // — GENERADORES (anamorfismos: producen estructura desde un seed) — range: { cat: 'gen', symbol: 'range', sig: 'n → [1..n]', params: [{k:'n',d:20,min:1,max:120}], run: (_,p) => Array.from({length:p.n}, (_,i)=>i+1) }, linspace: { cat: 'gen', symbol: 'linspace', sig: 'n → [0..2π]', params: [{k:'n',d:60,min:2,max:200}], run: (_,p) => Array.from({length:p.n}, (_,i)=>(i/(p.n-1))*2*Math.PI) }, sine: { cat: 'gen', symbol: 'sine', sig: 'n → sin(4π·t)', params: [{k:'n',d:80,min:4,max:200}], run: (_,p) => Array.from({length:p.n}, (_,i)=>Math.sin((i/(p.n-1))*4*Math.PI)) }, noise: { cat: 'gen', symbol: 'noise', sig: 'seed → U(-1,1)', params: [{k:'n',d:50,min:2,max:200},{k:'seed',d:7,min:1,max:9999}], run: (_,p) => { const r=mulberry32(p.seed); return Array.from({length:p.n}, ()=>r()*2-1); } }, fib: { cat: 'gen', symbol: 'fib', sig: 'n → Fibₙ', params: [{k:'n',d:12,min:2,max:25}], run: (_,p) => { const r=[1,1]; while(r.length a.map(x=>x*2) }, square: { cat: 'map', symbol: 'x²', sig: 'x ↦ x²', run: (a) => a.map(x=>x*x) }, negate: { cat: 'map', symbol: '-x', sig: 'x ↦ -x', run: (a) => a.map(x=>-x) }, abs: { cat: 'map', symbol: '|x|', sig: 'x ↦ |x|', run: (a) => a.map(x=>Math.abs(x)) }, sqrt: { cat: 'map', symbol: '√', sig: 'x ↦ √|x|', run: (a) => a.map(x=>Math.sqrt(Math.abs(x))) }, sin: { cat: 'map', symbol: 'sin', sig: 'x ↦ sin x', run: (a) => a.map(x=>Math.sin(x)) }, log: { cat: 'map', symbol: 'ln', sig: 'x ↦ ln(1+|x|)', run: (a) => a.map(x=>Math.log(1+Math.abs(x))) }, // — FILTROS (refinamiento) — positive: { cat: 'filter', symbol: '>0', sig: 'x > 0', run: (a) => a.filter(x=>x>0) }, even: { cat: 'filter', symbol: 'par', sig: 'x even', run: (a) => a.filter(x=>Math.round(x)%2===0) }, gt: { cat: 'filter', symbol: '>t', sig: 'x > t', params: [{k:'t',d:0,min:-50,max:50,step:0.5}], run: (a,p) => a.filter(x=>x>p.t) }, // — SCANS (folds acumulativos: dejan rastro) — cumsum: { cat: 'scan', symbol: 'Σ*', sig: 'Σ prefix', run: (a) => { let s=0; return a.map(x=>s+=x); } }, cummax: { cat: 'scan', symbol: 'max*', sig: 'max prefix', run: (a) => { let m=-Infinity; return a.map(x=>{m=Math.max(m,x); return m;}); } }, diff: { cat: 'scan', symbol: 'Δ', sig: 'xₙ - xₙ₋₁', run: (a) => a.map((x,i)=>i===0?0:x-a[i-1]) }, mavg: { cat: 'scan', symbol: 'μ_k', sig: 'media móvil k', params: [{k:'k',d:3,min:1,max:15}], run: (a,p) => a.map((_,i)=>{ const s=Math.max(0,i-p.k+1); const sl=a.slice(s,i+1); return sl.reduce((t,v)=>t+v,0)/sl.length; }) }, // — FOLDS (catamorfismos: colapsan a escalar · terminales) — sum: { cat: 'fold', symbol: 'Σ', sig: '[a] → Σa', run: (a) => a.reduce((s,x)=>s+x, 0) }, product: { cat: 'fold', symbol: '∏', sig: '[a] → ∏a', run: (a) => a.reduce((s,x)=>s*x, 1) }, max: { cat: 'fold', symbol: 'max', sig: '[a] → max', run: (a) => a.length ? Math.max(...a) : 0 }, min: { cat: 'fold', symbol: 'min', sig: '[a] → min', run: (a) => a.length ? Math.min(...a) : 0 }, mean: { cat: 'fold', symbol: 'μ', sig: '[a] → μ', run: (a) => a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0 }, count: { cat: 'fold', symbol: '#', sig: '[a] → ℕ', run: (a) => a.length }, }; const BY_CATEGORY = Object.entries(CATEGORIES).map(([catKey, catMeta]) => ({ ...catMeta, key: catKey, items: Object.entries(FUNCTIONS).filter(([, f]) => f.cat === catKey).map(([name, f]) => ({ name, ...f })), })); // ───────────────────────────────────────────────────────────────── // Ejecutor: aplica el pipeline y conserva salidas intermedias // ───────────────────────────────────────────────────────────────── function executePipeline(pipeline) { const steps = []; let current = Array.from({length: 10}, (_, i) => i + 1); // semilla si no hay generador let terminated = false; for (const item of pipeline) { const def = FUNCTIONS[item.name]; if (!def) continue; if (terminated) { steps.push({ ...item, def, unreachable: true }); continue; } try { const value = def.run(current, item.params || {}); steps.push({ ...item, def, value }); if (def.cat === 'fold') terminated = true; else current = value; } catch (e) { steps.push({ ...item, def, error: e.message }); terminated = true; } } return { steps, terminated }; } // ───────────────────────────────────────────────────────────────── // Helpers de id y parámetros // ───────────────────────────────────────────────────────────────── let _uid = 0; const uid = () => `n${++_uid}_${Date.now().toString(36)}`; function defaultParams(name) { const def = FUNCTIONS[name]; if (!def.params) return {}; return Object.fromEntries(def.params.map(p => [p.k, p.d])); } // ───────────────────────────────────────────────────────────────── // Sparkline compacto (SVG manual) // ───────────────────────────────────────────────────────────────── function Sparkline({ data, color = '#888', w = 180, h = 34 }) { if (!Array.isArray(data) || data.length === 0) { return
; } const min = Math.min(...data); const max = Math.max(...data); const span = max - min || 1; const n = data.length; const xStep = n > 1 ? (w - 6) / (n - 1) : 0; const pts = data.map((v, i) => [3 + i * xStep, h - 3 - ((v - min) / span) * (h - 6)]); const path = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' '); const zeroY = h - 3 - ((0 - min) / span) * (h - 6); const showZero = min < 0 && max > 0; return ( {showZero && } {n <= 40 && pts.map((p, i) => ( ))} ); } // ───────────────────────────────────────────────────────────────── // Ficha en la paleta (draggable) // ───────────────────────────────────────────────────────────────── function PaletteItem({ fnName, fn, color, onDragStart, onClick }) { return (
{ e.dataTransfer.setData('text/fn-name', fnName); e.dataTransfer.effectAllowed = 'copy'; onDragStart?.(); }} onClick={onClick} className="group flex items-center gap-2 px-2.5 py-1.5 rounded-md cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-neutral-800/60" style={{ borderLeft: `2px solid ${color}` }} title={`${fnName} :: ${fn.sig}`} > {fn.symbol} {fnName} drag
); } // ───────────────────────────────────────────────────────────────── // Nodo del pipeline // ───────────────────────────────────────────────────────────────── function PipelineNode({ step, index, isLast, onRemove, onParamChange, onDragStart, onDragOver, onDrop, isDragging }) { const { def, value, unreachable, error } = step; const cat = CATEGORIES[def.cat]; const color = cat.color; const isScalar = def.cat === 'fold'; return (
{ e.dataTransfer.setData('text/reorder-index', String(index)); e.dataTransfer.effectAllowed = 'move'; onDragStart?.(index); }} onDragOver={(e) => { e.preventDefault(); onDragOver?.(index); }} onDrop={(e) => onDrop?.(e, index)} className="relative group" style={{ opacity: isDragging ? 0.4 : 1 }} >
{/* cabecera */}
{def.symbol} {step.name}
{/* params */} {def.params && (
{def.params.map(p => ( ))}
)} {/* preview */}
{unreachable ? (
inalcanzable · fold anterior terminó el pipeline
) : error ? (
error: {error}
) : isScalar ? (
resultado {formatScalar(value)}
) : (
n={value?.length ?? 0} · range [{formatScalar(Math.min(...(value?.length?value:[0])))}, {formatScalar(Math.max(...(value?.length?value:[0])))}]
)}
{/* flecha de composición */} {!isLast && (
)}
); } function formatScalar(v) { if (v === undefined || v === null || !isFinite(v)) return '—'; if (Number.isInteger(v)) return String(v); const abs = Math.abs(v); if (abs >= 1000 || (abs > 0 && abs < 0.01)) return v.toExponential(2); return v.toFixed(3); } // ───────────────────────────────────────────────────────────────── // Visualización grande del output final // ───────────────────────────────────────────────────────────────── function FinalView({ lastStep }) { if (!lastStep) { return (
arrastra una función para empezar
); } const { def, value, error, unreachable } = lastStep; if (error || unreachable) return null; const color = CATEGORIES[def.cat].color; if (def.cat === 'fold') { return (
resultado · escalar {formatScalar(value)}
); } // plot grande if (!Array.isArray(value) || value.length === 0) { return
∅ (lista vacía)
; } const W = 720, H = 180, pad = 20; const min = Math.min(...value), max = Math.max(...value); const span = max - min || 1; const n = value.length; const xStep = n > 1 ? (W - 2 * pad) / (n - 1) : 0; const pts = value.map((v, i) => [pad + i * xStep, H - pad - ((v - min) / span) * (H - 2 * pad)]); const path = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(1)},${p[1].toFixed(1)}` : `L${p[0].toFixed(1)},${p[1].toFixed(1)}`)).join(' '); const areaPath = `${path} L${pts[pts.length-1][0]},${H-pad} L${pts[0][0]},${H-pad} Z`; const zeroY = H - pad - ((0 - min) / span) * (H - 2 * pad); const showZero = min < 0 && max > 0; return (
resultado · señal n={n} · [{formatScalar(min)}, {formatScalar(max)}]
{/* grid */} {[0.25, 0.5, 0.75].map(f => ( ))} {showZero && } {n <= 80 && pts.map((p, i) => ( ))} {/* eje */}
); } // ───────────────────────────────────────────────────────────────── // APP principal // ───────────────────────────────────────────────────────────────── export default function App() { const [pipeline, setPipeline] = useState([ { id: uid(), name: 'range', params: defaultParams('range') }, { id: uid(), name: 'square', params: defaultParams('square') }, { id: uid(), name: 'cumsum', params: defaultParams('cumsum') }, ]); const [dragIndex, setDragIndex] = useState(null); const [hoverIndex, setHoverIndex] = useState(null); const dropZoneRef = useRef(null); const { steps } = useMemo(() => executePipeline(pipeline), [pipeline]); const lastStep = steps[steps.length - 1]; // expresión simbólica: fold_sum ∘ map_square ∘ gen_range(20) const expression = useMemo(() => { if (pipeline.length === 0) return '∅'; const parts = pipeline.map(s => { const def = FUNCTIONS[s.name]; const paramStr = def.params ? '(' + def.params.map(p => `${p.k}=${s.params?.[p.k] ?? p.d}`).join(',') + ')' : ''; return `${s.name}${paramStr}`; }).reverse(); return parts.join(' ∘ '); }, [pipeline]); const addFunction = (name) => { setPipeline(p => [...p, { id: uid(), name, params: defaultParams(name) }]); }; const removeFunction = (index) => { setPipeline(p => p.filter((_, i) => i !== index)); }; const changeParam = (index, key, value) => { setPipeline(p => p.map((s, i) => i === index ? { ...s, params: { ...s.params, [key]: value } } : s)); }; const handleDropOnZone = (e) => { e.preventDefault(); const fnName = e.dataTransfer.getData('text/fn-name'); const reorderIdx = e.dataTransfer.getData('text/reorder-index'); if (fnName && FUNCTIONS[fnName]) { addFunction(fnName); } else if (reorderIdx !== '') { // soltar al final si vino de un nodo const from = Number(reorderIdx); setPipeline(p => { const copy = [...p]; const [moved] = copy.splice(from, 1); copy.push(moved); return copy; }); } setDragIndex(null); setHoverIndex(null); }; const handleDropOnNode = (e, targetIdx) => { e.preventDefault(); e.stopPropagation(); const fnName = e.dataTransfer.getData('text/fn-name'); const reorderIdx = e.dataTransfer.getData('text/reorder-index'); if (fnName && FUNCTIONS[fnName]) { setPipeline(p => { const copy = [...p]; copy.splice(targetIdx, 0, { id: uid(), name: fnName, params: defaultParams(fnName) }); return copy; }); } else if (reorderIdx !== '') { const from = Number(reorderIdx); if (from === targetIdx) return; setPipeline(p => { const copy = [...p]; const [moved] = copy.splice(from, 1); const adjusted = from < targetIdx ? targetIdx - 1 : targetIdx; copy.splice(adjusted, 0, moved); return copy; }); } setDragIndex(null); setHoverIndex(null); }; return (
{/* HEADER */}
lab · 001 / composición funcional

laboratorio de morfismos

arrastra funciones al pipeline y componlas de izquierda a derecha. cada tipo corresponde a un esquema de recursión distinto.

{/* MAIN GRID · 2 columnas: paleta | (expresión + pipeline + visualizador apilados) */}
{/* ─── IZQUIERDA · PALETA ─── */} {/* ─── DERECHA · EXPRESIÓN + PIPELINE + VISUALIZADOR ─── */}
{/* Expresión simbólica */}
expresión se aplica de derecha a izquierda
{expression}
{/* Pipeline */}
pipeline {pipeline.length} {pipeline.length === 1 ? 'nodo' : 'nodos'}
{ e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }} onDrop={handleDropOnZone} className="rounded-xl border border-dashed border-white/10 p-5 transition-colors hover:border-white/20" style={{background: 'rgba(255,255,255,0.015)', minHeight: '180px'}} > {pipeline.length === 0 ? (
zona de composición
arrastra una primitiva desde la izquierda. los nodos se encadenan de izquierda a derecha.
) : (
{steps.map((step, i) => ( ))}
)}
{/* Visualizador */}
visualizador
{/* Nota didáctica */}
anamorfismo genera estructura · functor map transforma punto a punto ·{' '} filtro refina · scan acumula dejando rastro ·{' '} catamorfismo colapsa (es terminal)
); }