/* 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]; } function connection(value,message='Track has an invalid piece connection'){ if(!value||typeof value!=='object'||Array.isArray(value)|| !Number.isInteger(value.side)||value.side<0||value.side>3|| !Number.isInteger(value.index)||value.index<0||value.index>2|| !Number.isInteger(value.type)||value.type<0)throw Error(message); return {side:value.side,index:value.index,type:value.type}; } function connectionPoint(value,x,y,size){ const {side,index}=connection(value); // Native Connection.position uses offsets 128,384,640 in a 768-unit tile. // Shared boundaries keep adjacent path endpoints and the saved start aligned. const offset=[128,384,640][index]/768*size; switch(side){ case 0:return [x+offset,y]; case 1:return [x+offset,y+size]; case 2:return [x,y+offset]; case 3:return [x+size,y+offset]; } } function connectionControl(own,other,point,target){ // Native TrackUtils.GetPathCurve (random_track.rs, b90b8af): each // connection's inward normal sets its cubic handle, adjusted by port type. // At the exit this is the next tile's negated entry normal, so the same // per-tile construction also preserves the curve when endpoints reverse. const [nx,ny]=[[0,1],[0,-1],[1,0],[-1,0]][own.side]; const dx=target[0]-point[0],dy=target[1]-point[1]; const angle=own.type===1?Math.PI/4:own.type===3?Math.atan2(128,384):own.type===4?Math.atan2(384,128):0; const rotation=Math.sign(nx*dy-ny*dx)*angle; const c=Math.cos(rotation),s=Math.sin(rotation); const smoothness=[0,2,100].includes(own.type)&&[1,3,4].includes(other.type)?0.2:0.5; const reach=Math.hypot(dx,dy)*smoothness; return [point[0]+(nx*c-ny*s)*reach,point[1]+(nx*s+ny*c)*reach]; } 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'); if(!Array.isArray(block.connections)||block.connections.length!==2)throw Error('Track piece connections are unavailable'); return {x,y,surface:block.surface,connections:block.connections.map(value=>connection(value))}; }); if(new Set(tiles.map(t=>`${t.x},${t.y}`)).size!==tiles.length)throw Error('Duplicate track tile'); const start=vector(saved.StartTileCoords); const startTile=tiles.find(tile=>tile.x===start[0]&&tile.y===start[1]); if(!startTile)throw Error('Start tile is not present in this track'); const startConnection=connection({side:saved.StartTileConnection?.Side,index:saved.StartTileConnection?.Index,type:saved.StartTileConnection?.Type},'Track has an invalid or missing start connection'); if(!startTile.connections.some(item=>item.side===startConnection.side&&item.index===startConnection.index&&item.type===startConnection.type)) throw Error('Saved start connection does not match the start tile'); 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,startConnection,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(); if(Array.isArray(tile.connections)&&tile.connections.length>=2){ const [entry,exit]=tile.connections; const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size); const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a); context.beginPath();context.moveTo(...a); context.bezierCurveTo(...p1,...p2,...z); context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055); context.lineCap='round';context.stroke(); } } const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size; const [sx,sy]=connectionPoint(track.startConnection,startX,startY,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 saved starting point'; legend.textContent=NAMES.filter((_,i)=>counts[i]).map((name,index)=>{ const i=NAMES.indexOf(name);return name+' '+counts[i]; }).join(' · ')+' · Orange line follows the track centerline; collision shapes and car positions 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}; });