@inventure71/paddockjs 2.0.0 → 3.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 (110) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/INSTALL_AND_UPDATE.md +10 -1
  3. package/README.md +92 -17
  4. package/docs/architecture.md +26 -16
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/concepts.md +7 -5
  7. package/docs/custom_model_controller.md +2 -2
  8. package/docs/data_contract.md +121 -33
  9. package/docs/data_model.md +1 -1
  10. package/docs/index.md +10 -0
  11. package/docs/learnings.md +6 -6
  12. package/docs/rules.md +39 -9
  13. package/docs/sense_contract.md +59 -0
  14. package/docs/system_specs.md +76 -20
  15. package/docs/training.md +27 -20
  16. package/package.json +1 -1
  17. package/src/api/PaddockSimulatorController.js +75 -36
  18. package/src/app/BrowserExpertAdapter.js +9 -300
  19. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  20. package/src/app/F1SimulatorApp.js +190 -68
  21. package/src/app/banners/raceDataBannerController.js +71 -36
  22. package/src/app/banners/stewardMessageController.js +5 -4
  23. package/src/app/camera/cameraConstants.js +2 -0
  24. package/src/app/camera/cameraController.js +86 -29
  25. package/src/app/camera/cameraGeometry.js +12 -1
  26. package/src/app/domBindings.js +89 -9
  27. package/src/app/layoutScheduler.js +27 -0
  28. package/src/app/layoutSupport.js +165 -0
  29. package/src/app/raceOverlayClearanceSupport.js +158 -0
  30. package/src/app/readouts/carOverviewRenderer.js +16 -8
  31. package/src/app/readouts/controlStateRenderer.js +1 -1
  32. package/src/app/readouts/raceStatusRenderer.js +30 -21
  33. package/src/app/readouts/readoutFormatters.js +2 -0
  34. package/src/app/readouts/telemetryRenderer.js +17 -8
  35. package/src/app/readouts/timingTowerRenderer.js +62 -10
  36. package/src/app/runtime/runtimeVisibility.js +66 -5
  37. package/src/config/cssValues.js +16 -0
  38. package/src/config/defaultOptions.js +51 -38
  39. package/src/config/restartOptions.js +34 -0
  40. package/src/config/themeOptions.js +825 -0
  41. package/src/config/timingGapMode.js +13 -0
  42. package/src/config/urlOptions.js +25 -0
  43. package/src/data/championship.js +14 -9
  44. package/src/environment/controllerLoop.js +34 -5
  45. package/src/environment/episode.js +14 -2
  46. package/src/environment/index.d.ts +17 -4
  47. package/src/environment/observationVector.js +200 -0
  48. package/src/environment/observations.js +58 -285
  49. package/src/environment/options.js +1 -1
  50. package/src/environment/recorder.js +13 -6
  51. package/src/environment/rewards.js +13 -2
  52. package/src/environment/runtime.js +65 -25
  53. package/src/environment/scenarios.js +22 -3
  54. package/src/environment/sensors/index.js +3 -3
  55. package/src/environment/sensors/opponentRadar.js +13 -3
  56. package/src/environment/sensors/sensorTargets.js +8 -2
  57. package/src/environment/sensors/surfaceRays.js +84 -12
  58. package/src/environment/sensors/trackRays.js +173 -15
  59. package/src/environment/specs.js +78 -40
  60. package/src/index.d.ts +112 -9
  61. package/src/index.js +26 -33
  62. package/src/simulation/driver/driverController.js +1 -1
  63. package/src/simulation/driver/edgeRecovery.js +1 -1
  64. package/src/simulation/driver/racingControls.js +2 -2
  65. package/src/simulation/driver/rejoinControls.js +2 -2
  66. package/src/simulation/pit/pitFlow.js +5 -0
  67. package/src/simulation/pit/pitKinematics.js +24 -0
  68. package/src/simulation/pit/pitQueue.js +5 -0
  69. package/src/simulation/pit/pitRouteMovement.js +7 -0
  70. package/src/simulation/pit/pitRouting.js +2 -4
  71. package/src/simulation/pit/pitService.js +2 -0
  72. package/src/simulation/pit/pitTireService.js +2 -0
  73. package/src/simulation/race/raceLifecycle.js +2 -6
  74. package/src/simulation/race/raceProgress.js +6 -1
  75. package/src/simulation/race/raceSetup.js +2 -6
  76. package/src/simulation/race/raceStep.js +2 -2
  77. package/src/simulation/race/redFlag.js +3 -3
  78. package/src/simulation/race/safetyCar.js +10 -4
  79. package/src/simulation/race/stalledDnf.js +8 -17
  80. package/src/simulation/raceSimulation.js +1 -0
  81. package/src/simulation/rules/rulesReview.js +3 -0
  82. package/src/simulation/snapshots/raceSnapshots.js +1 -0
  83. package/src/simulation/timing/lapTelemetry.js +28 -9
  84. package/src/simulation/track/pitLaneLayout.js +7 -3
  85. package/src/simulation/track/spatialQueries.js +5 -28
  86. package/src/simulation/track/trackQueryGrid.js +94 -0
  87. package/src/simulation/track/trackQueryIndex.js +193 -434
  88. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  89. package/src/simulation/track/trackQueryScratch.js +46 -0
  90. package/src/simulation/track/trackQueryStats.js +28 -0
  91. package/src/simulation/track/trackStatePolicy.js +0 -1
  92. package/src/simulation/vehicle/contactResolution.js +67 -2
  93. package/src/simulation/vehicle/runoffResponse.js +11 -12
  94. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  95. package/src/simulation/vehicle/vehiclePhysics.js +5 -4
  96. package/src/simulation/vehicle/vehicleSnapshots.js +22 -0
  97. package/src/simulation/vehicle/vehicleState.js +36 -2
  98. package/src/simulation/vehicle/wheelSurface.js +3 -1
  99. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  100. package/src/styles.css +1185 -136
  101. package/src/ui/bannerTemplates.js +38 -28
  102. package/src/ui/cameraControlsTemplate.js +16 -5
  103. package/src/ui/carOverviewTemplate.js +12 -5
  104. package/src/ui/raceCanvasTemplate.js +31 -8
  105. package/src/ui/raceControlsTemplate.js +27 -10
  106. package/src/ui/shellTemplate.js +3 -1
  107. package/src/ui/telemetryDrawerTemplate.js +23 -10
  108. package/src/ui/telemetryTemplates.js +58 -32
  109. package/src/ui/templateUtils.js +48 -0
  110. package/src/ui/timingTowerTemplate.js +29 -12
@@ -1,5 +1,7 @@
1
1
  import { Application, Container, Graphics } from 'pixi.js';
2
2
  import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
3
+ import { formatCssUrl } from '../config/cssValues.js';
4
+ import { getNextTimingGapMode, normalizeTimingGapMode } from '../config/timingGapMode.js';
3
5
  import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
4
6
  import { interpolateRenderSnapshotInto } from '../rendering/renderSnapshot.js';
5
7
  import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
@@ -7,7 +9,9 @@ import { clamp } from '../simulation/simMath.js';
7
9
  import { WORLD } from '../simulation/track/trackModel.js';
8
10
  import { CameraController } from './camera/cameraController.js';
9
11
  import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
10
- import { querySimulatorDom } from './domBindings.js';
12
+ import { installLayoutSupport } from './layoutSupport.js';
13
+ import { installRaceOverlayClearanceSupport } from './raceOverlayClearanceSupport.js';
14
+ import { querySimulatorDom, resolveNodes } from './domBindings.js';
11
15
  import { loadAppTextures } from './rendering/appAssets.js';
12
16
  import { CarRenderer } from './rendering/carRenderer.js';
13
17
  import { DrsTrailRenderer } from './rendering/drsTrailRenderer.js';
@@ -16,17 +20,12 @@ import { PitLaneStatusRenderer } from './rendering/pitLaneStatusRenderer.js';
16
20
  import { ReplayGhostRenderer } from './rendering/replayGhostRenderer.js';
17
21
  import { renderTrackSurface } from './rendering/trackRenderer.js';
18
22
  import {
19
- formatLapGap,
20
- formatRaceGap,
21
- getPenaltyByDriver,
22
23
  getTimingOrderKey,
23
24
  getTimingPenaltyKey,
24
- isWavedFlagCar,
25
- renderTimingTower,
25
+ renderTimingTowers,
26
26
  syncTimingGapModeControls,
27
27
  } from './readouts/timingTowerRenderer.js';
28
- import { formatTelemetryGap } from './readouts/readoutFormatters.js';
29
- import { getOverviewFields, renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
28
+ import { renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
30
29
  import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
31
30
  import {
32
31
  renderRaceFinish,
@@ -65,6 +64,44 @@ import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
65
64
  const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
66
65
  const DRS_DRAG_REDUCTION_PERCENT = 58;
67
66
 
67
+ function uniqueNodes(nodes = []) {
68
+ return [...new Set([...nodes].filter(Boolean))];
69
+ }
70
+
71
+ function normalizeRuntimeSnapshot(snapshot, totalLaps) {
72
+ const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
73
+ const raceControlSource = source.raceControl && typeof source.raceControl === 'object'
74
+ ? source.raceControl
75
+ : {};
76
+ const pitLaneStatus = source.pitLaneStatus ?? raceControlSource.pitLaneStatus ?? null;
77
+ return {
78
+ ...source,
79
+ time: Number.isFinite(source.time) ? source.time : 0,
80
+ world: source.world ?? WORLD,
81
+ totalLaps: Number.isFinite(source.totalLaps) ? source.totalLaps : totalLaps,
82
+ raceControl: {
83
+ ...raceControlSource,
84
+ mode: raceControlSource.mode ?? 'green',
85
+ redFlag: Boolean(raceControlSource.redFlag),
86
+ pitLaneOpen: Boolean(raceControlSource.pitLaneOpen),
87
+ pitLaneStatus,
88
+ finished: Boolean(raceControlSource.finished),
89
+ finishedAt: raceControlSource.finishedAt ?? null,
90
+ winner: raceControlSource.winner ?? null,
91
+ classification: Array.isArray(raceControlSource.classification) ? raceControlSource.classification : [],
92
+ start: raceControlSource.start && typeof raceControlSource.start === 'object'
93
+ ? raceControlSource.start
94
+ : {},
95
+ },
96
+ pitLaneStatus,
97
+ safetyCar: source.safetyCar && typeof source.safetyCar === 'object' ? source.safetyCar : {},
98
+ events: Array.isArray(source.events) ? source.events : [],
99
+ penalties: Array.isArray(source.penalties) ? source.penalties : [],
100
+ cars: Array.isArray(source.cars) ? source.cars : [],
101
+ replayGhosts: Array.isArray(source.replayGhosts) ? source.replayGhosts : [],
102
+ };
103
+ }
104
+
68
105
  function createMountTrackSeed() {
69
106
  const values = new Uint32Array(1);
70
107
  try {
@@ -110,16 +147,22 @@ export class F1SimulatorApp {
110
147
  this.drivers = options.drivers;
111
148
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
112
149
  this.assets = options.assets;
113
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
150
+ this.root.style.setProperty('--broadcast-panel-surface', formatCssUrl(this.assets.broadcastPanel));
114
151
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
115
- applyPaddockThemeCssVariables(this.root, options.theme);
152
+ this.totalLaps = options.totalLaps;
153
+ this.selectedId = this.drivers[0]?.id ?? null;
154
+ this.lastThemeContextKey = null;
155
+ this.syncThemeContext();
116
156
  Object.assign(this, querySimulatorDom(root));
117
157
  this.abortController = new AbortController();
118
158
  this.resizeHandler = null;
119
159
  this.layoutResizeObserver = null;
160
+ this.layoutSupportCleanup = installLayoutSupport(root);
161
+ this.raceOverlayClearanceCleanup = installRaceOverlayClearanceSupport(root);
120
162
  this.cameraController = null;
121
163
  this.visibilityChangeHandler = null;
122
164
  this.visibilityObserver = null;
165
+ this.runtimeVisibilitySyncFrame = null;
123
166
  this.tickerCallback = null;
124
167
  this.expert = null;
125
168
  this.expertMode = Boolean(options.expert?.enabled);
@@ -148,7 +191,6 @@ export class F1SimulatorApp {
148
191
  this.replayGhostRenderer = new ReplayGhostRenderer();
149
192
  this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
150
193
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
151
- this.selectedId = this.drivers[0]?.id ?? null;
152
194
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
153
195
  this.raceDataBannersMuted = false;
154
196
  this.penaltyBannerEnabled = Boolean(options.ui?.penaltyBanners);
@@ -171,6 +213,7 @@ export class F1SimulatorApp {
171
213
  canvasHost: this.canvasHost,
172
214
  readouts: this.readouts,
173
215
  initialMode: options.initialCameraMode,
216
+ driverCamera: options.ui?.driverCamera,
174
217
  });
175
218
  this.camera = this.cameraController.camera;
176
219
  this.overviewMode = 'vehicle';
@@ -198,7 +241,7 @@ export class F1SimulatorApp {
198
241
  this.lastLeaderLap = null;
199
242
  this.emittedRaceEventKeys = new Set();
200
243
  this.raceFinishEmitted = false;
201
- this.timingGapMode = 'interval';
244
+ this.timingGapMode = normalizeTimingGapMode(options.ui?.timingGapMode);
202
245
  this.telemetryDrawerOpen = Boolean(
203
246
  this.readouts.telemetryDrawerWorkbench?.classList?.contains?.('is-telemetry-open'),
204
247
  );
@@ -244,6 +287,7 @@ export class F1SimulatorApp {
244
287
  );
245
288
  this.createCars();
246
289
  this.bindControls();
290
+ this.syncTimingPanelDisclosureState();
247
291
  this.renderTrack();
248
292
  const snapshot = this.sim.snapshot();
249
293
  this.updateDom(snapshot);
@@ -255,7 +299,10 @@ export class F1SimulatorApp {
255
299
  this.completeComponentLoading();
256
300
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
257
301
  this.emitHostCallback('onReady', { snapshot });
258
- this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
302
+ this.resizeHandler = () => {
303
+ this.syncTimingPanelDisclosureState();
304
+ this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
305
+ };
259
306
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
260
307
  this.observeLayoutResize();
261
308
  if (this.expertMode) {
@@ -309,7 +356,6 @@ export class F1SimulatorApp {
309
356
  rules: options.rules,
310
357
  participantInteractions: options.participantInteractions,
311
358
  replayGhosts: options.replayGhosts,
312
- trackQueryIndex: options.trackQueryIndex,
313
359
  warmup: normalizeWarmupOptions(options.warmup, 'browser'),
314
360
  });
315
361
  }
@@ -327,18 +373,6 @@ export class F1SimulatorApp {
327
373
  });
328
374
  }
329
375
 
330
- createRawCarGeometryGraphic(driver) {
331
- return this.carRenderer.createRawCarGeometryGraphic(driver);
332
- }
333
-
334
- createServiceCountdownLabel() {
335
- return this.carRenderer.createServiceCountdownLabel();
336
- }
337
-
338
- applyServiceCountdownTone(label, tone) {
339
- this.carRenderer.applyServiceCountdownTone(label, tone);
340
- }
341
-
342
376
  bindControls() {
343
377
  const eventOptions = { signal: this.abortController.signal };
344
378
 
@@ -400,30 +434,42 @@ export class F1SimulatorApp {
400
434
 
401
435
  this.timingGapModeButtons.forEach((button) => {
402
436
  button.addEventListener('click', () => {
403
- this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
404
- this.syncTimingGapModeControls();
405
- const snapshot = this.sim?.snapshot?.();
406
- if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
437
+ this.toggleTimingGapMode();
407
438
  }, eventOptions);
408
439
  });
409
440
 
410
- this.timingList?.addEventListener?.('pointerdown', (event) => {
411
- const row = event.target instanceof Element ? event.target.closest('[data-driver-id]') : null;
412
- if (!row) return;
413
- this.selectCar(row.dataset.driverId, { focus: true });
414
- }, eventOptions);
441
+ this.timingLists?.forEach?.((timingList) => {
442
+ timingList.addEventListener?.('pointerdown', (event) => {
443
+ const row = event.target instanceof Element ? event.target.closest('[data-driver-id]') : null;
444
+ if (!row) return;
445
+ this.selectCar(row.dataset.driverId, { focus: true });
446
+ }, eventOptions);
447
+ });
448
+
449
+ this.readouts.timingPanelToggles?.forEach?.((toggle) => {
450
+ toggle.addEventListener?.('click', () => {
451
+ const racePanel = toggle.closest?.('.sim-canvas-panel');
452
+ this.setTimingPanelOpen(!racePanel?.classList?.contains?.('is-timing-panel-open'), racePanel);
453
+ }, eventOptions);
454
+ });
415
455
 
416
- this.openButton?.addEventListener('click', () => {
456
+ const raceDataOpenButtons = uniqueNodes(this.readouts.raceDataOpens?.length
457
+ ? this.readouts.raceDataOpens
458
+ : [this.openButton]);
459
+ raceDataOpenButtons.forEach((button) => button.addEventListener('click', () => {
417
460
  const driver = this.driverById.get(this.activeRaceDataId ?? this.selectedId);
418
461
  if (!driver) return;
419
462
  this.options.onDriverOpen?.(driver);
420
- }, eventOptions);
463
+ }, eventOptions));
421
464
 
422
- this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
465
+ const raceDataDismissButtons = uniqueNodes(this.readouts.raceDataDismisses?.length
466
+ ? this.readouts.raceDataDismisses
467
+ : [this.readouts.raceDataDismiss]);
468
+ raceDataDismissButtons.forEach((button) => button.addEventListener('click', (event) => {
423
469
  event.preventDefault();
424
470
  event.stopPropagation();
425
471
  this.dismissRaceDataPanel(performance.now());
426
- }, eventOptions);
472
+ }, eventOptions));
427
473
 
428
474
  this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
429
475
  this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
@@ -483,13 +529,51 @@ export class F1SimulatorApp {
483
529
  }
484
530
  this.readouts.telemetryDrawerToggle?.setAttribute?.('aria-expanded', String(this.telemetryDrawerOpen));
485
531
  if (this.readouts.telemetryDrawerToggle) {
486
- this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
532
+ this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close' : 'Telemetry';
533
+ this.readouts.telemetryDrawerToggle.setAttribute(
534
+ 'aria-label',
535
+ this.telemetryDrawerOpen ? 'Close telemetry' : 'Open telemetry',
536
+ );
487
537
  }
488
538
  this.invalidateCameraSafeArea();
489
539
  this.syncRendererToCurrentLayout({ render: true });
490
540
  this.scheduleLayoutResizeSync(380);
491
541
  }
492
542
 
543
+ setTimingPanelOpen(open, racePanel = this.readouts.timingTower?.closest?.('.sim-canvas-panel')) {
544
+ racePanel?.classList?.toggle?.('is-timing-panel-open', Boolean(open));
545
+ this.syncTimingPanelDisclosureState(racePanel);
546
+ this.invalidateCameraSafeArea();
547
+ this.syncRendererToCurrentLayout({ render: true });
548
+ this.scheduleLayoutResizeSync(380);
549
+ }
550
+
551
+ isTimingPanelDisclosureMode(toggle = this.readouts.timingPanelToggle) {
552
+ if (!toggle) return false;
553
+ const style = getComputedStyle(toggle);
554
+ return style.display !== 'none' && style.visibility !== 'hidden';
555
+ }
556
+
557
+ syncTimingPanelDisclosureState(targetPanel = null) {
558
+ const toggles = [...(this.readouts.timingPanelToggles ?? [])];
559
+ for (const toggle of toggles) {
560
+ const racePanel = toggle.closest?.('.sim-canvas-panel');
561
+ if (targetPanel && racePanel !== targetPanel) continue;
562
+ const tower = racePanel?.querySelector?.('[data-timing-tower]');
563
+ const disclosureMode = this.isTimingPanelDisclosureMode(toggle);
564
+ const open = disclosureMode && Boolean(racePanel?.classList?.contains?.('is-timing-panel-open'));
565
+ racePanel?.classList?.toggle?.('is-timing-panel-open', open);
566
+ if (!disclosureMode || open) {
567
+ tower?.removeAttribute?.('inert');
568
+ tower?.setAttribute?.('aria-hidden', 'false');
569
+ } else {
570
+ tower?.setAttribute?.('inert', '');
571
+ tower?.setAttribute?.('aria-hidden', 'true');
572
+ }
573
+ toggle.setAttribute?.('aria-expanded', String(open));
574
+ }
575
+ }
576
+
493
577
  setRaceDataBannersMuted(muted, now = performance.now()) {
494
578
  this.raceDataBannersMuted = Boolean(muted);
495
579
  if (this.raceDataBannersMuted) {
@@ -554,11 +638,13 @@ export class F1SimulatorApp {
554
638
  if (typeof ResizeObserver !== 'function') return;
555
639
  this.layoutResizeObserver?.disconnect?.();
556
640
  this.layoutResizeObserver = new ResizeObserver(() => {
641
+ this.syncTimingPanelDisclosureState();
557
642
  this.invalidateCameraSafeArea();
558
643
  this.syncRendererToCurrentLayout({ render: true });
559
644
  });
560
645
  if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
561
- if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
646
+ resolveNodes(this.readouts.timingTowers, this.readouts.timingTower)
647
+ .forEach((timingTower) => this.layoutResizeObserver.observe(timingTower));
562
648
  }
563
649
 
564
650
  invalidateCameraSafeArea() {
@@ -589,6 +675,7 @@ export class F1SimulatorApp {
589
675
  renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
590
676
  if (!snapshot) return;
591
677
  const now = performance.now();
678
+ this.sampleFps(now);
592
679
  const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
593
680
  this.applyCamera(renderSnapshot);
594
681
  this.renderDrsTrails(renderSnapshot);
@@ -687,6 +774,7 @@ export class F1SimulatorApp {
687
774
  }
688
775
 
689
776
  updateDom(snapshot, { emitLifecycle = true } = {}) {
777
+ snapshot = normalizeRuntimeSnapshot(snapshot, this.totalLaps);
690
778
  const leader = snapshot.cars[0];
691
779
  const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
692
780
  const activeDrs = snapshot.cars.filter((car) => car.drsActive).length;
@@ -694,6 +782,7 @@ export class F1SimulatorApp {
694
782
  const now = performance.now();
695
783
 
696
784
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
785
+ this.syncThemeContext(snapshot);
697
786
 
698
787
  if (
699
788
  this.shouldAutoHideActiveRaceData() &&
@@ -734,7 +823,7 @@ export class F1SimulatorApp {
734
823
  this.lastTimingPenaltyKey = timingPenaltyKey;
735
824
  this.lastTimingOrderKey = timingOrderKey;
736
825
  }
737
- this.renderTelemetry(selected);
826
+ if (selected) this.renderTelemetry(selected);
738
827
  const activeRaceDataCar = this.activeRaceDataId
739
828
  ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
740
829
  : null;
@@ -821,7 +910,8 @@ export class F1SimulatorApp {
821
910
 
822
911
  renderTiming(cars, raceMode, penalties = []) {
823
912
  this.syncTimingGapModeControls();
824
- this.lastTimingMarkup = renderTimingTower({
913
+ this.lastTimingMarkup = renderTimingTowers({
914
+ timingLists: this.timingLists,
825
915
  timingList: this.timingList,
826
916
  cars,
827
917
  raceMode,
@@ -834,16 +924,37 @@ export class F1SimulatorApp {
834
924
  });
835
925
  }
836
926
 
837
- getTimingOrderKey(cars = []) {
838
- return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
927
+ setTimingGapMode(mode) {
928
+ const nextMode = normalizeTimingGapMode(mode);
929
+ if (this.timingGapMode === nextMode) {
930
+ this.syncTimingGapModeControls();
931
+ return this.timingGapMode;
932
+ }
933
+ this.timingGapMode = nextMode;
934
+ this.options = {
935
+ ...this.options,
936
+ ui: {
937
+ ...this.options.ui,
938
+ timingGapMode: nextMode,
939
+ },
940
+ };
941
+ this.lastTimingRenderTime = 0;
942
+ this.syncTimingGapModeControls();
943
+ const snapshot = this.sim?.snapshot?.();
944
+ if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
945
+ return this.timingGapMode;
946
+ }
947
+
948
+ getTimingGapMode() {
949
+ return this.timingGapMode;
839
950
  }
840
951
 
841
- isWavedFlagCar(car) {
842
- return isWavedFlagCar(car);
952
+ toggleTimingGapMode() {
953
+ return this.setTimingGapMode(getNextTimingGapMode(this.timingGapMode));
843
954
  }
844
955
 
845
- getPenaltyByDriver(penalties = []) {
846
- return getPenaltyByDriver(penalties);
956
+ getTimingOrderKey(cars = []) {
957
+ return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
847
958
  }
848
959
 
849
960
  getTimingPenaltyKey(penalties = []) {
@@ -852,18 +963,6 @@ export class F1SimulatorApp {
852
963
  });
853
964
  }
854
965
 
855
- formatTimingGap(car) {
856
- return formatRaceGap(car, this.timingGapMode);
857
- }
858
-
859
- formatLapGap(laps) {
860
- return formatLapGap(laps);
861
- }
862
-
863
- formatTelemetryGap(car, mode) {
864
- return formatTelemetryGap(car, mode);
865
- }
866
-
867
966
  syncTimingGapModeControls() {
868
967
  syncTimingGapModeControls({
869
968
  readouts: this.readouts,
@@ -886,10 +985,6 @@ export class F1SimulatorApp {
886
985
  renderLapTelemetry(this.readouts, telemetry);
887
986
  }
888
987
 
889
- getOverviewFields(driver, mode) {
890
- return getOverviewFields(driver, mode);
891
- }
892
-
893
988
  renderCarDriverOverview(car) {
894
989
  const previousKey = this.lastOverviewRenderKey;
895
990
  this.lastOverviewRenderKey = renderCarDriverOverview({
@@ -1091,6 +1186,20 @@ export class F1SimulatorApp {
1091
1186
  updateToggleButtons(this.safetyButtons, active);
1092
1187
  }
1093
1188
 
1189
+ syncThemeContext(snapshot = null) {
1190
+ const selectedTeamId = this.getSelectedTeamId(snapshot);
1191
+ const contextKey = selectedTeamId ?? '';
1192
+ if (this.lastThemeContextKey === contextKey) return;
1193
+ this.lastThemeContextKey = contextKey;
1194
+ applyPaddockThemeCssVariables(this.root, this.options.theme, { selectedTeamId });
1195
+ }
1196
+
1197
+ getSelectedTeamId(snapshot = null) {
1198
+ const selectedCar = snapshot?.cars?.find?.((car) => car.id === this.selectedId);
1199
+ const selectedDriver = this.driverById.get(this.selectedId);
1200
+ return selectedCar?.team?.id ?? selectedDriver?.team?.id ?? null;
1201
+ }
1202
+
1094
1203
  applyExpertOptions(nextOptions = {}) {
1095
1204
  this.options = {
1096
1205
  ...this.options,
@@ -1112,6 +1221,7 @@ export class F1SimulatorApp {
1112
1221
  },
1113
1222
  },
1114
1223
  };
1224
+ this.totalLaps = this.options.totalLaps;
1115
1225
  this.assets = this.options.assets;
1116
1226
  if (hasOwnOption(nextOptions, 'trackSeed')) {
1117
1227
  this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
@@ -1120,6 +1230,7 @@ export class F1SimulatorApp {
1120
1230
  this.drivers = nextOptions.drivers;
1121
1231
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
1122
1232
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
1233
+ this.lastThemeContextKey = null;
1123
1234
  this.createCars();
1124
1235
  }
1125
1236
  }
@@ -1137,13 +1248,16 @@ export class F1SimulatorApp {
1137
1248
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1138
1249
  this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
1139
1250
  this.timingPenaltyBadgesEnabled = Boolean(this.options.ui?.timingPenaltyBadges);
1140
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1141
- applyPaddockThemeCssVariables(this.root, this.options.theme);
1251
+ this.timingGapMode = normalizeTimingGapMode(this.options.ui?.timingGapMode);
1252
+ this.cameraController.driverCamera = Boolean(this.options.ui?.driverCamera);
1253
+ this.root.style.setProperty('--broadcast-panel-surface', formatCssUrl(this.assets.broadcastPanel));
1254
+ this.selectedId = this.drivers[0]?.id ?? null;
1255
+ this.lastThemeContextKey = null;
1256
+ this.syncThemeContext();
1142
1257
  this.sim = this.createRaceSimulation();
1143
1258
  if (this.expertMode) {
1144
1259
  this.expert = createBrowserExpertAdapter(this, this.options.expert);
1145
1260
  }
1146
- this.selectedId = this.drivers[0]?.id ?? null;
1147
1261
  this.lastTimingMarkup = null;
1148
1262
  this.lastOverviewRenderKey = null;
1149
1263
  this.lastFinishClassificationMarkup = null;
@@ -1218,8 +1332,16 @@ export class F1SimulatorApp {
1218
1332
  cancelAnimationFrame(this.layoutResizeFrame);
1219
1333
  }
1220
1334
  this.layoutResizeFrame = null;
1335
+ if (this.runtimeVisibilitySyncFrame && typeof cancelAnimationFrame === 'function') {
1336
+ cancelAnimationFrame(this.runtimeVisibilitySyncFrame);
1337
+ }
1338
+ this.runtimeVisibilitySyncFrame = null;
1221
1339
  this.layoutResizeObserver?.disconnect?.();
1222
1340
  this.layoutResizeObserver = null;
1341
+ this.layoutSupportCleanup?.();
1342
+ this.layoutSupportCleanup = null;
1343
+ this.raceOverlayClearanceCleanup?.();
1344
+ this.raceOverlayClearanceCleanup = null;
1223
1345
  this.visibilityObserver?.disconnect?.();
1224
1346
  this.visibilityObserver = null;
1225
1347
  if (this.app && this.tickerCallback) {
@@ -1,5 +1,6 @@
1
1
  import { formatDriverNumber } from '../../data/championship.js';
2
- import { setText } from '../domBindings.js';
2
+ import { formatCssColor } from '../../config/cssValues.js';
3
+ import { setStyleProperty, setText } from '../domBindings.js';
3
4
 
4
5
  export const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
5
6
  export const RADIO_BREAK_MIN_MS = 4800;
@@ -8,47 +9,80 @@ export const RADIO_VISIBLE_MIN_MS = 6200;
8
9
  export const RADIO_VISIBLE_MAX_MS = 9200;
9
10
  export const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
10
11
 
12
+ function getRaceDataPanelBindings(readouts) {
13
+ if (readouts.raceDataPanelBindings?.length) return readouts.raceDataPanelBindings;
14
+ if (!readouts.raceDataPanel) return [];
15
+ return [{
16
+ panel: readouts.raceDataPanel,
17
+ kicker: readouts.raceDataKicker,
18
+ title: readouts.raceDataTitle,
19
+ number: readouts.raceDataNumber,
20
+ subtitle: readouts.raceDataSubtitle,
21
+ open: readouts.raceDataOpen,
22
+ dismiss: readouts.raceDataDismiss,
23
+ }];
24
+ }
25
+
26
+ function hasTelemetryDetailPanel(readouts) {
27
+ return getRaceDataPanelBindings(readouts).some((binding) => (
28
+ binding.panel?.classList?.contains?.('race-data-panel--with-telemetry')
29
+ ));
30
+ }
31
+
32
+ function clearRaceDataIdleMode(panel) {
33
+ panel?.removeAttribute?.('data-idle-mode');
34
+ if (panel?.dataset) delete panel.dataset.idleMode;
35
+ }
36
+
37
+ function setRaceDataIdleMode(panel, mode) {
38
+ if (panel?.dataset) {
39
+ panel.dataset.idleMode = mode;
40
+ return;
41
+ }
42
+ panel?.setAttribute?.('data-idle-mode', mode);
43
+ }
44
+
11
45
  export function shouldAutoHideActiveRaceData({ activeRaceDataId, options, readouts }) {
12
46
  return Boolean(
13
47
  activeRaceDataId &&
14
48
  !(
15
49
  options.ui?.raceDataTelemetryDetail ||
16
- readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry')
50
+ hasTelemetryDetailPanel(readouts)
17
51
  ),
18
52
  );
19
53
  }
20
54
 
21
55
  export function renderRaceData({ car, drivers, readouts, options }) {
22
- if (!car || !readouts.raceDataPanel) return false;
56
+ const panels = getRaceDataPanelBindings(readouts);
57
+ if (!car || !panels.length) return false;
23
58
  if (typeof options.isRaceDataBannerEnabled === 'function' && !options.isRaceDataBannerEnabled('project')) return false;
24
59
  const driver = drivers.find((item) => item.id === car.id);
25
60
  if (!driver) return false;
26
61
 
27
- readouts.raceDataPanel.style.setProperty('--driver-color', driver.color);
28
- readouts.raceDataPanel.classList.remove('is-hidden');
29
- readouts.raceDataPanel.classList.add('is-project-mode');
30
- readouts.raceDataPanel.classList.remove('is-radio-mode');
31
- readouts.raceDataPanel.removeAttribute('data-idle-mode');
32
- setText(readouts.raceDataKicker, 'Project');
33
- setText(readouts.raceDataTitle, driver.name);
34
- if (readouts.raceDataNumber) {
35
- readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
36
- }
37
- if (readouts.raceDataSubtitle) {
38
- readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
39
- }
40
- if (readouts.raceDataOpen) {
41
- readouts.raceDataOpen.hidden = typeof options.onDriverOpen !== 'function';
42
- }
62
+ panels.forEach((binding) => {
63
+ setStyleProperty(binding.panel, '--driver-color', formatCssColor(driver.color));
64
+ binding.panel.classList.remove('is-hidden');
65
+ binding.panel.classList.add('is-project-mode');
66
+ binding.panel.classList.remove('is-radio-mode');
67
+ clearRaceDataIdleMode(binding.panel);
68
+ setText(binding.kicker, 'Project');
69
+ setText(binding.title, driver.name);
70
+ setText(binding.number, formatDriverNumber(car.driverNumber ?? driver.driverNumber));
71
+ setText(binding.subtitle, `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`);
72
+ if (binding.open) {
73
+ binding.open.hidden = typeof options.onDriverOpen !== 'function';
74
+ }
75
+ });
43
76
  return true;
44
77
  }
45
78
 
46
79
  export function hideRaceDataPanel(readouts) {
47
- if (!readouts.raceDataPanel) return;
48
- readouts.raceDataPanel.classList.add('is-hidden');
49
- readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
50
- readouts.raceDataPanel.removeAttribute('data-idle-mode');
51
- if (readouts.raceDataOpen) readouts.raceDataOpen.hidden = true;
80
+ getRaceDataPanelBindings(readouts).forEach((binding) => {
81
+ binding.panel.classList.add('is-hidden');
82
+ binding.panel.classList.remove('is-project-mode', 'is-radio-mode');
83
+ clearRaceDataIdleMode(binding.panel);
84
+ if (binding.open) binding.open.hidden = true;
85
+ });
52
86
  }
53
87
 
54
88
  export function getProjectRadioQuote({ drivers, radioState }) {
@@ -56,24 +90,25 @@ export function getProjectRadioQuote({ drivers, radioState }) {
56
90
  const quote = driver.raceData?.[radioState.quoteIndex] ?? 'Project entry';
57
91
 
58
92
  return {
59
- color: driver.color,
93
+ color: formatCssColor(driver.color),
60
94
  title: driver.name,
61
95
  subtitle: `${driver.code} - "${quote}"`,
62
96
  };
63
97
  }
64
98
 
65
99
  export function renderProjectRadio({ readouts, radio }) {
66
- if (!readouts.raceDataPanel) return;
67
- readouts.raceDataPanel.style.setProperty('--driver-color', radio.color);
68
- readouts.raceDataPanel.classList.remove('is-hidden');
69
- readouts.raceDataPanel.classList.add('is-radio-mode');
70
- readouts.raceDataPanel.classList.remove('is-project-mode');
71
- readouts.raceDataPanel.dataset.idleMode = 'quote';
72
- setText(readouts.raceDataKicker, 'Project radio');
73
- setText(readouts.raceDataTitle, radio.title);
74
- setText(readouts.raceDataNumber, '');
75
- setText(readouts.raceDataSubtitle, radio.subtitle);
76
- if (readouts.raceDataOpen) readouts.raceDataOpen.hidden = true;
100
+ getRaceDataPanelBindings(readouts).forEach((binding) => {
101
+ setStyleProperty(binding.panel, '--driver-color', formatCssColor(radio.color));
102
+ binding.panel.classList.remove('is-hidden');
103
+ binding.panel.classList.add('is-radio-mode');
104
+ binding.panel.classList.remove('is-project-mode');
105
+ setRaceDataIdleMode(binding.panel, 'quote');
106
+ setText(binding.kicker, 'Project radio');
107
+ setText(binding.title, radio.title);
108
+ setText(binding.number, '');
109
+ setText(binding.subtitle, radio.subtitle);
110
+ if (binding.open) binding.open.hidden = true;
111
+ });
77
112
  }
78
113
 
79
114
  export function randomRadioRange(nextRandom, min, max) {
@@ -1,4 +1,5 @@
1
- import { setText } from '../domBindings.js';
1
+ import { formatCssColor } from '../../config/cssValues.js';
2
+ import { setStyleProperty, setText } from '../domBindings.js';
2
3
  import {
3
4
  formatPenaltyChip,
4
5
  formatPenaltyHeadline,
@@ -18,7 +19,7 @@ export function createPenaltyStewardMessage(penalty, driverById) {
18
19
  driverId: penalty.driverId,
19
20
  penaltyId: penalty.id,
20
21
  time: penalty.time,
21
- color: driver?.color ?? 'var(--red)',
22
+ color: formatCssColor(driver?.color),
22
23
  kicker: formatPenaltyChip(penalty),
23
24
  title: `${code} ${formatPenaltyHeadline(penalty).replace(/^\+\d+ seconds? /u, '')}`,
24
25
  detail: `${formatPenaltyType(penalty.type)} - ${penalty.reason ?? 'Steward decision'}`,
@@ -39,7 +40,7 @@ export function createWarningStewardMessage(event, driverById) {
39
40
  driverId: event.carId,
40
41
  violationCount: event.violationCount,
41
42
  time: event.at,
42
- color: driver?.color ?? 'var(--yellow)',
43
+ color: formatCssColor(driver?.color, '#ffd166'),
43
44
  kicker: 'Warning',
44
45
  title: `${code} ${formatPenaltyType(event.type).toLowerCase()}`,
45
46
  detail: `${formatPenaltyType(event.type)}${warningProgress}`,
@@ -90,7 +91,7 @@ export function renderActiveStewardMessage(readouts, activeStewardMessage) {
90
91
  panel.classList.remove('is-penalty', 'is-warning');
91
92
  return;
92
93
  }
93
- panel.style.setProperty('--steward-color', message.color);
94
+ setStyleProperty(panel, '--steward-color', formatCssColor(message.color));
94
95
  panel.classList.remove('is-hidden', 'is-penalty', 'is-warning');
95
96
  panel.classList.add(message.kind === 'penalty' ? 'is-penalty' : 'is-warning');
96
97
  setText(readouts.stewardMessageKicker, message.kicker);