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>