Files

1128 lines
59 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)<Math.abs(closest-requested)?option:closest,.5);
return{
mix:Number.isFinite(requested)?mix:.5,
mutationLevel:Number.isFinite(requestedLevel)?clamp(Math.round(requestedLevel),0,3):1
};
}
function freshState(){return{version:7,history:[],nextId:1,current:null,selectedParents:[],breeding:normalizeBreeding({mix:.5,mutationLevel:1})}}
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,
rootLayout:0,rootSpread:Math.PI*2,rootSpacing:.12,
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',
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;index<mutationCount;index++){
let choice=Math.floor(randomFrom(seed,281+index)*keys.length);
while(used.has(choice)||!mutationRules[keys[choice]])choice=(choice+1)%keys.length;
used.add(choice);
const key=keys[choice],[amount,min,max,integer]=mutationRules[key];
const direction=randomFrom(seed,300+index)<.5?-1:1;
let value;
if(key==='tipSides'||key==='figureSides'){
const upper=key==='tipSides'?8:10;
value=child[key]===0?3+Math.floor(randomFrom(seed,310+index)*(upper-2)):randomFrom(seed,320+index)<.18?0:child[key]+direction;
if(value>0)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)<profile.structural*1.25){
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)<profile.structural){
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');
}
if(a.rootLayout===0&&b.rootLayout===0&&child.rootLayout===0&&randomFrom(seed,350)<profile.structural){
child.rootLayout=1+Math.floor(randomFrom(seed,351)*3);
child.rootSpread=child.rootLayout===1?range(seed,352,.7,2.4):range(seed,352,0,.6);
child.rootSpacing=range(seed,353,.06,.16);
mutations.push('rootLayout');
}
child.family=classifyFamily({...child,family:null});
return{genome:child,inherited,mutations,breeding};
}
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 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<rootCount;root++){
const placement=rootPlacement(g,root,rootCount);
queue.push({...placement,length:g.rootLength,depth:0,root,turn:g.turn,bend:g.bend});
}
let cursor=0;
while(cursor<queue.length&&paths.length<MAX_PATHS){
const node=queue[cursor++],curve=traceGenomeCurve(node,g,facets);
const generationGap=.71/(g.depth+1),stagger=((node.root*17+cursor*7)%13)/13*generationGap*.14;
const birth=node.depth*generationGap+stagger,duration=generationGap*(1.65+g.growthOverlap);
paths.push({
points:curve.points,depth:node.depth,root:node.root,birth,duration,
hue:(g.hue+node.depth*g.hueStep+node.root*360/Math.max(1,g.symmetry)+3600)%360,
saturation:g.saturation,lightness:g.lightness,width:Math.max(.42,g.lineWidth*Math.pow(.84,node.depth))
});
const figureEvery=clamp(Math.round(g.figureEvery),1,4);
if(g.figureSides>=3&&node.depth>0&&node.depth%figureEvery===0&&paths.length<MAX_PATHS){
paths.push({
points:traceFigureSegment(curve,node,g),depth:node.depth+.45,root:node.root,
birth:birth+duration*.32,duration:duration*.72,
hue:(g.hue+node.depth*g.hueStep+38+node.root*360/Math.max(1,g.symmetry)+3600)%360,
saturation:g.saturation,lightness:clamp(g.lightness+7,0,92),
width:Math.max(.38,g.lineWidth*Math.pow(.83,node.depth))
});
}
const harmonicPhase=(node.depth+1)*g.scaleFrequency+g.phase+node.root*Math.PI*2/Math.max(1,g.symmetry);
const effectiveScale=clamp(
g.scale+g.scaleDrift*Math.sin((node.depth+1)*2.399963)+g.scaleWave*Math.cos(harmonicPhase),
.38,.88
);
const terminal=node.depth>=g.depth||node.length*effectiveScale<.0035;
if(terminal){
const tipSides=Math.round(g.tipSides);
if(tipSides>=3&&paths.length<MAX_PATHS){
const radius=Math.max(.004,node.length*g.tipScale),rotation=curve.endDirection+Math.PI/2+g.phase*.25;
const points=Array.from({length:tipSides+1},(_,index)=>{
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<MAX_PATHS){
const endpoints=children.map(child=>{
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(full<path.points.length-1&&fraction>0){
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(full<path.points.length-1&&fraction>0){
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=`<b>${label}</b> ${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();