599 lines
30 KiB
React
599 lines
30 KiB
React
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<p.n) r.push(r[r.length-1]+r[r.length-2]); return r.slice(0,p.n); } },
|
||
|
||
// — MAPS (functor f: a→b aplicado punto-a-punto) —
|
||
double: { cat: 'map', symbol: '·2', sig: 'x ↦ 2x', run: (a) => 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 <div style={{width:w, height:h}} className="flex items-center justify-center text-[10px] text-neutral-600">∅</div>;
|
||
}
|
||
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 (
|
||
<svg width={w} height={h} className="block">
|
||
{showZero && <line x1={0} x2={w} y1={zeroY} y2={zeroY} stroke={color} strokeOpacity={0.15} strokeDasharray="2 3" />}
|
||
<path d={path} fill="none" stroke={color} strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round" />
|
||
{n <= 40 && pts.map((p, i) => (
|
||
<circle key={i} cx={p[0]} cy={p[1]} r={1.4} fill={color} fillOpacity={0.85} />
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Ficha en la paleta (draggable)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
function PaletteItem({ fnName, fn, color, onDragStart, onClick }) {
|
||
return (
|
||
<div
|
||
draggable
|
||
onDragStart={(e) => { 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}`}
|
||
>
|
||
<span className="font-mono text-[11px] font-semibold tracking-tight" style={{ color }}>{fn.symbol}</span>
|
||
<span className="font-mono text-[11px] text-neutral-400 flex-1 truncate">{fnName}</span>
|
||
<span className="font-mono text-[9px] text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity">drag</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// 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 (
|
||
<div
|
||
draggable
|
||
onDragStart={(e) => { 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 }}
|
||
>
|
||
<div
|
||
className="flex flex-col rounded-lg backdrop-blur-sm transition-all"
|
||
style={{
|
||
background: `linear-gradient(180deg, ${color}14 0%, ${color}06 100%)`,
|
||
border: `1px solid ${color}40`,
|
||
boxShadow: `0 0 0 1px ${color}10, 0 8px 24px -12px ${color}30`,
|
||
minWidth: 200,
|
||
}}
|
||
>
|
||
{/* cabecera */}
|
||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b" style={{ borderColor: `${color}25` }}>
|
||
<GripVertical size={12} className="text-neutral-600 cursor-grab" />
|
||
<span className="font-mono text-[11px] font-bold" style={{ color }}>{def.symbol}</span>
|
||
<span className="font-mono text-[10px] text-neutral-500 flex-1">{step.name}</span>
|
||
<button
|
||
onClick={() => onRemove(index)}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity text-neutral-500 hover:text-neutral-200 p-0.5 rounded"
|
||
aria-label="Eliminar"
|
||
>
|
||
<X size={11} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* params */}
|
||
{def.params && (
|
||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b flex-wrap" style={{ borderColor: `${color}18` }}>
|
||
{def.params.map(p => (
|
||
<label key={p.k} className="flex items-center gap-1 font-mono text-[10px] text-neutral-500">
|
||
<span>{p.k}=</span>
|
||
<input
|
||
type="number"
|
||
value={step.params?.[p.k] ?? p.d}
|
||
min={p.min} max={p.max} step={p.step ?? 1}
|
||
onChange={(e) => onParamChange(index, p.k, Number(e.target.value))}
|
||
className="w-12 bg-neutral-900/60 border border-neutral-700/50 rounded px-1 py-0.5 text-neutral-200 font-mono text-[10px] focus:outline-none focus:border-neutral-500"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* preview */}
|
||
<div className="px-2.5 py-1.5">
|
||
{unreachable ? (
|
||
<div className="font-mono text-[10px] text-neutral-600 italic">inalcanzable · fold anterior terminó el pipeline</div>
|
||
) : error ? (
|
||
<div className="font-mono text-[10px] text-rose-400">error: {error}</div>
|
||
) : isScalar ? (
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="font-mono text-[10px] text-neutral-500">resultado</span>
|
||
<span className="font-display text-2xl font-light" style={{ color }}>{formatScalar(value)}</span>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Sparkline data={value} color={color} w={180} h={32} />
|
||
<div className="font-mono text-[9px] text-neutral-600 mt-1">
|
||
n={value?.length ?? 0} · range [{formatScalar(Math.min(...(value?.length?value:[0])))}, {formatScalar(Math.max(...(value?.length?value:[0])))}]
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* flecha de composición */}
|
||
{!isLast && (
|
||
<div className="absolute top-1/2 -right-5 -translate-y-1/2 font-mono text-neutral-600 text-sm pointer-events-none">→</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex-1 flex items-center justify-center text-neutral-600 font-mono text-xs">
|
||
arrastra una función para empezar
|
||
</div>
|
||
);
|
||
}
|
||
const { def, value, error, unreachable } = lastStep;
|
||
if (error || unreachable) return null;
|
||
const color = CATEGORIES[def.cat].color;
|
||
|
||
if (def.cat === 'fold') {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-6 w-full">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500 mb-2">resultado · escalar</span>
|
||
<span className="font-display text-5xl font-light break-all text-center" style={{ color }}>{formatScalar(value)}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// plot grande
|
||
if (!Array.isArray(value) || value.length === 0) {
|
||
return <div className="font-mono text-xs text-neutral-600 p-4">∅ (lista vacía)</div>;
|
||
}
|
||
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 (
|
||
<div className="flex flex-col">
|
||
<div className="flex items-baseline justify-between mb-2">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-neutral-500">resultado · señal</span>
|
||
<span className="font-mono text-[10px] text-neutral-500">n={n} · [{formatScalar(min)}, {formatScalar(max)}]</span>
|
||
</div>
|
||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
{/* grid */}
|
||
{[0.25, 0.5, 0.75].map(f => (
|
||
<line key={f} x1={pad} x2={W-pad} y1={pad + f*(H-2*pad)} y2={pad + f*(H-2*pad)} stroke="#fff" strokeOpacity={0.04} />
|
||
))}
|
||
{showZero && <line x1={pad} x2={W-pad} y1={zeroY} y2={zeroY} stroke={color} strokeOpacity={0.25} strokeDasharray="3 4" />}
|
||
<path d={areaPath} fill="url(#areaGrad)" />
|
||
<path d={path} fill="none" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||
{n <= 80 && pts.map((p, i) => (
|
||
<circle key={i} cx={p[0]} cy={p[1]} r={2} fill={color} />
|
||
))}
|
||
{/* eje */}
|
||
<line x1={pad} x2={W-pad} y1={H-pad} y2={H-pad} stroke="#fff" strokeOpacity={0.1} />
|
||
<line x1={pad} x2={pad} y1={pad} y2={H-pad} stroke="#fff" strokeOpacity={0.1} />
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// 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 (
|
||
<div className="w-full min-h-screen text-neutral-200" style={{
|
||
background: 'radial-gradient(ellipse at top, #0f1419 0%, #06080a 60%, #030506 100%)',
|
||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||
}}>
|
||
<style>{`
|
||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||
.font-display { font-family: 'Fraunces', serif; font-optical-sizing: auto; }
|
||
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||
.grid-bg {
|
||
background-image:
|
||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
||
background-size: 24px 24px;
|
||
}
|
||
/* scrollbar */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); }
|
||
`}</style>
|
||
|
||
<div className="grid-bg min-h-screen">
|
||
{/* HEADER */}
|
||
<header className="px-6 pt-6 pb-5 border-b border-white/5">
|
||
<div className="flex items-end justify-between flex-wrap gap-4">
|
||
<div>
|
||
<div className="flex items-baseline gap-3">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">lab · 001</span>
|
||
<span className="font-mono text-[10px] text-neutral-700">/</span>
|
||
<span className="font-mono text-[10px] text-neutral-500">composición funcional</span>
|
||
</div>
|
||
<h1 className="font-display text-3xl font-light tracking-tight mt-1.5">
|
||
laboratorio de <em className="italic" style={{color:'#c4b5fd'}}>morfismos</em>
|
||
</h1>
|
||
<p className="font-mono text-[11px] text-neutral-500 mt-1.5 max-w-2xl">
|
||
arrastra funciones al pipeline y componlas de izquierda a derecha. cada tipo corresponde a un esquema de recursión distinto.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setPipeline([])}
|
||
className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-wider text-neutral-500 hover:text-neutral-200 border border-white/10 hover:border-white/30 px-3 py-1.5 rounded transition-colors"
|
||
>
|
||
<Trash2 size={11} /> vaciar
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* MAIN GRID · 2 columnas: paleta | (expresión + pipeline + visualizador apilados) */}
|
||
<main
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '200px minmax(280px, 1fr)',
|
||
minHeight: 'calc(100vh - 140px)',
|
||
overflowX: 'auto',
|
||
}}
|
||
>
|
||
{/* ─── IZQUIERDA · PALETA ─── */}
|
||
<aside className="border-r border-white/5 p-5 overflow-y-auto" style={{maxHeight: 'calc(100vh - 140px)'}}>
|
||
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-4">primitivas</div>
|
||
<div className="flex flex-col gap-5">
|
||
{BY_CATEGORY.map(cat => (
|
||
<section key={cat.key}>
|
||
<div className="flex items-center gap-2 mb-2 px-1">
|
||
<span className="w-1.5 h-1.5 rounded-full" style={{background: cat.color, boxShadow: `0 0 8px ${cat.color}`}} />
|
||
<span className="font-display text-sm italic" style={{color: cat.color}}>{cat.label}</span>
|
||
</div>
|
||
<div className="font-mono text-[9px] text-neutral-600 mb-2 px-1 leading-snug">
|
||
{cat.subtitle} · <span className="text-neutral-500">{cat.signature}</span>
|
||
</div>
|
||
<div className="flex flex-col gap-0.5">
|
||
{cat.items.map(item => (
|
||
<PaletteItem
|
||
key={item.name}
|
||
fnName={item.name}
|
||
fn={item}
|
||
color={cat.color}
|
||
onClick={() => addFunction(item.name)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
<div className="mt-6 pt-4 border-t border-white/5 font-mono text-[9px] text-neutral-600 leading-relaxed">
|
||
tip · click o drag para añadir al pipeline. reordena arrastrando nodos existentes.
|
||
</div>
|
||
</aside>
|
||
|
||
{/* ─── DERECHA · EXPRESIÓN + PIPELINE + VISUALIZADOR ─── */}
|
||
<section className="p-4 md:p-6 overflow-y-auto flex flex-col gap-5" style={{maxHeight: 'calc(100vh - 140px)'}}>
|
||
{/* Expresión simbólica */}
|
||
<div>
|
||
<div className="flex items-baseline gap-3 mb-2">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">expresión</span>
|
||
<span className="font-mono text-[9px] text-neutral-600">se aplica de derecha a izquierda</span>
|
||
</div>
|
||
<div className="rounded-lg bg-white/[0.03] border border-white/10 p-3">
|
||
<code className="font-mono text-xs text-neutral-200 break-all leading-relaxed">
|
||
{expression}
|
||
</code>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pipeline */}
|
||
<div>
|
||
<div className="flex items-baseline gap-3 mb-2">
|
||
<span className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500">pipeline</span>
|
||
<span className="font-mono text-[10px] text-neutral-600">{pipeline.length} {pipeline.length === 1 ? 'nodo' : 'nodos'}</span>
|
||
</div>
|
||
<div
|
||
ref={dropZoneRef}
|
||
onDragOver={(e) => { 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 ? (
|
||
<div className="h-full flex flex-col items-center justify-center py-12 text-center">
|
||
<div className="font-display text-xl italic text-neutral-500 mb-2">zona de composición</div>
|
||
<div className="font-mono text-[10px] text-neutral-600 max-w-xs">arrastra una primitiva desde la izquierda. los nodos se encadenan de izquierda a derecha.</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-wrap gap-x-8 gap-y-4 items-start">
|
||
{steps.map((step, i) => (
|
||
<PipelineNode
|
||
key={step.id}
|
||
step={step}
|
||
index={i}
|
||
isLast={i === steps.length - 1}
|
||
onRemove={removeFunction}
|
||
onParamChange={changeParam}
|
||
onDragStart={setDragIndex}
|
||
onDragOver={setHoverIndex}
|
||
onDrop={handleDropOnNode}
|
||
isDragging={dragIndex === i}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Visualizador */}
|
||
<div>
|
||
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-neutral-500 mb-2">visualizador</div>
|
||
<div className="rounded-lg border border-white/5 bg-white/[0.015] p-4" style={{minHeight: '220px'}}>
|
||
<div className="h-full flex items-center justify-center">
|
||
<FinalView lastStep={lastStep} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Nota didáctica */}
|
||
<div className="font-mono text-[10px] text-neutral-600 leading-relaxed border-l-2 border-neutral-800 pl-3">
|
||
<span style={{color:'#7dd3fc'}}>anamorfismo</span> genera estructura · <span style={{color:'#c4b5fd'}}>functor map</span> transforma punto a punto ·{' '}
|
||
<span style={{color:'#fcd34d'}}>filtro</span> refina · <span style={{color:'#86efac'}}>scan</span> acumula dejando rastro ·{' '}
|
||
<span style={{color:'#fda4af'}}>catamorfismo</span> colapsa (es terminal)
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|