commit d2218f76363e2f7d257e6d018d46f7b8927bc6b8 Author: Emil Date: Wed Jul 29 21:58:47 2026 +0300 Build procedural fractal selection generator diff --git a/app.js b/app.js new file mode 100644 index 0000000..04f531b --- /dev/null +++ b/app.js @@ -0,0 +1,932 @@ +const canvas=document.querySelector('#field'); +const ctx=canvas.getContext('2d',{alpha:false}); +const ui={ + formula:document.querySelector('#formula-panel'), + gallery:document.querySelector('#gallery-panel'), + archive:document.querySelector('#archive'), + cross:document.querySelector('#cross'), + progress:document.querySelector('#generation-progress'), + generationStatus:document.querySelector('#generation-status'), + renderStatus:document.querySelector('#render-status'), + storageStatus:document.querySelector('#storage-status') +}; + +const LEGACY_STORE_KEY='fractalier:clean-start:v2'; +const DB_NAME='fractalier-player-data'; +const DB_STATE='state'; +const DB_HISTORY='population'; +const MAX_PATHS=3200; +const REVEAL_TIME=2200; +const PREVIEW_TIME=1800; +const HISTORY_PAGE=48; + +let width=0,height=0,background; +let view={scale:1,offsetX:0,offsetY:0}; +let database=null,storedHistoryIds=new Set(),historyVisible=HISTORY_PAGE; +let animationFrame=0,animationToken=0,previewAnimationFrame=0,previewToken=0,lastProgress=1,galleryOpen=false,toastTimer=0,isBusy=false; +let state=freshState(); + +function freshState(){return{version:6,history:[],nextId:1,current:null,selectedParents:[]}} +function baseGenome(){ + return{ + family:'arboreal',branches:2,angle:.52,scale:.68,depth:8,symmetry:1,anchors:1,anchorStart:.65, + twist:0,bend:.055,turn:0,angleDrift:0,scaleDrift:0,alternation:0,closure:0, + angleWave:0,angleFrequency:1.618,scaleWave:0,scaleFrequency:2.399963,phase:0, + curvatureDrift:0,branchBias:0,facets:14,tipSides:0,tipScale:.28, + figureSides:0,figureSpan:.5,figureScale:.22,figureEvery:2,figureSpin:0, + curveMode:0,curveAmplitude:0,curveFrequency:1, + rootLength:.235,lineWidth:1.75,hue:94,hueStep:8,saturation:72,lightness:70,growthOverlap:.42 + }; +} +function normalizeGenome(genome){return{...baseGenome(),...(genome||{})}} +function clamp(value,min,max){return Math.max(min,Math.min(max,value))} +function randomFrom(seed,...values){ + let value=seed|0; + for(const part of values)value=Math.imul(value^Math.floor(part*100003),2654435761); + value^=value>>>16; + return(value>>>0)/4294967296; +} +function randomSeed(){ + if(globalThis.crypto?.getRandomValues){const value=new Uint32Array(1);crypto.getRandomValues(value);return value[0]} + return Math.floor(Math.random()*4294967295); +} +function pick(seed,salt,items){return items[Math.floor(randomFrom(seed,salt)*items.length)]} +function range(seed,salt,min,max){return min+randomFrom(seed,salt)*(max-min)} +function formulaId(id){return`F${String(id).padStart(3,'0')}`} +function familyName(family){ + return{ + arboreal:'Recursive bloom', + radial:'Radial constellation', + crystal:'Geometric crystal', + spiral:'Spiral recursion', + lattice:'Connected lattice' + }[family]||'Recursive structure'; +} +function curveName(mode){return['linear','sine','cosine','tangent','harmonic'][Math.round(mode)]||'linear'} +function classifyFamily(genome){ + if(genome.family)return genome.family; + if(genome.closure>.35)return'lattice'; + if(Math.abs(genome.turn)>.35||Math.abs(genome.twist)>.3)return'spiral'; + if(genome.symmetry>=6&&Math.abs(genome.bend)<.08)return'crystal'; + if(genome.symmetry>1)return'radial'; + return'arboreal'; +} +function normalizeRecord(item){ + const genome=normalizeGenome(item?.genome); + genome.family=classifyFamily(genome); + return{ + id:Number(item?.id)||1, + seed:Number(item?.seed)||Number(item?.id)||1, + genome, + thumbnail:item?.thumbnail||'', + thumbnailVersion:item?.thumbnailVersion||0, + createdAt:item?.createdAt||Date.now(), + source:item?.source||'legacy', + parents:item?.parents||[], + inheritance:item?.inheritance||null, + mutations:item?.mutations||[] + }; +} + +function openDatabase(){ + return new Promise((resolve,reject)=>{ + const request=indexedDB.open(DB_NAME,2); + request.onupgradeneeded=()=>{ + if(!request.result.objectStoreNames.contains(DB_STATE))request.result.createObjectStore(DB_STATE); + if(!request.result.objectStoreNames.contains(DB_HISTORY))request.result.createObjectStore(DB_HISTORY,{keyPath:'id'}); + }; + request.onsuccess=()=>resolve(request.result); + request.onerror=()=>reject(request.error); + }); +} +function readStore(store,key){ + return new Promise((resolve,reject)=>{ + const objectStore=database.transaction(store,'readonly').objectStore(store); + const request=key===undefined?objectStore.getAll():objectStore.get(key); + request.onsuccess=()=>resolve(request.result); + request.onerror=()=>reject(request.error); + }); +} +async function hydrateState(){ + let legacy=null; + try{legacy=JSON.parse(localStorage.getItem(LEGACY_STORE_KEY))}catch{} + try{ + database=await openDatabase(); + const[saved,records]=await Promise.all([readStore(DB_STATE,'main'),readStore(DB_HISTORY)]); + const source=saved||legacy; + const rawHistory=records.length?records:(source?.history||source?.population||[]); + const history=rawHistory.map(normalizeRecord).sort((a,b)=>a.id-b.id); + const oldCurrent=source?.current; + const current=oldCurrent?.genome?{ + id:Number(oldCurrent.id)||history.at(-1)?.id||1, + seed:Number(oldCurrent.seed)||randomSeed(), + genome:normalizeGenome(oldCurrent.genome), + source:'restored',parents:oldCurrent.parents||[],inheritance:oldCurrent.inheritance||null,mutations:oldCurrent.mutations||[] + }:history.length?{...history.at(-1),source:'restored'}:null; + const nextId=Math.max(Number(source?.nextId||source?.nextOrganismId)||1,...history.map(item=>item.id+1),current?current.id+1:1); + const selectedParents=(source?.selectedParents||[]).filter(id=>history.some(item=>item.id===id)).slice(0,2); + state={version:6,history,nextId,current,selectedParents}; + storedHistoryIds=new Set(records.map(item=>item.id)); + if(saved?.version!==6||(!records.length&&history.length))saveState(); + }catch(error){ + console.warn('IndexedDB is unavailable; using a temporary session',error); + const history=(legacy?.population||[]).map(normalizeRecord); + state={version:6,history,nextId:Math.max(1,...history.map(item=>item.id+1)),current:history.at(-1)||null,selectedParents:[]}; + } +} +function persistableCurrent(){ + if(!state.current)return null; + const{id,seed,genome,source,parents,inheritance,mutations}=state.current; + return{id,seed,genome,source,parents,inheritance,mutations}; +} +function saveState(){ + if(!database){ui.storageStatus.textContent='storage unavailable';return} + try{ + const transaction=database.transaction([DB_STATE,DB_HISTORY],'readwrite'); + transaction.objectStore(DB_STATE).put({version:6,nextId:state.nextId,current:persistableCurrent(),selectedParents:state.selectedParents,history:[]},'main'); + const historyStore=transaction.objectStore(DB_HISTORY); + const additions=state.history.filter(item=>!storedHistoryIds.has(item.id)); + for(const item of additions)historyStore.put(item); + transaction.oncomplete=()=>{ + for(const item of additions)storedHistoryIds.add(item.id); + if(ui.storageStatus.textContent!=='persistent local storage')ui.storageStatus.textContent='saved in this browser'; + }; + transaction.onerror=()=>{ui.storageStatus.textContent='local save failed'}; + }catch(error){ui.storageStatus.textContent='local save failed';console.warn('Could not save the formula collection',error)} +} +async function refreshStorageStatus(){ + if(!database){ui.storageStatus.textContent='storage unavailable';return} + try{ + const persistent=await navigator.storage?.persisted?.(); + ui.storageStatus.textContent=persistent?'persistent local storage':'saved in this browser'; + }catch{ui.storageStatus.textContent='saved in this browser'} +} +async function requestPersistentStorage(){ + if(!database||!navigator.storage?.persist)return; + try{ + const persistent=await navigator.storage.persist(); + ui.storageStatus.textContent=persistent?'persistent local storage':'saved in this browser'; + }catch{ui.storageStatus.textContent='saved in this browser'} +} + +function createGenome(seed){ + const family=pick(seed,1,['arboreal','radial','radial','crystal','spiral','lattice']); + const g={...baseGenome(),family}; + g.hue=range(seed,2,28,330); + g.hueStep=range(seed,3,-18,24); + g.saturation=range(seed,4,62,84); + g.lightness=range(seed,5,62,78); + g.lineWidth=range(seed,6,1.25,2.25); + g.rootLength=range(seed,7,.19,.29); + g.growthOverlap=range(seed,8,.35,.72); + g.phase=range(seed,9,0,Math.PI*2); + + if(family==='arboreal'){ + g.branches=pick(seed,10,[2,2,2,3]);g.symmetry=1;g.depth=g.branches===3?pick(seed,11,[5,6]):pick(seed,11,[7,8,9]); + g.angle=range(seed,12,.34,.78);g.scale=range(seed,13,.61,.74);g.bend=range(seed,14,-.16,.16); + g.twist=range(seed,15,-.055,.055);g.angleDrift=range(seed,16,-.035,.035);g.scaleDrift=range(seed,17,-.045,.045); + g.angleWave=range(seed,18,.015,.105);g.scaleWave=range(seed,19,.008,.052); + g.angleFrequency=range(seed,60,1.15,2.65);g.scaleFrequency=range(seed,61,1.4,3.1); + g.curvatureDrift=range(seed,62,-.08,.08);g.branchBias=range(seed,63,-.055,.055); + g.facets=pick(seed,64,[7,10,14,14]); + g.figureSides=randomFrom(seed,94)<.2?pick(seed,95,[3,4,5]):0; + g.figureSpan=range(seed,96,.22,.5);g.figureScale=range(seed,97,.12,.24);g.figureEvery=pick(seed,98,[2,3]); + g.figureSpin=range(seed,99,-.22,.22); + g.curveMode=pick(seed,120,[0,0,0,1,2]);g.curveAmplitude=g.curveMode?range(seed,121,.04,.16):0; + g.curveFrequency=range(seed,122,.65,1.5); + }else if(family==='radial'){ + g.branches=2;g.symmetry=pick(seed,20,[3,4,5,6,8]);g.depth=pick(seed,21,[5,6,7]); + g.angle=range(seed,22,.24,.68);g.scale=range(seed,23,.56,.69);g.bend=range(seed,24,-.11,.11); + g.twist=range(seed,25,-.12,.12);g.angleDrift=range(seed,26,-.025,.045); + g.angleWave=range(seed,27,.035,.17);g.scaleWave=range(seed,28,.015,.09); + g.angleFrequency=range(seed,65,.8,2.8);g.scaleFrequency=range(seed,66,1.1,3.4); + g.curvatureDrift=range(seed,67,-.14,.14);g.branchBias=range(seed,68,-.09,.09); + g.facets=pick(seed,69,[8,10,14,14]); + g.figureSides=pick(seed,100,[0,0,3,4,5,6,8]);g.figureSpan=range(seed,101,.2,.62); + g.figureScale=range(seed,102,.1,.24);g.figureEvery=pick(seed,103,[2,2,3]);g.figureSpin=range(seed,104,-.35,.35); + g.curveMode=pick(seed,123,[0,0,1,2]);g.curveAmplitude=g.curveMode?range(seed,124,.04,.18):0; + g.curveFrequency=range(seed,125,.75,1.8); + }else if(family==='crystal'){ + g.branches=2;g.symmetry=pick(seed,30,[4,6,6,8]);g.depth=pick(seed,31,[5,6]); + g.angle=pick(seed,32,[Math.PI/6,Math.PI/4,Math.PI/3]);g.scale=range(seed,33,.55,.66); + g.bend=0;g.twist=pick(seed,34,[0,0,Math.PI/12,-Math.PI/12]);g.closure=range(seed,35,.08,.38); + g.hueStep=range(seed,36,-8,12); + g.angleWave=range(seed,37,0,.065);g.scaleWave=range(seed,38,0,.045); + g.angleFrequency=pick(seed,70,[Math.PI/2,Math.PI*2/3,Math.PI]);g.scaleFrequency=pick(seed,71,[Math.PI/2,Math.PI]); + g.facets=pick(seed,72,[3,4,6]);g.tipSides=randomFrom(seed,73)<.4?pick(seed,74,[3,4,6]):0; + g.tipScale=range(seed,75,.22,.48); + g.figureSides=pick(seed,105,[3,4,6,6,8]);g.figureSpan=pick(seed,106,[.25,.5,.5,.75,1]); + g.figureScale=range(seed,107,.12,.28);g.figureEvery=pick(seed,108,[1,2]);g.figureSpin=pick(seed,109,[0,Math.PI/6,-Math.PI/6]); + g.curveMode=pick(seed,126,[0,0,0,3]);g.curveAmplitude=g.curveMode?range(seed,127,.025,.1):0; + g.curveFrequency=range(seed,128,.75,1.35); + }else if(family==='spiral'){ + g.branches=pick(seed,40,[1,2,2]);g.symmetry=pick(seed,41,[3,4,5,6]);g.depth=g.branches===1?9:pick(seed,42,[5,6,7]); + g.angle=range(seed,43,.25,.6);g.scale=range(seed,44,.61,.76);g.twist=range(seed,45,-.42,.42); + g.turn=range(seed,46,.28,1.15)*(randomFrom(seed,47)<.5?-1:1);g.bend=range(seed,48,-.18,.18); + g.alternation=range(seed,49,-.18,.18); + g.angleWave=range(seed,76,.07,.25);g.scaleWave=range(seed,77,.025,.105); + g.angleFrequency=range(seed,78,.7,2.35);g.scaleFrequency=range(seed,79,1.2,3.6); + g.curvatureDrift=range(seed,80,.08,.3)*(randomFrom(seed,81)<.5?-1:1); + g.branchBias=range(seed,82,-.16,.16);g.facets=pick(seed,83,[8,10,14]); + g.figureSides=pick(seed,110,[0,0,3,5,6]);g.figureSpan=range(seed,111,.18,.48); + g.figureScale=range(seed,112,.11,.25);g.figureEvery=pick(seed,113,[2,3]);g.figureSpin=range(seed,114,-.55,.55); + g.curveMode=pick(seed,129,[1,1,2,4]);g.curveAmplitude=range(seed,130,.1,.32); + g.curveFrequency=range(seed,131,.65,1.8); + }else{ + g.branches=pick(seed,50,[2,2,3]);g.symmetry=pick(seed,51,[3,4,5,6]);g.depth=g.branches===3?4:pick(seed,52,[5,6]); + g.angle=range(seed,53,.36,.9);g.scale=range(seed,54,.52,.64);g.closure=range(seed,55,.48,.92); + g.bend=range(seed,56,-.08,.08);g.twist=range(seed,57,-.1,.1);g.hueStep=range(seed,58,5,26); + g.angleWave=range(seed,84,.015,.11);g.scaleWave=range(seed,85,.012,.07); + g.angleFrequency=pick(seed,86,[Math.PI/2,Math.PI*2/3,Math.PI]);g.scaleFrequency=range(seed,87,1.1,3.2); + g.curvatureDrift=range(seed,88,-.1,.1);g.branchBias=range(seed,89,-.075,.075); + g.facets=pick(seed,90,[3,4,6,10]);g.tipSides=randomFrom(seed,91)<.28?pick(seed,92,[3,4,5,6]):0; + g.tipScale=range(seed,93,.18,.4); + g.figureSides=pick(seed,115,[3,4,4,6,8]);g.figureSpan=pick(seed,116,[.25,.5,.75,1]); + g.figureScale=range(seed,117,.12,.3);g.figureEvery=pick(seed,118,[1,2,2]);g.figureSpin=range(seed,119,-.28,.28); + g.curveMode=pick(seed,132,[0,0,1,3]);g.curveAmplitude=g.curveMode?range(seed,133,.03,.13):0; + g.curveFrequency=range(seed,134,.75,1.5); + } + return g; +} +const mutationRules={ + branches:[1,1,4,true],angle:[.18,.12,1.45],scale:[.09,.45,.82],depth:[1,4,9,true], + symmetry:[1,1,8,true],anchors:[1,1,2,true],anchorStart:[.16,.35,.92],twist:[.15,-.65,.65], + bend:[.15,-.5,.5],turn:[.38,-1.5,1.5],angleDrift:[.06,-.16,.16],scaleDrift:[.06,-.14,.14], + alternation:[.14,-.55,.55],closure:[.22,0,1],rootLength:[.045,.14,.34],lineWidth:[.35,.8,2.8], + angleWave:[.12,0,.5],angleFrequency:[.65,.35,4.5],scaleWave:[.08,0,.24],scaleFrequency:[.65,.35,4.5], + phase:[Math.PI/2,0,Math.PI*2],curvatureDrift:[.12,-.4,.4],branchBias:[.14,-.45,.45],facets:[2,3,18,true], + tipSides:[1,0,8,true],tipScale:[.12,.08,.6],hue:[48,0,360],hueStep:[16,-30,30], + figureSides:[1,0,10,true],figureSpan:[.18,.12,1],figureScale:[.12,.08,.55], + figureEvery:[1,1,4,true],figureSpin:[.3,-1.2,1.2], + curveMode:[1,0,4,true],curveAmplitude:[.14,0,.6],curveFrequency:[.5,.35,3.5], + saturation:[12,48,92],lightness:[10,48,86],growthOverlap:[.16,.2,.85] +}; +function crossoverGenomes(parentA,parentB,seed){ + const a=normalizeGenome(parentA.genome),b=normalizeGenome(parentB.genome),child={},inherited={a:0,b:0}; + const keys=Object.keys(baseGenome()).filter(key=>key!=='family'); + for(const[index,key]of keys.entries()){ + const fromA=index===0||index>0&&randomFrom(seed,200+index)<.5; + child[key]=fromA?a[key]:b[key];inherited[fromA?'a':'b']++; + } + if(inherited.b===0){const key=keys.at(-1);child[key]=b[key];inherited.a--;inherited.b++} + const mutations=[],roll=randomFrom(seed,280),mutationCount=roll<.8?0:roll<.98?1:2,used=new Set(); + for(let index=0;index0)value=clamp(Math.round(value),3,upper); + }else if(key==='curveMode'){ + value=child[key]===0?1+Math.floor(randomFrom(seed,310+index)*4):randomFrom(seed,320+index)<.16?0:clamp(child[key]+direction,1,4); + }else value=integer?child[key]+direction*amount:child[key]+direction*amount*(.45+randomFrom(seed,310+index)*.55); + if(integer)value=Math.round(value); + child[key]=key==='phase'||key==='hue'?((value%max)+max)%max:clamp(value,min,max); + mutations.push(key); + } + if(a.figureSides===0&&b.figureSides===0&&child.figureSides===0&&randomFrom(seed,330)<.09){ + child.figureSides=3+Math.floor(randomFrom(seed,331)*6); + child.figureSpan=pick(seed,332,[.25,.5,.75,1]); + child.figureScale=range(seed,333,.12,.3); + child.figureEvery=pick(seed,334,[1,2,2,3]); + child.figureSpin=range(seed,335,-.45,.45); + mutations.push('figureSides'); + } + if(a.curveMode===0&&b.curveMode===0&&child.curveMode===0&&randomFrom(seed,340)<.07){ + child.curveMode=1+Math.floor(randomFrom(seed,341)*4); + child.curveAmplitude=range(seed,342,.06,.24); + child.curveFrequency=range(seed,343,.6,2.2); + mutations.push('curveMode'); + } + child.family=classifyFamily({...child,family:null}); + return{genome:child,inherited,mutations}; +} + +function trigonometricWave(mode,phase){ + if(mode===1)return Math.cos(phase); + if(mode===2)return Math.sin(phase); + if(mode===3)return 2/Math.PI*Math.atan(Math.tan(phase)); + if(mode===4)return .68*Math.cos(phase)+.32*Math.cos(phase*2+Math.PI/3); + return 0; +} +function traceCurve(x,y,direction,length,turn,bend,samples=14,curveMode=0,curveAmplitude=0,curveFrequency=1,curvePhase=0){ + const points=[[x,y]],step=length/samples; + let px=x,py=y,lastDirection=direction; + for(let index=1;index<=samples;index++){ + const t=(index-.5)/samples; + const wavePhase=t*Math.PI*2*curveFrequency+curvePhase; + lastDirection=direction+turn*t+bend*Math.sin(Math.PI*t)+curveAmplitude*trigonometricWave(curveMode,wavePhase); + px+=Math.cos(lastDirection)*step;py+=Math.sin(lastDirection)*step; + points.push([px,py]); + } + return{points,endX:px,endY:py,endDirection:lastDirection}; +} +function traceGenomeCurve(node,g,facets){ + const samples=g.curveMode===0||g.curveAmplitude===0 + ?facets + :clamp(Math.max(facets,Math.ceil(g.curveFrequency*8)),facets,24); + const curvePhase=g.phase+node.depth*g.angleFrequency+node.root*Math.PI*2/Math.max(1,g.symmetry); + return traceCurve( + node.x,node.y,node.direction,node.length,node.turn,node.bend,samples, + g.curveMode,g.curveAmplitude,g.curveFrequency,curvePhase + ); +} +function pointOnCurve(curve,position){ + const target=clamp(position,0,1)*(curve.points.length-1); + const index=Math.min(curve.points.length-2,Math.floor(target)),part=target-index; + const a=curve.points[index],b=curve.points[index+1]; + return{x:a[0]+(b[0]-a[0])*part,y:a[1]+(b[1]-a[1])*part,direction:Math.atan2(b[1]-a[1],b[0]-a[0])}; +} +function branchOffsets(count,angle){ + if(count===1)return[0]; + return Array.from({length:count},(_,index)=>(index-(count-1)/2)*angle*(count===2?2:1)); +} +function traceFigureSegment(curve,node,g){ + const sides=clamp(Math.round(g.figureSides),3,10); + const edges=clamp(Math.round(sides*g.figureSpan),1,sides); + const radius=Math.max(.003,node.length*g.figureScale); + const start=curve.endDirection+Math.PI/2+g.phase*.2+node.depth*g.figureSpin; + const centerX=curve.endX-Math.cos(start)*radius,centerY=curve.endY-Math.sin(start)*radius; + return Array.from({length:edges+1},(_,index)=>{ + const theta=start+index*Math.PI*2/sides; + return[centerX+Math.cos(theta)*radius,centerY+Math.sin(theta)*radius]; + }); +} +function compileGeometry(genome){ + const g=normalizeGenome(genome),paths=[],queue=[]; + const facets=clamp(Math.round(g.facets),3,18); + const rootY=g.symmetry===1?.34:0; + for(let root=0;root=3&&node.depth>0&&node.depth%figureEvery===0&&paths.length=g.depth||node.length*effectiveScale<.0035; + if(terminal){ + const tipSides=Math.round(g.tipSides); + if(tipSides>=3&&paths.length{ + const theta=rotation+index*Math.PI*2/tipSides; + return[curve.endX+Math.cos(theta)*radius,curve.endY+Math.sin(theta)*radius]; + }); + paths.push({ + points,depth:node.depth+1,root:node.root,birth:birth+duration*.56,duration:duration*.72, + hue:(g.hue+(node.depth+1)*g.hueStep+72+3600)%360,saturation:g.saturation, + lightness:clamp(g.lightness+8,0,92),width:Math.max(.38,g.lineWidth*Math.pow(.82,node.depth+1)) + }); + } + continue; + } + const anchorPositions=g.anchors===1?[1]:Array.from({length:g.anchors},(_,index)=>g.anchorStart+(1-g.anchorStart)*index/(g.anchors-1)); + const angularPhase=node.depth*g.angleFrequency+g.phase+node.root*Math.PI*2/Math.max(1,g.symmetry); + const effectiveAngle=clamp(g.angle+g.angleDrift*node.depth+g.angleWave*Math.sin(angularPhase),.08,1.65); + const offsets=g.branches===1?[effectiveAngle*.55*Math.sin(angularPhase)]:branchOffsets(g.branches,effectiveAngle); + for(const[anchorIndex,position]of anchorPositions.entries()){ + const anchor=pointOnCurve(curve,position),anchorScale=Math.pow(.86,g.anchors-1-anchorIndex); + const children=[],alternating=g.alternation*((node.depth+anchorIndex)%2?-1:1); + const bias=g.branchBias*Math.sin(angularPhase+anchorIndex*Math.PI/2); + for(const offset of offsets){ + if(queue.length>=MAX_PATHS*2)break; + const side=offset===0?1:Math.sign(offset); + const child={ + x:anchor.x,y:anchor.y,direction:anchor.direction+offset+g.twist*(node.depth+1)+alternating+bias, + length:node.length*effectiveScale*anchorScale,depth:node.depth+1,root:node.root, + turn:(g.turn+g.curvatureDrift*(node.depth+1)/Math.max(1,g.depth))*side,bend:g.bend*side + }; + children.push(child);queue.push(child); + } + if(g.closure>.05&&children.length>1&&paths.length{ + const predicted=traceGenomeCurve(child,g,facets); + return[predicted.endX,predicted.endY]; + }); + if(g.closure>.62&&endpoints.length>2)endpoints.push(endpoints[0]); + paths.push({ + points:endpoints,depth:node.depth+1,root:node.root,birth:(node.depth+1)*generationGap+generationGap*.28, + duration:generationGap*(1.3+g.growthOverlap),hue:(g.hue+(node.depth+1)*g.hueStep+42+3600)%360, + saturation:g.saturation,lightness:clamp(g.lightness+6,0,92), + width:Math.max(.4,g.lineWidth*Math.pow(.82,node.depth+1)*g.closure) + }); + } + } + } + return paths; +} +function geometryBounds(paths){ + let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity; + for(const path of paths||[])for(const point of path.points){ + minX=Math.min(minX,point[0]);maxX=Math.max(maxX,point[0]); + minY=Math.min(minY,point[1]);maxY=Math.max(maxY,point[1]); + } + return Number.isFinite(minX)?{minX,maxX,minY,maxY}:{minX:-.25,maxX:.25,minY:-.25,maxY:.25}; +} +function pathProgress(path,progress){ + const raw=clamp((progress-path.birth)/path.duration,0,1); + return raw<.5?2*raw*raw:1-Math.pow(-2*raw+2,2)/2; +} + +function updateView(){ + const bounds=state.current?.bounds||geometryBounds(state.current?.paths); + let left,right,top,bottom; + if(width>=1180){ + left=386;right=galleryOpen?width-326:width-34;top=82;bottom=height-116; + }else if(width>760){ + left=356;right=width-28;top=82;bottom=height-112; + }else{ + left=22;right=width-22;top=Math.max(270,ui.formula.getBoundingClientRect().bottom+14);bottom=height-112; + } + if(right-left<160){left=22;right=width-22} + if(bottom-top<150&&width>760){top=76;bottom=height-98} + const worldWidth=Math.max(.08,bounds.maxX-bounds.minX),worldHeight=Math.max(.08,bounds.maxY-bounds.minY); + const scale=Math.min((right-left)/worldWidth,(bottom-top)/worldHeight)*.88; + view.scale=Math.min(scale,Math.min(width,height)*2.1); + view.offsetX=(left+right)/2-(bounds.minX+bounds.maxX)/2*view.scale; + view.offsetY=(top+bottom)/2-(bounds.minY+bounds.maxY)/2*view.scale; +} +function resize(){ + const ratio=Math.min(devicePixelRatio||1,1.25); + width=innerWidth;height=innerHeight; + canvas.width=Math.round(width*ratio);canvas.height=Math.round(height*ratio); + ctx.setTransform(canvas.width/width,0,0,canvas.height/height,0,0); + background=ctx.createRadialGradient(width*.5,height*.5,0,width*.5,height*.5,Math.max(width,height)*.72); + background.addColorStop(0,'#17382f');background.addColorStop(.52,'#0a1d18');background.addColorStop(1,'#050d0c'); + updateView();render(lastProgress); +} +function worldToScreen(x,y){return[x*view.scale+view.offsetX,y*view.scale+view.offsetY]} +function drawPartialPath(targetContext,path,local){ + const target=(path.points.length-1)*local,full=Math.floor(target),fraction=target-full; + if(target<=0)return; + targetContext.beginPath(); + const first=worldToScreen(path.points[0][0],path.points[0][1]); + targetContext.moveTo(first[0],first[1]); + for(let index=1;index<=full;index++){ + const point=worldToScreen(path.points[index][0],path.points[index][1]); + targetContext.lineTo(point[0],point[1]); + } + if(full0){ + const a=path.points[full],b=path.points[full+1]; + const point=worldToScreen(a[0]+(b[0]-a[0])*fraction,a[1]+(b[1]-a[1])*fraction); + targetContext.lineTo(point[0],point[1]); + } + targetContext.stroke(); +} +function drawFractal(targetContext,progress){ + if(!state.current)return; + targetContext.lineCap='round';targetContext.lineJoin='round'; + for(const path of state.current.paths){ + const local=pathProgress(path,progress); + if(local<=0)continue; + targetContext.globalAlpha=(.5+Math.min(.34,path.depth*.026))*(.82+local*.18); + targetContext.lineWidth=path.width; + targetContext.strokeStyle=`hsl(${path.hue} ${path.saturation}% ${path.lightness}%)`; + drawPartialPath(targetContext,path,local); + } + targetContext.globalAlpha=1; +} +function render(progress=1){ + lastProgress=progress; + ctx.fillStyle=background;ctx.fillRect(0,0,width,height); + const seed=state.current?.seed||7193; + for(let index=0;index<30;index++){ + ctx.fillStyle=`rgba(215,255,225,${.035+randomFrom(seed,index,800)*.1})`; + ctx.fillRect(randomFrom(seed,index,801)*width,randomFrom(seed,index,802)*height,1,1); + } + drawFractal(ctx,progress); +} +function createExportCanvas(includeBackground){ + if(includeBackground)return canvas; + const transparent=document.createElement('canvas'); + transparent.width=canvas.width;transparent.height=canvas.height; + const target=transparent.getContext('2d'); + target.setTransform(canvas.width/width,0,0,canvas.height/height,0,0); + drawFractal(target,1); + return transparent; +} +function downloadImage(includeBackground){ + if(!state.current)return; + const link=document.createElement('a'),variant=includeBackground?'background':'transparent'; + link.download=`fractalier-${formulaId(state.current.id)}-${variant}.png`; + link.href=createExportCanvas(includeBackground).toDataURL('image/png'); + link.click();closeExportMenu(); + showToast(`${variant==='background'?'Background':'Transparent'} PNG downloaded`); +} +function downloadBlob(blob,filename){ + const link=document.createElement('a'),url=URL.createObjectURL(blob); + link.download=filename;link.href=url;link.click(); + setTimeout(()=>URL.revokeObjectURL(url),1000); +} +function exportFormula(){ + if(!state.current)return; + const{id,seed,genome,parents,inheritance,mutations}=state.current; + const payload={ + format:'fractalier-formula',version:1,id:formulaId(id),seed, + equation:'F[n+1] = union(s[n] R(i alpha[n] + n tau) K(kappa[n] + gamma T(f t + phi)) A[i](F[n])) union Cq(E[n]) union Pm^lambda(E[n])', + genome,parents:parents.map(formulaId),inheritance,mutations + }; + downloadBlob(new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),`fractalier-${formulaId(id)}.json`); + closeExportMenu();showToast('Formula JSON downloaded'); +} +async function exportVideo(){ + if(!state.current||isBusy)return; + closeExportMenu(); + if(!canvas.captureStream||typeof MediaRecorder==='undefined'){ + showToast('Video export is not supported by this browser');return; + } + const mimeType=['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm'] + .find(type=>MediaRecorder.isTypeSupported(type)); + if(!mimeType){showToast('WebM export is not supported by this browser');return} + cancelAnimationFrame(animationFrame);animationToken++; + const chunks=[],stream=canvas.captureStream(60); + const recorder=new MediaRecorder(stream,{mimeType,videoBitsPerSecond:8_000_000}); + recorder.ondataavailable=event=>{if(event.data.size)chunks.push(event.data)}; + const stopped=new Promise((resolve,reject)=>{ + recorder.onstop=resolve; + recorder.onerror=event=>reject(event.error||new Error('Video recording failed')); + }); + try{ + setBusy(true);ui.generationStatus.textContent='recording';ui.renderStatus.textContent='encoding WebM animation…'; + render(0);ui.progress.style.width='0%';recorder.start(); + await new Promise(resolve=>{ + const start=performance.now(); + const frame=now=>{ + const linear=clamp((now-start)/REVEAL_TIME,0,1),progress=1-Math.pow(1-linear,2.4); + render(progress);ui.progress.style.width=`${linear*100}%`; + if(linear<1){animationFrame=requestAnimationFrame(frame);return} + render(1);setTimeout(resolve,180); + }; + animationFrame=requestAnimationFrame(frame); + }); + recorder.stop();await stopped; + downloadBlob(new Blob(chunks,{type:mimeType}),`fractalier-${formulaId(state.current.id)}-growth.webm`); + showToast('Animated WebM downloaded'); + }catch(error){ + console.warn('Could not export the animation',error);showToast('Video export failed'); + }finally{ + for(const track of stream.getTracks())track.stop(); + render(1);ui.progress.style.width='100%';setBusy(false); + } +} +function paintFormulaMiniature(target,paths,seed,progress=1){ + const canvasWidth=target.canvas.width,canvasHeight=target.canvas.height,bounds=geometryBounds(paths); + target.setTransform(1,0,0,1,0,0);target.globalAlpha=1; + const backdrop=target.createRadialGradient(canvasWidth/2,canvasHeight/2,0,canvasWidth/2,canvasHeight/2,Math.max(canvasWidth,canvasHeight)*.64); + backdrop.addColorStop(0,'#17382f');backdrop.addColorStop(.58,'#0a1d18');backdrop.addColorStop(1,'#050d0c'); + target.fillStyle=backdrop;target.fillRect(0,0,canvasWidth,canvasHeight); + for(let index=0;index<18;index++){ + target.fillStyle=`rgba(215,255,225,${.035+randomFrom(seed,index,880)*.1})`; + target.fillRect(randomFrom(seed,index,881)*canvasWidth,randomFrom(seed,index,882)*canvasHeight,1,1); + } + const worldWidth=Math.max(.04,bounds.maxX-bounds.minX),worldHeight=Math.max(.04,bounds.maxY-bounds.minY); + const scale=Math.min((canvasWidth-28)/worldWidth,(canvasHeight-28)/worldHeight); + const offsetX=canvasWidth/2-(bounds.minX+bounds.maxX)/2*scale; + const offsetY=canvasHeight/2-(bounds.minY+bounds.maxY)/2*scale; + const point=([x,y])=>[x*scale+offsetX,y*scale+offsetY]; + target.lineCap='round';target.lineJoin='round'; + for(const path of paths){ + const local=pathProgress(path,progress); + if(local<=0||path.points.length<2)continue; + const targetPoint=(path.points.length-1)*local,full=Math.floor(targetPoint),fraction=targetPoint-full; + const first=point(path.points[0]);target.beginPath();target.moveTo(first[0],first[1]); + for(let index=1;index<=full;index++){const next=point(path.points[index]);target.lineTo(next[0],next[1])} + if(full0){ + const a=path.points[full],b=path.points[full+1]; + const next=point([a[0]+(b[0]-a[0])*fraction,a[1]+(b[1]-a[1])*fraction]); + target.lineTo(next[0],next[1]); + } + target.globalAlpha=(.58+Math.min(.28,path.depth*.025))*(.85+local*.15); + target.lineWidth=Math.max(.38,path.width*.62); + target.strokeStyle=`hsl(${path.hue} ${path.saturation}% ${path.lightness}%)`;target.stroke(); + } + target.globalAlpha=1; +} +function renderFormulaThumbnail(genome,seed){ + const thumb=document.createElement('canvas');thumb.width=300;thumb.height=200; + paintFormulaMiniature(thumb.getContext('2d'),compileGeometry(genome),seed,1); + return thumb.toDataURL('image/webp',.76); +} +function storeHistoryRecord(record){ + if(!database)return; + try{database.transaction(DB_HISTORY,'readwrite').objectStore(DB_HISTORY).put(record)} + catch(error){console.warn('Could not update a collection thumbnail',error)} +} +function ensureThumbnail(record){ + if(record.thumbnail&&record.thumbnailVersion===4)return; + record.thumbnail=renderFormulaThumbnail(record.genome,record.seed); + record.thumbnailVersion=4;storeHistoryRecord(record); +} + +function setBusy(busy){ + isBusy=busy; + document.querySelector('#export-toggle').disabled=busy; + ui.generationStatus.textContent=busy?'generating':'ready'; + ui.renderStatus.textContent=busy?'drawing recursive paths…':'idle · canvas at rest'; + updateSelectionUI(); +} +function animateFormula(onComplete){ + cancelAnimationFrame(animationFrame); + const token=++animationToken,start=performance.now(); + const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches,duration=reduced?120:REVEAL_TIME; + setBusy(true);ui.progress.style.width='0%'; + const frame=now=>{ + if(token!==animationToken)return; + const linear=clamp((now-start)/duration,0,1); + const progress=1-Math.pow(1-linear,2.4); + render(progress);ui.progress.style.width=`${linear*100}%`; + if(linear<1){animationFrame=requestAnimationFrame(frame);return} + render(1);setBusy(false);onComplete?.(); + }; + animationFrame=requestAnimationFrame(frame); +} +function runtimeFormula({id,seed,genome,source,parents=[],inheritance=null,mutations=[]}){ + const normalized=normalizeGenome(genome),paths=compileGeometry(normalized); + return{id,seed,genome:normalized,source,parents,inheritance,mutations,paths,bounds:geometryBounds(paths)}; +} +function createFounderRecord(){ + const seed=randomSeed(),id=state.nextId++,genome=createGenome(seed); + return{ + id,seed,genome,source:'founder',parents:[],inheritance:null,mutations:[],createdAt:Date.now(), + thumbnail:renderFormulaThumbnail(genome,seed),thumbnailVersion:4 + }; +} +function ensureInitialPopulation(){ + let initialized=false; + if(state.current&&!state.history.some(item=>item.id===state.current.id)){ + const{id,seed,genome,source,parents=[],inheritance=null,mutations=[]}=state.current; + state.history.push({ + id,seed,genome:normalizeGenome(genome),source,parents,inheritance,mutations,createdAt:Date.now(), + thumbnail:renderFormulaThumbnail(genome,seed),thumbnailVersion:4 + }); + initialized=true; + } + while(state.history.length<2){state.history.push(createFounderRecord());initialized=true} + if(!state.current||initialized)state.current={...state.history.at(-1)}; + if(initialized)state.selectedParents=state.history.slice(-2).map(item=>item.id); + if(initialized)saveState(); +} +function addCurrentToHistory(){ + if(!state.current||state.history.some(item=>item.id===state.current.id))return; + const{id,seed,genome,source,parents,inheritance,mutations}=state.current; + state.history.push({ + id,seed,genome:{...genome},source,parents,inheritance,mutations,createdAt:Date.now(), + thumbnail:renderFormulaThumbnail(genome,seed),thumbnailVersion:4 + }); + saveState();renderArchive(); +} +function showFormula(entry,{selectAfter=true}={}){ + state.selectedParents=[]; + state.current=runtimeFormula(entry); + updateView();updateFormulaUI();renderArchive();saveState(); + animateFormula(()=>{ + addCurrentToHistory(); + if(selectAfter)state.selectedParents=[state.current.id]; + saveState();renderArchive(); + }); +} +function viewStoredFormula(item){ + if(isBusy)return; + state.current=runtimeFormula(item); + updateFormulaUI();renderArchive();saveState(); + closeGalleryOnSmallScreen();updateView();animateFormula(); +} +function selectedRecords(){return state.selectedParents.map(id=>state.history.find(item=>item.id===id)).filter(Boolean)} +function crossSelected(){ + const selected=selectedRecords();if(selected.length!==2||isBusy)return; + const seed=randomSeed(),id=state.nextId++; + const result=crossoverGenomes(selected[0],selected[1],seed); + showFormula({ + id,seed,genome:result.genome,source:'crossover',parents:[selected[0].id,selected[1].id], + inheritance:result.inherited,mutations:result.mutations + }); +} +function toggleParent(id){ + if(isBusy)return; + const selected=state.selectedParents,index=selected.indexOf(id); + if(index>=0)selected.splice(index,1); + else if(selected.length<2)selected.push(id); + else{selected.shift();selected.push(id)} + saveState();renderArchive(); + if(selected.length===2&&width<=760)setGallery(false); +} + +const geneLabels={branches:'branches',angle:'angle',scale:'scale',depth:'recursion',symmetry:'symmetry',anchors:'anchors', + anchorStart:'anchor position',twist:'twist',bend:'bend',turn:'turn',angleDrift:'angle drift',scaleDrift:'scale drift', + alternation:'alternation',closure:'closure',angleWave:'angle resonance',angleFrequency:'angle frequency', + scaleWave:'scale pulse',scaleFrequency:'scale frequency',phase:'harmonic phase',curvatureDrift:'curvature drift', + branchBias:'branch bias',facets:'faceting',tipSides:'terminal polygon',tipScale:'terminal scale', + figureSides:'figure order',figureSpan:'figure segment',figureScale:'figure scale', + figureEvery:'figure interval',figureSpin:'figure rotation', + curveMode:'trigonometric curve',curveAmplitude:'curve amplitude',curveFrequency:'curve frequency', + rootLength:'root length',lineWidth:'line width',hue:'color',hueStep:'color shift', + saturation:'saturation',lightness:'lightness',growthOverlap:'growth overlap'}; +function updateFormulaUI(){ + const c=state.current,g=c?.genome||baseGenome(); + document.querySelector('#formula-name').textContent=familyName(g.family); + document.querySelector('#organism-id').textContent=c?formulaId(c.id):'F000'; + document.querySelector('#trait-branch').textContent=g.branches; + document.querySelector('#trait-angle').textContent=`${Math.round(g.angle*180/Math.PI)}°`; + document.querySelector('#trait-scale').textContent=g.scale.toFixed(2); + document.querySelector('#trait-symmetry').textContent=g.symmetry; + document.querySelector('#trait-depth').textContent=g.depth; + document.querySelector('#trait-closure').textContent=g.closure.toFixed(2); + document.querySelector('#trait-wave').textContent=`${Math.round(g.angleWave*180/Math.PI)}°`; + document.querySelector('#trait-pulse').textContent=g.scaleWave.toFixed(2); + document.querySelector('#trait-facets').textContent=g.facets; + document.querySelector('#trait-figure').textContent=g.figureSides>=3 + ?`${Math.round(g.figureSpan*g.figureSides)}/${g.figureSides}-gon` + :'none'; + document.querySelector('#trait-curve').textContent=g.curveMode + ?`${curveName(g.curveMode)} ${g.curveFrequency.toFixed(1)}×` + :'linear'; + const lineage=document.querySelector('#lineage'); + if(c?.parents?.length===2){ + const inherited=c.inheritance?`${c.inheritance.a}/${c.inheritance.b} genes`:'mixed genes'; + const mutations=c.mutations?.length?` · mutation: ${c.mutations.map(key=>geneLabels[key]||key).join(', ')}`:' · no mutation'; + lineage.textContent=`${formulaId(c.parents[0])} × ${formulaId(c.parents[1])} · inherited ${inherited}${mutations}`; + }else lineage.textContent='founder formula · no parents'; + updateSelectionUI(); +} +function updateSelectionUI(){ + const selected=selectedRecords(); + const slot=(selector,label,item)=>{ + document.querySelector(selector).innerHTML=`${label} ${item?`${formulaId(item.id)} · ${familyName(item.genome.family)}`:'choose a formula'}`; + }; + slot('#parent-a','A',selected[0]);slot('#parent-b','B',selected[1]); + ui.cross.disabled=isBusy||selected.length!==2; + const crossLabel=ui.cross.querySelector('span'); + if(selected.length===2){ + crossLabel.textContent=`Cross ${formulaId(selected[0].id)} × ${formulaId(selected[1].id)}`; + document.querySelector('#action-kicker').textContent='gene-by-gene crossover · rare mutation'; + document.querySelector('#action-title').textContent=`${familyName(selected[0].genome.family)} × ${familyName(selected[1].genome.family)}`; + }else if(selected.length===1){ + crossLabel.textContent='Choose parent B'; + document.querySelector('#action-kicker').textContent=`parent A · ${formulaId(selected[0].id)}`; + document.querySelector('#action-title').textContent='Choose one more formula from the Gallery'; + }else{ + crossLabel.textContent='Cross A × B'; + document.querySelector('#action-kicker').textContent='selective generation · choose A and B'; + document.querySelector('#action-title').textContent='Choose two parents from the Gallery'; + } +} +function hideArchivePreview(){ + previewToken++;cancelAnimationFrame(previewAnimationFrame); + document.querySelector('#genome-preview').classList.add('hidden'); +} +function showArchivePreview(item,card){ + if(matchMedia('(hover: none)').matches)return; + const token=++previewToken; + cancelAnimationFrame(previewAnimationFrame); + const preview=document.querySelector('#genome-preview'); + const previewCanvas=document.querySelector('#preview-canvas'),paths=compileGeometry(item.genome); + paintFormulaMiniature(previewCanvas.getContext('2d'),paths,item.seed,0); + document.querySelector('#preview-id').textContent=formulaId(item.id); + document.querySelector('#preview-meta').textContent=item.parents?.length===2 + ?`${formulaId(item.parents[0])} × ${formulaId(item.parents[1])} · ${familyName(item.genome.family)}` + :`${familyName(item.genome.family)} · founder`; + preview.classList.remove('hidden'); + const panel=ui.gallery.getBoundingClientRect(),cardBox=card.getBoundingClientRect(),previewWidth=preview.offsetWidth; + const left=panel.left-previewWidth-12>=12?panel.left-previewWidth-12:Math.min(innerWidth-previewWidth-12,panel.right+12); + const top=clamp(cardBox.top+cardBox.height/2-preview.offsetHeight/2,72,innerHeight-preview.offsetHeight-38); + preview.style.left=`${left}px`;preview.style.top=`${top}px`; + if(matchMedia('(prefers-reduced-motion: reduce)').matches){ + paintFormulaMiniature(previewCanvas.getContext('2d'),paths,item.seed,1);return; + } + const start=performance.now(); + let lastPaint=0; + const frame=now=>{ + if(token!==previewToken)return; + const linear=clamp((now-start)/PREVIEW_TIME,0,1); + if(now-lastPaint>=30||linear===1){ + paintFormulaMiniature(previewCanvas.getContext('2d'),paths,item.seed,1-Math.pow(1-linear,2.2)); + lastPaint=now; + } + if(linear<1)previewAnimationFrame=requestAnimationFrame(frame); + }; + previewAnimationFrame=requestAnimationFrame(frame); +} +function renderArchive(){ + hideArchivePreview();ui.archive.replaceChildren(); + const items=[...state.history].sort((a,b)=>b.id-a.id); + for(const item of items.slice(0,historyVisible)){ + ensureThumbnail(item); + const card=document.createElement('article'); + if(item.id===state.current?.id)card.classList.add('current'); + const selectedIndex=state.selectedParents.indexOf(item.id); + if(selectedIndex>=0)card.classList.add(selectedIndex===0?'selected-a':'selected-b'); + card.dataset.formulaId=item.id;card.tabIndex=0;card.setAttribute('role','button'); + card.setAttribute('aria-label',`View ${formulaId(item.id)}, ${familyName(item.genome.family)}`); + card.addEventListener('click',()=>viewStoredFormula(item)); + card.addEventListener('keydown',event=>{ + if(event.target===card&&(event.key==='Enter'||event.key===' ')){event.preventDefault();viewStoredFormula(item)} + }); + card.addEventListener('mouseenter',()=>showArchivePreview(item,card));card.addEventListener('mouseleave',hideArchivePreview); + card.addEventListener('focus',()=>showArchivePreview(item,card));card.addEventListener('blur',hideArchivePreview); + const image=document.createElement('img');image.src=item.thumbnail;image.alt=`Fractal ${formulaId(item.id)}`;image.loading='lazy'; + const label=document.createElement('span');label.textContent=formulaId(item.id); + const parentPick=document.createElement('button');parentPick.className='parent-pick'; + parentPick.textContent=selectedIndex>=0?(selectedIndex===0?'A':'B'):'+'; + parentPick.setAttribute('aria-label',selectedIndex>=0 + ?`Remove ${formulaId(item.id)} from parent ${selectedIndex===0?'A':'B'}` + :`Select ${formulaId(item.id)} as a parent`); + parentPick.title=selectedIndex>=0?'Remove parent':'Select for crossing'; + parentPick.addEventListener('click',event=>{event.stopPropagation();toggleParent(item.id)}); + card.append(image,label,parentPick);ui.archive.append(card); + } + const remaining=Math.max(0,items.length-historyVisible),loadMore=document.querySelector('#load-more'); + loadMore.classList.toggle('hidden',remaining===0);loadMore.textContent=`Show more · ${remaining}`; + document.querySelector('#gallery-count').textContent=state.history.length; + updateSelectionUI(); +} +function setGallery(open){ + galleryOpen=open; + ui.gallery.classList.toggle('open',open);ui.gallery.classList.toggle('closed',!open); + document.querySelector('#gallery-toggle').setAttribute('aria-expanded',String(open)); + hideArchivePreview();updateView();render(lastProgress); +} +function closeGalleryOnSmallScreen(){if(width<1180)setGallery(false)} +function toggleGallery(){setGallery(!galleryOpen)} +function closeExportMenu(){ + document.querySelector('#export-menu').classList.add('hidden'); + document.querySelector('#export-toggle').setAttribute('aria-expanded','false'); +} +function showToast(message){ + const toast=document.querySelector('#toast');clearTimeout(toastTimer); + toast.textContent=message;toast.classList.remove('hidden'); + toastTimer=setTimeout(()=>toast.classList.add('hidden'),1800); +} + +ui.cross.addEventListener('click',crossSelected); +document.querySelector('#gallery-toggle').addEventListener('click',toggleGallery); +document.querySelector('#gallery-close').addEventListener('click',()=>setGallery(false)); +document.querySelector('#clear-parents').addEventListener('click',()=>{ + state.selectedParents=[];saveState();renderArchive(); +}); +document.querySelector('#load-more').addEventListener('click',()=>{historyVisible+=HISTORY_PAGE;renderArchive()}); +document.querySelector('#export-toggle').addEventListener('click',event=>{ + event.stopPropagation();const menu=document.querySelector('#export-menu'),open=menu.classList.contains('hidden'); + menu.classList.toggle('hidden',!open);event.currentTarget.setAttribute('aria-expanded',String(open)); +}); +document.querySelector('#export-menu').addEventListener('click',event=>event.stopPropagation()); +document.querySelector('#save-background').addEventListener('click',()=>downloadImage(true)); +document.querySelector('#save-transparent').addEventListener('click',()=>downloadImage(false)); +document.querySelector('#save-video').addEventListener('click',exportVideo); +document.querySelector('#save-formula').addEventListener('click',exportFormula); +document.addEventListener('click',closeExportMenu); +document.addEventListener('pointerdown',requestPersistentStorage,{once:true,capture:true}); +document.addEventListener('keydown',event=>{ + if(event.key==='Escape'){closeExportMenu();hideArchivePreview();if(width<1180)setGallery(false);return} + if(event.repeat||event.target.closest('button,input,select,textarea'))return; + if(event.code==='Space'){event.preventDefault();if(!ui.cross.disabled)crossSelected()} +}); +addEventListener('resize',()=>{ + const wasDesktop=width>=1180;resize(); + if(wasDesktop!==(width>=1180))setGallery(width>=1180); +}); +addEventListener('beforeunload',saveState); + +async function boot(){ + await hydrateState(); + await refreshStorageStatus(); + ensureInitialPopulation(); + galleryOpen=innerWidth>=1180; + ui.gallery.classList.toggle('closed',!galleryOpen);ui.gallery.classList.toggle('open',galleryOpen); + document.querySelector('#gallery-toggle').setAttribute('aria-expanded',String(galleryOpen)); + if(state.current)state.current=runtimeFormula(state.current); + resize();renderArchive(); + if(state.current){ + updateFormulaUI();updateView(); + animateFormula(()=>{ + addCurrentToHistory(); + if(!state.selectedParents.length)state.selectedParents=[state.current.id]; + saveState();renderArchive(); + }); + } +} +boot(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..da0d73a --- /dev/null +++ b/index.html @@ -0,0 +1,115 @@ + + + + + + + Fractalier — Mathematical Fractal Generator + + + +
+ + +
+
FRACTALIER
+
+ ready +
+
+ +
+ + + + + + + +
+
+ selective generation · choose A and B + Choose two parents from the Gallery +
+
+ +
+
+ + + +
+ idle · zero GPU animation + SPACE · cross selected + checking local storage… +
+
+ + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..82b8a14 --- /dev/null +++ b/style.css @@ -0,0 +1,157 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600&display=swap'); + +:root{ + color-scheme:dark; + --ink:#edf5ef; + --muted:#71867c; + --soft:#9db0a4; + --line:rgba(211,239,218,.14); + --line-bright:rgba(197,248,121,.45); + --lime:#c5f879; + --cyan:#75daff; + --panel:rgba(6,16,14,.84); + --deep:#07100f; +} + +*{box-sizing:border-box} +html,body,main{width:100%;height:100%;margin:0;overflow:hidden} +body{background:var(--deep);color:var(--ink);font-family:Manrope,sans-serif} +button{font:inherit} +button:focus-visible,input:focus-visible{outline:1px solid var(--lime);outline-offset:2px} +canvas{position:absolute;inset:0;width:100%;height:100%} +.hidden{display:none!important} + +header{ + position:absolute;z-index:10;top:0;left:0;right:0;height:64px; + display:grid;grid-template-columns:1fr minmax(180px,320px) 1fr;align-items:center; + padding:0 22px;border-bottom:1px solid var(--line); + background:linear-gradient(180deg,rgba(5,14,13,.96),rgba(5,14,13,.66)); + backdrop-filter:blur(12px); +} +.brand{font:500 12px DM Mono,monospace;letter-spacing:2.2px} +.brand i{display:inline-block;width:7px;height:7px;margin-right:9px;border:1px solid var(--lime);transform:rotate(45deg)} +.generation-state{display:grid;grid-template-columns:74px 1fr;gap:10px;align-items:center;color:var(--soft);font:8px DM Mono,monospace;text-transform:uppercase} +.generation-state>div{height:1px;overflow:hidden;background:#294038} +.generation-state b{display:block;width:0;height:100%;background:var(--lime);transition:width .08s linear} +nav{justify-self:end;display:flex;gap:6px} +nav button{height:32px;padding:0 10px;border:1px solid var(--line);background:rgba(7,18,16,.86);color:var(--soft);cursor:pointer;font:9px DM Mono,monospace} +nav button:hover{border-color:var(--line-bright);color:var(--lime)} +.gallery-toggle span{display:inline-grid;place-items:center;min-width:17px;height:17px;margin-left:5px;padding:0 4px;background:rgba(197,248,121,.12);color:var(--lime)} +.export-control{position:relative} +.export-menu{position:absolute;z-index:20;top:38px;right:0;width:178px;padding:5px;border:1px solid var(--line);background:rgba(5,14,13,.98);box-shadow:0 18px 50px rgba(0,0,0,.48)} +nav .export-menu button{display:block;width:100%;border:0;text-align:left} +.export-menu button+button{border-top:1px solid var(--line)} + +.formula-panel,.gallery-panel,.action-bar,.genome-preview{ + border:1px solid var(--line);background:var(--panel);backdrop-filter:blur(12px); +} +.formula-panel{position:absolute;z-index:4;top:84px;left:22px;width:340px;padding:15px 16px} +.panel-heading,.gallery-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px} +.kicker{margin:0 0 6px;color:var(--lime);font:8px DM Mono,monospace;letter-spacing:1.2px;text-transform:uppercase} +.panel-heading strong,.gallery-head strong{font:500 13px Manrope,sans-serif} +.panel-heading>span{color:#81978c;font:9px DM Mono,monospace} +.equation{margin-top:13px;padding:9px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:#dce9df;font:9px/1.55 DM Mono,monospace} +.equation span{display:block;white-space:nowrap} +.equation span:first-child{color:#81978c;font-size:8px} +.traits{display:grid;grid-template-columns:repeat(3,1fr);gap:8px 10px;margin-top:11px} +.traits span{color:var(--muted);font:8px DM Mono,monospace} +.traits b{margin-left:3px;color:#b9cabf;font-weight:400} +.lineage{margin:11px 0 0;padding-top:9px;border-top:1px solid var(--line);color:#71867c;font:8px DM Mono,monospace} + +.gallery-panel{position:absolute;z-index:5;top:84px;right:22px;bottom:112px;width:292px;padding:15px;overflow:hidden} +.gallery-head button{display:none;width:28px;height:28px;border:1px solid var(--line);background:transparent;color:var(--soft);cursor:pointer} +.gallery-help{margin:8px 0 12px;color:var(--muted);font-size:9px;line-height:1.45} +.parent-slots{display:grid;grid-template-columns:1fr 1fr 27px;gap:5px;margin-bottom:10px} +.parent-slots span{overflow:hidden;padding:7px 8px;border:1px solid var(--line);color:#82968b;font:8px DM Mono,monospace;white-space:nowrap;text-overflow:ellipsis} +.parent-slots span b{margin-right:5px;color:var(--lime);font-weight:500}.parent-slots span:nth-child(2) b{color:var(--cyan)} +.parent-slots button{border:1px solid var(--line);background:transparent;color:var(--soft);cursor:pointer}.parent-slots button:hover{border-color:var(--line-bright);color:var(--lime)} +.archive{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;max-height:calc(100% - 137px);overflow:auto;scrollbar-width:thin;scrollbar-color:#385047 transparent} +.archive article{position:relative;aspect-ratio:1.5;overflow:hidden;border:1px solid var(--line);background:#081411;cursor:pointer} +.archive article:hover,.archive article.current{border-color:var(--line-bright)} +.archive article.current{box-shadow:inset 0 0 0 1px var(--lime)} +.archive article.selected-a{box-shadow:inset 0 0 0 2px var(--lime)} +.archive article.selected-b{box-shadow:inset 0 0 0 2px var(--cyan)} +.archive img{display:block;width:100%;height:100%;object-fit:cover;transition:transform .25s ease} +.archive article:hover img{transform:scale(1.035)} +.archive span{position:absolute;right:4px;bottom:3px;padding:2px 4px;background:rgba(2,9,8,.82);color:#d2e6d7;font:7px DM Mono,monospace} +.archive .parent-pick{ + position:absolute;top:4px;right:4px;width:21px;height:21px;padding:0;border:1px solid rgba(211,239,218,.28); + background:rgba(2,9,8,.88);color:var(--lime);cursor:pointer;font:9px DM Mono,monospace +} +.archive article.selected-b .parent-pick{color:var(--cyan)} +.archive .parent-pick:hover{border-color:var(--lime);background:#14251d} +.load-more{width:100%;height:29px;margin-top:7px;border:1px solid var(--line);background:transparent;color:var(--soft);cursor:pointer;font:8px DM Mono,monospace} +.load-more:hover{border-color:var(--line-bright);color:var(--lime)} + +.genome-preview{position:fixed;z-index:15;width:330px;padding:8px;pointer-events:none;box-shadow:0 24px 65px rgba(0,0,0,.58)} +.genome-preview canvas{position:static;inset:auto;display:block;width:100%;height:auto;aspect-ratio:1.5;background:#081411} +.genome-preview>div{display:flex;justify-content:space-between;align-items:center;padding:9px 3px 2px} +.genome-preview strong{color:var(--lime);font:11px DM Mono,monospace;font-weight:400} +.genome-preview span{color:var(--muted);font:8px DM Mono,monospace} + +.action-bar{ + position:absolute;z-index:8;left:50%;bottom:48px;width:min(660px,calc(100% - 700px));min-width:540px; + display:flex;align-items:center;justify-content:space-between;gap:18px;padding:10px; + transform:translateX(-50%); +} +.action-copy{min-width:0;padding-left:5px} +.action-copy span{display:block;overflow:hidden;margin-bottom:4px;color:var(--muted);font:7px DM Mono,monospace;text-overflow:ellipsis;white-space:nowrap;text-transform:uppercase} +.action-copy strong{display:block;overflow:hidden;font-size:10px;font-weight:500;text-overflow:ellipsis;white-space:nowrap} +.action-buttons{display:flex;gap:7px;flex:0 0 auto} +.action-buttons button{height:48px;border:0;cursor:pointer} +.action-buttons button:disabled{cursor:wait;opacity:.58} +.action-buttons span,.action-buttons small{display:block} +.action-buttons small{margin-top:2px;font:7px DM Mono,monospace;opacity:.65} +.action-buttons .secondary{min-width:142px;padding:0 15px;border:1px solid var(--line);background:#0b1b17;color:#b6c6bc;text-align:left;font-size:9px} +.action-buttons .secondary:hover{border-color:var(--line-bright);color:var(--lime)} +.action-buttons .primary{min-width:184px;display:flex;align-items:center;justify-content:space-between;padding:0 14px 0 16px;background:var(--lime);color:#102015;font-size:10px} +.action-buttons .primary i{font-style:normal;font-size:18px} + +.toast{position:absolute;z-index:30;left:50%;bottom:122px;padding:8px 12px;border:1px solid var(--line);background:rgba(5,14,13,.94);color:#c7d9cd;font:8px DM Mono,monospace;transform:translateX(-50%)} +footer{position:absolute;z-index:9;left:0;right:0;bottom:0;height:30px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 22px;border-top:1px solid var(--line);background:rgba(5,14,13,.93);color:#60756b;font:7px DM Mono,monospace} +footer span:last-child{text-align:right} +.live{display:inline-block;width:5px;height:5px;margin-right:7px;border-radius:50%;background:var(--lime);box-shadow:0 0 7px var(--lime)} + +@media(max-width:1179px){ + .gallery-panel{display:none;top:76px;right:12px;bottom:105px;width:300px;box-shadow:0 20px 60px rgba(0,0,0,.55)} + .gallery-panel.open{display:block} + .gallery-head button{display:block} + .formula-panel{top:78px;left:12px;width:320px} + .action-bar{width:min(620px,calc(100% - 48px));min-width:0} +} + +@media(max-width:760px){ + header{height:62px;grid-template-columns:1fr auto;padding:0 10px} + .generation-state{position:absolute;top:66px;left:11px;right:11px;grid-template-columns:62px 1fr} + nav{gap:5px} + nav>button,nav>.export-control>button{width:34px;padding:0;overflow:hidden;font-size:0} + nav .gallery-toggle:before{content:'▦';font-size:14px} + nav .gallery-toggle span{position:absolute;top:10px;right:43px;min-width:13px;height:13px;margin:0;font-size:6px} + nav>.export-control>button:before{content:'↓';font-size:14px} + .export-menu{right:0} + .formula-panel{top:91px;left:10px;right:10px;width:auto;padding:12px 14px} + .equation{margin-top:9px;padding:8px 0;font-size:7.5px} + .equation span{overflow:hidden;text-overflow:ellipsis} + .traits{gap:6px 8px;margin-top:8px} + .gallery-panel,.gallery-panel.open{top:72px;right:10px;bottom:112px;left:10px;width:auto} + .archive{grid-template-columns:repeat(3,1fr)} + .genome-preview{display:none!important} + .action-bar{right:10px;bottom:40px;left:10px;width:auto;padding:8px;transform:none} + .action-copy{display:none} + .action-buttons{width:100%} + .action-buttons button{height:46px} + .action-buttons .secondary,.action-buttons .primary{min-width:0;flex:1} + .toast{bottom:101px} + footer{grid-template-columns:1fr auto;padding:0 10px} + footer>span:nth-child(2){display:none} +} + +@media(max-height:620px) and (min-width:761px){ + .formula-panel{top:74px} + .gallery-panel{top:74px;bottom:94px} + .action-bar{bottom:38px} +} + +@media(prefers-reduced-motion:reduce){ + .archive img,.generation-state b{transition:none} +}