@inventure71/paddockjs 1.0.0 → 2.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 (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,96 +1,70 @@
1
- import { Application, Assets, Container, Graphics, Sprite, Text, Texture } from 'pixi.js';
2
- import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../data/championship.js';
1
+ import { Application, Container, Graphics } from 'pixi.js';
3
2
  import { applyPaddockThemeCssVariables } from '../config/defaultOptions.js';
4
- import { normalizeCustomFields } from '../data/customFields.js';
5
- import { getCarRayOrigin, getCarRayVector } from '../environment/sensors.js';
6
3
  import { ProceduralTrackAsset } from '../rendering/proceduralTrackAsset.js';
7
- import { createRenderSnapshot } from '../rendering/renderSnapshot.js';
4
+ import { interpolateRenderSnapshotInto } from '../rendering/renderSnapshot.js';
8
5
  import { createRaceSimulation, FIXED_STEP } from '../simulation/raceSimulation.js';
9
6
  import { clamp } from '../simulation/simMath.js';
10
- import { offsetTrackPoint, pointAt, WORLD } from '../simulation/trackModel.js';
11
- import { metersToSimUnits } from '../simulation/units.js';
12
- import { VEHICLE_GEOMETRY } from '../simulation/vehicleGeometry.js';
13
- import { getRaceControlStatusBanner } from '../ui/raceControlStatusBanner.js';
7
+ import { WORLD } from '../simulation/track/trackModel.js';
8
+ import { CameraController } from './camera/cameraController.js';
14
9
  import { createBrowserExpertAdapter } from './BrowserExpertAdapter.js';
15
- import { querySimulatorDom, setText, setTextAll } from './domBindings.js';
16
-
17
- const TARGET_RENDER_FPS = 60;
18
- const TARGET_FRAME_MS = 1000 / TARGET_RENDER_FPS;
19
- const FRAME_PACING_EPSILON_MS = 0.75;
20
- const MAX_FRAME_CATCHUP_COUNT = 4;
21
- const BASE_MAX_SIMULATION_STEPS_PER_RENDER = 5;
22
- const HARD_MAX_SIMULATION_STEPS_PER_RENDER = 60;
23
- const DOM_UPDATE_INTERVAL_MS = 100;
24
- const TIMING_UPDATE_INTERVAL_MS = 250;
25
- const CAMERA_TARGET_LERP = 0.12;
26
- const CAMERA_SCALE_LERP = 0.12;
10
+ import { querySimulatorDom } from './domBindings.js';
11
+ import { loadAppTextures } from './rendering/appAssets.js';
12
+ import { CarRenderer } from './rendering/carRenderer.js';
13
+ import { DrsTrailRenderer } from './rendering/drsTrailRenderer.js';
14
+ import { renderExpertSensorRays } from './rendering/expertSensorRenderer.js';
15
+ import { PitLaneStatusRenderer } from './rendering/pitLaneStatusRenderer.js';
16
+ import { ReplayGhostRenderer } from './rendering/replayGhostRenderer.js';
17
+ import { renderTrackSurface } from './rendering/trackRenderer.js';
18
+ import {
19
+ formatLapGap,
20
+ formatRaceGap,
21
+ getPenaltyByDriver,
22
+ getTimingOrderKey,
23
+ getTimingPenaltyKey,
24
+ isWavedFlagCar,
25
+ renderTimingTower,
26
+ syncTimingGapModeControls,
27
+ } from './readouts/timingTowerRenderer.js';
28
+ import { formatTelemetryGap } from './readouts/readoutFormatters.js';
29
+ import { getOverviewFields, renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
30
+ import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
31
+ import {
32
+ renderRaceFinish,
33
+ renderRaceStatusReadouts,
34
+ renderStartLights,
35
+ } from './readouts/raceStatusRenderer.js';
36
+ import {
37
+ updateCameraControlButtons,
38
+ updateModeButtons,
39
+ updateToggleButtons,
40
+ } from './readouts/controlStateRenderer.js';
41
+ import {
42
+ createPenaltyStewardMessage,
43
+ createWarningStewardMessage,
44
+ renderActiveStewardMessage,
45
+ updateStewardMessageState,
46
+ } from './banners/stewardMessageController.js';
47
+ import {
48
+ hideRaceDataPanel,
49
+ getProjectRadioQuote,
50
+ RACE_DATA_SELECTED_VISIBLE_MS,
51
+ RADIO_SCHEDULE_CATCHUP_LIMIT_MS,
52
+ renderProjectRadio,
53
+ renderRaceData,
54
+ resetRaceDataBannerState,
55
+ scheduleRadioBreak,
56
+ scheduleRadioPopup,
57
+ shouldAutoHideActiveRaceData,
58
+ getNextRadioBreakTime,
59
+ } from './banners/raceDataBannerController.js';
60
+ import { runFrameLoopTick } from './runtime/frameLoop.js';
61
+ import { observeRuntimeVisibility, syncRuntimeTicker } from './runtime/runtimeVisibility.js';
62
+ import { TARGET_FRAME_MS, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
63
+ import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
64
+
27
65
  const SIMULATION_SPEED_STEPS = [1, 2, 3, 4, 5, 10];
28
- const CAR_WORLD_LENGTH = VEHICLE_GEOMETRY.visualLength;
29
- const CAR_WORLD_WIDTH = VEHICLE_GEOMETRY.visualWidth;
30
- // Temporary debug view: set to false to restore normal car sprites.
31
- const TEMP_RENDER_RAW_CAR_GEOMETRY = false;
32
- const SAFETY_CAR_WORLD_LENGTH = metersToSimUnits(5.3);
33
- const SAFETY_CAR_WORLD_WIDTH = metersToSimUnits(2.1);
34
- const CAMERA_PRESETS = {
35
- overview: 1,
36
- leader: 18,
37
- selected: 24,
38
- 'show-all': 1,
39
- pit: 2.35,
40
- };
41
- const CAMERA_MIN_ZOOM = 0.55;
42
- const CAMERA_MAX_ZOOM = 120;
43
- const CAMERA_ZOOM_STEP = 1.4;
44
- const TRACK_CAMERA_PADDING = metersToSimUnits(120);
45
- const SERVICE_COUNTDOWN_LABEL_STYLES = {
46
- penalty: {
47
- background: 0x110b0b,
48
- stroke: 0xff2d55,
49
- fill: 0xffffff,
50
- },
51
- pit: {
52
- background: 0x171305,
53
- stroke: 0xffd166,
54
- fill: 0xfff2b0,
55
- },
56
- };
57
- const SHOW_ALL_PADDING = 520;
58
- const SHOW_ALL_TOP_RESERVED = 92;
59
- const SHOW_ALL_BOTTOM_RESERVED = 132;
60
- const PIT_CAMERA_PADDING = metersToSimUnits(34);
61
- const DRS_TRAIL_TTL = 0.34;
62
- const DRS_TRAIL_MIN_DISTANCE = metersToSimUnits(0.9);
63
- const SENSOR_RAY_TRACK_COLOR = 0xf1c65b;
64
- const SENSOR_RAY_TRACK_ENTRY_COLOR = 0x68d8ff;
65
- const SENSOR_RAY_CAR_COLOR = 0xff4d5f;
66
- const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
67
- const RADIO_BREAK_MIN_MS = 4800;
68
- const RADIO_BREAK_MAX_MS = 11800;
69
- const RADIO_VISIBLE_MIN_MS = 6200;
70
- const RADIO_VISIBLE_MAX_MS = 9200;
71
- const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
72
- const PENALTY_BANNER_VISIBLE_MS = 12000;
73
66
  const DRS_DRAG_REDUCTION_PERCENT = 58;
74
67
 
75
- const VEHICLE_OVERVIEW_FIELDS = [
76
- ['power', 'Power'],
77
- ['braking', 'Braking'],
78
- ['aero', 'Aero'],
79
- ['dragEfficiency', 'Drag'],
80
- ['mechanicalGrip', 'Grip'],
81
- ['weightControl', 'Weight'],
82
- ];
83
- const DRIVER_OVERVIEW_FIELDS = [
84
- ['pace', 'Pace'],
85
- ['racecraft', 'Racecraft'],
86
- ['aggression', 'Aggression'],
87
- ['riskTolerance', 'Risk'],
88
- ['patience', 'Patience'],
89
- ['consistency', 'Consistency'],
90
- ];
91
-
92
- const TINT_BY_COLOR = new Map();
93
-
94
68
  function createMountTrackSeed() {
95
69
  const values = new Uint32Array(1);
96
70
  try {
@@ -101,199 +75,25 @@ function createMountTrackSeed() {
101
75
  return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
102
76
  }
103
77
 
104
- function escapeHtml(value) {
105
- return String(value ?? '')
106
- .replaceAll('&', '&')
107
- .replaceAll('<', '&lt;')
108
- .replaceAll('>', '&gt;')
109
- .replaceAll('"', '&quot;')
110
- .replaceAll("'", '&#39;');
111
- }
112
-
113
- function getTireClass(tire) {
114
- return String(tire ?? 'M').toLowerCase();
115
- }
116
-
117
- function formatPenaltyType(type) {
118
- return String(type ?? 'penalty')
119
- .split('-')
120
- .filter(Boolean)
121
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
122
- .join(' ');
123
- }
124
-
125
- function formatPenaltyHeadline(penalty) {
126
- const serviceType = penalty?.serviceType;
127
- if (serviceType === 'driveThrough') {
128
- return penalty?.unserved ? 'unserved drive-through penalty' : 'drive-through penalty';
129
- }
130
- if (serviceType === 'stopGo') {
131
- return penalty?.unserved ? 'unserved stop-go penalty' : 'stop-go penalty';
132
- }
133
- const positionDrop = Number(penalty?.positionDrop);
134
- if (Number.isFinite(positionDrop) && positionDrop > 0) {
135
- return `${positionDrop}-place position drop`;
136
- }
137
- const gridDrop = Number(penalty?.gridDrop);
138
- if (Number.isFinite(gridDrop) && gridDrop > 0) {
139
- return `${gridDrop}-place grid drop`;
140
- }
141
- if (penalty?.disqualified) return 'disqualification';
142
- const seconds = Number(penalty?.penaltySeconds);
143
- if (!Number.isFinite(seconds) || seconds <= 0) return 'Penalty decision';
144
- const unit = seconds === 1 ? 'second' : 'seconds';
145
- return `+${seconds} ${unit} time penalty`;
146
- }
147
-
148
- function formatPenaltyChip(penalty) {
149
- const serviceType = penalty?.serviceType;
150
- if (serviceType === 'driveThrough') return 'DT';
151
- if (serviceType === 'stopGo') return 'SG';
152
- if (penalty?.disqualified) return 'DSQ';
153
- const positionDrop = Number(penalty?.positionDrop);
154
- if (Number.isFinite(positionDrop) && positionDrop > 0) return `-${positionDrop}P`;
155
- const gridDrop = Number(penalty?.gridDrop);
156
- if (Number.isFinite(gridDrop) && gridDrop > 0) return `-${gridDrop}G`;
157
- const seconds = Number(penalty?.penaltySeconds);
158
- return Number.isFinite(seconds) && seconds > 0 ? `+${seconds}s` : 'Penalty';
159
- }
160
-
161
- function formatPenaltyBadgeLabel(penalty) {
162
- const seconds = Number(penalty?.penaltySeconds);
163
- const timeLabel = Number.isFinite(seconds) && seconds > 0 ? `${seconds}s ` : '';
164
- if (Number.isFinite(seconds) && seconds > 0 && !penalty?.serviceType) {
165
- return `Penalty: ${timeLabel}${String(penalty?.type ?? 'decision')}`;
166
- }
167
- return `Penalty: ${timeLabel}${formatPenaltyHeadline(penalty)}`;
168
- }
169
-
170
- function hasVisiblePenaltyEffect(penalty) {
171
- if (!penalty || penalty.status === 'cancelled') return false;
172
- if (penalty.serviceRequired && penalty.status === 'served') return false;
173
- const penaltySeconds = Number(penalty.penaltySeconds);
174
- const pendingPenaltySeconds = Number(penalty.pendingPenaltySeconds);
175
- const positionDrop = Number(penalty.positionDrop);
176
- const gridDrop = Number(penalty.gridDrop);
177
- return penalty.serviceRequired ||
178
- (Number.isFinite(penaltySeconds) && penaltySeconds > 0) ||
179
- (Number.isFinite(pendingPenaltySeconds) && pendingPenaltySeconds > 0) ||
180
- (Number.isFinite(positionDrop) && positionDrop > 0) ||
181
- (Number.isFinite(gridDrop) && gridDrop > 0) ||
182
- Boolean(penalty.disqualified);
183
- }
184
-
185
- function formatStewardMessageKey(message) {
186
- if (!message) return '';
187
- return [
188
- message.kind,
189
- message.type,
190
- message.driverId,
191
- message.penaltyId,
192
- message.violationCount,
193
- message.time,
194
- ].join(':');
195
- }
196
-
197
- function formatTelemetryTime(value) {
198
- if (!Number.isFinite(value)) return '--';
199
- const clamped = Math.max(0, value);
200
- if (clamped >= 60) {
201
- const minutes = Math.floor(clamped / 60);
202
- const seconds = clamped - minutes * 60;
203
- return `${minutes}:${seconds.toFixed(3).padStart(6, '0')}`;
204
- }
205
- return `${clamped.toFixed(3)}s`;
206
- }
207
-
208
- function setPerformanceClass(node, status) {
209
- ['overall-best', 'personal-best', 'slower'].forEach((name) => {
210
- node?.classList?.toggle?.(`is-${name}`, status === name);
211
- });
212
- }
213
-
214
- function colorToTint(color) {
215
- if (TINT_BY_COLOR.has(color)) return TINT_BY_COLOR.get(color);
216
- const tint = Number.parseInt(String(color ?? '').replace('#', ''), 16);
217
- const normalizedTint = Number.isFinite(tint) ? tint : 0xffffff;
218
- TINT_BY_COLOR.set(color, normalizedTint);
219
- return normalizedTint;
220
- }
221
-
222
- function pruneExpiredTrailPoints(history, now) {
223
- let expiredCount = 0;
224
- while (expiredCount < history.length && now - history[expiredCount].at > DRS_TRAIL_TTL) {
225
- expiredCount += 1;
226
- }
227
- if (expiredCount > 0) history.splice(0, expiredCount);
228
- }
229
-
230
- function smoothAngle(current, target, amount) {
231
- if (!Number.isFinite(current)) return target;
232
- let diff = ((target - current + Math.PI) % (Math.PI * 2)) - Math.PI;
233
- if (diff < -Math.PI) diff += Math.PI * 2;
234
- return current + diff * amount;
235
- }
236
-
237
- function destroyDisplayChildren(container) {
238
- container?.removeChildren?.().forEach((child) => {
239
- child.destroy?.({ children: true, texture: false, textureSource: false });
240
- });
241
- }
242
-
243
- function clampCameraScale(scale, anchorScale, minimumScale = null) {
244
- const safeAnchor = Number.isFinite(anchorScale) && anchorScale > 0 ? anchorScale : 1;
245
- const safeMinimum = Number.isFinite(minimumScale) && minimumScale > 0
246
- ? minimumScale
247
- : safeAnchor * CAMERA_MIN_ZOOM;
248
- return clamp(scale, safeMinimum, safeAnchor * CAMERA_MAX_ZOOM);
249
- }
250
-
251
- function expertVisualizesRays(expertOptions) {
252
- const setting = expertOptions?.visualizeSensors;
253
- if (setting === true) return true;
254
- if (!setting || setting === false) return false;
255
- return Boolean(setting.rays);
256
- }
257
-
258
- function pointFromRay(origin, ray, distance) {
259
- return {
260
- x: origin.x + ray.x * distance,
261
- y: origin.y + ray.y * distance,
262
- };
263
- }
264
-
265
78
  function hasOwnOption(options, key) {
266
79
  return Object.hasOwn(options ?? {}, key);
267
80
  }
268
81
 
269
- function maxSimulationStepsForFrame(simulationSpeed, elapsedFrameCount) {
270
- const speed = Math.max(1, Number(simulationSpeed) || 1);
271
- const frameCount = Math.max(1, Number(elapsedFrameCount) || 1);
272
- return clamp(
273
- Math.ceil(speed * frameCount) + 1,
274
- BASE_MAX_SIMULATION_STEPS_PER_RENDER,
275
- HARD_MAX_SIMULATION_STEPS_PER_RENDER,
276
- );
277
- }
278
-
279
- function domUpdateIntervalForSpeed(simulationSpeed) {
280
- const speed = Math.max(1, Number(simulationSpeed) || 1);
281
- if (speed >= 10) return 250;
282
- if (speed >= 5) return 180;
283
- return DOM_UPDATE_INTERVAL_MS;
284
- }
285
-
286
- function timingUpdateIntervalForSpeed(simulationSpeed) {
287
- const speed = Math.max(1, Number(simulationSpeed) || 1);
288
- if (speed >= 10) return 600;
289
- if (speed >= 5) return 400;
290
- return TIMING_UPDATE_INTERVAL_MS;
291
- }
292
-
293
82
  function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
294
83
  return nextExpertOptions !== currentExpertOptions;
295
84
  }
296
85
 
86
+ function withSelectedExpertSensorDriver(visualizeSensors, selectedDriverId) {
87
+ if (!visualizeSensors) return visualizeSensors;
88
+ if (visualizeSensors === true) return { rays: true, selectedDriverId };
89
+ if (typeof visualizeSensors !== 'object') return visualizeSensors;
90
+ if (visualizeSensors.drivers === 'all' || Array.isArray(visualizeSensors.drivers)) return visualizeSensors;
91
+ return {
92
+ ...visualizeSensors,
93
+ selectedDriverId,
94
+ };
95
+ }
96
+
297
97
  function assetSetsEqual(first = {}, second = {}) {
298
98
  const firstTrackTextures = first.trackTextures ?? {};
299
99
  const secondTrackTextures = second.trackTextures ?? {};
@@ -317,7 +117,7 @@ export class F1SimulatorApp {
317
117
  this.abortController = new AbortController();
318
118
  this.resizeHandler = null;
319
119
  this.layoutResizeObserver = null;
320
- this.cameraSafeAreaCache = null;
120
+ this.cameraController = null;
321
121
  this.visibilityChangeHandler = null;
322
122
  this.visibilityObserver = null;
323
123
  this.tickerCallback = null;
@@ -331,12 +131,22 @@ export class F1SimulatorApp {
331
131
  this.trailLayer = null;
332
132
  this.sensorLayer = null;
333
133
  this.pitLaneStatusLayer = null;
134
+ this.replayGhostLayer = null;
334
135
  this.carLayer = null;
335
136
  this.textures = {};
336
137
  this.carSprites = new Map();
337
138
  this.carHitAreas = new Map();
338
139
  this.serviceCountdownLabels = new Map();
140
+ this.carRenderer = new CarRenderer({
141
+ carSprites: this.carSprites,
142
+ carHitAreas: this.carHitAreas,
143
+ serviceCountdownLabels: this.serviceCountdownLabels,
144
+ onSelectCar: (driverId) => this.selectCar(driverId, { focus: true }),
145
+ });
339
146
  this.drsTrails = new Map();
147
+ this.drsTrailRenderer = new DrsTrailRenderer({ trails: this.drsTrails });
148
+ this.replayGhostRenderer = new ReplayGhostRenderer();
149
+ this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
340
150
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
341
151
  this.selectedId = this.drivers[0]?.id ?? null;
342
152
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
@@ -357,16 +167,12 @@ export class F1SimulatorApp {
357
167
  quoteIndex: 0,
358
168
  };
359
169
  this.resetRaceDataBannerState(this.lastRaceDataInteraction);
360
- this.camera = {
361
- mode: options.initialCameraMode,
362
- zoom: CAMERA_PRESETS[options.initialCameraMode] ?? CAMERA_PRESETS.leader,
363
- scale: null,
364
- initialized: false,
365
- x: WORLD.width / 2,
366
- y: WORLD.height / 2,
367
- free: false,
368
- freeTarget: null,
369
- };
170
+ this.cameraController = new CameraController({
171
+ canvasHost: this.canvasHost,
172
+ readouts: this.readouts,
173
+ initialMode: options.initialCameraMode,
174
+ });
175
+ this.camera = this.cameraController.camera;
370
176
  this.overviewMode = 'vehicle';
371
177
  this.simulationSpeed = 1;
372
178
  this.accumulator = 0;
@@ -378,9 +184,6 @@ export class F1SimulatorApp {
378
184
  this.lastTimingPenaltyKey = '';
379
185
  this.lastTimingOrderKey = '';
380
186
  this.lastTimingMarkup = null;
381
- this.lastPitLaneStatusRenderKey = null;
382
- this.pitCameraBoundsCache = null;
383
- this.trackCameraBoundsCache = null;
384
187
  this.lastOverviewRenderKey = null;
385
188
  this.lastFinishClassificationMarkup = null;
386
189
  this.runtimeViewportVisible = true;
@@ -391,6 +194,7 @@ export class F1SimulatorApp {
391
194
  current: 0,
392
195
  lastSample: this.lastTime,
393
196
  };
197
+ this.renderSnapshotBuffer = {};
394
198
  this.lastLeaderLap = null;
395
199
  this.emittedRaceEventKeys = new Set();
396
200
  this.raceFinishEmitted = false;
@@ -423,6 +227,7 @@ export class F1SimulatorApp {
423
227
  this.trailLayer = new Graphics();
424
228
  this.sensorLayer = new Graphics();
425
229
  this.pitLaneStatusLayer = new Graphics();
230
+ this.replayGhostLayer = new Container();
426
231
  this.carLayer = new Container();
427
232
  this.app.stage.addChild(this.worldLayer);
428
233
 
@@ -434,6 +239,7 @@ export class F1SimulatorApp {
434
239
  this.trailLayer,
435
240
  this.pitLaneStatusLayer,
436
241
  this.sensorLayer,
242
+ this.replayGhostLayer,
437
243
  this.carLayer,
438
244
  );
439
245
  this.createCars();
@@ -452,7 +258,9 @@ export class F1SimulatorApp {
452
258
  this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
453
259
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
454
260
  this.observeLayoutResize();
455
- if (!this.expertMode) {
261
+ if (this.expertMode) {
262
+ this.app.ticker.stop?.();
263
+ } else {
456
264
  this.resetFrameClock();
457
265
  this.tickerCallback = () => this.tick();
458
266
  this.app.ticker.add(this.tickerCallback);
@@ -494,167 +302,41 @@ export class F1SimulatorApp {
494
302
  return createRaceSimulation({
495
303
  seed: options.seed,
496
304
  trackSeed: options.trackSeed ?? this.trackSeed,
305
+ trackGeneration: options.trackGeneration,
306
+ physicsMode: options.physicsMode,
497
307
  drivers: options.drivers ?? this.drivers,
498
308
  totalLaps: options.totalLaps,
499
309
  rules: options.rules,
310
+ participantInteractions: options.participantInteractions,
311
+ replayGhosts: options.replayGhosts,
312
+ trackQueryIndex: options.trackQueryIndex,
313
+ warmup: normalizeWarmupOptions(options.warmup, 'browser'),
500
314
  });
501
315
  }
502
316
 
503
317
  async loadAssets() {
504
- this.textures.car = Texture.WHITE;
505
- try {
506
- this.textures.car = await Assets.load(this.assets.car);
507
- this.textures.car.source.scaleMode = 'linear';
508
- this.textures.car.source.autoGenerateMipmaps = true;
509
- } catch {
510
- this.textures.car = Texture.WHITE;
511
- }
512
-
513
- try {
514
- this.textures.safetyCar = await Assets.load(this.assets.safetyCar);
515
- this.textures.safetyCar.source.scaleMode = 'linear';
516
- this.textures.safetyCar.source.autoGenerateMipmaps = true;
517
- } catch {
518
- this.textures.safetyCar = Texture.WHITE;
519
- }
520
-
521
- await Promise.all(Object.entries(this.assets.trackTextures).map(async ([key, url]) => {
522
- try {
523
- const texture = await Assets.load(url);
524
- texture.source.scaleMode = 'linear';
525
- this.textures[key] = texture;
526
- } catch {
527
- this.textures[key] = Texture.WHITE;
528
- }
529
- }));
318
+ this.textures = await loadAppTextures(this.assets);
530
319
  }
531
320
 
532
321
  createCars() {
533
- const texture = this.textures.car ?? Texture.WHITE;
534
- const baseScale = Math.min(
535
- CAR_WORLD_LENGTH / Math.max(texture.width, 1),
536
- CAR_WORLD_WIDTH / Math.max(texture.height, 1),
537
- );
538
-
539
- this.carSprites.forEach((sprite) => sprite.destroy());
540
- this.carHitAreas.forEach((hit) => hit.destroy());
541
- this.serviceCountdownLabels.forEach((label) => label.destroy({ children: true }));
542
- this.carSprites.clear();
543
- this.carHitAreas.clear();
544
- this.serviceCountdownLabels.clear();
545
322
  this.drsTrails.clear();
546
-
547
- if (this.safetySprite) {
548
- this.safetySprite.destroy();
549
- this.safetySprite = null;
550
- }
551
-
552
- this.drivers.forEach((driver) => {
553
- const sprite = TEMP_RENDER_RAW_CAR_GEOMETRY
554
- ? this.createRawCarGeometryGraphic(driver)
555
- : new Sprite(texture);
556
- if (!TEMP_RENDER_RAW_CAR_GEOMETRY) {
557
- sprite.anchor.set(0.5);
558
- sprite.baseScale = baseScale;
559
- sprite.scale.set(baseScale);
560
- } else {
561
- sprite.baseScale = 1;
562
- }
563
- sprite.tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(driver.color);
564
- sprite.eventMode = 'static';
565
- sprite.cursor = 'pointer';
566
- sprite.on('pointerdown', () => {
567
- this.selectCar(driver.id, { focus: true });
568
- });
569
- this.carSprites.set(driver.id, sprite);
570
- this.carLayer.addChild(sprite);
571
-
572
- const hit = new Graphics();
573
- hit.circle(0, 0, 24).fill({ color: 0xffffff, alpha: 0.001 });
574
- hit.eventMode = 'static';
575
- hit.cursor = 'pointer';
576
- hit.on('pointerdown', () => {
577
- this.selectCar(driver.id, { focus: true });
578
- });
579
- this.carHitAreas.set(driver.id, hit);
580
- this.carLayer.addChild(hit);
581
-
582
- const serviceLabel = this.createServiceCountdownLabel();
583
- serviceLabel.visible = false;
584
- this.serviceCountdownLabels.set(driver.id, serviceLabel);
585
- this.carLayer.addChild(serviceLabel);
323
+ this.carRenderer.createCars({
324
+ drivers: this.drivers,
325
+ textures: this.textures,
326
+ carLayer: this.carLayer,
586
327
  });
587
328
  }
588
329
 
589
330
  createRawCarGeometryGraphic(driver) {
590
- const graphic = new Graphics();
591
- const tint = colorToTint(driver.color);
592
- const wheelLong = VEHICLE_GEOMETRY.wheelLongitudinalOffset;
593
- const wheelLat = VEHICLE_GEOMETRY.wheelLateralOffset;
594
- const wheelLength = VEHICLE_GEOMETRY.wheelLength;
595
- const wheelWidth = VEHICLE_GEOMETRY.wheelWidth;
596
-
597
- graphic
598
- .rect(
599
- -VEHICLE_GEOMETRY.bodyLength / 2,
600
- -VEHICLE_GEOMETRY.bodyWidth / 2,
601
- VEHICLE_GEOMETRY.bodyLength,
602
- VEHICLE_GEOMETRY.bodyWidth,
603
- )
604
- .fill({ color: tint, alpha: 0.42 })
605
- .stroke({ width: 2.2, color: 0xf8fafc, alpha: 0.95 });
606
-
607
- [
608
- [wheelLong, -wheelLat],
609
- [wheelLong, wheelLat],
610
- [-wheelLong, -wheelLat],
611
- [-wheelLong, wheelLat],
612
- ].forEach(([x, y]) => {
613
- graphic
614
- .rect(x - wheelLength / 2, y - wheelWidth / 2, wheelLength, wheelWidth)
615
- .fill({ color: 0x111827, alpha: 0.95 })
616
- .stroke({ width: 1.5, color: tint, alpha: 1 });
617
- });
618
-
619
- const noseMarkerLength = metersToSimUnits(0.75);
620
- graphic
621
- .moveTo(VEHICLE_GEOMETRY.bodyLength / 2 - noseMarkerLength, 0)
622
- .lineTo(VEHICLE_GEOMETRY.bodyLength / 2 + noseMarkerLength, 0)
623
- .stroke({ width: 2, color: 0xf1c65b, alpha: 0.95 });
624
- return graphic;
331
+ return this.carRenderer.createRawCarGeometryGraphic(driver);
625
332
  }
626
333
 
627
334
  createServiceCountdownLabel() {
628
- const container = new Container();
629
- const background = new Graphics();
630
- const text = new Text({
631
- text: '0s',
632
- style: {
633
- fill: 0xffffff,
634
- fontFamily: 'Arial, sans-serif',
635
- fontSize: 16,
636
- fontWeight: '900',
637
- align: 'center',
638
- },
639
- });
640
- text.anchor.set(0.5);
641
- text.resolution = 2;
642
- container.addChild(background, text);
643
- container.labelBackground = background;
644
- container.labelText = text;
645
- this.applyServiceCountdownTone(container, 'pit');
646
- return container;
335
+ return this.carRenderer.createServiceCountdownLabel();
647
336
  }
648
337
 
649
338
  applyServiceCountdownTone(label, tone) {
650
- const style = SERVICE_COUNTDOWN_LABEL_STYLES[tone] ?? SERVICE_COUNTDOWN_LABEL_STYLES.pit;
651
- if (label.serviceTone === tone) return;
652
- label.labelBackground?.clear?.();
653
- label.labelBackground?.roundRect(-28, -14, 56, 28, 4)
654
- .fill({ color: style.background, alpha: 0.92 })
655
- .stroke({ width: 2, color: style.stroke, alpha: 0.95 });
656
- if (label.labelText?.style) label.labelText.style.fill = style.fill;
657
- label.serviceTone = tone;
339
+ this.carRenderer.applyServiceCountdownTone(label, tone);
658
340
  }
659
341
 
660
342
  bindControls() {
@@ -752,18 +434,13 @@ export class F1SimulatorApp {
752
434
 
753
435
  setCameraMode(mode) {
754
436
  const snapshot = this.sim?.snapshot?.();
755
- if (!this.isCameraModeAvailable(mode, snapshot)) return false;
756
- this.camera.mode = mode;
757
- this.camera.free = false;
758
- this.camera.freeTarget = null;
759
- if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
437
+ if (!this.cameraController.setMode(mode, snapshot)) return false;
760
438
  this.updateCameraControls(snapshot);
761
439
  return true;
762
440
  }
763
441
 
764
442
  adjustCameraZoom(direction) {
765
- const steps = Number.isFinite(Number(direction)) ? Number(direction) : 0;
766
- this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
443
+ this.cameraController.adjustZoom(direction);
767
444
  this.updateCameraControls();
768
445
  return this.camera.zoom;
769
446
  }
@@ -834,7 +511,7 @@ export class F1SimulatorApp {
834
511
  }
835
512
 
836
513
  renderInitialFrame(snapshot) {
837
- const renderSnapshot = createRenderSnapshot(snapshot, 0);
514
+ const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
838
515
  this.resizeRendererToCanvasHost();
839
516
  this.applyCamera(renderSnapshot, { immediate: true });
840
517
  this.renderDrsTrails(renderSnapshot);
@@ -847,7 +524,11 @@ export class F1SimulatorApp {
847
524
  this.resizeRendererToCanvasHost();
848
525
  const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
849
526
  if (!snapshot) return;
850
- const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
527
+ const renderSnapshot = interpolateRenderSnapshotInto(
528
+ this.renderSnapshotBuffer,
529
+ snapshot,
530
+ clamp(this.accumulator / FIXED_STEP, 0, 1),
531
+ );
851
532
  this.applyCamera(renderSnapshot);
852
533
  if (render) {
853
534
  this.renderDrsTrails(renderSnapshot);
@@ -881,53 +562,15 @@ export class F1SimulatorApp {
881
562
  }
882
563
 
883
564
  invalidateCameraSafeArea() {
884
- this.cameraSafeAreaCache = null;
565
+ this.cameraController.invalidateSafeArea();
885
566
  }
886
567
 
887
568
  observeRuntimeVisibility() {
888
- if (!this.canvasHost) return;
889
-
890
- if (typeof IntersectionObserver === 'function') {
891
- this.visibilityObserver?.disconnect?.();
892
- this.visibilityObserver = new IntersectionObserver((entries) => {
893
- const entry = entries.find((item) => item.target === this.canvasHost) ?? entries[0];
894
- this.runtimeViewportVisible = Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0);
895
- this.syncRuntimeTicker();
896
- }, {
897
- root: null,
898
- rootMargin: '240px 0px',
899
- threshold: 0,
900
- });
901
- this.visibilityObserver.observe(this.canvasHost);
902
- }
903
-
904
- if (typeof document !== 'undefined') {
905
- this.visibilityChangeHandler = () => {
906
- this.runtimeDocumentVisible = document.visibilityState !== 'hidden';
907
- this.syncRuntimeTicker();
908
- };
909
- document.addEventListener('visibilitychange', this.visibilityChangeHandler, {
910
- signal: this.abortController.signal,
911
- });
912
- }
913
-
914
- this.syncRuntimeTicker();
569
+ observeRuntimeVisibility(this);
915
570
  }
916
571
 
917
572
  syncRuntimeTicker() {
918
- if (this.expertMode) return;
919
- if (!this.app?.ticker) return;
920
-
921
- const shouldRun = this.runtimeViewportVisible && this.runtimeDocumentVisible;
922
- if (shouldRun === this.runtimeTickerRunning) return;
923
-
924
- this.runtimeTickerRunning = shouldRun;
925
- if (shouldRun) {
926
- this.resetFrameClock();
927
- this.app.ticker.start?.();
928
- } else {
929
- this.app.ticker.stop?.();
930
- }
573
+ syncRuntimeTicker(this);
931
574
  }
932
575
 
933
576
  resetFrameClock(now = performance.now()) {
@@ -940,612 +583,107 @@ export class F1SimulatorApp {
940
583
  }
941
584
 
942
585
  tick() {
943
- if (this.expertMode) return;
944
- const now = performance.now();
945
- if (now < this.nextGameFrameTime - FRAME_PACING_EPSILON_MS) return;
946
-
947
- const elapsedFrameCount = clamp(
948
- Math.floor((now - this.nextGameFrameTime + FRAME_PACING_EPSILON_MS) / TARGET_FRAME_MS) + 1,
949
- 1,
950
- MAX_FRAME_CATCHUP_COUNT,
951
- );
952
- const frameSeconds = (TARGET_FRAME_MS * elapsedFrameCount) / 1000;
953
- this.nextGameFrameTime += TARGET_FRAME_MS * elapsedFrameCount;
954
- if (now - this.nextGameFrameTime > TARGET_FRAME_MS * MAX_FRAME_CATCHUP_COUNT) {
955
- this.nextGameFrameTime = now + TARGET_FRAME_MS;
956
- }
957
- this.lastTime = now;
958
- this.sampleFps(now);
959
- this.accumulator += frameSeconds * this.simulationSpeed;
960
-
961
- let simulationSteps = 0;
962
- const stepEvents = [];
963
- const maxSimulationSteps = maxSimulationStepsForFrame(this.simulationSpeed, elapsedFrameCount);
964
- while (this.accumulator >= FIXED_STEP && simulationSteps < maxSimulationSteps) {
965
- this.sim.step(FIXED_STEP);
966
- if (Array.isArray(this.sim.events) && this.sim.events.length > 0) {
967
- stepEvents.push(...this.sim.events);
968
- }
969
- this.accumulator -= FIXED_STEP;
970
- simulationSteps += 1;
971
- }
972
-
973
- if (this.accumulator >= FIXED_STEP) {
974
- this.accumulator %= FIXED_STEP;
975
- this.nextGameFrameTime = now + TARGET_FRAME_MS;
976
- }
977
-
978
- const shouldUpdateDom = now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed);
979
- const fullSnapshot = stepEvents.length > 0 || shouldUpdateDom ? this.sim.snapshot() : null;
980
- if (stepEvents.length > 0) this.emitRaceEvents(stepEvents, fullSnapshot);
981
- const renderSource = fullSnapshot ?? this.sim.snapshotRender?.() ?? this.sim.snapshot();
982
- const renderSnapshot = createRenderSnapshot(renderSource, clamp(this.accumulator / FIXED_STEP, 0, 1));
983
- this.applyCamera(renderSnapshot);
984
- this.renderDrsTrails(renderSnapshot);
985
- this.renderPitLaneStatus(renderSnapshot);
986
- this.renderCars(renderSnapshot);
987
- if (shouldUpdateDom) {
988
- this.updateDom(fullSnapshot, { emitLifecycle: false });
989
- this.lastDomUpdateTime = now;
990
- }
586
+ runFrameLoopTick(this);
991
587
  }
992
588
 
993
- renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
589
+ renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
994
590
  if (!snapshot) return;
995
- const renderSnapshot = createRenderSnapshot(snapshot, 0);
591
+ const now = performance.now();
592
+ const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
996
593
  this.applyCamera(renderSnapshot);
997
594
  this.renderDrsTrails(renderSnapshot);
998
595
  this.renderExpertSensorRays(renderSnapshot, observation);
999
596
  this.renderPitLaneStatus(renderSnapshot);
1000
597
  this.renderCars(renderSnapshot);
1001
- this.updateDom(snapshot, { emitLifecycle: false });
1002
- this.lastDomUpdateTime = performance.now();
598
+ this.app?.render?.();
599
+ if (forceDomUpdate || now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed)) {
600
+ this.updateDom(snapshot, { emitLifecycle: false });
601
+ this.lastDomUpdateTime = now;
602
+ }
1003
603
  }
1004
604
 
1005
605
  renderTrack() {
1006
- destroyDisplayChildren(this.drsLayer);
1007
- this.sensorLayer?.clear();
1008
- this.pitLaneStatusLayer?.clear();
1009
- this.lastPitLaneStatusRenderKey = null;
1010
- this.pitCameraBoundsCache = null;
1011
- this.trackCameraBoundsCache = null;
606
+ this.pitLaneStatusRenderer.reset();
607
+ this.cameraController.invalidateTrackCaches();
1012
608
  const snapshot = this.sim.snapshot();
1013
- const track = snapshot.track;
1014
-
1015
- this.trackAsset.render(track);
1016
-
1017
- track.drsZones.forEach((zone) => {
1018
- const zoneLine = new Graphics();
1019
- const steps = 44;
1020
- for (let index = 0; index <= steps; index += 1) {
1021
- const basePoint = pointAt(track, zone.start + ((zone.end - zone.start) * index) / steps);
1022
- const point = offsetTrackPoint(basePoint, track.width / 2 - 22);
1023
- if (index === 0) zoneLine.moveTo(point.x, point.y);
1024
- else zoneLine.lineTo(point.x, point.y);
1025
- }
1026
- zoneLine.stroke({ width: 8, color: 0x14c784, alpha: 0.5, join: 'round', cap: 'round' });
1027
- this.drsLayer.addChild(zoneLine);
609
+ renderTrackSurface({
610
+ drsLayer: this.drsLayer,
611
+ sensorLayer: this.sensorLayer,
612
+ pitLaneStatusLayer: this.pitLaneStatusLayer,
613
+ trackAsset: this.trackAsset,
614
+ snapshot,
1028
615
  });
1029
616
  }
1030
617
 
1031
618
  renderPitLaneStatus(snapshot) {
1032
- if (!this.pitLaneStatusLayer) return;
1033
- const pitLane = snapshot.track?.pitLane;
1034
- if (!pitLane?.enabled || !pitLane.mainLane?.start) {
1035
- if (this.lastPitLaneStatusRenderKey !== 'none') {
1036
- this.pitLaneStatusLayer.clear();
1037
- this.lastPitLaneStatusRenderKey = 'none';
1038
- }
1039
- return;
1040
- }
1041
- const status = snapshot.pitLaneStatus ?? snapshot.raceControl?.pitLaneStatus;
1042
- const color = colorToTint(status?.light ?? (status?.open ? '#22c55e' : '#ef4444'));
1043
- const heading = pitLane.mainLane.heading ?? 0;
1044
- const normal = pitLane.serviceNormal ?? { x: -Math.sin(heading), y: Math.cos(heading) };
1045
- const sign = pitLane.workingLane?.offset != null && pitLane.workingLane.offset < 0 ? 1 : -1;
1046
- const base = pitLane.mainLane.start;
1047
- const x = base.x + normal.x * sign * (pitLane.width * 0.72);
1048
- const y = base.y + normal.y * sign * (pitLane.width * 0.72);
1049
- const renderKey = [
1050
- color,
1051
- x.toFixed(3),
1052
- y.toFixed(3),
1053
- pitLane.width,
1054
- status?.open ? 1 : 0,
1055
- status?.reason ?? '',
1056
- ].join(':');
1057
- if (renderKey === this.lastPitLaneStatusRenderKey) return;
1058
- this.lastPitLaneStatusRenderKey = renderKey;
1059
-
1060
- this.pitLaneStatusLayer.clear();
1061
- this.pitLaneStatusLayer
1062
- .circle(x, y, 17)
1063
- .fill({ color: 0x0b0f14, alpha: 0.92 })
1064
- .circle(x, y, 11)
1065
- .fill({ color, alpha: 0.94 })
1066
- .circle(x, y, 18)
1067
- .stroke({ width: 3, color: 0xf8fafc, alpha: 0.82 });
619
+ this.pitLaneStatusRenderer.render(snapshot, this.pitLaneStatusLayer);
1068
620
  }
1069
621
 
1070
622
  renderCars(snapshot) {
1071
- snapshot.cars.forEach((car) => {
1072
- const sprite = this.carSprites.get(car.id);
1073
- const hit = this.carHitAreas.get(car.id);
1074
- if (!sprite || !hit) return;
1075
- if (sprite.x !== car.x) sprite.x = car.x;
1076
- if (sprite.y !== car.y) sprite.y = car.y;
1077
- if (TEMP_RENDER_RAW_CAR_GEOMETRY) {
1078
- sprite.currentRotation = car.heading;
1079
- if (sprite.rotation !== car.heading) sprite.rotation = car.heading;
1080
- } else {
1081
- sprite.currentRotation = smoothAngle(sprite.currentRotation, car.heading, 0.24);
1082
- if (sprite.rotation !== sprite.currentRotation) sprite.rotation = sprite.currentRotation;
1083
- }
1084
- const alpha = snapshot.raceControl.mode === 'safety-car' ? 0.82 : 1;
1085
- if (sprite.alpha !== alpha) sprite.alpha = alpha;
1086
- if (sprite.lastRenderedScale !== sprite.baseScale) {
1087
- sprite.scale.set(sprite.baseScale);
1088
- sprite.lastRenderedScale = sprite.baseScale;
1089
- }
1090
- const tint = TEMP_RENDER_RAW_CAR_GEOMETRY ? 0xffffff : colorToTint(car.color);
1091
- if (sprite.tint !== tint) sprite.tint = tint;
1092
- if (hit.x !== car.x) hit.x = car.x;
1093
- if (hit.y !== car.y) hit.y = car.y;
1094
- this.renderServiceCountdownLabel(car);
623
+ this.replayGhostRenderer.render(snapshot, {
624
+ textures: this.textures,
625
+ replayGhostLayer: this.replayGhostLayer,
626
+ });
627
+ this.carRenderer.renderCars(snapshot, {
628
+ textures: this.textures,
629
+ carLayer: this.carLayer,
1095
630
  });
1096
-
1097
- if (!this.safetySprite && snapshot.safetyCar.deployed) {
1098
- const texture = this.textures.safetyCar ?? Texture.WHITE;
1099
- this.safetySprite = new Sprite(texture);
1100
- this.safetySprite.anchor.set(0.5);
1101
- this.safetySprite.baseScale = Math.min(
1102
- SAFETY_CAR_WORLD_LENGTH / Math.max(texture.width, 1),
1103
- SAFETY_CAR_WORLD_WIDTH / Math.max(texture.height, 1),
1104
- );
1105
- this.safetySprite.scale.set(this.safetySprite.baseScale);
1106
- this.carLayer.addChild(this.safetySprite);
1107
- }
1108
-
1109
- if (this.safetySprite) {
1110
- if (this.safetySprite.visible !== snapshot.safetyCar.deployed) {
1111
- this.safetySprite.visible = snapshot.safetyCar.deployed;
1112
- }
1113
- if (this.safetySprite.x !== snapshot.safetyCar.x) this.safetySprite.x = snapshot.safetyCar.x;
1114
- if (this.safetySprite.y !== snapshot.safetyCar.y) this.safetySprite.y = snapshot.safetyCar.y;
1115
- if (this.safetySprite.rotation !== snapshot.safetyCar.heading) this.safetySprite.rotation = snapshot.safetyCar.heading;
1116
- }
1117
631
  }
1118
632
 
1119
633
  renderServiceCountdownLabel(car) {
1120
- const label = this.serviceCountdownLabels.get(car.id);
1121
- if (!label) return;
1122
- const penaltyRemaining = Number(car.pitStop?.penaltyServiceRemainingSeconds);
1123
- const serviceRemaining = Number(car.pitStop?.serviceRemainingSeconds);
1124
- const isPenalty = car.pitStop?.phase === 'penalty' && Number.isFinite(penaltyRemaining) && penaltyRemaining > 0;
1125
- const isPitService = car.pitStop?.phase === 'service' && Number.isFinite(serviceRemaining) && serviceRemaining > 0;
1126
- const visible = isPenalty || isPitService;
1127
- label.visible = visible;
1128
- if (!visible) return;
1129
- const tone = isPenalty ? 'penalty' : 'pit';
1130
- const remaining = isPenalty ? penaltyRemaining : serviceRemaining;
1131
- if (label.x !== car.x) label.x = car.x;
1132
- const labelY = car.y - 48;
1133
- if (label.y !== labelY) label.y = labelY;
1134
- if (label.rotation !== 0) label.rotation = 0;
1135
- this.applyServiceCountdownTone(label, tone);
1136
- if (label.labelText) {
1137
- const nextText = `${tone === 'penalty' ? '+' : ''}${Math.ceil(remaining)}s`;
1138
- if (label.labelText.text !== nextText) label.labelText.text = nextText;
1139
- }
634
+ this.carRenderer.renderServiceCountdownLabel(car);
1140
635
  }
1141
636
 
1142
637
  renderExpertSensorRays(snapshot, observation) {
1143
- if (!this.sensorLayer) return;
1144
- this.sensorLayer.clear();
1145
- if (!this.expertMode || !expertVisualizesRays(this.options.expert)) return;
1146
-
1147
- const controlledDrivers = this.options.expert?.controlledDrivers ?? [];
1148
- if (!controlledDrivers.length || !observation) return;
1149
-
1150
- const carsById = new Map(snapshot.cars.map((car) => [car.id, car]));
1151
- controlledDrivers.forEach((driverId) => {
1152
- const car = carsById.get(driverId);
1153
- const rays = observation?.[driverId]?.object?.rays;
1154
- if (!car || !Array.isArray(rays) || rays.length === 0) return;
1155
-
1156
- const origin = getCarRayOrigin(car);
1157
-
1158
- rays.forEach((ray) => {
1159
- const rayVector = getCarRayVector(car, Number(ray.angleDegrees) || 0);
1160
- const totalDistanceMeters = Math.max(0, Number(ray.lengthMeters) || 0);
1161
- const trackDistanceMeters = Math.max(0, Number(ray.track?.distanceMeters) || totalDistanceMeters);
1162
- const carDistanceMeters = Math.max(0, Number(ray.car?.distanceMeters) || totalDistanceMeters);
1163
- const trackHit = Boolean(ray.track?.hit) && trackDistanceMeters <= totalDistanceMeters;
1164
- const carHit = Boolean(ray.car?.hit) && carDistanceMeters <= totalDistanceMeters;
1165
- const fullEnd = pointFromRay(origin, rayVector, metersToSimUnits(totalDistanceMeters));
1166
-
1167
- this.sensorLayer
1168
- .moveTo(origin.x, origin.y)
1169
- .lineTo(fullEnd.x, fullEnd.y)
1170
- .stroke({ width: 2, color: 0xffffff, alpha: 0.18, cap: 'round' });
1171
-
1172
- if (!trackHit && !carHit) return;
1173
-
1174
- const carIsClosest = carHit && (!trackHit || carDistanceMeters <= trackDistanceMeters);
1175
- const hitDistanceMeters = carIsClosest ? carDistanceMeters : trackDistanceMeters;
1176
- const hitEnd = pointFromRay(origin, rayVector, metersToSimUnits(hitDistanceMeters));
1177
- const hitColor = carIsClosest
1178
- ? SENSOR_RAY_CAR_COLOR
1179
- : ray.track?.kind === 'entry'
1180
- ? SENSOR_RAY_TRACK_ENTRY_COLOR
1181
- : SENSOR_RAY_TRACK_COLOR;
1182
-
1183
- this.sensorLayer
1184
- .moveTo(origin.x, origin.y)
1185
- .lineTo(hitEnd.x, hitEnd.y)
1186
- .stroke({ width: carIsClosest ? 5 : 3.4, color: hitColor, alpha: carIsClosest ? 0.9 : 0.76, cap: 'round' });
1187
- this.sensorLayer
1188
- .circle(hitEnd.x, hitEnd.y, carIsClosest ? 7 : 5)
1189
- .fill({ color: hitColor, alpha: 0.92 })
1190
- .circle(hitEnd.x, hitEnd.y, carIsClosest ? 10 : 8)
1191
- .stroke({ width: 1.5, color: 0x10131a, alpha: 0.72 });
1192
- });
638
+ renderExpertSensorRays({
639
+ snapshot,
640
+ observation,
641
+ sensorLayer: this.sensorLayer,
642
+ expertMode: this.expertMode,
643
+ expertOptions: {
644
+ ...this.options.expert,
645
+ visualizeSensors: withSelectedExpertSensorDriver(this.options.expert?.visualizeSensors, this.selectedId),
646
+ },
1193
647
  });
1194
648
  }
1195
649
 
1196
650
  renderDrsTrails(snapshot) {
1197
- if (!this.trailLayer) return;
1198
-
1199
- snapshot.cars.forEach((car) => {
1200
- const history = this.drsTrails.get(car.id);
1201
- if (!history && !car.drsActive) return;
1202
- const activeHistory = history ?? [];
1203
- const rear = {
1204
- x: car.x - Math.cos(car.heading) * CAR_WORLD_LENGTH * 0.46,
1205
- y: car.y - Math.sin(car.heading) * CAR_WORLD_LENGTH * 0.46,
1206
- at: snapshot.time,
1207
- };
1208
- const last = activeHistory[activeHistory.length - 1];
1209
-
1210
- if (
1211
- car.drsActive &&
1212
- (!last || Math.hypot(rear.x - last.x, rear.y - last.y) >= DRS_TRAIL_MIN_DISTANCE)
1213
- ) {
1214
- activeHistory.push(rear);
1215
- }
1216
-
1217
- pruneExpiredTrailPoints(activeHistory, snapshot.time);
1218
- if (activeHistory.length) this.drsTrails.set(car.id, activeHistory);
1219
- else this.drsTrails.delete(car.id);
1220
- });
1221
-
1222
- this.trailLayer.clear();
1223
- this.drsTrails.forEach((history) => {
1224
- if (history.length < 2) return;
1225
- const newest = history[history.length - 1];
1226
- const newestLife = clamp(1 - (snapshot.time - newest.at) / DRS_TRAIL_TTL, 0, 1);
1227
- this.trailLayer.moveTo(history[0].x, history[0].y);
1228
- for (let index = 1; index < history.length; index += 1) {
1229
- this.trailLayer.lineTo(history[index].x, history[index].y);
1230
- }
1231
- this.trailLayer.stroke({
1232
- width: 10,
1233
- color: 0x3be8ff,
1234
- alpha: 0.28 + newestLife * 0.34,
1235
- cap: 'round',
1236
- join: 'round',
1237
- });
1238
- this.trailLayer.moveTo(history[0].x, history[0].y);
1239
- for (let index = 1; index < history.length; index += 1) {
1240
- this.trailLayer.lineTo(history[index].x, history[index].y);
1241
- }
1242
- this.trailLayer.stroke({
1243
- width: 3.2,
1244
- color: 0xd8fbff,
1245
- alpha: 0.18 + newestLife * 0.28,
1246
- cap: 'round',
1247
- join: 'round',
1248
- });
1249
- });
651
+ this.drsTrailRenderer.render(snapshot, this.trailLayer);
1250
652
  }
1251
653
 
1252
654
  applyCamera(snapshot, { immediate = false } = {}) {
1253
- if (!this.worldLayer) return;
1254
-
1255
- const width = this.canvasHost.clientWidth || 900;
1256
- const height = this.canvasHost.clientHeight || 640;
1257
- const safeArea = this.getCameraSafeArea(width);
1258
- const baseScale = Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
1259
- const frame = this.getCameraFrame(snapshot, width, height, baseScale, safeArea);
1260
- const scale = frame.scale;
1261
- const target = frame.target;
1262
- const snapCamera = immediate || !this.camera.initialized;
1263
- this.camera.x = snapCamera
1264
- ? target.x
1265
- : this.camera.x + (target.x - this.camera.x) * CAMERA_TARGET_LERP;
1266
- this.camera.y = snapCamera
1267
- ? target.y
1268
- : this.camera.y + (target.y - this.camera.y) * CAMERA_TARGET_LERP;
1269
- const activeScale = snapCamera || this.camera.scale === null
1270
- ? scale
1271
- : this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
1272
- this.camera.initialized = true;
1273
- this.camera.scale = activeScale;
1274
- this.worldLayer.scale.set(activeScale);
1275
- this.worldLayer.position.set(
1276
- frame.screenX - this.camera.x * activeScale,
1277
- frame.screenY - this.camera.y * activeScale,
1278
- );
655
+ this.cameraController.applyToWorldLayer(this.worldLayer, snapshot, {
656
+ immediate,
657
+ selectedId: this.selectedId,
658
+ });
1279
659
  }
1280
660
 
1281
661
  getCameraSafeArea(width) {
1282
- if (this.cameraSafeAreaCache?.width === width) {
1283
- return this.cameraSafeAreaCache.safeArea;
1284
- }
1285
-
1286
- const canvasRect = this.canvasHost?.getBoundingClientRect?.();
1287
- const towerRect = this.readouts.timingTower?.getBoundingClientRect?.();
1288
- if (!canvasRect || !towerRect) {
1289
- const safeArea = { left: 0, width };
1290
- this.cameraSafeAreaCache = { width, safeArea };
1291
- return safeArea;
1292
- }
1293
- const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
1294
- const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
1295
- Number.isFinite(canvasRect.bottom) &&
1296
- Number.isFinite(towerRect.top) &&
1297
- Number.isFinite(towerRect.bottom);
1298
- const overlapsCanvasVertically = !hasVerticalBounds ||
1299
- (towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
1300
- const canvasWidth = Math.max(1, canvasRect.right - canvasRect.left || width);
1301
- const towerWidth = Math.max(0, towerRect.right - towerRect.left);
1302
- const isSideGutter = towerWidth < canvasWidth * 0.6;
1303
- if (!overlapsCanvasHorizontally || !overlapsCanvasVertically || !isSideGutter) {
1304
- const safeArea = { left: 0, width };
1305
- this.cameraSafeAreaCache = { width, safeArea };
1306
- return safeArea;
1307
- }
1308
-
1309
- const overlayGap = 16;
1310
- const reservedLeft = clamp(towerRect.right - canvasRect.left + overlayGap, 0, width * 0.48);
1311
- const safeArea = {
1312
- left: reservedLeft,
1313
- width: Math.max(1, width - reservedLeft),
1314
- };
1315
- this.cameraSafeAreaCache = { width, safeArea };
1316
- return safeArea;
662
+ return this.cameraController.getSafeArea(width);
1317
663
  }
1318
664
 
1319
665
  getCameraFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }) {
1320
- const screenCenterX = safeArea.left + safeArea.width / 2;
1321
- const trackBounds = this.getTrackCameraBounds(snapshot.track);
1322
- const trackFitScale = this.getCameraBoundsFitScale(trackBounds, height, safeArea);
1323
-
1324
- if (this.camera.mode === 'overview') {
1325
- const target = trackBounds
1326
- ? {
1327
- x: (trackBounds.minX + trackBounds.maxX) / 2,
1328
- y: (trackBounds.minY + trackBounds.maxY) / 2,
1329
- }
1330
- : { x: WORLD.width / 2, y: WORLD.height / 2 };
1331
-
1332
- const scale = trackFitScale
1333
- ? clampCameraScale(trackFitScale * this.camera.zoom, trackFitScale, trackFitScale)
1334
- : clampCameraScale(baseScale * this.camera.zoom, baseScale);
1335
-
1336
- return this.applyFreeCameraFrame({
1337
- target,
1338
- scale,
1339
- screenX: screenCenterX,
1340
- screenY: height / 2,
1341
- });
1342
- }
1343
-
1344
- if (this.camera.mode === 'show-all') {
1345
- const bounds = snapshot.cars.reduce((box, car) => ({
1346
- minX: Math.min(box.minX, car.x),
1347
- minY: Math.min(box.minY, car.y),
1348
- maxX: Math.max(box.maxX, car.x),
1349
- maxY: Math.max(box.maxY, car.y),
1350
- }), {
1351
- minX: Infinity,
1352
- minY: Infinity,
1353
- maxX: -Infinity,
1354
- maxY: -Infinity,
1355
- });
1356
- const target = {
1357
- x: (bounds.minX + bounds.maxX) / 2,
1358
- y: (bounds.minY + bounds.maxY) / 2,
1359
- };
1360
- const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
1361
- const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
1362
- const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
1363
- const fitScale = Math.min(safeArea.width / fitWidth, safeHeight / fitHeight);
1364
- const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, trackFitScale);
1365
- return this.applyFreeCameraFrame({
1366
- target,
1367
- scale,
1368
- screenX: screenCenterX,
1369
- screenY: height / 2,
1370
- });
1371
- }
1372
-
1373
- if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
1374
- return this.applyFreeCameraFrame(
1375
- this.getPitCameraFrame(snapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
1376
- );
1377
- }
1378
-
1379
- return this.applyFreeCameraFrame({
1380
- target: this.getCameraTarget(snapshot),
1381
- scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
1382
- screenX: screenCenterX,
1383
- screenY: height / 2,
1384
- });
1385
- }
1386
-
1387
- applyFreeCameraFrame(frame) {
1388
- if (!this.camera.free || !this.camera.freeTarget) return frame;
1389
- return {
1390
- ...frame,
1391
- target: this.camera.freeTarget,
1392
- };
666
+ return this.cameraController.getFrame(snapshot, width, height, baseScale, safeArea, this.selectedId);
1393
667
  }
1394
668
 
1395
669
  getCameraTarget(snapshot) {
1396
- if (this.camera.free && this.camera.freeTarget) return this.camera.freeTarget;
1397
- if (this.camera.mode === 'overview') return this.getTrackCameraTarget(snapshot.track);
1398
-
1399
- if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
1400
- return this.getPitCameraTarget(snapshot.track.pitLane);
1401
- }
1402
-
1403
- if (this.camera.mode === 'selected') {
1404
- const selected = snapshot.cars.find((car) => car.id === this.selectedId);
1405
- if (selected) return selected;
1406
- }
1407
-
1408
- const leader = snapshot.cars[0];
1409
- return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
670
+ return this.cameraController.getTarget(snapshot, this.selectedId);
1410
671
  }
1411
672
 
1412
673
  getTrackCameraBounds(track) {
1413
- if (this.trackCameraBoundsCache && this.trackCameraBoundsCache.track === track) {
1414
- return this.trackCameraBoundsCache.bounds;
1415
- }
1416
-
1417
- const bounds = {
1418
- minX: Infinity,
1419
- minY: Infinity,
1420
- maxX: -Infinity,
1421
- maxY: -Infinity,
1422
- };
1423
- const includePoint = (point) => {
1424
- if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
1425
- bounds.minX = Math.min(bounds.minX, point.x);
1426
- bounds.minY = Math.min(bounds.minY, point.y);
1427
- bounds.maxX = Math.max(bounds.maxX, point.x);
1428
- bounds.maxY = Math.max(bounds.maxY, point.y);
1429
- };
1430
- const includeBounds = (box) => {
1431
- if (!box) return;
1432
- includePoint({ x: box.minX, y: box.minY });
1433
- includePoint({ x: box.maxX, y: box.maxY });
1434
- };
1435
-
1436
- (track?.samples ?? []).forEach(includePoint);
1437
- includeBounds(track?.pitLane?.bounds);
1438
-
1439
- const trackEdgePadding = Math.max(
1440
- TRACK_CAMERA_PADDING,
1441
- ((track?.width ?? 0) / 2) + (track?.kerbWidth ?? 0) + (track?.runoffWidth ?? 0),
1442
- );
1443
- const result = Number.isFinite(bounds.minX)
1444
- ? {
1445
- minX: bounds.minX - trackEdgePadding,
1446
- minY: bounds.minY - trackEdgePadding,
1447
- maxX: bounds.maxX + trackEdgePadding,
1448
- maxY: bounds.maxY + trackEdgePadding,
1449
- }
1450
- : null;
1451
-
1452
- this.trackCameraBoundsCache = { track, bounds: result };
1453
- return result;
1454
- }
1455
-
1456
- getTrackCameraTarget(track) {
1457
- const bounds = this.getTrackCameraBounds(track);
1458
- if (!bounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
1459
-
1460
- return {
1461
- x: (bounds.minX + bounds.maxX) / 2,
1462
- y: (bounds.minY + bounds.maxY) / 2,
1463
- };
674
+ return this.cameraController.getTrackBounds(track);
1464
675
  }
1465
676
 
1466
677
  getCameraBoundsFitScale(bounds, height, safeArea) {
1467
- if (!bounds) return null;
1468
- const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX);
1469
- const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY);
1470
- const scale = Math.min(safeArea.width / fitWidth, height / fitHeight);
1471
- return Number.isFinite(scale) && scale > 0 ? scale : null;
678
+ return this.cameraController.getBoundsFitScale(bounds, height, safeArea);
1472
679
  }
1473
680
 
1474
681
  getPitCameraBounds(pitLane) {
1475
- if (this.pitCameraBoundsCache?.pitLane === pitLane) return this.pitCameraBoundsCache.bounds;
1476
-
1477
- const bounds = {
1478
- minX: Infinity,
1479
- minY: Infinity,
1480
- maxX: -Infinity,
1481
- maxY: -Infinity,
1482
- };
1483
- const includePoint = (point) => {
1484
- if (!point) return;
1485
- bounds.minX = Math.min(bounds.minX, point.x);
1486
- bounds.minY = Math.min(bounds.minY, point.y);
1487
- bounds.maxX = Math.max(bounds.maxX, point.x);
1488
- bounds.maxY = Math.max(bounds.maxY, point.y);
1489
- };
1490
- const includePoints = (points = []) => {
1491
- points.forEach(includePoint);
1492
- };
1493
-
1494
- includePoint(pitLane.entry?.lanePoint);
1495
- includePoints(pitLane.mainLane?.points);
1496
- includePoints(pitLane.workingLane?.points);
1497
- includePoint(pitLane.exit?.lanePoint);
1498
- (pitLane.boxes ?? []).forEach((box) => includePoints(box.corners));
1499
- (pitLane.serviceAreas ?? []).forEach((area) => {
1500
- includePoints(area.corners);
1501
- includePoints(area.queueCorners);
1502
- });
1503
-
1504
- const result = Number.isFinite(bounds.minX) ? bounds : null;
1505
- this.pitCameraBoundsCache = { pitLane, bounds: result };
1506
- return result;
1507
- }
1508
-
1509
- getPitCameraTarget(pitLane) {
1510
- const bounds = this.getPitCameraBounds(pitLane);
1511
- if (!bounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
1512
-
1513
- return {
1514
- x: (bounds.minX + bounds.maxX) / 2,
1515
- y: (bounds.minY + bounds.maxY) / 2,
1516
- };
682
+ return this.cameraController.getPitBounds(pitLane);
1517
683
  }
1518
684
 
1519
685
  getPitCameraFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
1520
- const bounds = this.getPitCameraBounds(pitLane);
1521
- const target = bounds
1522
- ? {
1523
- x: (bounds.minX + bounds.maxX) / 2,
1524
- y: (bounds.minY + bounds.maxY) / 2,
1525
- }
1526
- : { x: WORLD.width / 2, y: WORLD.height / 2 };
1527
-
1528
- const presetScale = clampCameraScale(baseScale * this.camera.zoom, baseScale, minimumScale);
1529
- if (!bounds) {
1530
- return {
1531
- target,
1532
- scale: presetScale,
1533
- screenX: screenCenterX,
1534
- screenY: height / 2,
1535
- };
1536
- }
1537
-
1538
- const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + PIT_CAMERA_PADDING);
1539
- const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + PIT_CAMERA_PADDING);
1540
- const fitScale = Math.min(safeArea.width / fitWidth, height / fitHeight);
1541
- const scale = clampCameraScale(fitScale * this.camera.zoom, fitScale, minimumScale);
1542
-
1543
- return {
1544
- target,
1545
- scale: Number.isFinite(scale) && scale > 0 ? scale : presetScale,
1546
- screenX: screenCenterX,
1547
- screenY: height / 2,
1548
- };
686
+ return this.cameraController.getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale);
1549
687
  }
1550
688
 
1551
689
  updateDom(snapshot, { emitLifecycle = true } = {}) {
@@ -1566,65 +704,18 @@ export class F1SimulatorApp {
1566
704
  }
1567
705
  this.updateStewardMessageState(snapshot, now);
1568
706
 
1569
- if (this.readouts.mode) {
1570
- const modeLabel = snapshot.raceControl.mode === 'safety-car'
1571
- ? 'SC'
1572
- : snapshot.raceControl.mode === 'red-flag'
1573
- ? 'RED'
1574
- : snapshot.raceControl.finished
1575
- ? 'FINISH'
1576
- : 'GREEN';
1577
- this.readouts.mode.textContent = modeLabel;
1578
- this.readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
1579
- ? 'var(--yellow)'
1580
- : snapshot.raceControl.mode === 'red-flag'
1581
- ? 'var(--race-control-red)'
1582
- : snapshot.raceControl.mode === 'finished'
1583
- ? 'var(--red)'
1584
- : 'var(--green)';
1585
- }
1586
707
  this.syncSafetyCarControls(snapshot.raceControl.mode === 'safety-car');
1587
- if (this.readouts.lap) this.readouts.lap.textContent = `${leader?.lap ?? 1}/${snapshot.totalLaps}`;
1588
- if (this.readouts.towerLap) this.readouts.towerLap.textContent = leader?.lap ?? 1;
1589
- if (this.readouts.towerTotalLaps) this.readouts.towerTotalLaps.textContent = snapshot.totalLaps;
1590
- if (this.readouts.timingTower) {
1591
- this.readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
1592
- this.readouts.timingTower.classList.toggle('is-red-flag', snapshot.raceControl.mode === 'red-flag');
1593
- this.readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
1594
- }
1595
- if (this.readouts.towerRaceControlBanner) {
1596
- const raceControlBanner = getRaceControlStatusBanner(snapshot.raceControl.mode);
1597
- this.readouts.towerRaceControlBanner.hidden = !raceControlBanner;
1598
- this.readouts.towerRaceControlBanner.classList?.toggle?.('is-safety-car', raceControlBanner?.status === 'safety-car');
1599
- this.readouts.towerRaceControlBanner.classList?.toggle?.('is-red-flag', raceControlBanner?.status === 'red-flag');
1600
- if (raceControlBanner && this.readouts.towerRaceControlBanner.dataset) {
1601
- this.readouts.towerRaceControlBanner.dataset.raceControlStatus = raceControlBanner.status;
1602
- } else if (this.readouts.towerRaceControlBanner.dataset) {
1603
- delete this.readouts.towerRaceControlBanner.dataset.raceControlStatus;
1604
- }
1605
- if (raceControlBanner) {
1606
- setText(this.readouts.towerRaceControlKicker, raceControlBanner.kicker);
1607
- setText(this.readouts.towerRaceControlTitle, raceControlBanner.title);
1608
- }
1609
- }
1610
- if (this.readouts.drs) {
1611
- this.readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
1612
- ? 'DISABLED'
1613
- : activeDrs
1614
- ? `${activeDrs} OPEN`
1615
- : 'ARMED';
1616
- }
1617
- if (this.readouts.contacts) this.readouts.contacts.textContent = String(contactCount);
1618
- this.renderStartLights(snapshot.raceControl);
1619
- if (this.readouts.camera) {
1620
- const zoom = Math.round(this.camera.zoom * 100);
1621
- const mode = this.camera.free ? 'FREE' : this.camera.mode.toUpperCase().replace('-', ' ');
1622
- this.readouts.camera.textContent = `${mode} ${zoom}%`;
1623
- }
1624
- if (this.readouts.fps) {
1625
- this.readouts.fps.textContent = this.fps.current ? `${this.fps.current}` : '--';
1626
- }
1627
- this.renderRaceFinish(snapshot);
708
+ this.lastFinishClassificationMarkup = renderRaceStatusReadouts({
709
+ readouts: this.readouts,
710
+ startLightNodes: this.startLightNodes,
711
+ snapshot,
712
+ camera: this.camera,
713
+ fps: this.fps,
714
+ activeDrs,
715
+ contactCount,
716
+ driverById: this.driverById,
717
+ lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
718
+ });
1628
719
 
1629
720
  this.updateCameraControls(snapshot);
1630
721
  this.syncTimingGapModeControls();
@@ -1692,38 +783,12 @@ export class F1SimulatorApp {
1692
783
  }
1693
784
 
1694
785
  renderRaceFinish(snapshot) {
1695
- const panel = this.readouts.finishPanel;
1696
- if (!panel) return;
1697
-
1698
- panel.hidden = !snapshot.raceControl.finished;
1699
- if (!snapshot.raceControl.finished) return;
1700
-
1701
- const winner = snapshot.raceControl.winner;
1702
- const winnerDriver = winner ? this.driverById.get(winner.id) : null;
1703
- const winnerName = winnerDriver?.name ?? winner?.name ?? 'Winner';
1704
- panel.style.setProperty('--driver-color', winner?.color ?? winnerDriver?.color ?? 'var(--red)');
1705
- setText(this.readouts.finishWinner, winnerName);
1706
-
1707
- if (this.readouts.finishClassification) {
1708
- const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
1709
- const classificationMarkup = topThree.map((entry) => `
1710
- <li>
1711
- <span>P${escapeHtml(entry.rank)}</span>
1712
- <strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
1713
- </li>
1714
- `).join('');
1715
- if (classificationMarkup !== this.lastFinishClassificationMarkup) {
1716
- this.readouts.finishClassification.innerHTML = classificationMarkup;
1717
- this.lastFinishClassificationMarkup = classificationMarkup;
1718
- }
1719
- }
1720
- }
1721
-
1722
- getBaseScale() {
1723
- const width = this.canvasHost.clientWidth || 900;
1724
- const height = this.canvasHost.clientHeight || 640;
1725
- const safeArea = this.getCameraSafeArea(width);
1726
- return Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
786
+ this.lastFinishClassificationMarkup = renderRaceFinish({
787
+ readouts: this.readouts,
788
+ snapshot,
789
+ driverById: this.driverById,
790
+ lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
791
+ });
1727
792
  }
1728
793
 
1729
794
  sampleFps(now) {
@@ -1736,24 +801,7 @@ export class F1SimulatorApp {
1736
801
  }
1737
802
 
1738
803
  renderStartLights(raceControl) {
1739
- const panel = this.readouts.startLights;
1740
- if (!panel) return;
1741
-
1742
- const start = raceControl.start;
1743
- const visible = Boolean(start?.visible);
1744
- panel.hidden = !visible;
1745
- if (!visible) return;
1746
-
1747
- this.startLightNodes.forEach((light, index) => {
1748
- light.classList.toggle('is-lit', index < (start.lightsLit ?? 0));
1749
- });
1750
- panel.classList.toggle('is-lights-out', raceControl.mode === 'green' && start.released);
1751
-
1752
- if (this.readouts.startLightsLabel) {
1753
- this.readouts.startLightsLabel.textContent = raceControl.mode === 'green' && start.released
1754
- ? 'Lights out'
1755
- : `${start.lightsLit}/${start.lightCount}`;
1756
- }
804
+ renderStartLights(this.readouts, this.startLightNodes, raceControl);
1757
805
  }
1758
806
 
1759
807
  selectCar(id, { focus = false } = {}) {
@@ -1772,363 +820,107 @@ export class F1SimulatorApp {
1772
820
  }
1773
821
 
1774
822
  renderTiming(cars, raceMode, penalties = []) {
1775
- if (!this.timingList) return;
1776
823
  this.syncTimingGapModeControls();
1777
- const penaltyByDriver = this.timingPenaltyBadgesEnabled ? this.getPenaltyByDriver(penalties) : new Map();
1778
- const timingMarkup = cars.map((car) => {
1779
- const driver = this.driverById.get(car.id);
1780
- let gap = 'Leader';
1781
- if (this.isWavedFlagCar(car) && raceMode !== 'finished') {
1782
- gap = 'WAVED';
1783
- } else if (raceMode === 'finished') {
1784
- gap = car.rank === 1 ? 'Winner' : 'FIN';
1785
- } else if (raceMode === 'safety-car' && car.rank > 1) {
1786
- gap = 'SC';
1787
- } else if (raceMode === 'pre-start') {
1788
- gap = car.rank === 1 ? 'Pole' : 'Grid';
1789
- } else if (car.rank > 1) {
1790
- gap = this.formatTimingGap(car);
1791
- }
1792
- const tire = car.tire ?? driver?.tire ?? 'M';
1793
- const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
1794
- const team = car.team ?? driver?.team ?? null;
1795
- const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
1796
- const iconColor = team?.color ?? car.color;
1797
- const penalty = penaltyByDriver.get(car.id);
1798
- const penaltyBadge = penalty
1799
- ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
1800
- : '';
1801
-
1802
- return `
1803
- <li>
1804
- <button class="timing-row ${car.id === this.selectedId ? 'is-selected' : ''}" type="button"
1805
- data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
1806
- style="--driver-color: ${escapeHtml(car.color)}">
1807
- <span class="timing-position">${car.rank}</span>
1808
- <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
1809
- <span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
1810
- <span class="timing-gap">${escapeHtml(gap)}</span>
1811
- <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
1812
- </button>
1813
- </li>
1814
- `;
1815
- }).join('');
1816
- if (timingMarkup !== this.lastTimingMarkup) {
1817
- this.timingList.innerHTML = timingMarkup;
1818
- this.lastTimingMarkup = timingMarkup;
1819
- }
824
+ this.lastTimingMarkup = renderTimingTower({
825
+ timingList: this.timingList,
826
+ cars,
827
+ raceMode,
828
+ penalties,
829
+ driverById: this.driverById,
830
+ selectedId: this.selectedId,
831
+ timingGapMode: this.timingGapMode,
832
+ timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
833
+ lastTimingMarkup: this.lastTimingMarkup,
834
+ });
1820
835
  }
1821
836
 
1822
837
  getTimingOrderKey(cars = []) {
1823
- return cars.map((car) => [
1824
- car.id,
1825
- car.rank,
1826
- car.lap,
1827
- this.formatTimingGap(car),
1828
- car.penaltySeconds ?? 0,
1829
- car.classifiedRank ?? 0,
1830
- car.finishRank ?? 0,
1831
- car.raceStatus ?? car.status ?? '',
1832
- car.wavedFlag ? 1 : 0,
1833
- car.tire ?? '',
1834
- ].join(':')).join('|');
838
+ return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
1835
839
  }
1836
840
 
1837
841
  isWavedFlagCar(car) {
1838
- return Boolean(car?.wavedFlag || car?.raceStatus === 'waved-flag' || car?.status === 'waved-flag');
842
+ return isWavedFlagCar(car);
1839
843
  }
1840
844
 
1841
845
  getPenaltyByDriver(penalties = []) {
1842
- const byDriver = new Map();
1843
- penalties.forEach((penalty) => {
1844
- if (!penalty?.driverId || byDriver.has(penalty.driverId)) return;
1845
- if (!hasVisiblePenaltyEffect(penalty)) return;
1846
- byDriver.set(penalty.driverId, penalty);
1847
- });
1848
- return byDriver;
846
+ return getPenaltyByDriver(penalties);
1849
847
  }
1850
848
 
1851
849
  getTimingPenaltyKey(penalties = []) {
1852
- if (!this.timingPenaltyBadgesEnabled) return '';
1853
- return penalties
1854
- .filter(hasVisiblePenaltyEffect)
1855
- .map((penalty) => [
1856
- penalty.id,
1857
- penalty.driverId,
1858
- penalty.status,
1859
- penalty.penaltySeconds,
1860
- penalty.pendingPenaltySeconds,
1861
- penalty.positionDrop,
1862
- penalty.gridDrop,
1863
- penalty.disqualified,
1864
- ].join(':'))
1865
- .join('|');
850
+ return getTimingPenaltyKey(penalties, {
851
+ timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
852
+ });
1866
853
  }
1867
854
 
1868
855
  formatTimingGap(car) {
1869
- const lapValue = this.timingGapMode === 'leader'
1870
- ? car.leaderGapLaps
1871
- : (car.intervalAheadLaps ?? car.gapAheadLaps);
1872
- if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
1873
-
1874
- const value = this.timingGapMode === 'leader'
1875
- ? car.leaderGapSeconds
1876
- : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1877
- return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
856
+ return formatRaceGap(car, this.timingGapMode);
1878
857
  }
1879
858
 
1880
859
  formatLapGap(laps) {
1881
- const wholeLaps = Math.max(0, Math.floor(laps));
1882
- return `+${wholeLaps}`;
860
+ return formatLapGap(laps);
1883
861
  }
1884
862
 
1885
863
  formatTelemetryGap(car, mode) {
1886
- const lapValue = mode === 'leader'
1887
- ? car.leaderGapLaps
1888
- : (car.intervalAheadLaps ?? car.gapAheadLaps);
1889
- if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
1890
-
1891
- const seconds = mode === 'leader'
1892
- ? car.leaderGapSeconds
1893
- : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1894
- return Number.isFinite(seconds) ? `${seconds.toFixed(2)}s` : '--';
864
+ return formatTelemetryGap(car, mode);
1895
865
  }
1896
866
 
1897
867
  syncTimingGapModeControls() {
1898
- const label = this.timingGapMode === 'leader' ? 'Gap' : 'Int';
1899
- setText(this.readouts.timingGapLabel, label);
1900
- this.timingGapModeButtons.forEach((button) => {
1901
- const active = button.dataset.timingGapMode === this.timingGapMode;
1902
- button.setAttribute('aria-pressed', String(active));
1903
- button.classList?.toggle?.('is-active', active);
868
+ syncTimingGapModeControls({
869
+ readouts: this.readouts,
870
+ buttons: this.timingGapModeButtons,
871
+ timingGapMode: this.timingGapMode,
1904
872
  });
1905
873
  }
1906
874
 
1907
875
  renderTelemetry(car) {
1908
- if (!car) return;
1909
- const driver = this.driverById.get(car.id);
1910
- const icon = car.icon ?? driver?.icon ?? car.code;
1911
- const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
1912
- const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
1913
- const surface = (car.surface ?? 'track').toUpperCase();
1914
-
1915
- setTextAll(this.readouts.selectedCode, car.code);
1916
- this.readouts.selectedCode?.forEach?.((node) => {
1917
- node.style.color = car.color;
876
+ renderTelemetryReadouts({
877
+ readouts: this.readouts,
878
+ car,
879
+ driverById: this.driverById,
1918
880
  });
1919
- this.readouts.telemetrySectorBanners?.forEach?.((node) => {
1920
- node.style.setProperty('--driver-color', car.color);
1921
- });
1922
- setTextAll(this.readouts.selectedName, car.name);
1923
- setTextAll(this.readouts.speed, `${Math.round(car.speedKph)} km/h`);
1924
- setTextAll(this.readouts.throttle, `${Math.round(car.throttle * 100)}%`);
1925
- setTextAll(this.readouts.brake, `${Math.round(car.brake * 100)}%`);
1926
- setTextAll(this.readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
1927
- setTextAll(this.readouts.selectedDrs, drsState);
1928
- setTextAll(this.readouts.surface, surface);
1929
- setTextAll(
1930
- this.readouts.gap,
1931
- car.rank === 1
1932
- ? '--'
1933
- : this.formatTelemetryGap(car, 'interval'),
1934
- );
1935
- setTextAll(
1936
- this.readouts.leaderGap,
1937
- car.rank === 1
1938
- ? '--'
1939
- : this.formatTelemetryGap(car, 'leader'),
1940
- );
1941
-
1942
881
  this.renderCarDriverOverview(car);
1943
882
  this.renderLapTelemetry(car.lapTelemetry);
1944
883
  }
1945
884
 
1946
885
  renderLapTelemetry(telemetry) {
1947
- if (!telemetry) return;
1948
-
1949
- setTextAll(this.readouts.currentSector, `S${telemetry.currentSector ?? 1}`);
1950
- setTextAll(this.readouts.completedLaps, `${telemetry.completedLaps ?? 0} laps`);
1951
- setTextAll(this.readouts.currentLapTime, formatTelemetryTime(telemetry.currentLapTime));
1952
- setTextAll(this.readouts.lastLapTime, formatTelemetryTime(telemetry.lastLapTime));
1953
- setTextAll(this.readouts.bestLapTime, formatTelemetryTime(telemetry.bestLapTime));
1954
-
1955
- this.readouts.telemetrySectorBars?.forEach((bar) => {
1956
- const sector = Number(bar.dataset.telemetrySectorBar);
1957
- const index = sector - 1;
1958
- const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
1959
- const isActive = index === activeIndex;
1960
- const isCompletedCurrentSector = index < activeIndex;
1961
- const hasLiveProgress = Array.isArray(telemetry.sectorProgress);
1962
- const progress = index > activeIndex
1963
- ? 0
1964
- : hasLiveProgress
1965
- ? telemetry.sectorProgress[index]
1966
- : isActive
1967
- ? telemetry.currentSectorProgress
1968
- : isCompletedCurrentSector && Number.isFinite(telemetry.currentSectors?.[index])
1969
- ? 1
1970
- : 0;
1971
- const fill = clamp((progress ?? 0) * 100, 0, 100);
1972
- const sectorComplete = fill >= 99.9;
1973
- const fillValue = `${fill.toFixed(1)}%`;
1974
- if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
1975
- bar.style.setProperty('--sector-fill', fillValue);
1976
- }
1977
- bar.classList.toggle('is-active', isActive);
1978
- bar.classList.toggle('is-complete', sectorComplete);
1979
- setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
1980
- });
1981
-
1982
- this.readouts.telemetrySectorTimes?.forEach((node) => {
1983
- const sector = Number(node.dataset.telemetrySectorTime);
1984
- const index = sector - 1;
1985
- const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
1986
- const isActive = index === activeIndex;
1987
- const isCompletedCurrentSector = index < activeIndex;
1988
- const hasLiveSectors = Array.isArray(telemetry.liveSectors);
1989
- const value = index > activeIndex
1990
- ? null
1991
- : hasLiveSectors
1992
- ? isActive
1993
- ? telemetry.currentSectorElapsed ?? telemetry.liveSectors[index]
1994
- : telemetry.liveSectors[index]
1995
- : isActive && !Number.isFinite(telemetry.currentSectors?.[index])
1996
- ? telemetry.currentSectorElapsed
1997
- : isCompletedCurrentSector
1998
- ? telemetry.currentSectors?.[index]
1999
- : null;
2000
- setText(node, formatTelemetryTime(value));
2001
- setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
2002
- });
2003
-
2004
- this.readouts.telemetrySectorLast?.forEach((node) => {
2005
- const index = Number(node.dataset.telemetrySectorLast) - 1;
2006
- setText(node, formatTelemetryTime(telemetry.lastSectors?.[index]));
2007
- setPerformanceClass(node, telemetry.sectorPerformance?.last?.[index]);
2008
- });
2009
-
2010
- this.readouts.telemetrySectorBest?.forEach((node) => {
2011
- const index = Number(node.dataset.telemetrySectorBest) - 1;
2012
- setText(node, formatTelemetryTime(telemetry.bestSectors?.[index]));
2013
- setPerformanceClass(node, telemetry.sectorPerformance?.best?.[index]);
2014
- });
886
+ renderLapTelemetry(this.readouts, telemetry);
2015
887
  }
2016
888
 
2017
889
  getOverviewFields(driver, mode) {
2018
- if (mode === 'driver') {
2019
- const ratings = driver?.constructorArgs?.driver?.ratings ?? {};
2020
- return [
2021
- ...DRIVER_OVERVIEW_FIELDS.map(([key, label]) => ({
2022
- key,
2023
- label,
2024
- value: ratings[key] ?? DRIVER_STAT_DEFINITIONS[key]?.neutral,
2025
- })),
2026
- ...normalizeCustomFields([
2027
- ...(driver?.constructorArgs?.driver?.customFields ?? []),
2028
- ...(driver?.customFields ?? []),
2029
- ]),
2030
- ];
2031
- }
2032
-
2033
- const ratings = driver?.constructorArgs?.vehicle?.ratings ?? driver?.vehicle?.ratings ?? {};
2034
- return [
2035
- ...VEHICLE_OVERVIEW_FIELDS.map(([key, label]) => ({
2036
- key,
2037
- label,
2038
- value: ratings[key] ?? VEHICLE_STAT_DEFINITIONS[key]?.neutral,
2039
- })),
2040
- ...normalizeCustomFields(driver?.vehicle?.customFields ?? driver?.constructorArgs?.vehicle?.customFields ?? []),
2041
- ];
890
+ return getOverviewFields(driver, mode);
2042
891
  }
2043
892
 
2044
893
  renderCarDriverOverview(car) {
2045
- if (!car || !this.readouts.carOverview) return;
2046
- const driver = this.driverById.get(car.id);
2047
- const icon = car.icon ?? driver?.icon ?? car.code;
2048
- const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
2049
- const mode = this.overviewMode === 'driver' ? 'driver' : 'vehicle';
2050
- const fields = this.getOverviewFields(driver, mode);
2051
- const displayFields = fields.length > 0
2052
- ? fields
2053
- : [{ label: mode === 'driver' ? 'Driver fields' : 'Car fields', value: 'No custom fields' }];
2054
- const imageSrc = mode === 'driver'
2055
- ? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? this.assets.driverHelmet)
2056
- : this.assets.carOverview;
2057
- const overviewCode = mode === 'driver'
2058
- ? `${car.code} driver`
2059
- : `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
2060
- const overviewKey = JSON.stringify({
2061
- id: car.id,
2062
- mode,
2063
- color: car.color,
2064
- code: car.code,
2065
- icon,
2066
- driverNumber,
2067
- imageSrc,
2068
- overviewCode,
2069
- fields: displayFields.map((field) => [field.label, field.value]),
894
+ const previousKey = this.lastOverviewRenderKey;
895
+ this.lastOverviewRenderKey = renderCarDriverOverview({
896
+ readouts: this.readouts,
897
+ car,
898
+ driverById: this.driverById,
899
+ assets: this.assets,
900
+ overviewMode: this.overviewMode,
901
+ lastOverviewRenderKey: this.lastOverviewRenderKey,
2070
902
  });
2071
- if (overviewKey === this.lastOverviewRenderKey) return;
2072
- this.lastOverviewRenderKey = overviewKey;
2073
-
2074
- this.readouts.carOverview?.style.setProperty('--driver-color', car.color);
2075
- this.readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
2076
- if (this.readouts.carOverviewTitle) {
2077
- this.readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
2078
- }
2079
- if (this.readouts.carOverviewCode) {
2080
- this.readouts.carOverviewCode.textContent = overviewCode;
2081
- }
2082
- if (this.readouts.carOverviewIcon) this.readouts.carOverviewIcon.textContent = icon;
2083
- if (this.readouts.carOverviewImage) {
2084
- this.readouts.carOverviewImage.src = imageSrc;
2085
- }
2086
- if (this.readouts.carOverviewNumber) this.readouts.carOverviewNumber.textContent = driverNumber;
2087
- if (this.readouts.carOverviewCoreStat) this.readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
2088
- this.readouts.carOverviewFields.forEach((fieldNode, index) => {
2089
- const field = displayFields[index];
2090
- fieldNode.hidden = !field;
2091
- if (!field) return;
2092
- const labelNode = fieldNode.querySelector('[data-overview-field-label]');
2093
- const valueNode = fieldNode.querySelector('[data-overview-field-value]');
2094
- setText(labelNode, field.label);
2095
- setText(valueNode, field.value);
2096
- });
2097
- this.updateOverviewModeButtons();
903
+ if (this.lastOverviewRenderKey !== previousKey) this.updateOverviewModeButtons();
2098
904
  }
2099
905
 
2100
906
  renderRaceData(car) {
2101
- if (!car || !this.readouts.raceDataPanel || !this.isRaceDataBannerEnabled('project')) return;
2102
- const driver = this.drivers.find((item) => item.id === car.id);
2103
- if (!driver) return;
2104
-
2105
- this.readouts.raceDataPanel.style.setProperty('--driver-color', driver.color);
2106
- this.readouts.raceDataPanel.classList.remove('is-hidden');
2107
- this.readouts.raceDataPanel.classList.add('is-project-mode');
2108
- this.readouts.raceDataPanel.classList.remove('is-radio-mode');
2109
- this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
2110
- setText(this.readouts.raceDataKicker, 'Project');
2111
- setText(this.readouts.raceDataTitle, driver.name);
2112
- if (this.readouts.raceDataNumber) {
2113
- this.readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
2114
- }
2115
- if (this.readouts.raceDataSubtitle) {
2116
- this.readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
2117
- }
2118
- if (this.readouts.raceDataOpen) {
2119
- this.readouts.raceDataOpen.hidden = typeof this.options.onDriverOpen !== 'function';
2120
- }
907
+ renderRaceData({
908
+ car,
909
+ drivers: this.drivers,
910
+ readouts: this.readouts,
911
+ options: {
912
+ ...this.options,
913
+ isRaceDataBannerEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
914
+ },
915
+ });
2121
916
  }
2122
917
 
2123
918
  shouldAutoHideActiveRaceData() {
2124
- return Boolean(this.activeRaceDataId && !this.hasPersistentRaceDataTelemetry());
2125
- }
2126
-
2127
- hasPersistentRaceDataTelemetry() {
2128
- return Boolean(
2129
- this.options.ui?.raceDataTelemetryDetail ||
2130
- this.readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry'),
2131
- );
919
+ return shouldAutoHideActiveRaceData({
920
+ activeRaceDataId: this.activeRaceDataId,
921
+ options: this.options,
922
+ readouts: this.readouts,
923
+ });
2132
924
  }
2133
925
 
2134
926
  renderProjectRadio(now = performance.now()) {
@@ -2143,26 +935,14 @@ export class F1SimulatorApp {
2143
935
  return;
2144
936
  }
2145
937
 
2146
- const radio = this.getProjectRadioQuote();
2147
-
2148
- this.readouts.raceDataPanel.style.setProperty('--driver-color', radio.color);
2149
- this.readouts.raceDataPanel.classList.remove('is-hidden');
2150
- this.readouts.raceDataPanel.classList.add('is-radio-mode');
2151
- this.readouts.raceDataPanel.classList.remove('is-project-mode');
2152
- this.readouts.raceDataPanel.dataset.idleMode = 'quote';
2153
- setText(this.readouts.raceDataKicker, 'Project radio');
2154
- setText(this.readouts.raceDataTitle, radio.title);
2155
- setText(this.readouts.raceDataNumber, '');
2156
- setText(this.readouts.raceDataSubtitle, radio.subtitle);
2157
- if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
938
+ renderProjectRadio({
939
+ readouts: this.readouts,
940
+ radio: this.getProjectRadioQuote(),
941
+ });
2158
942
  }
2159
943
 
2160
944
  hideRaceDataPanel() {
2161
- if (!this.readouts.raceDataPanel) return;
2162
- this.readouts.raceDataPanel.classList.add('is-hidden');
2163
- this.readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
2164
- this.readouts.raceDataPanel.removeAttribute('data-idle-mode');
2165
- if (this.readouts.raceDataOpen) this.readouts.raceDataOpen.hidden = true;
945
+ hideRaceDataPanel(this.readouts);
2166
946
  }
2167
947
 
2168
948
  dismissRaceDataPanel(now = performance.now()) {
@@ -2174,85 +954,33 @@ export class F1SimulatorApp {
2174
954
  }
2175
955
 
2176
956
  updateStewardMessageState(snapshot, now = performance.now()) {
2177
- if (!this.penaltyBannerEnabled || !this.readouts.stewardMessage) {
2178
- this.activeStewardMessage = null;
2179
- return;
2180
- }
2181
- if (this.activeStewardMessage && now < this.activeStewardMessage.visibleUntil) return;
2182
- this.activeStewardMessage = null;
2183
-
2184
- const warning = [...(snapshot.events ?? [])].reverse().find((event) => (
2185
- event?.type === 'track-limits' && event.decision === 'warning'
2186
- ));
2187
- const penalty = [...(snapshot.penalties ?? [])].reverse().find((entry) => (
2188
- entry?.id && entry.id !== this.lastPenaltyBannerId
2189
- ));
2190
- const message = penalty
2191
- ? this.createPenaltyStewardMessage(penalty)
2192
- : this.createWarningStewardMessage(warning);
2193
- const key = formatStewardMessageKey(message);
2194
- if (!message || !key || key === this.lastStewardMessageKey) return;
2195
- this.lastStewardMessageKey = key;
2196
- if (message.penaltyId) this.lastPenaltyBannerId = message.penaltyId;
2197
- this.activeStewardMessage = {
2198
- message,
2199
- visibleUntil: now + PENALTY_BANNER_VISIBLE_MS,
2200
- };
957
+ const state = updateStewardMessageState({
958
+ snapshot,
959
+ now,
960
+ state: {
961
+ activeStewardMessage: this.activeStewardMessage,
962
+ lastPenaltyBannerId: this.lastPenaltyBannerId,
963
+ lastStewardMessageKey: this.lastStewardMessageKey,
964
+ },
965
+ driverById: this.driverById,
966
+ penaltyBannerEnabled: this.penaltyBannerEnabled,
967
+ stewardMessageNode: this.readouts.stewardMessage,
968
+ });
969
+ this.activeStewardMessage = state.activeStewardMessage;
970
+ this.lastPenaltyBannerId = state.lastPenaltyBannerId;
971
+ this.lastStewardMessageKey = state.lastStewardMessageKey;
2201
972
  }
2202
973
 
2203
974
  createPenaltyStewardMessage(penalty) {
2204
- if (!penalty) return null;
2205
- const driver = this.driverById.get(penalty.driverId);
2206
- const code = driver?.timingCode ?? driver?.code ?? penalty.driverId ?? 'CAR';
2207
- return {
2208
- kind: 'penalty',
2209
- type: penalty.type,
2210
- driverId: penalty.driverId,
2211
- penaltyId: penalty.id,
2212
- time: penalty.time,
2213
- color: driver?.color ?? 'var(--red)',
2214
- kicker: formatPenaltyChip(penalty),
2215
- title: `${code} ${formatPenaltyHeadline(penalty).replace(/^\+\d+ seconds? /u, '')}`,
2216
- detail: `${formatPenaltyType(penalty.type)} - ${penalty.reason ?? 'Steward decision'}`,
2217
- };
975
+ return createPenaltyStewardMessage(penalty, this.driverById);
2218
976
  }
2219
977
 
2220
978
  createWarningStewardMessage(event) {
2221
- if (!event) return null;
2222
- const driver = this.driverById.get(event.carId);
2223
- const code = driver?.timingCode ?? driver?.code ?? event.carId ?? 'CAR';
2224
- const warningLimit = Number(event.warningsBeforePenalty);
2225
- const warningProgress = Number.isFinite(warningLimit) && warningLimit > 0
2226
- ? ` ${event.violationCount}/${warningLimit}`
2227
- : '';
2228
- return {
2229
- kind: 'warning',
2230
- type: event.type,
2231
- driverId: event.carId,
2232
- violationCount: event.violationCount,
2233
- time: event.at,
2234
- color: driver?.color ?? 'var(--yellow)',
2235
- kicker: 'Warning',
2236
- title: `${code} ${formatPenaltyType(event.type).toLowerCase()}`,
2237
- detail: `${formatPenaltyType(event.type)}${warningProgress}`,
2238
- };
979
+ return createWarningStewardMessage(event, this.driverById);
2239
980
  }
2240
981
 
2241
982
  renderActiveStewardMessage() {
2242
- const message = this.activeStewardMessage?.message;
2243
- const panel = this.readouts.stewardMessage;
2244
- if (!panel) return;
2245
- if (!message) {
2246
- panel.classList.add('is-hidden');
2247
- panel.classList.remove('is-penalty', 'is-warning');
2248
- return;
2249
- }
2250
- panel.style.setProperty('--steward-color', message.color);
2251
- panel.classList.remove('is-hidden', 'is-penalty', 'is-warning');
2252
- panel.classList.add(message.kind === 'penalty' ? 'is-penalty' : 'is-warning');
2253
- setText(this.readouts.stewardMessageKicker, message.kicker);
2254
- setText(this.readouts.stewardMessageTitle, message.title);
2255
- setText(this.readouts.stewardMessageDetail, message.detail);
983
+ renderActiveStewardMessage(this.readouts, this.activeStewardMessage);
2256
984
  }
2257
985
 
2258
986
  updateRadioSchedule(now) {
@@ -2279,26 +1007,22 @@ export class F1SimulatorApp {
2279
1007
  }
2280
1008
 
2281
1009
  scheduleRadioBreak(now) {
2282
- this.radioState.visible = false;
2283
- this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
1010
+ scheduleRadioBreak({
1011
+ radioState: this.radioState,
1012
+ now,
1013
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1014
+ nextRandom: () => this.nextRadioRandom(),
1015
+ });
2284
1016
  }
2285
1017
 
2286
1018
  scheduleRadioPopup(now) {
2287
- if (!this.isRaceDataBannerEnabled('radio')) {
2288
- this.scheduleRadioBreak(now);
2289
- return;
2290
- }
2291
- const driverIndex = Math.floor(this.nextRadioRandom() * this.drivers.length);
2292
- const driver = this.drivers[driverIndex] ?? this.drivers[0];
2293
- const quoteCount = Math.max(1, driver.raceData?.length ?? 1);
2294
- this.radioState.visible = true;
2295
- this.radioState.driverIndex = driverIndex;
2296
- this.radioState.quoteIndex = Math.floor(this.nextRadioRandom() * quoteCount);
2297
- this.radioState.nextChangeAt = now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS);
2298
- }
2299
-
2300
- randomRadioRange(min, max) {
2301
- return min + this.nextRadioRandom() * (max - min);
1019
+ scheduleRadioPopup({
1020
+ radioState: this.radioState,
1021
+ now,
1022
+ drivers: this.drivers,
1023
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1024
+ nextRandom: () => this.nextRadioRandom(),
1025
+ });
2302
1026
  }
2303
1027
 
2304
1028
  nextRadioRandom() {
@@ -2307,24 +1031,22 @@ export class F1SimulatorApp {
2307
1031
  }
2308
1032
 
2309
1033
  getNextRadioBreakTime(now) {
2310
- return this.isRaceDataBannerEnabled('radio')
2311
- ? now + this.randomRadioRange(RADIO_BREAK_MIN_MS, RADIO_BREAK_MAX_MS)
2312
- : Number.POSITIVE_INFINITY;
1034
+ return getNextRadioBreakTime({
1035
+ now,
1036
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1037
+ nextRandom: () => this.nextRadioRandom(),
1038
+ });
2313
1039
  }
2314
1040
 
2315
1041
  resetRaceDataBannerState(now = performance.now()) {
2316
- const initialRaceDataMode = this.raceDataBannerConfig.initial ?? 'project';
2317
- this.activeRaceDataId = this.isRaceDataBannerEnabled('project') && initialRaceDataMode === 'project'
2318
- ? this.selectedId
2319
- : null;
2320
- this.lastRaceDataInteraction = now;
2321
- const showInitialRadio = this.isRaceDataBannerEnabled('radio') && initialRaceDataMode === 'radio';
2322
- this.radioState.visible = showInitialRadio;
2323
- this.radioState.nextChangeAt = showInitialRadio
2324
- ? now + this.randomRadioRange(RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS)
2325
- : this.getNextRadioBreakTime(now);
2326
- this.radioState.driverIndex = 0;
2327
- this.radioState.quoteIndex = 0;
1042
+ resetRaceDataBannerState({
1043
+ state: this,
1044
+ now,
1045
+ initialMode: this.raceDataBannerConfig.initial ?? 'project',
1046
+ selectedId: this.selectedId,
1047
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1048
+ nextRandom: () => this.nextRadioRandom(),
1049
+ });
2328
1050
  }
2329
1051
 
2330
1052
  isRaceDataBannerEnabled(kind) {
@@ -2332,66 +1054,41 @@ export class F1SimulatorApp {
2332
1054
  }
2333
1055
 
2334
1056
  getProjectRadioQuote() {
2335
- const driver = this.drivers[this.radioState.driverIndex] ?? this.drivers[0];
2336
- const quote = driver.raceData?.[this.radioState.quoteIndex] ?? 'Project entry';
2337
-
2338
- return {
2339
- color: driver.color,
2340
- title: driver.name,
2341
- subtitle: `${driver.code} - "${quote}"`,
2342
- };
1057
+ return getProjectRadioQuote({
1058
+ drivers: this.drivers,
1059
+ radioState: this.radioState,
1060
+ });
2343
1061
  }
2344
1062
 
2345
1063
  updateCameraControls(snapshot = this.sim?.snapshot?.()) {
2346
- if (this.camera.mode === 'pit' && !this.hasPitCamera(snapshot)) {
2347
- this.camera.mode = 'leader';
2348
- this.camera.zoom = CAMERA_PRESETS.leader;
2349
- }
2350
-
2351
- this.cameraButtons.forEach((button) => {
2352
- const mode = button.dataset.cameraMode;
2353
- const isAvailable = this.isCameraModeAvailable(mode, snapshot);
2354
- if (mode === 'pit') {
2355
- button.hidden = !isAvailable;
2356
- button.disabled = !isAvailable;
2357
- button.setAttribute('aria-hidden', String(!isAvailable));
2358
- }
2359
-
2360
- const isActive = !this.camera.free && isAvailable && mode === this.camera.mode;
2361
- button.classList.toggle('is-active', isActive);
2362
- button.setAttribute('aria-pressed', String(isActive));
1064
+ updateCameraControlButtons({
1065
+ camera: this.camera,
1066
+ cameraButtons: this.cameraButtons,
1067
+ snapshot,
1068
+ hasPitCamera: (currentSnapshot) => this.hasPitCamera(currentSnapshot),
1069
+ isCameraModeAvailable: (mode, currentSnapshot) => this.isCameraModeAvailable(mode, currentSnapshot),
2363
1070
  });
2364
1071
  this.updateBannerMuteControls();
2365
1072
  }
2366
1073
 
2367
1074
  updateBannerMuteControls() {
2368
- this.bannerMuteButtons?.forEach((button) => {
2369
- button.classList.toggle('is-active', this.raceDataBannersMuted);
2370
- button.setAttribute('aria-pressed', String(this.raceDataBannersMuted));
2371
- });
1075
+ updateToggleButtons(this.bannerMuteButtons, this.raceDataBannersMuted);
2372
1076
  }
2373
1077
 
2374
1078
  hasPitCamera(snapshot = this.sim?.snapshot?.()) {
2375
- return Boolean(snapshot?.track?.pitLane?.enabled);
1079
+ return this.cameraController.hasPitCamera(snapshot);
2376
1080
  }
2377
1081
 
2378
1082
  isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
2379
- return mode !== 'pit' || this.hasPitCamera(snapshot);
1083
+ return this.cameraController.isModeAvailable(mode, snapshot);
2380
1084
  }
2381
1085
 
2382
1086
  updateOverviewModeButtons() {
2383
- this.overviewModeButtons.forEach((button) => {
2384
- const isActive = button.dataset.overviewMode === this.overviewMode;
2385
- button.classList.toggle('is-active', isActive);
2386
- button.setAttribute('aria-pressed', String(isActive));
2387
- });
1087
+ updateModeButtons(this.overviewModeButtons, this.overviewMode, 'overviewMode');
2388
1088
  }
2389
1089
 
2390
1090
  syncSafetyCarControls(active) {
2391
- this.safetyButtons.forEach((button) => {
2392
- button.classList.toggle('is-active', active);
2393
- button.setAttribute('aria-pressed', String(active));
2394
- });
1091
+ updateToggleButtons(this.safetyButtons, active);
2395
1092
  }
2396
1093
 
2397
1094
  applyExpertOptions(nextOptions = {}) {
@@ -2434,6 +1131,8 @@ export class F1SimulatorApp {
2434
1131
  if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
2435
1132
  throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
2436
1133
  }
1134
+ this.expert?.destroy?.();
1135
+ this.expert = null;
2437
1136
  this.applyExpertOptions(nextOptions);
2438
1137
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
2439
1138
  this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
@@ -2526,6 +1225,9 @@ export class F1SimulatorApp {
2526
1225
  if (this.app && this.tickerCallback) {
2527
1226
  this.app.ticker.remove(this.tickerCallback);
2528
1227
  }
1228
+ this.expert?.destroy?.();
1229
+ this.expert = null;
1230
+ this.replayGhostRenderer.destroy();
2529
1231
  this.app?.destroy(true, { children: true, texture: false });
2530
1232
  this.app = null;
2531
1233
  this.carSprites.clear();