import { createFallback } from './webgl'; type Engine={frame:(time:number,dt:number,shape:number,x:number,y:number,active:number,width:number,height:number,mode:number,still:boolean)=>boolean;particle_count:()=>number;state_bytes:()=>number;destroy:()=>void;free?:()=>void}; let engine:Engine|undefined; let canvas:HTMLCanvasElement; let loading=false,failed=false,paused=false,reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; let shape=0,mode=0,raf=0,last=0,elapsed=0,frameCount=0,dirty=true,snap=true; let px=0,py=0,active=0,backend='Preparing',backendDetail=''; let preferredBackend='auto'; let samples:number[]=[]; let sampleStarted=0,recording=false,recordSamples:number[]=[]; const reducedQuery=matchMedia('(prefers-reduced-motion: reduce)'); function routeShape(){return Number(document.body.dataset.shape)||0;} function size(){const dpr=Math.min(devicePixelRatio,innerWidth<650?1.25:1.5);const width=Math.max(1,Math.round(innerWidth*dpr));const height=Math.max(1,Math.round(innerHeight*dpr));if(canvas.width!==width)canvas.width=width;if(canvas.height!==height)canvas.height=height;return {width,height};} function updateText(){ document.querySelectorAll('[data-backend]').forEach(el=>el.textContent=backend); const debugSelect=document.querySelector('#view-select');if(debugSelect){debugSelect.disabled=backend!=='Rust / wgpu';debugSelect.title=backend==='Rust / wgpu'?'':'WebGL2 호환 경로에서는 사용할 수 없습니다';} const runtimeSelect=document.querySelector('#backend-select');if(runtimeSelect)runtimeSelect.value=preferredBackend; const ids:Record={'lab-backend':backendDetail,'lab-particles':engine?.particle_count().toLocaleString()??'—','lab-memory':engine?`${(engine.state_bytes()/1024).toFixed(0)} KiB`:'—','lab-frame':!engine?'—':paused||reduced?'정지':samples.length>10?`${percentile(samples,.5).toFixed(1)} ms / ${percentile(samples,.95).toFixed(1)} ms`:'수집 중','lab-viewport':canvas?`${canvas.width} × ${canvas.height}`:'—'}; for(const [id,value]of Object.entries(ids)){const el=document.getElementById(id);if(el)el.textContent=value;} const measure=document.querySelector('#measure');if(measure&&!recording)measure.disabled=!engine||paused||reduced; const save=document.querySelector('#save-frame');if(save)save.disabled=!engine; const motion=document.getElementById('motion-toggle');if(motion){(motion as HTMLButtonElement).disabled=!engine;motion.setAttribute('aria-pressed',String(paused||reduced));motion.setAttribute('aria-label',paused||reduced?'배경 움직임 재생':'배경 움직임 정지');const text=motion.querySelector('span');if(text)text.textContent=paused||reduced?'Play':'Pause';const icon=motion.querySelector('img');if(icon)icon.src=paused||reduced?'/icons/play.svg':'/icons/pause.svg';} if(canvas){canvas.dataset.backend=backend;canvas.dataset.shape=String(shape);canvas.dataset.frames=String(frameCount);canvas.dataset.paused=String(paused||reduced);} } function percentile(values:number[],q:number){const sorted=[...values].sort((a,b)=>a-b);return sorted[Math.min(sorted.length-1,Math.floor(sorted.length*q))]??0;} function schedule(){if(!raf&&!document.hidden&&engine&&!failed)raf=requestAnimationFrame(tick);} function tick(now:number){ raf=0;if(!engine||document.hidden||failed)return; const dt=last?Math.min((now-last)/1000,.033):1/60; if(last&&!paused&&!reduced){const interval=now-last;if(interval>0&&Number.isFinite(interval)){samples.push(interval);if(samples.length>180)samples.shift();if(recording)recordSamples.push(interval);}} last=now; if(!paused&&!reduced)elapsed+=dt; if(!paused&&!reduced||dirty){try{const {width,height}=size();const shown=engine.frame(elapsed,paused||reduced?0:dt,shape,px,py,paused||reduced?0:active,width,height,mode,snap||reduced);if(shown){frameCount++;canvas.dataset.frames=String(frameCount);dirty=false;snap=false;document.getElementById('field-stage')?.setAttribute('data-ready','true');}}catch(error){fail(error);return;}} if(frameCount%25===0||dirty)updateText(); if(recording&&now-sampleStarted>=10000)finishRecording(); if(!paused&&!reduced||dirty)schedule(); } function fail(error:unknown){console.error('[Field / Form]',error);failed=true;loading=false;backend='Static view';backendDetail='GPU 초기화 또는 실행 실패 · 본문은 계속 열람할 수 있습니다';if(raf)cancelAnimationFrame(raf);raf=0;try{engine?.destroy();engine?.free?.();}catch{}engine=undefined;document.getElementById('graphics-retry')?.removeAttribute('hidden');document.getElementById('field-stage')?.setAttribute('data-fallback','true');updateText();} async function boot(){ if(engine||loading)return; canvas=document.querySelector('#field')!;if(!canvas)return; if(preferredBackend==='static'){backend='Static view';backendDetail='정적 렌더 이미지 · GPU 실행 없음';document.getElementById('field-stage')?.setAttribute('data-fallback','true');updateText();return;} loading=true;failed=false;shape=routeShape();size(); const count=innerWidth<650?12288:32768; try{ if(preferredBackend==='webgl'||!navigator.gpu)throw new Error('WebGL2 compatibility path selected or WebGPU unavailable'); const wasm=await import('./wasm/field_form.js'); await wasm.default();engine=await wasm.FieldRenderer.create(canvas,count); backend='Rust / wgpu';backendDetail='Rust → WASM → wgpu 29 → WebGPU'; }catch(error){ console.info('[Field / Form] WebGPU unavailable; trying WebGL2.',String(error)); const replacement=canvas.cloneNode(false) as HTMLCanvasElement;canvas.replaceWith(replacement);canvas=replacement;size(); try{engine=createFallback(canvas,count);backend='WebGL2';backendDetail='WebGL2 · vertex morph fallback';}catch(fallbackError){fail(fallbackError);return;} } loading=false;canvas.dataset.instance=crypto.randomUUID();document.getElementById('graphics-retry')?.setAttribute('hidden','');document.getElementById('field-stage')?.removeAttribute('data-fallback');dirty=true;snap=true;last=0;updateText();schedule(); } function syncRoute(){shape=routeShape();mode=0;active=0;samples=[];recording=false;dirty=true;snap=paused||reduced;last=0;updateText();if(engine)schedule();else void boot();} function finishRecording(){recording=false;const el=document.getElementById('measurement-result');const button=document.getElementById('measure');if(button){button.textContent='10초 측정';button.removeAttribute('disabled');}if(el)el.textContent=`${recordSamples.length} frames · 중앙값 ${percentile(recordSamples,.5).toFixed(1)} ms · p95 ${percentile(recordSamples,.95).toFixed(1)} ms. rAF 프레임 간격이며 GPU 실행 시간이 아닙니다.`;} function invalidateMeasurement(reason:string){if(!recording)return;recording=false;recordSamples=[];const el=document.getElementById('measurement-result');if(el)el.textContent=`측정 취소: ${reason}`;const button=document.getElementById('measure');if(button){button.textContent='10초 측정';button.removeAttribute('disabled');}} // Listeners are installed once. The stage survives Astro client-side navigation. document.addEventListener('astro:page-load',syncRoute); document.addEventListener('astro:before-preparation',()=>invalidateMeasurement('페이지 이동')); document.addEventListener('click',event=>{ const target=event.target as HTMLElement; if(target.closest('#motion-toggle')){paused=!(paused||reduced);if(!paused)reduced=false;invalidateMeasurement('재생 상태 변경');dirty=true;last=0;updateText();schedule();} if(target.closest('#graphics-retry')){void boot();} if(target.closest('#save-frame')){const savedShape=shape;canvas.toBlob(blob=>{if(!blob)return;const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`sookie-field-form-${savedShape}.png`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);},'image/png');} if(target.closest('#measure')){if(!engine||paused||reduced)return;recording=true;recordSamples=[];sampleStarted=performance.now();const b=document.getElementById('measure')!;b.textContent='측정 중…';b.setAttribute('disabled','');const el=document.getElementById('measurement-result');if(el)el.textContent='같은 화면을 유지하며 10초 동안 수집합니다.';} }); document.addEventListener('change',event=>{const target=event.target as HTMLSelectElement;if(target.id==='shape-select'){shape=Number(target.value);document.body.dataset.shape=String(shape);dirty=true;snap=paused||reduced;samples=[];invalidateMeasurement('형상 변경');schedule();}if(target.id==='view-select'){mode=Number(target.value);dirty=true;schedule();}if(target.id==='backend-select'&&!loading){preferredBackend=target.value;samples=[];invalidateMeasurement('렌더 경로 변경');cancelAnimationFrame(raf);raf=0;try{engine?.destroy();engine?.free?.();}catch{}engine=undefined;const replacement=canvas.cloneNode(false) as HTMLCanvasElement;canvas.replaceWith(replacement);canvas=replacement;document.getElementById('field-stage')?.removeAttribute('data-ready');void boot();}}); window.addEventListener('pointermove',event=>{px=event.clientX/innerWidth*2-1;py=1-event.clientY/innerHeight*2;const el=event.target as HTMLElement;active=event.pointerType==='mouse'&&!el.closest('a,button,select,.story')?1:0;if(active&&!paused&&!reduced){dirty=true;schedule();}},{passive:true}); window.addEventListener('pointerout',event=>{if(!event.relatedTarget)active=0;},{passive:true}); window.addEventListener('resize',()=>{dirty=true;last=0;samples=[];invalidateMeasurement('화면 크기 변경');schedule();},{passive:true}); document.addEventListener('visibilitychange',()=>{last=0;samples=[];invalidateMeasurement('탭 표시 상태 변경');if(document.hidden){cancelAnimationFrame(raf);raf=0;}else{dirty=true;schedule();}}); reducedQuery.addEventListener('change',event=>{reduced=event.matches;dirty=true;snap=reduced;last=0;updateText();schedule();}); window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);raf=0;}); window.addEventListener('pageshow',()=>{last=0;dirty=true;schedule();}); void boot();