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; const MIX_OPTIONS=[.2,.35,.5,.65,.8]; const MUTATION_PROFILES=[ {name:'stable',strength:.55,structural:.025}, {name:'natural',strength:1,structural:.07}, {name:'active',strength:1.45,structural:.15}, {name:'volatile',strength:2,structural:.26} ]; 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 normalizeBreeding(value){ const requested=Number(value?.mix); const requestedLevel=Number(value?.mutationLevel); const mix=MIX_OPTIONS.reduce((closest,option)=>Math.abs(option-requested)>>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', fan:'Angular fan', field:'Parallel field', bilateral:'Bilateral weave' }[family]||'Recursive structure'; } function curveName(mode){return['linear','sine','cosine','tangent','harmonic'][Math.round(mode)]||'linear'} function layoutName(mode){return['radial','fan','parallel','bilateral'][Math.round(mode)]||'radial'} function mixName(mix){ const a=Math.round(mix*100); return`${a}% A · ${100-a}% B`; } function classifyFamily(genome){ if(genome.family)return genome.family; if(genome.rootLayout===1)return'fan'; if(genome.rootLayout===2)return'field'; if(genome.rootLayout===3)return'bilateral'; 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||[], breeding:item?.breeding?normalizeBreeding(item.breeding):null }; } 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 currentHistoryRecord=oldCurrent?history.find(item=>item.id===Number(oldCurrent.id)):null; const current=oldCurrent?.genome?{ id:Number(oldCurrent.id)||history.at(-1)?.id||1, seed:Number(oldCurrent.seed)||randomSeed(), genome:normalizeGenome(oldCurrent.genome), source:oldCurrent.source&&oldCurrent.source!=='restored'?oldCurrent.source:currentHistoryRecord?.source||'restored', parents:oldCurrent.parents||[],inheritance:oldCurrent.inheritance||null,mutations:oldCurrent.mutations||[], breeding:oldCurrent.breeding?normalizeBreeding(oldCurrent.breeding):currentHistoryRecord?.breeding||null }: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); const breeding=normalizeBreeding(source?.breeding); state={version:7,history,nextId,current,selectedParents,breeding}; storedHistoryIds=new Set(records.map(item=>item.id)); if(saved?.version!==7||(!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:7,history,nextId:Math.max(1,...history.map(item=>item.id+1)),current:history.at(-1)||null, selectedParents:[],breeding:normalizeBreeding(legacy?.breeding) }; } } function persistableCurrent(){ if(!state.current)return null; const{id,seed,genome,source,parents,inheritance,mutations,breeding}=state.current; return{id,seed,genome,source,parents,inheritance,mutations,breeding}; } 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:7,nextId:state.nextId,current:persistableCurrent(),selectedParents:state.selectedParents, breeding:state.breeding,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','fan','field','bilateral']); 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 if(family==='lattice'){ 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); }else if(family==='fan'){ g.rootLayout=1;g.rootSpread=range(seed,140,.7,2.45);g.symmetry=pick(seed,141,[3,4,5,7]); g.branches=pick(seed,142,[1,2,2]);g.depth=g.branches===1?8:pick(seed,143,[5,6,7]); g.angle=range(seed,144,.26,.68);g.scale=range(seed,145,.59,.73);g.bend=range(seed,146,-.13,.13); g.twist=range(seed,147,-.12,.12);g.angleWave=range(seed,148,.04,.18);g.scaleWave=range(seed,149,.01,.08); g.angleFrequency=range(seed,150,.8,2.4);g.scaleFrequency=range(seed,151,1.1,3.2); g.branchBias=range(seed,152,-.12,.12);g.facets=pick(seed,153,[8,10,14]); g.figureSides=pick(seed,154,[0,0,3,4,5]);g.figureSpan=range(seed,155,.2,.55); g.figureScale=range(seed,156,.1,.22);g.figureEvery=pick(seed,157,[2,3]); g.curveMode=pick(seed,158,[0,1,1,2,4]);g.curveAmplitude=g.curveMode?range(seed,159,.05,.22):0; g.curveFrequency=range(seed,160,.7,1.9); }else if(family==='field'){ g.rootLayout=2;g.rootSpread=range(seed,161,0,.36);g.rootSpacing=range(seed,162,.065,.15); g.symmetry=pick(seed,163,[3,4,5,6,8]);g.branches=pick(seed,164,[1,2,2]);g.depth=g.branches===1?8:pick(seed,165,[5,6]); g.angle=range(seed,166,.2,.58);g.scale=range(seed,167,.57,.71);g.bend=range(seed,168,-.1,.1); g.twist=range(seed,169,-.07,.07);g.angleWave=range(seed,170,.025,.13);g.scaleWave=range(seed,171,.01,.065); g.angleFrequency=range(seed,172,.8,2.2);g.scaleFrequency=range(seed,173,1.2,3); g.alternation=range(seed,174,-.12,.12);g.facets=pick(seed,175,[8,10,14]); g.figureSides=pick(seed,176,[0,0,3,4,6]);g.figureSpan=range(seed,177,.18,.5); g.figureScale=range(seed,178,.09,.2);g.figureEvery=pick(seed,179,[2,3]); g.curveMode=pick(seed,180,[0,0,1,2,3]);g.curveAmplitude=g.curveMode?range(seed,181,.035,.16):0; g.curveFrequency=range(seed,182,.7,1.7); }else{ g.rootLayout=3;g.rootSpread=range(seed,183,0,.65);g.rootSpacing=range(seed,184,.055,.14); g.symmetry=pick(seed,185,[2,4,4,6]);g.branches=pick(seed,186,[1,2,2]);g.depth=g.branches===1?8:pick(seed,187,[5,6]); g.angle=range(seed,188,.25,.7);g.scale=range(seed,189,.57,.7);g.bend=range(seed,190,-.12,.12); g.twist=range(seed,191,-.1,.1);g.angleWave=range(seed,192,.035,.16);g.scaleWave=range(seed,193,.01,.07); g.angleFrequency=range(seed,194,.8,2.3);g.scaleFrequency=range(seed,195,1.1,3.1); g.alternation=range(seed,196,-.15,.15);g.closure=range(seed,197,0,.3);g.facets=pick(seed,198,[7,10,14]); g.figureSides=pick(seed,199,[0,3,4,6]);g.figureSpan=range(seed,200,.2,.58); g.figureScale=range(seed,201,.1,.22);g.figureEvery=pick(seed,202,[2,3]); g.curveMode=pick(seed,203,[0,1,2,4]);g.curveAmplitude=g.curveMode?range(seed,204,.05,.2):0; g.curveFrequency=range(seed,205,.7,1.8); } 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], rootLayout:[1,0,3,true],rootSpread:[.45,0,Math.PI*2],rootSpacing:[.045,.04,.24], saturation:[12,48,92],lightness:[10,48,86],growthOverlap:[.16,.2,.85] }; function mutationCountFor(level,roll){ if(level===0)return roll<.92?0:1; if(level===1)return roll<.8?0:roll<.98?1:2; if(level===2)return roll<.55?0:roll<.9?1:2; return roll<.25?0:roll<.6?1:roll<.87?2:3; } function crossoverGenomes(parentA,parentB,seed,options={}){ const a=normalizeGenome(parentA.genome),b=normalizeGenome(parentB.genome),child={},inherited={a:0,b:0}; const keys=Object.keys(baseGenome()).filter(key=>key!=='family'); const breeding=normalizeBreeding(options),profile=MUTATION_PROFILES[breeding.mutationLevel]; const targetA=clamp(Math.round(keys.length*breeding.mix),1,keys.length-1); const fromAKeys=new Set(keys.map((key,index)=>({key,index,score:randomFrom(seed,200+index)})) .sort((left,right)=>left.score-right.score||left.index-right.index).slice(0,targetA).map(item=>item.key)); for(const[index,key]of keys.entries()){ const fromA=fromAKeys.has(key); child[key]=fromA?a[key]:b[key];inherited[fromA?'a':'b']++; } const mutations=[],mutationCount=mutationCountFor(breeding.mutationLevel,randomFrom(seed,280)),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 if(key==='rootLayout'){ value=child[key]===0?1+Math.floor(randomFrom(seed,310+index)*3):randomFrom(seed,320+index)<.16?0:clamp(child[key]+direction,1,3); }else{ const step=integer?Math.max(1,Math.round(amount*profile.strength)):amount*profile.strength; value=integer?child[key]+direction*step:child[key]+direction*step*(.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); if(key==='rootLayout'&&child.rootLayout>0){ child.rootSpread=child.rootLayout===1?range(seed,325+index,.7,2.4):range(seed,325+index,0,.6); child.rootSpacing=range(seed,327+index,.06,.16); } mutations.push(key); } if(a.figureSides===0&&b.figureSides===0&&child.figureSides===0&&randomFrom(seed,330)(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 rootPlacement(g,root,count){ const t=count===1?.5:root/(count-1),centered=t-.5,layout=Math.round(g.rootLayout); if(layout===1){ return{x:0,y:.28,direction:-Math.PI/2+centered*g.rootSpread}; } if(layout===2){ return{ x:centered*g.rootSpacing*(count-1), y:.3+Math.sin(t*Math.PI*2+g.phase)*g.rootSpacing*.12, direction:-Math.PI/2+centered*g.rootSpread }; } if(layout===3){ const upward=root%2===0,tilt=centered*g.rootSpread; return{ x:centered*g.rootSpacing*(count-1),y:0, direction:(upward?-Math.PI/2:Math.PI/2)+(upward?tilt:-tilt) }; } return count===1 ?{x:0,y:.34,direction:-Math.PI/2} :{x:0,y:0,direction:-Math.PI/2+root*Math.PI*2/count}; } function compileGeometry(genome){ const g=normalizeGenome(genome),paths=[],queue=[]; const facets=clamp(Math.round(g.facets),3,18),rootCount=clamp(Math.round(g.symmetry),1,8); 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); const galleryRect=galleryOpen?ui.gallery.getBoundingClientRect():null; const actionTop=document.querySelector('.action-bar').getBoundingClientRect().top; const formulaVisible=ui.formula.getClientRects().length>0; const compactLandscape=galleryOpen&&height<520; let left,right,top,bottom; if(width>=1180){ left=386;right=galleryOpen?width-326:width-34;top=82;bottom=height-116; }else if(compactLandscape){ left=22;right=galleryRect.left-14;top=82;bottom=actionTop-14; }else if(width>760){ left=formulaVisible?356:22;right=width-28;top=82; bottom=galleryOpen?galleryRect.top-14:actionTop-14; }else{ left=22;right=width-22;top=formulaVisible?Math.max(270,ui.formula.getBoundingClientRect().bottom+14):82; bottom=galleryOpen?galleryRect.top-14:actionTop-14; } if(right-left<160){left=22;right=width-22} if(bottom-top<90)top=Math.max(72,bottom-90); 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,breeding}=state.current; const payload={ format:'fractalier-formula',version:1,id:formulaId(id),seed, equation:'F[n+1] = union_r L_layout(r) union_i(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,breeding }; downloadBlob(new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),`fractalier-${formulaId(id)}.json`); closeExportMenu();showToast('Formula JSON downloaded'); } function sanitizeImportedGenome(payload){ if(!payload||typeof payload!=='object'||Array.isArray(payload))throw new Error('The JSON root must be an object'); if(payload.format&&payload.format!=='fractalier-formula')throw new Error('This is not a Fractalier formula'); const candidate=payload.genome||payload; if(!candidate||typeof candidate!=='object'||Array.isArray(candidate))throw new Error('The file does not contain a genome'); const defaults=baseGenome(),genome={...defaults},keys=Object.keys(defaults).filter(key=>key!=='family'); let recognized=0; for(const key of keys){ if(typeof candidate[key]!=='number'||!Number.isFinite(candidate[key]))continue; recognized++; let value=candidate[key],rule=mutationRules[key]; if(rule){ const[,min,max,integer]=rule; if(integer)value=Math.round(value); value=clamp(value,min,max); } if((key==='tipSides'||key==='figureSides')&&value>0)value=clamp(Math.round(value),3,key==='tipSides'?8:10); genome[key]=value; } if(recognized<5)throw new Error('No recognizable Fractalier genome was found'); genome.family=classifyFamily({...genome,family:null}); return genome; } function importFormulaPayload(payload){ const genome=sanitizeImportedGenome(payload); const importedSeed=Number(payload?.seed); const seed=Number.isFinite(importedSeed)?Math.abs(Math.trunc(importedSeed))>>>0:randomSeed(); const id=state.nextId++; showFormula({id,seed,genome,source:'import',parents:[],inheritance:null,mutations:[]}); showToast(`Imported as ${formulaId(id)}`); return id; } async function importFormulaFile(file){ if(!file)return; if(file.size>256*1024)throw new Error('The formula file is too large'); const payload=JSON.parse(await file.text()); return importFormulaPayload(payload); } 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=[],breeding=null}){ const normalized=normalizeGenome(genome),paths=compileGeometry(normalized); return{id,seed,genome:normalized,source,parents,inheritance,mutations,breeding,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=[],breeding=null}=state.current; state.history.push({ id,seed,genome:normalizeGenome(genome),source,parents,inheritance,mutations,breeding,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,breeding}=state.current; state.history.push({ id,seed,genome:{...genome},source,parents,inheritance,mutations,breeding,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,state.breeding); showFormula({ id,seed,genome:result.genome,source:'crossover',parents:[selected[0].id,selected[1].id], inheritance:result.inherited,mutations:result.mutations,breeding:result.breeding }); } 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:'root count',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', rootLayout:'root layout',rootSpread:'root spread',rootSpacing:'root spacing', 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'; document.querySelector('#trait-layout').textContent=layoutName(g.rootLayout); const lineage=document.querySelector('#lineage'); if(c?.parents?.length===2){ const inherited=c.inheritance?`${c.inheritance.a}/${c.inheritance.b} genes`:'mixed genes'; const breeding=c.breeding ?` · ${mixName(c.breeding.mix)} · ${MUTATION_PROFILES[c.breeding.mutationLevel].name}` :''; 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}${breeding}${mutations}`; }else if(c?.source==='import')lineage.textContent='imported formula · external genome'; 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=`${mixName(state.breeding.mix)} · ${MUTATION_PROFILES[state.breeding.mutationLevel].name} 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 syncBreedingUI(){ document.querySelector('#gene-mix').value=String(state.breeding.mix); document.querySelector('#mutation-level').value=String(state.breeding.mutationLevel); } 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)} · ${item.source==='import'?'imported':'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.body.classList.toggle('gallery-open',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.querySelector('#import-formula').addEventListener('click',()=>{ closeExportMenu();document.querySelector('#formula-file').click(); }); document.querySelector('#formula-file').addEventListener('change',async event=>{ const input=event.currentTarget,file=input.files?.[0]; try{await importFormulaFile(file)} catch(error){console.warn('Could not import the formula',error);showToast(error instanceof SyntaxError?'Invalid JSON file':error.message||'Formula import failed')} finally{input.value=''} }); document.querySelector('#gene-mix').addEventListener('change',event=>{ state.breeding=normalizeBreeding({...state.breeding,mix:Number(event.currentTarget.value)}); saveState();updateSelectionUI(); }); document.querySelector('#mutation-level').addEventListener('change',event=>{ state.breeding=normalizeBreeding({...state.breeding,mutationLevel:Number(event.currentTarget.value)}); saveState();updateSelectionUI(); }); 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',()=>{ resize(); }); addEventListener('beforeunload',saveState); async function boot(){ await hydrateState(); syncBreedingUI(); await refreshStorageStatus(); ensureInitialPopulation(); galleryOpen=true; ui.gallery.classList.toggle('closed',!galleryOpen);ui.gallery.classList.toggle('open',galleryOpen); document.body.classList.toggle('gallery-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();