@inventure71/paddockjs 2.0.1 → 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 (80) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/INSTALL_AND_UPDATE.md +10 -1
  3. package/README.md +65 -15
  4. package/docs/architecture.md +17 -13
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/concepts.md +2 -2
  7. package/docs/custom_model_controller.md +2 -2
  8. package/docs/data_contract.md +96 -21
  9. package/docs/index.md +2 -1
  10. package/docs/learnings.md +6 -6
  11. package/docs/rules.md +7 -7
  12. package/docs/sense_contract.md +1 -1
  13. package/docs/system_specs.md +25 -18
  14. package/docs/training.md +13 -12
  15. package/package.json +1 -1
  16. package/src/api/PaddockSimulatorController.js +74 -4
  17. package/src/app/F1SimulatorApp.js +191 -28
  18. package/src/app/banners/raceDataBannerController.js +71 -36
  19. package/src/app/banners/stewardMessageController.js +5 -4
  20. package/src/app/camera/cameraConstants.js +2 -0
  21. package/src/app/camera/cameraController.js +86 -29
  22. package/src/app/camera/cameraGeometry.js +12 -1
  23. package/src/app/domBindings.js +89 -9
  24. package/src/app/layoutScheduler.js +27 -0
  25. package/src/app/layoutSupport.js +165 -0
  26. package/src/app/raceOverlayClearanceSupport.js +158 -0
  27. package/src/app/readouts/carOverviewRenderer.js +16 -8
  28. package/src/app/readouts/controlStateRenderer.js +1 -1
  29. package/src/app/readouts/raceStatusRenderer.js +30 -21
  30. package/src/app/readouts/readoutFormatters.js +2 -0
  31. package/src/app/readouts/telemetryRenderer.js +17 -8
  32. package/src/app/readouts/timingTowerRenderer.js +62 -10
  33. package/src/app/runtime/runtimeVisibility.js +66 -5
  34. package/src/config/cssValues.js +16 -0
  35. package/src/config/defaultOptions.js +51 -38
  36. package/src/config/themeOptions.js +825 -0
  37. package/src/config/timingGapMode.js +13 -0
  38. package/src/config/urlOptions.js +25 -0
  39. package/src/data/championship.js +14 -9
  40. package/src/environment/controllerLoop.js +17 -3
  41. package/src/environment/index.d.ts +1 -2
  42. package/src/environment/options.js +1 -1
  43. package/src/environment/runtime.js +3 -11
  44. package/src/environment/scenarios.js +1 -1
  45. package/src/environment/sensors/surfaceRays.js +41 -33
  46. package/src/environment/sensors/trackRays.js +44 -49
  47. package/src/index.d.ts +110 -9
  48. package/src/index.js +25 -1
  49. package/src/simulation/driver/driverController.js +1 -1
  50. package/src/simulation/driver/edgeRecovery.js +1 -1
  51. package/src/simulation/driver/racingControls.js +2 -2
  52. package/src/simulation/driver/rejoinControls.js +2 -2
  53. package/src/simulation/pit/pitKinematics.js +2 -2
  54. package/src/simulation/pit/pitRouting.js +2 -4
  55. package/src/simulation/race/raceSetup.js +2 -6
  56. package/src/simulation/race/raceStep.js +1 -1
  57. package/src/simulation/race/safetyCar.js +1 -1
  58. package/src/simulation/race/stalledDnf.js +1 -1
  59. package/src/simulation/timing/lapTelemetry.js +28 -9
  60. package/src/simulation/track/pitLaneLayout.js +7 -3
  61. package/src/simulation/track/spatialQueries.js +5 -28
  62. package/src/simulation/track/trackQueryIndex.js +180 -135
  63. package/src/simulation/track/trackStatePolicy.js +0 -1
  64. package/src/simulation/vehicle/contactResolution.js +3 -3
  65. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  66. package/src/simulation/vehicle/vehiclePhysics.js +3 -3
  67. package/src/simulation/vehicle/vehicleSnapshots.js +12 -1
  68. package/src/simulation/vehicle/vehicleState.js +1 -1
  69. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  70. package/src/styles.css +1185 -136
  71. package/src/ui/bannerTemplates.js +38 -28
  72. package/src/ui/cameraControlsTemplate.js +16 -5
  73. package/src/ui/carOverviewTemplate.js +12 -5
  74. package/src/ui/raceCanvasTemplate.js +31 -8
  75. package/src/ui/raceControlsTemplate.js +27 -10
  76. package/src/ui/shellTemplate.js +3 -1
  77. package/src/ui/telemetryDrawerTemplate.js +23 -10
  78. package/src/ui/telemetryTemplates.js +58 -32
  79. package/src/ui/templateUtils.js +48 -0
  80. 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,10 @@ 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.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
305
+ };
254
306
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
255
307
  this.observeLayoutResize();
256
308
  if (this.expertMode) {
@@ -304,7 +356,6 @@ export class F1SimulatorApp {
304
356
  rules: options.rules,
305
357
  participantInteractions: options.participantInteractions,
306
358
  replayGhosts: options.replayGhosts,
307
- trackQueryIndex: options.trackQueryIndex,
308
359
  warmup: normalizeWarmupOptions(options.warmup, 'browser'),
309
360
  });
310
361
  }
@@ -383,30 +434,42 @@ export class F1SimulatorApp {
383
434
 
384
435
  this.timingGapModeButtons.forEach((button) => {
385
436
  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 ?? []);
437
+ this.toggleTimingGapMode();
390
438
  }, eventOptions);
391
439
  });
392
440
 
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);
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
+ });
398
448
 
399
- this.openButton?.addEventListener('click', () => {
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
+ });
455
+
456
+ const raceDataOpenButtons = uniqueNodes(this.readouts.raceDataOpens?.length
457
+ ? this.readouts.raceDataOpens
458
+ : [this.openButton]);
459
+ raceDataOpenButtons.forEach((button) => button.addEventListener('click', () => {
400
460
  const driver = this.driverById.get(this.activeRaceDataId ?? this.selectedId);
401
461
  if (!driver) return;
402
462
  this.options.onDriverOpen?.(driver);
403
- }, eventOptions);
463
+ }, eventOptions));
404
464
 
405
- 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) => {
406
469
  event.preventDefault();
407
470
  event.stopPropagation();
408
471
  this.dismissRaceDataPanel(performance.now());
409
- }, eventOptions);
472
+ }, eventOptions));
410
473
 
411
474
  this.readouts.telemetryDrawerToggle?.addEventListener('click', () => {
412
475
  this.setTelemetryDrawerOpen(!this.telemetryDrawerOpen);
@@ -466,13 +529,51 @@ export class F1SimulatorApp {
466
529
  }
467
530
  this.readouts.telemetryDrawerToggle?.setAttribute?.('aria-expanded', String(this.telemetryDrawerOpen));
468
531
  if (this.readouts.telemetryDrawerToggle) {
469
- 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
+ );
470
537
  }
471
538
  this.invalidateCameraSafeArea();
472
539
  this.syncRendererToCurrentLayout({ render: true });
473
540
  this.scheduleLayoutResizeSync(380);
474
541
  }
475
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
+
476
577
  setRaceDataBannersMuted(muted, now = performance.now()) {
477
578
  this.raceDataBannersMuted = Boolean(muted);
478
579
  if (this.raceDataBannersMuted) {
@@ -537,11 +638,13 @@ export class F1SimulatorApp {
537
638
  if (typeof ResizeObserver !== 'function') return;
538
639
  this.layoutResizeObserver?.disconnect?.();
539
640
  this.layoutResizeObserver = new ResizeObserver(() => {
641
+ this.syncTimingPanelDisclosureState();
540
642
  this.invalidateCameraSafeArea();
541
643
  this.syncRendererToCurrentLayout({ render: true });
542
644
  });
543
645
  if (this.canvasHost) this.layoutResizeObserver.observe(this.canvasHost);
544
- 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));
545
648
  }
546
649
 
547
650
  invalidateCameraSafeArea() {
@@ -572,6 +675,7 @@ export class F1SimulatorApp {
572
675
  renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
573
676
  if (!snapshot) return;
574
677
  const now = performance.now();
678
+ this.sampleFps(now);
575
679
  const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
576
680
  this.applyCamera(renderSnapshot);
577
681
  this.renderDrsTrails(renderSnapshot);
@@ -670,6 +774,7 @@ export class F1SimulatorApp {
670
774
  }
671
775
 
672
776
  updateDom(snapshot, { emitLifecycle = true } = {}) {
777
+ snapshot = normalizeRuntimeSnapshot(snapshot, this.totalLaps);
673
778
  const leader = snapshot.cars[0];
674
779
  const selected = snapshot.cars.find((car) => car.id === this.selectedId) ?? leader;
675
780
  const activeDrs = snapshot.cars.filter((car) => car.drsActive).length;
@@ -677,6 +782,7 @@ export class F1SimulatorApp {
677
782
  const now = performance.now();
678
783
 
679
784
  if (emitLifecycle) this.emitSnapshotLifecycle(snapshot);
785
+ this.syncThemeContext(snapshot);
680
786
 
681
787
  if (
682
788
  this.shouldAutoHideActiveRaceData() &&
@@ -717,7 +823,7 @@ export class F1SimulatorApp {
717
823
  this.lastTimingPenaltyKey = timingPenaltyKey;
718
824
  this.lastTimingOrderKey = timingOrderKey;
719
825
  }
720
- this.renderTelemetry(selected);
826
+ if (selected) this.renderTelemetry(selected);
721
827
  const activeRaceDataCar = this.activeRaceDataId
722
828
  ? snapshot.cars.find((car) => car.id === this.activeRaceDataId)
723
829
  : null;
@@ -804,7 +910,8 @@ export class F1SimulatorApp {
804
910
 
805
911
  renderTiming(cars, raceMode, penalties = []) {
806
912
  this.syncTimingGapModeControls();
807
- this.lastTimingMarkup = renderTimingTower({
913
+ this.lastTimingMarkup = renderTimingTowers({
914
+ timingLists: this.timingLists,
808
915
  timingList: this.timingList,
809
916
  cars,
810
917
  raceMode,
@@ -817,6 +924,35 @@ export class F1SimulatorApp {
817
924
  });
818
925
  }
819
926
 
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;
950
+ }
951
+
952
+ toggleTimingGapMode() {
953
+ return this.setTimingGapMode(getNextTimingGapMode(this.timingGapMode));
954
+ }
955
+
820
956
  getTimingOrderKey(cars = []) {
821
957
  return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
822
958
  }
@@ -1050,6 +1186,20 @@ export class F1SimulatorApp {
1050
1186
  updateToggleButtons(this.safetyButtons, active);
1051
1187
  }
1052
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
+
1053
1203
  applyExpertOptions(nextOptions = {}) {
1054
1204
  this.options = {
1055
1205
  ...this.options,
@@ -1071,6 +1221,7 @@ export class F1SimulatorApp {
1071
1221
  },
1072
1222
  },
1073
1223
  };
1224
+ this.totalLaps = this.options.totalLaps;
1074
1225
  this.assets = this.options.assets;
1075
1226
  if (hasOwnOption(nextOptions, 'trackSeed')) {
1076
1227
  this.trackSeed = this.options.trackSeed ?? createMountTrackSeed();
@@ -1079,6 +1230,7 @@ export class F1SimulatorApp {
1079
1230
  this.drivers = nextOptions.drivers;
1080
1231
  this.driverById = new Map(this.drivers.map((driver) => [driver.id, driver]));
1081
1232
  this.root.style.setProperty('--paddock-entry-count', String(this.drivers.length));
1233
+ this.lastThemeContextKey = null;
1082
1234
  this.createCars();
1083
1235
  }
1084
1236
  }
@@ -1096,13 +1248,16 @@ export class F1SimulatorApp {
1096
1248
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
1097
1249
  this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
1098
1250
  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);
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();
1101
1257
  this.sim = this.createRaceSimulation();
1102
1258
  if (this.expertMode) {
1103
1259
  this.expert = createBrowserExpertAdapter(this, this.options.expert);
1104
1260
  }
1105
- this.selectedId = this.drivers[0]?.id ?? null;
1106
1261
  this.lastTimingMarkup = null;
1107
1262
  this.lastOverviewRenderKey = null;
1108
1263
  this.lastFinishClassificationMarkup = null;
@@ -1177,8 +1332,16 @@ export class F1SimulatorApp {
1177
1332
  cancelAnimationFrame(this.layoutResizeFrame);
1178
1333
  }
1179
1334
  this.layoutResizeFrame = null;
1335
+ if (this.runtimeVisibilitySyncFrame && typeof cancelAnimationFrame === 'function') {
1336
+ cancelAnimationFrame(this.runtimeVisibilitySyncFrame);
1337
+ }
1338
+ this.runtimeVisibilitySyncFrame = null;
1180
1339
  this.layoutResizeObserver?.disconnect?.();
1181
1340
  this.layoutResizeObserver = null;
1341
+ this.layoutSupportCleanup?.();
1342
+ this.layoutSupportCleanup = null;
1343
+ this.raceOverlayClearanceCleanup?.();
1344
+ this.raceOverlayClearanceCleanup = null;
1182
1345
  this.visibilityObserver?.disconnect?.();
1183
1346
  this.visibilityObserver = null;
1184
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);
@@ -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
  };