@inventure71/paddockjs 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL_AND_UPDATE.md +3 -1
  3. package/README.md +142 -9
  4. package/docs/architecture.md +49 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +185 -27
  7. package/docs/data_model.md +1 -1
  8. package/docs/learnings.md +1 -1
  9. package/docs/rules.md +140 -19
  10. package/docs/system_specs.md +39 -16
  11. package/docs/training.md +103 -1
  12. package/examples/train-basic-policy.mjs +5 -6
  13. package/examples/trainingData.mjs +133 -0
  14. package/package.json +5 -2
  15. package/src/api/PaddockSimulatorController.js +32 -2
  16. package/src/app/F1SimulatorApp.js +1101 -128
  17. package/src/app/domBindings.js +10 -1
  18. package/src/config/defaultOptions.js +4 -4
  19. package/src/environment/actions.js +25 -2
  20. package/src/environment/evaluation.js +184 -0
  21. package/src/environment/index.d.ts +281 -1
  22. package/src/environment/index.js +13 -0
  23. package/src/environment/observationOptions.js +12 -0
  24. package/src/environment/observations.js +189 -17
  25. package/src/environment/options.js +7 -0
  26. package/src/environment/recorder.js +28 -0
  27. package/src/environment/runtime.js +41 -6
  28. package/src/environment/scenarios.js +220 -0
  29. package/src/environment/sensors.js +145 -35
  30. package/src/environment/specs.js +92 -8
  31. package/src/environment/workerProtocol.js +52 -0
  32. package/src/index.d.ts +366 -3
  33. package/src/index.js +21 -0
  34. package/src/rendering/proceduralTrackAsset.js +336 -23
  35. package/src/simulation/collisionGeometry.js +214 -0
  36. package/src/simulation/driverController.js +431 -65
  37. package/src/simulation/raceSimulation.js +2132 -171
  38. package/src/simulation/rules/collisionSteward.js +69 -0
  39. package/src/simulation/rules/penaltyLedger.js +153 -0
  40. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  41. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  42. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  43. package/src/simulation/rulesConfig.js +352 -0
  44. package/src/simulation/trackModel.js +1154 -76
  45. package/src/simulation/units.js +6 -4
  46. package/src/simulation/vehicleGeometry.js +247 -0
  47. package/src/simulation/vehiclePhysics.js +82 -50
  48. package/src/simulation/wheelSurface.js +338 -0
  49. package/src/styles.css +300 -42
  50. package/src/ui/bannerTemplates.js +69 -0
  51. package/src/ui/componentTemplates.js +36 -96
  52. package/src/ui/raceControlStatusBanner.js +39 -0
  53. package/src/ui/shellTemplate.js +1 -1
  54. package/src/ui/templateUtils.js +23 -0
@@ -1,4 +1,4 @@
1
- import { Application, Assets, Container, Graphics, Sprite, Texture } from 'pixi.js';
1
+ import { Application, Assets, Container, Graphics, Sprite, Text, Texture } from 'pixi.js';
2
2
  import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
3
3
  import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
4
4
  import { normalizeCustomFields } from '../data/customFields.js';
@@ -9,6 +9,8 @@ import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.j
9
9
  import { clamp } from '../simulation/simMath.js';
10
10
  import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
11
11
  import { metersToSimUnits } from '../simulation/units.js';
12
+ import { VEHICLE_GEOMETRY } from '../simulation/vehicleGeometry.js';
13
+ import { getRaceControlStatusBanner } from '../ui/raceControlStatusBanner.js';
12
14
  import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
13
15
  import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
14
16
 
@@ -16,26 +18,48 @@ const TARGET_RENDER_FPS = 60;
16
18
  const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
17
19
  const FRAME_PACING_EPSILON_MS = 0.75;
18
20
  const MAX_FRAME_CATCHUP_COUNT = 4;
19
- const MAX_SIMULATION_STEPS_PER_RENDER = 5;
21
+ const BASE_MAX_SIMULATION_STEPS_PER_RENDER = 5;
22
+ const HARD_MAX_SIMULATION_STEPS_PER_RENDER = 60;
20
23
  const DOM_UPDATE_INTERVAL_MS = 100;
21
24
  const TIMING_UPDATE_INTERVAL_MS = 250;
22
- const SIM_SPEED = 3.25;
23
- const CAR_WORLD_LENGTH = 66;
24
- const CAR_WORLD_WIDTH = 23;
25
- const SAFETY_CAR_WORLD_LENGTH = 92;
26
- const SAFETY_CAR_WORLD_WIDTH = 38;
25
+ const CAMERA_TARGET_LERP = 0.12;
26
+ const CAMERA_SCALE_LERP = 0.12;
27
+ const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
28
+ const CAR_WORLD_LENGTH = VEHICLE_GEOMETRY.visualLength;
29
+ const CAR_WORLD_WIDTH = VEHICLE_GEOMETRY.visualWidth;
30
+ // Temporary debug view: set to false to restore normal car sprites.
31
+ const TEMP_RENDER_RAW_CAR_GEOMETRY = false;
32
+ const SAFETY_CAR_WORLD_LENGTH = metersToSimUnits(5.3);
33
+ const SAFETY_CAR_WORLD_WIDTH = metersToSimUnits(2.1);
27
34
  const CAMERA_PRESETS = {
28
- overview: 1.6,
29
- leader: 5.35,
30
- selected: 6.1,
35
+ overview: 1,
36
+ leader: 18,
37
+ selected: 24,
38
+ 'show-all': 1,
39
+ pit: 2.35,
40
+ };
41
+ const CAMERA_MIN_ZOOM = 0.55;
42
+ const CAMERA_MAX_ZOOM = 120;
43
+ const CAMERA_ZOOM_STEP = 1.4;
44
+ const TRACK_CAMERA_PADDING = metersToSimUnits(120);
45
+ const SERVICE_COUNTDOWN_LABEL_STYLES = {
46
+ penalty: {
47
+ background: 0x110b0b,
48
+ stroke: 0xff2d55,
49
+ fill: 0xffffff,
50
+ },
51
+ pit: {
52
+ background: 0x171305,
53
+ stroke: 0xffd166,
54
+ fill: 0xfff2b0,
55
+ },
31
56
  };
32
57
  const SHOW_ALL_PADDING = 520;
33
- const SHOW_ALL_MIN_ZOOM = 1.1;
34
- const SHOW_ALL_MAX_ZOOM = 6.4;
35
58
  const SHOW_ALL_TOP_RESERVED = 92;
36
59
  const SHOW_ALL_BOTTOM_RESERVED = 132;
37
- const DRS_TRAIL_TTL = 0.68;
38
- const DRS_TRAIL_MIN_DISTANCE = 10;
60
+ const PIT_CAMERA_PADDING = metersToSimUnits(34);
61
+ const DRS_TRAIL_TTL = 0.34;
62
+ const DRS_TRAIL_MIN_DISTANCE = metersToSimUnits(0.9);
39
63
  const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
40
64
  const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
41
65
  const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
@@ -45,6 +69,7 @@ const RADIO_BREAK_MAX_MS = 11800;
45
69
  const RADIO_VISIBLE_MIN_MS = 6200;
46
70
  const RADIO_VISIBLE_MAX_MS = 9200;
47
71
  const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
72
+ const PENALTY_BANNER_VISIBLE_MS = 12000;
48
73
  const DRS_DRAG_REDUCTION_PERCENT = 58;
49
74
 
50
75
  const VEHICLE_OVERVIEW_FIELDS = [
@@ -89,6 +114,86 @@ function getTireClass(tire) {
89
114
  return String(tire ?? 'M').toLowerCase();
90
115
  }
91
116
 
117
+ function formatPenaltyType(type) {
118
+ return String(type ?? 'penalty')
119
+ .split('-')
120
+ .filter(Boolean)
121
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
122
+ .join(' ');
123
+ }
124
+
125
+ function formatPenaltyHeadline(penalty) {
126
+ const serviceType = penalty?.serviceType;
127
+ if (serviceType === 'driveThrough') {
128
+ return penalty?.unserved ? 'unserved drive-through penalty' : 'drive-through penalty';
129
+ }
130
+ if (serviceType === 'stopGo') {
131
+ return penalty?.unserved ? 'unserved stop-go penalty' : 'stop-go penalty';
132
+ }
133
+ const positionDrop = Number(penalty?.positionDrop);
134
+ if (Number.isFinite(positionDrop) && positionDrop > 0) {
135
+ return `${positionDrop}-place position drop`;
136
+ }
137
+ const gridDrop = Number(penalty?.gridDrop);
138
+ if (Number.isFinite(gridDrop) && gridDrop > 0) {
139
+ return `${gridDrop}-place grid drop`;
140
+ }
141
+ if (penalty?.disqualified) return 'disqualification';
142
+ const seconds = Number(penalty?.penaltySeconds);
143
+ if (!Number.isFinite(seconds) || seconds <= 0) return 'Penalty decision';
144
+ const unit = seconds === 1 ? 'second' : 'seconds';
145
+ return `+${seconds} ${unit} time penalty`;
146
+ }
147
+
148
+ function formatPenaltyChip(penalty) {
149
+ const serviceType = penalty?.serviceType;
150
+ if (serviceType === 'driveThrough') return 'DT';
151
+ if (serviceType === 'stopGo') return 'SG';
152
+ if (penalty?.disqualified) return 'DSQ';
153
+ const positionDrop = Number(penalty?.positionDrop);
154
+ if (Number.isFinite(positionDrop) && positionDrop > 0) return `-${positionDrop}P`;
155
+ const gridDrop = Number(penalty?.gridDrop);
156
+ if (Number.isFinite(gridDrop) && gridDrop > 0) return `-${gridDrop}G`;
157
+ const seconds = Number(penalty?.penaltySeconds);
158
+ return Number.isFinite(seconds) && seconds > 0 ? `+${seconds}s` : 'Penalty';
159
+ }
160
+
161
+ function formatPenaltyBadgeLabel(penalty) {
162
+ const seconds = Number(penalty?.penaltySeconds);
163
+ const timeLabel = Number.isFinite(seconds) && seconds > 0 ? `${seconds}s ` : '';
164
+ if (Number.isFinite(seconds) && seconds > 0 && !penalty?.serviceType) {
165
+ return `Penalty: ${timeLabel}${String(penalty?.type ?? 'decision')}`;
166
+ }
167
+ return `Penalty: ${timeLabel}${formatPenaltyHeadline(penalty)}`;
168
+ }
169
+
170
+ function hasVisiblePenaltyEffect(penalty) {
171
+ if (!penalty || penalty.status === 'cancelled') return false;
172
+ if (penalty.serviceRequired && penalty.status === 'served') return false;
173
+ const penaltySeconds = Number(penalty.penaltySeconds);
174
+ const pendingPenaltySeconds = Number(penalty.pendingPenaltySeconds);
175
+ const positionDrop = Number(penalty.positionDrop);
176
+ const gridDrop = Number(penalty.gridDrop);
177
+ return penalty.serviceRequired ||
178
+ (Number.isFinite(penaltySeconds) && penaltySeconds > 0) ||
179
+ (Number.isFinite(pendingPenaltySeconds) && pendingPenaltySeconds > 0) ||
180
+ (Number.isFinite(positionDrop) && positionDrop > 0) ||
181
+ (Number.isFinite(gridDrop) && gridDrop > 0) ||
182
+ Boolean(penalty.disqualified);
183
+ }
184
+
185
+ function formatStewardMessageKey(message) {
186
+ if (!message) return '';
187
+ return [
188
+ message.kind,
189
+ message.type,
190
+ message.driverId,
191
+ message.penaltyId,
192
+ message.violationCount,
193
+ message.time,
194
+ ].join(':');
195
+ }
196
+
92
197
  function formatTelemetryTime(value) {
93
198
  if (!Number.isFinite(value)) return '--';
94
199
  const clamped = Math.max(0, value);
@@ -114,6 +219,14 @@ function colorToTint(color) {
114
219
  return normalizedTint;
115
220
  }
116
221
 
222
+ function pruneExpiredTrailPoints(history, now) {
223
+ let expiredCount = 0;
224
+ while (expiredCount < history.length && now - history[expiredCount].at > DRS_TRAIL_TTL) {
225
+ expiredCount += 1;
226
+ }
227
+ if (expiredCount > 0) history.splice(0, expiredCount);
228
+ }
229
+
117
230
  function smoothAngle(current, target, amount) {
118
231
  if (!Number.isFinite(current)) return target;
119
232
  let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
@@ -127,6 +240,14 @@ function destroyDisplayChildren(container) {
127
240
  });
128
241
  }
129
242
 
243
+ function clampCameraScale(scale, anchorScale, minimumScale = null) {
244
+ const safeAnchor = Number.isFinite(anchorScale) && anchorScale > 0 ? anchorScale : 1;
245
+ const safeMinimum = Number.isFinite(minimumScale) && minimumScale > 0
246
+ ? minimumScale
247
+ : safeAnchor * CAMERA_MIN_ZOOM;
248
+ return clamp(scale, safeMinimum, safeAnchor * CAMERA_MAX_ZOOM);
249
+ }
250
+
130
251
  function expertVisualizesRays(expertOptions) {
131
252
  const setting = expertOptions?.visualizeSensors;
132
253
  if (setting === true) return true;
@@ -145,6 +266,30 @@ function hasOwnOption(options, key) {
145
266
  return Object.hasOwn(options ?? {}, key);
146
267
  }
147
268
 
269
+ function maxSimulationStepsForFrame(simulationSpeed, elapsedFrameCount) {
270
+ const speed = Math.max(1, Number(simulationSpeed) || 1);
271
+ const frameCount = Math.max(1, Number(elapsedFrameCount) || 1);
272
+ return clamp(
273
+ Math.ceil(speed * frameCount) + 1,
274
+ BASE_MAX_SIMULATION_STEPS_PER_RENDER,
275
+ HARD_MAX_SIMULATION_STEPS_PER_RENDER,
276
+ );
277
+ }
278
+
279
+ function domUpdateIntervalForSpeed(simulationSpeed) {
280
+ const speed = Math.max(1, Number(simulationSpeed) || 1);
281
+ if (speed >= 10) return 250;
282
+ if (speed >= 5) return 180;
283
+ return DOM_UPDATE_INTERVAL_MS;
284
+ }
285
+
286
+ function timingUpdateIntervalForSpeed(simulationSpeed) {
287
+ const speed = Math.max(1, Number(simulationSpeed) || 1);
288
+ if (speed >= 10) return 600;
289
+ if (speed >= 5) return 400;
290
+ return TIMING_UPDATE_INTERVAL_MS;
291
+ }
292
+
148
293
  function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
149
294
  return nextExpertOptions !== currentExpertOptions;
150
295
  }
@@ -185,15 +330,24 @@ export class F1SimulatorApp {
185
330
  this.drsLayer = null;
186
331
  this.trailLayer = null;
187
332
  this.sensorLayer = null;
333
+ this.pitLaneStatusLayer = null;
188
334
  this.carLayer = null;
189
335
  this.textures = {};
190
336
  this.carSprites = new Map();
191
337
  this.carHitAreas = new Map();
338
+ this.serviceCountdownLabels = new Map();
192
339
  this.drsTrails = new Map();
193
340
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
194
341
  this.selectedId = this.drivers[0]?.id ?? null;
195
342
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
343
+ this.raceDataBannersMuted = false;
344
+ this.penaltyBannerEnabled = Boolean(options.ui?.penaltyBanners);
345
+ this.timingPenaltyBadgesEnabled = Boolean(options.ui?.timingPenaltyBadges);
196
346
  this.activeRaceDataId = null;
347
+ this.activePenaltyBanner = null;
348
+ this.lastPenaltyBannerId = null;
349
+ this.activeStewardMessage = null;
350
+ this.lastStewardMessageKey = null;
197
351
  this.lastRaceDataInteraction = performance.now();
198
352
  this.radioRandomState = (this.trackSeed ^ 0x9e3779b9) >>> 0;
199
353
  this.radioState = {
@@ -207,17 +361,26 @@ export class F1SimulatorApp {
207
361
  mode: options.initialCameraMode,
208
362
  zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
209
363
  scale: null,
364
+ initialized: false,
210
365
  x: WORLD.width / 2,
211
366
  y: WORLD.height / 2,
367
+ free: false,
368
+ freeTarget: null,
212
369
  };
213
370
  this.overviewMode = 'vehicle';
371
+ this.simulationSpeed = 1;
214
372
  this.accumulator = 0;
215
373
  this.lastTime = performance.now();
216
374
  this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
217
375
  this.lastDomUpdateTime = 0;
218
376
  this.lastTimingRenderTime = 0;
219
377
  this.lastTimingRaceMode = null;
378
+ this.lastTimingPenaltyKey = '';
379
+ this.lastTimingOrderKey = '';
220
380
  this.lastTimingMarkup = null;
381
+ this.lastPitLaneStatusRenderKey = null;
382
+ this.pitCameraBoundsCache = null;
383
+ this.trackCameraBoundsCache = null;
221
384
  this.lastOverviewRenderKey = null;
222
385
  this.lastFinishClassificationMarkup = null;
223
386
  this.runtimeViewportVisible = true;
@@ -259,27 +422,38 @@ export class F1SimulatorApp {
259
422
  this.drsLayer = new Container();
260
423
  this.trailLayer = new Graphics();
261
424
  this.sensorLayer = new Graphics();
425
+ this.pitLaneStatusLayer = new Graphics();
262
426
  this.carLayer = new Container();
263
427
  this.app.stage.addChild(this.worldLayer);
264
428
 
265
429
  await this.loadAssets();
266
430
  this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
267
- this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, this.sensorLayer, this.carLayer);
431
+ this.worldLayer.addChild(
432
+ this.trackAsset.container,
433
+ this.drsLayer,
434
+ this.trailLayer,
435
+ this.pitLaneStatusLayer,
436
+ this.sensorLayer,
437
+ this.carLayer,
438
+ );
268
439
  this.createCars();
269
440
  this.bindControls();
270
441
  this.renderTrack();
271
442
  const snapshot = this.sim.snapshot();
272
443
  this.updateDom(snapshot);
444
+ this.lastDomUpdateTime = performance.now();
273
445
  if (this.expertMode) {
274
446
  this.expert = createBrowserExpertAdapter(this, this.options.expert);
275
447
  }
448
+ this.renderInitialFrame(snapshot);
276
449
  this.completeComponentLoading();
277
450
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
278
451
  this.emitHostCallback('onReady', { snapshot });
279
- this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
452
+ this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
280
453
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
281
454
  this.observeLayoutResize();
282
455
  if (!this.expertMode) {
456
+ this.resetFrameClock();
283
457
  this.tickerCallback = () => this.tick();
284
458
  this.app.ticker.add(this.tickerCallback);
285
459
  this.observeRuntimeVisibility();
@@ -322,6 +496,7 @@ export class F1SimulatorApp {
322
496
  trackSeed: options.trackSeed ?? this.trackSeed,
323
497
  drivers: options.drivers ?? this.drivers,
324
498
  totalLaps: options.totalLaps,
499
+ rules: options.rules,
325
500
  });
326
501
  }
327
502
 
@@ -363,8 +538,10 @@ export class F1SimulatorApp {
363
538
 
364
539
  this.carSprites.forEach((sprite) => sprite.destroy());
365
540
  this.carHitAreas.forEach((hit) => hit.destroy());
541
+ this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
366
542
  this.carSprites.clear();
367
543
  this.carHitAreas.clear();
544
+ this.serviceCountdownLabels.clear();
368
545
  this.drsTrails.clear();
369
546
 
370
547
  if (this.safetySprite) {
@@ -373,11 +550,17 @@ export class F1SimulatorApp {
373
550
  }
374
551
 
375
552
  this.drivers.forEach((driver) => {
376
- const sprite = new Sprite(texture);
377
- sprite.anchor.set(0.5);
378
- sprite.baseScale = baseScale;
379
- sprite.scale.set(baseScale);
380
- sprite.tint = colorToTint(driver.color);
553
+ const sprite = TEMP_RENDER_RAW_CAR_GEOMETRY
554
+ ? this.createRawCarGeometryGraphic(driver)
555
+ : new Sprite(texture);
556
+ if (!TEMP_RENDER_RAW_CAR_GEOMETRY) {
557
+ sprite.anchor.set(0.5);
558
+ sprite.baseScale = baseScale;
559
+ sprite.scale.set(baseScale);
560
+ } else {
561
+ sprite.baseScale = 1;
562
+ }
563
+ sprite.tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(driver.color);
381
564
  sprite.eventMode = 'static';
382
565
  sprite.cursor = 'pointer';
383
566
  sprite.on('pointerdown', () => {
@@ -395,9 +578,85 @@ export class F1SimulatorApp {
395
578
  });
396
579
  this.carHitAreas.set(driver.id, hit);
397
580
  this.carLayer.addChild(hit);
581
+
582
+ const serviceLabel = this.createServiceCountdownLabel();
583
+ serviceLabel.visible = false;
584
+ this.serviceCountdownLabels.set(driver.id, serviceLabel);
585
+ this.carLayer.addChild(serviceLabel);
398
586
  });
399
587
  }
400
588
 
589
+ createRawCarGeometryGraphic(driver) {
590
+ const graphic = new Graphics();
591
+ const tint = colorToTint(driver.color);
592
+ const wheelLong = VEHICLE_GEOMETRY.wheelLongitudinalOffset;
593
+ const wheelLat = VEHICLE_GEOMETRY.wheelLateralOffset;
594
+ const wheelLength = VEHICLE_GEOMETRY.wheelLength;
595
+ const wheelWidth = VEHICLE_GEOMETRY.wheelWidth;
596
+
597
+ graphic
598
+ .rect(
599
+ -VEHICLE_GEOMETRY.bodyLength / 2,
600
+ -VEHICLE_GEOMETRY.bodyWidth / 2,
601
+ VEHICLE_GEOMETRY.bodyLength,
602
+ VEHICLE_GEOMETRY.bodyWidth,
603
+ )
604
+ .fill({ color: tint, alpha: 0.42 })
605
+ .stroke({ width: 2.2, color: 0xf8fafc, alpha: 0.95 });
606
+
607
+ [
608
+ [wheelLong, -wheelLat],
609
+ [wheelLong, wheelLat],
610
+ [-wheelLong, -wheelLat],
611
+ [-wheelLong, wheelLat],
612
+ ].forEach(([x, y]) => {
613
+ graphic
614
+ .rect(x - wheelLength / 2, y - wheelWidth / 2, wheelLength, wheelWidth)
615
+ .fill({ color: 0x111827, alpha: 0.95 })
616
+ .stroke({ width: 1.5, color: tint, alpha: 1 });
617
+ });
618
+
619
+ const noseMarkerLength = metersToSimUnits(0.75);
620
+ graphic
621
+ .moveTo(VEHICLE_GEOMETRY.bodyLength / 2 - noseMarkerLength, 0)
622
+ .lineTo(VEHICLE_GEOMETRY.bodyLength / 2 + noseMarkerLength, 0)
623
+ .stroke({ width: 2, color: 0xf1c65b, alpha: 0.95 });
624
+ return graphic;
625
+ }
626
+
627
+ createServiceCountdownLabel() {
628
+ const container = new Container();
629
+ const background = new Graphics();
630
+ const text = new Text({
631
+ text: '0s',
632
+ style: {
633
+ fill: 0xffffff,
634
+ fontFamily: 'Arial, sans-serif',
635
+ fontSize: 16,
636
+ fontWeight: '900',
637
+ align: 'center',
638
+ },
639
+ });
640
+ text.anchor.set(0.5);
641
+ text.resolution = 2;
642
+ container.addChild(background, text);
643
+ container.labelBackground = background;
644
+ container.labelText = text;
645
+ this.applyServiceCountdownTone(container, 'pit');
646
+ return container;
647
+ }
648
+
649
+ applyServiceCountdownTone(label, tone) {
650
+ const style = SERVICE_COUNTDOWN_LABEL_STYLES[tone] ?? SERVICE_COUNTDOWN_LABEL_STYLES.pit;
651
+ if (label.serviceTone === tone) return;
652
+ label.labelBackground?.clear?.();
653
+ label.labelBackground?.roundRect(-28, -14, 56, 28, 4)
654
+ .fill({ color: style.background, alpha: 0.92 })
655
+ .stroke({ width: 2, color: style.stroke, alpha: 0.95 });
656
+ if (label.labelText?.style) label.labelText.style.fill = style.fill;
657
+ label.serviceTone = tone;
658
+ }
659
+
401
660
  bindControls() {
402
661
  const eventOptions = { signal: this.abortController.signal };
403
662
 
@@ -417,27 +676,38 @@ export class F1SimulatorApp {
417
676
  this.trailLayer?.clear();
418
677
  this.lastTimingRenderTime = 0;
419
678
  this.lastTimingRaceMode = null;
679
+ this.lastTimingPenaltyKey = '';
680
+ this.lastTimingOrderKey = '';
420
681
  this.renderTrack();
421
682
  }, eventOptions);
422
683
 
423
684
  this.cameraButtons.forEach((button) => {
424
685
  button.addEventListener('click', () => {
425
- this.camera.mode = button.dataset.cameraMode;
426
- if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
427
- this.updateCameraControls();
686
+ this.setCameraMode(button.dataset.cameraMode);
428
687
  }, eventOptions);
429
688
  });
430
689
 
431
690
  this.zoomInButton?.addEventListener('click', () => {
432
- this.camera.zoom = clamp(this.camera.zoom + 0.42, 0.72, 8.5);
433
- this.updateCameraControls();
691
+ this.adjustCameraZoom(1);
434
692
  }, eventOptions);
435
693
 
436
694
  this.zoomOutButton?.addEventListener('click', () => {
437
- this.camera.zoom = clamp(this.camera.zoom - 0.42, 0.72, 8.5);
438
- this.updateCameraControls();
695
+ this.adjustCameraZoom(-1);
439
696
  }, eventOptions);
440
697
 
698
+ this.simulationSpeedButtons?.forEach((button) => {
699
+ button.addEventListener('click', () => {
700
+ this.cycleSimulationSpeed();
701
+ }, eventOptions);
702
+ });
703
+ this.updateSimulationSpeedControls();
704
+
705
+ this.bannerMuteButtons?.forEach((button) => {
706
+ button.addEventListener('click', () => {
707
+ this.setRaceDataBannersMuted(!this.raceDataBannersMuted);
708
+ }, eventOptions);
709
+ });
710
+
441
711
  this.overviewModeButtons.forEach((button) => {
442
712
  button.addEventListener('click', () => {
443
713
  this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
@@ -451,7 +721,7 @@ export class F1SimulatorApp {
451
721
  this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
452
722
  this.syncTimingGapModeControls();
453
723
  const snapshot = this.sim?.snapshot?.();
454
- if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
724
+ if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
455
725
  }, eventOptions);
456
726
  });
457
727
 
@@ -467,9 +737,61 @@ export class F1SimulatorApp {
467
737
  this.options.onDriverOpen?.(driver);
468
738
  }, eventOptions);
469
739
 
740
+ this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
741
+ event.preventDefault();
742
+ event.stopPropagation();
743
+ this.dismissRaceDataPanel(performance.now());
744
+ }, eventOptions);
745
+
470
746
  this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
471
747
  this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
472
748
  }, eventOptions);
749
+
750
+ this.bindCameraWheelControls(eventOptions);
751
+ }
752
+
753
+ setCameraMode(mode) {
754
+ const snapshot = this.sim?.snapshot?.();
755
+ if (!this.isCameraModeAvailable(mode, snapshot)) return false;
756
+ this.camera.mode = mode;
757
+ this.camera.free = false;
758
+ this.camera.freeTarget = null;
759
+ if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
760
+ this.updateCameraControls(snapshot);
761
+ return true;
762
+ }
763
+
764
+ adjustCameraZoom(direction) {
765
+ const steps = Number.isFinite(Number(direction)) ? Number(direction) : 0;
766
+ this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
767
+ this.updateCameraControls();
768
+ return this.camera.zoom;
769
+ }
770
+
771
+ cycleSimulationSpeed() {
772
+ const currentIndex = SIMULATION_SPEED_STEPS.indexOf(this.simulationSpeed);
773
+ const nextIndex = currentIndex >= 0
774
+ ? (currentIndex + 1) % SIMULATION_SPEED_STEPS.length
775
+ : 0;
776
+ this.simulationSpeed = SIMULATION_SPEED_STEPS[nextIndex];
777
+ this.updateSimulationSpeedControls();
778
+ return this.simulationSpeed;
779
+ }
780
+
781
+ updateSimulationSpeedControls() {
782
+ this.simulationSpeedButtons?.forEach((button) => {
783
+ const label = `${this.simulationSpeed}x`;
784
+ button.textContent = label;
785
+ button.setAttribute?.('aria-label', `Simulation speed ${label}`);
786
+ });
787
+ }
788
+
789
+ bindCameraWheelControls(eventOptions) {
790
+ if (!this.canvasHost?.addEventListener) return;
791
+ this.canvasHost.addEventListener('wheel', (event) => {
792
+ event.preventDefault?.();
793
+ this.adjustCameraZoom(event.deltaY > 0 ? -1 : 1);
794
+ }, { ...eventOptions, passive: false });
473
795
  }
474
796
 
475
797
  setTelemetryDrawerOpen(open) {
@@ -487,12 +809,73 @@ export class F1SimulatorApp {
487
809
  this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
488
810
  }
489
811
  this.invalidateCameraSafeArea();
812
+ this.syncRendererToCurrentLayout({ render: true });
813
+ this.scheduleLayoutResizeSync(380);
814
+ }
815
+
816
+ setRaceDataBannersMuted(muted, now = performance.now()) {
817
+ this.raceDataBannersMuted = Boolean(muted);
818
+ if (this.raceDataBannersMuted) {
819
+ this.activeRaceDataId = null;
820
+ this.radioState.visible = false;
821
+ this.radioState.nextChangeAt = Number.POSITIVE_INFINITY;
822
+ this.hideRaceDataPanel();
823
+ } else {
824
+ this.resetRaceDataBannerState(now);
825
+ }
826
+ this.updateBannerMuteControls();
827
+ }
828
+
829
+ resizeRendererToCanvasHost() {
830
+ if (!this.app?.renderer?.resize || !this.canvasHost) return;
831
+ const width = Math.max(1, Math.round(this.canvasHost.clientWidth || 0));
832
+ const height = Math.max(1, Math.round(this.canvasHost.clientHeight || 0));
833
+ this.app.renderer.resize(width, height);
834
+ }
835
+
836
+ renderInitialFrame(snapshot) {
837
+ const renderSnapshot = createRenderSnapshot(snapshot, 0);
838
+ this.resizeRendererToCanvasHost();
839
+ this.applyCamera(renderSnapshot, { immediate: true });
840
+ this.renderDrsTrails(renderSnapshot);
841
+ this.renderPitLaneStatus(renderSnapshot);
842
+ this.renderCars(renderSnapshot);
843
+ this.app?.render?.();
844
+ }
845
+
846
+ syncRendererToCurrentLayout({ render = false } = {}) {
847
+ this.resizeRendererToCanvasHost();
848
+ const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
849
+ if (!snapshot) return;
850
+ const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
851
+ this.applyCamera(renderSnapshot);
852
+ if (render) {
853
+ this.renderDrsTrails(renderSnapshot);
854
+ this.renderPitLaneStatus(renderSnapshot);
855
+ this.renderCars(renderSnapshot);
856
+ this.app?.render?.();
857
+ }
858
+ }
859
+
860
+ scheduleLayoutResizeSync(durationMs = 360) {
861
+ if (typeof requestAnimationFrame !== 'function') return;
862
+ const startedAt = performance.now();
863
+ const run = (timestamp = performance.now()) => {
864
+ this.syncRendererToCurrentLayout({ render: true });
865
+ if (timestamp - startedAt < durationMs) {
866
+ this.layoutResizeFrame = requestAnimationFrame(run);
867
+ }
868
+ };
869
+ this.layoutResizeFrame = requestAnimationFrame(run);
490
870
  }
491
871
 
492
872
  observeLayoutResize() {
493
873
  if (typeof ResizeObserver !== 'function') return;
494
874
  this.layoutResizeObserver?.disconnect?.();
495
- this.layoutResizeObserver = new ResizeObserver(() => this.invalidateCameraSafeArea());
875
+ this.layoutResizeObserver = new ResizeObserver(() => {
876
+ this.invalidateCameraSafeArea();
877
+ this.syncRendererToCurrentLayout({ render: true });
878
+ });
496
879
  if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
497
880
  if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
498
881
  }
@@ -551,6 +934,7 @@ export class F1SimulatorApp {
551
934
  this.accumulator = 0;
552
935
  this.lastTime = now;
553
936
  this.nextGameFrameTime = now + TARGET_FRAME_MS;
937
+ this.fps.current = 0;
554
938
  this.fps.frames = 0;
555
939
  this.fps.lastSample = now;
556
940
  }
@@ -572,13 +956,16 @@ export class F1SimulatorApp {
572
956
  }
573
957
  this.lastTime = now;
574
958
  this.sampleFps(now);
575
- this.accumulator += frameSeconds * SIM_SPEED;
959
+ this.accumulator += frameSeconds * this.simulationSpeed;
576
960
 
577
961
  let simulationSteps = 0;
578
- while (this.accumulator >= FIXED_STEP && simulationSteps < MAX_SIMULATION_STEPS_PER_RENDER) {
962
+ const stepEvents = [];
963
+ const maxSimulationSteps = maxSimulationStepsForFrame(this.simulationSpeed, elapsedFrameCount);
964
+ while (this.accumulator >= FIXED_STEP && simulationSteps < maxSimulationSteps) {
579
965
  this.sim.step(FIXED_STEP);
580
- const stepSnapshot = this.sim.snapshot();
581
- this.emitSnapshotLifecycle(stepSnapshot);
966
+ if (Array.isArray(this.sim.events) && this.sim.events.length > 0) {
967
+ stepEvents.push(...this.sim.events);
968
+ }
582
969
  this.accumulator -= FIXED_STEP;
583
970
  simulationSteps += 1;
584
971
  }
@@ -588,13 +975,17 @@ export class F1SimulatorApp {
588
975
  this.nextGameFrameTime = now + TARGET_FRAME_MS;
589
976
  }
590
977
 
591
- const snapshot = this.sim.snapshot();
592
- const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
978
+ const shouldUpdateDom = now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed);
979
+ const fullSnapshot = stepEvents.length > 0 || shouldUpdateDom ? this.sim.snapshot() : null;
980
+ if (stepEvents.length > 0) this.emitRaceEvents(stepEvents, fullSnapshot);
981
+ const renderSource = fullSnapshot ?? this.sim.snapshotRender?.() ?? this.sim.snapshot();
982
+ const renderSnapshot = createRenderSnapshot(renderSource, clamp(this.accumulator / FIXED_STEP, 0, 1));
593
983
  this.applyCamera(renderSnapshot);
594
984
  this.renderDrsTrails(renderSnapshot);
985
+ this.renderPitLaneStatus(renderSnapshot);
595
986
  this.renderCars(renderSnapshot);
596
- if (now - this.lastDomUpdateTime >= DOM_UPDATE_INTERVAL_MS) {
597
- this.updateDom(snapshot, { emitLifecycle: false });
987
+ if (shouldUpdateDom) {
988
+ this.updateDom(fullSnapshot, { emitLifecycle: false });
598
989
  this.lastDomUpdateTime = now;
599
990
  }
600
991
  }
@@ -605,6 +996,7 @@ export class F1SimulatorApp {
605
996
  this.applyCamera(renderSnapshot);
606
997
  this.renderDrsTrails(renderSnapshot);
607
998
  this.renderExpertSensorRays(renderSnapshot, observation);
999
+ this.renderPitLaneStatus(renderSnapshot);
608
1000
  this.renderCars(renderSnapshot);
609
1001
  this.updateDom(snapshot, { emitLifecycle: false });
610
1002
  this.lastDomUpdateTime = performance.now();
@@ -613,6 +1005,10 @@ export class F1SimulatorApp {
613
1005
  renderTrack() {
614
1006
  destroyDisplayChildren(this.drsLayer);
615
1007
  this.sensorLayer?.clear();
1008
+ this.pitLaneStatusLayer?.clear();
1009
+ this.lastPitLaneStatusRenderKey = null;
1010
+ this.pitCameraBoundsCache = null;
1011
+ this.trackCameraBoundsCache = null;
616
1012
  const snapshot = this.sim.snapshot();
617
1013
  const track = snapshot.track;
618
1014
 
@@ -632,20 +1028,70 @@ export class F1SimulatorApp {
632
1028
  });
633
1029
  }
634
1030
 
1031
+ renderPitLaneStatus(snapshot) {
1032
+ if (!this.pitLaneStatusLayer) return;
1033
+ const pitLane = snapshot.track?.pitLane;
1034
+ if (!pitLane?.enabled || !pitLane.mainLane?.start) {
1035
+ if (this.lastPitLaneStatusRenderKey !== 'none') {
1036
+ this.pitLaneStatusLayer.clear();
1037
+ this.lastPitLaneStatusRenderKey = 'none';
1038
+ }
1039
+ return;
1040
+ }
1041
+ const status = snapshot.pitLaneStatus ?? snapshot.raceControl?.pitLaneStatus;
1042
+ const color = colorToTint(status?.light ?? (status?.open ? '#22c55e' : '#ef4444'));
1043
+ const heading = pitLane.mainLane.heading ?? 0;
1044
+ const normal = pitLane.serviceNormal ?? { x: -Math.sin(heading), y: Math.cos(heading) };
1045
+ const sign = pitLane.workingLane?.offset != null && pitLane.workingLane.offset < 0 ? 1 : -1;
1046
+ const base = pitLane.mainLane.start;
1047
+ const x = base.x + normal.x * sign * (pitLane.width * 0.72);
1048
+ const y = base.y + normal.y * sign * (pitLane.width * 0.72);
1049
+ const renderKey = [
1050
+ color,
1051
+ x.toFixed(3),
1052
+ y.toFixed(3),
1053
+ pitLane.width,
1054
+ status?.open ? 1 : 0,
1055
+ status?.reason ?? '',
1056
+ ].join(':');
1057
+ if (renderKey === this.lastPitLaneStatusRenderKey) return;
1058
+ this.lastPitLaneStatusRenderKey = renderKey;
1059
+
1060
+ this.pitLaneStatusLayer.clear();
1061
+ this.pitLaneStatusLayer
1062
+ .circle(x, y, 17)
1063
+ .fill({ color: 0x0b0f14, alpha: 0.92 })
1064
+ .circle(x, y, 11)
1065
+ .fill({ color, alpha: 0.94 })
1066
+ .circle(x, y, 18)
1067
+ .stroke({ width: 3, color: 0xf8fafc, alpha: 0.82 });
1068
+ }
1069
+
635
1070
  renderCars(snapshot) {
636
1071
  snapshot.cars.forEach((car) => {
637
1072
  const sprite = this.carSprites.get(car.id);
638
1073
  const hit = this.carHitAreas.get(car.id);
639
1074
  if (!sprite || !hit) return;
640
- sprite.x = car.x;
641
- sprite.y = car.y;
642
- sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
643
- sprite.rotation = sprite.currentRotation;
644
- sprite.alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
645
- sprite.scale.set(sprite.baseScale);
646
- sprite.tint = colorToTint(car.color);
647
- hit.x = car.x;
648
- hit.y = car.y;
1075
+ if (sprite.x !== car.x) sprite.x = car.x;
1076
+ if (sprite.y !== car.y) sprite.y = car.y;
1077
+ if (TEMP_RENDER_RAW_CAR_GEOMETRY) {
1078
+ sprite.currentRotation = car.heading;
1079
+ if (sprite.rotation !== car.heading) sprite.rotation = car.heading;
1080
+ } else {
1081
+ sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
1082
+ if (sprite.rotation !== sprite.currentRotation) sprite.rotation = sprite.currentRotation;
1083
+ }
1084
+ const alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
1085
+ if (sprite.alpha !== alpha) sprite.alpha = alpha;
1086
+ if (sprite.lastRenderedScale !== sprite.baseScale) {
1087
+ sprite.scale.set(sprite.baseScale);
1088
+ sprite.lastRenderedScale = sprite.baseScale;
1089
+ }
1090
+ const tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(car.color);
1091
+ if (sprite.tint !== tint) sprite.tint = tint;
1092
+ if (hit.x !== car.x) hit.x = car.x;
1093
+ if (hit.y !== car.y) hit.y = car.y;
1094
+ this.renderServiceCountdownLabel(car);
649
1095
  });
650
1096
 
651
1097
  if (!this.safetySprite && snapshot.safetyCar.deployed) {
@@ -661,10 +1107,35 @@ export class F1SimulatorApp {
661
1107
  }
662
1108
 
663
1109
  if (this.safetySprite) {
664
- this.safetySprite.visible = snapshot.safetyCar.deployed;
665
- this.safetySprite.x = snapshot.safetyCar.x;
666
- this.safetySprite.y = snapshot.safetyCar.y;
667
- this.safetySprite.rotation = snapshot.safetyCar.heading;
1110
+ if (this.safetySprite.visible !== snapshot.safetyCar.deployed) {
1111
+ this.safetySprite.visible = snapshot.safetyCar.deployed;
1112
+ }
1113
+ if (this.safetySprite.x !== snapshot.safetyCar.x) this.safetySprite.x = snapshot.safetyCar.x;
1114
+ if (this.safetySprite.y !== snapshot.safetyCar.y) this.safetySprite.y = snapshot.safetyCar.y;
1115
+ if (this.safetySprite.rotation !== snapshot.safetyCar.heading) this.safetySprite.rotation = snapshot.safetyCar.heading;
1116
+ }
1117
+ }
1118
+
1119
+ renderServiceCountdownLabel(car) {
1120
+ const label = this.serviceCountdownLabels.get(car.id);
1121
+ if (!label) return;
1122
+ const penaltyRemaining = Number(car.pitStop?.penaltyServiceRemainingSeconds);
1123
+ const serviceRemaining = Number(car.pitStop?.serviceRemainingSeconds);
1124
+ const isPenalty = car.pitStop?.phase === 'penalty' && Number.isFinite(penaltyRemaining) && penaltyRemaining > 0;
1125
+ const isPitService = car.pitStop?.phase === 'service' && Number.isFinite(serviceRemaining) && serviceRemaining > 0;
1126
+ const visible = isPenalty || isPitService;
1127
+ label.visible = visible;
1128
+ if (!visible) return;
1129
+ const tone = isPenalty ? 'penalty' : 'pit';
1130
+ const remaining = isPenalty ? penaltyRemaining : serviceRemaining;
1131
+ if (label.x !== car.x) label.x = car.x;
1132
+ const labelY = car.y - 48;
1133
+ if (label.y !== labelY) label.y = labelY;
1134
+ if (label.rotation !== 0) label.rotation = 0;
1135
+ this.applyServiceCountdownTone(label, tone);
1136
+ if (label.labelText) {
1137
+ const nextText = `${tone === 'penalty' ? '+' : ''}${Math.ceil(remaining)}s`;
1138
+ if (label.labelText.text !== nextText) label.labelText.text = nextText;
668
1139
  }
669
1140
  }
670
1141
 
@@ -726,47 +1197,59 @@ export class F1SimulatorApp {
726
1197
  if (!this.trailLayer) return;
727
1198
 
728
1199
  snapshot.cars.forEach((car) => {
729
- const history = this.drsTrails.get(car.id) ?? [];
1200
+ const history = this.drsTrails.get(car.id);
1201
+ if (!history && !car.drsActive) return;
1202
+ const activeHistory = history ?? [];
730
1203
  const rear = {
731
1204
  x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
732
1205
  y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
733
1206
  at: snapshot.time,
734
1207
  };
735
- const last = history[history.length - 1];
1208
+ const last = activeHistory[activeHistory.length - 1];
736
1209
 
737
1210
  if (
738
1211
  car.drsActive &&
739
1212
  (!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
740
1213
  ) {
741
- history.push(rear);
1214
+ activeHistory.push(rear);
742
1215
  }
743
1216
 
744
- const activeTrail = history.filter((point) => snapshot.time - point.at <= DRS_TRAIL_TTL);
745
- if (activeTrail.length) this.drsTrails.set(car.id, activeTrail);
1217
+ pruneExpiredTrailPoints(activeHistory, snapshot.time);
1218
+ if (activeHistory.length) this.drsTrails.set(car.id, activeHistory);
746
1219
  else this.drsTrails.delete(car.id);
747
1220
  });
748
1221
 
749
1222
  this.trailLayer.clear();
750
1223
  this.drsTrails.forEach((history) => {
1224
+ if (history.length < 2) return;
1225
+ const newest = history[history.length - 1];
1226
+ const newestLife = clamp(1 - (snapshot.time - newest.at) / DRS_TRAIL_TTL, 0, 1);
1227
+ this.trailLayer.moveTo(history[0].x, history[0].y);
751
1228
  for (let index = 1; index < history.length; index += 1) {
752
- const previous = history[index - 1];
753
- const point = history[index];
754
- const age = snapshot.time - point.at;
755
- const life = clamp(1 - age / DRS_TRAIL_TTL, 0, 1);
756
- this.trailLayer.moveTo(previous.x, previous.y);
757
- this.trailLayer.lineTo(point.x, point.y);
758
- this.trailLayer.stroke({
759
- width: 5 + life * 7,
760
- color: 0x3be8ff,
761
- alpha: 0.22 + life * 0.48,
762
- cap: 'round',
763
- join: 'round',
764
- });
1229
+ this.trailLayer.lineTo(history[index].x, history[index].y);
765
1230
  }
1231
+ this.trailLayer.stroke({
1232
+ width: 10,
1233
+ color: 0x3be8ff,
1234
+ alpha: 0.28 + newestLife * 0.34,
1235
+ cap: 'round',
1236
+ join: 'round',
1237
+ });
1238
+ this.trailLayer.moveTo(history[0].x, history[0].y);
1239
+ for (let index = 1; index < history.length; index += 1) {
1240
+ this.trailLayer.lineTo(history[index].x, history[index].y);
1241
+ }
1242
+ this.trailLayer.stroke({
1243
+ width: 3.2,
1244
+ color: 0xd8fbff,
1245
+ alpha: 0.18 + newestLife * 0.28,
1246
+ cap: 'round',
1247
+ join: 'round',
1248
+ });
766
1249
  });
767
1250
  }
768
1251
 
769
- applyCamera(snapshot) {
1252
+ applyCamera(snapshot, { immediate = false } = {}) {
770
1253
  if (!this.worldLayer) return;
771
1254
 
772
1255
  const width = this.canvasHost.clientWidth || 900;
@@ -776,11 +1259,17 @@ export class F1SimulatorApp {
776
1259
  const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
777
1260
  const scale = frame.scale;
778
1261
  const target = frame.target;
779
- this.camera.x += (target.x - this.camera.x) * 0.08;
780
- this.camera.y += (target.y - this.camera.y) * 0.08;
781
- const activeScale = this.camera.scale === null
1262
+ const snapCamera = immediate || !this.camera.initialized;
1263
+ this.camera.x = snapCamera
1264
+ ? target.x
1265
+ : this.camera.x + (target.x - this.camera.x) * CAMERA_TARGET_LERP;
1266
+ this.camera.y = snapCamera
1267
+ ? target.y
1268
+ : this.camera.y + (target.y - this.camera.y) * CAMERA_TARGET_LERP;
1269
+ const activeScale = snapCamera || this.camera.scale === null
782
1270
  ? scale
783
- : this.camera.scale + (scale - this.camera.scale) * 0.12;
1271
+ : this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
1272
+ this.camera.initialized = true;
784
1273
  this.camera.scale = activeScale;
785
1274
  this.worldLayer.scale.set(activeScale);
786
1275
  this.worldLayer.position.set(
@@ -829,6 +1318,28 @@ export class F1SimulatorApp {
829
1318
 
830
1319
  getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
831
1320
  const screenCenterX = safeArea.left + safeArea.width / 2;
1321
+ const trackBounds = this.getTrackCameraBounds(snapshot.track);
1322
+ const trackFitScale = this.getCameraBoundsFitScale(trackBounds, height, safeArea);
1323
+
1324
+ if (this.camera.mode === 'overview') {
1325
+ const target = trackBounds
1326
+ ? {
1327
+ x: (trackBounds.minX + trackBounds.maxX) / 2,
1328
+ y: (trackBounds.minY + trackBounds.maxY) / 2,
1329
+ }
1330
+ : { x: WORLD.width / 2, y: WORLD.height / 2 };
1331
+
1332
+ const scale = trackFitScale
1333
+ ? clampCameraScale(trackFitScale * this.camera.zoom, trackFitScale, trackFitScale)
1334
+ : clampCameraScale(baseScale * this.camera.zoom, baseScale);
1335
+
1336
+ return this.applyFreeCameraFrame({
1337
+ target,
1338
+ scale,
1339
+ screenX: screenCenterX,
1340
+ screenY: height / 2,
1341
+ });
1342
+ }
832
1343
 
833
1344
  if (this.camera.mode === 'show-all') {
834
1345
  const bounds = snapshot.cars.reduce((box, car) => ({
@@ -849,30 +1360,44 @@ export class F1SimulatorApp {
849
1360
  const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
850
1361
  const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
851
1362
  const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
852
- const scale = clamp(
853
- Math.min(safeArea.width / fitWidth, safeHeight / fitHeight),
854
- baseScale * SHOW_ALL_MIN_ZOOM,
855
- baseScale * SHOW_ALL_MAX_ZOOM,
856
- );
857
- return {
1363
+ const fitScale = Math.min(safeArea.width / fitWidth, safeHeight / fitHeight);
1364
+ const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, trackFitScale);
1365
+ return this.applyFreeCameraFrame({
858
1366
  target,
859
1367
  scale,
860
1368
  screenX: screenCenterX,
861
- screenY: SHOW_ALL_TOP_RESERVED + safeHeight / 2,
862
- };
1369
+ screenY: height / 2,
1370
+ });
863
1371
  }
864
1372
 
865
- return {
1373
+ if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
1374
+ return this.applyFreeCameraFrame(
1375
+ this.getPitCameraFrame(snapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
1376
+ );
1377
+ }
1378
+
1379
+ return this.applyFreeCameraFrame({
866
1380
  target: this.getCameraTarget(snapshot),
867
- scale: baseScale * this.camera.zoom,
1381
+ scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
868
1382
  screenX: screenCenterX,
869
1383
  screenY: height / 2,
1384
+ });
1385
+ }
1386
+
1387
+ applyFreeCameraFrame(frame) {
1388
+ if (!this.camera.free || !this.camera.freeTarget) return frame;
1389
+ return {
1390
+ ...frame,
1391
+ target: this.camera.freeTarget,
870
1392
  };
871
1393
  }
872
1394
 
873
1395
  getCameraTarget(snapshot) {
874
- if (this.camera.mode === 'overview') {
875
- return { x: WORLD.width / 2, y: WORLD.height / 2 };
1396
+ if (this.camera.free && this.camera.freeTarget) return this.camera.freeTarget;
1397
+ if (this.camera.mode === 'overview') return this.getTrackCameraTarget(snapshot.track);
1398
+
1399
+ if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
1400
+ return this.getPitCameraTarget(snapshot.track.pitLane);
876
1401
  }
877
1402
 
878
1403
  if (this.camera.mode === 'selected') {
@@ -884,6 +1409,145 @@ export class F1SimulatorApp {
884
1409
  return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
885
1410
  }
886
1411
 
1412
+ getTrackCameraBounds(track) {
1413
+ if (this.trackCameraBoundsCache && this.trackCameraBoundsCache.track === track) {
1414
+ return this.trackCameraBoundsCache.bounds;
1415
+ }
1416
+
1417
+ const bounds = {
1418
+ minX: Infinity,
1419
+ minY: Infinity,
1420
+ maxX: -Infinity,
1421
+ maxY: -Infinity,
1422
+ };
1423
+ const includePoint = (point) => {
1424
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
1425
+ bounds.minX = Math.min(bounds.minX, point.x);
1426
+ bounds.minY = Math.min(bounds.minY, point.y);
1427
+ bounds.maxX = Math.max(bounds.maxX, point.x);
1428
+ bounds.maxY = Math.max(bounds.maxY, point.y);
1429
+ };
1430
+ const includeBounds = (box) => {
1431
+ if (!box) return;
1432
+ includePoint({ x: box.minX, y: box.minY });
1433
+ includePoint({ x: box.maxX, y: box.maxY });
1434
+ };
1435
+
1436
+ (track?.samples ?? []).forEach(includePoint);
1437
+ includeBounds(track?.pitLane?.bounds);
1438
+
1439
+ const trackEdgePadding = Math.max(
1440
+ TRACK_CAMERA_PADDING,
1441
+ ((track?.width ?? 0) / 2) + (track?.kerbWidth ?? 0) + (track?.runoffWidth ?? 0),
1442
+ );
1443
+ const result = Number.isFinite(bounds.minX)
1444
+ ? {
1445
+ minX: bounds.minX - trackEdgePadding,
1446
+ minY: bounds.minY - trackEdgePadding,
1447
+ maxX: bounds.maxX + trackEdgePadding,
1448
+ maxY: bounds.maxY + trackEdgePadding,
1449
+ }
1450
+ : null;
1451
+
1452
+ this.trackCameraBoundsCache = { track, bounds: result };
1453
+ return result;
1454
+ }
1455
+
1456
+ getTrackCameraTarget(track) {
1457
+ const bounds = this.getTrackCameraBounds(track);
1458
+ if (!bounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
1459
+
1460
+ return {
1461
+ x: (bounds.minX + bounds.maxX) / 2,
1462
+ y: (bounds.minY + bounds.maxY) / 2,
1463
+ };
1464
+ }
1465
+
1466
+ getCameraBoundsFitScale(bounds, height, safeArea) {
1467
+ if (!bounds) return null;
1468
+ const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX);
1469
+ const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY);
1470
+ const scale = Math.min(safeArea.width / fitWidth, height / fitHeight);
1471
+ return Number.isFinite(scale) && scale > 0 ? scale : null;
1472
+ }
1473
+
1474
+ getPitCameraBounds(pitLane) {
1475
+ if (this.pitCameraBoundsCache?.pitLane === pitLane) return this.pitCameraBoundsCache.bounds;
1476
+
1477
+ const bounds = {
1478
+ minX: Infinity,
1479
+ minY: Infinity,
1480
+ maxX: -Infinity,
1481
+ maxY: -Infinity,
1482
+ };
1483
+ const includePoint = (point) => {
1484
+ if (!point) return;
1485
+ bounds.minX = Math.min(bounds.minX, point.x);
1486
+ bounds.minY = Math.min(bounds.minY, point.y);
1487
+ bounds.maxX = Math.max(bounds.maxX, point.x);
1488
+ bounds.maxY = Math.max(bounds.maxY, point.y);
1489
+ };
1490
+ const includePoints = (points = []) => {
1491
+ points.forEach(includePoint);
1492
+ };
1493
+
1494
+ includePoint(pitLane.entry?.lanePoint);
1495
+ includePoints(pitLane.mainLane?.points);
1496
+ includePoints(pitLane.workingLane?.points);
1497
+ includePoint(pitLane.exit?.lanePoint);
1498
+ (pitLane.boxes ?? []).forEach((box) => includePoints(box.corners));
1499
+ (pitLane.serviceAreas ?? []).forEach((area) => {
1500
+ includePoints(area.corners);
1501
+ includePoints(area.queueCorners);
1502
+ });
1503
+
1504
+ const result = Number.isFinite(bounds.minX) ? bounds : null;
1505
+ this.pitCameraBoundsCache = { pitLane, bounds: result };
1506
+ return result;
1507
+ }
1508
+
1509
+ getPitCameraTarget(pitLane) {
1510
+ const bounds = this.getPitCameraBounds(pitLane);
1511
+ if (!bounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
1512
+
1513
+ return {
1514
+ x: (bounds.minX + bounds.maxX) / 2,
1515
+ y: (bounds.minY + bounds.maxY) / 2,
1516
+ };
1517
+ }
1518
+
1519
+ getPitCameraFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
1520
+ const bounds = this.getPitCameraBounds(pitLane);
1521
+ const target = bounds
1522
+ ? {
1523
+ x: (bounds.minX + bounds.maxX) / 2,
1524
+ y: (bounds.minY + bounds.maxY) / 2,
1525
+ }
1526
+ : { x: WORLD.width / 2, y: WORLD.height / 2 };
1527
+
1528
+ const presetScale = clampCameraScale(baseScale * this.camera.zoom, baseScale, minimumScale);
1529
+ if (!bounds) {
1530
+ return {
1531
+ target,
1532
+ scale: presetScale,
1533
+ screenX: screenCenterX,
1534
+ screenY: height / 2,
1535
+ };
1536
+ }
1537
+
1538
+ const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + PIT_CAMERA_PADDING);
1539
+ const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + PIT_CAMERA_PADDING);
1540
+ const fitScale = Math.min(safeArea.width / fitWidth, height / fitHeight);
1541
+ const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, minimumScale);
1542
+
1543
+ return {
1544
+ target,
1545
+ scale: Number.isFinite(scale) && scale > 0 ? scale : presetScale,
1546
+ screenX: screenCenterX,
1547
+ screenY: height / 2,
1548
+ };
1549
+ }
1550
+
887
1551
  updateDom(snapshot, { emitLifecycle = true } = {}) {
888
1552
  const leader = snapshot.cars[0];
889
1553
  const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
@@ -893,20 +1557,28 @@ export class F1SimulatorApp {
893
1557
 
894
1558
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
895
1559
 
896
- if (this.activeRaceDataId && now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS) {
1560
+ if (
1561
+ this.shouldAutoHideActiveRaceData() &&
1562
+ now - this.lastRaceDataInteraction > RACE_DATA_SELECTED_VISIBLE_MS
1563
+ ) {
897
1564
  this.activeRaceDataId = null;
898
1565
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(now);
899
1566
  }
1567
+ this.updateStewardMessageState(snapshot, now);
900
1568
 
901
1569
  if (this.readouts.mode) {
902
1570
  const modeLabel = snapshot.raceControl.mode === 'safety-car'
903
1571
  ? 'SC'
1572
+ : snapshot.raceControl.mode === 'red-flag'
1573
+ ? 'RED'
904
1574
  : snapshot.raceControl.finished
905
1575
  ? 'FINISH'
906
1576
  : 'GREEN';
907
1577
  this.readouts.mode.textContent = modeLabel;
908
1578
  this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
909
1579
  ? 'var(--yellow)'
1580
+ : snapshot.raceControl.mode === 'red-flag'
1581
+ ? 'var(--race-control-red)'
910
1582
  : snapshot.raceControl.mode === 'finished'
911
1583
  ? 'var(--red)'
912
1584
  : 'var(--green)';
@@ -917,13 +1589,26 @@ export class F1SimulatorApp {
917
1589
  if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
918
1590
  if (this.readouts.timingTower) {
919
1591
  this.readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
1592
+ this.readouts.timingTower.classList.toggle('is-red-flag', snapshot.raceControl.mode === 'red-flag');
920
1593
  this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
921
1594
  }
922
- if (this.readouts.towerSafetyBanner) {
923
- this.readouts.towerSafetyBanner.hidden = snapshot.raceControl.mode !== 'safety-car';
1595
+ if (this.readouts.towerRaceControlBanner) {
1596
+ const raceControlBanner = getRaceControlStatusBanner(snapshot.raceControl.mode);
1597
+ this.readouts.towerRaceControlBanner.hidden = !raceControlBanner;
1598
+ this.readouts.towerRaceControlBanner.classList?.toggle?.('is-safety-car', raceControlBanner?.status === 'safety-car');
1599
+ this.readouts.towerRaceControlBanner.classList?.toggle?.('is-red-flag', raceControlBanner?.status === 'red-flag');
1600
+ if (raceControlBanner && this.readouts.towerRaceControlBanner.dataset) {
1601
+ this.readouts.towerRaceControlBanner.dataset.raceControlStatus = raceControlBanner.status;
1602
+ } else if (this.readouts.towerRaceControlBanner.dataset) {
1603
+ delete this.readouts.towerRaceControlBanner.dataset.raceControlStatus;
1604
+ }
1605
+ if (raceControlBanner) {
1606
+ setText(this.readouts.towerRaceControlKicker, raceControlBanner.kicker);
1607
+ setText(this.readouts.towerRaceControlTitle, raceControlBanner.title);
1608
+ }
924
1609
  }
925
1610
  if (this.readouts.drs) {
926
- this.readouts.drs.textContent = snapshot.raceControl.mode === 'safety-car'
1611
+ this.readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
927
1612
  ? 'DISABLED'
928
1613
  : activeDrs
929
1614
  ? `${activeDrs} OPEN`
@@ -932,30 +1617,37 @@ export class F1SimulatorApp {
932
1617
  if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
933
1618
  this.renderStartLights(snapshot.raceControl);
934
1619
  if (this.readouts.camera) {
935
- const zoom = this.camera.mode === 'show-all'
936
- ? Math.round(((this.camera.scale ?? 0) / Math.max(0.0001, this.getBaseScale())) * 100)
937
- : Math.round(this.camera.zoom * 100);
938
- this.readouts.camera.textContent = `${this.camera.mode.toUpperCase().replace('-', ' ')} ${zoom}%`;
1620
+ const zoom = Math.round(this.camera.zoom * 100);
1621
+ const mode = this.camera.free ? 'FREE' : this.camera.mode.toUpperCase().replace('-', ' ');
1622
+ this.readouts.camera.textContent = `${mode} ${zoom}%`;
939
1623
  }
940
1624
  if (this.readouts.fps) {
941
1625
  this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
942
1626
  }
943
1627
  this.renderRaceFinish(snapshot);
944
1628
 
945
- this.updateCameraControls();
1629
+ this.updateCameraControls(snapshot);
946
1630
  this.syncTimingGapModeControls();
1631
+ const timingPenaltyKey = this.getTimingPenaltyKey(snapshot.penalties ?? []);
1632
+ const timingOrderKey = this.getTimingOrderKey(snapshot.cars);
1633
+ const timingUpdateInterval = timingUpdateIntervalForSpeed(this.simulationSpeed);
947
1634
  if (
948
- now - this.lastTimingRenderTime >= TIMING_UPDATE_INTERVAL_MS ||
949
- this.lastTimingRaceMode !== snapshot.raceControl.mode
1635
+ now - this.lastTimingRenderTime >= timingUpdateInterval ||
1636
+ this.lastTimingRaceMode !== snapshot.raceControl.mode ||
1637
+ this.lastTimingPenaltyKey !== timingPenaltyKey ||
1638
+ this.lastTimingOrderKey !== timingOrderKey
950
1639
  ) {
951
- this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
1640
+ this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
952
1641
  this.lastTimingRenderTime = now;
953
1642
  this.lastTimingRaceMode = snapshot.raceControl.mode;
1643
+ this.lastTimingPenaltyKey = timingPenaltyKey;
1644
+ this.lastTimingOrderKey = timingOrderKey;
954
1645
  }
955
1646
  this.renderTelemetry(selected);
956
1647
  const activeRaceDataCar = this.activeRaceDataId
957
1648
  ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
958
1649
  : null;
1650
+ this.renderActiveStewardMessage();
959
1651
  if (activeRaceDataCar) {
960
1652
  this.renderRaceData(activeRaceDataCar);
961
1653
  } else {
@@ -978,12 +1670,7 @@ export class F1SimulatorApp {
978
1670
  this.lastLeaderLap = leaderLap;
979
1671
  }
980
1672
 
981
- snapshot.events.forEach((event) => {
982
- const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
983
- if (this.emittedRaceEventKeys.has(key)) return;
984
- this.emittedRaceEventKeys.add(key);
985
- this.emitHostCallback('onRaceEvent', event, snapshot);
986
- });
1673
+ this.emitRaceEvents(snapshot.events, snapshot);
987
1674
 
988
1675
  if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
989
1676
  this.raceFinishEmitted = true;
@@ -995,6 +1682,15 @@ export class F1SimulatorApp {
995
1682
  }
996
1683
  }
997
1684
 
1685
+ emitRaceEvents(events = [], snapshot) {
1686
+ events.forEach((event) => {
1687
+ const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
1688
+ if (this.emittedRaceEventKeys.has(key)) return;
1689
+ this.emittedRaceEventKeys.add(key);
1690
+ this.emitHostCallback('onRaceEvent', event, snapshot);
1691
+ });
1692
+ }
1693
+
998
1694
  renderRaceFinish(snapshot) {
999
1695
  const panel = this.readouts.finishPanel;
1000
1696
  if (!panel) return;
@@ -1066,9 +1762,7 @@ export class F1SimulatorApp {
1066
1762
  this.lastRaceDataInteraction = performance.now();
1067
1763
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
1068
1764
  if (focus) {
1069
- this.camera.mode = 'selected';
1070
- this.camera.zoom = CAMERA_PRESETS.selected;
1071
- this.updateCameraControls();
1765
+ this.setCameraMode('selected');
1072
1766
  }
1073
1767
  this.lastTimingRenderTime = 0;
1074
1768
  const snapshot = this.sim.snapshot();
@@ -1077,13 +1771,16 @@ export class F1SimulatorApp {
1077
1771
  if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
1078
1772
  }
1079
1773
 
1080
- renderTiming(cars, raceMode) {
1774
+ renderTiming(cars, raceMode, penalties = []) {
1081
1775
  if (!this.timingList) return;
1082
1776
  this.syncTimingGapModeControls();
1777
+ const penaltyByDriver = this.timingPenaltyBadgesEnabled ? this.getPenaltyByDriver(penalties) : new Map();
1083
1778
  const timingMarkup = cars.map((car) => {
1084
1779
  const driver = this.driverById.get(car.id);
1085
1780
  let gap = 'Leader';
1086
- if (raceMode === 'finished') {
1781
+ if (this.isWavedFlagCar(car) && raceMode !== 'finished') {
1782
+ gap = 'WAVED';
1783
+ } else if (raceMode === 'finished') {
1087
1784
  gap = car.rank === 1 ? 'Winner' : 'FIN';
1088
1785
  } else if (raceMode === 'safety-car' && car.rank > 1) {
1089
1786
  gap = 'SC';
@@ -1097,6 +1794,10 @@ export class F1SimulatorApp {
1097
1794
  const team = car.team ?? driver?.team ?? null;
1098
1795
  const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
1099
1796
  const iconColor = team?.color ?? car.color;
1797
+ const penalty = penaltyByDriver.get(car.id);
1798
+ const penaltyBadge = penalty
1799
+ ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
1800
+ : '';
1100
1801
 
1101
1802
  return `
1102
1803
  <li>
@@ -1105,7 +1806,7 @@ export class F1SimulatorApp {
1105
1806
  style="--driver-color: ${escapeHtml(car.color)}">
1106
1807
  <span class="timing-position">${car.rank}</span>
1107
1808
  <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
1108
- <span class="timing-name" title="${escapeHtml(car.name)}">${escapeHtml(timingCode)}</span>
1809
+ <span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
1109
1810
  <span class="timing-gap">${escapeHtml(gap)}</span>
1110
1811
  <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
1111
1812
  </button>
@@ -1118,13 +1819,81 @@ export class F1SimulatorApp {
1118
1819
  }
1119
1820
  }
1120
1821
 
1822
+ getTimingOrderKey(cars = []) {
1823
+ return cars.map((car) => [
1824
+ car.id,
1825
+ car.rank,
1826
+ car.lap,
1827
+ this.formatTimingGap(car),
1828
+ car.penaltySeconds ?? 0,
1829
+ car.classifiedRank ?? 0,
1830
+ car.finishRank ?? 0,
1831
+ car.raceStatus ?? car.status ?? '',
1832
+ car.wavedFlag ? 1 : 0,
1833
+ car.tire ?? '',
1834
+ ].join(':')).join('|');
1835
+ }
1836
+
1837
+ isWavedFlagCar(car) {
1838
+ return Boolean(car?.wavedFlag || car?.raceStatus === 'waved-flag' || car?.status === 'waved-flag');
1839
+ }
1840
+
1841
+ getPenaltyByDriver(penalties = []) {
1842
+ const byDriver = new Map();
1843
+ penalties.forEach((penalty) => {
1844
+ if (!penalty?.driverId || byDriver.has(penalty.driverId)) return;
1845
+ if (!hasVisiblePenaltyEffect(penalty)) return;
1846
+ byDriver.set(penalty.driverId, penalty);
1847
+ });
1848
+ return byDriver;
1849
+ }
1850
+
1851
+ getTimingPenaltyKey(penalties = []) {
1852
+ if (!this.timingPenaltyBadgesEnabled) return '';
1853
+ return penalties
1854
+ .filter(hasVisiblePenaltyEffect)
1855
+ .map((penalty) => [
1856
+ penalty.id,
1857
+ penalty.driverId,
1858
+ penalty.status,
1859
+ penalty.penaltySeconds,
1860
+ penalty.pendingPenaltySeconds,
1861
+ penalty.positionDrop,
1862
+ penalty.gridDrop,
1863
+ penalty.disqualified,
1864
+ ].join(':'))
1865
+ .join('|');
1866
+ }
1867
+
1121
1868
  formatTimingGap(car) {
1869
+ const lapValue = this.timingGapMode === 'leader'
1870
+ ? car.leaderGapLaps
1871
+ : (car.intervalAheadLaps ?? car.gapAheadLaps);
1872
+ if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
1873
+
1122
1874
  const value = this.timingGapMode === 'leader'
1123
1875
  ? car.leaderGapSeconds
1124
1876
  : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1125
1877
  return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
1126
1878
  }
1127
1879
 
1880
+ formatLapGap(laps) {
1881
+ const wholeLaps = Math.max(0, Math.floor(laps));
1882
+ return `+${wholeLaps}`;
1883
+ }
1884
+
1885
+ formatTelemetryGap(car, mode) {
1886
+ const lapValue = mode === 'leader'
1887
+ ? car.leaderGapLaps
1888
+ : (car.intervalAheadLaps ?? car.gapAheadLaps);
1889
+ if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
1890
+
1891
+ const seconds = mode === 'leader'
1892
+ ? car.leaderGapSeconds
1893
+ : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1894
+ return Number.isFinite(seconds) ? `${seconds.toFixed(2)}s` : '--';
1895
+ }
1896
+
1128
1897
  syncTimingGapModeControls() {
1129
1898
  const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
1130
1899
  setText(this.readouts.timingGapLabel, label);
@@ -1159,15 +1928,15 @@ export class F1SimulatorApp {
1159
1928
  setTextAll(this.readouts.surface, surface);
1160
1929
  setTextAll(
1161
1930
  this.readouts.gap,
1162
- car.rank === 1 || !Number.isFinite(car.gapAheadSeconds)
1931
+ car.rank === 1
1163
1932
  ? '--'
1164
- : `${car.gapAheadSeconds.toFixed(2)}s`,
1933
+ : this.formatTelemetryGap(car, 'interval'),
1165
1934
  );
1166
1935
  setTextAll(
1167
1936
  this.readouts.leaderGap,
1168
- car.rank === 1 || !Number.isFinite(car.leaderGapSeconds)
1937
+ car.rank === 1
1169
1938
  ? '--'
1170
- : `${car.leaderGapSeconds.toFixed(2)}s`,
1939
+ : this.formatTelemetryGap(car, 'leader'),
1171
1940
  );
1172
1941
 
1173
1942
  this.renderCarDriverOverview(car);
@@ -1186,28 +1955,50 @@ export class F1SimulatorApp {
1186
1955
  this.readouts.telemetrySectorBars?.forEach((bar) => {
1187
1956
  const sector = Number(bar.dataset.telemetrySectorBar);
1188
1957
  const index = sector - 1;
1189
- const isActive = sector === telemetry.currentSector;
1190
- const sectorComplete = Number.isFinite(telemetry.currentSectors?.[index]);
1191
- const fill = sectorComplete
1192
- ? 100
1193
- : (isActive ? clamp((telemetry.currentSectorProgress ?? 0) * 100, 0, 100) : 0);
1958
+ const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
1959
+ const isActive = index === activeIndex;
1960
+ const isCompletedCurrentSector = index < activeIndex;
1961
+ const hasLiveProgress = Array.isArray(telemetry.sectorProgress);
1962
+ const progress = index > activeIndex
1963
+ ? 0
1964
+ : hasLiveProgress
1965
+ ? telemetry.sectorProgress[index]
1966
+ : isActive
1967
+ ? telemetry.currentSectorProgress
1968
+ : isCompletedCurrentSector && Number.isFinite(telemetry.currentSectors?.[index])
1969
+ ? 1
1970
+ : 0;
1971
+ const fill = clamp((progress ?? 0) * 100, 0, 100);
1972
+ const sectorComplete = fill >= 99.9;
1194
1973
  const fillValue = `${fill.toFixed(1)}%`;
1195
1974
  if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
1196
1975
  bar.style.setProperty('--sector-fill', fillValue);
1197
1976
  }
1198
1977
  bar.classList.toggle('is-active', isActive);
1199
1978
  bar.classList.toggle('is-complete', sectorComplete);
1200
- setPerformanceClass(bar, telemetry.sectorPerformance?.current?.[index]);
1979
+ setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
1201
1980
  });
1202
1981
 
1203
1982
  this.readouts.telemetrySectorTimes?.forEach((node) => {
1204
1983
  const sector = Number(node.dataset.telemetrySectorTime);
1205
1984
  const index = sector - 1;
1206
- const value = sector === telemetry.currentSector && !Number.isFinite(telemetry.currentSectors?.[index])
1207
- ? telemetry.currentSectorElapsed
1208
- : telemetry.currentSectors?.[index];
1985
+ const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
1986
+ const isActive = index === activeIndex;
1987
+ const isCompletedCurrentSector = index < activeIndex;
1988
+ const hasLiveSectors = Array.isArray(telemetry.liveSectors);
1989
+ const value = index > activeIndex
1990
+ ? null
1991
+ : hasLiveSectors
1992
+ ? isActive
1993
+ ? telemetry.currentSectorElapsed ?? telemetry.liveSectors[index]
1994
+ : telemetry.liveSectors[index]
1995
+ : isActive && !Number.isFinite(telemetry.currentSectors?.[index])
1996
+ ? telemetry.currentSectorElapsed
1997
+ : isCompletedCurrentSector
1998
+ ? telemetry.currentSectors?.[index]
1999
+ : null;
1209
2000
  setText(node, formatTelemetryTime(value));
1210
- setPerformanceClass(node, telemetry.sectorPerformance?.current?.[index]);
2001
+ setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
1211
2002
  });
1212
2003
 
1213
2004
  this.readouts.telemetrySectorLast?.forEach((node) => {
@@ -1329,6 +2120,17 @@ export class F1SimulatorApp {
1329
2120
  }
1330
2121
  }
1331
2122
 
2123
+ shouldAutoHideActiveRaceData() {
2124
+ return Boolean(this.activeRaceDataId && !this.hasPersistentRaceDataTelemetry());
2125
+ }
2126
+
2127
+ hasPersistentRaceDataTelemetry() {
2128
+ return Boolean(
2129
+ this.options.ui?.raceDataTelemetryDetail ||
2130
+ this.readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry'),
2131
+ );
2132
+ }
2133
+
1332
2134
  renderProjectRadio(now = performance.now()) {
1333
2135
  if (!this.readouts.raceDataPanel) return;
1334
2136
  if (!this.isRaceDataBannerEnabled('radio')) {
@@ -1363,6 +2165,96 @@ export class F1SimulatorApp {
1363
2165
  if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
1364
2166
  }
1365
2167
 
2168
+ dismissRaceDataPanel(now = performance.now()) {
2169
+ this.activeRaceDataId = null;
2170
+ this.lastRaceDataInteraction = now;
2171
+ this.radioState.visible = false;
2172
+ this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
2173
+ this.hideRaceDataPanel();
2174
+ }
2175
+
2176
+ updateStewardMessageState(snapshot, now = performance.now()) {
2177
+ if (!this.penaltyBannerEnabled || !this.readouts.stewardMessage) {
2178
+ this.activeStewardMessage = null;
2179
+ return;
2180
+ }
2181
+ if (this.activeStewardMessage && now < this.activeStewardMessage.visibleUntil) return;
2182
+ this.activeStewardMessage = null;
2183
+
2184
+ const warning = [...(snapshot.events ?? [])].reverse().find((event) => (
2185
+ event?.type === 'track-limits' && event.decision === 'warning'
2186
+ ));
2187
+ const penalty = [...(snapshot.penalties ?? [])].reverse().find((entry) => (
2188
+ entry?.id && entry.id !== this.lastPenaltyBannerId
2189
+ ));
2190
+ const message = penalty
2191
+ ? this.createPenaltyStewardMessage(penalty)
2192
+ : this.createWarningStewardMessage(warning);
2193
+ const key = formatStewardMessageKey(message);
2194
+ if (!message || !key || key === this.lastStewardMessageKey) return;
2195
+ this.lastStewardMessageKey = key;
2196
+ if (message.penaltyId) this.lastPenaltyBannerId = message.penaltyId;
2197
+ this.activeStewardMessage = {
2198
+ message,
2199
+ visibleUntil: now + PENALTY_BANNER_VISIBLE_MS,
2200
+ };
2201
+ }
2202
+
2203
+ createPenaltyStewardMessage(penalty) {
2204
+ if (!penalty) return null;
2205
+ const driver = this.driverById.get(penalty.driverId);
2206
+ const code = driver?.timingCode ?? driver?.code ?? penalty.driverId ?? 'CAR';
2207
+ return {
2208
+ kind: 'penalty',
2209
+ type: penalty.type,
2210
+ driverId: penalty.driverId,
2211
+ penaltyId: penalty.id,
2212
+ time: penalty.time,
2213
+ color: driver?.color ?? 'var(--red)',
2214
+ kicker: formatPenaltyChip(penalty),
2215
+ title: `${code} ${formatPenaltyHeadline(penalty).replace(/^\+\d+ seconds? /u, '')}`,
2216
+ detail: `${formatPenaltyType(penalty.type)} - ${penalty.reason ?? 'Steward decision'}`,
2217
+ };
2218
+ }
2219
+
2220
+ createWarningStewardMessage(event) {
2221
+ if (!event) return null;
2222
+ const driver = this.driverById.get(event.carId);
2223
+ const code = driver?.timingCode ?? driver?.code ?? event.carId ?? 'CAR';
2224
+ const warningLimit = Number(event.warningsBeforePenalty);
2225
+ const warningProgress = Number.isFinite(warningLimit) && warningLimit > 0
2226
+ ? ` ${event.violationCount}/${warningLimit}`
2227
+ : '';
2228
+ return {
2229
+ kind: 'warning',
2230
+ type: event.type,
2231
+ driverId: event.carId,
2232
+ violationCount: event.violationCount,
2233
+ time: event.at,
2234
+ color: driver?.color ?? 'var(--yellow)',
2235
+ kicker: 'Warning',
2236
+ title: `${code} ${formatPenaltyType(event.type).toLowerCase()}`,
2237
+ detail: `${formatPenaltyType(event.type)}${warningProgress}`,
2238
+ };
2239
+ }
2240
+
2241
+ renderActiveStewardMessage() {
2242
+ const message = this.activeStewardMessage?.message;
2243
+ const panel = this.readouts.stewardMessage;
2244
+ if (!panel) return;
2245
+ if (!message) {
2246
+ panel.classList.add('is-hidden');
2247
+ panel.classList.remove('is-penalty', 'is-warning');
2248
+ return;
2249
+ }
2250
+ panel.style.setProperty('--steward-color', message.color);
2251
+ panel.classList.remove('is-hidden', 'is-penalty', 'is-warning');
2252
+ panel.classList.add(message.kind === 'penalty' ? 'is-penalty' : 'is-warning');
2253
+ setText(this.readouts.stewardMessageKicker, message.kicker);
2254
+ setText(this.readouts.stewardMessageTitle, message.title);
2255
+ setText(this.readouts.stewardMessageDetail, message.detail);
2256
+ }
2257
+
1366
2258
  updateRadioSchedule(now) {
1367
2259
  if (!this.isRaceDataBannerEnabled('radio')) {
1368
2260
  this.radioState.visible = false;
@@ -1436,7 +2328,7 @@ export class F1SimulatorApp {
1436
2328
  }
1437
2329
 
1438
2330
  isRaceDataBannerEnabled(kind) {
1439
- return this.raceDataBannerConfig.enabled?.includes(kind) ?? true;
2331
+ return !this.raceDataBannersMuted && (this.raceDataBannerConfig.enabled?.includes(kind) ?? true);
1440
2332
  }
1441
2333
 
1442
2334
  getProjectRadioQuote() {
@@ -1450,12 +2342,41 @@ export class F1SimulatorApp {
1450
2342
  };
1451
2343
  }
1452
2344
 
1453
- updateCameraControls() {
2345
+ updateCameraControls(snapshot = this.sim?.snapshot?.()) {
2346
+ if (this.camera.mode === 'pit' && !this.hasPitCamera(snapshot)) {
2347
+ this.camera.mode = 'leader';
2348
+ this.camera.zoom = CAMERA_PRESETS.leader;
2349
+ }
2350
+
1454
2351
  this.cameraButtons.forEach((button) => {
1455
- const isActive = button.dataset.cameraMode === this.camera.mode;
2352
+ const mode = button.dataset.cameraMode;
2353
+ const isAvailable = this.isCameraModeAvailable(mode, snapshot);
2354
+ if (mode === 'pit') {
2355
+ button.hidden = !isAvailable;
2356
+ button.disabled = !isAvailable;
2357
+ button.setAttribute('aria-hidden', String(!isAvailable));
2358
+ }
2359
+
2360
+ const isActive = !this.camera.free && isAvailable && mode === this.camera.mode;
1456
2361
  button.classList.toggle('is-active', isActive);
1457
2362
  button.setAttribute('aria-pressed', String(isActive));
1458
2363
  });
2364
+ this.updateBannerMuteControls();
2365
+ }
2366
+
2367
+ updateBannerMuteControls() {
2368
+ this.bannerMuteButtons?.forEach((button) => {
2369
+ button.classList.toggle('is-active', this.raceDataBannersMuted);
2370
+ button.setAttribute('aria-pressed', String(this.raceDataBannersMuted));
2371
+ });
2372
+ }
2373
+
2374
+ hasPitCamera(snapshot = this.sim?.snapshot?.()) {
2375
+ return Boolean(snapshot?.track?.pitLane?.enabled);
2376
+ }
2377
+
2378
+ isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
2379
+ return mode !== 'pit' || this.hasPitCamera(snapshot);
1459
2380
  }
1460
2381
 
1461
2382
  updateOverviewModeButtons() {
@@ -1515,6 +2436,8 @@ export class F1SimulatorApp {
1515
2436
  }
1516
2437
  this.applyExpertOptions(nextOptions);
1517
2438
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
2439
+ this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
2440
+ this.timingPenaltyBadgesEnabled = Boolean(this.options.ui?.timingPenaltyBadges);
1518
2441
  this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1519
2442
  applyPaddockThemeCssVariables(this.root, this.options.theme);
1520
2443
  this.sim = this.createRaceSimulation();
@@ -1528,11 +2451,20 @@ export class F1SimulatorApp {
1528
2451
  this.resetRaceDataBannerState(performance.now());
1529
2452
  this.lastTimingRenderTime = 0;
1530
2453
  this.lastTimingRaceMode = null;
2454
+ this.lastTimingPenaltyKey = '';
2455
+ this.lastTimingOrderKey = '';
2456
+ this.activePenaltyBanner = null;
2457
+ this.lastPenaltyBannerId = null;
2458
+ this.activeStewardMessage = null;
2459
+ this.lastStewardMessageKey = null;
1531
2460
  this.lastLeaderLap = null;
1532
2461
  this.emittedRaceEventKeys.clear();
1533
2462
  this.raceFinishEmitted = false;
1534
2463
  this.renderTrack();
1535
- this.updateDom(this.sim.snapshot());
2464
+ const snapshot = this.sim.snapshot();
2465
+ this.updateDom(snapshot);
2466
+ this.lastDomUpdateTime = performance.now();
2467
+ this.renderInitialFrame(snapshot);
1536
2468
  }
1537
2469
 
1538
2470
  setSafetyCarDeployed(deployed) {
@@ -1541,12 +2473,52 @@ export class F1SimulatorApp {
1541
2473
  this.syncSafetyCarControls(active);
1542
2474
  }
1543
2475
 
2476
+ setRedFlagDeployed(deployed) {
2477
+ this.sim?.setRedFlag?.(Boolean(deployed));
2478
+ if (this.sim) this.updateDom(this.sim.snapshot());
2479
+ }
2480
+
2481
+ setPitLaneOpen(open) {
2482
+ this.sim?.setPitLaneOpen?.(Boolean(open));
2483
+ if (this.sim) this.updateDom(this.sim.snapshot());
2484
+ }
2485
+
2486
+ setPitIntent(driverId, intent, targetCompound) {
2487
+ const applied = Boolean(this.sim?.setPitIntent?.(driverId, intent, targetCompound));
2488
+ if (applied) this.updateDom(this.sim.snapshot());
2489
+ return applied;
2490
+ }
2491
+
2492
+ getPitIntent(driverId) {
2493
+ return this.sim?.getPitIntent?.(driverId) ?? 0;
2494
+ }
2495
+
2496
+ getPitTargetCompound(driverId) {
2497
+ return this.sim?.getPitTargetCompound?.(driverId) ?? null;
2498
+ }
2499
+
2500
+ servePenalty(penaltyId) {
2501
+ const penalty = this.sim?.servePenalty(penaltyId) ?? null;
2502
+ if (penalty) this.updateDom(this.sim.snapshot());
2503
+ return penalty;
2504
+ }
2505
+
2506
+ cancelPenalty(penaltyId) {
2507
+ const penalty = this.sim?.cancelPenalty(penaltyId) ?? null;
2508
+ if (penalty) this.updateDom(this.sim.snapshot());
2509
+ return penalty;
2510
+ }
2511
+
1544
2512
  getSnapshot() {
1545
2513
  return this.sim?.snapshot() ?? null;
1546
2514
  }
1547
2515
 
1548
2516
  destroy() {
1549
2517
  this.abortController.abort();
2518
+ if (this.layoutResizeFrame && typeof cancelAnimationFrame === 'function') {
2519
+ cancelAnimationFrame(this.layoutResizeFrame);
2520
+ }
2521
+ this.layoutResizeFrame = null;
1550
2522
  this.layoutResizeObserver?.disconnect?.();
1551
2523
  this.layoutResizeObserver = null;
1552
2524
  this.visibilityObserver?.disconnect?.();
@@ -1558,6 +2530,7 @@ export class F1SimulatorApp {
1558
2530
  this.app = null;
1559
2531
  this.carSprites.clear();
1560
2532
  this.carHitAreas.clear();
2533
+ this.serviceCountdownLabels.clear();
1561
2534
  this.drsTrails.clear();
1562
2535
  }
1563
2536
  }