/* Browser-only preview of the selected saved track. No upload for preview. */ (function(root,factory){ if(typeof module==='object'&&module.exports)module.exports=factory(); else root.TrackMap=factory(); })(typeof window==='undefined'?this:window,function(){ const COLOURS=['#607e96','#b89561','#b9d9ed']; const NAMES=['Asphalt','Dirt','Ice']; const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/; function vector(value){ const match=typeof value==='string'&&VECTOR.exec(value); if(!match)throw Error('Track has invalid tile coordinates'); const x=Number(match[1]),y=Number(match[2]); if(!Number.isSafeInteger(x)||!Number.isSafeInteger(y)||Math.abs(x)>1000||Math.abs(y)>1000) throw Error('Track coordinates exceed preview bounds'); return [x,y]; } async function decode(file){ if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB'); if(typeof DecompressionStream!=='function')throw Error('This browser cannot preview gzip tracks'); const reader=file.stream().pipeThrough(new DecompressionStream('gzip')).getReader(); const chunks=[];let total=0; try{ while(true){ const {done,value}=await reader.read();if(done)break; total+=value.byteLength; if(total>2*1024*1024)throw Error('Track preview exceeds the 2 MB expanded limit'); chunks.push(value); } }finally{reader.releaseLock();} const bytes=new Uint8Array(total);let at=0; for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;} return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)); } function model(saved,atlas){ const entries=Object.entries(saved?.Tiles??{}); if(entries.length<1||entries.length>256)throw Error('Track has no previewable tiles'); const tiles=entries.map(([position,item])=>{ const [x,y]=vector(position); const id=item?.Block?.TileSetId; const block=atlas?.blocks?.[`${id?.AtlasId}:${id?.AlternativeId}`]; if(!block||![0,1,2].includes(block.surface))throw Error('Track uses a piece not in the simulator atlas'); return {x,y,surface:block.surface,connections:block.connections}; }); if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile'); const start=vector(saved.StartTileCoords); const minX=Math.min(...tiles.map(t=>t.x)),maxX=Math.max(...tiles.map(t=>t.x)); const minY=Math.min(...tiles.map(t=>t.y)),maxY=Math.max(...tiles.map(t=>t.y)); if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large'); return {tiles,start,bounds:{minX,maxX,minY,maxY}}; } function render(canvas,track){ const context=canvas.getContext('2d'); if(!context)throw Error('Canvas preview unavailable'); const w=canvas.width,h=canvas.height; context.clearRect(0,0,w,h); context.fillStyle='#0c1926';context.fillRect(0,0,w,h); const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1; const size=Math.min(160,(w-70)/columns,(h-70)/rows); const left=(w-columns*size)/2,top=(h-rows*size)/2; for(const tile of track.tiles){ const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size; context.fillStyle=COLOURS[tile.surface]; context.beginPath();context.roundRect(x+2,y+2,size-4,size-4,Math.max(4,size*.13));context.fill(); context.strokeStyle='#d7e8f2';context.lineWidth=Math.max(1,size*.018); context.stroke(); const edge=connection=>{ const offset=(connection.index-1)*size*.16; switch(connection.side){ case 0:return [x+size/2+offset,y+2]; case 1:return [x+size/2+offset,y+size-2]; case 2:return [x+2,y+size/2+offset]; case 3:return [x+size-2,y+size/2+offset]; default:return [x+size/2,y+size/2]; } }; if(Array.isArray(tile.connections)&&tile.connections.length>=2){ const a=edge(tile.connections[0]),z=edge(tile.connections[1]); context.beginPath();context.moveTo(...a); context.quadraticCurveTo(x+size/2,y+size/2,...z); context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055); context.lineCap='round';context.stroke(); } } const sx=left+(track.start[0]-b.minX+.5)*size,sy=top+(track.start[1]-b.minY+.5)*size; context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2); context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke(); } function mount({document,fetch,picker}){ const canvas=document.getElementById('trackMap'); const status=document.getElementById('mapStatus'); const legend=document.getElementById('mapLegend'); if(!canvas||!status||!picker)return; let version=0,atlasPromise=null; async function refresh(){ const current=++version; const info=picker.info(); const source=info.source==='builtin'?info.display_name:document.getElementById('track')?.files?.[0]?.name; if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;} status.textContent='Loading '+source+'…'; try{ atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{ if(!r.ok)throw Error('Track atlas unavailable');return r.json(); }); const [file,atlas]=await Promise.all([picker.file(),atlasPromise]); const track=model(await decode(file),atlas); if(current!==version)return; render(canvas,track); const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++); status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the start'; legend.textContent=NAMES.filter((_,i)=>counts[i]).map((name,index)=>{ const i=NAMES.indexOf(name);return name+' '+counts[i]; }).join(' · ')+' · Piece and terrain preview; collision shapes are not shown.'; }catch(error){ if(current!==version)return; renderEmpty();status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.'; } } function renderEmpty(){const ctx=canvas.getContext('2d');ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);} document.addEventListener('track-library-change',refresh); refresh(); return {refresh}; } return {mount,model,decode,render}; });