@inventure71/paddockjs 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
@@ -0,0 +1,176 @@
1
+ import { WORLD } from '../../simulation/trackModel.js';
2
+ import { clamp } from '../../simulation/simMath.js';
3
+ import {
4
+ CAMERA_MAX_ZOOM,
5
+ CAMERA_MIN_ZOOM,
6
+ CAR_WORLD_LENGTH,
7
+ PIT_CAMERA_PADDING,
8
+ SHOW_ALL_BOTTOM_RESERVED,
9
+ SHOW_ALL_PADDING,
10
+ SHOW_ALL_TOP_RESERVED,
11
+ TRACK_CAMERA_PADDING,
12
+ } from './cameraConstants.js';
13
+
14
+ export function clampCameraScale(scale, anchorScale, minimumScale = null) {
15
+ const safeAnchor = Number.isFinite(anchorScale) && anchorScale > 0 ? anchorScale : 1;
16
+ const safeMinimum = Number.isFinite(minimumScale) && minimumScale > 0
17
+ ? minimumScale
18
+ : safeAnchor * CAMERA_MIN_ZOOM;
19
+ return clamp(scale, safeMinimum, safeAnchor * CAMERA_MAX_ZOOM);
20
+ }
21
+
22
+ export function getCameraBoundsFitScale(bounds, height, safeArea) {
23
+ if (!bounds) return null;
24
+ const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX);
25
+ const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY);
26
+ const scale = Math.min(safeArea.width / fitWidth, height / fitHeight);
27
+ return Number.isFinite(scale) && scale > 0 ? scale : null;
28
+ }
29
+
30
+ export function getTrackCameraBounds(track) {
31
+ const bounds = {
32
+ minX: Infinity,
33
+ minY: Infinity,
34
+ maxX: -Infinity,
35
+ maxY: -Infinity,
36
+ };
37
+ const includePoint = (point) => {
38
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
39
+ bounds.minX = Math.min(bounds.minX, point.x);
40
+ bounds.minY = Math.min(bounds.minY, point.y);
41
+ bounds.maxX = Math.max(bounds.maxX, point.x);
42
+ bounds.maxY = Math.max(bounds.maxY, point.y);
43
+ };
44
+ const includeBounds = (box) => {
45
+ if (!box) return;
46
+ includePoint({ x: box.minX, y: box.minY });
47
+ includePoint({ x: box.maxX, y: box.maxY });
48
+ };
49
+
50
+ (track?.samples ?? []).forEach(includePoint);
51
+ includeBounds(track?.pitLane?.bounds);
52
+
53
+ const trackEdgePadding = Math.max(
54
+ TRACK_CAMERA_PADDING,
55
+ ((track?.width ?? 0) / 2) + (track?.kerbWidth ?? 0) + (track?.runoffWidth ?? 0),
56
+ );
57
+ return Number.isFinite(bounds.minX)
58
+ ? {
59
+ minX: bounds.minX - trackEdgePadding,
60
+ minY: bounds.minY - trackEdgePadding,
61
+ maxX: bounds.maxX + trackEdgePadding,
62
+ maxY: bounds.maxY + trackEdgePadding,
63
+ }
64
+ : null;
65
+ }
66
+
67
+ export function getTrackCameraTarget(trackBounds) {
68
+ if (!trackBounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
69
+
70
+ return {
71
+ x: (trackBounds.minX + trackBounds.maxX) / 2,
72
+ y: (trackBounds.minY + trackBounds.maxY) / 2,
73
+ };
74
+ }
75
+
76
+ export function getShowAllCameraFrame({ cars, cameraZoom, height, safeArea, screenCenterX, trackFitScale }) {
77
+ const bounds = cars.reduce((box, car) => ({
78
+ minX: Math.min(box.minX, car.x),
79
+ minY: Math.min(box.minY, car.y),
80
+ maxX: Math.max(box.maxX, car.x),
81
+ maxY: Math.max(box.maxY, car.y),
82
+ }), {
83
+ minX: Infinity,
84
+ minY: Infinity,
85
+ maxX: -Infinity,
86
+ maxY: -Infinity,
87
+ });
88
+ const target = {
89
+ x: (bounds.minX + bounds.maxX) / 2,
90
+ y: (bounds.minY + bounds.maxY) / 2,
91
+ };
92
+ const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxX - bounds.minX + SHOW_ALL_PADDING);
93
+ const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, bounds.maxY - bounds.minY + SHOW_ALL_PADDING);
94
+ const safeHeight = Math.max(height * 0.48, height - SHOW_ALL_TOP_RESERVED - SHOW_ALL_BOTTOM_RESERVED);
95
+ const fitScale = Math.min(safeArea.width / fitWidth, safeHeight / fitHeight);
96
+ const scale = clampCameraScale(fitScale * cameraZoom, fitScale, trackFitScale);
97
+ return {
98
+ target,
99
+ scale,
100
+ screenX: screenCenterX,
101
+ screenY: height / 2,
102
+ };
103
+ }
104
+
105
+ export function getPitCameraBounds(pitLane) {
106
+ const bounds = {
107
+ minX: Infinity,
108
+ minY: Infinity,
109
+ maxX: -Infinity,
110
+ maxY: -Infinity,
111
+ };
112
+ const includePoint = (point) => {
113
+ if (!point) return;
114
+ bounds.minX = Math.min(bounds.minX, point.x);
115
+ bounds.minY = Math.min(bounds.minY, point.y);
116
+ bounds.maxX = Math.max(bounds.maxX, point.x);
117
+ bounds.maxY = Math.max(bounds.maxY, point.y);
118
+ };
119
+ const includePoints = (points = []) => {
120
+ points.forEach(includePoint);
121
+ };
122
+
123
+ includePoint(pitLane?.entry?.lanePoint);
124
+ includePoints(pitLane?.mainLane?.points);
125
+ includePoints(pitLane?.workingLane?.points);
126
+ includePoint(pitLane?.exit?.lanePoint);
127
+ (pitLane?.boxes ?? []).forEach((box) => includePoints(box.corners));
128
+ (pitLane?.serviceAreas ?? []).forEach((area) => {
129
+ includePoints(area.corners);
130
+ includePoints(area.queueCorners);
131
+ });
132
+
133
+ return Number.isFinite(bounds.minX) ? bounds : null;
134
+ }
135
+
136
+ export function getPitCameraTarget(pitBounds) {
137
+ if (!pitBounds) return { x: WORLD.width / 2, y: WORLD.height / 2 };
138
+
139
+ return {
140
+ x: (pitBounds.minX + pitBounds.maxX) / 2,
141
+ y: (pitBounds.minY + pitBounds.maxY) / 2,
142
+ };
143
+ }
144
+
145
+ export function getPitCameraFrame({
146
+ pitBounds,
147
+ cameraZoom,
148
+ height,
149
+ baseScale,
150
+ safeArea,
151
+ screenCenterX,
152
+ minimumScale = null,
153
+ }) {
154
+ const target = getPitCameraTarget(pitBounds);
155
+ const presetScale = clampCameraScale(baseScale * cameraZoom, baseScale, minimumScale);
156
+ if (!pitBounds) {
157
+ return {
158
+ target,
159
+ scale: presetScale,
160
+ screenX: screenCenterX,
161
+ screenY: height / 2,
162
+ };
163
+ }
164
+
165
+ const fitWidth = Math.max(CAR_WORLD_LENGTH * 3, pitBounds.maxX - pitBounds.minX + PIT_CAMERA_PADDING);
166
+ const fitHeight = Math.max(CAR_WORLD_LENGTH * 3, pitBounds.maxY - pitBounds.minY + PIT_CAMERA_PADDING);
167
+ const fitScale = Math.min(safeArea.width / fitWidth, height / fitHeight);
168
+ const scale = clampCameraScale(fitScale * cameraZoom, fitScale, minimumScale);
169
+
170
+ return {
171
+ target,
172
+ scale: Number.isFinite(scale) && scale > 0 ? scale : presetScale,
173
+ screenX: screenCenterX,
174
+ screenY: height / 2,
175
+ };
176
+ }
@@ -23,6 +23,10 @@ export function querySimulatorDom(root) {
23
23
  tyres: root.querySelectorAll('[data-telemetry-tyres]'),
24
24
  selectedDrs: root.querySelectorAll('[data-telemetry-drs]'),
25
25
  surface: root.querySelectorAll('[data-telemetry-surface]'),
26
+ grip: root.querySelectorAll('[data-telemetry-grip]'),
27
+ lateralG: root.querySelectorAll('[data-telemetry-lateral-g]'),
28
+ slipAngle: root.querySelectorAll('[data-telemetry-slip-angle]'),
29
+ stability: root.querySelectorAll('[data-telemetry-stability]'),
26
30
  gap: root.querySelectorAll('[data-telemetry-gap]'),
27
31
  leaderGap: root.querySelectorAll('[data-telemetry-leader-gap]'),
28
32
  currentSector: root.querySelectorAll('[data-telemetry-current-sector]'),
@@ -0,0 +1,109 @@
1
+ import { DRIVER_STAT_DEFINITIONS, formatDriverNumber, VEHICLE_STAT_DEFINITIONS } from '../../data/championship.js';
2
+ import { normalizeCustomFields } from '../../data/customFields.js';
3
+ import { setText } from '../domBindings.js';
4
+
5
+ const VEHICLE_OVERVIEW_FIELDS = [
6
+ ['power', 'Power'],
7
+ ['braking', 'Braking'],
8
+ ['aero', 'Aero'],
9
+ ['dragEfficiency', 'Drag'],
10
+ ['mechanicalGrip', 'Grip'],
11
+ ['weightControl', 'Weight'],
12
+ ];
13
+ const DRIVER_OVERVIEW_FIELDS = [
14
+ ['pace', 'Pace'],
15
+ ['racecraft', 'Racecraft'],
16
+ ['aggression', 'Aggression'],
17
+ ['riskTolerance', 'Risk'],
18
+ ['patience', 'Patience'],
19
+ ['consistency', 'Consistency'],
20
+ ];
21
+
22
+ export function getOverviewFields(driver, mode) {
23
+ if (mode === 'driver') {
24
+ const ratings = driver?.constructorArgs?.driver?.ratings ?? {};
25
+ return [
26
+ ...DRIVER_OVERVIEW_FIELDS.map(([key, label]) => ({
27
+ key,
28
+ label,
29
+ value: ratings[key] ?? DRIVER_STAT_DEFINITIONS[key]?.neutral,
30
+ })),
31
+ ...normalizeCustomFields([
32
+ ...(driver?.constructorArgs?.driver?.customFields ?? []),
33
+ ...(driver?.customFields ?? []),
34
+ ]),
35
+ ];
36
+ }
37
+
38
+ const ratings = driver?.constructorArgs?.vehicle?.ratings ?? driver?.vehicle?.ratings ?? {};
39
+ return [
40
+ ...VEHICLE_OVERVIEW_FIELDS.map(([key, label]) => ({
41
+ key,
42
+ label,
43
+ value: ratings[key] ?? VEHICLE_STAT_DEFINITIONS[key]?.neutral,
44
+ })),
45
+ ...normalizeCustomFields(driver?.vehicle?.customFields ?? driver?.constructorArgs?.vehicle?.customFields ?? []),
46
+ ];
47
+ }
48
+
49
+ export function renderCarDriverOverview({
50
+ readouts,
51
+ car,
52
+ driverById,
53
+ assets,
54
+ overviewMode,
55
+ lastOverviewRenderKey,
56
+ }) {
57
+ if (!car || !readouts.carOverview) return lastOverviewRenderKey;
58
+ const driver = driverById.get(car.id);
59
+ const icon = car.icon ?? driver?.icon ?? car.code;
60
+ const driverNumber = formatDriverNumber(car.driverNumber ?? driver?.driverNumber);
61
+ const mode = overviewMode === 'driver' ? 'driver' : 'vehicle';
62
+ const fields = getOverviewFields(driver, mode);
63
+ const displayFields = fields.length > 0
64
+ ? fields
65
+ : [{ label: mode === 'driver' ? 'Driver fields' : 'Car fields', value: 'No custom fields' }];
66
+ const imageSrc = mode === 'driver'
67
+ ? (driver?.driverImage ?? driver?.portrait ?? driver?.avatar ?? assets.driverHelmet)
68
+ : assets.carOverview;
69
+ const overviewCode = mode === 'driver'
70
+ ? `${car.code} driver`
71
+ : `${driver?.vehicle?.name ?? car.vehicleName ?? car.code}`;
72
+ const overviewKey = JSON.stringify({
73
+ id: car.id,
74
+ mode,
75
+ color: car.color,
76
+ code: car.code,
77
+ icon,
78
+ driverNumber,
79
+ imageSrc,
80
+ overviewCode,
81
+ fields: displayFields.map((field) => [field.label, field.value]),
82
+ });
83
+ if (overviewKey === lastOverviewRenderKey) return lastOverviewRenderKey;
84
+
85
+ readouts.carOverview?.style.setProperty('--driver-color', car.color);
86
+ readouts.carOverviewDiagram?.style.setProperty('--driver-color', car.color);
87
+ if (readouts.carOverviewTitle) {
88
+ readouts.carOverviewTitle.textContent = mode === 'driver' ? 'Driver overview' : 'Car overview';
89
+ }
90
+ if (readouts.carOverviewCode) {
91
+ readouts.carOverviewCode.textContent = overviewCode;
92
+ }
93
+ if (readouts.carOverviewIcon) readouts.carOverviewIcon.textContent = icon;
94
+ if (readouts.carOverviewImage) {
95
+ readouts.carOverviewImage.src = imageSrc;
96
+ }
97
+ if (readouts.carOverviewNumber) readouts.carOverviewNumber.textContent = driverNumber;
98
+ if (readouts.carOverviewCoreStat) readouts.carOverviewCoreStat.textContent = mode === 'driver' ? 'Driver' : 'Car';
99
+ readouts.carOverviewFields.forEach((fieldNode, index) => {
100
+ const field = displayFields[index];
101
+ fieldNode.hidden = !field;
102
+ if (!field) return;
103
+ const labelNode = fieldNode.querySelector('[data-overview-field-label]');
104
+ const valueNode = fieldNode.querySelector('[data-overview-field-value]');
105
+ setText(labelNode, field.label);
106
+ setText(valueNode, field.value);
107
+ });
108
+ return overviewKey;
109
+ }
@@ -0,0 +1,37 @@
1
+ import { CAMERA_PRESETS } from '../camera/cameraConstants.js';
2
+
3
+ export function updateCameraControlButtons({ camera, cameraButtons, snapshot, hasPitCamera, isCameraModeAvailable }) {
4
+ if (camera.mode === 'pit' && !hasPitCamera(snapshot)) {
5
+ camera.mode = 'leader';
6
+ camera.zoom = CAMERA_PRESETS.leader;
7
+ }
8
+
9
+ cameraButtons.forEach((button) => {
10
+ const mode = button.dataset.cameraMode;
11
+ const isAvailable = isCameraModeAvailable(mode, snapshot);
12
+ if (mode === 'pit') {
13
+ button.hidden = !isAvailable;
14
+ button.disabled = !isAvailable;
15
+ button.setAttribute('aria-hidden', String(!isAvailable));
16
+ }
17
+
18
+ const isActive = !camera.free && isAvailable && mode === camera.mode;
19
+ button.classList.toggle('is-active', isActive);
20
+ button.setAttribute('aria-pressed', String(isActive));
21
+ });
22
+ }
23
+
24
+ export function updateToggleButtons(buttons, active) {
25
+ buttons?.forEach((button) => {
26
+ button.classList.toggle('is-active', active);
27
+ button.setAttribute('aria-pressed', String(active));
28
+ });
29
+ }
30
+
31
+ export function updateModeButtons(buttons, activeMode, datasetKey) {
32
+ buttons.forEach((button) => {
33
+ const isActive = button.dataset[datasetKey] === activeMode;
34
+ button.classList.toggle('is-active', isActive);
35
+ button.setAttribute('aria-pressed', String(isActive));
36
+ });
37
+ }
@@ -0,0 +1,133 @@
1
+ import { getRaceControlStatusBanner } from '../../ui/raceControlStatusBanner.js';
2
+ import { setText } from '../domBindings.js';
3
+ import { escapeHtml } from './readoutFormatters.js';
4
+
5
+ export function renderStartLights(readouts, startLightNodes, raceControl) {
6
+ const panel = readouts.startLights;
7
+ if (!panel) return;
8
+
9
+ const start = raceControl.start;
10
+ const visible = Boolean(start?.visible);
11
+ panel.hidden = !visible;
12
+ if (!visible) return;
13
+
14
+ startLightNodes.forEach((light, index) => {
15
+ light.classList.toggle('is-lit', index < (start.lightsLit ?? 0));
16
+ });
17
+ panel.classList.toggle('is-lights-out', raceControl.mode === 'green' && start.released);
18
+
19
+ if (readouts.startLightsLabel) {
20
+ readouts.startLightsLabel.textContent = raceControl.mode === 'green' && start.released
21
+ ? 'Lights out'
22
+ : `${start.lightsLit}/${start.lightCount}`;
23
+ }
24
+ }
25
+
26
+ export function renderRaceFinish({ readouts, snapshot, driverById, lastFinishClassificationMarkup }) {
27
+ const panel = readouts.finishPanel;
28
+ if (!panel) return lastFinishClassificationMarkup;
29
+
30
+ panel.hidden = !snapshot.raceControl.finished;
31
+ if (!snapshot.raceControl.finished) return lastFinishClassificationMarkup;
32
+
33
+ const winner = snapshot.raceControl.winner;
34
+ const winnerDriver = winner ? driverById.get(winner.id) : null;
35
+ const winnerName = winnerDriver?.name ?? winner?.name ?? 'Winner';
36
+ panel.style.setProperty('--driver-color', winner?.color ?? winnerDriver?.color ?? 'var(--red)');
37
+ setText(readouts.finishWinner, winnerName);
38
+
39
+ if (readouts.finishClassification) {
40
+ const topThree = (snapshot.raceControl.classification ?? []).slice(0, 3);
41
+ const classificationMarkup = topThree.map((entry) => `
42
+ <li>
43
+ <span>P${escapeHtml(entry.rank)}</span>
44
+ <strong>${escapeHtml(entry.timingCode ?? entry.code ?? entry.id)}</strong>
45
+ </li>
46
+ `).join('');
47
+ if (classificationMarkup !== lastFinishClassificationMarkup) {
48
+ readouts.finishClassification.innerHTML = classificationMarkup;
49
+ return classificationMarkup;
50
+ }
51
+ }
52
+
53
+ return lastFinishClassificationMarkup;
54
+ }
55
+
56
+ export function renderRaceStatusReadouts({
57
+ readouts,
58
+ startLightNodes,
59
+ snapshot,
60
+ camera,
61
+ fps,
62
+ activeDrs,
63
+ contactCount,
64
+ driverById,
65
+ lastFinishClassificationMarkup,
66
+ }) {
67
+ const leader = snapshot.cars[0];
68
+
69
+ if (readouts.mode) {
70
+ const modeLabel = snapshot.raceControl.mode === 'safety-car'
71
+ ? 'SC'
72
+ : snapshot.raceControl.mode === 'red-flag'
73
+ ? 'RED'
74
+ : snapshot.raceControl.finished
75
+ ? 'FINISH'
76
+ : 'GREEN';
77
+ readouts.mode.textContent = modeLabel;
78
+ readouts.mode.style.color = snapshot.raceControl.mode === 'safety-car'
79
+ ? 'var(--yellow)'
80
+ : snapshot.raceControl.mode === 'red-flag'
81
+ ? 'var(--race-control-red)'
82
+ : snapshot.raceControl.mode === 'finished'
83
+ ? 'var(--red)'
84
+ : 'var(--green)';
85
+ }
86
+ if (readouts.lap) readouts.lap.textContent = `${leader?.lap ?? 1}/${snapshot.totalLaps}`;
87
+ if (readouts.towerLap) readouts.towerLap.textContent = leader?.lap ?? 1;
88
+ if (readouts.towerTotalLaps) readouts.towerTotalLaps.textContent = snapshot.totalLaps;
89
+ if (readouts.timingTower) {
90
+ readouts.timingTower.classList.toggle('is-safety-car', snapshot.raceControl.mode === 'safety-car');
91
+ readouts.timingTower.classList.toggle('is-red-flag', snapshot.raceControl.mode === 'red-flag');
92
+ readouts.timingTower.classList.toggle('is-pre-start', snapshot.raceControl.mode === 'pre-start');
93
+ }
94
+ if (readouts.towerRaceControlBanner) {
95
+ const raceControlBanner = getRaceControlStatusBanner(snapshot.raceControl.mode);
96
+ readouts.towerRaceControlBanner.hidden = !raceControlBanner;
97
+ readouts.towerRaceControlBanner.classList?.toggle?.('is-safety-car', raceControlBanner?.status === 'safety-car');
98
+ readouts.towerRaceControlBanner.classList?.toggle?.('is-red-flag', raceControlBanner?.status === 'red-flag');
99
+ if (raceControlBanner && readouts.towerRaceControlBanner.dataset) {
100
+ readouts.towerRaceControlBanner.dataset.raceControlStatus = raceControlBanner.status;
101
+ } else if (readouts.towerRaceControlBanner.dataset) {
102
+ delete readouts.towerRaceControlBanner.dataset.raceControlStatus;
103
+ }
104
+ if (raceControlBanner) {
105
+ setText(readouts.towerRaceControlKicker, raceControlBanner.kicker);
106
+ setText(readouts.towerRaceControlTitle, raceControlBanner.title);
107
+ }
108
+ }
109
+ if (readouts.drs) {
110
+ readouts.drs.textContent = ['safety-car', 'red-flag'].includes(snapshot.raceControl.mode)
111
+ ? 'DISABLED'
112
+ : activeDrs
113
+ ? `${activeDrs} OPEN`
114
+ : 'ARMED';
115
+ }
116
+ if (readouts.contacts) readouts.contacts.textContent = String(contactCount);
117
+ renderStartLights(readouts, startLightNodes, snapshot.raceControl);
118
+ if (readouts.camera) {
119
+ const zoom = Math.round(camera.zoom * 100);
120
+ const mode = camera.free ? 'FREE' : camera.mode.toUpperCase().replace('-', ' ');
121
+ readouts.camera.textContent = `${mode} ${zoom}%`;
122
+ }
123
+ if (readouts.fps) {
124
+ readouts.fps.textContent = fps.current ? `${fps.current}` : '--';
125
+ }
126
+
127
+ return renderRaceFinish({
128
+ readouts,
129
+ snapshot,
130
+ driverById,
131
+ lastFinishClassificationMarkup,
132
+ });
133
+ }
@@ -0,0 +1,58 @@
1
+ export function escapeHtml(value) {
2
+ return String(value ?? '')
3
+ .replaceAll('&', '&amp;')
4
+ .replaceAll('<', '&lt;')
5
+ .replaceAll('>', '&gt;')
6
+ .replaceAll('"', '&quot;')
7
+ .replaceAll("'", '&#39;');
8
+ }
9
+
10
+ export function getTireClass(tire) {
11
+ return String(tire ?? 'M').toLowerCase();
12
+ }
13
+
14
+ export function formatTelemetryTime(value) {
15
+ if (!Number.isFinite(value)) return '--';
16
+ const clamped = Math.max(0, value);
17
+ if (clamped >= 60) {
18
+ const minutes = Math.floor(clamped / 60);
19
+ const seconds = clamped - minutes * 60;
20
+ return `${minutes}:${seconds.toFixed(3).padStart(6, '0')}`;
21
+ }
22
+ return `${clamped.toFixed(3)}s`;
23
+ }
24
+
25
+ export function setPerformanceClass(node, status) {
26
+ ['overall-best', 'personal-best', 'slower'].forEach((name) => {
27
+ node?.classList?.toggle?.(`is-${name}`, status === name);
28
+ });
29
+ }
30
+
31
+ export function formatLapGap(laps) {
32
+ const wholeLaps = Math.max(0, Math.floor(laps));
33
+ return `+${wholeLaps}`;
34
+ }
35
+
36
+ export function formatRaceGap(car, mode) {
37
+ const lapValue = mode === 'leader'
38
+ ? car.leaderGapLaps
39
+ : (car.intervalAheadLaps ?? car.gapAheadLaps);
40
+ if (Number.isFinite(lapValue) && lapValue > 0) return formatLapGap(lapValue);
41
+
42
+ const value = mode === 'leader'
43
+ ? car.leaderGapSeconds
44
+ : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
45
+ return Number.isFinite(value) ? `+${Math.max(0, value).toFixed(3)}` : '--';
46
+ }
47
+
48
+ export function formatTelemetryGap(car, mode) {
49
+ const lapValue = mode === 'leader'
50
+ ? car.leaderGapLaps
51
+ : (car.intervalAheadLaps ?? car.gapAheadLaps);
52
+ if (Number.isFinite(lapValue) && lapValue > 0) return formatLapGap(lapValue);
53
+
54
+ const seconds = mode === 'leader'
55
+ ? car.leaderGapSeconds
56
+ : (car.intervalAheadSeconds ?? car.gapAheadSeconds);
57
+ return Number.isFinite(seconds) ? `${seconds.toFixed(2)}s` : '--';
58
+ }
@@ -0,0 +1,113 @@
1
+ import { setTextAll, setText } from '../domBindings.js';
2
+ import { clamp } from '../../simulation/simMath.js';
3
+ import { formatDriverNumber } from '../../data/championship.js';
4
+ import { formatTelemetryGap, formatTelemetryTime, setPerformanceClass } from './readoutFormatters.js';
5
+
6
+ export function renderTelemetryReadouts({ readouts, car, driverById }) {
7
+ if (!car) return;
8
+ const driver = driverById.get(car.id);
9
+ const drsState = car.drsActive ? 'OPEN' : car.drsEligible ? 'READY' : 'OFF';
10
+ const surface = (car.surface ?? 'track').toUpperCase();
11
+
12
+ setTextAll(readouts.selectedCode, car.code);
13
+ readouts.selectedCode?.forEach?.((node) => {
14
+ node.style.color = car.color;
15
+ });
16
+ readouts.telemetrySectorBanners?.forEach?.((node) => {
17
+ node.style.setProperty('--driver-color', car.color);
18
+ });
19
+ setTextAll(readouts.selectedName, car.name);
20
+ setTextAll(readouts.speed, `${Math.round(car.speedKph)} km/h`);
21
+ setTextAll(readouts.throttle, `${Math.round(car.throttle * 100)}%`);
22
+ setTextAll(readouts.brake, `${Math.round(car.brake * 100)}%`);
23
+ setTextAll(readouts.tyres, `${Math.round(car.tireEnergy ?? 100)}%`);
24
+ setTextAll(readouts.selectedDrs, drsState);
25
+ setTextAll(readouts.surface, surface);
26
+ setTextAll(readouts.grip, `${Math.round(clamp(car.gripUsage ?? 0, 0, 9.99) * 100)}%`);
27
+ setTextAll(readouts.lateralG, `${(car.lateralG ?? 0).toFixed(1)}g`);
28
+ setTextAll(readouts.slipAngle, `${(((car.slipAngleRadians ?? 0) * 180) / Math.PI).toFixed(1)} deg`);
29
+ setTextAll(readouts.stability, String(car.stabilityState ?? 'stable').toUpperCase());
30
+ setTextAll(
31
+ readouts.gap,
32
+ car.rank === 1
33
+ ? '--'
34
+ : formatTelemetryGap(car, 'interval'),
35
+ );
36
+ setTextAll(
37
+ readouts.leaderGap,
38
+ car.rank === 1
39
+ ? '--'
40
+ : formatTelemetryGap(car, 'leader'),
41
+ );
42
+ }
43
+
44
+ export function renderLapTelemetry(readouts, telemetry) {
45
+ if (!telemetry) return;
46
+
47
+ setTextAll(readouts.currentSector, `S${telemetry.currentSector ?? 1}`);
48
+ setTextAll(readouts.completedLaps, `${telemetry.completedLaps ?? 0} laps`);
49
+ setTextAll(readouts.currentLapTime, formatTelemetryTime(telemetry.currentLapTime));
50
+ setTextAll(readouts.lastLapTime, formatTelemetryTime(telemetry.lastLapTime));
51
+ setTextAll(readouts.bestLapTime, formatTelemetryTime(telemetry.bestLapTime));
52
+
53
+ readouts.telemetrySectorBars?.forEach((bar) => {
54
+ const sector = Number(bar.dataset.telemetrySectorBar);
55
+ const index = sector - 1;
56
+ const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
57
+ const isActive = index === activeIndex;
58
+ const isCompletedCurrentSector = index < activeIndex;
59
+ const hasLiveProgress = Array.isArray(telemetry.sectorProgress);
60
+ const progress = index > activeIndex
61
+ ? 0
62
+ : hasLiveProgress
63
+ ? telemetry.sectorProgress[index]
64
+ : isActive
65
+ ? telemetry.currentSectorProgress
66
+ : isCompletedCurrentSector && Number.isFinite(telemetry.currentSectors?.[index])
67
+ ? 1
68
+ : 0;
69
+ const fill = clamp((progress ?? 0) * 100, 0, 100);
70
+ const sectorComplete = fill >= 99.9;
71
+ const fillValue = `${fill.toFixed(1)}%`;
72
+ if (bar.style.getPropertyValue('--sector-fill') !== fillValue) {
73
+ bar.style.setProperty('--sector-fill', fillValue);
74
+ }
75
+ bar.classList.toggle('is-active', isActive);
76
+ bar.classList.toggle('is-complete', sectorComplete);
77
+ setPerformanceClass(bar, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
78
+ });
79
+
80
+ readouts.telemetrySectorTimes?.forEach((node) => {
81
+ const sector = Number(node.dataset.telemetrySectorTime);
82
+ const index = sector - 1;
83
+ const activeIndex = clamp((telemetry.currentSector ?? 1) - 1, 0, 2);
84
+ const isActive = index === activeIndex;
85
+ const isCompletedCurrentSector = index < activeIndex;
86
+ const hasLiveSectors = Array.isArray(telemetry.liveSectors);
87
+ const value = index > activeIndex
88
+ ? null
89
+ : hasLiveSectors
90
+ ? isActive
91
+ ? telemetry.currentSectorElapsed ?? telemetry.liveSectors[index]
92
+ : telemetry.liveSectors[index]
93
+ : isActive && !Number.isFinite(telemetry.currentSectors?.[index])
94
+ ? telemetry.currentSectorElapsed
95
+ : isCompletedCurrentSector
96
+ ? telemetry.currentSectors?.[index]
97
+ : null;
98
+ setText(node, formatTelemetryTime(value));
99
+ setPerformanceClass(node, isCompletedCurrentSector ? telemetry.sectorPerformance?.current?.[index] : null);
100
+ });
101
+
102
+ readouts.telemetrySectorLast?.forEach((node) => {
103
+ const index = Number(node.dataset.telemetrySectorLast) - 1;
104
+ setText(node, formatTelemetryTime(telemetry.lastSectors?.[index]));
105
+ setPerformanceClass(node, telemetry.sectorPerformance?.last?.[index]);
106
+ });
107
+
108
+ readouts.telemetrySectorBest?.forEach((node) => {
109
+ const index = Number(node.dataset.telemetrySectorBest) - 1;
110
+ setText(node, formatTelemetryTime(telemetry.bestSectors?.[index]));
111
+ setPerformanceClass(node, telemetry.sectorPerformance?.best?.[index]);
112
+ });
113
+ }