NODE BASED - 1D CELLULAR AUTOMATA STUDIO

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/f6f73b61bf7e74d9/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CA NODES</title>
<style>
  :root { --ink:#111; --mid:#8a8a8a; --line:#e6e6e6; --soft:#f6f6f6; --pink:#e6397b; }
  * { box-sizing:border-box; }
  html, body { margin:0; height:100%; background:#fff; color:var(--ink);
    font:12px/1.4 -apple-system,"Helvetica Neue",Helvetica,Arial,sans-serif; }
  #app { display:flex; height:100vh; width:100vw; overflow:hidden; }

  /* ---------- left: node graph ---------- */
  #left { position:relative; flex:0 0 58%; min-width:260px; overflow:hidden; background:#fff;
    background-image:radial-gradient(#dcdcdc 1px, transparent 1px); background-size:20px 20px; }
  #ws { position:absolute; left:0; top:0; width:0; height:0; transform-origin:0 0; }
  #wires { position:absolute; left:0; top:0; width:1px; height:1px; overflow:visible; pointer-events:none; z-index:0; }
  .wire { fill:none; stroke:#111; stroke-width:1.5; }
  .wire.c { stroke:var(--pink); }
  .wire.tmp { stroke-dasharray:4 3; stroke:#999; }

  #tb { position:absolute; left:12px; top:12px; z-index:20; display:flex; gap:6px; align-items:center;
    background:#fff; border:1px solid var(--line); border-radius:6px; padding:5px 8px; }
  #tb b { font-size:10px; letter-spacing:.14em; margin-right:6px; }
  button { font:inherit; background:#fff; color:var(--ink); border:1px solid #d6d6d6; border-radius:4px;
    padding:3px 9px; cursor:pointer; }
  button:hover { border-color:#111; }
  #hint { position:absolute; left:14px; bottom:10px; z-index:20; color:#aaa; font-size:10px; pointer-events:none; }

  #menu { position:absolute; z-index:30; display:none; background:#fff; border:1px solid #cfcfcf;
    border-radius:6px; padding:6px; min-width:180px; overflow-y:auto; box-shadow:0 6px 24px rgba(0,0,0,.08); }
  #menu .g { font-size:9px; letter-spacing:.12em; text-transform:uppercase; color:#aaa; margin:8px 6px 2px; }
  #menu .g:first-child { margin-top:2px; }
  #menu .it { padding:4px 8px; border-radius:4px; cursor:pointer; }
  #menu .it:hover { background:var(--soft); }

  .node { position:absolute; width:190px; background:#fff; border:1px solid #cfcfcf; border-radius:6px; z-index:1; user-select:none; }
  .node.sel { border-color:#111; box-shadow:0 0 0 1px #111; }
  .node.off { opacity:.5; }
  .nh { display:flex; align-items:center; gap:6px; padding:6px 8px; cursor:grab; border-bottom:1px solid var(--line);
    font-size:10px; letter-spacing:.08em; text-transform:uppercase; font-weight:600; }
  .nh .t { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .nh .x { cursor:pointer; color:#bbb; font-size:14px; line-height:1; }
  .nh .x:hover { color:#111; }
  .nh .byp { width:9px; height:9px; border:1.5px solid #bbb; border-radius:50%; cursor:pointer; }
  .nh .byp:hover { border-color:#111; }
  .node.off .byp { background:#111; border-color:#111; }
  .nb { padding:8px; }
  .in { position:relative; display:flex; align-items:center; height:18px; margin-bottom:4px; color:#555; font-size:11px; padding-left:2px; }
  .port { position:absolute; width:10px; height:10px; border:1.5px solid #111; border-radius:50%; background:#fff; }
  .port::after { content:""; position:absolute; inset:-6px; }
  .port:hover { background:#111; }
  .port.c { border-color:var(--pink); }
  .port.c:hover { background:var(--pink); }
  .port.in { left:-14px; top:4px; }
  .port.out { right:-6px; top:8px; }
  .thumb { display:block; height:84px; margin:4px auto 8px; border:1px solid var(--line); background:#fff; image-rendering:pixelated; }
  .row { margin-bottom:7px; }
  .lb { display:flex; justify-content:space-between; color:#777; font-size:10px; margin-bottom:1px; }
  .lb .val { color:#111; font-variant-numeric:tabular-nums; }
  input[type=range] { width:100%; height:14px; margin:0; accent-color:#111; }
  select, input[type=text] { width:100%; font:inherit; font-size:11px; padding:3px 4px; border:1px solid #ddd; border-radius:3px; background:#fff; color:#111; }
  input[type=color] { width:100%; height:22px; border:1px solid #ddd; border-radius:3px; padding:1px; background:#fff; }

  .gbar { height:16px; border:1px solid #ddd; border-radius:3px; cursor:copy; }
  .gh { position:relative; height:14px; margin:3px 5px 6px; }
  .gstop { position:absolute; top:0; width:10px; height:12px; margin-left:-5px; border:1.5px solid #bbb; border-radius:2px; cursor:ew-resize; }
  .gstop.on { border-color:#111; box-shadow:0 0 0 1px #111; }
  .grow { display:flex; gap:4px; }
  .grow input { flex:1; }
  .gdel { padding:0 8px; }

  #split { flex:0 0 5px; cursor:col-resize; background:#fff; border-left:1px solid var(--line); }
  #split:hover { background:var(--soft); }

  /* ---------- right: generator ---------- */
  #right { flex:1 1 auto; display:flex; flex-direction:column; min-width:260px; background:#fff; }
  #stage { flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; }
  #view { border:1px solid var(--line); background:#fff; }
  #msg { position:absolute; color:#bbb; font-size:11px; pointer-events:none; }
  #bar { flex:0 0 auto; display:flex; flex-wrap:wrap; gap:8px 12px; align-items:center; padding:10px 14px; border-top:1px solid var(--line); }
  #bar label { display:flex; align-items:center; gap:6px; color:#777; font-size:10px; }
  #bar input[type=range] { width:90px; }
  #info { margin-left:auto; color:#999; font-size:10px; font-variant-numeric:tabular-nums; }
</style>
</head>
<body>
<div id="app">
  <section id="left">
    <div id="ws"><svg id="wires"></svg></div>
    <div id="tb">
      <b>CA NODES</b>
      <button id="bAdd">+ Node</button>
      <button id="bFit">Fit</button>
      <button id="bReset">Reset</button>
      <button id="bExp">Export</button>
      <button id="bImp">Import</button>
      <input type="file" id="fImp" accept=".json,application/json" hidden>
    </div>
    <div id="menu"></div>
    <div id="hint">Scroll = verschieben · ⌘/Strg + Scroll = zoomen · Doppelklick = Node hinzufügen · Entf = löschen · rosa Kabel = Farbe</div>
  </section>
  <div id="split"></div>
  <section id="right">
    <div id="stage"><canvas id="view"></canvas><div id="msg"></div></div>
    <div id="bar">
      <button id="bRun">▶ Run</button>
      <button id="bReseed">Reseed</button>
      <button id="bPng">PNG</button>
      <label>Speed <input id="sSpeed" type="range" min="1" max="16" step="1" value="3"></label>
      <span id="info"></span>
    </div>
  </section>
</div>

<script>
// ============================================================
//  CA NODES v2 — node-based 1D cellular automaton generator
//  Skalare Felder: Float32Array(W*H), Werte 0..1      (graue Ports)
//  Farbfelder:     Float32Array(W*H*3), Werte 0..1    (rosa Ports)
// ============================================================

// ---------- helpers ----------
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const smooth01=(e0,e1,x)=>{const t=clamp((x-e0)/(e1-e0),0,1);return t*t*(3-2*t);};
function hashStr(s){let h=5381;for(let i=0;i<s.length;i++)h=((h<<5)+h+s.charCodeAt(i))|0;return h;}
function hexToRgb(h){const m=/^#?([0-9a-f]{6})$/i.exec(h)||[0,'111111'];const n=parseInt(m[1],16);return [n>>16&255,n>>8&255,n&255];}
function rgbToHex(r,g,b){return '#'+[r,g,b].map(v=>clamp(Math.round(v),0,255).toString(16).padStart(2,'0')).join('');}

function makePerlin(seed){
  const r=mulberry32(seed), perm=[...Array(256).keys()];
  for(let i=255;i>0;i--){const j=Math.floor(r()*(i+1));[perm[i],perm[j]]=[perm[j],perm[i]];}
  const p=new Uint8Array(512); for(let i=0;i<512;i++)p[i]=perm[i&255];
  const fade=t=>t*t*t*(t*(t*6-15)+10);
  const grad=(h,x,y)=>{switch(h&7){case 0:return x+y;case 1:return -x+y;case 2:return x-y;case 3:return -x-y;case 4:return x;case 5:return -x;case 6:return y;default:return -y;}};
  return (x,y)=>{
    const X=Math.floor(x),Y=Math.floor(y),xf=x-X,yf=y-Y,xi=X&255,yi=Y&255;
    const aa=p[p[xi]+yi],ab=p[p[xi]+yi+1],ba=p[p[xi+1]+yi],bb=p[p[xi+1]+yi+1];
    const u=fade(xf),v=fade(yf);
    return lerp(lerp(grad(aa,xf,yf),grad(ba,xf-1,yf),u),lerp(grad(ab,xf,yf-1),grad(bb,xf-1,yf-1),u),v);
  };
}

const ruleTables={};
function ruleTable(rule){
  if(!ruleTables[rule]){const t=new Uint8Array(8);for(let i=0;i<8;i++)t[i]=(rule>>i)&1;ruleTables[rule]=t;}
  return ruleTables[rule];
}
function parseRules(s){
  const a=String(s).split(/[^0-9]+/).filter(Boolean).map(n=>clamp(parseInt(n,10),0,255));
  return a.length?a:[90];
}

function morph(src,W,H,r,isMax){
  const tmp=new Float32Array(W*H),out=new Float32Array(W*H);
  for(let y=0;y<H;y++)for(let x=0;x<W;x++){
    let m=isMax?-1:2;
    for(let k=-r;k<=r;k++){const xx=x+k;if(xx<0||xx>=W)continue;const v=src[y*W+xx];m=isMax?(v>m?v:m):(v<m?v:m);}
    tmp[y*W+x]=m;
  }
  for(let y=0;y<H;y++)for(let x=0;x<W;x++){
    let m=isMax?-1:2;
    for(let k=-r;k<=r;k++){const yy=y+k;if(yy<0||yy>=H)continue;const v=tmp[yy*W+x];m=isMax?(v>m?v:m):(v<m?v:m);}
    out[y*W+x]=m;
  }
  return out;
}

function boxBlur(src,W,H,r){
  const tmp=new Float32Array(W*H),out=new Float32Array(W*H),pre=new Float64Array(Math.max(W,H)+1);
  for(let y=0;y<H;y++){
    pre[0]=0;for(let x=0;x<W;x++)pre[x+1]=pre[x]+src[y*W+x];
    for(let x=0;x<W;x++){const lo=Math.max(0,x-r),hi=Math.min(W,x+r+1);tmp[y*W+x]=(pre[hi]-pre[lo])/(hi-lo);}
  }
  for(let x=0;x<W;x++){
    pre[0]=0;for(let y=0;y<H;y++)pre[y+1]=pre[y]+tmp[y*W+x];
    for(let y=0;y<H;y++){const lo=Math.max(0,y-r),hi=Math.min(H,y+r+1);out[y*W+x]=(pre[hi]-pre[lo])/(hi-lo);}
  }
  return out;
}

function drawLine(o,W,H,x0,y0,x1,y1,r){
  x0=Math.round(x0);y0=Math.round(y0);x1=Math.round(x1);y1=Math.round(y1);
  const dx=Math.abs(x1-x0),dy=-Math.abs(y1-y0),sx=x0<x1?1:-1,sy=y0<y1?1:-1;
  let err=dx+dy,guard=0;
  for(;;){
    for(let j=-r;j<=r;j++){const yy=y0+j;if(yy<0||yy>=H)continue;
      for(let i=-r;i<=r;i++){const xx=x0+i;if(xx>=0&&xx<W)o[yy*W+xx]=1;}}
    if((x0===x1&&y0===y1)||++guard>20000)break;
    const e2=2*err;
    if(e2>=dy){err+=dy;x0+=sx;}
    if(e2<=dx){err+=dx;y0+=sy;}
  }
}

function gradLUT(stops){
  const s=[...stops].sort((a,b)=>a.p-b.p).map(o=>({p:o.p,c:hexToRgb(o.c).map(v=>v/255)}));
  const lut=new Float32Array(256*3);
  for(let i=0;i<256;i++){
    const t=i/255;let c;
    if(t<=s[0].p)c=s[0].c;
    else if(t>=s[s.length-1].p)c=s[s.length-1].c;
    else{
      for(let j=0;j<s.length-1;j++){
        if(t>=s[j].p&&t<=s[j+1].p){
          const span=s[j+1].p-s[j].p,u=span>1e-6?(t-s[j].p)/span:0;
          c=[lerp(s[j].c[0],s[j+1].c[0],u),lerp(s[j].c[1],s[j+1].c[1],u),lerp(s[j].c[2],s[j+1].c[2],u)];break;
        }
      }
    }
    lut[i*3]=c[0];lut[i*3+1]=c[1];lut[i*3+2]=c[2];
  }
  return lut;
}

// ---------- math functions ----------
const MATHFN={
  sqrt:v=>Math.sqrt(v),
  square:v=>v*v,
  pow:(v,a)=>Math.pow(v,a),
  log:(v,a)=>Math.log(1+a*v)/Math.log(1+a),
  exp:(v,a)=>(Math.exp(a*v)-1)/(Math.exp(a)-1),
  sin:(v,a,b)=>0.5+0.5*Math.sin(6.283185307*(v*a+b)),
  sigmoid:(v,a,b)=>1/(1+Math.exp(-a*4*(v-b))),
  smoothstep:(v,a,b)=>{const w=0.5/Math.max(a,0.05);return smooth01(b-w,b+w,v);},
  contrast:(v,a,b)=>(v-b)*a+0.5,
  fract:(v,a)=>{const t=v*a;return t-Math.floor(t);},
  posterize:(v,a)=>{const s=Math.max(2,Math.round(a));return Math.round(v*(s-1))/(s-1);},
  bias:(v,a,b)=>{const g=clamp(b,0.01,0.99);return v/((1/g-2)*(1-v)+1);},
  gain:(v,a,b)=>{const g=clamp(1-b,0.01,0.99);const bi=x=>x/((1/g-2)*(1-x)+1);return v<0.5?bi(2*v)/2:1-bi(2-2*v)/2;},
  tent:v=>1-Math.abs(2*v-1),
  invert:v=>1-v
};
const MATHDYN={
  sqrt:['',''],square:['',''],pow:['Exponent',''],log:['Strength',''],exp:['Strength',''],
  sin:['Frequency','Phase'],sigmoid:['Steepness','Center'],smoothstep:['Sharpness','Center'],
  contrast:['Contrast','Center'],fract:['Repeats',''],posterize:['Steps',''],bias:['','Bias'],gain:['','Gain'],tent:['',''],invert:['','']
};
const M2={
  add:(a,b)=>a+b, subtract:(a,b)=>a-b, multiply:(a,b)=>a*b,
  divide:(a,b)=>b>0.001?a/b:1, power:(a,b)=>Math.pow(a,b*4),
  min:Math.min, max:Math.max,
  modulo:(a,b)=>{const m=Math.max(b,0.01);return (a%m)/m;},
  difference:(a,b)=>Math.abs(a-b), screen:(a,b)=>1-(1-a)*(1-b),
  hypot:(a,b)=>Math.hypot(a,b)/1.41421356,
  atan2:(a,b)=>Math.atan2(a-0.5,b-0.5)/6.283185307+0.5,
  greater:(a,b)=>a>b?1:0
};

// ---------- node type definitions ----------
const R=(id,label,min,max,step,def)=>({id,label,type:'range',min,max,step,def});
const S=(id,label,options,def)=>({id,label,type:'select',options,def});
const T=(id,label,def)=>({id,label,type:'text',def});
const C=(id,label,def)=>({id,label,type:'color',def});
const G=(id,label,def)=>({id,label,type:'gradient',def});
const opts=o=>Object.keys(o).map(k=>[k,k]);

const TYPES={
  perlin:{
    title:'Perlin Map', inputs:[],
    params:[
      R('scaleX','Scale X',0.001,0.05,0.001,0.008),
      R('scaleY','Scale Y',0.0001,0.05,0.0001,0.001),
      R('octaves','Octaves',1,5,1,1),
      R('contrast','Contrast',0.5,4,0.1,1.6),
      R('seed','Seed',0,999,1,7)
    ],
    eval(ctx,_,p){
      const {W,H}=ctx, noise=makePerlin((p.seed|0)+ctx.gseed*131), o=new Float32Array(W*H), oct=p.octaves|0;
      for(let y=0;y<H;y++)for(let x=0;x<W;x++){
        let amp=1,f=1,sum=0,norm=0;
        for(let k=0;k<oct;k++){sum+=amp*noise(x*p.scaleX*f+k*17.3,y*p.scaleY*f+k*9.1);norm+=amp;amp*=0.5;f*=2;}
        o[y*W+x]=clamp(0.5+0.5*(sum/norm)*p.contrast,0,1);
      }
      return o;
    }
  },

  osc:{
    title:'Oscillator',
    inputs:[{id:'fm',label:'Freq Mod'},{id:'pm',label:'Phase Mod'},{id:'am',label:'Amp Mod'}],
    params:[
      S('wave','Wave',[['sine','sine'],['triangle','triangle / ramp'],['saw','saw'],['square','square / pulse']],'sine'),
      S('space','Space',[['linear','linear'],['radial','radial'],['angular','angular']],'linear'),
      R('freq','Frequency (Zyklen)',0.25,64,0.25,6),
      R('angle','Angle',0,180,1,0),
      R('phase','Phase',0,1,0.01,0),
      R('duty','Duty / Skew',0.05,0.95,0.01,0.5),
      R('depth','Depth',0,1,0.01,1),
      R('offset','Offset',0,1,0.01,0.5),
      R('fmAmt','Freq Mod Amt',0,4,0.05,0.5),
      R('pmAmt','Phase Mod Amt',0,4,0.05,1),
      R('amAmt','Amp Mod Amt',0,1,0.01,0.5)
    ],
    eval(ctx,[fm,pm,am],p){
      const {W,H}=ctx,o=new Float32Array(W*H);
      const ca=Math.cos(p.angle*Math.PI/180),sa=Math.sin(p.angle*Math.PI/180),cx=W/2,cy=H/2,d=p.duty;
      for(let y=0;y<H;y++)for(let x=0;x<W;x++){
        const i=y*W+x;
        let u;
        if(p.space==='radial')u=Math.hypot(x-cx,y-cy)/W;
        else if(p.space==='angular')u=(Math.atan2(y-cy,x-cx)+Math.PI)/(2*Math.PI); // 1 Umlauf, Frequenz = Anzahl Arme
        else u=(x*ca+y*sa)/W;
        const f=Math.max(0,p.freq*(1+(fm?(fm[i]-0.5)*2:0)*p.fmAmt));
        const ph=u*f+p.phase+(pm?(pm[i]-0.5)*p.pmAmt:0);
        const t=ph-Math.floor(ph);
        let w;
        switch(p.wave){
          case 'triangle':w=t<d?-1+2*t/d:1-2*(t-d)/(1-d);break;
          case 'saw':w=2*t-1;break;
          case 'square':w=t<d?1:-1;break;
          default:w=Math.sin(6.283185307*ph);
        }
        const amp=p.depth*(1-p.amAmt+p.amAmt*(am?am[i]:1));
        o[i]=clamp(p.offset+0.5*amp*w,0,1);
      }
      return o;
    }
  },

  voronoi:{
    title:'Voronoi / Delaunay', inputs:[{id:'warp',label:'Warp'}],
    params:[
      S('mode','Mode',[['cells','cells (Zufallswert)'],['edges','edges'],['dist','distance'],['f2f1','F2 − F1'],['delaunay','delaunay lines']],'cells'),
      R('size','Cell Size',6,128,1,32),
      R('jitter','Jitter',0,1,0.01,1),
      R('width','Line Width',0.5,8,0.5,1.5),
      R('warpAmt','Warp Amount',0,120,1,0),
      R('seed','Seed',0,999,1,5)
    ],
    eval(ctx,[warp],p){
      const {W,H}=ctx,s=Math.max(2,p.size|0),n=W*H,o=new Float32Array(n);
      const gc=Math.ceil(W/s)+3,gr=Math.ceil(H/s)+3,N=gc*gr;
      const rnd=mulberry32((p.seed|0)*131+ctx.gseed*7919+3);
      const px=new Float32Array(N),py=new Float32Array(N),pv=new Float32Array(N);
      for(let gy=-1;gy<gr-1;gy++)for(let gx=-1;gx<gc-1;gx++){
        const k=(gy+1)*gc+(gx+1);
        px[k]=(gx+0.5+(rnd()-0.5)*p.jitter)*s;
        py[k]=(gy+0.5+(rnd()-0.5)*p.jitter)*s;
        pv[k]=rnd();
      }
      const mode=p.mode,wa=p.warpAmt,wd=mode==='delaunay',own=wd?new Int32Array(n):null;
      const ox=(W/3)|0,oy=(H/5)|0;
      for(let y=0;y<H;y++)for(let x=0;x<W;x++){
        const i=y*W+x;
        let qx=x,qy=y;
        if(warp&&wa>0){
          qx=x+(warp[i]-0.5)*wa;
          qy=y+(warp[((y+oy)%H)*W+((x+ox)%W)]-0.5)*wa;
          qx=clamp(qx,0,W-1);qy=clamp(qy,0,H-1);
        }
        const cx=Math.floor(qx/s),cy=Math.floor(qy/s);
        let f1=1e18,f2=1e18,id=0;
        for(let j=-1;j<=1;j++){
          const gy=cy+j;if(gy<-1||gy>gr-2)continue;
          for(let q=-1;q<=1;q++){
            const gx=cx+q;if(gx<-1||gx>gc-2)continue;
            const k=(gy+1)*gc+gx+1,dx=px[k]-qx,dy=py[k]-qy,dd=dx*dx+dy*dy;
            if(dd<f1){f2=f1;f1=dd;id=k;}else if(dd<f2)f2=dd;
          }
        }
        if(wd){own[i]=id;continue;}
        const d1=Math.sqrt(f1),d2=Math.sqrt(f2);
        if(mode==='cells')o[i]=pv[id];
        else if(mode==='dist')o[i]=clamp(d1/(s*0.8),0,1);
        else if(mode==='f2f1')o[i]=clamp((d2-d1)/(s*0.5),0,1);
        else o[i]=(d2-d1)<p.width?1:0;
      }
      if(wd){
        const seen=new Set(),r=Math.floor(p.width/2);
        for(let y=0;y<H;y++)for(let x=0;x<W;x++){
          const i=y*W+x,a=own[i];
          if(x+1<W){const b=own[i+1];if(b!==a)seen.add(a<b?a*N+b:b*N+a);}
          if(y+1<H){const b=own[i+W];if(b!==a)seen.add(a<b?a*N+b:b*N+a);}
        }
        for(const key of seen){
          const a=Math.floor(key/N),b=key-a*N;
          drawLine(o,W,H,px[a],py[a],px[b],py[b],r);
        }
      }
      return o;
    }
  },

  ca:{
    title:'CA 1D', inputs:[{id:'map',label:'Rule Map'}],
    params:[
      T('rules','Ruleset (Komma)','90'),
      R('cellW','Cell Width',1,32,1,1),
      R('cellH','Cell Height',1,32,1,1),
      S('start','Start Row',[['random','random'],['center','center'],['sparse','sparse']],'random'),
      R('inject','Injection',0,0.02,0.0005,0.001),
      R('seed','Seed',0,999,1,1)
    ],
    eval(ctx,[map],p){
      const {W,H}=ctx, cw=Math.max(1,p.cellW|0), ch=Math.max(1,p.cellH|0);
      const cols=Math.ceil(W/cw), rows=Math.ceil(H/ch);
      const tabs=parseRules(p.rules).map(ruleTable);
      const rnd=mulberry32((p.seed|0)*977+ctx.gseed*7919+1);
      let prev=new Uint8Array(cols), next=new Uint8Array(cols);
      if(p.start==='center') prev[cols>>1]=1;
      else if(p.start==='sparse'){for(let x=0;x<cols;x++)if(rnd()<0.06)prev[x]=1;}
      else for(let x=0;x<cols;x++)prev[x]=rnd()<0.5?1:0;
      const out=new Float32Array(W*H);
      for(let y=0;y<rows;y++){
        if(y>0){
          for(let x=0;x<cols;x++){
            let tab=tabs[0];
            if(map&&tabs.length>1){
              const mx=Math.min(W-1,x*cw+(cw>>1)), my=Math.min(H-1,y*ch+(ch>>1));
              const ri=Math.floor(map[my*W+mx]*tabs.length);
              tab=tabs[clamp(ri,0,tabs.length-1)];
            }
            const l=prev[(x-1+cols)%cols],c=prev[x],r=prev[(x+1)%cols];
            let v=tab[(l<<2)|(c<<1)|r];
            if(p.inject>0&&rnd()<p.inject)v=rnd()<0.5?1:0;
            next[x]=v;
          }
          const t=prev;prev=next;next=t;
        }
        const y0=y*ch,y1=Math.min(H,y0+ch);
        for(let x=0;x<cols;x++){
          if(!prev[x])continue;
          const x0=x*cw,x1=Math.min(W,x0+cw);
          for(let yy=y0;yy<y1;yy++){const b=yy*W;for(let xx=x0;xx<x1;xx++)out[b+xx]=1;}
        }
      }
      return out;
    }
  },

  gradient:{
    title:'Gradient Map', out:'c',
    inputs:[{id:'value',label:'Value'},{id:'mod',label:'Mod'}],
    params:[
      G('stops','Gradient',[{p:0,c:'#111111'},{p:0.5,c:'#ff2d87'},{p:1,c:'#ffd0e4'}]),
      R('cycles','Cycles',0.1,8,0.05,1),
      R('shift','Shift',-1,1,0.01,0),
      R('modAmt','Mod Amount',0,2,0.01,1.2),
      S('wrap','Wrap',[['clamp','clamp'],['repeat','repeat'],['mirror','mirror']],'clamp'),
      R('steps','Steps (0 = weich)',0,16,1,0)
    ],
    eval(ctx,[val,mod],p){
      const n=ctx.W*ctx.H,o=new Float32Array(n*3),lut=gradLUT(p.stops),steps=p.steps|0;
      for(let i=0;i<n;i++){
        let v=(val?val[i]:0)*p.cycles+p.shift+(mod?(mod[i]-0.5)*p.modAmt:0);
        if(p.wrap==='repeat')v=v-Math.floor(v);
        else if(p.wrap==='mirror'){const t=((v%2)+2)%2;v=t>1?2-t:t;}
        else v=clamp(v,0,1);
        if(steps>1)v=Math.min(steps-1,Math.floor(v*steps))/(steps-1);
        const k=((v*255+0.5)|0)*3;
        o[i*3]=lut[k];o[i*3+1]=lut[k+1];o[i*3+2]=lut[k+2];
      }
      return o;
    }
  },

  math:{
    title:'Math', dyn:MATHDYN, inputs:[{id:'in',label:'Field'}],
    params:[
      S('op','Function',[['sqrt','sqrt'],['square','square'],['pow','pow'],['log','log'],['exp','exp'],['sin','sin'],['sigmoid','sigmoid'],['smoothstep','smoothstep'],['contrast','contrast'],['fract','fract (Wiederholung)'],['posterize','posterize'],['bias','bias'],['gain','gain'],['tent','tent'],['invert','invert']],'sqrt'),
      R('a','A',0.05,32,0.05,2),
      R('b','B',0,1,0.01,0.5)
    ],
    eval(ctx,[a],p){
      const n=ctx.W*ctx.H,o=new Float32Array(n);if(!a)return o;
      const f=MATHFN[p.op]||MATHFN.sqrt;
      for(let i=0;i<n;i++){const v=f(a[i],p.a,p.b);o[i]=v>=0?(v<=1?v:1):(v!==v?0:0);}
      return o;
    }
  },

  math2:{
    title:'Math (A, B)', inputs:[{id:'a',label:'A'},{id:'b',label:'B'}],
    params:[
      S('op','Operation',opts(M2),'multiply'),
      R('b','B (konstant)',0,1,0.01,0.5)
    ],
    eval(ctx,[a,b],p){
      const n=ctx.W*ctx.H,o=new Float32Array(n),f=M2[p.op]||M2.add;
      for(let i=0;i<n;i++){const v=f(a?a[i]:0,b?b[i]:p.b);o[i]=v>=0?(v<=1?v:1):0;}
      return o;
    }
  },

  mix:{
    title:'Mix', inputs:[{id:'a',label:'A'},{id:'b',label:'B'},{id:'mask',label:'Mask'}],
    params:[
      S('mode','Mode',[['mix','mix'],['add','add'],['multiply','multiply'],['xor','xor'],['min','min'],['max','max'],['diff','difference']],'mix'),
      R('amount','Amount',0,1,0.01,0.35)
    ],
    eval(ctx,[a,b,mask],p){
      const n=ctx.W*ctx.H,o=new Float32Array(n);
      for(let i=0;i<n;i++){
        const av=a?a[i]:0,bv=b?b[i]:0,m=p.amount*(mask?mask[i]:1);
        let op;
        switch(p.mode){
          case 'add':op=Math.min(1,av+bv);break;
          case 'multiply':op=av*bv;break;
          case 'xor':op=((av>0.5)!==(bv>0.5))?1:0;break;
          case 'min':op=Math.min(av,bv);break;
          case 'max':op=Math.max(av,bv);break;
          case 'diff':op=Math.abs(av-bv);break;
          default:op=bv;
        }
        o[i]=av*(1-m)+op*m;
      }
      return o;
    }
  },

  threshold:{
    title:'Threshold', inputs:[{id:'in',label:'Field'}],
    params:[R('level','Level',0,1,0.01,0.5)],
    eval(ctx,[a],p){const n=ctx.W*ctx.H,o=new Float32Array(n);if(!a)return o;for(let i=0;i<n;i++)o[i]=a[i]>=p.level?1:0;return o;}
  },

  invert:{
    title:'Invert', inputs:[{id:'in',label:'Field'}], params:[],
    eval(ctx,[a]){const n=ctx.W*ctx.H,o=new Float32Array(n);for(let i=0;i<n;i++)o[i]=1-(a?a[i]:0);return o;}
  },

  mirror:{
    title:'Mirror', out:'same', inputs:[{id:'in',label:'Field',type:'any'}],
    params:[S('mode','Mode',[['lr','links → rechts'],['rl','rechts → links'],['tb','oben → unten'],['bt','unten → oben'],['quad','4-fach']],'lr')],
    eval(ctx,[a],p){
      const {W,H}=ctx;
      if(!a)return new Float32Array(W*H);
      const ch=Math.round(a.length/(W*H)),o=new Float32Array(W*H*ch),hw=W>>1,hh=H>>1;
      for(let y=0;y<H;y++)for(let x=0;x<W;x++){
        let sx=x,sy=y;
        if(p.mode==='lr'||p.mode==='quad'){if(x>=hw)sx=W-1-x;}
        if(p.mode==='rl'){if(x<hw)sx=W-1-x;}
        if(p.mode==='tb'||p.mode==='quad'){if(y>=hh)sy=H-1-y;}
        if(p.mode==='bt'){if(y<hh)sy=H-1-y;}
        const si=sy*W+sx,di=y*W+x;
        for(let c=0;c<ch;c++)o[di*ch+c]=a[si*ch+c];
      }
      return o;
    }
  },

  warp:{
    title:'Warp', out:'same', inputs:[{id:'in',label:'Field',type:'any'},{id:'map',label:'Warp Map'}],
    params:[
      S('mode','Mode',[['xy','2D'],['x','horizontal'],['y','vertikal']],'xy'),
      R('amount','Amount (px)',0,200,1,20)
    ],
    eval(ctx,[a,m],p){
      const {W,H}=ctx;
      if(!a)return new Float32Array(W*H);
      if(!m)return a.slice();
      const ch=Math.round(a.length/(W*H)),o=new Float32Array(W*H*ch),ox=(W/3)|0,oy=(H/5)|0,amt=p.amount;
      for(let y=0;y<H;y++)for(let x=0;x<W;x++){
        const i=y*W+x;let dx=0,dy=0;
        if(p.mode==='xy'){dx=(m[i]-0.5)*amt;dy=(m[((y+oy)%H)*W+((x+ox)%W)]-0.5)*amt;}
        else if(p.mode==='x')dx=(m[i]-0.5)*amt;
        else dy=(m[i]-0.5)*amt;
        const sx=clamp(Math.round(x+dx),0,W-1),sy=clamp(Math.round(y+dy),0,H-1),si=sy*W+sx;
        for(let c=0;c<ch;c++)o[i*ch+c]=a[si*ch+c];
      }
      return o;
    }
  },

  blur:{
    title:'Blur', inputs:[{id:'in',label:'Field'}],
    params:[R('r','Radius',1,24,1,4),R('passes','Passes',1,4,1,2)],
    eval(ctx,[a],p){
      const {W,H}=ctx;if(!a)return new Float32Array(W*H);
      let f=a;for(let k=0;k<(p.passes|0);k++)f=boxBlur(f,W,H,p.r|0);
      return f;
    }
  },

  snap:{
    title:'Grid Snap', inputs:[{id:'in',label:'Field'}],
    params:[R('size','Cell Size',1,16,1,3),R('thr','Majority',0.1,0.9,0.05,0.5)],
    eval(ctx,[a],p){
      const {W,H}=ctx,o=new Float32Array(W*H);if(!a)return o;
      const s=Math.max(1,p.size|0);
      for(let by=0;by<H;by+=s)for(let bx=0;bx<W;bx+=s){
        const x1=Math.min(W,bx+s),y1=Math.min(H,by+s);let sum=0,c=0;
        for(let y=by;y<y1;y++)for(let x=bx;x<x1;x++){sum+=a[y*W+x];c++;}
        const v=(sum/c)>=p.thr?1:0;
        for(let y=by;y<y1;y++)for(let x=bx;x<x1;x++)o[y*W+x]=v;
      }
      return o;
    }
  },

  morph:{
    title:'Dilate / Erode', inputs:[{id:'in',label:'Field'}],
    params:[S('op','Operation',[['dilate','dilate'],['erode','erode'],['open','open'],['close','close']],'dilate'),R('r','Radius',1,6,1,1)],
    eval(ctx,[a],p){
      const {W,H}=ctx;if(!a)return new Float32Array(W*H);
      const r=p.r|0;
      switch(p.op){
        case 'erode':return morph(a,W,H,r,false);
        case 'open':return morph(morph(a,W,H,r,false),W,H,r,true);
        case 'close':return morph(morph(a,W,H,r,true),W,H,r,false);
        default:return morph(a,W,H,r,true);
      }
    }
  },

  output:{
    title:'Output', noOut:true, thumb:false,
    inputs:[{id:'shape',label:'Shape'},{id:'color',label:'Color',type:'c'}],
    params:[
      S('size','Resolution',[['256x320','256 × 320'],['128x160','128 × 160'],['192x240','192 × 240'],['384x480','384 × 480'],['512x640','512 × 640'],['768x960','768 × 960'],['1024x1280','1024 × 1280'],['256x256','256 × 256'],['512x512','512 × 512']],'256x320'),
      S('up','Upscale',[['1','1×'],['2','2×'],['3','3×'],['4','4×']],'2'),
      S('mode','Rendering',[['pixel','pixel (scharf)'],['smooth','smooth (Kanten glätten)']],'pixel'),
      R('sharp','Smooth Sharpen',0,1,0.01,0.85),
      C('ink','Ink','#111111'),
      C('paper','Paper','#ffffff')
    ],
    eval(){return null;}
  }
};

const MENU=[
  ['Source',[
    {type:'perlin',label:'Perlin Map'},
    {type:'osc',label:'Oscillator'},
    {type:'voronoi',label:'Voronoi / Delaunay'},
    {type:'ca',label:'CA · Structure',params:{cellW:1,cellH:1,rules:'30,45,60,90,105,110,150,154',inject:0.001}},
    {type:'ca',label:'CA · LFO',params:{cellW:16,cellH:20,rules:'90',inject:0}}
  ]],
  ['Color',[{type:'gradient',label:'Gradient Map'}]],
  ['Math',[{type:'math',label:'Math'},{type:'math2',label:'Math (A, B)'},{type:'mix',label:'Mix'},{type:'threshold',label:'Threshold'},{type:'invert',label:'Invert'}]],
  ['Filter',[{type:'mirror',label:'Mirror'},{type:'warp',label:'Warp'},{type:'blur',label:'Blur'},{type:'snap',label:'Grid Snap'},{type:'morph',label:'Dilate / Erode'}]],
  ['Output',[{type:'output',label:'Output'}]]
];

// ---------- state ----------
let nodes=[], nextId=1, gseed=0, tx=0, ty=0, sc=1, selected=null;
let cur={W:256,H:320,k:2,mode:'pixel',sharp:0.85,ink:'#111111',paper:'#ffffff',shape:null,color:null};
let evalNeeded=true, dirty=true, playing=false, reveal=1e9, saveTimer=null, evalMs=0;
let evalId=0, lastRenderSig=null, full=null, fctx=null;
const STORE_KEY='ca-nodes-v2';

const $=id=>document.getElementById(id);
const left=$('left'), ws=$('ws'), wires=$('wires'), menu=$('menu');
const view=$('view'), vctx=view.getContext('2d'), stage=$('stage');
const byId=id=>nodes.find(n=>n.id===id);

function defaultsOf(spec){
  const o={};
  spec.params.forEach(pd=>{o[pd.id]=pd.type==='gradient'?JSON.parse(JSON.stringify(pd.def)):pd.def;});
  return o;
}
function makeNode(type,x,y,over,label){
  const spec=TYPES[type], params=defaultsOf(spec);
  if(over)Object.assign(params,over);
  return {id:'n'+(nextId++),type,label:label||null,x,y,params,bypass:false,inputs:{}};
}

function outType(n){
  const sp=TYPES[n.type];
  if(sp.noOut)return null;
  if(sp.out==='same'){
    const s=n.inputs[sp.inputs[0].id]&&byId(n.inputs[sp.inputs[0].id]);
    return s&&s!==n?outType(s):'s';
  }
  return sp.out||'s';
}
function inType(n,portId){const inp=TYPES[n.type].inputs.find(i=>i.id===portId);return inp&&inp.type||'s';}
function typeOk(want,have){return want==='any'||want===have;}

function defaultGraph(){
  nodes=[];nextId=1;
  const perlin=makeNode('perlin',30,30);
  const lfo=makeNode('ca',260,30,{cellW:16,cellH:20,rules:'90',inject:0,seed:3},'CA · LFO');
  const mix=makeNode('mix',490,30);
  const grad=makeNode('gradient',720,30);
  const ca=makeNode('ca',30,430,{cellW:1,cellH:1,rules:'30,45,60,90,105,110,150,154',inject:0.001},'CA · Structure');
  const mirS=makeNode('mirror',260,430);
  const mirC=makeNode('mirror',490,430);
  const out=makeNode('output',720,470);
  mix.inputs={a:perlin.id,b:lfo.id};
  ca.inputs={map:mix.id};
  grad.inputs={value:lfo.id,mod:perlin.id};
  mirS.inputs={in:ca.id};
  mirC.inputs={in:grad.id};
  out.inputs={shape:mirS.id,color:mirC.id};
  nodes=[perlin,lfo,mix,grad,ca,mirS,mirC,out];
}

// ---------- evaluation ----------
function evaluate(){
  const t0=performance.now();evalId++;
  const outNode=nodes.find(n=>n.type==='output');
  let W=256,H=320,k=2;
  if(outNode){[W,H]=outNode.params.size.split('x').map(Number);k=parseInt(outNode.params.up,10)||1;}
  k=Math.max(1,Math.min(k,Math.floor(Math.sqrt(16e6/(W*H)))||1));
  const ctx={W,H,gseed},visiting=new Set();

  function ev(n){
    if(n._evalId===evalId)return n._res;
    if(visiting.has(n.id))return null;
    visiting.add(n.id);
    const spec=TYPES[n.type];
    const srcs=spec.inputs.map(inp=>{
      const s=n.inputs[inp.id]&&byId(n.inputs[inp.id]);
      if(!s||s===n)return null;
      ev(s);
      return typeOk(inp.type||'s',outType(s))?s:null;
    });
    const ins=srcs.map(s=>s?(s._res||null):null);
    const sig=hashStr([n.type,JSON.stringify(n.params),n.bypass?1:0,W,H,gseed,...srcs.map(s=>s?s._sig:'-')].join('|'));
    if(n._sig!==sig||n._res===undefined){
      let res=null;
      if(!spec.noOut){
        if(n.bypass){
          const sz=spec.out==='c'?3*W*H:W*H;
          res=spec.out==='same'?(ins[0]||new Float32Array(W*H)):(ins.find(v=>v&&v.length===sz)||new Float32Array(sz));
        }else res=spec.eval(ctx,ins,n.params);
      }
      n._res=res;n._sig=sig;
    }
    visiting.delete(n.id);n._evalId=evalId;
    return n._res;
  }
  nodes.forEach(ev);

  nodes.forEach(n=>{
    if(TYPES[n.type].thumb!==false&&n.thumb&&n._thumbSig!==n._sig){drawThumb(n.thumb,n._res,W,H);n._thumbSig=n._sig;}
  });

  const getIn=(n,id,type)=>{const s=n.inputs[id]&&byId(n.inputs[id]);return s&&outType(s)===type?(s._res||null):null;};
  let shape=null,color=null;
  if(outNode){shape=getIn(outNode,'shape','s');color=getIn(outNode,'color','c');}
  cur.W=W;cur.H=H;cur.k=k;cur.shape=shape;cur.color=color;
  if(outNode){cur.mode=outNode.params.mode;cur.sharp=outNode.params.sharp;cur.ink=outNode.params.ink;cur.paper=outNode.params.paper;}
  const rsig=outNode?String(outNode._sig)+'|'+k:'none';
  if(rsig!==lastRenderSig){renderFull();lastRenderSig=rsig;}
  evalMs=performance.now()-t0;
  $('info').textContent=W+'×'+H+' → '+(W*k)+'×'+(H*k)+' · '+nodes.length+' Nodes · '+evalMs.toFixed(0)+' ms';
  $('msg').textContent=(shape||color)?'':'Output-Node mit Shape oder Color verbinden';
}

function drawThumb(cv,f,W,H){
  const th=84,tw=Math.max(8,Math.min(170,Math.round(th*W/H)));
  cv.width=tw;cv.height=th;
  const c=cv.getContext('2d'),img=c.createImageData(tw,th),d=img.data;
  const col=f&&f.length===W*H*3;
  for(let y=0;y<th;y++)for(let x=0;x<tw;x++){
    const si=Math.floor(y*H/th)*W+Math.floor(x*W/tw),i=(y*tw+x)*4;
    if(col){d[i]=f[si*3]*255;d[i+1]=f[si*3+1]*255;d[i+2]=f[si*3+2]*255;}
    else{const g=255-Math.round((f?f[si]:0)*255);d[i]=d[i+1]=d[i+2]=g;}
    d[i+3]=255;
  }
  c.putImageData(img,0,0);
}

// ---------- output rendering ----------
function renderFull(){
  const {W,H,k}=cur,OW=W*k,OH=H*k;
  if(!full||full.width!==OW||full.height!==OH){full=document.createElement('canvas');full.width=OW;full.height=OH;fctx=full.getContext('2d');}
  const img=fctx.createImageData(OW,OH),d=img.data;
  const ink=hexToRgb(cur.ink),paper=hexToRgb(cur.paper),S=cur.shape,Cc=cur.color;
  if(!S&&!Cc){
    for(let i=0;i<OW*OH;i++){d[i*4]=paper[0];d[i*4+1]=paper[1];d[i*4+2]=paper[2];d[i*4+3]=255;}
    fctx.putImageData(img,0,0);return;
  }
  const smooth=cur.mode==='smooth'&&k>1,w=0.5*(1-cur.sharp)+0.02;
  const x0=new Int32Array(OW),x1=new Int32Array(OW),fx=new Float32Array(OW);
  for(let X=0;X<OW;X++){
    if(smooth){const v=(X+0.5)/k-0.5,f=Math.floor(v);fx[X]=v-f;x0[X]=clamp(f,0,W-1);x1[X]=clamp(f+1,0,W-1);}
    else x0[X]=(X/k)|0;
  }
  for(let Y=0;Y<OH;Y++){
    let y0,y1,fy=0;
    if(smooth){const v=(Y+0.5)/k-0.5,f=Math.floor(v);fy=v-f;y0=clamp(f,0,H-1);y1=clamp(f+1,0,H-1);}
    else y0=(Y/k)|0;
    for(let X=0;X<OW;X++){
      let s=1,r=ink[0],g=ink[1],b=ink[2];
      if(smooth){
        const a=y0*W+x0[X],bb=y0*W+x1[X],c=y1*W+x0[X],e=y1*W+x1[X],u=fx[X];
        if(S){s=lerp(lerp(S[a],S[bb],u),lerp(S[c],S[e],u),fy);s=smooth01(0.5-w,0.5+w,s);}
        if(Cc){
          r=255*lerp(lerp(Cc[a*3],Cc[bb*3],u),lerp(Cc[c*3],Cc[e*3],u),fy);
          g=255*lerp(lerp(Cc[a*3+1],Cc[bb*3+1],u),lerp(Cc[c*3+1],Cc[e*3+1],u),fy);
          b=255*lerp(lerp(Cc[a*3+2],Cc[bb*3+2],u),lerp(Cc[c*3+2],Cc[e*3+2],u),fy);
        }
      }else{
        const i=y0*W+x0[X];
        if(S)s=S[i];
        if(Cc){r=Cc[i*3]*255;g=Cc[i*3+1]*255;b=Cc[i*3+2]*255;}
      }
      const o=(Y*OW+X)*4;
      d[o]=paper[0]+(r-paper[0])*s;d[o+1]=paper[1]+(g-paper[1])*s;d[o+2]=paper[2]+(b-paper[2])*s;d[o+3]=255;
    }
  }
  fctx.putImageData(img,0,0);
}

function drawView(){
  const OW=cur.W*cur.k,OH=cur.H*cur.k;
  if(view.width!==OW||view.height!==OH){view.width=OW;view.height=OH;fitView();}
  vctx.fillStyle=cur.paper;vctx.fillRect(0,0,OW,OH);
  if(full){
    const rows=Math.min(OH,Math.floor(Math.min(reveal,cur.H)*cur.k));
    if(rows>0)vctx.drawImage(full,0,0,OW,rows,0,0,OW,rows);
  }
}
function fitView(){
  const OW=cur.W*cur.k,OH=cur.H*cur.k;
  const aw=stage.clientWidth-48,ah=stage.clientHeight-48;
  let s=Math.min(aw/OW,ah/OH);
  if(s>=1)s=Math.floor(s);
  s=Math.max(0.05,s);
  view.style.width=(OW*s)+'px';view.style.height=(OH*s)+'px';
  view.style.imageRendering=s>=1?'pixelated':'auto';
}
new ResizeObserver(fitView).observe(stage);

// ---------- graph UI ----------
function applyTransform(){
  ws.style.transform='translate('+tx+'px,'+ty+'px) scale('+sc+')';
  left.style.backgroundPosition=tx+'px '+ty+'px';
  left.style.backgroundSize=(20*sc)+'px '+(20*sc)+'px';
}
function changed(){evalNeeded=true;clearTimeout(saveTimer);saveTimer=setTimeout(save,400);}

function updateDyn(n){
  const spec=TYPES[n.type];if(!spec.dyn)return;
  const lab=spec.dyn[n.params.op]||['A','B'];
  ['a','b'].forEach((id,k)=>{
    const row=n.rows[id];if(!row)return;
    row.style.display=lab[k]?'':'none';
    const sp=row.querySelector('.lb span');if(sp)sp.textContent=lab[k]||id;
  });
}

function buildNode(n){
  const spec=TYPES[n.type];
  const el=document.createElement('div');
  el.className='node'+(n.bypass?' off':'');
  el.style.left=n.x+'px';el.style.top=n.y+'px';
  const head=document.createElement('div');head.className='nh';
  head.innerHTML='<span class="t"></span><span class="byp" title="Bypass"></span><span class="x" title="Löschen">×</span>';
  head.querySelector('.t').textContent=n.label||spec.title;
  el.appendChild(head);
  n.outEl=null;
  if(!spec.noOut){
    const po=document.createElement('span');po.className='port out';po.dataset.node=n.id;el.appendChild(po);n.outEl=po;
  }
  const body=document.createElement('div');body.className='nb';el.appendChild(body);
  n.portEls={};n.rows={};n._thumbSig=null;n.thumb=null;
  spec.inputs.forEach(inp=>{
    const r=document.createElement('div');r.className='in';
    r.innerHTML='<span class="port in'+(inp.type==='c'?' c':'')+'"></span><span></span>';
    r.lastChild.textContent=inp.label;
    const p=r.firstChild;p.dataset.node=n.id;p.dataset.port=inp.id;
    n.portEls[inp.id]=p;body.appendChild(r);
  });
  if(spec.thumb!==false){const cv=document.createElement('canvas');cv.className='thumb';body.appendChild(cv);n.thumb=cv;}
  spec.params.forEach(pd=>{const r=buildParam(n,pd);n.rows[pd.id]=r;body.appendChild(r);});
  updateDyn(n);

  head.addEventListener('pointerdown',e=>{
    if(e.target.classList.contains('x')||e.target.classList.contains('byp'))return;
    select(n.id);
    const sx=e.clientX,sy=e.clientY,nx=n.x,ny=n.y;
    const mv=ev=>{n.x=nx+(ev.clientX-sx)/sc;n.y=ny+(ev.clientY-sy)/sc;el.style.left=n.x+'px';el.style.top=n.y+'px';redrawWires();};
    const up=()=>{window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);changed();};
    window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);
    e.preventDefault();
  });
  el.addEventListener('pointerdown',()=>select(n.id));
  head.querySelector('.x').addEventListener('click',()=>removeNode(n.id));
  head.querySelector('.byp').addEventListener('click',()=>{n.bypass=!n.bypass;el.classList.toggle('off',n.bypass);changed();});

  el.querySelectorAll('.port.in').forEach(p=>p.addEventListener('pointerdown',e=>{
    const src=n.inputs[p.dataset.port];if(!src)return;
    const sn=byId(src);if(!sn||!sn.outEl)return;
    n.inputs[p.dataset.port]=null;redrawWires();changed();
    startWire(sn.outEl,e);e.stopPropagation();e.preventDefault();
  }));
  if(n.outEl)n.outEl.addEventListener('pointerdown',e=>{startWire(n.outEl,e);e.stopPropagation();e.preventDefault();});

  ws.appendChild(el);n.el=el;
}

function buildParam(n,pd){
  const row=document.createElement('div');row.className='row';
  const set=v=>{n.params[pd.id]=v;changed();};
  if(pd.type==='range'){
    const dec=(String(pd.step).split('.')[1]||'').length;
    row.innerHTML='<div class="lb"><span></span><span class="val"></span></div><input type="range" min="'+pd.min+'" max="'+pd.max+'" step="'+pd.step+'">';
    row.querySelector('.lb span').textContent=pd.label;
    const inp=row.querySelector('input'),val=row.querySelector('.val');
    inp.value=n.params[pd.id];val.textContent=(+inp.value).toFixed(dec);
    inp.addEventListener('input',()=>{val.textContent=(+inp.value).toFixed(dec);set(parseFloat(inp.value));});
  }else if(pd.type==='select'){
    row.innerHTML='<div class="lb"><span></span></div><select></select>';
    row.querySelector('span').textContent=pd.label;
    const sel=row.querySelector('select');
    pd.options.forEach(([v,l])=>{const o=document.createElement('option');o.value=v;o.textContent=l;sel.appendChild(o);});
    sel.value=n.params[pd.id];
    sel.addEventListener('change',()=>{set(sel.value);updateDyn(n);});
  }else if(pd.type==='text'){
    row.innerHTML='<div class="lb"><span></span></div><input type="text" spellcheck="false">';
    row.querySelector('span').textContent=pd.label;
    const inp=row.querySelector('input');inp.value=n.params[pd.id];
    inp.addEventListener('input',()=>set(inp.value));
  }else if(pd.type==='color'){
    row.innerHTML='<div class="lb"><span></span></div><input type="color">';
    row.querySelector('span').textContent=pd.label;
    const inp=row.querySelector('input');inp.value=n.params[pd.id];
    inp.addEventListener('input',()=>set(inp.value));
  }else if(pd.type==='gradient'){
    buildGradient(n,pd,row);
  }
  return row;
}

function buildGradient(n,pd,row){
  row.innerHTML='<div class="lb"><span></span></div><div class="gbar" title="Klick = Stop hinzufügen"></div><div class="gh"></div><div class="grow"><input type="color"><button class="gdel" title="Gewählten Stop löschen">×</button></div>';
  row.querySelector('.lb span').textContent=pd.label;
  const bar=row.querySelector('.gbar'),gh=row.querySelector('.gh'),col=row.querySelector('input'),del=row.querySelector('.gdel');
  let sel=0;
  const stops=()=>n.params[pd.id];
  function refresh(){
    const st=stops(),sorted=[...st].sort((a,b)=>a.p-b.p);
    bar.style.background='linear-gradient(90deg,'+sorted.map(o=>o.c+' '+(o.p*100)+'%').join(',')+')';
    gh.innerHTML='';
    st.forEach((o,idx)=>{
      const h=document.createElement('div');
      h.className='gstop'+(idx===sel?' on':'');h.style.left=(o.p*100)+'%';h.style.background=o.c;
      h.addEventListener('pointerdown',e=>{
        e.stopPropagation();e.preventDefault();
        sel=idx;col.value=o.c;refresh();
        const mv=ev=>{const r=gh.getBoundingClientRect();o.p=clamp((ev.clientX-r.left)/r.width,0,1);refresh();changed();};
        const up=()=>{window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);};
        window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);
      });
      gh.appendChild(h);
    });
  }
  bar.addEventListener('pointerdown',e=>{
    e.stopPropagation();
    const r=bar.getBoundingClientRect(),p=clamp((e.clientX-r.left)/r.width,0,1),lut=gradLUT(stops()),k=Math.round(p*255)*3;
    const c=rgbToHex(lut[k]*255,lut[k+1]*255,lut[k+2]*255);
    stops().push({p,c});sel=stops().length-1;col.value=c;refresh();changed();
  });
  col.addEventListener('input',()=>{stops()[sel].c=col.value;refresh();changed();});
  del.addEventListener('click',()=>{if(stops().length>2){stops().splice(sel,1);sel=0;col.value=stops()[0].c;refresh();changed();}});
  col.value=stops()[0].c;refresh();
}

function select(id){
  selected=id;nodes.forEach(n=>n.el&&n.el.classList.toggle('sel',n.id===id));
}
function removeNode(id){
  const n=byId(id);if(!n)return;
  n.el.remove();nodes=nodes.filter(x=>x!==n);
  nodes.forEach(m=>{for(const k in m.inputs)if(m.inputs[k]===id)m.inputs[k]=null;});
  if(selected===id)selected=null;
  redrawWires();changed();
}
function buildAll(){
  ws.querySelectorAll('.node').forEach(e=>e.remove());
  nodes.forEach(buildNode);
  requestAnimationFrame(redrawWires);
}

// ---------- wires ----------
function portPos(el){
  const r=el.getBoundingClientRect(),w=ws.getBoundingClientRect();
  return {x:(r.left+r.width/2-w.left)/sc,y:(r.top+r.height/2-w.top)/sc};
}
function wirePath(a,b){
  const dx=Math.max(40,Math.abs(b.x-a.x)*0.5);
  return 'M'+a.x+','+a.y+' C'+(a.x+dx)+','+a.y+' '+(b.x-dx)+','+b.y+' '+b.x+','+b.y;
}
function redrawWires(){
  wires.querySelectorAll('.wire:not(.tmp)').forEach(p=>p.remove());
  nodes.forEach(n=>{if(n.outEl)n.outEl.classList.toggle('c',outType(n)==='c');});
  nodes.forEach(n=>{
    for(const pid in n.inputs){
      const s=n.inputs[pid]&&byId(n.inputs[pid]);
      if(!s||!s.outEl||!n.portEls[pid])continue;
      const p=document.createElementNS('http://www.w3.org/2000/svg','path');
      p.setAttribute('class','wire'+(outType(s)==='c'?' c':''));
      p.setAttribute('d',wirePath(portPos(s.outEl),portPos(n.portEls[pid])));
      wires.appendChild(p);
    }
  });
}
function reaches(fromId,toId,seen=new Set()){
  if(fromId===toId)return true;
  if(seen.has(fromId))return false;seen.add(fromId);
  const n=byId(fromId);if(!n)return false;
  return Object.values(n.inputs).some(id=>id&&reaches(id,toId,seen));
}
function startWire(outEl,e){
  const start=portPos(outEl),srcId=outEl.dataset.node;
  const tmp=document.createElementNS('http://www.w3.org/2000/svg','path');
  tmp.setAttribute('class','wire tmp');wires.appendChild(tmp);
  const toWs=ev=>{const w=ws.getBoundingClientRect();return {x:(ev.clientX-w.left)/sc,y:(ev.clientY-w.top)/sc};};
  tmp.setAttribute('d',wirePath(start,toWs(e)));
  const mv=ev=>tmp.setAttribute('d',wirePath(start,toWs(ev)));
  const up=ev=>{
    window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);tmp.remove();
    const t=document.elementFromPoint(ev.clientX,ev.clientY);
    if(t&&t.classList&&t.classList.contains('in')&&t.classList.contains('port')){
      const tn=byId(t.dataset.node),sn=byId(srcId);
      if(tn&&sn&&!reaches(srcId,tn.id)&&typeOk(inType(tn,t.dataset.port),outType(sn)))tn.inputs[t.dataset.port]=srcId;
    }
    redrawWires();changed();
  };
  window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);
}

// ---------- pan / zoom / menu ----------
left.addEventListener('pointerdown',e=>{
  hideMenu();
  if(e.target!==left)return;
  select(null);
  const sx=e.clientX,sy=e.clientY,ox=tx,oy=ty;
  const mv=ev=>{tx=ox+ev.clientX-sx;ty=oy+ev.clientY-sy;applyTransform();};
  const up=()=>{window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);};
  window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);
});
left.addEventListener('wheel',e=>{
  e.preventDefault();
  if(e.ctrlKey||e.metaKey){
    const r=left.getBoundingClientRect(),px=e.clientX-r.left,py=e.clientY-r.top;
    const ns=clamp(sc*Math.exp(-e.deltaY*0.01),0.3,1.6);
    tx=px-(px-tx)*(ns/sc);ty=py-(py-ty)*(ns/sc);sc=ns;
  }else{tx-=e.deltaX;ty-=e.deltaY;}
  applyTransform();
},{passive:false});

function fit(){
  if(!nodes.length)return;
  let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
  nodes.forEach(n=>{x0=Math.min(x0,n.x);y0=Math.min(y0,n.y);x1=Math.max(x1,n.x+190);y1=Math.max(y1,n.y+(n.el?n.el.offsetHeight:300));});
  const vw=left.clientWidth,vh=left.clientHeight,bw=x1-x0+100,bh=y1-y0+110;
  sc=clamp(Math.min(vw/bw,vh/bh),0.3,1);
  tx=(vw-(x1-x0)*sc)/2-x0*sc;ty=(vh-(y1-y0)*sc)/2-y0*sc+14;
  applyTransform();redrawWires();
}

function buildMenu(){
  menu.innerHTML='';
  MENU.forEach(([g,items])=>{
    const h=document.createElement('div');h.className='g';h.textContent=g;menu.appendChild(h);
    items.forEach(it=>{
      const d=document.createElement('div');d.className='it';d.textContent=it.label;
      d.addEventListener('click',()=>{
        const pos=menu._pos||{x:40,y:40};
        const n=makeNode(it.type,pos.x,pos.y,it.params,it.params?it.label:null);
        nodes.push(n);buildNode(n);select(n.id);hideMenu();redrawWires();changed();
      });
      menu.appendChild(d);
    });
  });
}
// Klicks im Menü dürfen das Menü nicht über den Hintergrund-Handler schließen
menu.addEventListener('pointerdown',e=>e.stopPropagation());
menu.addEventListener('dblclick',e=>e.stopPropagation());
menu.addEventListener('wheel',e=>e.stopPropagation(),{passive:true});
$('tb').addEventListener('pointerdown',e=>e.stopPropagation());

function showMenu(px,py,spawn){
  const r=left.getBoundingClientRect();
  menu._pos=spawn;
  menu.style.display='block';
  const top=clamp(py,4,Math.max(4,r.height-120));
  menu.style.maxHeight=Math.max(120,r.height-top-12)+'px';
  menu.style.left=clamp(px,4,Math.max(4,r.width-200))+'px';
  menu.style.top=top+'px';
}
function hideMenu(){menu.style.display='none';}
$('bAdd').addEventListener('click',e=>{
  e.stopPropagation();
  if(menu.style.display==='block'){hideMenu();return;}
  const off=(nodes.length%6)*18;
  showMenu(12,52,{x:(left.clientWidth/2-tx)/sc-95+off,y:(left.clientHeight/2-ty)/sc-120+off});
});
left.addEventListener('dblclick',e=>{
  if(e.target!==left)return;
  const r=left.getBoundingClientRect(),cx=e.clientX-r.left,cy=e.clientY-r.top;
  showMenu(cx,cy,{x:(cx-tx)/sc-95,y:(cy-ty)/sc-15});
});
window.addEventListener('keydown',e=>{
  if(e.key==='Escape')hideMenu();
  if((e.key==='Delete'||e.key==='Backspace')&&selected&&!/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)){removeNode(selected);}
});

// ---------- splitter ----------
$('split').addEventListener('pointerdown',e=>{
  const mv=ev=>{left.style.flex='0 0 '+clamp(ev.clientX,260,window.innerWidth-280)+'px';};
  const up=()=>{window.removeEventListener('pointermove',mv);window.removeEventListener('pointerup',up);redrawWires();};
  window.addEventListener('pointermove',mv);window.addEventListener('pointerup',up);
  e.preventDefault();
});

// ---------- persistence ----------
function serialize(){
  return {version:2,gseed,nodes:nodes.map(n=>({id:n.id,type:n.type,label:n.label,x:n.x,y:n.y,params:n.params,bypass:n.bypass,inputs:n.inputs}))};
}
function load(data){
  if(!data||!Array.isArray(data.nodes))return false;
  const list=[];
  for(const d of data.nodes){
    const spec=TYPES[d.type];if(!spec)continue;           // unbekannte (z. B. alte PCB-)Nodes überspringen
    const params=defaultsOf(spec);
    for(const pd of spec.params){
      if(!d.params||d.params[pd.id]===undefined)continue;
      if(pd.type==='gradient'){if(Array.isArray(d.params[pd.id])&&d.params[pd.id].length>=2)params[pd.id]=d.params[pd.id];}
      else params[pd.id]=d.params[pd.id];
    }
    list.push({id:String(d.id),type:d.type,label:d.label||null,x:+d.x||0,y:+d.y||0,params,bypass:!!d.bypass,inputs:d.inputs||{}});
  }
  if(!list.length)return false;
  const ids=new Set(list.map(n=>n.id));
  list.forEach(n=>{
    const spec=TYPES[n.type],clean={};
    spec.inputs.forEach(inp=>{if(n.inputs[inp.id]&&ids.has(n.inputs[inp.id]))clean[inp.id]=n.inputs[inp.id];});
    n.inputs=clean;
  });
  nodes=list;gseed=data.gseed|0;
  nextId=1+Math.max(0,...nodes.map(n=>parseInt(String(n.id).replace(/\D/g,''),10)||0));
  return true;
}
function save(){try{localStorage.setItem(STORE_KEY,JSON.stringify(serialize()));}catch(e){}}
$('bExp').addEventListener('click',()=>{
  const a=document.createElement('a');
  a.href=URL.createObjectURL(new Blob([JSON.stringify(serialize(),null,1)],{type:'application/json'}));
  a.download='ca-nodes.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);
});
$('bImp').addEventListener('click',()=>$('fImp').click());
$('fImp').addEventListener('change',e=>{
  const f=e.target.files[0];if(!f)return;
  const r=new FileReader();
  r.onload=()=>{try{if(load(JSON.parse(r.result))){buildAll();requestAnimationFrame(fit);changed();}}catch(err){}};
  r.readAsText(f);e.target.value='';
});
$('bReset').addEventListener('click',()=>{defaultGraph();gseed=0;buildAll();requestAnimationFrame(fit);changed();});
$('bFit').addEventListener('click',fit);

// ---------- right-hand controls ----------
$('bRun').addEventListener('click',()=>{
  if(playing){playing=false;reveal=1e9;$('bRun').textContent='▶ Run';}
  else{reveal=0;playing=true;$('bRun').textContent='■ Stop';}
  dirty=true;
});
$('bReseed').addEventListener('click',()=>{gseed=Math.floor(Math.random()*100000);changed();});
$('bPng').addEventListener('click',()=>{
  if(!full)return;
  const a=document.createElement('a');a.href=full.toDataURL('image/png');a.download='ca-nodes.png';a.click();
});

// ---------- main loop ----------
function loop(){
  if(evalNeeded){evalNeeded=false;evaluate();dirty=true;}
  if(playing){
    reveal+=parseInt($('sSpeed').value,10)*Math.max(1,cur.H/320);
    if(reveal>=cur.H){reveal=1e9;playing=false;$('bRun').textContent='▶ Run';}
    dirty=true;
  }
  if(dirty){drawView();dirty=false;}
  requestAnimationFrame(loop);
}

// ---------- init ----------
(function init(){
  let ok=false;
  try{const s=localStorage.getItem(STORE_KEY);if(s)ok=load(JSON.parse(s));}catch(e){ok=false;}
  if(!ok)defaultGraph();
  buildMenu();buildAll();applyTransform();
  requestAnimationFrame(()=>{fit();});
  loop();
})();
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}