Add formula JSON import

This commit is contained in:
Emil
2026-07-29 22:40:20 +03:00
parent 4a3219f960
commit 7c8189e35c
2 changed files with 58 additions and 6 deletions
+54 -4
View File
@@ -125,11 +125,13 @@ async function hydrateState(){
const rawHistory=records.length?records:(source?.history||source?.population||[]); const rawHistory=records.length?records:(source?.history||source?.population||[]);
const history=rawHistory.map(normalizeRecord).sort((a,b)=>a.id-b.id); const history=rawHistory.map(normalizeRecord).sort((a,b)=>a.id-b.id);
const oldCurrent=source?.current; const oldCurrent=source?.current;
const currentHistoryRecord=oldCurrent?history.find(item=>item.id===Number(oldCurrent.id)):null;
const current=oldCurrent?.genome?{ const current=oldCurrent?.genome?{
id:Number(oldCurrent.id)||history.at(-1)?.id||1, id:Number(oldCurrent.id)||history.at(-1)?.id||1,
seed:Number(oldCurrent.seed)||randomSeed(), seed:Number(oldCurrent.seed)||randomSeed(),
genome:normalizeGenome(oldCurrent.genome), genome:normalizeGenome(oldCurrent.genome),
source:'restored',parents:oldCurrent.parents||[],inheritance:oldCurrent.inheritance||null,mutations:oldCurrent.mutations||[] source:oldCurrent.source&&oldCurrent.source!=='restored'?oldCurrent.source:currentHistoryRecord?.source||'restored',
parents:oldCurrent.parents||[],inheritance:oldCurrent.inheritance||null,mutations:oldCurrent.mutations||[]
}:history.length?{...history.at(-1),source:'restored'}: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 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 selectedParents=(source?.selectedParents||[]).filter(id=>history.some(item=>item.id===id)).slice(0,2);
@@ -300,7 +302,7 @@ const mutationRules={
figureSides:[1,0,10,true],figureSpan:[.18,.12,1],figureScale:[.12,.08,.55], figureSides:[1,0,10,true],figureSpan:[.18,.12,1],figureScale:[.12,.08,.55],
figureEvery:[1,1,4,true],figureSpin:[.3,-1.2,1.2], 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], curveMode:[1,0,4,true],curveAmplitude:[.14,0,.6],curveFrequency:[.5,.35,3.5],
rootLayout:[1,0,3,true],rootSpread:[.45,0,2.8],rootSpacing:[.045,.04,.24], 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] saturation:[12,48,92],lightness:[10,48,86],growthOverlap:[.16,.2,.85]
}; };
function crossoverGenomes(parentA,parentB,seed){ function crossoverGenomes(parentA,parentB,seed){
@@ -631,6 +633,44 @@ function exportFormula(){
downloadBlob(new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),`fractalier-${formulaId(id)}.json`); downloadBlob(new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),`fractalier-${formulaId(id)}.json`);
closeExportMenu();showToast('Formula JSON downloaded'); 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(){ async function exportVideo(){
if(!state.current||isBusy)return; if(!state.current||isBusy)return;
closeExportMenu(); closeExportMenu();
@@ -849,7 +889,8 @@ function updateFormulaUI(){
const inherited=c.inheritance?`${c.inheritance.a}/${c.inheritance.b} genes`:'mixed genes'; const inherited=c.inheritance?`${c.inheritance.a}/${c.inheritance.b} genes`:'mixed genes';
const mutations=c.mutations?.length?` · mutation: ${c.mutations.map(key=>geneLabels[key]||key).join(', ')}`:' · no mutation'; 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}${mutations}`;
}else lineage.textContent='founder formula · no parents'; }else if(c?.source==='import')lineage.textContent='imported formula · external genome';
else lineage.textContent='founder formula · no parents';
updateSelectionUI(); updateSelectionUI();
} }
function updateSelectionUI(){ function updateSelectionUI(){
@@ -888,7 +929,7 @@ function showArchivePreview(item,card){
document.querySelector('#preview-id').textContent=formulaId(item.id); document.querySelector('#preview-id').textContent=formulaId(item.id);
document.querySelector('#preview-meta').textContent=item.parents?.length===2 document.querySelector('#preview-meta').textContent=item.parents?.length===2
?`${formulaId(item.parents[0])} × ${formulaId(item.parents[1])} · ${familyName(item.genome.family)}` ?`${formulaId(item.parents[0])} × ${formulaId(item.parents[1])} · ${familyName(item.genome.family)}`
:`${familyName(item.genome.family)} · founder`; :`${familyName(item.genome.family)} · ${item.source==='import'?'imported':'founder'}`;
preview.classList.remove('hidden'); preview.classList.remove('hidden');
const panel=ui.gallery.getBoundingClientRect(),cardBox=card.getBoundingClientRect(),previewWidth=preview.offsetWidth; 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 left=panel.left-previewWidth-12>=12?panel.left-previewWidth-12:Math.min(innerWidth-previewWidth-12,panel.right+12);
@@ -977,6 +1018,15 @@ document.querySelector('#save-background').addEventListener('click',()=>download
document.querySelector('#save-transparent').addEventListener('click',()=>downloadImage(false)); document.querySelector('#save-transparent').addEventListener('click',()=>downloadImage(false));
document.querySelector('#save-video').addEventListener('click',exportVideo); document.querySelector('#save-video').addEventListener('click',exportVideo);
document.querySelector('#save-formula').addEventListener('click',exportFormula); 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.addEventListener('click',closeExportMenu); document.addEventListener('click',closeExportMenu);
document.addEventListener('pointerdown',requestPersistentStorage,{once:true,capture:true}); document.addEventListener('pointerdown',requestPersistentStorage,{once:true,capture:true});
document.addEventListener('keydown',event=>{ document.addEventListener('keydown',event=>{
+4 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#07100f" /> <meta name="theme-color" content="#07100f" />
<title>Fractalier — Mathematical Fractal Generator</title> <title>Fractalier — Mathematical Fractal Generator</title>
<link rel="stylesheet" href="style.css?v=35" /> <link rel="stylesheet" href="style.css?v=38" />
</head> </head>
<body> <body>
<main> <main>
@@ -28,6 +28,8 @@
<button id="save-transparent">PNG · transparent</button> <button id="save-transparent">PNG · transparent</button>
<button id="save-video">WebM · growth video</button> <button id="save-video">WebM · growth video</button>
<button id="save-formula">JSON · formula</button> <button id="save-formula">JSON · formula</button>
<button id="import-formula">Import · formula JSON</button>
<input id="formula-file" type="file" accept=".json,application/json" hidden />
</div> </div>
</div> </div>
</nav> </nav>
@@ -111,6 +113,6 @@
<span id="storage-status">checking local storage…</span> <span id="storage-status">checking local storage…</span>
</footer> </footer>
</main> </main>
<script src="app.js?v=35"></script> <script src="app.js?v=38"></script>
</body> </body>
</html> </html>