Add configurable crossover controls

This commit is contained in:
Emil
2026-07-29 22:48:28 +03:00
parent 7c8189e35c
commit 2f7315016c
3 changed files with 126 additions and 36 deletions
+89 -31
View File
@@ -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)<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,
@@ -68,6 +84,10 @@ function familyName(family){
}
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';
@@ -92,7 +112,8 @@ function normalizeRecord(item){
source:item?.source||'legacy',
parents:item?.parents||[],
inheritance:item?.inheritance||null,
mutations:item?.mutations||[]
mutations:item?.mutations||[],
breeding:item?.breeding?normalizeBreeding(item.breeding):null
};
}
@@ -131,29 +152,37 @@ async function hydrateState(){
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||[]
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);
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;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;
@@ -329,7 +367,10 @@ function crossoverGenomes(parentA,parentB,seed){
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 value=integer?child[key]+direction*amount:child[key]+direction*amount*(.45+randomFrom(seed,310+index)*.55);
}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){
@@ -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)<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);
@@ -346,20 +387,20 @@ function crossoverGenomes(parentA,parentB,seed){
child.figureSpin=range(seed,335,-.45,.45);
mutations.push('figureSides');
}
if(a.curveMode===0&&b.curveMode===0&&child.curveMode===0&&randomFrom(seed,340)<.07){
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)<.07){
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};
return{genome:child,inherited,mutations,breeding};
}
function trigonometricWave(mode,phase){
@@ -540,7 +581,8 @@ function updateView(){
}else if(width>760){
left=356;right=width-28;top=82;bottom=height-112;
}else{
left=22;right=width-22;top=Math.max(270,ui.formula.getBoundingClientRect().bottom+14);bottom=height-112;
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;
+23 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#07100f" />
<title>Fractalier — Mathematical Fractal Generator</title>
<link rel="stylesheet" href="style.css?v=38" />
<link rel="stylesheet" href="style.css?v=39" />
</head>
<body>
<main>
@@ -97,6 +97,27 @@
<span id="action-kicker">selective generation · choose A and B</span>
<strong id="action-title">Choose two parents from the Gallery</strong>
</div>
<div class="breeding-controls" aria-label="Crossover settings">
<label>
<span>inheritance</span>
<select id="gene-mix" aria-label="Genome inheritance proportion">
<option value="0.2">20% A · 80% B</option>
<option value="0.35">35% A · 65% B</option>
<option value="0.5" selected>50% A · 50% B</option>
<option value="0.65">65% A · 35% B</option>
<option value="0.8">80% A · 20% B</option>
</select>
</label>
<label>
<span>mutation</span>
<select id="mutation-level" aria-label="Mutation intensity">
<option value="0">Stable</option>
<option value="1" selected>Natural</option>
<option value="2">Active</option>
<option value="3">Volatile</option>
</select>
</label>
</div>
<div class="action-buttons">
<button class="primary" id="cross" disabled>
<span>Cross A × B</span>
@@ -113,6 +134,6 @@
<span id="storage-status">checking local storage…</span>
</footer>
</main>
<script src="app.js?v=38"></script>
<script src="app.js?v=39"></script>
</body>
</html>
+14 -3
View File
@@ -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}
}