TiGa-RCE commited on
Commit
ff2b518
·
verified ·
1 Parent(s): fb32e2e

Render native track curves and recorded poses only

Browse files
Files changed (1) hide show
  1. track-map.js +180 -952
track-map.js CHANGED
@@ -1,11 +1,10 @@
1
- /* Browser-only preview and zero-compute telemetry replay of the selected saved track. No upload for preview. */
2
  (function(root,factory){
3
  if(typeof module==='object'&&module.exports)module.exports=factory(root);
4
  else root.TrackMap=factory(root);
5
  })(typeof window==='undefined'?this:window,function(root){
6
  const COLOURS=['#607e96','#b89561','#b9d9ed'];
7
  const NAMES=['Asphalt','Dirt','Ice'];
8
- const VEHICLE_NAMES=['Formula','Rally','Snowmobile','Truck'];
9
  const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
10
  function vector(value){
11
  const match=typeof value==='string'&&VECTOR.exec(value);
@@ -88,139 +87,6 @@
88
  if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
89
  return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
90
  }
91
-
92
- function cubicPoint(a,p1,p2,z,t){
93
- const u=1-t,uu=u*u,tt=t*t;
94
- return [
95
- uu*u*a[0]+3*uu*t*p1[0]+3*u*tt*p2[0]+tt*t*z[0],
96
- uu*u*a[1]+3*uu*t*p1[1]+3*u*tt*p2[1]+tt*t*z[1]
97
- ];
98
- }
99
- function cubicTangent(a,p1,p2,z,t){
100
- const u=1-t;
101
- const dx=3*u*u*(p1[0]-a[0])+6*u*t*(p2[0]-p1[0])+3*t*t*(z[0]-p2[0]);
102
- const dy=3*u*u*(p1[1]-a[1])+6*u*t*(p2[1]-p1[1])+3*t*t*(z[1]-p2[1]);
103
- const len=Math.hypot(dx,dy)||1;
104
- return [dx/len,dy/len];
105
- }
106
- function oppositeSide(side){return [1,0,3,2][side];}
107
- function neighborDelta(side){return [[0,-1],[0,1],[-1,0],[1,0]][side];}
108
-
109
- function buildTrackGeometry(track,w,h){
110
- const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
111
- const size=Math.min(160,(w-70)/columns,(h-70)/rows);
112
- const left=(w-columns*size)/2,top=(h-rows*size)/2;
113
- const roadHalfWidth=Math.max(7,size*0.215);
114
- const tileMap=new Map(track.tiles.map(t=>[`${t.x},${t.y}`,t]));
115
- const orderedSegments=[];
116
- const visited=new Set();
117
- let curTile=tileMap.get(`${track.start[0]},${track.start[1]}`);
118
- let entryConn=track.startConnection;
119
- while(curTile&&orderedSegments.length<track.tiles.length){
120
- const key=`${curTile.x},${curTile.y}`;
121
- if(visited.has(key))break;
122
- visited.add(key);
123
- const [c0,c1]=curTile.connections;
124
- const isFirst=c0.side===entryConn.side&&c0.index===entryConn.index;
125
- const enter=isFirst?c0:c1;
126
- const exit=isFirst?c1:c0;
127
- orderedSegments.push({tile:curTile,enter,exit});
128
- const [dx,dy]=neighborDelta(exit.side);
129
- const nextTile=tileMap.get(`${curTile.x+dx},${curTile.y+dy}`);
130
- if(!nextTile)break;
131
- const nextSide=oppositeSide(exit.side);
132
- const match=nextTile.connections.find(c=>c.side===nextSide&&c.index===exit.index);
133
- if(!match)break;
134
- curTile=nextTile;
135
- entryConn=match;
136
- }
137
- for(const tile of track.tiles){
138
- const key=`${tile.x},${tile.y}`;
139
- if(!visited.has(key)&&tile.connections.length>=2){
140
- orderedSegments.push({tile,enter:tile.connections[0],exit:tile.connections[1]});
141
- }
142
- }
143
- const rawSamples=[];
144
- const stepsPerTile=26;
145
- for(let sIdx=0;sIdx<orderedSegments.length;sIdx++){
146
- const {tile,enter,exit}=orderedSegments[sIdx];
147
- const tx=left+(tile.x-b.minX)*size,ty=top+(tile.y-b.minY)*size;
148
- const a=connectionPoint(enter,tx,ty,size),z=connectionPoint(exit,tx,ty,size);
149
- const p1=connectionControl(enter,exit,a,z),p2=connectionControl(exit,entryConnForControl(exit,enter),z,a);
150
- for(let i=0;i<(sIdx===orderedSegments.length-1?stepsPerTile+1:stepsPerTile);i++){
151
- const t=i/stepsPerTile;
152
- const [px,py]=cubicPoint(a,p1,p2,z,t);
153
- const [ux,uy]=cubicTangent(a,p1,p2,z,t);
154
- const nx=-uy,ny=ux;
155
- rawSamples.push({x:px,y:py,tx:ux,ty:uy,nx,ny,surface:tile.surface,tileIndex:sIdx});
156
- }
157
- }
158
- function entryConnForControl(_,enter){return enter;}
159
- if(rawSamples.length<2){
160
- const sx=left+(track.start[0]-b.minX)*size+size*0.5,sy=top+(track.start[1]-b.minY)*size+size*0.5;
161
- rawSamples.push({x:sx,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0},{x:sx+10,y:sy,tx:1,ty:0,nx:0,ny:1,surface:0,tileIndex:0});
162
- }
163
- let totalLength=0;
164
- rawSamples[0].s=0;
165
- for(let i=1;i<rawSamples.length;i++){
166
- const ds=Math.hypot(rawSamples[i].x-rawSamples[i-1].x,rawSamples[i].y-rawSamples[i-1].y);
167
- totalLength+=ds;
168
- rawSamples[i].s=totalLength;
169
- }
170
- totalLength=Math.max(1,totalLength);
171
- const n=rawSamples.length;
172
- const closed=Math.hypot(rawSamples[n-1].x-rawSamples[0].x,rawSamples[n-1].y-rawSamples[0].y)<size*0.45;
173
- for(let i=0;i<n;i++){
174
- const prev=rawSamples[closed?(i-2+n)%n:Math.max(0,i-2)];
175
- const next=rawSamples[closed?(i+2)%n:Math.min(n-1,i+2)];
176
- const a1=Math.atan2(prev.ty,prev.tx),a2=Math.atan2(next.ty,next.tx);
177
- let da=a2-a1;
178
- while(da>Math.PI)da-=Math.PI*2;
179
- while(da<-Math.PI)da+=Math.PI*2;
180
- const ds=Math.max(2,Math.hypot(next.x-prev.x,next.y-prev.y));
181
- const p=rawSamples[i];
182
- p.curvature=da/ds;
183
- p.heading=Math.atan2(p.ty,p.tx);
184
- p.u=p.s/totalLength;
185
- p.lx=p.x+p.nx*roadHalfWidth;
186
- p.ly=p.y+p.ny*roadHalfWidth;
187
- p.rx=p.x-p.nx*roadHalfWidth;
188
- p.ry=p.y-p.ny*roadHalfWidth;
189
- }
190
- return {size,left,top,columns,rows,roadHalfWidth,samples:rawSamples,totalLength,closed};
191
- }
192
-
193
- function sampleAtProgress(geom,u){
194
- const samples=geom.samples,n=samples.length;
195
- if(!n)return {x:0,y:0,tx:1,ty:0,nx:0,ny:1,heading:0,curvature:0,surface:0,u:0};
196
- let norm=geom.closed?((u%1)+1)%1:Math.max(0,Math.min(1,u));
197
- const targetS=norm*geom.totalLength;
198
- let lo=0,hi=n-1;
199
- while(lo<hi){
200
- const mid=(lo+hi)>>1;
201
- if(samples[mid].s<targetS)lo=mid+1;
202
- else hi=mid;
203
- }
204
- const i1=Math.min(n-1,lo),i0=Math.max(0,i1-1);
205
- const s0=samples[i0],s1=samples[i1];
206
- const span=Math.max(1e-5,s1.s-s0.s);
207
- const t=Math.max(0,Math.min(1,(targetS-s0.s)/span));
208
- const tx=s0.tx+(s1.tx-s0.tx)*t,ty=s0.ty+(s1.ty-s0.ty)*t;
209
- const tLen=Math.hypot(tx,ty)||1;
210
- const ux=tx/tLen,uy=ty/tLen;
211
- return {
212
- x:s0.x+(s1.x-s0.x)*t,
213
- y:s0.y+(s1.y-s0.y)*t,
214
- tx:ux,ty:uy,
215
- nx:-uy,ny:ux,
216
- heading:Math.atan2(uy,ux),
217
- curvature:s0.curvature+(s1.curvature-s0.curvature)*t,
218
- surface:t<0.5?s0.surface:s1.surface,
219
- u:norm,
220
- idx:i0
221
- };
222
- }
223
-
224
  function render(canvas,track){
225
  const context=canvas.getContext('2d');
226
  if(!context)throw Error('Canvas preview unavailable');
@@ -230,18 +96,6 @@
230
  const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
231
  const size=Math.min(160,(w-70)/columns,(h-70)/rows);
232
  const left=(w-columns*size)/2,top=(h-rows*size)/2;
233
-
234
- // Subtle CAD coordinate grid in background
235
- if(typeof context.save==='function'){
236
- context.save();
237
- context.strokeStyle='rgba(42,61,82,0.25)';
238
- context.lineWidth=1;
239
- const step=Math.max(24,Math.round(size*0.5));
240
- for(let gx=left%step;gx<w;gx+=step){context.beginPath();context.moveTo(gx,0);context.lineTo(gx,h);context.stroke();}
241
- for(let gy=top%step;gy<h;gy+=step){context.beginPath();context.moveTo(0,gy);context.lineTo(w,gy);context.stroke();}
242
- context.restore();
243
- }
244
-
245
  for(const tile of track.tiles){
246
  const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
247
  context.fillStyle=COLOURS[tile.surface];
@@ -252,142 +106,26 @@
252
  const [entry,exit]=tile.connections;
253
  const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
254
  const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
255
- // Road bed & wall corridor
256
- context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
257
- context.strokeStyle='#cbd7d9';context.lineWidth=Math.max(8,size*.46);context.lineCap='round';context.stroke();
258
- context.beginPath();context.moveTo(...a);context.bezierCurveTo(...p1,...p2,...z);
259
- context.strokeStyle=tile.surface===2?'#223c52':tile.surface===1?'#2e2720':'#162434';
260
- context.lineWidth=Math.max(6,size*.40);context.lineCap='round';context.stroke();
261
- // Original centerline
262
  context.beginPath();context.moveTo(...a);
263
  context.bezierCurveTo(...p1,...p2,...z);
264
  context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
265
  context.lineCap='round';context.stroke();
266
  }
267
  }
268
-
269
- // High-detail FIA curbs, sector gates, and start gantry when full 2D context is available
270
- const geom=buildTrackGeometry(track,w,h);
271
- track._geometry=geom;
272
- if(typeof context.save==='function'&&geom.samples.length>8){
273
- context.save();
274
- const hw=geom.roadHalfWidth;
275
- // FIA red/white apex curbs on sharp corners
276
- for(let i=1;i<geom.samples.length-1;i+=2){
277
- const s0=geom.samples[i-1],s1=geom.samples[i+1];
278
- if(Math.abs(s0.curvature)*size>0.65){
279
- const side=s0.curvature>0?-1:1;
280
- const ox0=s0.x+s0.nx*side*(hw*0.92),oy0=s0.y+s0.ny*side*(hw*0.92);
281
- const ox1=s1.x+s1.nx*side*(hw*0.92),oy1=s1.y+s1.ny*side*(hw*0.92);
282
- context.beginPath();context.moveTo(ox0,oy0);context.lineTo(ox1,oy1);
283
- context.strokeStyle=((i>>1)&1)?'#e05252':'#f0f6fc';
284
- context.lineWidth=Math.max(2,size*0.045);context.lineCap='butt';context.stroke();
285
- }
286
- }
287
- // Sector split markers (S1 / S2 / S3)
288
- for(const [frac,label] of [[0.333,'S2'],[0.667,'S3']]){
289
- const sp=sampleAtProgress(geom,frac);
290
- context.beginPath();
291
- context.moveTo(sp.x+sp.nx*hw*0.95,sp.y+sp.ny*hw*0.95);
292
- context.lineTo(sp.x-sp.nx*hw*0.95,sp.y-sp.ny*hw*0.95);
293
- context.strokeStyle='rgba(88,166,255,0.55)';context.lineWidth=1.5;context.stroke();
294
- if(typeof context.fillText==='function'){
295
- context.fillStyle='#8ec2ff';context.font='700 10px system-ui,sans-serif';
296
- context.fillText(label,sp.x+sp.nx*(hw+8)-6,sp.y+sp.ny*(hw+8)+3);
297
- }
298
- }
299
- // Checkered Start/Finish line at u=0
300
- const st=geom.samples[0];
301
- if(st){
302
- const checkers=6;
303
- for(let c=0;c<checkers;c++){
304
- const f0=-1+2*c/checkers,f1=-1+2*(c+1)/checkers;
305
- context.beginPath();
306
- context.moveTo(st.x+st.nx*hw*f0,st.y+st.ny*hw*f0);
307
- context.lineTo(st.x+st.nx*hw*f1,st.y+st.ny*hw*f1);
308
- context.strokeStyle=(c&1)?'#0c1926':'#f0f6fc';
309
- context.lineWidth=Math.max(3,size*0.055);context.lineCap='butt';context.stroke();
310
- }
311
- }
312
- context.restore();
313
- }
314
-
315
  const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
316
  const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
317
  context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
318
  context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
319
  }
320
-
321
- // Fast forward pass of an .evolution NeuralNetwork (<0.01 ms per frame)
322
- function evaluateNeuralNetwork(nn,inputs){
323
- if(!nn||!Array.isArray(nn.Weights)||!Array.isArray(nn.Biases))return null;
324
- let act=inputs.slice(0,nn.Shape?.[0]||inputs.length);
325
- while(act.length<(nn.Shape?.[0]||0))act.push(0);
326
- const layerActivations=[act];
327
- for(let l=0;l<nn.Weights.length;l++){
328
- const W=nn.Weights[l],B=nn.Biases[l];
329
- const next=new Array(B.length);
330
- for(let j=0;j<B.length;j++){
331
- let sum=B[j]||0;
332
- for(let i=0;i<act.length;i++)sum+=(act[i]||0)*(W[i]?.[j]||0);
333
- next[j]=Math.tanh(sum);
334
- }
335
- act=next;
336
- layerActivations.push(act);
337
- }
338
- return {outputs:act,layerActivations};
339
- }
340
-
341
- // Analytic raycast against track corridor walls near car position
342
- function castRayAgainstCorridor(geom,carX,carY,rayAngle,maxDistPx,centerIdx){
343
- const samples=geom.samples,n=samples.length;
344
- if(n<4)return {dist:maxDistPx,frac:1,hx:carX+Math.cos(rayAngle)*maxDistPx,hy:carY+Math.sin(rayAngle)*maxDistPx,hit:false};
345
- const dx=Math.cos(rayAngle),dy=Math.sin(rayAngle);
346
- let bestT=maxDistPx,hit=false;
347
- const windowSize=Math.min(n-1,56);
348
- const startIdx=geom.closed?(centerIdx-12+n)%n:Math.max(0,centerIdx-12);
349
- for(let k=0;k<windowSize;k++){
350
- const i0=geom.closed?(startIdx+k)%n:Math.min(n-2,startIdx+k);
351
- const i1=geom.closed?(i0+1)%n:i0+1;
352
- const s0=samples[i0],s1=samples[i1];
353
- for(const [ax,ay,bx,by] of [[s0.lx,s0.ly,s1.lx,s1.ly],[s0.rx,s0.ry,s1.rx,s1.ry]]){
354
- const sx=bx-ax,sy=by-ay;
355
- const det=dx*sy-dy*sx;
356
- if(Math.abs(det)<1e-6)continue;
357
- const qx=ax-carX,qy=ay-carY;
358
- const t=(qx*sy-qy*sx)/det;
359
- const u=(qx*dy-qy*dx)/det;
360
- if(t>2&&t<bestT&&u>=0&&u<=1){
361
- bestT=t;hit=true;
362
- }
363
- }
364
- }
365
- return {
366
- dist:bestT,
367
- frac:Math.max(0,Math.min(1,bestT/maxDistPx)),
368
- hx:carX+dx*bestT,
369
- hy:carY+dy*bestT,
370
- hit
371
- };
372
- }
373
-
374
- // Draw detailed 2D vehicle chassis with steerable front wheels, white shader outline, brake lights & boost flame
375
- function drawVehicle2D(ctx,x,y,heading,slipAngle,steerAngle,vehicleType,color,scale,showOutline,braking,boosting,active){
376
  ctx.save();
377
  ctx.translate(x,y);
378
- ctx.rotate(heading+slipAngle);
379
  ctx.scale(scale,scale);
380
  if(!active)ctx.globalAlpha=0.55;
381
 
382
- const wheelSteer=Math.max(-0.48,Math.min(0.48,steerAngle*0.42));
383
- // Boost exhaust flame
384
- if(boosting&&active){
385
- ctx.fillStyle='#ff9a3c';
386
- ctx.beginPath();ctx.moveTo(-14,-3);ctx.lineTo(-24,0);ctx.lineTo(-14,3);ctx.closePath();ctx.fill();
387
- ctx.fillStyle='#ffd073';
388
- ctx.beginPath();ctx.moveTo(-14,-1.6);ctx.lineTo(-19.5,0);ctx.lineTo(-14,1.6);ctx.closePath();ctx.fill();
389
- }
390
-
391
  // Draw wheels / skis
392
  ctx.fillStyle='#101820';
393
  ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
@@ -461,16 +199,7 @@
461
  ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
462
  }
463
 
464
- // Brake light glow
465
- if(braking&&active){
466
- ctx.fillStyle='#ff4d4d';
467
- ctx.shadowColor='#ff4d4d';ctx.shadowBlur=6;
468
- ctx.fillRect(-13.8,-5.2,1.8,3.2);
469
- ctx.fillRect(-13.8,2.0,1.8,3.2);
470
- ctx.shadowBlur=0;
471
- }
472
-
473
- // Eliminated / crashed X badge over car
474
  if(!active){
475
  ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
476
  ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
@@ -478,721 +207,220 @@
478
  ctx.restore();
479
  }
480
 
481
- function buildCandidateProfile(spec,geom,maxTicks){
482
- const ticks=Math.max(60,Math.min(18000,spec.ticksElapsed||maxTicks||900));
483
- const lapSec=spec.bestLapSeconds&&spec.bestLapSeconds>0?spec.bestLapSeconds:null;
484
- const laps=spec.forwardLaps||0;
485
- const raw=spec.rawMetrics||{};
486
- const driftFactor=raw.total_drifting_amount!=null?Math.min(1.4,Math.max(0.15,raw.total_drifting_amount/Math.max(1,ticks*0.25))):0.35;
487
- const apexEff=raw.total_racing_line_efficiency!=null?Math.min(1,Math.max(0.2,raw.total_racing_line_efficiency/Math.max(1,ticks*0.5))):0.68;
488
- const contacts=spec.physicalContacts??0;
489
- const active=spec.active!==false;
490
 
491
- // Determine how many laps/distance the car covers over maxTicks
492
- let totalProgress=0.82;
493
- if(lapSec){
494
- const lapTicks=Math.max(90,lapSec*60);
495
- totalProgress=Math.max(1.05,ticks/lapTicks);
496
- }else if(spec.totalDistance&&spec.totalDistance>0){
497
- totalProgress=Math.max(0.18,Math.min(3.5,spec.totalDistance/(geom.totalLength*3.1)));
498
- }else if(laps>0){
499
- totalProgress=Math.max(laps+0.15,1.1);
500
- }
501
- const phaseSeed=(spec.carIndex||0)*0.6180339887;
502
- return {
503
- id:spec.id||'primary',
504
- label:spec.label||'Active Driver',
505
- role:spec.role||'primary',
506
- color:spec.color||'#62dfb1',
507
- vehicleType:Number.isInteger(spec.vehicleType)?spec.vehicleType:0,
508
- network:spec.network||null,
509
- ticksElapsed:ticks,
510
- bestLapSeconds:lapSec,
511
- forwardLaps:laps,
512
- physicalContacts:contacts,
513
- active,
514
- driftFactor,
515
- apexEff,
516
- totalProgress,
517
- phaseSeed
518
- };
519
  }
520
-
521
- function evaluateCarAtTick(profile,geom,tick,maxTicks){
522
- const clampedTick=Math.min(tick,profile.ticksElapsed);
523
- const alive=tick<=profile.ticksElapsed||profile.active;
524
- const frac=clampedTick/Math.max(1,profile.ticksElapsed);
525
- // Modulate progress slightly by cornering slowdowns so cars brake into sharp turns and accelerate on straights
526
- const baseU=frac*profile.totalProgress;
527
- const sCenter=sampleAtProgress(geom,baseU);
528
- const nextCorner=sampleAtProgress(geom,baseU+0.032);
529
- const cornerSeverity=Math.min(1,Math.abs(sCenter.curvature)*geom.size*1.15);
530
- const approachSeverity=Math.min(1,Math.abs(nextCorner.curvature)*geom.size*1.25);
531
-
532
- // Apex cutting offset: shift toward inside of upcoming/current turn
533
- const turnSign=Math.sign(sCenter.curvature*0.65+nextCorner.curvature*0.35)||0;
534
- const lateralTarget=-turnSign*geom.roadHalfWidth*0.58*profile.apexEff*Math.min(1,(cornerSeverity+approachSeverity)*0.9);
535
- const wobble=Math.sin(baseU*Math.PI*6+profile.phaseSeed*6.28)*geom.roadHalfWidth*0.08*(1-profile.apexEff);
536
- const latOffset=Math.max(-geom.roadHalfWidth*0.76,Math.min(geom.roadHalfWidth*0.76,lateralTarget+wobble));
537
-
538
- const x=sCenter.x+sCenter.nx*latOffset;
539
- const y=sCenter.y+sCenter.ny*latOffset;
540
-
541
- // Speed & control state
542
- const surfaceGrip=sCenter.surface===2?0.62:sCenter.surface===1?0.78:1.0;
543
- const braking=approachSeverity>0.48&& approachSeverity>cornerSeverity*0.85;
544
- const speedNorm=Math.max(0.24,Math.min(1.0,1.0-approachSeverity*0.42-cornerSeverity*0.28*(2-surfaceGrip)));
545
- const speedKmh=alive?Math.round(75+speedNorm*185):0;
546
- const slipAngle=alive?(-turnSign*cornerSeverity*0.38*profile.driftFactor*(1.35-surfaceGrip*0.5)):0;
547
- const steerCmd=alive?Math.max(-1,Math.min(1,sCenter.curvature*geom.size*1.45)):0;
548
- const throttleCmd=alive?(braking?0.18:Math.min(1,0.55+speedNorm*0.45)):0;
549
- const brakeCmd=alive&&braking?Math.min(1,(approachSeverity-0.35)*1.4):0;
550
- const hbrakeCmd=alive&&Math.abs(slipAngle)>0.18?Math.min(1,Math.abs(slipAngle)*2.2):0;
551
- const boostCmd=alive&&!braking&&cornerSeverity<0.18&&speedNorm>0.82?0.9:0;
552
-
553
- return {
554
- x,y,
555
- heading:sCenter.heading,
556
- curvature:sCenter.curvature,
557
- surface:sCenter.surface,
558
- u:sCenter.u,
559
- lap:Math.floor(baseU)+1,
560
- idx:sCenter.idx||0,
561
- alive,
562
- speedNorm,
563
- speedKmh,
564
- slipAngle,
565
- braking,
566
- boosting:boostCmd>0.5,
567
- controls:{steer:steerCmd,throttle:throttleCmd,brake:brakeCmd,hbrake:hbrakeCmd,boost:boostCmd}
568
- };
569
  }
570
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  function mount({document,fetch,picker}){
572
- const canvas=document.getElementById('trackMap');
573
- const status=document.getElementById('mapStatus');
574
- const legend=document.getElementById('mapLegend');
575
- if(!canvas||!status||!picker)return;
576
- let version=0,atlasPromise=null;
577
- let currentTrack=null,currentSourceName='',staticLayer=null;
578
- let animId=null,playing=true,playSpeed=1,cameraMode='full',focusMode='winners';
579
- let currentTick=0,maxTicks=900,lastFrameTime=0;
580
- let stagedNet=null,analysisState=null,receiptState=null;
581
- let profiles=[],swarmProfiles=[],speedRibbon=[],skidSegments=[],contactMarkers=[];
582
- const layers={outline:true,rays:true,line:true,skids:true,hud:true};
583
-
584
- function createOffscreen(w,h){
585
- if(typeof document.createElement==='function'){
586
- const c=document.createElement('canvas');
587
- if(c&&typeof c.getContext==='function'){c.width=w;c.height=h;return c;}
588
- }
589
- return null;
590
- }
591
-
592
- function rebuildStaticLayer(){
593
- if(!currentTrack)return;
594
- const off=createOffscreen(canvas.width,canvas.height);
595
- if(off){
596
- render(off,currentTrack);
597
- staticLayer=off;
598
  }else{
599
- render(canvas,currentTrack);
600
- staticLayer=null;
601
  }
602
  }
603
-
604
- function rebuildProfiles(){
605
- if(!currentTrack||!currentTrack._geometry)return;
606
- const geom=currentTrack._geometry;
607
- const reqTicks=analysisState?.summary?.conditions?.requested_ticks||receiptState?.report?.requested_ticks||Number(document.getElementById('training_ticks')?.value)||900;
608
- maxTicks=Math.max(60,Math.min(18000,reqTicks));
609
- const scrub=document.getElementById('replay_scrub');
610
- if(scrub)scrub.max=String(maxTicks);
611
- if(currentTick>maxTicks)currentTick=0;
612
-
613
- const vType=analysisState?.summary?.conditions?.vehicle_type??receiptState?.report?.vehicle_type??stagedNet?.Type??Number(document.getElementById('starter_vehicle')?.value||0);
614
- profiles=[];
615
- swarmProfiles=[];
616
-
617
- if(analysisState?.summary?.mode==='train'&&analysisState.summary.winners){
618
- const roleMeta=[
619
- ['fitness','Fitness Leader','#62dfb1'],
620
- ['fastest_lap','Fastest Lap','#ffd073'],
621
- ['clean_laps','Clean Laps','#58a6ff']
622
- ];
623
- for(const [role,label,color] of roleMeta){
624
- const w=analysisState.summary.winners[role];
625
- if(w&&w.status==='available'){
626
- const row=analysisState.descriptorRows?.find(r=>r.candidate_id===w.candidate_id);
627
- profiles.push(buildCandidateProfile({
628
- id:w.candidate_id,
629
- role,
630
- label:`${label} (${w.candidate_id})`,
631
- color,
632
- vehicleType:vType,
633
- network:analysisState.winnerNetworks?.[role]?.decoded||stagedNet,
634
- ticksElapsed:row?.ticks_elapsed||maxTicks,
635
- bestLapSeconds:w.best_lap_seconds,
636
- forwardLaps:w.forward_laps,
637
- physicalContacts:w.physical_contacts,
638
- totalDistance:w.total_actual_distance,
639
- active:row?.objectives?.active??true,
640
- rawMetrics:row?.raw_metrics,
641
- carIndex:w.car
642
- },geom,maxTicks));
643
- }
644
- }
645
- // Build population swarm for the selected generation in descriptor_generation (up to 20 cars)
646
- if(Array.isArray(analysisState.descriptorRows)&&analysisState.descriptorRows.length){
647
- const targetGen=Number(document.getElementById('descriptor_generation')?.value)||analysisState.summary.observations?.generations||1;
648
- const genRows=analysisState.descriptorRows.filter(r=>r.generation===targetGen).slice(0,20);
649
- const palette=['#62dfb1','#ffd073','#58a6ff','#c792ea','#ff8b7b','#7ce4c3'];
650
- genRows.forEach((row,idx)=>{
651
- swarmProfiles.push(buildCandidateProfile({
652
- id:row.candidate_id,
653
- role:'swarm',
654
- label:`Gen ${row.generation} · Car #${row.car}`,
655
- color:palette[idx%palette.length],
656
- vehicleType:vType,
657
- network:stagedNet,
658
- ticksElapsed:row.ticks_elapsed||maxTicks,
659
- bestLapSeconds:row.objectives?.best_lap_seconds,
660
- forwardLaps:row.objectives?.forward_laps,
661
- physicalContacts:row.objectives?.physical_contacts,
662
- totalDistance:row.objectives?.total_actual_distance,
663
- active:row.objectives?.active,
664
- rawMetrics:row.raw_metrics,
665
- carIndex:row.car
666
- },geom,maxTicks));
667
- });
668
- }
669
- }else if(analysisState?.summary?.mode==='evaluate'&&analysisState.evaluation){
670
- const m=analysisState.evaluation.measurements;
671
- profiles.push(buildCandidateProfile({
672
- id:m.candidate_id||'eval',
673
- role:'evaluate',
674
- label:'Frozen Evaluation Policy',
675
- color:'#62dfb1',
676
- vehicleType:vType,
677
- network:stagedNet,
678
- ticksElapsed:m.ticks_elapsed||maxTicks,
679
- bestLapSeconds:m.objectives?.best_lap_seconds,
680
- forwardLaps:m.objectives?.forward_laps,
681
- physicalContacts:m.objectives?.physical_contacts,
682
- totalDistance:m.objectives?.total_actual_distance,
683
- active:m.objectives?.active,
684
- rawMetrics:m.raw_metrics,
685
- carIndex:0
686
- },geom,maxTicks));
687
- }else if(receiptState?.report){
688
- const r=receiptState.report;
689
- profiles.push(buildCandidateProfile({
690
- id:'receipt',
691
- role:'fitness',
692
- label:`Run Candidate (${VEHICLE_NAMES[vType]||'Formula'})`,
693
- color:'#62dfb1',
694
- vehicleType:vType,
695
- network:stagedNet,
696
- ticksElapsed:r.simulated_ticks||maxTicks,
697
- bestLapSeconds:r.best_lap_seconds,
698
- forwardLaps:r.best_lap_seconds?1:0,
699
- physicalContacts:0,
700
- totalDistance:null,
701
- active:true,
702
- carIndex:0
703
- },geom,maxTicks));
704
- }
705
-
706
- if(!profiles.length){
707
- profiles.push(buildCandidateProfile({
708
- id:'staged',
709
- role:'staged',
710
- label:stagedNet?.Name?`Staged · ${stagedNet.Name}`:`Preview · ${VEHICLE_NAMES[vType]||'Formula'} Starter`,
711
- color:'#62dfb1',
712
- vehicleType:vType,
713
- network:stagedNet,
714
- ticksElapsed:maxTicks,
715
- bestLapSeconds:11.8,
716
- forwardLaps:1,
717
- physicalContacts:0,
718
- active:true,
719
- carIndex:0
720
- },geom,maxTicks));
721
- }
722
-
723
- // Precompute speed-heatmap ribbon, skid segments, and wall contact markers for the lead profile (zero per-frame path math)
724
- const lead=getActiveProfiles()[0]||profiles[0];
725
- speedRibbon=[];
726
- skidSegments=[];
727
- contactMarkers=[];
728
- const ribbonSteps=140;
729
- for(let i=0;i<=ribbonSteps;i++){
730
- const st=evaluateCarAtTick(lead,geom,(i/ribbonSteps)*lead.ticksElapsed,maxTicks);
731
- const col=st.braking?'#ff6b6b':st.speedNorm>0.76?'#62dfb1':'#ffd073';
732
- speedRibbon.push({x:st.x,y:st.y,color:col,slip:st.slipAngle,heading:st.heading});
733
- if(Math.abs(st.slipAngle)>0.11&&i>0){
734
- const prev=speedRibbon[i-1];
735
- skidSegments.push({x0:prev.x,y0:prev.y,x1:st.x,y1:st.y,nx:-Math.sin(st.heading),ny:Math.cos(st.heading)});
736
- }
737
- }
738
- const totalContacts=lead.physicalContacts||(lead.active?0:1);
739
- if(totalContacts>0){
740
- // Place contact markers at sharpest corners or terminal point
741
- const sortedCorners=geom.samples.map((s,idx)=>({s,idx,k:Math.abs(s.curvature)})).sort((a,b)=>b.k-a.k);
742
- for(let c=0;c<Math.min(6,totalContacts);c++){
743
- const pick=sortedCorners[c*4]||sortedCorners[0];
744
- if(pick){
745
- const side=pick.s.curvature>0?1:-1;
746
- contactMarkers.push({
747
- x:pick.s.x+pick.s.nx*side*geom.roadHalfWidth*0.92,
748
- y:pick.s.y+pick.s.ny*side*geom.roadHalfWidth*0.92,
749
- label:`Contact #${c+1}`
750
- });
751
- }
752
- }
753
- }
754
- if(!lead.active){
755
- const term=evaluateCarAtTick(lead,geom,lead.ticksElapsed,maxTicks);
756
- contactMarkers.push({x:term.x,y:term.y,label:`Eliminated @ ${lead.ticksElapsed}t`,terminal:true});
757
- }
758
- }
759
-
760
- function getActiveProfiles(){
761
- if(focusMode==='swarm'&&swarmProfiles.length)return swarmProfiles;
762
- if(focusMode==='fitness'||focusMode==='fastest_lap'||focusMode==='clean_laps'){
763
- const found=profiles.find(p=>p.role===focusMode);
764
- if(found)return [found];
765
- }
766
- return profiles;
767
  }
768
-
769
- function drawFrame(){
770
- if(!currentTrack||!currentTrack._geometry)return;
771
- const ctx=canvas.getContext('2d');
772
- if(!ctx||typeof ctx.save!=='function')return;
773
- const w=canvas.width,h=canvas.height;
774
- const geom=currentTrack._geometry;
775
- const activeList=getActiveProfiles();
776
- const lead=activeList[0]||profiles[0];
777
- if(!lead)return;
778
- const leadState=evaluateCarAtTick(lead,geom,currentTick,maxTicks);
779
-
780
- ctx.clearRect(0,0,w,h);
781
- ctx.fillStyle='#0c1926';
782
- ctx.fillRect(0,0,w,h);
783
-
784
  ctx.save();
785
- if(cameraMode==='follow'){
786
- const zoom=2.15;
787
- const lookX=leadState.x+Math.cos(leadState.heading)*28;
788
- const lookY=leadState.y+Math.sin(leadState.heading)*28;
789
- ctx.translate(w*0.5,h*0.5);
790
- ctx.scale(zoom,zoom);
791
- ctx.translate(-lookX,-lookY);
792
- }
793
-
794
- if(staticLayer)ctx.drawImage(staticLayer,0,0);
795
- else render(canvas,currentTrack);
796
-
797
- // 1. Tire skid marks in high-slip drift zones
798
- if(layers.skids&&skidSegments.length){
799
- ctx.save();
800
- ctx.strokeStyle='rgba(12,18,26,0.58)';
801
- ctx.lineWidth=Math.max(1.4,geom.size*0.022);
802
- const trackHalf=Math.max(3,geom.size*0.055);
803
- for(const seg of skidSegments){
804
- for(const s of [-1,1]){
805
- ctx.beginPath();
806
- ctx.moveTo(seg.x0+seg.nx*s*trackHalf,seg.y0+seg.ny*s*trackHalf);
807
- ctx.lineTo(seg.x1+seg.nx*s*trackHalf,seg.y1+seg.ny*s*trackHalf);
808
- ctx.stroke();
809
- }
810
- }
811
- ctx.restore();
812
- }
813
-
814
- // 2. Speed-colored racing line heatmap
815
- if(layers.line&&speedRibbon.length>1){
816
- ctx.save();
817
- ctx.lineWidth=Math.max(2.6,geom.size*0.048);
818
- ctx.lineCap='round';
819
- ctx.globalAlpha=0.82;
820
- for(let i=1;i<speedRibbon.length;i++){
821
- const p0=speedRibbon[i-1],p1=speedRibbon[i];
822
- ctx.beginPath();ctx.moveTo(p0.x,p0.y);ctx.lineTo(p1.x,p1.y);
823
- ctx.strokeStyle=p1.color;ctx.stroke();
824
- }
825
- ctx.restore();
826
  }
827
-
828
- // 3. Collision & Elimination markers (matching r40 collision marker visual language)
829
- if(contactMarkers.length){
830
- ctx.save();
831
- for(const m of contactMarkers){
832
- ctx.beginPath();ctx.arc(m.x,m.y,m.terminal?9:6.5,0,Math.PI*2);
833
- ctx.fillStyle=m.terminal?'rgba(255,107,107,0.28)':'rgba(255,208,115,0.24)';
834
- ctx.fill();
835
- ctx.strokeStyle=m.terminal?'#ff6b6b':'#ffd073';
836
- ctx.lineWidth=2;
837
  ctx.beginPath();
838
- ctx.moveTo(m.x-4.5,m.y-4.5);ctx.lineTo(m.x+4.5,m.y+4.5);
839
- ctx.moveTo(m.x+4.5,m.y-4.5);ctx.lineTo(m.x-4.5,m.y+4.5);
840
- ctx.stroke();
841
- }
842
- ctx.restore();
843
- }
844
-
845
- // 4. Raycast Sensor Fan on the lead vehicle (using actual .evolution Sensors or default 7-ray fan)
846
- const netObj=lead.network||stagedNet;
847
- const sensorDefs=Array.isArray(netObj?.Sensors)&&netObj.Sensors.some(s=>s.$type==='raycast')
848
- ?netObj.Sensors
849
- :[
850
- {$type:'speed'},
851
- {$type:'raycast',Degrees:-75,Length:750},
852
- {$type:'raycast',Degrees:-42,Length:850},
853
- {$type:'raycast',Degrees:-18,Length:950},
854
- {$type:'raycast',Degrees:0,Length:1100},
855
- {$type:'raycast',Degrees:18,Length:950},
856
- {$type:'raycast',Degrees:42,Length:850},
857
- {$type:'raycast',Degrees:75,Length:750}
858
- ];
859
-
860
- const sensorInputs=[];
861
- const pxPerSimUnit=Math.max(0.08,geom.size/768);
862
- for(const s of sensorDefs){
863
- if(s.$type==='raycast'){
864
- const rad=leadState.heading+(s.Degrees||0)*Math.PI/180;
865
- const maxPx=Math.max(28,Math.min(260,(s.Length||800)*pxPerSimUnit*1.6));
866
- const hit=castRayAgainstCorridor(geom,leadState.x,leadState.y,rad,maxPx,leadState.idx);
867
- sensorInputs.push(hit.frac*2-1);
868
- if(layers.rays&&leadState.alive){
869
- ctx.save();
870
- const beamCol=hit.frac<0.32?'#ff6b6b':hit.frac<0.62?'#ffd073':'#62dfb1';
871
- ctx.strokeStyle=beamCol;
872
- ctx.globalAlpha=0.52;
873
- ctx.lineWidth=1.35;
874
- ctx.beginPath();ctx.moveTo(leadState.x,leadState.y);ctx.lineTo(hit.hx,hit.hy);ctx.stroke();
875
- ctx.globalAlpha=0.95;
876
- ctx.fillStyle=beamCol;
877
- ctx.beginPath();ctx.arc(hit.hx,hit.hy,2.6,0,Math.PI*2);ctx.fill();
878
- ctx.restore();
879
- }
880
- }else if(s.$type==='speed'){
881
- sensorInputs.push(leadState.speedNorm);
882
- }else if(s.$type==='velocitySide'||s.$type==='angularVelocity'){
883
- sensorInputs.push(leadState.slipAngle*2);
884
- }else if(s.$type==='trackCurvature'){
885
- sensorInputs.push(Math.max(-1,Math.min(1,leadState.curvature*geom.size)));
886
- }else{
887
- sensorInputs.push(leadState.speedNorm*0.7);
888
- }
889
- }
890
-
891
- // Evaluate actual neural network forward pass if available (<0.01ms)
892
- const nnEval=netObj?.NeuralNetwork?evaluateNeuralNetwork(netObj.NeuralNetwork,sensorInputs):null;
893
- if(nnEval&&Array.isArray(netObj?.Outputs)&&leadState.alive){
894
- const outIds=netObj.Outputs;
895
- const idxSteer=outIds.indexOf(0),idxAccel=outIds.indexOf(1),idxHbk=outIds.indexOf(2),idxBrk=outIds.indexOf(3),idxBst=outIds.indexOf(4);
896
- if(idxSteer>=0)leadState.controls.steer=leadState.controls.steer*0.55+nnEval.outputs[idxSteer]*0.45;
897
- if(idxAccel>=0)leadState.controls.throttle=Math.max(0,(nnEval.outputs[idxAccel]+1)*0.5);
898
- if(idxBrk>=0)leadState.controls.brake=Math.max(0,nnEval.outputs[idxBrk]);
899
- if(idxHbk>=0)leadState.controls.hbrake=Math.max(0,nnEval.outputs[idxHbk]);
900
- if(idxBst>=0)leadState.controls.boost=Math.max(0,nnEval.outputs[idxBst]);
901
- }
902
-
903
- // 5. Draw all active ghost/swarm cars back-to-front so lead car is on top
904
- const carScale=Math.max(0.68,Math.min(1.38,geom.size/64));
905
- for(let i=activeList.length-1;i>=0;i--){
906
- const prof=activeList[i];
907
- const st=i===0?leadState:evaluateCarAtTick(prof,geom,currentTick,maxTicks);
908
- drawVehicle2D(
909
- ctx,st.x,st.y,st.heading,st.slipAngle,st.controls.steer,
910
- prof.vehicleType,prof.color,carScale*(i===0?1.06:0.92),
911
- layers.outline,st.braking,st.boosting,st.alive
912
- );
913
- // Floating badge above ghost cars in multi-car race
914
- if(activeList.length>1&&activeList.length<=4&&typeof ctx.fillText==='function'){
915
- ctx.save();
916
- ctx.font='700 10px system-ui,sans-serif';
917
- ctx.fillStyle=prof.color;
918
- ctx.strokeStyle='#08111c';ctx.lineWidth=2.5;
919
- const tag=prof.role==='fitness'?'FIT':prof.role==='fastest_lap'?'LAP':prof.role==='clean_laps'?'CLN':`#${i+1}`;
920
- ctx.strokeText(tag,st.x-10,st.y-14*carScale);
921
- ctx.fillText(tag,st.x-10,st.y-14*carScale);
922
- ctx.restore();
923
  }
 
 
924
  }
925
  ctx.restore();
926
-
927
- // 6. Screen-Space Telemetry & Ghost Leaderboard HUD Overlay
928
- if(layers.hud&&typeof ctx.fillText==='function'){
929
- drawHudOverlay(ctx,w,h,lead,leadState,activeList,nnEval);
930
- }
931
-
932
- // Sync timeline scrubber label
933
- const timeLabel=document.getElementById('replay_time_label');
934
- if(timeLabel){
935
- const sec=(currentTick/60).toFixed(2);
936
- const maxSec=(maxTicks/60).toFixed(1);
937
- timeLabel.textContent=`${sec}s / ${maxSec}s · Tick ${Math.round(currentTick)} / ${maxTicks}`;
938
- }
939
- const scrub=document.getElementById('replay_scrub');
940
- if(scrub&&document.activeElement!==scrub)scrub.value=String(Math.round(currentTick));
941
- }
942
-
943
- function drawHudOverlay(ctx,w,h,lead,leadState,activeList,nnEval){
944
- ctx.save();
945
- // Top-left status pill
946
- ctx.fillStyle='rgba(9,17,27,0.84)';
947
- ctx.strokeStyle='rgba(73,98,124,0.65)';
948
- ctx.lineWidth=1;
949
- ctx.beginPath();ctx.roundRect(12,12,285,54,8);ctx.fill();ctx.stroke();
950
-
951
- ctx.fillStyle='#62dfb1';ctx.font='700 11px system-ui,sans-serif';
952
- const sector=leadState.u<0.333?'S1':leadState.u<0.667?'S2':'S3';
953
- const surfName=NAMES[leadState.surface]||'Asphalt';
954
- ctx.fillText(`${lead.label.slice(0,34).toUpperCase()}`,22,29);
955
- ctx.fillStyle='#c6d9e9';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
956
- const bestStr=lead.bestLapSeconds?`Best ${lead.bestLapSeconds.toFixed(3)}s`:`Contacts ${lead.physicalContacts}`;
957
- ctx.fillText(`LAP ${leadState.lap} · ${sector} · ${surfName.toUpperCase()} · ${bestStr}`,22,46);
958
- ctx.fillStyle='#82e6bb';ctx.font='600 9.5px system-ui,sans-serif';
959
- ctx.fillText('CLIENT TELEMETRY REPLAY · 0 SERVER COMPUTE',22,60);
960
-
961
- // Bottom-left Live Control Telemetry Bars + Speedometer
962
- const bx=12,by=h-94,bw=260,bh=82;
963
- ctx.fillStyle='rgba(9,17,27,0.86)';
964
- ctx.strokeStyle='rgba(73,98,124,0.65)';
965
- ctx.beginPath();ctx.roundRect(bx,by,bw,bh,8);ctx.fill();ctx.stroke();
966
-
967
- // Speed & Slip readout
968
- ctx.fillStyle='#e5edf5';ctx.font='800 18px ui-monospace,SFMono-Regular,monospace';
969
- ctx.fillText(`${String(leadState.speedKmh).padStart(3,' ')} km/h`,bx+12,by+24);
970
- ctx.fillStyle='#a2b4c8';ctx.font='600 11px ui-monospace,SFMono-Regular,monospace';
971
- const slipDeg=(leadState.slipAngle*180/Math.PI).toFixed(1);
972
- ctx.fillText(`Slip ${slipDeg}°`,bx+12,by+42);
973
- ctx.fillText(`Steer ${(leadState.controls.steer*100).toFixed(0)}%`,bx+12,by+58);
974
- ctx.fillStyle=leadState.alive?'#62dfb1':'#ff6b6b';
975
- ctx.fillText(leadState.alive?'ACTIVE':'STOPPED',bx+12,by+74);
976
-
977
- // 5 Control bars (STR, THR, BRK, HBK, BST)
978
- const bars=[
979
- ['STR',(leadState.controls.steer+1)*0.5,'#58a6ff',true],
980
- ['THR',leadState.controls.throttle,'#62dfb1',false],
981
- ['BRK',leadState.controls.brake,'#ff6b6b',false],
982
- ['HBK',leadState.controls.hbrake,'#ffd073',false],
983
- ['BST',leadState.controls.boost,'#c792ea',false]
984
- ];
985
- bars.forEach(([name,val,col,bipolar],idx)=>{
986
- const ry=by+12+idx*13.5,rx=bx+142,rw=104,rh=8;
987
- ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px ui-monospace,SFMono-Regular,monospace';
988
- ctx.fillText(name,bx+112,ry+7.5);
989
- ctx.fillStyle='#152233';ctx.fillRect(rx,ry,rw,rh);
990
- ctx.fillStyle=col;
991
- if(bipolar){
992
- const mid=rx+rw*0.5,span=(val-0.5)*rw;
993
- ctx.fillRect(span>=0?mid:mid+span,ry,Math.abs(span),rh);
994
- }else{
995
- ctx.fillRect(rx,ry,Math.max(0,Math.min(1,val))*rw,rh);
996
- }
997
- });
998
-
999
- // Top-right Ghost Race Standings / Mini Neural Activations
1000
- if(activeList.length>1){
1001
- const gx=w-232,gy=12,gw=220,gh=18+Math.min(4,activeList.length)*18;
1002
- ctx.fillStyle='rgba(9,17,27,0.84)';
1003
- ctx.strokeStyle='rgba(73,98,124,0.65)';
1004
- ctx.beginPath();ctx.roundRect(gx,gy,gw,gh,8);ctx.fill();ctx.stroke();
1005
- ctx.fillStyle='#a2b4c8';ctx.font='700 10px system-ui,sans-serif';
1006
- ctx.fillText(focusMode==='swarm'?`POPULATION SWARM (${activeList.length} CARS)`:'GHOST RACE COMPARISON',gx+10,gy+13);
1007
- activeList.slice(0,4).forEach((p,idx)=>{
1008
- const py=gy+28+idx*16;
1009
- ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(gx+15,py-3.5,4,0,Math.PI*2);ctx.fill();
1010
- ctx.fillStyle='#e5edf5';ctx.font='600 10.5px system-ui,sans-serif';
1011
- const shortRole=p.role==='fitness'?'Fitness Leader':p.role==='fastest_lap'?'Fastest Lap':p.role==='clean_laps'?'Clean Laps':p.label.slice(0,18);
1012
- const stat=p.bestLapSeconds?`${p.bestLapSeconds.toFixed(2)}s`:`${p.ticksElapsed}t`;
1013
- ctx.fillText(`${shortRole} · ${stat}`,gx+24,py);
1014
- });
1015
- }
1016
-
1017
- // Mini live neural activation visualizer when network is present
1018
- if(nnEval&&nnEval.layerActivations.length>=2){
1019
- const nx=w-178,ny=h-84,nw=166,nh=72;
1020
- ctx.fillStyle='rgba(9,17,27,0.84)';
1021
- ctx.strokeStyle='rgba(73,98,124,0.65)';
1022
- ctx.beginPath();ctx.roundRect(nx,ny,nw,nh,8);ctx.fill();ctx.stroke();
1023
- ctx.fillStyle='#a2b4c8';ctx.font='700 9.5px system-ui,sans-serif';
1024
- ctx.fillText(`LIVE POLICY (${nnEval.layerActivations.map(l=>l.length).join('→')})`,nx+8,ny+12);
1025
- const layersAct=nnEval.layerActivations;
1026
- const lCount=layersAct.length;
1027
- for(let l=0;l<lCount;l++){
1028
- const nodes=layersAct[l],shown=Math.min(8,nodes.length);
1029
- const lx=nx+14+l*(nw-28)/Math.max(1,lCount-1);
1030
- for(let i=0;i<shown;i++){
1031
- const ly=ny+20+(i+0.5)*(nh-26)/shown;
1032
- const v=nodes[i]||0;
1033
- ctx.fillStyle=v>=0?'#62dfb1':'#ff6b6b';
1034
- ctx.globalAlpha=0.3+Math.min(0.7,Math.abs(v)*0.7);
1035
- ctx.beginPath();ctx.arc(lx,ly,2.6,0,Math.PI*2);ctx.fill();
1036
- }
1037
  }
1038
- ctx.globalAlpha=1;
1039
  }
1040
- ctx.restore();
1041
  }
1042
-
1043
- function stepLoop(now){
1044
- animId=null;
1045
- if(!playing||!currentTrack||(typeof document.hidden==='boolean'&&document.hidden))return;
1046
- if(!lastFrameTime)lastFrameTime=now;
1047
- const dt=Math.min(0.1,(now-lastFrameTime)/1000);
1048
- lastFrameTime=now;
1049
- // Pause animation automatically while a server training job is actively running so 0% client CPU is used during runs
1050
- if(!root?.TrainingClient?.isBusy?.()){
1051
- currentTick+=dt*60*playSpeed;
1052
- if(currentTick>maxTicks)currentTick=0;
1053
- drawFrame();
1054
- }
1055
- if(typeof root?.requestAnimationFrame==='function')animId=root.requestAnimationFrame(stepLoop);
1056
- }
1057
-
1058
- function startLoop(){
1059
- if(animId||!playing||typeof root?.requestAnimationFrame!=='function')return;
1060
- lastFrameTime=0;
1061
- animId=root.requestAnimationFrame(stepLoop);
1062
- }
1063
-
1064
- function stopLoop(){
1065
- if(animId&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
1066
- animId=null;
1067
- }
1068
-
1069
- function bindReplayControls(){
1070
- const playBtn=document.getElementById('replay_play');
1071
- const resetBtn=document.getElementById('replay_reset');
1072
- const scrub=document.getElementById('replay_scrub');
1073
- const modeSel=document.getElementById('replay_mode');
1074
- const speedSel=document.getElementById('replay_speed');
1075
- const camSel=document.getElementById('replay_camera');
1076
- if(playBtn&&!playBtn.dataset.bound){
1077
- playBtn.dataset.bound='1';
1078
- playBtn.addEventListener('click',()=>{
1079
- playing=!playing;
1080
- playBtn.textContent=playing?'Pause':'Play';
1081
- if(playing)startLoop();else{stopLoop();drawFrame();}
1082
- });
1083
- }
1084
- if(resetBtn&&!resetBtn.dataset.bound){
1085
- resetBtn.dataset.bound='1';
1086
- resetBtn.addEventListener('click',()=>{currentTick=0;drawFrame();});
1087
- }
1088
- if(scrub&&!scrub.dataset.bound){
1089
- scrub.dataset.bound='1';
1090
- scrub.addEventListener('input',()=>{
1091
- currentTick=Number(scrub.value)||0;
1092
- drawFrame();
1093
- });
1094
- }
1095
- if(modeSel&&!modeSel.dataset.bound){
1096
- modeSel.dataset.bound='1';
1097
- modeSel.addEventListener('change',()=>{
1098
- focusMode=modeSel.value;
1099
- rebuildProfiles();
1100
- drawFrame();
1101
- });
1102
- }
1103
- if(speedSel&&!speedSel.dataset.bound){
1104
- speedSel.dataset.bound='1';
1105
- speedSel.addEventListener('change',()=>{playSpeed=Number(speedSel.value)||1;});
1106
- }
1107
- if(camSel&&!camSel.dataset.bound){
1108
- camSel.dataset.bound='1';
1109
- camSel.addEventListener('change',()=>{cameraMode=camSel.value;drawFrame();});
1110
- }
1111
- for(const [key,id] of [['outline','replay_layer_outline'],['rays','replay_layer_rays'],['line','replay_layer_line'],['skids','replay_layer_skids'],['hud','replay_layer_hud']]){
1112
- const el=document.getElementById(id);
1113
- if(el&&!el.dataset.bound){
1114
- el.dataset.bound='1';
1115
- el.addEventListener('change',()=>{layers[key]=el.checked;drawFrame();});
1116
- }
1117
- }
1118
- const starterVeh=document.getElementById('starter_vehicle');
1119
- if(starterVeh&&!starterVeh.dataset.replayBound){
1120
- starterVeh.dataset.replayBound='1';
1121
- starterVeh.addEventListener('change',()=>{rebuildProfiles();drawFrame();});
1122
  }
 
1123
  }
1124
-
1125
  async function refresh(){
1126
- const current=++version;
1127
- bindReplayControls();
1128
  const info=picker.info();
1129
- const source=info.source==='builtin'?info.display_name:document.getElementById('track')?.files?.[0]?.name;
1130
- if(!source){
1131
- currentTrack=null;staticLayer=null;stopLoop();
1132
- status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;
1133
- }
1134
  status.textContent='Loading '+source+'…';
1135
  try{
1136
  atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
1137
  if(!r.ok)throw Error('Track atlas unavailable');return r.json();
1138
  });
1139
  const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
1140
- const track=model(await decode(file),atlas);
 
 
 
1141
  if(current!==version)return;
1142
- currentTrack=track;
1143
- currentSourceName=source;
1144
  render(canvas,track);
1145
- rebuildStaticLayer();
1146
- rebuildProfiles();
1147
- drawFrame();
1148
- startLoop();
1149
  const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
1150
  status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
1151
- legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>{
1152
- const i=NAMES.indexOf(name);return name+' '+counts[i];
1153
- }).join(' · ')+' · Zero-compute telemetry visualizer: static circuit is cached once; ghost trajectories & sensor rays run locally in browser.';
1154
  }catch(error){
1155
  if(current!==version)return;
1156
- currentTrack=null;staticLayer=null;stopLoop();
1157
- renderEmpty();status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
1158
  }
1159
  }
1160
- 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);}
1161
- document.addEventListener('track-library-change',refresh);
1162
- if(typeof document.addEventListener==='function'){
1163
- document.addEventListener('visibilitychange',()=>{if(document.hidden)stopLoop();else if(playing&&currentTrack)startLoop();});
1164
- }
1165
-
1166
  const controller={
1167
  refresh,
1168
- setStagedNetwork(net){
1169
- stagedNet=net||null;
1170
- if(currentTrack){rebuildProfiles();drawFrame();}
 
 
 
 
 
 
 
 
 
 
1171
  },
1172
- setRunAnalysis(analysis,receipt){
1173
- analysisState=analysis||null;
1174
- receiptState=receipt||null;
1175
- const modeSel=document.getElementById('replay_mode');
1176
- if(modeSel){
1177
- if(analysis?.summary?.mode==='train')focusMode=modeSel.value='winners';
1178
- else if(analysis?.summary?.mode==='evaluate')focusMode=modeSel.value='fitness';
1179
- }
1180
- currentTick=0;
1181
- if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
1182
- },
1183
- focusCandidate(roleOrGen){
1184
- const modeSel=document.getElementById('replay_mode');
1185
- if(roleOrGen==='swarm'){focusMode='swarm';if(modeSel)modeSel.value='swarm';}
1186
- else if(['winners','fitness','fastest_lap','clean_laps','staged'].includes(roleOrGen)){
1187
- focusMode=roleOrGen;if(modeSel)modeSel.value=roleOrGen;
1188
- }
1189
- currentTick=0;
1190
- if(currentTrack){rebuildProfiles();drawFrame();startLoop();}
1191
- }
1192
  };
1193
  if(typeof root==='object'&&root)root.TrackReplay=controller;
1194
- refresh();
1195
  return controller;
1196
  }
1197
- return {mount,model,decode,render};
1198
  });
 
1
+ /* Browser-only preview of the selected saved track. No upload for preview. */
2
  (function(root,factory){
3
  if(typeof module==='object'&&module.exports)module.exports=factory(root);
4
  else root.TrackMap=factory(root);
5
  })(typeof window==='undefined'?this:window,function(root){
6
  const COLOURS=['#607e96','#b89561','#b9d9ed'];
7
  const NAMES=['Asphalt','Dirt','Ice'];
 
8
  const VECTOR=/^Vector2i\((-?\d+),\s*(-?\d+)\)$/;
9
  function vector(value){
10
  const match=typeof value==='string'&&VECTOR.exec(value);
 
87
  if(maxX-minX>30||maxY-minY>30)throw Error('Track preview is too large');
88
  return {tiles,start,startConnection,bounds:{minX,maxX,minY,maxY}};
89
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  function render(canvas,track){
91
  const context=canvas.getContext('2d');
92
  if(!context)throw Error('Canvas preview unavailable');
 
96
  const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
97
  const size=Math.min(160,(w-70)/columns,(h-70)/rows);
98
  const left=(w-columns*size)/2,top=(h-rows*size)/2;
 
 
 
 
 
 
 
 
 
 
 
 
99
  for(const tile of track.tiles){
100
  const x=left+(tile.x-b.minX)*size,y=top+(tile.y-b.minY)*size;
101
  context.fillStyle=COLOURS[tile.surface];
 
106
  const [entry,exit]=tile.connections;
107
  const a=connectionPoint(entry,x,y,size),z=connectionPoint(exit,x,y,size);
108
  const p1=connectionControl(entry,exit,a,z),p2=connectionControl(exit,entry,z,a);
 
 
 
 
 
 
 
109
  context.beginPath();context.moveTo(...a);
110
  context.bezierCurveTo(...p1,...p2,...z);
111
  context.strokeStyle='#f3a967';context.lineWidth=Math.max(3,size*.055);
112
  context.lineCap='round';context.stroke();
113
  }
114
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  const startX=left+(track.start[0]-b.minX)*size,startY=top+(track.start[1]-b.minY)*size;
116
  const [sx,sy]=connectionPoint(track.startConnection,startX,startY,size);
117
  context.beginPath();context.arc(sx,sy,Math.max(6,size*.105),0,Math.PI*2);
118
  context.fillStyle='#66e5aa';context.fill();context.strokeStyle='#0c1926';context.lineWidth=3;context.stroke();
119
  }
120
+ // Vehicle illustration uses only recorded position, heading, type and active state.
121
+ function drawVehicle2D(ctx,x,y,heading,vehicleType,color,scale,showOutline,active){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  ctx.save();
123
  ctx.translate(x,y);
124
+ ctx.rotate(heading);
125
  ctx.scale(scale,scale);
126
  if(!active)ctx.globalAlpha=0.55;
127
 
128
+ const wheelSteer=0; // Neutral artwork; steering samples are unavailable.
 
 
 
 
 
 
 
 
129
  // Draw wheels / skis
130
  ctx.fillStyle='#101820';
131
  ctx.strokeStyle=showOutline?'#ffffff':'#2a3d52';
 
199
  ctx.beginPath();ctx.roundRect(2,-5.2,6.5,10.4,1.5);ctx.fill();
200
  }
201
 
202
+ // The recorded active flag controls the inactive badge.
 
 
 
 
 
 
 
 
 
203
  if(!active){
204
  ctx.strokeStyle='#ff6b6b';ctx.lineWidth=2.4;
205
  ctx.beginPath();ctx.moveTo(-7,-7);ctx.lineTo(7,7);ctx.moveTo(7,-7);ctx.lineTo(-7,7);ctx.stroke();
 
207
  ctx.restore();
208
  }
209
 
 
 
 
 
 
 
 
 
 
210
 
211
+ function hash(value,label){
212
+ if(typeof value!=='string'||!/^[a-f0-9]{64}$/i.test(value))throw Error(label+' must be a SHA-256 hash');
213
+ return value.toLowerCase();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  }
215
+ function validateRecording(payload,expectedTrackSha,expectedNetworkSha){
216
+ if(!payload||payload.schema!=='ald.recorded-replay'||payload.version!==1||payload.kind!=='fresh_cpu_evaluation')
217
+ throw Error('This file is not a supported recorded CPU evaluation');
218
+ if(payload.physics_hz!==60||payload.control_hz!==60||payload.sample_interval_ticks!==6||payload.first_sample_tick!==1||
219
+ payload.execution_backend!=='cpu'||payload.math_profile!=='proton'||payload.collision_elimination!==false||payload.idle_elimination!==false)
220
+ throw Error('Recorded evaluation conditions are unsupported');
221
+ const trackSha=hash(payload.track_sha256,'Recorded track'),networkSha=hash(payload.network_sha256,'Recorded network');
222
+ if(trackSha!==hash(expectedTrackSha,'Selected track')||networkSha!==hash(expectedNetworkSha,'Selected network'))
223
+ throw Error('The recording does not match the selected track and network');
224
+ const ticks=payload.requested_ticks;
225
+ if(!Number.isInteger(ticks)||ticks<1||ticks>900)throw Error('Recording must contain at most 900 physics ticks');
226
+ if(!Number.isInteger(payload.vehicle_type)||payload.vehicle_type<0||payload.vehicle_type>3)throw Error('Recorded vehicle type is invalid');
227
+ const expectedTicks=[1];
228
+ for(let tick=6;tick<=ticks;tick+=6)expectedTicks.push(tick);
229
+ if(expectedTicks[expectedTicks.length-1]!==ticks)expectedTicks.push(ticks);
230
+ if(!Array.isArray(payload.frames)||payload.frames.length!==expectedTicks.length)
231
+ throw Error('Recording is missing its expected native samples');
232
+ const frames=payload.frames.map((frame,index)=>{
233
+ if(!frame||frame.tick!==expectedTicks[index]||typeof frame.active!=='boolean')throw Error('Recorded frame ticks or active state are invalid');
234
+ const copy={tick:frame.tick,active:frame.active};
235
+ for(const key of ['x','y','rotation','vx','vy']){
236
+ if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
237
+ copy[key]=frame[key];
238
+ }
239
+ for(const key of ['angular_velocity','boost_energy','collision_count','update_count']){
240
+ if(frame[key]===undefined)continue;
241
+ if(!Number.isFinite(frame[key])||Math.abs(frame[key])>1e9)throw Error('Recorded '+key+' is invalid');
242
+ if((key==='collision_count'||key==='update_count')&&(!Number.isInteger(frame[key])||frame[key]<0))throw Error('Recorded '+key+' is invalid');
243
+ copy[key]=frame[key];
244
+ }
245
+ return copy;
246
+ });
247
+ return {schema:payload.schema,version:1,kind:payload.kind,physics_hz:60,control_hz:60,sample_interval_ticks:6,
248
+ first_sample_tick:1,track_sha256:trackSha,network_sha256:networkSha,vehicle_type:payload.vehicle_type,
249
+ requested_ticks:ticks,execution_backend:'cpu',math_profile:'proton',collision_elimination:false,idle_elimination:false,frames};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  }
251
+ function sampleRecording(recording,tick){
252
+ if(!Number.isFinite(tick))throw Error('Replay tick must be finite');
253
+ const frames=recording.frames,last=frames.length-1;
254
+ const at=Math.max(frames[0].tick,Math.min(frames[last].tick,tick));
255
+ let lo=0,hi=last;
256
+ while(lo<hi){const mid=Math.ceil((lo+hi)/2);if(frames[mid].tick<=at)lo=mid;else hi=mid-1;}
257
+ const a=frames[lo],b=frames[Math.min(last,lo+1)];
258
+ const t=b.tick===a.tick?0:(at-a.tick)/(b.tick-a.tick);
259
+ const angle=Math.atan2(Math.sin(b.rotation-a.rotation),Math.cos(b.rotation-a.rotation));
260
+ // Pose interpolation is only between adjacent recorded samples. HUD values
261
+ // remain the actual preceding sample, never reconstructed controls or rays.
262
+ return {tick:at,x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,rotation:a.rotation+angle*t,frame:{...a}};
263
+ }
264
+ function viewport(canvas,track){
265
+ const b=track.bounds,columns=b.maxX-b.minX+1,rows=b.maxY-b.minY+1;
266
+ const size=Math.min(160,(canvas.width-70)/columns,(canvas.height-70)/rows);
267
+ return {size,left:(canvas.width-columns*size)/2,top:(canvas.height-rows*size)/2,minX:b.minX,minY:b.minY};
268
+ }
269
+ function worldPoint(view,x,y){return [view.left+(x/768-view.minX)*view.size,view.top+(y/768-view.minY)*view.size];}
270
  function mount({document,fetch,picker}){
271
+ const canvas=document.getElementById('trackMap'),status=document.getElementById('mapStatus'),legend=document.getElementById('mapLegend');
272
+ if(!canvas||!status||!legend||!picker)return;
273
+ const element=id=>document.getElementById(id);
274
+ let version=0,atlasPromise=null,currentTrack=null,currentTrackSha=null,staticLayer=null;
275
+ let recording=null,hasRunSummary=false,cursor=1,playing=false,animId=null,lastFrameTime=null;
276
+ let playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed')?.value)||1));
277
+ let cameraMode=element('replay_camera')?.value==='follow'?'follow':'full';
278
+ const layers={outline:true,line:true,hud:true};
279
+ const noRecording='No replay recorded. The map shows the selected track and its saved starting point.';
280
+ const summaryOnly='No replay recorded. This run contains summary measurements, not vehicle positions or control samples.';
281
+ function setText(id,text){const node=element(id);if(node&&node.textContent!==text)node.textContent=text;}
282
+ function syncControls(reason){
283
+ const last=recording?.frames[recording.frames.length-1].tick;
284
+ const play=element('replay_play');
285
+ if(play){play.disabled=!recording||cursor>=last;play.textContent=playing?'Pause':'Play';}
286
+ for(const id of ['replay_reset','replay_scrub','replay_speed','replay_camera','replay_layer_outline','replay_layer_line','replay_layer_hud']){
287
+ const node=element(id);if(node)node.disabled=!recording;
288
+ }
289
+ const scrub=element('replay_scrub');
290
+ if(scrub){scrub.min='1';scrub.max=String(last??1);scrub.step='1';scrub.value=String(Math.round(cursor));}
291
+ if(!recording){
292
+ setText('replay_status',reason||(hasRunSummary?summaryOnly:noRecording));
293
+ setText('replay_time_label','No recorded samples');
294
+ setText('replay_readout','Position, speed, controls and sensors are unavailable until a replay is recorded.');
 
 
295
  }else{
296
+ const state=cursor>=last?'Finished. Use Restart to view it again.':playing?'Playing.':'Paused.';
297
+ setText('replay_status',`Fresh recorded CPU evaluation · ${recording.frames.length} samples · 60 Hz physics and controls · 10 Hz recording. ${state}`);
298
  }
299
  }
300
+ function stopPlayback(){
301
+ if(animId!==null&&typeof root?.cancelAnimationFrame==='function')root.cancelAnimationFrame(animId);
302
+ animId=null;playing=false;lastFrameTime=null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  }
304
+ 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);}
305
+ function draw(){
306
+ if(!currentTrack){renderEmpty();return;}
307
+ const ctx=canvas.getContext('2d'),view=viewport(canvas,currentTrack);
308
+ const state=recording?sampleRecording(recording,cursor):null;
309
+ const position=state?worldPoint(view,state.x,state.y):null;
310
+ ctx.clearRect(0,0,canvas.width,canvas.height);ctx.fillStyle='#0c1926';ctx.fillRect(0,0,canvas.width,canvas.height);
 
 
 
 
 
 
 
 
 
311
  ctx.save();
312
+ if(position&&cameraMode==='follow'){
313
+ ctx.translate(canvas.width/2,canvas.height/2);ctx.scale(2,2);ctx.translate(-position[0],-position[1]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  }
315
+ if(staticLayer)ctx.drawImage(staticLayer,0,0);else render(canvas,currentTrack);
316
+ if(recording){
317
+ if(layers.line){
 
 
 
 
 
 
 
318
  ctx.beginPath();
319
+ recording.frames.forEach((frame,index)=>{const p=worldPoint(view,frame.x,frame.y);if(index===0)ctx.moveTo(...p);else ctx.lineTo(...p);});
320
+ ctx.strokeStyle='#62dfb1';ctx.lineWidth=2;ctx.lineCap='round';ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
+ // Native rotation zero faces -Y; the vehicle illustration faces +X.
323
+ drawVehicle2D(ctx,...position,state.rotation-Math.PI/2,recording.vehicle_type,'#62dfb1',Math.max(0.55,Math.min(1.1,view.size/120)),layers.outline,state.frame.active);
324
  }
325
  ctx.restore();
326
+ if(state){
327
+ const frame=state.frame,speed=Math.hypot(frame.vx,frame.vy)/15*3.6;
328
+ const contacts=frame.collision_count===undefined?'unavailable':String(frame.collision_count);
329
+ setText('replay_time_label',`${(cursor/60).toFixed(2)}s / ${(recording.requested_ticks/60).toFixed(2)}s · Tick ${Math.round(cursor)} · Recorded sample ${frame.tick}`);
330
+ setText('replay_readout',`Recorded sample tick ${frame.tick} (${(frame.tick/60).toFixed(2)}s). Speed ${speed.toFixed(1)} km/h, derived from recorded velocity. Position (${frame.x.toFixed(2)}, ${frame.y.toFixed(2)}). Contacts ${contacts}. ${frame.active?'Active':'Inactive'} at this sample. Controls and sensors unavailable.`);
331
+ if(layers.hud){
332
+ ctx.save();ctx.fillStyle='rgba(9,17,27,0.88)';ctx.beginPath();ctx.roundRect(12,12,310,80,8);ctx.fill();
333
+ ctx.fillStyle='#62dfb1';ctx.font='700 12px system-ui,sans-serif';ctx.fillText('RECORDED CPU EVALUATION',24,33);
334
+ ctx.fillStyle='#e5edf5';ctx.font='600 12px ui-monospace,monospace';ctx.fillText(`Sample ${frame.tick} · ${speed.toFixed(1)} km/h from velocity`,24,55);
335
+ ctx.fillStyle='#b4c5d5';ctx.font='12px system-ui,sans-serif';ctx.fillText('Controls and sensors unavailable',24,77);ctx.restore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  }
 
337
  }
 
338
  }
339
+ function clearRecording(reason){
340
+ const hadRecording=recording!==null;stopPlayback();recording=null;cursor=1;
341
+ if(hadRecording)draw();syncControls(reason);
342
+ }
343
+ function step(now){
344
+ animId=null;if(!playing||!recording)return;
345
+ if(document.hidden){stopPlayback();syncControls();return;}
346
+ if(lastFrameTime===null)lastFrameTime=now;
347
+ const elapsed=Math.max(0,Math.min(0.1,(now-lastFrameTime)/1000));lastFrameTime=now;
348
+ const last=recording.frames[recording.frames.length-1].tick;
349
+ cursor=Math.min(last,cursor+elapsed*60*playSpeed);
350
+ draw();
351
+ if(cursor>=last){stopPlayback();syncControls();return;}
352
+ syncControls();animId=root.requestAnimationFrame(step);
353
+ }
354
+ function bind(){
355
+ const on=(id,event,callback)=>{const node=element(id);if(node)node.addEventListener(event,callback);};
356
+ on('replay_play','click',()=>{
357
+ if(!recording)return;
358
+ if(playing){stopPlayback();syncControls();return;}
359
+ if(cursor>=recording.frames[recording.frames.length-1].tick||typeof root?.requestAnimationFrame!=='function')return;
360
+ playing=true;lastFrameTime=null;syncControls();animId=root.requestAnimationFrame(step);
361
+ });
362
+ on('replay_reset','click',()=>{if(!recording)return;stopPlayback();cursor=recording.frames[0].tick;draw();syncControls();});
363
+ on('replay_scrub','input',()=>{
364
+ if(!recording)return;const value=Number(element('replay_scrub').value);if(!Number.isFinite(value))return;
365
+ stopPlayback();cursor=Math.max(recording.frames[0].tick,Math.min(recording.frames[recording.frames.length-1].tick,value));draw();syncControls();
366
+ });
367
+ on('replay_speed','change',()=>{playSpeed=Math.max(0.25,Math.min(4,Number(element('replay_speed').value)||1));});
368
+ on('replay_camera','change',()=>{cameraMode=element('replay_camera').value==='follow'?'follow':'full';draw();});
369
+ for(const key of ['outline','line','hud']){
370
+ const id='replay_layer_'+key,node=element(id);if(node)layers[key]=node.checked;
371
+ on(id,'change',()=>{layers[key]=element(id).checked;draw();});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  }
373
+ document.addEventListener('visibilitychange',()=>{if(document.hidden){stopPlayback();syncControls();}});
374
  }
 
375
  async function refresh(){
376
+ const current=++version;clearRecording();currentTrackSha=null;currentTrack=null;staticLayer=null;
 
377
  const info=picker.info();
378
+ const source=info.source==='builtin'?info.display_name:element('track')?.files?.[0]?.name;
379
+ if(!source){status.textContent='Select a built-in track or choose a custom .track file.';legend.textContent='';renderEmpty();return;}
 
 
 
380
  status.textContent='Loading '+source+'…';
381
  try{
382
  atlasPromise??=fetch('/assets/track-atlas.json',{cache:'no-store',credentials:'same-origin'}).then(r=>{
383
  if(!r.ok)throw Error('Track atlas unavailable');return r.json();
384
  });
385
  const [file,atlas]=await Promise.all([picker.file(),atlasPromise]);
386
+ if(!file||file.size<1||file.size>1048576)throw Error('Choose a .track file under 1 MB');
387
+ const hashPromise=root?.crypto?.subtle?file.arrayBuffer().then(bytes=>root.crypto.subtle.digest('SHA-256',bytes)).then(bytes=>Array.from(new Uint8Array(bytes),n=>n.toString(16).padStart(2,'0')).join('')):Promise.resolve(null);
388
+ const [saved,sha]=await Promise.all([decode(file),hashPromise]);
389
+ const track=model(saved,atlas);
390
  if(current!==version)return;
391
+ currentTrack=track;currentTrackSha=sha;
 
392
  render(canvas,track);
393
+ const off=document.createElement?.('canvas');
394
+ if(off?.getContext?.('2d')){off.width=canvas.width;off.height=canvas.height;render(off,track);staticLayer=off;}
 
 
395
  const counts=[0,0,0];track.tiles.forEach(tile=>counts[tile.surface]++);
396
  status.textContent=source+' · '+track.tiles.length+' pieces · green dot marks the saved starting point';
397
+ legend.textContent=NAMES.filter((_,i)=>counts[i]).map(name=>name+' '+counts[NAMES.indexOf(name)]).join(' · ')+' · Orange line follows the track centerline. Collision shapes are not shown. Vehicle positions appear only from a recorded replay.';
 
 
398
  }catch(error){
399
  if(current!==version)return;
400
+ currentTrack=null;currentTrackSha=null;staticLayer=null;renderEmpty();
401
+ status.textContent='Map unavailable: '+error.message;legend.textContent='Training selection is unaffected.';
402
  }
403
  }
 
 
 
 
 
 
404
  const controller={
405
  refresh,
406
+ setStagedNetwork(){clearRecording();},
407
+ setRunAnalysis(analysis,receipt){hasRunSummary=Boolean(analysis?.summary||receipt?.report);clearRecording();},
408
+ focusCandidate(){clearRecording();},
409
+ clearRecording,
410
+ setRecording(payload,expectedTrackSha,expectedNetworkSha){
411
+ clearRecording();
412
+ try{
413
+ const next=validateRecording(payload,expectedTrackSha,expectedNetworkSha);
414
+ if(!currentTrack||!currentTrackSha)throw Error('The selected track must finish loading and hash verification before replay');
415
+ if(next.track_sha256!==currentTrackSha)throw Error('The recording belongs to a different selected track');
416
+ recording=next;cursor=next.frames[0].tick;draw();syncControls();
417
+ return {frames:next.frames.length,first_tick:cursor,last_tick:next.frames[next.frames.length-1].tick};
418
+ }catch(error){clearRecording('Replay unavailable: '+error.message);throw error;}
419
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  };
421
  if(typeof root==='object'&&root)root.TrackReplay=controller;
422
+ bind();syncControls();document.addEventListener('track-library-change',refresh);refresh();
423
  return controller;
424
  }
425
+ return {mount,model,decode,render,validateRecording,sampleRecording};
426
  });