@inventure71/paddockjs 2.0.1 → 4.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 (136) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/INSTALL_AND_UPDATE.md +24 -4
  3. package/README.md +84 -512
  4. package/docs/architecture.md +38 -24
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/composable-layouts.md +77 -0
  7. package/docs/concepts.md +2 -2
  8. package/docs/custom_model_controller.md +6 -6
  9. package/docs/data-helpers.md +50 -0
  10. package/docs/data_contract.md +104 -27
  11. package/docs/getting-started.md +75 -0
  12. package/docs/headless-environment.md +68 -0
  13. package/docs/index.md +18 -4
  14. package/docs/learnings.md +6 -6
  15. package/docs/rules.md +12 -8
  16. package/docs/sense_contract.md +1 -1
  17. package/docs/system_specs.md +71 -21
  18. package/docs/theming.md +64 -0
  19. package/docs/training.md +26 -19
  20. package/docs/troubleshooting.md +76 -0
  21. package/docs/upgrading-hosts-to-3.md +42 -0
  22. package/examples/python/README.md +6 -0
  23. package/examples/python/base_policy_server.py +19 -0
  24. package/package.json +10 -3
  25. package/src/api/PaddockSimulatorController.js +150 -24
  26. package/src/api/runtimeTheme.js +59 -0
  27. package/src/app/F1SimulatorApp.js +213 -29
  28. package/src/app/banners/raceDataBannerController.js +71 -36
  29. package/src/app/banners/stewardMessageController.js +5 -4
  30. package/src/app/camera/cameraConstants.js +2 -0
  31. package/src/app/camera/cameraController.js +86 -29
  32. package/src/app/camera/cameraGeometry.js +12 -1
  33. package/src/app/domBindings.js +89 -9
  34. package/src/app/layoutScheduler.js +27 -0
  35. package/src/app/layoutSupport.js +165 -0
  36. package/src/app/raceOverlayClearanceSupport.js +158 -0
  37. package/src/app/readouts/carOverviewRenderer.js +16 -8
  38. package/src/app/readouts/controlStateRenderer.js +1 -1
  39. package/src/app/readouts/raceStatusRenderer.js +30 -21
  40. package/src/app/readouts/readoutFormatters.js +2 -0
  41. package/src/app/readouts/telemetryRenderer.js +17 -8
  42. package/src/app/readouts/timingTowerRenderer.js +262 -49
  43. package/src/app/runtime/frameLoop.js +5 -1
  44. package/src/app/runtime/runtimeVisibility.js +66 -5
  45. package/src/config/cssValues.js +16 -0
  46. package/src/config/defaultOptions.js +58 -38
  47. package/src/config/themeOptions.js +830 -0
  48. package/src/config/timingGapMode.js +13 -0
  49. package/src/config/urlOptions.js +25 -0
  50. package/src/data/championship.js +14 -9
  51. package/src/data/index.d.ts +212 -0
  52. package/src/data/index.js +14 -0
  53. package/src/environment/controllerLoop.js +17 -3
  54. package/src/environment/episode.js +11 -1
  55. package/src/environment/index.d.ts +1 -2
  56. package/src/environment/metrics.js +59 -16
  57. package/src/environment/observationVector.js +50 -33
  58. package/src/environment/observations.js +89 -20
  59. package/src/environment/options.js +1 -1
  60. package/src/environment/runtime.js +44 -11
  61. package/src/environment/scenarios.js +1 -1
  62. package/src/environment/sensors/carRays.js +48 -37
  63. package/src/environment/sensors/index.js +501 -97
  64. package/src/environment/sensors/indexedRayBands.js +134 -46
  65. package/src/environment/sensors/rayBandTrace.js +1171 -0
  66. package/src/environment/sensors/rayChannels.js +108 -0
  67. package/src/environment/sensors/rayConfig.js +71 -25
  68. package/src/environment/sensors/rayGeometry.js +23 -17
  69. package/src/environment/sensors/sensorTargets.js +67 -1
  70. package/src/environment/sensors/surfaceRays.js +167 -118
  71. package/src/environment/sensors/trackRays.js +157 -48
  72. package/src/index.d.ts +153 -9
  73. package/src/index.js +52 -3
  74. package/src/simulation/collisionGeometry.js +260 -96
  75. package/src/simulation/driver/driverController.js +1 -1
  76. package/src/simulation/driver/edgeRecovery.js +1 -1
  77. package/src/simulation/driver/racingControls.js +2 -2
  78. package/src/simulation/driver/rejoinControls.js +2 -2
  79. package/src/simulation/pit/pitFlow.js +21 -9
  80. package/src/simulation/pit/pitKinematics.js +2 -2
  81. package/src/simulation/pit/pitQueue.js +5 -3
  82. package/src/simulation/pit/pitRouteMovement.js +9 -4
  83. package/src/simulation/pit/pitRouting.js +79 -46
  84. package/src/simulation/pit/pitState.js +1 -0
  85. package/src/simulation/pit/pitTireService.js +7 -3
  86. package/src/simulation/race/raceFinish.js +25 -8
  87. package/src/simulation/race/raceOrder.js +189 -32
  88. package/src/simulation/race/raceProgress.js +129 -37
  89. package/src/simulation/race/raceSetup.js +4 -6
  90. package/src/simulation/race/raceStep.js +26 -14
  91. package/src/simulation/race/safetyCar.js +1 -1
  92. package/src/simulation/race/stalledDnf.js +1 -1
  93. package/src/simulation/raceSimulation.js +20 -17
  94. package/src/simulation/rules/collisionSteward.js +40 -23
  95. package/src/simulation/rules/rulesReview.js +77 -56
  96. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  97. package/src/simulation/timing/drsTiming.js +143 -30
  98. package/src/simulation/timing/gapEstimation.js +25 -3
  99. package/src/simulation/timing/lapTelemetry.js +138 -40
  100. package/src/simulation/timing/raceTiming.js +5 -1
  101. package/src/simulation/timing/sectorPerformance.js +104 -26
  102. package/src/simulation/timing/timingHistory.js +229 -14
  103. package/src/simulation/timing/timingLines.js +65 -7
  104. package/src/simulation/track/pitLaneLayout.js +30 -3
  105. package/src/simulation/track/pitLaneState.js +218 -19
  106. package/src/simulation/track/spatialQueries.js +66 -33
  107. package/src/simulation/track/trackMath.js +91 -24
  108. package/src/simulation/track/trackModel.js +2 -0
  109. package/src/simulation/track/trackQueryIndex.js +1468 -189
  110. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  111. package/src/simulation/track/trackQueryScratch.js +7 -0
  112. package/src/simulation/track/trackQueryStats.js +25 -0
  113. package/src/simulation/track/trackSnapshotJson.js +139 -0
  114. package/src/simulation/track/trackStatePolicy.js +249 -2
  115. package/src/simulation/vehicle/contactResolution.js +99 -62
  116. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  117. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  118. package/src/simulation/vehicle/runoffResponse.js +54 -29
  119. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  120. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  121. package/src/simulation/vehicle/vehiclePhysics.js +45 -30
  122. package/src/simulation/vehicle/vehicleSnapshots.js +35 -1
  123. package/src/simulation/vehicle/vehicleState.js +7 -1
  124. package/src/simulation/vehicle/wheelSurface.js +807 -92
  125. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  126. package/src/styles.css +1568 -519
  127. package/src/ui/bannerTemplates.js +38 -28
  128. package/src/ui/cameraControlsTemplate.js +16 -5
  129. package/src/ui/carOverviewTemplate.js +12 -5
  130. package/src/ui/raceCanvasTemplate.js +31 -8
  131. package/src/ui/raceControlsTemplate.js +27 -10
  132. package/src/ui/shellTemplate.js +3 -1
  133. package/src/ui/telemetryDrawerTemplate.js +23 -10
  134. package/src/ui/telemetryTemplates.js +58 -32
  135. package/src/ui/templateUtils.js +48 -0
  136. 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';
@@ -18,7 +22,7 @@ import { renderTrackSurface } from './rendering/trackRenderer.js';
18
22
  import {
19
23
  getTimingOrderKey,
20
24
  getTimingPenaltyKey,
21
- renderTimingTower,
25
+ renderTimingTowers,
22
26
  syncTimingGapModeControls,
23
27
  } from './readouts/timingTowerRenderer.js';
24
28
  import { renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
@@ -60,6 +64,44 @@ import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
60
64
  const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
61
65
  const DRS_DRAG_REDUCTION_PERCENT = 58;
62
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
+
63
105
  function createMountTrackSeed() {
64
106
  const values = new Uint32Array(1);
65
107
  try {
@@ -105,16 +147,22 @@ export class F1SimulatorApp {
105
147
  this.drivers = options.drivers;
106
148
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
107
149
  this.assets = options.assets;
108
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
150
+ this.root.style.setProperty('--broadcast-panel-surface', formatCssUrl(this.assets.broadcastPanel));
109
151
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
110
- 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();
111
156
  Object.assign(this, querySimulatorDom(root));
112
157
  this.abortController = new AbortController();
113
158
  this.resizeHandler = null;
114
159
  this.layoutResizeObserver = null;
160
+ this.layoutSupportCleanup = installLayoutSupport(root);
161
+ this.raceOverlayClearanceCleanup = installRaceOverlayClearanceSupport(root);
115
162
  this.cameraController = null;
116
163
  this.visibilityChangeHandler = null;
117
164
  this.visibilityObserver = null;
165
+ this.runtimeVisibilitySyncFrame = null;
118
166
  this.tickerCallback = null;
119
167
  this.expert = null;
120
168
  this.expertMode = Boolean(options.expert?.enabled);
@@ -143,7 +191,6 @@ export class F1SimulatorApp {
143
191
  this.replayGhostRenderer = new ReplayGhostRenderer();
144
192
  this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
145
193
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
146
- this.selectedId = this.drivers[0]?.id ?? null;
147
194
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
148
195
  this.raceDataBannersMuted = false;
149
196
  this.penaltyBannerEnabled = Boolean(options.ui?.penaltyBanners);
@@ -166,6 +213,7 @@ export class F1SimulatorApp {
166
213
  canvasHost: this.canvasHost,
167
214
  readouts: this.readouts,
168
215
  initialMode: options.initialCameraMode,
216
+ driverCamera: options.ui?.driverCamera,
169
217
  });
170
218
  this.camera = this.cameraController.camera;
171
219
  this.overviewMode = 'vehicle';
@@ -193,7 +241,7 @@ export class F1SimulatorApp {
193
241
  this.lastLeaderLap = null;
194
242
  this.emittedRaceEventKeys = new Set();
195
243
  this.raceFinishEmitted = false;
196
- this.timingGapMode = 'interval';
244
+ this.timingGapMode = normalizeTimingGapMode(options.ui?.timingGapMode);
197
245
  this.telemetryDrawerOpen = Boolean(
198
246
  this.readouts.telemetryDrawerWorkbench?.classList?.contains?.('is-telemetry-open'),
199
247
  );
@@ -239,6 +287,7 @@ export class F1SimulatorApp {
239
287
  );
240
288
  this.createCars();
241
289
  this.bindControls();
290
+ this.syncTimingPanelDisclosureState();
242
291
  this.renderTrack();
243
292
  const snapshot = this.sim.snapshot();
244
293
  this.updateDom(snapshot);
@@ -250,7 +299,11 @@ export class F1SimulatorApp {
250
299
  this.completeComponentLoading();
251
300
  this.emitHostCallback('onLoadingChange', { loading: false, phase: 'ready' });
252
301
  this.emitHostCallback('onReady', { snapshot });
253
- this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
302
+ this.resizeHandler = () => {
303
+ this.syncTimingPanelDisclosureState();
304
+ this.renderSourceSnapshotBuffer ??= {};
305
+ this.applyCamera(this.sim.snapshotRenderInto?.(this.renderSourceSnapshotBuffer) ?? this.sim.snapshotRender?.() ?? this.sim.snapshot());
306
+ };
254
307
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
255
308
  this.observeLayoutResize();
256
309
  if (this.expertMode) {
@@ -304,7 +357,6 @@ export class F1SimulatorApp {
304
357
  rules: options.rules,
305
358
  participantInteractions: options.participantInteractions,
306
359
  replayGhosts: options.replayGhosts,
307
- trackQueryIndex: options.trackQueryIndex,
308
360
  warmup: normalizeWarmupOptions(options.warmup, 'browser'),
309
361
  });
310
362
  }
@@ -383,30 +435,42 @@ export class F1SimulatorApp {
383
435
 
384
436
  this.timingGapModeButtons.forEach((button) => {
385
437
  button.addEventListener('click', () => {
386
- this.timingGapMode = button.dataset.timingGapMode === 'leader' ? 'leader' : 'interval';
387
- this.syncTimingGapModeControls();
388
- const snapshot = this.sim?.snapshot?.();
389
- if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
438
+ this.toggleTimingGapMode();
390
439
  }, eventOptions);
391
440
  });
392
441
 
393
- this.timingList?.addEventListener?.('pointerdown', (event) => {
394
- const row = event.target instanceof Element ? event.target.closest('[data-driver-id]') : null;
395
- if (!row) return;
396
- this.selectCar(row.dataset.driverId, { focus: true });
397
- }, eventOptions);
442
+ this.timingLists?.forEach?.((timingList) => {
443
+ timingList.addEventListener?.('pointerdown', (event) => {
444
+ const row = event.target instanceof Element ? event.target.closest('[data-driver-id]') : null;
445
+ if (!row) return;
446
+ this.selectCar(row.dataset.driverId, { focus: true });
447
+ }, eventOptions);
448
+ });
398
449
 
399
- this.openButton?.addEventListener('click', () => {
450
+ this.readouts.timingPanelToggles?.forEach?.((toggle) => {
451
+ toggle.addEventListener?.('click', () => {
452
+ const racePanel = toggle.closest?.('.sim-canvas-panel');
453
+ this.setTimingPanelOpen(!racePanel?.classList?.contains?.('is-timing-panel-open'), racePanel);
454
+ }, eventOptions);
455
+ });
456
+
457
+ const raceDataOpenButtons = uniqueNodes(this.readouts.raceDataOpens?.length
458
+ ? this.readouts.raceDataOpens
459
+ : [this.openButton]);
460
+ raceDataOpenButtons.forEach((button) => button.addEventListener('click', () => {
400
461
  const driver = this.driverById.get(this.activeRaceDataId ?? this.selectedId);
401
462
  if (!driver) return;
402
463
  this.options.onDriverOpen?.(driver);
403
- }, eventOptions);
464
+ }, eventOptions));
404
465
 
405
- this.readouts.raceDataDismiss?.addEventListener('click', (event) => {
466
+ const raceDataDismissButtons = uniqueNodes(this.readouts.raceDataDismisses?.length
467
+ ? this.readouts.raceDataDismisses
468
+ : [this.readouts.raceDataDismiss]);
469
+ raceDataDismissButtons.forEach((button) => button.addEventListener('click', (event) => {
406
470
  event.preventDefault();
407
471
  event.stopPropagation();
408
472
  this.dismissRaceDataPanel(performance.now());
409
- }, eventOptions);
473
+ }, eventOptions));
410
474
 
411
475
  this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
412
476
  this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
@@ -466,13 +530,51 @@ export class F1SimulatorApp {
466
530
  }
467
531
  this.readouts.telemetryDrawerToggle?.setAttribute?.('aria-expanded', String(this.telemetryDrawerOpen));
468
532
  if (this.readouts.telemetryDrawerToggle) {
469
- this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close telemetry' : 'Telemetry';
533
+ this.readouts.telemetryDrawerToggle.textContent = this.telemetryDrawerOpen ? 'Close' : 'Telemetry';
534
+ this.readouts.telemetryDrawerToggle.setAttribute(
535
+ 'aria-label',
536
+ this.telemetryDrawerOpen ? 'Close telemetry' : 'Open telemetry',
537
+ );
470
538
  }
471
539
  this.invalidateCameraSafeArea();
472
540
  this.syncRendererToCurrentLayout({ render: true });
473
541
  this.scheduleLayoutResizeSync(380);
474
542
  }
475
543
 
544
+ setTimingPanelOpen(open, racePanel = this.readouts.timingTower?.closest?.('.sim-canvas-panel')) {
545
+ racePanel?.classList?.toggle?.('is-timing-panel-open', Boolean(open));
546
+ this.syncTimingPanelDisclosureState(racePanel);
547
+ this.invalidateCameraSafeArea();
548
+ this.syncRendererToCurrentLayout({ render: true });
549
+ this.scheduleLayoutResizeSync(380);
550
+ }
551
+
552
+ isTimingPanelDisclosureMode(toggle = this.readouts.timingPanelToggle) {
553
+ if (!toggle) return false;
554
+ const style = getComputedStyle(toggle);
555
+ return style.display !== 'none' && style.visibility !== 'hidden';
556
+ }
557
+
558
+ syncTimingPanelDisclosureState(targetPanel = null) {
559
+ const toggles = [...(this.readouts.timingPanelToggles ?? [])];
560
+ for (const toggle of toggles) {
561
+ const racePanel = toggle.closest?.('.sim-canvas-panel');
562
+ if (targetPanel && racePanel !== targetPanel) continue;
563
+ const tower = racePanel?.querySelector?.('[data-timing-tower]');
564
+ const disclosureMode = this.isTimingPanelDisclosureMode(toggle);
565
+ const open = disclosureMode && Boolean(racePanel?.classList?.contains?.('is-timing-panel-open'));
566
+ racePanel?.classList?.toggle?.('is-timing-panel-open', open);
567
+ if (!disclosureMode || open) {
568
+ tower?.removeAttribute?.('inert');
569
+ tower?.setAttribute?.('aria-hidden', 'false');
570
+ } else {
571
+ tower?.setAttribute?.('inert', '');
572
+ tower?.setAttribute?.('aria-hidden', 'true');
573
+ }
574
+ toggle.setAttribute?.('aria-expanded', String(open));
575
+ }
576
+ }
577
+
476
578
  setRaceDataBannersMuted(muted, now = performance.now()) {
477
579
  this.raceDataBannersMuted = Boolean(muted);
478
580
  if (this.raceDataBannersMuted) {
@@ -505,7 +607,8 @@ export class F1SimulatorApp {
505
607
 
506
608
  syncRendererToCurrentLayout({ render = false } = {}) {
507
609
  this.resizeRendererToCanvasHost();
508
- const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
610
+ this.renderSourceSnapshotBuffer ??= {};
611
+ const snapshot = this.sim?.snapshotRenderInto?.(this.renderSourceSnapshotBuffer) ?? this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
509
612
  if (!snapshot) return;
510
613
  const renderSnapshot = interpolateRenderSnapshotInto(
511
614
  this.renderSnapshotBuffer,
@@ -537,11 +640,13 @@ export class F1SimulatorApp {
537
640
  if (typeof ResizeObserver !== 'function') return;
538
641
  this.layoutResizeObserver?.disconnect?.();
539
642
  this.layoutResizeObserver = new ResizeObserver(() => {
643
+ this.syncTimingPanelDisclosureState();
540
644
  this.invalidateCameraSafeArea();
541
645
  this.syncRendererToCurrentLayout({ render: true });
542
646
  });
543
647
  if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
544
- if (this.readouts.timingTower) this.layoutResizeObserver.observe(this.readouts.timingTower);
648
+ resolveNodes(this.readouts.timingTowers, this.readouts.timingTower)
649
+ .forEach((timingTower) => this.layoutResizeObserver.observe(timingTower));
545
650
  }
546
651
 
547
652
  invalidateCameraSafeArea() {
@@ -572,6 +677,7 @@ export class F1SimulatorApp {
572
677
  renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
573
678
  if (!snapshot) return;
574
679
  const now = performance.now();
680
+ this.sampleFps(now);
575
681
  const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
576
682
  this.applyCamera(renderSnapshot);
577
683
  this.renderDrsTrails(renderSnapshot);
@@ -670,6 +776,7 @@ export class F1SimulatorApp {
670
776
  }
671
777
 
672
778
  updateDom(snapshot, { emitLifecycle = true } = {}) {
779
+ snapshot = normalizeRuntimeSnapshot(snapshot, this.totalLaps);
673
780
  const leader = snapshot.cars[0];
674
781
  const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
675
782
  const activeDrs = snapshot.cars.filter((car) => car.drsActive).length;
@@ -677,6 +784,7 @@ export class F1SimulatorApp {
677
784
  const now = performance.now();
678
785
 
679
786
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
787
+ this.syncThemeContext(snapshot);
680
788
 
681
789
  if (
682
790
  this.shouldAutoHideActiveRaceData() &&
@@ -717,7 +825,7 @@ export class F1SimulatorApp {
717
825
  this.lastTimingPenaltyKey = timingPenaltyKey;
718
826
  this.lastTimingOrderKey = timingOrderKey;
719
827
  }
720
- this.renderTelemetry(selected);
828
+ if (selected) this.renderTelemetry(selected);
721
829
  const activeRaceDataCar = this.activeRaceDataId
722
830
  ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
723
831
  : null;
@@ -804,7 +912,8 @@ export class F1SimulatorApp {
804
912
 
805
913
  renderTiming(cars, raceMode, penalties = []) {
806
914
  this.syncTimingGapModeControls();
807
- this.lastTimingMarkup = renderTimingTower({
915
+ this.lastTimingMarkup = renderTimingTowers({
916
+ timingLists: this.timingLists,
808
917
  timingList: this.timingList,
809
918
  cars,
810
919
  raceMode,
@@ -817,6 +926,35 @@ export class F1SimulatorApp {
817
926
  });
818
927
  }
819
928
 
929
+ setTimingGapMode(mode) {
930
+ const nextMode = normalizeTimingGapMode(mode);
931
+ if (this.timingGapMode === nextMode) {
932
+ this.syncTimingGapModeControls();
933
+ return this.timingGapMode;
934
+ }
935
+ this.timingGapMode = nextMode;
936
+ this.options = {
937
+ ...this.options,
938
+ ui: {
939
+ ...this.options.ui,
940
+ timingGapMode: nextMode,
941
+ },
942
+ };
943
+ this.lastTimingRenderTime = 0;
944
+ this.syncTimingGapModeControls();
945
+ const snapshot = this.sim?.snapshot?.();
946
+ if (snapshot) this.renderTiming(snapshot.cars, snapshot.raceControl.mode, snapshot.penalties ?? []);
947
+ return this.timingGapMode;
948
+ }
949
+
950
+ getTimingGapMode() {
951
+ return this.timingGapMode;
952
+ }
953
+
954
+ toggleTimingGapMode() {
955
+ return this.setTimingGapMode(getNextTimingGapMode(this.timingGapMode));
956
+ }
957
+
820
958
  getTimingOrderKey(cars = []) {
821
959
  return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
822
960
  }
@@ -1050,6 +1188,39 @@ export class F1SimulatorApp {
1050
1188
  updateToggleButtons(this.safetyButtons, active);
1051
1189
  }
1052
1190
 
1191
+ syncThemeContext(snapshot = null) {
1192
+ const selectedTeamId = this.getSelectedTeamId(snapshot);
1193
+ const contextKey = selectedTeamId ?? '';
1194
+ if (this.lastThemeContextKey === contextKey) return;
1195
+ this.lastThemeContextKey = contextKey;
1196
+ const context = { selectedTeamId };
1197
+ if (typeof this.root?.applyCssVariables === 'function') {
1198
+ this.root.applyCssVariables(context);
1199
+ return;
1200
+ }
1201
+ applyPaddockThemeCssVariables(this.root, this.options.theme, context);
1202
+ }
1203
+
1204
+ setTheme(theme) {
1205
+ this.options = {
1206
+ ...this.options,
1207
+ theme,
1208
+ };
1209
+ this.lastThemeContextKey = null;
1210
+ this.syncThemeContext(this.getSnapshot());
1211
+ return this.options.theme;
1212
+ }
1213
+
1214
+ getTheme() {
1215
+ return this.options.theme;
1216
+ }
1217
+
1218
+ getSelectedTeamId(snapshot = null) {
1219
+ const selectedCar = snapshot?.cars?.find?.((car) => car.id === this.selectedId);
1220
+ const selectedDriver = this.driverById.get(this.selectedId);
1221
+ return selectedCar?.team?.id ?? selectedDriver?.team?.id ?? null;
1222
+ }
1223
+
1053
1224
  applyExpertOptions(nextOptions = {}) {
1054
1225
  this.options = {
1055
1226
  ...this.options,
@@ -1071,6 +1242,7 @@ export class F1SimulatorApp {
1071
1242
  },
1072
1243
  },
1073
1244
  };
1245
+ this.totalLaps = this.options.totalLaps;
1074
1246
  this.assets = this.options.assets;
1075
1247
  if (hasOwnOption(nextOptions, 'trackSeed')) {
1076
1248
  this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
@@ -1079,6 +1251,7 @@ export class F1SimulatorApp {
1079
1251
  this.drivers = nextOptions.drivers;
1080
1252
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
1081
1253
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
1254
+ this.lastThemeContextKey = null;
1082
1255
  this.createCars();
1083
1256
  }
1084
1257
  }
@@ -1096,13 +1269,16 @@ export class F1SimulatorApp {
1096
1269
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1097
1270
  this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
1098
1271
  this.timingPenaltyBadgesEnabled = Boolean(this.options.ui?.timingPenaltyBadges);
1099
- this.root.style.setProperty('--broadcast-panel-surface', `url('${this.assets.broadcastPanel}')`);
1100
- applyPaddockThemeCssVariables(this.root, this.options.theme);
1272
+ this.timingGapMode = normalizeTimingGapMode(this.options.ui?.timingGapMode);
1273
+ this.cameraController.driverCamera = Boolean(this.options.ui?.driverCamera);
1274
+ this.root.style.setProperty('--broadcast-panel-surface', formatCssUrl(this.assets.broadcastPanel));
1275
+ this.selectedId = this.drivers[0]?.id ?? null;
1276
+ this.lastThemeContextKey = null;
1277
+ this.syncThemeContext();
1101
1278
  this.sim = this.createRaceSimulation();
1102
1279
  if (this.expertMode) {
1103
1280
  this.expert = createBrowserExpertAdapter(this, this.options.expert);
1104
1281
  }
1105
- this.selectedId = this.drivers[0]?.id ?? null;
1106
1282
  this.lastTimingMarkup = null;
1107
1283
  this.lastOverviewRenderKey = null;
1108
1284
  this.lastFinishClassificationMarkup = null;
@@ -1177,8 +1353,16 @@ export class F1SimulatorApp {
1177
1353
  cancelAnimationFrame(this.layoutResizeFrame);
1178
1354
  }
1179
1355
  this.layoutResizeFrame = null;
1356
+ if (this.runtimeVisibilitySyncFrame && typeof cancelAnimationFrame === 'function') {
1357
+ cancelAnimationFrame(this.runtimeVisibilitySyncFrame);
1358
+ }
1359
+ this.runtimeVisibilitySyncFrame = null;
1180
1360
  this.layoutResizeObserver?.disconnect?.();
1181
1361
  this.layoutResizeObserver = null;
1362
+ this.layoutSupportCleanup?.();
1363
+ this.layoutSupportCleanup = null;
1364
+ this.raceOverlayClearanceCleanup?.();
1365
+ this.raceOverlayClearanceCleanup = null;
1182
1366
  this.visibilityObserver?.disconnect?.();
1183
1367
  this.visibilityObserver = null;
1184
1368
  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);
@@ -3,10 +3,12 @@ import { VEHICLE_GEOMETRY } from '../../simulation/vehicleGeometry.js';
3
3
 
4
4
  export const CAMERA_TARGET_LERP = 0.12;
5
5
  export const CAMERA_SCALE_LERP = 0.12;
6
+ export const CAMERA_ROTATION_LERP = 0.16;
6
7
  export const CAMERA_PRESETS = {
7
8
  overview: 1,
8
9
  leader: 18,
9
10
  selected: 24,
11
+ driver: 34,
10
12
  'show-all': 1,
11
13
  pit: 2.35,
12
14
  };