@inventure71/paddockjs 0.2.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 (63) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/INSTALL_AND_UPDATE.md +165 -0
  3. package/README.md +217 -20
  4. package/docs/architecture.md +84 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +412 -20
  7. package/docs/data_model.md +2 -1
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +1 -1
  10. package/docs/rules.md +140 -19
  11. package/docs/system_specs.md +74 -20
  12. package/docs/training.md +206 -0
  13. package/examples/train-basic-policy.mjs +276 -0
  14. package/examples/trainingData.mjs +133 -0
  15. package/package.json +18 -8
  16. package/src/api/PaddockSimulatorController.js +39 -4
  17. package/src/app/BrowserExpertAdapter.js +32 -0
  18. package/src/app/F1SimulatorApp.js +1251 -146
  19. package/src/app/domBindings.js +10 -2
  20. package/src/config/defaultOptions.js +11 -3
  21. package/src/data/championship.js +13 -0
  22. package/src/data/normalizeDrivers.js +7 -2
  23. package/src/environment/actions.js +68 -0
  24. package/src/environment/episode.js +17 -0
  25. package/src/environment/evaluation.js +184 -0
  26. package/src/environment/events.js +17 -0
  27. package/src/environment/index.d.ts +521 -0
  28. package/src/environment/index.js +15 -0
  29. package/src/environment/observationOptions.js +12 -0
  30. package/src/environment/observations.js +284 -0
  31. package/src/environment/options.js +157 -0
  32. package/src/environment/recorder.js +28 -0
  33. package/src/environment/rewards.js +69 -0
  34. package/src/environment/runtime.js +175 -0
  35. package/src/environment/scenarios.js +220 -0
  36. package/src/environment/sensors.js +367 -0
  37. package/src/environment/specs.js +163 -0
  38. package/src/environment/workerProtocol.js +52 -0
  39. package/src/index.d.ts +405 -6
  40. package/src/index.js +27 -2
  41. package/src/rendering/proceduralTrackAsset.js +343 -24
  42. package/src/simulation/collisionGeometry.js +214 -0
  43. package/src/simulation/driverController.js +431 -65
  44. package/src/simulation/raceSimulation.js +2140 -171
  45. package/src/simulation/rules/collisionSteward.js +69 -0
  46. package/src/simulation/rules/penaltyLedger.js +153 -0
  47. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  48. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  49. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  50. package/src/simulation/rulesConfig.js +352 -0
  51. package/src/simulation/trackModel.js +1154 -76
  52. package/src/simulation/units.js +6 -4
  53. package/src/simulation/vehicleGeometry.js +247 -0
  54. package/src/simulation/vehiclePhysics.js +82 -50
  55. package/src/simulation/wheelSurface.js +338 -0
  56. package/src/styles.css +369 -67
  57. package/src/ui/bannerTemplates.js +69 -0
  58. package/src/ui/componentTemplates.js +45 -81
  59. package/src/ui/raceControlStatusBanner.js +39 -0
  60. package/src/ui/shellTemplate.js +1 -1
  61. package/src/ui/templateUtils.js +23 -0
  62. package/AGENTS.md +0 -86
  63. package/src/standalone.js +0 -15
@@ -1,45 +1,75 @@
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';
5
+ import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
5
6
  import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
6
7
  import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
7
- import { createRaceSimulation } from '../simulation/raceSimulation.js';
8
+ import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
8
9
  import { clamp } from '../simulation/simMath.js';
9
10
  import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
11
+ import { metersToSimUnits } from '../simulation/units.js';
12
+ import { VEHICLE_GEOMETRY } from '../simulation/vehicleGeometry.js';
13
+ import { getRaceControlStatusBanner } from '../ui/raceControlStatusBanner.js';
14
+ import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
10
15
  import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
11
16
 
12
- const FIXED_STEP = 1 / 60;
13
17
  const TARGET_RENDER_FPS = 60;
14
18
  const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
15
19
  const FRAME_PACING_EPSILON_MS = 0.75;
16
20
  const MAX_FRAME_CATCHUP_COUNT = 4;
17
- 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;
18
23
  const DOM_UPDATE_INTERVAL_MS = 100;
19
24
  const TIMING_UPDATE_INTERVAL_MS = 250;
20
- const SIM_SPEED = 3.25;
21
- const CAR_WORLD_LENGTH = 66;
22
- const CAR_WORLD_WIDTH = 23;
23
- const SAFETY_CAR_WORLD_LENGTH = 92;
24
- 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);
25
34
  const CAMERA_PRESETS = {
26
- overview: 1.6,
27
- leader: 5.35,
28
- 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
+ },
29
56
  };
30
57
  const SHOW_ALL_PADDING = 520;
31
- const SHOW_ALL_MIN_ZOOM = 1.1;
32
- const SHOW_ALL_MAX_ZOOM = 6.4;
33
58
  const SHOW_ALL_TOP_RESERVED = 92;
34
59
  const SHOW_ALL_BOTTOM_RESERVED = 132;
35
- const DRS_TRAIL_TTL = 0.68;
36
- 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);
63
+ const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
64
+ const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
65
+ const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
37
66
  const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
38
67
  const RADIO_BREAK_MIN_MS = 4800;
39
68
  const RADIO_BREAK_MAX_MS = 11800;
40
69
  const RADIO_VISIBLE_MIN_MS = 6200;
41
70
  const RADIO_VISIBLE_MAX_MS = 9200;
42
71
  const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
72
+ const PENALTY_BANNER_VISIBLE_MS = 12000;
43
73
  const DRS_DRAG_REDUCTION_PERCENT = 58;
44
74
 
45
75
  const VEHICLE_OVERVIEW_FIELDS = [
@@ -84,6 +114,86 @@ function getTireClass(tire) {
84
114
  return String(tire ?? 'M').toLowerCase();
85
115
  }
86
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
+
87
197
  function formatTelemetryTime(value) {
88
198
  if (!Number.isFinite(value)) return '--';
89
199
  const clamped = Math.max(0, value);
@@ -109,6 +219,14 @@ function colorToTint(color) {
109
219
  return normalizedTint;
110
220
  }
111
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
+
112
230
  function smoothAngle(current, target, amount) {
113
231
  if (!Number.isFinite(current)) return target;
114
232
  let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
@@ -116,6 +234,75 @@ function smoothAngle(current, target, amount) {
116
234
  return current + diff * amount;
117
235
  }
118
236
 
237
+ function destroyDisplayChildren(container) {
238
+ container?.removeChildren?.().forEach((child) => {
239
+ child.destroy?.({ children: true, texture: false, textureSource: false });
240
+ });
241
+ }
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
+
251
+ function expertVisualizesRays(expertOptions) {
252
+ const setting = expertOptions?.visualizeSensors;
253
+ if (setting === true) return true;
254
+ if (!setting || setting === false) return false;
255
+ return Boolean(setting.rays);
256
+ }
257
+
258
+ function pointFromRay(origin, ray, distance) {
259
+ return {
260
+ x: origin.x + ray.x * distance,
261
+ y: origin.y + ray.y * distance,
262
+ };
263
+ }
264
+
265
+ function hasOwnOption(options, key) {
266
+ return Object.hasOwn(options ?? {}, key);
267
+ }
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
+
293
+ function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
294
+ return nextExpertOptions !== currentExpertOptions;
295
+ }
296
+
297
+ function assetSetsEqual(first = {}, second = {}) {
298
+ const firstTrackTextures = first.trackTextures ?? {};
299
+ const secondTrackTextures = second.trackTextures ?? {};
300
+ const assetKeys = ['car', 'carOverview', 'driverHelmet', 'safetyCar', 'broadcastPanel', 'f1Logo'];
301
+ const textureKeys = new Set([...Object.keys(firstTrackTextures), ...Object.keys(secondTrackTextures)]);
302
+ return assetKeys.every((key) => first[key] === second[key]) &&
303
+ [...textureKeys].every((key) => firstTrackTextures[key] === secondTrackTextures[key]);
304
+ }
305
+
119
306
  export class F1SimulatorApp {
120
307
  constructor(root, options) {
121
308
  this.root = root;
@@ -134,21 +321,33 @@ export class F1SimulatorApp {
134
321
  this.visibilityChangeHandler = null;
135
322
  this.visibilityObserver = null;
136
323
  this.tickerCallback = null;
324
+ this.expert = null;
325
+ this.expertMode = Boolean(options.expert?.enabled);
137
326
  this.sim = null;
138
327
  this.app = null;
139
328
  this.worldLayer = null;
140
329
  this.trackAsset = null;
141
330
  this.drsLayer = null;
142
331
  this.trailLayer = null;
332
+ this.sensorLayer = null;
333
+ this.pitLaneStatusLayer = null;
143
334
  this.carLayer = null;
144
335
  this.textures = {};
145
336
  this.carSprites = new Map();
146
337
  this.carHitAreas = new Map();
338
+ this.serviceCountdownLabels = new Map();
147
339
  this.drsTrails = new Map();
148
340
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
149
341
  this.selectedId = this.drivers[0]?.id ?? null;
150
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);
151
346
  this.activeRaceDataId = null;
347
+ this.activePenaltyBanner = null;
348
+ this.lastPenaltyBannerId = null;
349
+ this.activeStewardMessage = null;
350
+ this.lastStewardMessageKey = null;
152
351
  this.lastRaceDataInteraction = performance.now();
153
352
  this.radioRandomState = (this.trackSeed ^ 0x9e3779b9) >>> 0;
154
353
  this.radioState = {
@@ -162,17 +361,26 @@ export class F1SimulatorApp {
162
361
  mode: options.initialCameraMode,
163
362
  zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
164
363
  scale: null,
364
+ initialized: false,
165
365
  x: WORLD.width / 2,
166
366
  y: WORLD.height / 2,
367
+ free: false,
368
+ freeTarget: null,
167
369
  };
168
370
  this.overviewMode = 'vehicle';
371
+ this.simulationSpeed = 1;
169
372
  this.accumulator = 0;
170
373
  this.lastTime = performance.now();
171
374
  this.nextGameFrameTime = this.lastTime + TARGET_FRAME_MS;
172
375
  this.lastDomUpdateTime = 0;
173
376
  this.lastTimingRenderTime = 0;
174
377
  this.lastTimingRaceMode = null;
378
+ this.lastTimingPenaltyKey = '';
379
+ this.lastTimingOrderKey = '';
175
380
  this.lastTimingMarkup = null;
381
+ this.lastPitLaneStatusRenderKey = null;
382
+ this.pitCameraBoundsCache = null;
383
+ this.trackCameraBoundsCache = null;
176
384
  this.lastOverviewRenderKey = null;
177
385
  this.lastFinishClassificationMarkup = null;
178
386
  this.runtimeViewportVisible = true;
@@ -213,29 +421,47 @@ export class F1SimulatorApp {
213
421
  this.worldLayer = new Container();
214
422
  this.drsLayer = new Container();
215
423
  this.trailLayer = new Graphics();
424
+ this.sensorLayer = new Graphics();
425
+ this.pitLaneStatusLayer = new Graphics();
216
426
  this.carLayer = new Container();
217
427
  this.app.stage.addChild(this.worldLayer);
218
428
 
219
429
  await this.loadAssets();
220
430
  this.trackAsset = new ProceduralTrackAsset({ textures: this.textures, world: WORLD });
221
- this.worldLayer.addChild(this.trackAsset.container, this.drsLayer, this.trailLayer, 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
+ );
222
439
  this.createCars();
223
440
  this.bindControls();
224
441
  this.renderTrack();
225
442
  const snapshot = this.sim.snapshot();
226
443
  this.updateDom(snapshot);
444
+ this.lastDomUpdateTime = performance.now();
445
+ if (this.expertMode) {
446
+ this.expert = createBrowserExpertAdapter(this, this.options.expert);
447
+ }
448
+ this.renderInitialFrame(snapshot);
227
449
  this.completeComponentLoading();
228
450
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
229
451
  this.emitHostCallback('onReady', { snapshot });
230
- this.resizeHandler = () => this.applyCamera(this.sim.snapshot());
452
+ this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
231
453
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
232
- this.tickerCallback = () => this.tick();
233
- this.app.ticker.add(this.tickerCallback);
234
454
  this.observeLayoutResize();
235
- this.observeRuntimeVisibility();
455
+ if (!this.expertMode) {
456
+ this.resetFrameClock();
457
+ this.tickerCallback = () => this.tick();
458
+ this.app.ticker.add(this.tickerCallback);
459
+ this.observeRuntimeVisibility();
460
+ }
236
461
  } catch (error) {
237
462
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'error' });
238
463
  this.emitHostCallback('onError', error, { phase: 'init' });
464
+ this.destroy();
239
465
  throw error;
240
466
  }
241
467
  }
@@ -264,12 +490,13 @@ export class F1SimulatorApp {
264
490
  });
265
491
  }
266
492
 
267
- createRaceSimulation() {
493
+ createRaceSimulation(options = this.options) {
268
494
  return createRaceSimulation({
269
- seed: this.options.seed,
270
- trackSeed: this.trackSeed,
271
- drivers: this.drivers,
272
- totalLaps: this.options.totalLaps,
495
+ seed: options.seed,
496
+ trackSeed: options.trackSeed ?? this.trackSeed,
497
+ drivers: options.drivers ?? this.drivers,
498
+ totalLaps: options.totalLaps,
499
+ rules: options.rules,
273
500
  });
274
501
  }
275
502
 
@@ -311,8 +538,10 @@ export class F1SimulatorApp {
311
538
 
312
539
  this.carSprites.forEach((sprite) => sprite.destroy());
313
540
  this.carHitAreas.forEach((hit) => hit.destroy());
541
+ this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
314
542
  this.carSprites.clear();
315
543
  this.carHitAreas.clear();
544
+ this.serviceCountdownLabels.clear();
316
545
  this.drsTrails.clear();
317
546
 
318
547
  if (this.safetySprite) {
@@ -321,11 +550,17 @@ export class F1SimulatorApp {
321
550
  }
322
551
 
323
552
  this.drivers.forEach((driver) => {
324
- const sprite = new Sprite(texture);
325
- sprite.anchor.set(0.5);
326
- sprite.baseScale = baseScale;
327
- sprite.scale.set(baseScale);
328
- 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);
329
564
  sprite.eventMode = 'static';
330
565
  sprite.cursor = 'pointer';
331
566
  sprite.on('pointerdown', () => {
@@ -343,7 +578,83 @@ export class F1SimulatorApp {
343
578
  });
344
579
  this.carHitAreas.set(driver.id, hit);
345
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);
586
+ });
587
+ }
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
+ },
346
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;
347
658
  }
348
659
 
349
660
  bindControls() {
@@ -365,27 +676,38 @@ export class F1SimulatorApp {
365
676
  this.trailLayer?.clear();
366
677
  this.lastTimingRenderTime = 0;
367
678
  this.lastTimingRaceMode = null;
679
+ this.lastTimingPenaltyKey = '';
680
+ this.lastTimingOrderKey = '';
368
681
  this.renderTrack();
369
682
  }, eventOptions);
370
683
 
371
684
  this.cameraButtons.forEach((button) => {
372
685
  button.addEventListener('click', () => {
373
- this.camera.mode = button.dataset.cameraMode;
374
- if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
375
- this.updateCameraControls();
686
+ this.setCameraMode(button.dataset.cameraMode);
376
687
  }, eventOptions);
377
688
  });
378
689
 
379
690
  this.zoomInButton?.addEventListener('click', () => {
380
- this.camera.zoom = clamp(this.camera.zoom + 0.42, 0.72, 8.5);
381
- this.updateCameraControls();
691
+ this.adjustCameraZoom(1);
382
692
  }, eventOptions);
383
693
 
384
694
  this.zoomOutButton?.addEventListener('click', () => {
385
- this.camera.zoom = clamp(this.camera.zoom - 0.42, 0.72, 8.5);
386
- this.updateCameraControls();
695
+ this.adjustCameraZoom(-1);
387
696
  }, eventOptions);
388
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
+
389
711
  this.overviewModeButtons.forEach((button) => {
390
712
  button.addEventListener('click', () => {
391
713
  this.overviewMode = button.dataset.overviewMode === 'driver' ? 'driver' : 'vehicle';
@@ -399,7 +721,7 @@ export class F1SimulatorApp {
399
721
  this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
400
722
  this.syncTimingGapModeControls();
401
723
  const snapshot = this.sim?.snapshot?.();
402
- if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
724
+ if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
403
725
  }, eventOptions);
404
726
  });
405
727
 
@@ -415,13 +737,61 @@ export class F1SimulatorApp {
415
737
  this.options.onDriverOpen?.(driver);
416
738
  }, eventOptions);
417
739
 
740
+ this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
741
+ event.preventDefault();
742
+ event.stopPropagation();
743
+ this.dismissRaceDataPanel(performance.now());
744
+ }, eventOptions);
745
+
418
746
  this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
419
747
  this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
420
748
  }, eventOptions);
421
749
 
422
- this.readouts.telemetryDrawerClose?.addEventListener('click', () => {
423
- this.setTelemetryDrawerOpen(false);
424
- }, eventOptions);
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 });
425
795
  }
426
796
 
427
797
  setTelemetryDrawerOpen(open) {
@@ -439,12 +809,73 @@ export class F1SimulatorApp {
439
809
  this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
440
810
  }
441
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);
442
870
  }
443
871
 
444
872
  observeLayoutResize() {
445
873
  if (typeof ResizeObserver !== 'function') return;
446
874
  this.layoutResizeObserver?.disconnect?.();
447
- this.layoutResizeObserver = new ResizeObserver(() => this.invalidateCameraSafeArea());
875
+ this.layoutResizeObserver = new ResizeObserver(() => {
876
+ this.invalidateCameraSafeArea();
877
+ this.syncRendererToCurrentLayout({ render: true });
878
+ });
448
879
  if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
449
880
  if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
450
881
  }
@@ -484,6 +915,7 @@ export class F1SimulatorApp {
484
915
  }
485
916
 
486
917
  syncRuntimeTicker() {
918
+ if (this.expertMode) return;
487
919
  if (!this.app?.ticker) return;
488
920
 
489
921
  const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
@@ -502,11 +934,13 @@ export class F1SimulatorApp {
502
934
  this.accumulator = 0;
503
935
  this.lastTime = now;
504
936
  this.nextGameFrameTime = now + TARGET_FRAME_MS;
937
+ this.fps.current = 0;
505
938
  this.fps.frames = 0;
506
939
  this.fps.lastSample = now;
507
940
  }
508
941
 
509
942
  tick() {
943
+ if (this.expertMode) return;
510
944
  const now = performance.now();
511
945
  if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
512
946
 
@@ -522,13 +956,16 @@ export class F1SimulatorApp {
522
956
  }
523
957
  this.lastTime = now;
524
958
  this.sampleFps(now);
525
- this.accumulator += frameSeconds * SIM_SPEED;
959
+ this.accumulator += frameSeconds * this.simulationSpeed;
526
960
 
527
961
  let simulationSteps = 0;
528
- 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) {
529
965
  this.sim.step(FIXED_STEP);
530
- const stepSnapshot = this.sim.snapshot();
531
- this.emitSnapshotLifecycle(stepSnapshot);
966
+ if (Array.isArray(this.sim.events) && this.sim.events.length > 0) {
967
+ stepEvents.push(...this.sim.events);
968
+ }
532
969
  this.accumulator -= FIXED_STEP;
533
970
  simulationSteps += 1;
534
971
  }
@@ -538,19 +975,40 @@ export class F1SimulatorApp {
538
975
  this.nextGameFrameTime = now + TARGET_FRAME_MS;
539
976
  }
540
977
 
541
- const snapshot = this.sim.snapshot();
542
- 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));
543
983
  this.applyCamera(renderSnapshot);
544
984
  this.renderDrsTrails(renderSnapshot);
985
+ this.renderPitLaneStatus(renderSnapshot);
545
986
  this.renderCars(renderSnapshot);
546
- if (now - this.lastDomUpdateTime >= DOM_UPDATE_INTERVAL_MS) {
547
- this.updateDom(snapshot, { emitLifecycle: false });
987
+ if (shouldUpdateDom) {
988
+ this.updateDom(fullSnapshot, { emitLifecycle: false });
548
989
  this.lastDomUpdateTime = now;
549
990
  }
550
991
  }
551
992
 
993
+ renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
994
+ if (!snapshot) return;
995
+ const renderSnapshot = createRenderSnapshot(snapshot, 0);
996
+ this.applyCamera(renderSnapshot);
997
+ this.renderDrsTrails(renderSnapshot);
998
+ this.renderExpertSensorRays(renderSnapshot, observation);
999
+ this.renderPitLaneStatus(renderSnapshot);
1000
+ this.renderCars(renderSnapshot);
1001
+ this.updateDom(snapshot, { emitLifecycle: false });
1002
+ this.lastDomUpdateTime = performance.now();
1003
+ }
1004
+
552
1005
  renderTrack() {
553
- this.drsLayer.removeChildren();
1006
+ destroyDisplayChildren(this.drsLayer);
1007
+ this.sensorLayer?.clear();
1008
+ this.pitLaneStatusLayer?.clear();
1009
+ this.lastPitLaneStatusRenderKey = null;
1010
+ this.pitCameraBoundsCache = null;
1011
+ this.trackCameraBoundsCache = null;
554
1012
  const snapshot = this.sim.snapshot();
555
1013
  const track = snapshot.track;
556
1014
 
@@ -570,20 +1028,70 @@ export class F1SimulatorApp {
570
1028
  });
571
1029
  }
572
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
+
573
1070
  renderCars(snapshot) {
574
1071
  snapshot.cars.forEach((car) => {
575
1072
  const sprite = this.carSprites.get(car.id);
576
1073
  const hit = this.carHitAreas.get(car.id);
577
1074
  if (!sprite || !hit) return;
578
- sprite.x = car.x;
579
- sprite.y = car.y;
580
- sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
581
- sprite.rotation = sprite.currentRotation;
582
- sprite.alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
583
- sprite.scale.set(sprite.baseScale);
584
- sprite.tint = colorToTint(car.color);
585
- hit.x = car.x;
586
- 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);
587
1095
  });
588
1096
 
589
1097
  if (!this.safetySprite && snapshot.safetyCar.deployed) {
@@ -599,58 +1107,149 @@ export class F1SimulatorApp {
599
1107
  }
600
1108
 
601
1109
  if (this.safetySprite) {
602
- this.safetySprite.visible = snapshot.safetyCar.deployed;
603
- this.safetySprite.x = snapshot.safetyCar.x;
604
- this.safetySprite.y = snapshot.safetyCar.y;
605
- 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;
606
1116
  }
607
1117
  }
608
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;
1139
+ }
1140
+ }
1141
+
1142
+ renderExpertSensorRays(snapshot, observation) {
1143
+ if (!this.sensorLayer) return;
1144
+ this.sensorLayer.clear();
1145
+ if (!this.expertMode || !expertVisualizesRays(this.options.expert)) return;
1146
+
1147
+ const controlledDrivers = this.options.expert?.controlledDrivers ?? [];
1148
+ if (!controlledDrivers.length || !observation) return;
1149
+
1150
+ const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
1151
+ controlledDrivers.forEach((driverId) => {
1152
+ const car = carsById.get(driverId);
1153
+ const rays = observation?.[driverId]?.object?.rays;
1154
+ if (!car || !Array.isArray(rays) || rays.length === 0) return;
1155
+
1156
+ const origin = getCarRayOrigin(car);
1157
+
1158
+ rays.forEach((ray) => {
1159
+ const rayVector = getCarRayVector(car, Number(ray.angleDegrees) || 0);
1160
+ const totalDistanceMeters = Math.max(0, Number(ray.lengthMeters) || 0);
1161
+ const trackDistanceMeters = Math.max(0, Number(ray.track?.distanceMeters) || totalDistanceMeters);
1162
+ const carDistanceMeters = Math.max(0, Number(ray.car?.distanceMeters) || totalDistanceMeters);
1163
+ const trackHit = Boolean(ray.track?.hit) && trackDistanceMeters <= totalDistanceMeters;
1164
+ const carHit = Boolean(ray.car?.hit) && carDistanceMeters <= totalDistanceMeters;
1165
+ const fullEnd = pointFromRay(origin, rayVector, metersToSimUnits(totalDistanceMeters));
1166
+
1167
+ this.sensorLayer
1168
+ .moveTo(origin.x, origin.y)
1169
+ .lineTo(fullEnd.x, fullEnd.y)
1170
+ .stroke({ width: 2, color: 0xffffff, alpha: 0.18, cap: 'round' });
1171
+
1172
+ if (!trackHit && !carHit) return;
1173
+
1174
+ const carIsClosest = carHit && (!trackHit || carDistanceMeters <= trackDistanceMeters);
1175
+ const hitDistanceMeters = carIsClosest ? carDistanceMeters : trackDistanceMeters;
1176
+ const hitEnd = pointFromRay(origin, rayVector, metersToSimUnits(hitDistanceMeters));
1177
+ const hitColor = carIsClosest
1178
+ ? SENSOR_RAY_CAR_COLOR
1179
+ : ray.track?.kind === 'entry'
1180
+ ? SENSOR_RAY_TRACK_ENTRY_COLOR
1181
+ : SENSOR_RAY_TRACK_COLOR;
1182
+
1183
+ this.sensorLayer
1184
+ .moveTo(origin.x, origin.y)
1185
+ .lineTo(hitEnd.x, hitEnd.y)
1186
+ .stroke({ width: carIsClosest ? 5 : 3.4, color: hitColor, alpha: carIsClosest ? 0.9 : 0.76, cap: 'round' });
1187
+ this.sensorLayer
1188
+ .circle(hitEnd.x, hitEnd.y, carIsClosest ? 7 : 5)
1189
+ .fill({ color: hitColor, alpha: 0.92 })
1190
+ .circle(hitEnd.x, hitEnd.y, carIsClosest ? 10 : 8)
1191
+ .stroke({ width: 1.5, color: 0x10131a, alpha: 0.72 });
1192
+ });
1193
+ });
1194
+ }
1195
+
609
1196
  renderDrsTrails(snapshot) {
610
1197
  if (!this.trailLayer) return;
611
1198
 
612
1199
  snapshot.cars.forEach((car) => {
613
- 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 ?? [];
614
1203
  const rear = {
615
1204
  x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
616
1205
  y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
617
1206
  at: snapshot.time,
618
1207
  };
619
- const last = history[history.length - 1];
1208
+ const last = activeHistory[activeHistory.length - 1];
620
1209
 
621
1210
  if (
622
1211
  car.drsActive &&
623
1212
  (!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
624
1213
  ) {
625
- history.push(rear);
1214
+ activeHistory.push(rear);
626
1215
  }
627
1216
 
628
- const activeTrail = history.filter((point) => snapshot.time - point.at <= DRS_TRAIL_TTL);
629
- if (activeTrail.length) this.drsTrails.set(car.id, activeTrail);
1217
+ pruneExpiredTrailPoints(activeHistory, snapshot.time);
1218
+ if (activeHistory.length) this.drsTrails.set(car.id, activeHistory);
630
1219
  else this.drsTrails.delete(car.id);
631
1220
  });
632
1221
 
633
1222
  this.trailLayer.clear();
634
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);
635
1228
  for (let index = 1; index < history.length; index += 1) {
636
- const previous = history[index - 1];
637
- const point = history[index];
638
- const age = snapshot.time - point.at;
639
- const life = clamp(1 - age / DRS_TRAIL_TTL, 0, 1);
640
- this.trailLayer.moveTo(previous.x, previous.y);
641
- this.trailLayer.lineTo(point.x, point.y);
642
- this.trailLayer.stroke({
643
- width: 5 + life * 7,
644
- color: 0x3be8ff,
645
- alpha: 0.22 + life * 0.48,
646
- cap: 'round',
647
- join: 'round',
648
- });
1229
+ this.trailLayer.lineTo(history[index].x, history[index].y);
649
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
+ });
650
1249
  });
651
1250
  }
652
1251
 
653
- applyCamera(snapshot) {
1252
+ applyCamera(snapshot, { immediate = false } = {}) {
654
1253
  if (!this.worldLayer) return;
655
1254
 
656
1255
  const width = this.canvasHost.clientWidth || 900;
@@ -660,11 +1259,17 @@ export class F1SimulatorApp {
660
1259
  const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
661
1260
  const scale = frame.scale;
662
1261
  const target = frame.target;
663
- this.camera.x += (target.x - this.camera.x) * 0.08;
664
- this.camera.y += (target.y - this.camera.y) * 0.08;
665
- 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
666
1270
  ? scale
667
- : 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;
668
1273
  this.camera.scale = activeScale;
669
1274
  this.worldLayer.scale.set(activeScale);
670
1275
  this.worldLayer.position.set(
@@ -713,6 +1318,28 @@ export class F1SimulatorApp {
713
1318
 
714
1319
  getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
715
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
+ }
716
1343
 
717
1344
  if (this.camera.mode === 'show-all') {
718
1345
  const bounds = snapshot.cars.reduce((box, car) => ({
@@ -733,30 +1360,44 @@ export class F1SimulatorApp {
733
1360
  const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
734
1361
  const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
735
1362
  const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
736
- const scale = clamp(
737
- Math.min(safeArea.width / fitWidth, safeHeight / fitHeight),
738
- baseScale * SHOW_ALL_MIN_ZOOM,
739
- baseScale * SHOW_ALL_MAX_ZOOM,
740
- );
741
- 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({
742
1366
  target,
743
1367
  scale,
744
1368
  screenX: screenCenterX,
745
- screenY: SHOW_ALL_TOP_RESERVED + safeHeight / 2,
746
- };
1369
+ screenY: height / 2,
1370
+ });
747
1371
  }
748
1372
 
749
- 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({
750
1380
  target: this.getCameraTarget(snapshot),
751
- scale: baseScale * this.camera.zoom,
1381
+ scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
752
1382
  screenX: screenCenterX,
753
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,
754
1392
  };
755
1393
  }
756
1394
 
757
1395
  getCameraTarget(snapshot) {
758
- if (this.camera.mode === 'overview') {
759
- 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);
760
1401
  }
761
1402
 
762
1403
  if (this.camera.mode === 'selected') {
@@ -768,6 +1409,145 @@ export class F1SimulatorApp {
768
1409
  return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
769
1410
  }
770
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
+
771
1551
  updateDom(snapshot, { emitLifecycle = true } = {}) {
772
1552
  const leader = snapshot.cars[0];
773
1553
  const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
@@ -777,20 +1557,28 @@ export class F1SimulatorApp {
777
1557
 
778
1558
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
779
1559
 
780
- 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
+ ) {
781
1564
  this.activeRaceDataId = null;
782
1565
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(now);
783
1566
  }
1567
+ this.updateStewardMessageState(snapshot, now);
784
1568
 
785
1569
  if (this.readouts.mode) {
786
1570
  const modeLabel = snapshot.raceControl.mode === 'safety-car'
787
1571
  ? 'SC'
1572
+ : snapshot.raceControl.mode === 'red-flag'
1573
+ ? 'RED'
788
1574
  : snapshot.raceControl.finished
789
1575
  ? 'FINISH'
790
1576
  : 'GREEN';
791
1577
  this.readouts.mode.textContent = modeLabel;
792
1578
  this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
793
1579
  ? 'var(--yellow)'
1580
+ : snapshot.raceControl.mode === 'red-flag'
1581
+ ? 'var(--race-control-red)'
794
1582
  : snapshot.raceControl.mode === 'finished'
795
1583
  ? 'var(--red)'
796
1584
  : 'var(--green)';
@@ -801,13 +1589,26 @@ export class F1SimulatorApp {
801
1589
  if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
802
1590
  if (this.readouts.timingTower) {
803
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');
804
1593
  this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
805
1594
  }
806
- if (this.readouts.towerSafetyBanner) {
807
- 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
+ }
808
1609
  }
809
1610
  if (this.readouts.drs) {
810
- this.readouts.drs.textContent = snapshot.raceControl.mode === 'safety-car'
1611
+ this.readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
811
1612
  ? 'DISABLED'
812
1613
  : activeDrs
813
1614
  ? `${activeDrs} OPEN`
@@ -816,30 +1617,37 @@ export class F1SimulatorApp {
816
1617
  if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
817
1618
  this.renderStartLights(snapshot.raceControl);
818
1619
  if (this.readouts.camera) {
819
- const zoom = this.camera.mode === 'show-all'
820
- ? Math.round(((this.camera.scale ?? 0) / Math.max(0.0001, this.getBaseScale())) * 100)
821
- : Math.round(this.camera.zoom * 100);
822
- 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}%`;
823
1623
  }
824
1624
  if (this.readouts.fps) {
825
1625
  this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
826
1626
  }
827
1627
  this.renderRaceFinish(snapshot);
828
1628
 
829
- this.updateCameraControls();
1629
+ this.updateCameraControls(snapshot);
830
1630
  this.syncTimingGapModeControls();
1631
+ const timingPenaltyKey = this.getTimingPenaltyKey(snapshot.penalties ?? []);
1632
+ const timingOrderKey = this.getTimingOrderKey(snapshot.cars);
1633
+ const timingUpdateInterval = timingUpdateIntervalForSpeed(this.simulationSpeed);
831
1634
  if (
832
- now - this.lastTimingRenderTime >= TIMING_UPDATE_INTERVAL_MS ||
833
- this.lastTimingRaceMode !== snapshot.raceControl.mode
1635
+ now - this.lastTimingRenderTime >= timingUpdateInterval ||
1636
+ this.lastTimingRaceMode !== snapshot.raceControl.mode ||
1637
+ this.lastTimingPenaltyKey !== timingPenaltyKey ||
1638
+ this.lastTimingOrderKey !== timingOrderKey
834
1639
  ) {
835
- this.renderTiming(snapshot.cars, snapshot.raceControl.mode);
1640
+ this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
836
1641
  this.lastTimingRenderTime = now;
837
1642
  this.lastTimingRaceMode = snapshot.raceControl.mode;
1643
+ this.lastTimingPenaltyKey = timingPenaltyKey;
1644
+ this.lastTimingOrderKey = timingOrderKey;
838
1645
  }
839
1646
  this.renderTelemetry(selected);
840
1647
  const activeRaceDataCar = this.activeRaceDataId
841
1648
  ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
842
1649
  : null;
1650
+ this.renderActiveStewardMessage();
843
1651
  if (activeRaceDataCar) {
844
1652
  this.renderRaceData(activeRaceDataCar);
845
1653
  } else {
@@ -862,12 +1670,7 @@ export class F1SimulatorApp {
862
1670
  this.lastLeaderLap = leaderLap;
863
1671
  }
864
1672
 
865
- snapshot.events.forEach((event) => {
866
- const key = `${event.type}:${event.at ?? snapshot.time}:${event.carId ?? ''}:${event.otherCarId ?? ''}:${event.winnerId ?? ''}`;
867
- if (this.emittedRaceEventKeys.has(key)) return;
868
- this.emittedRaceEventKeys.add(key);
869
- this.emitHostCallback('onRaceEvent', event, snapshot);
870
- });
1673
+ this.emitRaceEvents(snapshot.events, snapshot);
871
1674
 
872
1675
  if (snapshot.raceControl.finished && !this.raceFinishEmitted) {
873
1676
  this.raceFinishEmitted = true;
@@ -879,6 +1682,15 @@ export class F1SimulatorApp {
879
1682
  }
880
1683
  }
881
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
+
882
1694
  renderRaceFinish(snapshot) {
883
1695
  const panel = this.readouts.finishPanel;
884
1696
  if (!panel) return;
@@ -950,9 +1762,7 @@ export class F1SimulatorApp {
950
1762
  this.lastRaceDataInteraction = performance.now();
951
1763
  if (this.isRaceDataBannerEnabled('radio')) this.scheduleRadioBreak(this.lastRaceDataInteraction);
952
1764
  if (focus) {
953
- this.camera.mode = 'selected';
954
- this.camera.zoom = CAMERA_PRESETS.selected;
955
- this.updateCameraControls();
1765
+ this.setCameraMode('selected');
956
1766
  }
957
1767
  this.lastTimingRenderTime = 0;
958
1768
  const snapshot = this.sim.snapshot();
@@ -961,13 +1771,16 @@ export class F1SimulatorApp {
961
1771
  if (driver) this.emitHostCallback('onDriverSelect', driver, snapshot);
962
1772
  }
963
1773
 
964
- renderTiming(cars, raceMode) {
1774
+ renderTiming(cars, raceMode, penalties = []) {
965
1775
  if (!this.timingList) return;
966
1776
  this.syncTimingGapModeControls();
1777
+ const penaltyByDriver = this.timingPenaltyBadgesEnabled ? this.getPenaltyByDriver(penalties) : new Map();
967
1778
  const timingMarkup = cars.map((car) => {
968
1779
  const driver = this.driverById.get(car.id);
969
1780
  let gap = 'Leader';
970
- if (raceMode === 'finished') {
1781
+ if (this.isWavedFlagCar(car) && raceMode !== 'finished') {
1782
+ gap = 'WAVED';
1783
+ } else if (raceMode === 'finished') {
971
1784
  gap = car.rank === 1 ? 'Winner' : 'FIN';
972
1785
  } else if (raceMode === 'safety-car' && car.rank > 1) {
973
1786
  gap = 'SC';
@@ -981,6 +1794,10 @@ export class F1SimulatorApp {
981
1794
  const team = car.team ?? driver?.team ?? null;
982
1795
  const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
983
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
+ : '';
984
1801
 
985
1802
  return `
986
1803
  <li>
@@ -989,7 +1806,7 @@ export class F1SimulatorApp {
989
1806
  style="--driver-color: ${escapeHtml(car.color)}">
990
1807
  <span class="timing-position">${car.rank}</span>
991
1808
  <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
992
- <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>
993
1810
  <span class="timing-gap">${escapeHtml(gap)}</span>
994
1811
  <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
995
1812
  </button>
@@ -1002,13 +1819,81 @@ export class F1SimulatorApp {
1002
1819
  }
1003
1820
  }
1004
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
+
1005
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
+
1006
1874
  const value = this.timingGapMode === 'leader'
1007
1875
  ? car.leaderGapSeconds
1008
1876
  : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1009
1877
  return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
1010
1878
  }
1011
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
+
1012
1897
  syncTimingGapModeControls() {
1013
1898
  const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
1014
1899
  setText(this.readouts.timingGapLabel, label);
@@ -1043,15 +1928,15 @@ export class F1SimulatorApp {
1043
1928
  setTextAll(this.readouts.surface, surface);
1044
1929
  setTextAll(
1045
1930
  this.readouts.gap,
1046
- car.rank === 1 || !Number.isFinite(car.gapAheadSeconds)
1931
+ car.rank === 1
1047
1932
  ? '--'
1048
- : `${car.gapAheadSeconds.toFixed(2)}s`,
1933
+ : this.formatTelemetryGap(car, 'interval'),
1049
1934
  );
1050
1935
  setTextAll(
1051
1936
  this.readouts.leaderGap,
1052
- car.rank === 1 || !Number.isFinite(car.leaderGapSeconds)
1937
+ car.rank === 1
1053
1938
  ? '--'
1054
- : `${car.leaderGapSeconds.toFixed(2)}s`,
1939
+ : this.formatTelemetryGap(car, 'leader'),
1055
1940
  );
1056
1941
 
1057
1942
  this.renderCarDriverOverview(car);
@@ -1070,28 +1955,50 @@ export class F1SimulatorApp {
1070
1955
  this.readouts.telemetrySectorBars?.forEach((bar) => {
1071
1956
  const sector = Number(bar.dataset.telemetrySectorBar);
1072
1957
  const index = sector - 1;
1073
- const isActive = sector === telemetry.currentSector;
1074
- const sectorComplete = Number.isFinite(telemetry.currentSectors?.[index]);
1075
- const fill = sectorComplete
1076
- ? 100
1077
- : (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;
1078
1973
  const fillValue = `${fill.toFixed(1)}%`;
1079
1974
  if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
1080
1975
  bar.style.setProperty('--sector-fill', fillValue);
1081
1976
  }
1082
1977
  bar.classList.toggle('is-active', isActive);
1083
1978
  bar.classList.toggle('is-complete', sectorComplete);
1084
- setPerformanceClass(bar, telemetry.sectorPerformance?.current?.[index]);
1979
+ setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
1085
1980
  });
1086
1981
 
1087
1982
  this.readouts.telemetrySectorTimes?.forEach((node) => {
1088
1983
  const sector = Number(node.dataset.telemetrySectorTime);
1089
1984
  const index = sector - 1;
1090
- const value = sector === telemetry.currentSector && !Number.isFinite(telemetry.currentSectors?.[index])
1091
- ? telemetry.currentSectorElapsed
1092
- : 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;
1093
2000
  setText(node, formatTelemetryTime(value));
1094
- setPerformanceClass(node, telemetry.sectorPerformance?.current?.[index]);
2001
+ setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
1095
2002
  });
1096
2003
 
1097
2004
  this.readouts.telemetrySectorLast?.forEach((node) => {
@@ -1213,6 +2120,17 @@ export class F1SimulatorApp {
1213
2120
  }
1214
2121
  }
1215
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
+
1216
2134
  renderProjectRadio(now = performance.now()) {
1217
2135
  if (!this.readouts.raceDataPanel) return;
1218
2136
  if (!this.isRaceDataBannerEnabled('radio')) {
@@ -1247,6 +2165,96 @@ export class F1SimulatorApp {
1247
2165
  if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
1248
2166
  }
1249
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
+
1250
2258
  updateRadioSchedule(now) {
1251
2259
  if (!this.isRaceDataBannerEnabled('radio')) {
1252
2260
  this.radioState.visible = false;
@@ -1320,7 +2328,7 @@ export class F1SimulatorApp {
1320
2328
  }
1321
2329
 
1322
2330
  isRaceDataBannerEnabled(kind) {
1323
- return this.raceDataBannerConfig.enabled?.includes(kind) ?? true;
2331
+ return !this.raceDataBannersMuted && (this.raceDataBannerConfig.enabled?.includes(kind) ?? true);
1324
2332
  }
1325
2333
 
1326
2334
  getProjectRadioQuote() {
@@ -1334,12 +2342,41 @@ export class F1SimulatorApp {
1334
2342
  };
1335
2343
  }
1336
2344
 
1337
- 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
+
1338
2351
  this.cameraButtons.forEach((button) => {
1339
- 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;
1340
2361
  button.classList.toggle('is-active', isActive);
1341
2362
  button.setAttribute('aria-pressed', String(isActive));
1342
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);
1343
2380
  }
1344
2381
 
1345
2382
  updateOverviewModeButtons() {
@@ -1357,7 +2394,7 @@ export class F1SimulatorApp {
1357
2394
  });
1358
2395
  }
1359
2396
 
1360
- restart(nextOptions = {}) {
2397
+ applyExpertOptions(nextOptions = {}) {
1361
2398
  this.options = {
1362
2399
  ...this.options,
1363
2400
  ...nextOptions,
@@ -1379,16 +2416,34 @@ export class F1SimulatorApp {
1379
2416
  },
1380
2417
  };
1381
2418
  this.assets = this.options.assets;
1382
- this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1383
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1384
- applyPaddockThemeCssVariables(this.root, this.options.theme);
2419
+ if (hasOwnOption(nextOptions, 'trackSeed')) {
2420
+ this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
2421
+ }
1385
2422
  if (nextOptions.drivers) {
1386
2423
  this.drivers = nextOptions.drivers;
1387
2424
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
1388
2425
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
1389
2426
  this.createCars();
1390
2427
  }
2428
+ }
2429
+
2430
+ restart(nextOptions = {}) {
2431
+ if (hasOwnOption(nextOptions, 'assets') && !assetSetsEqual(nextOptions.assets, this.options.assets)) {
2432
+ throw new Error('PaddockJS restart() does not support changing assets. Destroy and mount a new simulator with the new assets.');
2433
+ }
2434
+ if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
2435
+ throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
2436
+ }
2437
+ this.applyExpertOptions(nextOptions);
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);
2441
+ this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
2442
+ applyPaddockThemeCssVariables(this.root, this.options.theme);
1391
2443
  this.sim = this.createRaceSimulation();
2444
+ if (this.expertMode) {
2445
+ this.expert = createBrowserExpertAdapter(this, this.options.expert);
2446
+ }
1392
2447
  this.selectedId = this.drivers[0]?.id ?? null;
1393
2448
  this.lastTimingMarkup = null;
1394
2449
  this.lastOverviewRenderKey = null;
@@ -1396,11 +2451,20 @@ export class F1SimulatorApp {
1396
2451
  this.resetRaceDataBannerState(performance.now());
1397
2452
  this.lastTimingRenderTime = 0;
1398
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;
1399
2460
  this.lastLeaderLap = null;
1400
2461
  this.emittedRaceEventKeys.clear();
1401
2462
  this.raceFinishEmitted = false;
1402
2463
  this.renderTrack();
1403
- this.updateDom(this.sim.snapshot());
2464
+ const snapshot = this.sim.snapshot();
2465
+ this.updateDom(snapshot);
2466
+ this.lastDomUpdateTime = performance.now();
2467
+ this.renderInitialFrame(snapshot);
1404
2468
  }
1405
2469
 
1406
2470
  setSafetyCarDeployed(deployed) {
@@ -1409,12 +2473,52 @@ export class F1SimulatorApp {
1409
2473
  this.syncSafetyCarControls(active);
1410
2474
  }
1411
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
+
1412
2512
  getSnapshot() {
1413
2513
  return this.sim?.snapshot() ?? null;
1414
2514
  }
1415
2515
 
1416
2516
  destroy() {
1417
2517
  this.abortController.abort();
2518
+ if (this.layoutResizeFrame && typeof cancelAnimationFrame === 'function') {
2519
+ cancelAnimationFrame(this.layoutResizeFrame);
2520
+ }
2521
+ this.layoutResizeFrame = null;
1418
2522
  this.layoutResizeObserver?.disconnect?.();
1419
2523
  this.layoutResizeObserver = null;
1420
2524
  this.visibilityObserver?.disconnect?.();
@@ -1426,6 +2530,7 @@ export class F1SimulatorApp {
1426
2530
  this.app = null;
1427
2531
  this.carSprites.clear();
1428
2532
  this.carHitAreas.clear();
2533
+ this.serviceCountdownLabels.clear();
1429
2534
  this.drsTrails.clear();
1430
2535
  }
1431
2536
  }