From 2f7315016ccb8c31206eceef83ef7fc51b6137f4 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 29 Jul 2026 22:48:28 +0300 Subject: [PATCH] Add configurable crossover controls --- app.js | 120 +++++++++++++++++++++++++++++++++++++++-------------- index.html | 25 ++++++++++- style.css | 17 ++++++-- 3 files changed, 126 insertions(+), 36 deletions(-) diff --git a/app.js b/app.js index a602eb0..a406959 100644 --- a/app.js +++ b/app.js @@ -19,6 +19,13 @@ 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}; @@ -26,7 +33,16 @@ let database=null,storedHistoryIds=new Set(),historyVisible=HISTORY_PAGE; let animationFrame=0,animationToken=0,previewAnimationFrame=0,previewToken=0,lastProgress=1,galleryOpen=false,toastTimer=0,isBusy=false; let state=freshState(); -function freshState(){return{version:6,history:[],nextId:1,current:null,selectedParents:[]}} +function normalizeBreeding(value){ + const requested=Number(value?.mix); + const requestedLevel=Number(value?.mutationLevel); + const mix=MIX_OPTIONS.reduce((closest,option)=>Math.abs(option-requested)item.id+1),current?current.id+1:1); const selectedParents=(source?.selectedParents||[]).filter(id=>history.some(item=>item.id===id)).slice(0,2); - state={version:6,history,nextId,current,selectedParents}; + const breeding=normalizeBreeding(source?.breeding); + state={version:7,history,nextId,current,selectedParents,breeding}; storedHistoryIds=new Set(records.map(item=>item.id)); - if(saved?.version!==6||(!records.length&&history.length))saveState(); + 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:6,history,nextId:Math.max(1,...history.map(item=>item.id+1)),current:history.at(-1)||null,selectedParents:[]}; + 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}=state.current; - return{id,seed,genome,source,parents,inheritance,mutations}; + 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:6,nextId:state.nextId,current:persistableCurrent(),selectedParents:state.selectedParents,history:[]},'main'); + 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); @@ -305,15 +334,24 @@ const mutationRules={ 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 crossoverGenomes(parentA,parentB,seed){ +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=index===0||index>0&&randomFrom(seed,200+index)<.5; + const fromA=fromAKeys.has(key); child[key]=fromA?a[key]:b[key];inherited[fromA?'a':'b']++; } - if(inherited.b===0){const key=keys.at(-1);child[key]=b[key];inherited.a--;inherited.b++} - const mutations=[],roll=randomFrom(seed,280),mutationCount=roll<.8?0:roll<.98?1:2,used=new Set(); + const mutations=[],mutationCount=mutationCountFor(breeding.mutationLevel,randomFrom(seed,280)),used=new Set(); for(let index=0;index0){ @@ -338,7 +379,7 @@ function crossoverGenomes(parentA,parentB,seed){ } mutations.push(key); } - if(a.figureSides===0&&b.figureSides===0&&child.figureSides===0&&randomFrom(seed,330)<.09){ + if(a.figureSides===0&&b.figureSides===0&&child.figureSides===0&&randomFrom(seed,330)760){ left=356;right=width-28;top=82;bottom=height-112; }else{ - left=22;right=width-22;top=Math.max(270,ui.formula.getBoundingClientRect().bottom+14);bottom=height-112; + left=22;right=width-22;top=Math.max(270,ui.formula.getBoundingClientRect().bottom+14); + bottom=Math.min(height-112,document.querySelector('.action-bar').getBoundingClientRect().top-14); } if(right-left<160){left=22;right=width-22} if(bottom-top<150&&width>760){top=76;bottom=height-98} @@ -624,11 +666,11 @@ function downloadBlob(blob,filename){ } function exportFormula(){ if(!state.current)return; - const{id,seed,genome,parents,inheritance,mutations}=state.current; + 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 + 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'); @@ -782,9 +824,9 @@ function animateFormula(onComplete){ }; animationFrame=requestAnimationFrame(frame); } -function runtimeFormula({id,seed,genome,source,parents=[],inheritance=null,mutations=[]}){ +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,paths,bounds:geometryBounds(paths)}; + 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); @@ -796,9 +838,9 @@ function createFounderRecord(){ function ensureInitialPopulation(){ let initialized=false; if(state.current&&!state.history.some(item=>item.id===state.current.id)){ - const{id,seed,genome,source,parents=[],inheritance=null,mutations=[]}=state.current; + 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,createdAt:Date.now(), + id,seed,genome:normalizeGenome(genome),source,parents,inheritance,mutations,breeding,createdAt:Date.now(), thumbnail:renderFormulaThumbnail(genome,seed),thumbnailVersion:4 }); initialized=true; @@ -810,9 +852,9 @@ function ensureInitialPopulation(){ } function addCurrentToHistory(){ if(!state.current||state.history.some(item=>item.id===state.current.id))return; - const{id,seed,genome,source,parents,inheritance,mutations}=state.current; + const{id,seed,genome,source,parents,inheritance,mutations,breeding}=state.current; state.history.push({ - id,seed,genome:{...genome},source,parents,inheritance,mutations,createdAt:Date.now(), + id,seed,genome:{...genome},source,parents,inheritance,mutations,breeding,createdAt:Date.now(), thumbnail:renderFormulaThumbnail(genome,seed),thumbnailVersion:4 }); saveState();renderArchive(); @@ -837,10 +879,10 @@ function selectedRecords(){return state.selectedParents.map(id=>state.history.fi 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); + 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 + inheritance:result.inherited,mutations:result.mutations,breeding:result.breeding }); } function toggleParent(id){ @@ -887,8 +929,11 @@ function updateFormulaUI(){ 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}${mutations}`; + 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(); @@ -903,7 +948,7 @@ function updateSelectionUI(){ const crossLabel=ui.cross.querySelector('span'); if(selected.length===2){ crossLabel.textContent=`Cross ${formulaId(selected[0].id)} × ${formulaId(selected[1].id)}`; - document.querySelector('#action-kicker').textContent='gene-by-gene crossover · rare mutation'; + document.querySelector('#action-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'; @@ -915,6 +960,10 @@ function updateSelectionUI(){ 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'); @@ -1027,6 +1076,14 @@ document.querySelector('#formula-file').addEventListener('change',async event=>{ 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=>{ @@ -1042,6 +1099,7 @@ addEventListener('beforeunload',saveState); async function boot(){ await hydrateState(); + syncBreedingUI(); await refreshStorageStatus(); ensureInitialPopulation(); galleryOpen=innerWidth>=1180; diff --git a/index.html b/index.html index 4d62c09..68b796d 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ Fractalier — Mathematical Fractal Generator - +
@@ -97,6 +97,27 @@ selective generation · choose A and B Choose two parents from the Gallery +
+ + +
- + diff --git a/style.css b/style.css index 82b8a14..0c02413 100644 --- a/style.css +++ b/style.css @@ -97,6 +97,14 @@ nav .export-menu button{display:block;width:100%;border:0;text-align:left} .action-copy{min-width:0;padding-left:5px} .action-copy span{display:block;overflow:hidden;margin-bottom:4px;color:var(--muted);font:7px DM Mono,monospace;text-overflow:ellipsis;white-space:nowrap;text-transform:uppercase} .action-copy strong{display:block;overflow:hidden;font-size:10px;font-weight:500;text-overflow:ellipsis;white-space:nowrap} +.breeding-controls{display:flex;gap:6px;flex:0 0 auto} +.breeding-controls label{display:block} +.breeding-controls label>span{display:block;margin:0 0 4px;color:var(--muted);font:7px DM Mono,monospace;text-transform:uppercase} +.breeding-controls select{ + width:96px;height:29px;padding:0 22px 0 7px;border:1px solid var(--line);border-radius:0; + background:#0b1b17;color:#b6c6bc;cursor:pointer;font:8px DM Mono,monospace +} +.breeding-controls select:hover{border-color:var(--line-bright);color:var(--lime)} .action-buttons{display:flex;gap:7px;flex:0 0 auto} .action-buttons button{height:48px;border:0;cursor:pointer} .action-buttons button:disabled{cursor:wait;opacity:.58} @@ -133,15 +141,18 @@ footer span:last-child{text-align:right} .equation{margin-top:9px;padding:8px 0;font-size:7.5px} .equation span{overflow:hidden;text-overflow:ellipsis} .traits{gap:6px 8px;margin-top:8px} - .gallery-panel,.gallery-panel.open{top:72px;right:10px;bottom:112px;left:10px;width:auto} + .gallery-panel,.gallery-panel.open{top:72px;right:10px;bottom:158px;left:10px;width:auto} .archive{grid-template-columns:repeat(3,1fr)} .genome-preview{display:none!important} - .action-bar{right:10px;bottom:40px;left:10px;width:auto;padding:8px;transform:none} + .action-bar{right:10px;bottom:40px;left:10px;width:auto;flex-direction:column;gap:7px;padding:8px;transform:none} .action-copy{display:none} + .breeding-controls{width:100%} + .breeding-controls label{min-width:0;flex:1} + .breeding-controls select{width:100%} .action-buttons{width:100%} .action-buttons button{height:46px} .action-buttons .secondary,.action-buttons .primary{min-width:0;flex:1} - .toast{bottom:101px} + .toast{bottom:154px} footer{grid-template-columns:1fr auto;padding:0 10px} footer>span:nth-child(2){display:none} }