@inventure71/paddockjs 1.0.0 → 2.0.1

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 (203) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +219 -12
  4. package/docs/architecture.md +126 -45
  5. package/docs/concepts.md +9 -5
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +317 -22
  8. package/docs/data_model.md +1 -1
  9. package/docs/index.md +10 -0
  10. package/docs/learnings.md +22 -0
  11. package/docs/rules.md +71 -14
  12. package/docs/sense_contract.md +59 -0
  13. package/docs/system_specs.md +75 -13
  14. package/docs/training.md +280 -32
  15. package/examples/python/README.md +62 -0
  16. package/examples/python/base_policy_server.py +191 -0
  17. package/examples/python/requirements.txt +2 -0
  18. package/package.json +12 -4
  19. package/src/api/PaddockSimulatorController.js +6 -33
  20. package/src/app/BrowserExpertAdapter.js +76 -9
  21. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  22. package/src/app/F1SimulatorApp.js +291 -1630
  23. package/src/app/banners/bannerFormatters.js +55 -0
  24. package/src/app/banners/raceDataBannerController.js +141 -0
  25. package/src/app/banners/stewardMessageController.js +99 -0
  26. package/src/app/camera/cameraConstants.js +21 -0
  27. package/src/app/camera/cameraController.js +265 -0
  28. package/src/app/camera/cameraGeometry.js +176 -0
  29. package/src/app/domBindings.js +4 -0
  30. package/src/app/readouts/carOverviewRenderer.js +109 -0
  31. package/src/app/readouts/controlStateRenderer.js +37 -0
  32. package/src/app/readouts/raceStatusRenderer.js +133 -0
  33. package/src/app/readouts/readoutFormatters.js +58 -0
  34. package/src/app/readouts/telemetryRenderer.js +113 -0
  35. package/src/app/readouts/timingTowerRenderer.js +177 -0
  36. package/src/app/rendering/appAssets.js +38 -0
  37. package/src/app/rendering/carRenderer.js +286 -0
  38. package/src/app/rendering/displayUtils.js +22 -0
  39. package/src/app/rendering/drsTrailRenderer.js +90 -0
  40. package/src/app/rendering/expertSensorRenderer.js +126 -0
  41. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  42. package/src/app/rendering/replayGhostRenderer.js +124 -0
  43. package/src/app/rendering/trackRenderer.js +25 -0
  44. package/src/app/runtime/frameLoop.js +66 -0
  45. package/src/app/runtime/runtimeTiming.js +34 -0
  46. package/src/app/runtime/runtimeVisibility.js +45 -0
  47. package/src/config/defaultOptions.js +20 -0
  48. package/src/config/restartOptions.js +34 -0
  49. package/src/environment/actions.js +13 -5
  50. package/src/environment/controllerLoop.js +261 -0
  51. package/src/environment/episode.js +98 -2
  52. package/src/environment/evaluation.js +54 -32
  53. package/src/environment/index.d.ts +376 -10
  54. package/src/environment/index.js +1 -0
  55. package/src/environment/metrics.js +159 -0
  56. package/src/environment/observationOptions.js +22 -1
  57. package/src/environment/observationVector.js +200 -0
  58. package/src/environment/observations.js +199 -141
  59. package/src/environment/options.js +37 -5
  60. package/src/environment/recorder.js +13 -6
  61. package/src/environment/rewards.js +13 -2
  62. package/src/environment/runtime.js +272 -23
  63. package/src/environment/scenarios.js +57 -9
  64. package/src/environment/sensors/bodySenses.js +23 -0
  65. package/src/environment/sensors/boundarySenses.js +17 -0
  66. package/src/environment/sensors/carRays.js +75 -0
  67. package/src/environment/sensors/contactSenses.js +32 -0
  68. package/src/environment/sensors/index.js +157 -0
  69. package/src/environment/sensors/indexedRayBands.js +103 -0
  70. package/src/environment/sensors/nearbyCars.js +52 -0
  71. package/src/environment/sensors/opponentRadar.js +75 -0
  72. package/src/environment/sensors/rayConfig.js +110 -0
  73. package/src/environment/sensors/rayDefaults.js +6 -0
  74. package/src/environment/sensors/rayGeometry.js +60 -0
  75. package/src/environment/sensors/rayGuards.js +19 -0
  76. package/src/environment/sensors/sensorTargets.js +66 -0
  77. package/src/environment/sensors/surfaceRays.js +360 -0
  78. package/src/environment/sensors/trackRays.js +424 -0
  79. package/src/environment/sensors.js +1 -367
  80. package/src/environment/specs.js +159 -26
  81. package/src/environment/workerProtocol.js +13 -1
  82. package/src/index.d.ts +171 -6
  83. package/src/index.js +6 -32
  84. package/src/rendering/proceduralTrackAsset.js +20 -823
  85. package/src/rendering/renderSnapshot.js +38 -12
  86. package/src/rendering/track/finishLineRenderer.js +34 -0
  87. package/src/rendering/track/gridRenderer.js +47 -0
  88. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  89. package/src/rendering/track/pitLaneRenderer.js +248 -0
  90. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  91. package/src/rendering/track/trackRenderConstants.js +38 -0
  92. package/src/rendering/track/trackRenderGeometry.js +94 -0
  93. package/src/simulation/collisionGeometry.js +1 -1
  94. package/src/simulation/driver/driverControlConstants.js +28 -0
  95. package/src/simulation/driver/driverController.js +47 -0
  96. package/src/simulation/driver/driverInput.js +28 -0
  97. package/src/simulation/driver/driverMath.js +6 -0
  98. package/src/simulation/driver/driverPersonality.js +25 -0
  99. package/src/simulation/driver/edgeRecovery.js +48 -0
  100. package/src/simulation/driver/racingControls.js +399 -0
  101. package/src/simulation/driver/racingLinePlan.js +166 -0
  102. package/src/simulation/driver/recoveryDynamics.js +38 -0
  103. package/src/simulation/driver/rejoinControls.js +213 -0
  104. package/src/simulation/driver/safetyCarControls.js +35 -0
  105. package/src/simulation/driver/trafficScan.js +51 -0
  106. package/src/simulation/driverController.js +4 -619
  107. package/src/simulation/participants/participantInteractions.js +120 -0
  108. package/src/simulation/pit/pitFlow.js +239 -0
  109. package/src/simulation/pit/pitIntent.js +92 -0
  110. package/src/simulation/pit/pitKinematics.js +24 -0
  111. package/src/simulation/pit/pitOccupancy.js +47 -0
  112. package/src/simulation/pit/pitPenaltyService.js +71 -0
  113. package/src/simulation/pit/pitQueue.js +60 -0
  114. package/src/simulation/pit/pitRouteMovement.js +167 -0
  115. package/src/simulation/pit/pitRouting.js +319 -0
  116. package/src/simulation/pit/pitService.js +43 -0
  117. package/src/simulation/pit/pitServiceConstants.js +21 -0
  118. package/src/simulation/pit/pitServiceProfile.js +45 -0
  119. package/src/simulation/pit/pitSnapshots.js +61 -0
  120. package/src/simulation/pit/pitState.js +113 -0
  121. package/src/simulation/pit/pitTireService.js +94 -0
  122. package/src/simulation/race/classification.js +104 -0
  123. package/src/simulation/race/gridPenalties.js +37 -0
  124. package/src/simulation/race/raceControlState.js +38 -0
  125. package/src/simulation/race/raceDistance.js +32 -0
  126. package/src/simulation/race/raceFinish.js +65 -0
  127. package/src/simulation/race/raceLifecycle.js +67 -0
  128. package/src/simulation/race/raceOrder.js +113 -0
  129. package/src/simulation/race/raceProgress.js +129 -0
  130. package/src/simulation/race/raceSetup.js +93 -0
  131. package/src/simulation/race/raceStep.js +80 -0
  132. package/src/simulation/race/redFlag.js +15 -0
  133. package/src/simulation/race/retirements.js +44 -0
  134. package/src/simulation/race/safetyCar.js +92 -0
  135. package/src/simulation/race/stalledDnf.js +77 -0
  136. package/src/simulation/raceSimulation.js +231 -2817
  137. package/src/simulation/replay/replayGhosts.js +128 -0
  138. package/src/simulation/rules/moduleConfig.js +72 -0
  139. package/src/simulation/rules/penaltyConfig.js +99 -0
  140. package/src/simulation/rules/penaltyEvents.js +18 -0
  141. package/src/simulation/rules/penaltyStats.js +26 -0
  142. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  143. package/src/simulation/rules/ruleDefaults.js +85 -0
  144. package/src/simulation/rules/rulesReview.js +144 -0
  145. package/src/simulation/rules/rulesetPresets.js +47 -0
  146. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  147. package/src/simulation/rulesConfig.js +5 -323
  148. package/src/simulation/snapshots/raceSnapshots.js +193 -0
  149. package/src/simulation/timing/drsTiming.js +87 -0
  150. package/src/simulation/timing/gapEstimation.js +38 -0
  151. package/src/simulation/timing/lapTelemetry.js +182 -0
  152. package/src/simulation/timing/raceTiming.js +15 -0
  153. package/src/simulation/timing/sectorPerformance.js +60 -0
  154. package/src/simulation/timing/timingConstants.js +5 -0
  155. package/src/simulation/timing/timingHistory.js +46 -0
  156. package/src/simulation/timing/timingLines.js +79 -0
  157. package/src/simulation/track/centerlineSpline.js +59 -0
  158. package/src/simulation/track/drsZones.js +67 -0
  159. package/src/simulation/track/pitLaneAccess.js +103 -0
  160. package/src/simulation/track/pitLaneLayout.js +395 -0
  161. package/src/simulation/track/pitLaneState.js +160 -0
  162. package/src/simulation/track/proceduralCenterline.js +237 -0
  163. package/src/simulation/track/sampleGeometry.js +49 -0
  164. package/src/simulation/track/spatialQueries.js +154 -0
  165. package/src/simulation/track/startStraight.js +141 -0
  166. package/src/simulation/track/trackConstants.js +109 -0
  167. package/src/simulation/track/trackGenerationOptions.js +283 -0
  168. package/src/simulation/track/trackMath.js +241 -0
  169. package/src/simulation/track/trackModel.js +240 -0
  170. package/src/simulation/track/trackQueryGrid.js +94 -0
  171. package/src/simulation/track/trackQueryIndex.js +659 -0
  172. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  173. package/src/simulation/track/trackQueryScratch.js +46 -0
  174. package/src/simulation/track/trackQueryStats.js +28 -0
  175. package/src/simulation/track/trackSectors.js +19 -0
  176. package/src/simulation/track/trackStatePolicy.js +24 -0
  177. package/src/simulation/track/trackValidation.js +98 -0
  178. package/src/simulation/trackModel.js +1 -1706
  179. package/src/simulation/vehicle/contactResolution.js +181 -0
  180. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  181. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  182. package/src/simulation/vehicle/runoffResponse.js +87 -0
  183. package/src/simulation/vehicle/surfacePriority.js +28 -0
  184. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  185. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  186. package/src/simulation/vehicle/vehiclePhysics.js +364 -0
  187. package/src/simulation/vehicle/vehicleSnapshots.js +327 -0
  188. package/src/simulation/vehicle/vehicleState.js +220 -0
  189. package/src/simulation/vehicle/wheelSurface.js +152 -0
  190. package/src/simulation/vehicleGeometry.js +1 -247
  191. package/src/simulation/vehiclePhysics.js +1 -136
  192. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  193. package/src/simulation/wheelSurface.js +1 -338
  194. package/src/styles.css +42 -0
  195. package/src/ui/cameraControlsTemplate.js +20 -0
  196. package/src/ui/carOverviewTemplate.js +33 -0
  197. package/src/ui/componentTemplates.js +14 -360
  198. package/src/ui/raceCanvasTemplate.js +69 -0
  199. package/src/ui/raceControlsTemplate.js +36 -0
  200. package/src/ui/shellTemplate.js +5 -4
  201. package/src/ui/telemetryDrawerTemplate.js +55 -0
  202. package/src/ui/telemetryTemplates.js +136 -0
  203. package/src/ui/timingTowerTemplate.js +34 -0
@@ -1,96 +1,65 @@
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
+ getTimingOrderKey,
20
+ getTimingPenaltyKey,
21
+ renderTimingTower,
22
+ syncTimingGapModeControls,
23
+ } from './readouts/timingTowerRenderer.js';
24
+ import { renderCarDriverOverview } from './readouts/carOverviewRenderer.js';
25
+ import { renderLapTelemetry, renderTelemetryReadouts } from './readouts/telemetryRenderer.js';
26
+ import {
27
+ renderRaceFinish,
28
+ renderRaceStatusReadouts,
29
+ renderStartLights,
30
+ } from './readouts/raceStatusRenderer.js';
31
+ import {
32
+ updateCameraControlButtons,
33
+ updateModeButtons,
34
+ updateToggleButtons,
35
+ } from './readouts/controlStateRenderer.js';
36
+ import {
37
+ createPenaltyStewardMessage,
38
+ createWarningStewardMessage,
39
+ renderActiveStewardMessage,
40
+ updateStewardMessageState,
41
+ } from './banners/stewardMessageController.js';
42
+ import {
43
+ hideRaceDataPanel,
44
+ getProjectRadioQuote,
45
+ RACE_DATA_SELECTED_VISIBLE_MS,
46
+ RADIO_SCHEDULE_CATCHUP_LIMIT_MS,
47
+ renderProjectRadio,
48
+ renderRaceData,
49
+ resetRaceDataBannerState,
50
+ scheduleRadioBreak,
51
+ scheduleRadioPopup,
52
+ shouldAutoHideActiveRaceData,
53
+ getNextRadioBreakTime,
54
+ } from './banners/raceDataBannerController.js';
55
+ import { runFrameLoopTick } from './runtime/frameLoop.js';
56
+ import { observeRuntimeVisibility, syncRuntimeTicker } from './runtime/runtimeVisibility.js';
57
+ import { TARGET_FRAME_MS, domUpdateIntervalForSpeed, timingUpdateIntervalForSpeed } from './runtime/runtimeTiming.js';
58
+ import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
59
+
27
60
  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
61
  const DRS_DRAG_REDUCTION_PERCENT = 58;
74
62
 
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
63
  function createMountTrackSeed() {
95
64
  const values = new Uint32Array(1);
96
65
  try {
@@ -101,199 +70,25 @@ function createMountTrackSeed() {
101
70
  return (values[0] || Math.floor(Date.now() + performance.now() * 1000)) >>> 0;
102
71
  }
103
72
 
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
73
  function hasOwnOption(options, key) {
266
74
  return Object.hasOwn(options ?? {}, key);
267
75
  }
268
76
 
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
77
  function expertOptionsChanged(nextExpertOptions, currentExpertOptions) {
294
78
  return nextExpertOptions !== currentExpertOptions;
295
79
  }
296
80
 
81
+ function withSelectedExpertSensorDriver(visualizeSensors, selectedDriverId) {
82
+ if (!visualizeSensors) return visualizeSensors;
83
+ if (visualizeSensors === true) return { rays: true, selectedDriverId };
84
+ if (typeof visualizeSensors !== 'object') return visualizeSensors;
85
+ if (visualizeSensors.drivers === 'all' || Array.isArray(visualizeSensors.drivers)) return visualizeSensors;
86
+ return {
87
+ ...visualizeSensors,
88
+ selectedDriverId,
89
+ };
90
+ }
91
+
297
92
  function assetSetsEqual(first = {}, second = {}) {
298
93
  const firstTrackTextures = first.trackTextures ?? {};
299
94
  const secondTrackTextures = second.trackTextures ?? {};
@@ -317,7 +112,7 @@ export class F1SimulatorApp {
317
112
  this.abortController = new AbortController();
318
113
  this.resizeHandler = null;
319
114
  this.layoutResizeObserver = null;
320
- this.cameraSafeAreaCache = null;
115
+ this.cameraController = null;
321
116
  this.visibilityChangeHandler = null;
322
117
  this.visibilityObserver = null;
323
118
  this.tickerCallback = null;
@@ -331,12 +126,22 @@ export class F1SimulatorApp {
331
126
  this.trailLayer = null;
332
127
  this.sensorLayer = null;
333
128
  this.pitLaneStatusLayer = null;
129
+ this.replayGhostLayer = null;
334
130
  this.carLayer = null;
335
131
  this.textures = {};
336
132
  this.carSprites = new Map();
337
133
  this.carHitAreas = new Map();
338
134
  this.serviceCountdownLabels = new Map();
135
+ this.carRenderer = new CarRenderer({
136
+ carSprites: this.carSprites,
137
+ carHitAreas: this.carHitAreas,
138
+ serviceCountdownLabels: this.serviceCountdownLabels,
139
+ onSelectCar: (driverId) => this.selectCar(driverId, { focus: true }),
140
+ });
339
141
  this.drsTrails = new Map();
142
+ this.drsTrailRenderer = new DrsTrailRenderer({ trails: this.drsTrails });
143
+ this.replayGhostRenderer = new ReplayGhostRenderer();
144
+ this.pitLaneStatusRenderer = new PitLaneStatusRenderer();
340
145
  this.trackSeed = options.trackSeed ?? createMountTrackSeed();
341
146
  this.selectedId = this.drivers[0]?.id ?? null;
342
147
  this.raceDataBannerConfig = options.ui?.raceDataBanners ?? { initial: 'project', enabled: ['project', 'radio'] };
@@ -357,16 +162,12 @@ export class F1SimulatorApp {
357
162
  quoteIndex: 0,
358
163
  };
359
164
  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
- };
165
+ this.cameraController = new CameraController({
166
+ canvasHost: this.canvasHost,
167
+ readouts: this.readouts,
168
+ initialMode: options.initialCameraMode,
169
+ });
170
+ this.camera = this.cameraController.camera;
370
171
  this.overviewMode = 'vehicle';
371
172
  this.simulationSpeed = 1;
372
173
  this.accumulator = 0;
@@ -378,9 +179,6 @@ export class F1SimulatorApp {
378
179
  this.lastTimingPenaltyKey = '';
379
180
  this.lastTimingOrderKey = '';
380
181
  this.lastTimingMarkup = null;
381
- this.lastPitLaneStatusRenderKey = null;
382
- this.pitCameraBoundsCache = null;
383
- this.trackCameraBoundsCache = null;
384
182
  this.lastOverviewRenderKey = null;
385
183
  this.lastFinishClassificationMarkup = null;
386
184
  this.runtimeViewportVisible = true;
@@ -391,6 +189,7 @@ export class F1SimulatorApp {
391
189
  current: 0,
392
190
  lastSample: this.lastTime,
393
191
  };
192
+ this.renderSnapshotBuffer = {};
394
193
  this.lastLeaderLap = null;
395
194
  this.emittedRaceEventKeys = new Set();
396
195
  this.raceFinishEmitted = false;
@@ -423,6 +222,7 @@ export class F1SimulatorApp {
423
222
  this.trailLayer = new Graphics();
424
223
  this.sensorLayer = new Graphics();
425
224
  this.pitLaneStatusLayer = new Graphics();
225
+ this.replayGhostLayer = new Container();
426
226
  this.carLayer = new Container();
427
227
  this.app.stage.addChild(this.worldLayer);
428
228
 
@@ -434,6 +234,7 @@ export class F1SimulatorApp {
434
234
  this.trailLayer,
435
235
  this.pitLaneStatusLayer,
436
236
  this.sensorLayer,
237
+ this.replayGhostLayer,
437
238
  this.carLayer,
438
239
  );
439
240
  this.createCars();
@@ -452,7 +253,9 @@ export class F1SimulatorApp {
452
253
  this.resizeHandler = () => this.applyCamera(this.sim.snapshotRender?.() ?? this.sim.snapshot());
453
254
  window.addEventListener('resize', this.resizeHandler, { signal: this.abortController.signal });
454
255
  this.observeLayoutResize();
455
- if (!this.expertMode) {
256
+ if (this.expertMode) {
257
+ this.app.ticker.stop?.();
258
+ } else {
456
259
  this.resetFrameClock();
457
260
  this.tickerCallback = () => this.tick();
458
261
  this.app.ticker.add(this.tickerCallback);
@@ -494,167 +297,29 @@ export class F1SimulatorApp {
494
297
  return createRaceSimulation({
495
298
  seed: options.seed,
496
299
  trackSeed: options.trackSeed ?? this.trackSeed,
300
+ trackGeneration: options.trackGeneration,
301
+ physicsMode: options.physicsMode,
497
302
  drivers: options.drivers ?? this.drivers,
498
303
  totalLaps: options.totalLaps,
499
304
  rules: options.rules,
305
+ participantInteractions: options.participantInteractions,
306
+ replayGhosts: options.replayGhosts,
307
+ trackQueryIndex: options.trackQueryIndex,
308
+ warmup: normalizeWarmupOptions(options.warmup, 'browser'),
500
309
  });
501
310
  }
502
311
 
503
312
  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
- }));
313
+ this.textures = await loadAppTextures(this.assets);
530
314
  }
531
315
 
532
316
  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
317
  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);
586
- });
587
- }
588
-
589
- createRawCarGeometryGraphic(driver) {
590
- const graphic = new Graphics();
591
- const tint = colorToTint(driver.color);
592
- const wheelLong = VEHICLE_GEOMETRY.wheelLongitudinalOffset;
593
- const wheelLat = VEHICLE_GEOMETRY.wheelLateralOffset;
594
- const wheelLength = VEHICLE_GEOMETRY.wheelLength;
595
- const wheelWidth = VEHICLE_GEOMETRY.wheelWidth;
596
-
597
- graphic
598
- .rect(
599
- -VEHICLE_GEOMETRY.bodyLength / 2,
600
- -VEHICLE_GEOMETRY.bodyWidth / 2,
601
- VEHICLE_GEOMETRY.bodyLength,
602
- VEHICLE_GEOMETRY.bodyWidth,
603
- )
604
- .fill({ color: tint, alpha: 0.42 })
605
- .stroke({ width: 2.2, color: 0xf8fafc, alpha: 0.95 });
606
-
607
- [
608
- [wheelLong, -wheelLat],
609
- [wheelLong, wheelLat],
610
- [-wheelLong, -wheelLat],
611
- [-wheelLong, wheelLat],
612
- ].forEach(([x, y]) => {
613
- graphic
614
- .rect(x - wheelLength / 2, y - wheelWidth / 2, wheelLength, wheelWidth)
615
- .fill({ color: 0x111827, alpha: 0.95 })
616
- .stroke({ width: 1.5, color: tint, alpha: 1 });
617
- });
618
-
619
- const noseMarkerLength = metersToSimUnits(0.75);
620
- graphic
621
- .moveTo(VEHICLE_GEOMETRY.bodyLength / 2 - noseMarkerLength, 0)
622
- .lineTo(VEHICLE_GEOMETRY.bodyLength / 2 + noseMarkerLength, 0)
623
- .stroke({ width: 2, color: 0xf1c65b, alpha: 0.95 });
624
- return graphic;
625
- }
626
-
627
- createServiceCountdownLabel() {
628
- const container = new Container();
629
- const background = new Graphics();
630
- const text = new Text({
631
- text: '0s',
632
- style: {
633
- fill: 0xffffff,
634
- fontFamily: 'Arial, sans-serif',
635
- fontSize: 16,
636
- fontWeight: '900',
637
- align: 'center',
638
- },
318
+ this.carRenderer.createCars({
319
+ drivers: this.drivers,
320
+ textures: this.textures,
321
+ carLayer: this.carLayer,
639
322
  });
640
- text.anchor.set(0.5);
641
- text.resolution = 2;
642
- container.addChild(background, text);
643
- container.labelBackground = background;
644
- container.labelText = text;
645
- this.applyServiceCountdownTone(container, 'pit');
646
- return container;
647
- }
648
-
649
- applyServiceCountdownTone(label, tone) {
650
- const style = SERVICE_COUNTDOWN_LABEL_STYLES[tone] ?? SERVICE_COUNTDOWN_LABEL_STYLES.pit;
651
- if (label.serviceTone === tone) return;
652
- label.labelBackground?.clear?.();
653
- label.labelBackground?.roundRect(-28, -14, 56, 28, 4)
654
- .fill({ color: style.background, alpha: 0.92 })
655
- .stroke({ width: 2, color: style.stroke, alpha: 0.95 });
656
- if (label.labelText?.style) label.labelText.style.fill = style.fill;
657
- label.serviceTone = tone;
658
323
  }
659
324
 
660
325
  bindControls() {
@@ -752,18 +417,13 @@ export class F1SimulatorApp {
752
417
 
753
418
  setCameraMode(mode) {
754
419
  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];
420
+ if (!this.cameraController.setMode(mode, snapshot)) return false;
760
421
  this.updateCameraControls(snapshot);
761
422
  return true;
762
423
  }
763
424
 
764
425
  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);
426
+ this.cameraController.adjustZoom(direction);
767
427
  this.updateCameraControls();
768
428
  return this.camera.zoom;
769
429
  }
@@ -834,7 +494,7 @@ export class F1SimulatorApp {
834
494
  }
835
495
 
836
496
  renderInitialFrame(snapshot) {
837
- const renderSnapshot = createRenderSnapshot(snapshot, 0);
497
+ const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
838
498
  this.resizeRendererToCanvasHost();
839
499
  this.applyCamera(renderSnapshot, { immediate: true });
840
500
  this.renderDrsTrails(renderSnapshot);
@@ -847,7 +507,11 @@ export class F1SimulatorApp {
847
507
  this.resizeRendererToCanvasHost();
848
508
  const snapshot = this.sim?.snapshotRender?.() ?? this.sim?.snapshot?.();
849
509
  if (!snapshot) return;
850
- const renderSnapshot = createRenderSnapshot(snapshot, clamp(this.accumulator / FIXED_STEP, 0, 1));
510
+ const renderSnapshot = interpolateRenderSnapshotInto(
511
+ this.renderSnapshotBuffer,
512
+ snapshot,
513
+ clamp(this.accumulator / FIXED_STEP, 0, 1),
514
+ );
851
515
  this.applyCamera(renderSnapshot);
852
516
  if (render) {
853
517
  this.renderDrsTrails(renderSnapshot);
@@ -881,53 +545,15 @@ export class F1SimulatorApp {
881
545
  }
882
546
 
883
547
  invalidateCameraSafeArea() {
884
- this.cameraSafeAreaCache = null;
548
+ this.cameraController.invalidateSafeArea();
885
549
  }
886
550
 
887
551
  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();
552
+ observeRuntimeVisibility(this);
915
553
  }
916
554
 
917
555
  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
- }
556
+ syncRuntimeTicker(this);
931
557
  }
932
558
 
933
559
  resetFrameClock(now = performance.now()) {
@@ -940,612 +566,107 @@ export class F1SimulatorApp {
940
566
  }
941
567
 
942
568
  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
- }
569
+ runFrameLoopTick(this);
991
570
  }
992
571
 
993
- renderExpertFrame(snapshot = this.sim?.snapshot(), { observation } = {}) {
572
+ renderExpertFrame(snapshot = this.sim?.snapshot(), { forceDomUpdate = false, observation } = {}) {
994
573
  if (!snapshot) return;
995
- const renderSnapshot = createRenderSnapshot(snapshot, 0);
574
+ const now = performance.now();
575
+ const renderSnapshot = interpolateRenderSnapshotInto(this.renderSnapshotBuffer, snapshot, 0);
996
576
  this.applyCamera(renderSnapshot);
997
577
  this.renderDrsTrails(renderSnapshot);
998
578
  this.renderExpertSensorRays(renderSnapshot, observation);
999
579
  this.renderPitLaneStatus(renderSnapshot);
1000
580
  this.renderCars(renderSnapshot);
1001
- this.updateDom(snapshot, { emitLifecycle: false });
1002
- this.lastDomUpdateTime = performance.now();
581
+ this.app?.render?.();
582
+ if (forceDomUpdate || now - this.lastDomUpdateTime >= domUpdateIntervalForSpeed(this.simulationSpeed)) {
583
+ this.updateDom(snapshot, { emitLifecycle: false });
584
+ this.lastDomUpdateTime = now;
585
+ }
1003
586
  }
1004
587
 
1005
588
  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;
589
+ this.pitLaneStatusRenderer.reset();
590
+ this.cameraController.invalidateTrackCaches();
1012
591
  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);
592
+ renderTrackSurface({
593
+ drsLayer: this.drsLayer,
594
+ sensorLayer: this.sensorLayer,
595
+ pitLaneStatusLayer: this.pitLaneStatusLayer,
596
+ trackAsset: this.trackAsset,
597
+ snapshot,
1028
598
  });
1029
599
  }
1030
600
 
1031
601
  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 });
602
+ this.pitLaneStatusRenderer.render(snapshot, this.pitLaneStatusLayer);
1068
603
  }
1069
604
 
1070
605
  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);
606
+ this.replayGhostRenderer.render(snapshot, {
607
+ textures: this.textures,
608
+ replayGhostLayer: this.replayGhostLayer,
609
+ });
610
+ this.carRenderer.renderCars(snapshot, {
611
+ textures: this.textures,
612
+ carLayer: this.carLayer,
1095
613
  });
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
614
  }
1118
615
 
1119
616
  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
- }
617
+ this.carRenderer.renderServiceCountdownLabel(car);
1140
618
  }
1141
619
 
1142
620
  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
- });
621
+ renderExpertSensorRays({
622
+ snapshot,
623
+ observation,
624
+ sensorLayer: this.sensorLayer,
625
+ expertMode: this.expertMode,
626
+ expertOptions: {
627
+ ...this.options.expert,
628
+ visualizeSensors: withSelectedExpertSensorDriver(this.options.expert?.visualizeSensors, this.selectedId),
629
+ },
1193
630
  });
1194
631
  }
1195
632
 
1196
633
  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
- });
634
+ this.drsTrailRenderer.render(snapshot, this.trailLayer);
1250
635
  }
1251
636
 
1252
637
  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
- );
638
+ this.cameraController.applyToWorldLayer(this.worldLayer, snapshot, {
639
+ immediate,
640
+ selectedId: this.selectedId,
641
+ });
1279
642
  }
1280
643
 
1281
644
  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;
645
+ return this.cameraController.getSafeArea(width);
1317
646
  }
1318
647
 
1319
648
  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
- };
649
+ return this.cameraController.getFrame(snapshot, width, height, baseScale, safeArea, this.selectedId);
1393
650
  }
1394
651
 
1395
652
  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 };
653
+ return this.cameraController.getTarget(snapshot, this.selectedId);
1410
654
  }
1411
655
 
1412
656
  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
- };
657
+ return this.cameraController.getTrackBounds(track);
1464
658
  }
1465
659
 
1466
660
  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;
661
+ return this.cameraController.getBoundsFitScale(bounds, height, safeArea);
1472
662
  }
1473
663
 
1474
664
  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
- };
665
+ return this.cameraController.getPitBounds(pitLane);
1517
666
  }
1518
667
 
1519
668
  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
- };
669
+ return this.cameraController.getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale);
1549
670
  }
1550
671
 
1551
672
  updateDom(snapshot, { emitLifecycle = true } = {}) {
@@ -1566,65 +687,18 @@ export class F1SimulatorApp {
1566
687
  }
1567
688
  this.updateStewardMessageState(snapshot, now);
1568
689
 
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
690
  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);
691
+ this.lastFinishClassificationMarkup = renderRaceStatusReadouts({
692
+ readouts: this.readouts,
693
+ startLightNodes: this.startLightNodes,
694
+ snapshot,
695
+ camera: this.camera,
696
+ fps: this.fps,
697
+ activeDrs,
698
+ contactCount,
699
+ driverById: this.driverById,
700
+ lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
701
+ });
1628
702
 
1629
703
  this.updateCameraControls(snapshot);
1630
704
  this.syncTimingGapModeControls();
@@ -1692,38 +766,12 @@ export class F1SimulatorApp {
1692
766
  }
1693
767
 
1694
768
  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));
769
+ this.lastFinishClassificationMarkup = renderRaceFinish({
770
+ readouts: this.readouts,
771
+ snapshot,
772
+ driverById: this.driverById,
773
+ lastFinishClassificationMarkup: this.lastFinishClassificationMarkup,
774
+ });
1727
775
  }
1728
776
 
1729
777
  sampleFps(now) {
@@ -1736,24 +784,7 @@ export class F1SimulatorApp {
1736
784
  }
1737
785
 
1738
786
  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
- }
787
+ renderStartLights(this.readouts, this.startLightNodes, raceControl);
1757
788
  }
1758
789
 
1759
790
  selectCar(id, { focus = false } = {}) {
@@ -1772,363 +803,83 @@ export class F1SimulatorApp {
1772
803
  }
1773
804
 
1774
805
  renderTiming(cars, raceMode, penalties = []) {
1775
- if (!this.timingList) return;
1776
806
  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
- }
807
+ this.lastTimingMarkup = renderTimingTower({
808
+ timingList: this.timingList,
809
+ cars,
810
+ raceMode,
811
+ penalties,
812
+ driverById: this.driverById,
813
+ selectedId: this.selectedId,
814
+ timingGapMode: this.timingGapMode,
815
+ timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
816
+ lastTimingMarkup: this.lastTimingMarkup,
817
+ });
1820
818
  }
1821
819
 
1822
820
  getTimingOrderKey(cars = []) {
1823
- return cars.map((car) => [
1824
- car.id,
1825
- car.rank,
1826
- car.lap,
1827
- this.formatTimingGap(car),
1828
- car.penaltySeconds ?? 0,
1829
- car.classifiedRank ?? 0,
1830
- car.finishRank ?? 0,
1831
- car.raceStatus ?? car.status ?? '',
1832
- car.wavedFlag ? 1 : 0,
1833
- car.tire ?? '',
1834
- ].join(':')).join('|');
1835
- }
1836
-
1837
- isWavedFlagCar(car) {
1838
- return Boolean(car?.wavedFlag || car?.raceStatus === 'waved-flag' || car?.status === 'waved-flag');
1839
- }
1840
-
1841
- getPenaltyByDriver(penalties = []) {
1842
- const byDriver = new Map();
1843
- penalties.forEach((penalty) => {
1844
- if (!penalty?.driverId || byDriver.has(penalty.driverId)) return;
1845
- if (!hasVisiblePenaltyEffect(penalty)) return;
1846
- byDriver.set(penalty.driverId, penalty);
1847
- });
1848
- return byDriver;
821
+ return getTimingOrderKey(cars, { timingGapMode: this.timingGapMode });
1849
822
  }
1850
823
 
1851
824
  getTimingPenaltyKey(penalties = []) {
1852
- if (!this.timingPenaltyBadgesEnabled) return '';
1853
- return penalties
1854
- .filter(hasVisiblePenaltyEffect)
1855
- .map((penalty) => [
1856
- penalty.id,
1857
- penalty.driverId,
1858
- penalty.status,
1859
- penalty.penaltySeconds,
1860
- penalty.pendingPenaltySeconds,
1861
- penalty.positionDrop,
1862
- penalty.gridDrop,
1863
- penalty.disqualified,
1864
- ].join(':'))
1865
- .join('|');
1866
- }
1867
-
1868
- formatTimingGap(car) {
1869
- const lapValue = this.timingGapMode === 'leader'
1870
- ? car.leaderGapLaps
1871
- : (car.intervalAheadLaps ?? car.gapAheadLaps);
1872
- if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
1873
-
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)}` : '--';
1878
- }
1879
-
1880
- formatLapGap(laps) {
1881
- const wholeLaps = Math.max(0, Math.floor(laps));
1882
- return `+${wholeLaps}`;
1883
- }
1884
-
1885
- formatTelemetryGap(car, mode) {
1886
- const lapValue = mode === 'leader'
1887
- ? car.leaderGapLaps
1888
- : (car.intervalAheadLaps ?? car.gapAheadLaps);
1889
- if (Number.isFinite(lapValue) && lapValue > 0) return this.formatLapGap(lapValue);
1890
-
1891
- const seconds = mode === 'leader'
1892
- ? car.leaderGapSeconds
1893
- : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
1894
- return Number.isFinite(seconds) ? `${seconds.toFixed(2)}s` : '--';
825
+ return getTimingPenaltyKey(penalties, {
826
+ timingPenaltyBadgesEnabled: this.timingPenaltyBadgesEnabled,
827
+ });
1895
828
  }
1896
829
 
1897
830
  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);
831
+ syncTimingGapModeControls({
832
+ readouts: this.readouts,
833
+ buttons: this.timingGapModeButtons,
834
+ timingGapMode: this.timingGapMode,
1904
835
  });
1905
836
  }
1906
837
 
1907
838
  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;
1918
- });
1919
- this.readouts.telemetrySectorBanners?.forEach?.((node) => {
1920
- node.style.setProperty('--driver-color', car.color);
839
+ renderTelemetryReadouts({
840
+ readouts: this.readouts,
841
+ car,
842
+ driverById: this.driverById,
1921
843
  });
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
844
  this.renderCarDriverOverview(car);
1943
845
  this.renderLapTelemetry(car.lapTelemetry);
1944
846
  }
1945
847
 
1946
848
  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
- });
2015
- }
2016
-
2017
- 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
- ];
849
+ renderLapTelemetry(this.readouts, telemetry);
2042
850
  }
2043
851
 
2044
852
  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]),
2070
- });
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);
853
+ const previousKey = this.lastOverviewRenderKey;
854
+ this.lastOverviewRenderKey = renderCarDriverOverview({
855
+ readouts: this.readouts,
856
+ car,
857
+ driverById: this.driverById,
858
+ assets: this.assets,
859
+ overviewMode: this.overviewMode,
860
+ lastOverviewRenderKey: this.lastOverviewRenderKey,
2096
861
  });
2097
- this.updateOverviewModeButtons();
862
+ if (this.lastOverviewRenderKey !== previousKey) this.updateOverviewModeButtons();
2098
863
  }
2099
864
 
2100
865
  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
- }
866
+ renderRaceData({
867
+ car,
868
+ drivers: this.drivers,
869
+ readouts: this.readouts,
870
+ options: {
871
+ ...this.options,
872
+ isRaceDataBannerEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
873
+ },
874
+ });
2121
875
  }
2122
876
 
2123
877
  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
- );
878
+ return shouldAutoHideActiveRaceData({
879
+ activeRaceDataId: this.activeRaceDataId,
880
+ options: this.options,
881
+ readouts: this.readouts,
882
+ });
2132
883
  }
2133
884
 
2134
885
  renderProjectRadio(now = performance.now()) {
@@ -2143,26 +894,14 @@ export class F1SimulatorApp {
2143
894
  return;
2144
895
  }
2145
896
 
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;
897
+ renderProjectRadio({
898
+ readouts: this.readouts,
899
+ radio: this.getProjectRadioQuote(),
900
+ });
2158
901
  }
2159
902
 
2160
903
  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;
904
+ hideRaceDataPanel(this.readouts);
2166
905
  }
2167
906
 
2168
907
  dismissRaceDataPanel(now = performance.now()) {
@@ -2174,85 +913,33 @@ export class F1SimulatorApp {
2174
913
  }
2175
914
 
2176
915
  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
- };
916
+ const state = updateStewardMessageState({
917
+ snapshot,
918
+ now,
919
+ state: {
920
+ activeStewardMessage: this.activeStewardMessage,
921
+ lastPenaltyBannerId: this.lastPenaltyBannerId,
922
+ lastStewardMessageKey: this.lastStewardMessageKey,
923
+ },
924
+ driverById: this.driverById,
925
+ penaltyBannerEnabled: this.penaltyBannerEnabled,
926
+ stewardMessageNode: this.readouts.stewardMessage,
927
+ });
928
+ this.activeStewardMessage = state.activeStewardMessage;
929
+ this.lastPenaltyBannerId = state.lastPenaltyBannerId;
930
+ this.lastStewardMessageKey = state.lastStewardMessageKey;
2201
931
  }
2202
932
 
2203
933
  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
- };
934
+ return createPenaltyStewardMessage(penalty, this.driverById);
2218
935
  }
2219
936
 
2220
937
  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
- };
938
+ return createWarningStewardMessage(event, this.driverById);
2239
939
  }
2240
940
 
2241
941
  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);
942
+ renderActiveStewardMessage(this.readouts, this.activeStewardMessage);
2256
943
  }
2257
944
 
2258
945
  updateRadioSchedule(now) {
@@ -2279,26 +966,22 @@ export class F1SimulatorApp {
2279
966
  }
2280
967
 
2281
968
  scheduleRadioBreak(now) {
2282
- this.radioState.visible = false;
2283
- this.radioState.nextChangeAt = this.getNextRadioBreakTime(now);
969
+ scheduleRadioBreak({
970
+ radioState: this.radioState,
971
+ now,
972
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
973
+ nextRandom: () => this.nextRadioRandom(),
974
+ });
2284
975
  }
2285
976
 
2286
977
  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);
978
+ scheduleRadioPopup({
979
+ radioState: this.radioState,
980
+ now,
981
+ drivers: this.drivers,
982
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
983
+ nextRandom: () => this.nextRadioRandom(),
984
+ });
2302
985
  }
2303
986
 
2304
987
  nextRadioRandom() {
@@ -2307,24 +990,22 @@ export class F1SimulatorApp {
2307
990
  }
2308
991
 
2309
992
  getNextRadioBreakTime(now) {
2310
- return this.isRaceDataBannerEnabled('radio')
2311
- ? now + this.randomRadioRange(RADIO_BREAK_MIN_MS, RADIO_BREAK_MAX_MS)
2312
- : Number.POSITIVE_INFINITY;
993
+ return getNextRadioBreakTime({
994
+ now,
995
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
996
+ nextRandom: () => this.nextRadioRandom(),
997
+ });
2313
998
  }
2314
999
 
2315
1000
  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;
1001
+ resetRaceDataBannerState({
1002
+ state: this,
1003
+ now,
1004
+ initialMode: this.raceDataBannerConfig.initial ?? 'project',
1005
+ selectedId: this.selectedId,
1006
+ isEnabled: (kind) => this.isRaceDataBannerEnabled(kind),
1007
+ nextRandom: () => this.nextRadioRandom(),
1008
+ });
2328
1009
  }
2329
1010
 
2330
1011
  isRaceDataBannerEnabled(kind) {
@@ -2332,66 +1013,41 @@ export class F1SimulatorApp {
2332
1013
  }
2333
1014
 
2334
1015
  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
- };
1016
+ return getProjectRadioQuote({
1017
+ drivers: this.drivers,
1018
+ radioState: this.radioState,
1019
+ });
2343
1020
  }
2344
1021
 
2345
1022
  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));
1023
+ updateCameraControlButtons({
1024
+ camera: this.camera,
1025
+ cameraButtons: this.cameraButtons,
1026
+ snapshot,
1027
+ hasPitCamera: (currentSnapshot) => this.hasPitCamera(currentSnapshot),
1028
+ isCameraModeAvailable: (mode, currentSnapshot) => this.isCameraModeAvailable(mode, currentSnapshot),
2363
1029
  });
2364
1030
  this.updateBannerMuteControls();
2365
1031
  }
2366
1032
 
2367
1033
  updateBannerMuteControls() {
2368
- this.bannerMuteButtons?.forEach((button) => {
2369
- button.classList.toggle('is-active', this.raceDataBannersMuted);
2370
- button.setAttribute('aria-pressed', String(this.raceDataBannersMuted));
2371
- });
1034
+ updateToggleButtons(this.bannerMuteButtons, this.raceDataBannersMuted);
2372
1035
  }
2373
1036
 
2374
1037
  hasPitCamera(snapshot = this.sim?.snapshot?.()) {
2375
- return Boolean(snapshot?.track?.pitLane?.enabled);
1038
+ return this.cameraController.hasPitCamera(snapshot);
2376
1039
  }
2377
1040
 
2378
1041
  isCameraModeAvailable(mode, snapshot = this.sim?.snapshot?.()) {
2379
- return mode !== 'pit' || this.hasPitCamera(snapshot);
1042
+ return this.cameraController.isModeAvailable(mode, snapshot);
2380
1043
  }
2381
1044
 
2382
1045
  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
- });
1046
+ updateModeButtons(this.overviewModeButtons, this.overviewMode, 'overviewMode');
2388
1047
  }
2389
1048
 
2390
1049
  syncSafetyCarControls(active) {
2391
- this.safetyButtons.forEach((button) => {
2392
- button.classList.toggle('is-active', active);
2393
- button.setAttribute('aria-pressed', String(active));
2394
- });
1050
+ updateToggleButtons(this.safetyButtons, active);
2395
1051
  }
2396
1052
 
2397
1053
  applyExpertOptions(nextOptions = {}) {
@@ -2434,6 +1090,8 @@ export class F1SimulatorApp {
2434
1090
  if (hasOwnOption(nextOptions, 'expert') && expertOptionsChanged(nextOptions.expert, this.options.expert)) {
2435
1091
  throw new Error('PaddockJS restart() does not support changing expert mode. Destroy and mount a new simulator with the new expert options.');
2436
1092
  }
1093
+ this.expert?.destroy?.();
1094
+ this.expert = null;
2437
1095
  this.applyExpertOptions(nextOptions);
2438
1096
  this.raceDataBannerConfig = this.options.ui?.raceDataBanners ?? this.raceDataBannerConfig;
2439
1097
  this.penaltyBannerEnabled = Boolean(this.options.ui?.penaltyBanners);
@@ -2526,6 +1184,9 @@ export class F1SimulatorApp {
2526
1184
  if (this.app && this.tickerCallback) {
2527
1185
  this.app.ticker.remove(this.tickerCallback);
2528
1186
  }
1187
+ this.expert?.destroy?.();
1188
+ this.expert = null;
1189
+ this.replayGhostRenderer.destroy();
2529
1190
  this.app?.destroy(true, { children: true, texture: false });
2530
1191
  this.app = null;
2531
1192
  this.carSprites.clear();