@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,55 @@
1
+ export function formatPenaltyType(type) {
2
+ return String(type ?? 'penalty')
3
+ .split('-')
4
+ .filter(Boolean)
5
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
6
+ .join(' ');
7
+ }
8
+
9
+ export function formatPenaltyHeadline(penalty) {
10
+ const serviceType = penalty?.serviceType;
11
+ if (serviceType === 'driveThrough') {
12
+ return penalty?.unserved ? 'unserved drive-through penalty' : 'drive-through penalty';
13
+ }
14
+ if (serviceType === 'stopGo') {
15
+ return penalty?.unserved ? 'unserved stop-go penalty' : 'stop-go penalty';
16
+ }
17
+ const positionDrop = Number(penalty?.positionDrop);
18
+ if (Number.isFinite(positionDrop) && positionDrop > 0) {
19
+ return `${positionDrop}-place position drop`;
20
+ }
21
+ const gridDrop = Number(penalty?.gridDrop);
22
+ if (Number.isFinite(gridDrop) && gridDrop > 0) {
23
+ return `${gridDrop}-place grid drop`;
24
+ }
25
+ if (penalty?.disqualified) return 'disqualification';
26
+ const seconds = Number(penalty?.penaltySeconds);
27
+ if (!Number.isFinite(seconds) || seconds <= 0) return 'Penalty decision';
28
+ const unit = seconds === 1 ? 'second' : 'seconds';
29
+ return `+${seconds} ${unit} time penalty`;
30
+ }
31
+
32
+ export function formatPenaltyChip(penalty) {
33
+ const serviceType = penalty?.serviceType;
34
+ if (serviceType === 'driveThrough') return 'DT';
35
+ if (serviceType === 'stopGo') return 'SG';
36
+ if (penalty?.disqualified) return 'DSQ';
37
+ const positionDrop = Number(penalty?.positionDrop);
38
+ if (Number.isFinite(positionDrop) && positionDrop > 0) return `-${positionDrop}P`;
39
+ const gridDrop = Number(penalty?.gridDrop);
40
+ if (Number.isFinite(gridDrop) && gridDrop > 0) return `-${gridDrop}G`;
41
+ const seconds = Number(penalty?.penaltySeconds);
42
+ return Number.isFinite(seconds) && seconds > 0 ? `+${seconds}s` : 'Penalty';
43
+ }
44
+
45
+ export function formatStewardMessageKey(message) {
46
+ if (!message) return '';
47
+ return [
48
+ message.kind,
49
+ message.type,
50
+ message.driverId,
51
+ message.penaltyId,
52
+ message.violationCount,
53
+ message.time,
54
+ ].join(':');
55
+ }
@@ -0,0 +1,141 @@
1
+ import { formatDriverNumber } from '../../data/championship.js';
2
+ import { setText } from '../domBindings.js';
3
+
4
+ export const RACE_DATA_SELECTED_VISIBLE_MS = 5200;
5
+ export const RADIO_BREAK_MIN_MS = 4800;
6
+ export const RADIO_BREAK_MAX_MS = 11800;
7
+ export const RADIO_VISIBLE_MIN_MS = 6200;
8
+ export const RADIO_VISIBLE_MAX_MS = 9200;
9
+ export const RADIO_SCHEDULE_CATCHUP_LIMIT_MS = 30000;
10
+
11
+ export function shouldAutoHideActiveRaceData({ activeRaceDataId, options, readouts }) {
12
+ return Boolean(
13
+ activeRaceDataId &&
14
+ !(
15
+ options.ui?.raceDataTelemetryDetail ||
16
+ readouts.raceDataPanel?.classList?.contains?.('race-data-panel--with-telemetry')
17
+ ),
18
+ );
19
+ }
20
+
21
+ export function renderRaceData({ car, drivers, readouts, options }) {
22
+ if (!car || !readouts.raceDataPanel) return false;
23
+ if (typeof options.isRaceDataBannerEnabled === 'function' && !options.isRaceDataBannerEnabled('project')) return false;
24
+ const driver = drivers.find((item) => item.id === car.id);
25
+ if (!driver) return false;
26
+
27
+ readouts.raceDataPanel.style.setProperty('--driver-color', driver.color);
28
+ readouts.raceDataPanel.classList.remove('is-hidden');
29
+ readouts.raceDataPanel.classList.add('is-project-mode');
30
+ readouts.raceDataPanel.classList.remove('is-radio-mode');
31
+ readouts.raceDataPanel.removeAttribute('data-idle-mode');
32
+ setText(readouts.raceDataKicker, 'Project');
33
+ setText(readouts.raceDataTitle, driver.name);
34
+ if (readouts.raceDataNumber) {
35
+ readouts.raceDataNumber.textContent = formatDriverNumber(car.driverNumber ?? driver.driverNumber);
36
+ }
37
+ if (readouts.raceDataSubtitle) {
38
+ readouts.raceDataSubtitle.textContent = `${car.code} - P${car.rank} - ${driver.raceData?.[0] ?? 'Project entry'}`;
39
+ }
40
+ if (readouts.raceDataOpen) {
41
+ readouts.raceDataOpen.hidden = typeof options.onDriverOpen !== 'function';
42
+ }
43
+ return true;
44
+ }
45
+
46
+ export function hideRaceDataPanel(readouts) {
47
+ if (!readouts.raceDataPanel) return;
48
+ readouts.raceDataPanel.classList.add('is-hidden');
49
+ readouts.raceDataPanel.classList.remove('is-project-mode', 'is-radio-mode');
50
+ readouts.raceDataPanel.removeAttribute('data-idle-mode');
51
+ if (readouts.raceDataOpen) readouts.raceDataOpen.hidden = true;
52
+ }
53
+
54
+ export function getProjectRadioQuote({ drivers, radioState }) {
55
+ const driver = drivers[radioState.driverIndex] ?? drivers[0];
56
+ const quote = driver.raceData?.[radioState.quoteIndex] ?? 'Project entry';
57
+
58
+ return {
59
+ color: driver.color,
60
+ title: driver.name,
61
+ subtitle: `${driver.code} - "${quote}"`,
62
+ };
63
+ }
64
+
65
+ export function renderProjectRadio({ readouts, radio }) {
66
+ if (!readouts.raceDataPanel) return;
67
+ readouts.raceDataPanel.style.setProperty('--driver-color', radio.color);
68
+ readouts.raceDataPanel.classList.remove('is-hidden');
69
+ readouts.raceDataPanel.classList.add('is-radio-mode');
70
+ readouts.raceDataPanel.classList.remove('is-project-mode');
71
+ readouts.raceDataPanel.dataset.idleMode = 'quote';
72
+ setText(readouts.raceDataKicker, 'Project radio');
73
+ setText(readouts.raceDataTitle, radio.title);
74
+ setText(readouts.raceDataNumber, '');
75
+ setText(readouts.raceDataSubtitle, radio.subtitle);
76
+ if (readouts.raceDataOpen) readouts.raceDataOpen.hidden = true;
77
+ }
78
+
79
+ export function randomRadioRange(nextRandom, min, max) {
80
+ return min + nextRandom() * (max - min);
81
+ }
82
+
83
+ export function getNextRadioBreakTime({ now, isEnabled, nextRandom }) {
84
+ return isEnabled('radio')
85
+ ? now + randomRadioRange(nextRandom, RADIO_BREAK_MIN_MS, RADIO_BREAK_MAX_MS)
86
+ : Number.POSITIVE_INFINITY;
87
+ }
88
+
89
+ export function scheduleRadioBreak({ radioState, now, isEnabled, nextRandom }) {
90
+ radioState.visible = false;
91
+ radioState.nextChangeAt = getNextRadioBreakTime({ now, isEnabled, nextRandom });
92
+ }
93
+
94
+ export function scheduleRadioPopup({ radioState, now, drivers, isEnabled, nextRandom }) {
95
+ if (!isEnabled('radio')) {
96
+ scheduleRadioBreak({ radioState, now, isEnabled, nextRandom });
97
+ return;
98
+ }
99
+ const driverIndex = Math.floor(nextRandom() * drivers.length);
100
+ const driver = drivers[driverIndex] ?? drivers[0];
101
+ const quoteCount = Math.max(1, driver.raceData?.length ?? 1);
102
+ radioState.visible = true;
103
+ radioState.driverIndex = driverIndex;
104
+ radioState.quoteIndex = Math.floor(nextRandom() * quoteCount);
105
+ radioState.nextChangeAt = now + randomRadioRange(nextRandom, RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS);
106
+ }
107
+
108
+ export function updateRadioSchedule({ radioState, now, drivers, isEnabled, nextRandom }) {
109
+ if (!isEnabled('radio')) {
110
+ radioState.visible = false;
111
+ radioState.nextChangeAt = Number.POSITIVE_INFINITY;
112
+ return;
113
+ }
114
+ if (
115
+ Number.isFinite(radioState.nextChangeAt) &&
116
+ now - radioState.nextChangeAt > RADIO_SCHEDULE_CATCHUP_LIMIT_MS
117
+ ) {
118
+ if (radioState.visible) scheduleRadioBreak({ radioState, now, isEnabled, nextRandom });
119
+ else scheduleRadioPopup({ radioState, now, drivers, isEnabled, nextRandom });
120
+ return;
121
+ }
122
+ while (now >= radioState.nextChangeAt) {
123
+ if (radioState.visible) {
124
+ scheduleRadioBreak({ radioState, now: radioState.nextChangeAt, isEnabled, nextRandom });
125
+ } else {
126
+ scheduleRadioPopup({ radioState, now: radioState.nextChangeAt, drivers, isEnabled, nextRandom });
127
+ }
128
+ }
129
+ }
130
+
131
+ export function resetRaceDataBannerState({ state, now, initialMode, selectedId, isEnabled, nextRandom }) {
132
+ state.activeRaceDataId = isEnabled('project') && initialMode === 'project' ? selectedId : null;
133
+ state.lastRaceDataInteraction = now;
134
+ const showInitialRadio = isEnabled('radio') && initialMode === 'radio';
135
+ state.radioState.visible = showInitialRadio;
136
+ state.radioState.nextChangeAt = showInitialRadio
137
+ ? now + randomRadioRange(nextRandom, RADIO_VISIBLE_MIN_MS, RADIO_VISIBLE_MAX_MS)
138
+ : getNextRadioBreakTime({ now, isEnabled, nextRandom });
139
+ state.radioState.driverIndex = 0;
140
+ state.radioState.quoteIndex = 0;
141
+ }
@@ -0,0 +1,99 @@
1
+ import { setText } from '../domBindings.js';
2
+ import {
3
+ formatPenaltyChip,
4
+ formatPenaltyHeadline,
5
+ formatPenaltyType,
6
+ formatStewardMessageKey,
7
+ } from './bannerFormatters.js';
8
+
9
+ export const PENALTY_BANNER_VISIBLE_MS = 12000;
10
+
11
+ export function createPenaltyStewardMessage(penalty, driverById) {
12
+ if (!penalty) return null;
13
+ const driver = driverById.get(penalty.driverId);
14
+ const code = driver?.timingCode ?? driver?.code ?? penalty.driverId ?? 'CAR';
15
+ return {
16
+ kind: 'penalty',
17
+ type: penalty.type,
18
+ driverId: penalty.driverId,
19
+ penaltyId: penalty.id,
20
+ time: penalty.time,
21
+ color: driver?.color ?? 'var(--red)',
22
+ kicker: formatPenaltyChip(penalty),
23
+ title: `${code} ${formatPenaltyHeadline(penalty).replace(/^\+\d+ seconds? /u, '')}`,
24
+ detail: `${formatPenaltyType(penalty.type)} - ${penalty.reason ?? 'Steward decision'}`,
25
+ };
26
+ }
27
+
28
+ export function createWarningStewardMessage(event, driverById) {
29
+ if (!event) return null;
30
+ const driver = driverById.get(event.carId);
31
+ const code = driver?.timingCode ?? driver?.code ?? event.carId ?? 'CAR';
32
+ const warningLimit = Number(event.warningsBeforePenalty);
33
+ const warningProgress = Number.isFinite(warningLimit) && warningLimit > 0
34
+ ? ` ${event.violationCount}/${warningLimit}`
35
+ : '';
36
+ return {
37
+ kind: 'warning',
38
+ type: event.type,
39
+ driverId: event.carId,
40
+ violationCount: event.violationCount,
41
+ time: event.at,
42
+ color: driver?.color ?? 'var(--yellow)',
43
+ kicker: 'Warning',
44
+ title: `${code} ${formatPenaltyType(event.type).toLowerCase()}`,
45
+ detail: `${formatPenaltyType(event.type)}${warningProgress}`,
46
+ };
47
+ }
48
+
49
+ export function updateStewardMessageState({
50
+ snapshot,
51
+ now,
52
+ state,
53
+ driverById,
54
+ penaltyBannerEnabled,
55
+ stewardMessageNode,
56
+ }) {
57
+ if (!penaltyBannerEnabled || !stewardMessageNode) {
58
+ state.activeStewardMessage = null;
59
+ return state;
60
+ }
61
+ if (state.activeStewardMessage && now < state.activeStewardMessage.visibleUntil) return state;
62
+ state.activeStewardMessage = null;
63
+
64
+ const warning = [...(snapshot.events ?? [])].reverse().find((event) => (
65
+ event?.type === 'track-limits' && event.decision === 'warning'
66
+ ));
67
+ const penalty = [...(snapshot.penalties ?? [])].reverse().find((entry) => (
68
+ entry?.id && entry.id !== state.lastPenaltyBannerId
69
+ ));
70
+ const message = penalty
71
+ ? createPenaltyStewardMessage(penalty, driverById)
72
+ : createWarningStewardMessage(warning, driverById);
73
+ const key = formatStewardMessageKey(message);
74
+ if (!message || !key || key === state.lastStewardMessageKey) return state;
75
+ state.lastStewardMessageKey = key;
76
+ if (message.penaltyId) state.lastPenaltyBannerId = message.penaltyId;
77
+ state.activeStewardMessage = {
78
+ message,
79
+ visibleUntil: now + PENALTY_BANNER_VISIBLE_MS,
80
+ };
81
+ return state;
82
+ }
83
+
84
+ export function renderActiveStewardMessage(readouts, activeStewardMessage) {
85
+ const message = activeStewardMessage?.message;
86
+ const panel = readouts.stewardMessage;
87
+ if (!panel) return;
88
+ if (!message) {
89
+ panel.classList.add('is-hidden');
90
+ panel.classList.remove('is-penalty', 'is-warning');
91
+ return;
92
+ }
93
+ panel.style.setProperty('--steward-color', message.color);
94
+ panel.classList.remove('is-hidden', 'is-penalty', 'is-warning');
95
+ panel.classList.add(message.kind === 'penalty' ? 'is-penalty' : 'is-warning');
96
+ setText(readouts.stewardMessageKicker, message.kicker);
97
+ setText(readouts.stewardMessageTitle, message.title);
98
+ setText(readouts.stewardMessageDetail, message.detail);
99
+ }
@@ -0,0 +1,21 @@
1
+ import { metersToSimUnits } from '../../simulation/units.js';
2
+ import { VEHICLE_GEOMETRY } from '../../simulation/vehicleGeometry.js';
3
+
4
+ export const CAMERA_TARGET_LERP = 0.12;
5
+ export const CAMERA_SCALE_LERP = 0.12;
6
+ export const CAMERA_PRESETS = {
7
+ overview: 1,
8
+ leader: 18,
9
+ selected: 24,
10
+ 'show-all': 1,
11
+ pit: 2.35,
12
+ };
13
+ export const CAMERA_MIN_ZOOM = 0.55;
14
+ export const CAMERA_MAX_ZOOM = 120;
15
+ export const CAMERA_ZOOM_STEP = 1.4;
16
+ export const TRACK_CAMERA_PADDING = metersToSimUnits(120);
17
+ export const SHOW_ALL_PADDING = 520;
18
+ export const SHOW_ALL_TOP_RESERVED = 92;
19
+ export const SHOW_ALL_BOTTOM_RESERVED = 132;
20
+ export const PIT_CAMERA_PADDING = metersToSimUnits(34);
21
+ export const CAR_WORLD_LENGTH = VEHICLE_GEOMETRY.visualLength;
@@ -0,0 +1,265 @@
1
+ import { WORLD } from '../../simulation/trackModel.js';
2
+ import { clamp } from '../../simulation/simMath.js';
3
+ import {
4
+ CAMERA_PRESETS,
5
+ CAMERA_SCALE_LERP,
6
+ CAMERA_TARGET_LERP,
7
+ CAMERA_ZOOM_STEP,
8
+ CAMERA_MIN_ZOOM,
9
+ CAMERA_MAX_ZOOM,
10
+ } from './cameraConstants.js';
11
+ import {
12
+ clampCameraScale,
13
+ getCameraBoundsFitScale,
14
+ getPitCameraBounds,
15
+ getPitCameraFrame,
16
+ getPitCameraTarget,
17
+ getShowAllCameraFrame,
18
+ getTrackCameraBounds,
19
+ getTrackCameraTarget,
20
+ } from './cameraGeometry.js';
21
+
22
+ export function createCameraState(initialCameraMode) {
23
+ return {
24
+ mode: initialCameraMode,
25
+ zoom: CAMERA_PRESETS[initialCameraMode] ?? CAMERA_PRESETS.leader,
26
+ scale: null,
27
+ initialized: false,
28
+ x: WORLD.width / 2,
29
+ y: WORLD.height / 2,
30
+ free: false,
31
+ freeTarget: null,
32
+ };
33
+ }
34
+
35
+ export class CameraController {
36
+ constructor({ canvasHost, readouts, initialMode }) {
37
+ this.canvasHost = canvasHost;
38
+ this.readouts = readouts;
39
+ this.camera = createCameraState(initialMode);
40
+ this.safeAreaCache = null;
41
+ this.pitBoundsCache = null;
42
+ this.trackBoundsCache = null;
43
+ }
44
+
45
+ setMode(mode, snapshot) {
46
+ if (!this.isModeAvailable(mode, snapshot)) return false;
47
+ this.camera.mode = mode;
48
+ this.camera.free = false;
49
+ this.camera.freeTarget = null;
50
+ if (CAMERA_PRESETS[this.camera.mode]) this.camera.zoom = CAMERA_PRESETS[this.camera.mode];
51
+ return true;
52
+ }
53
+
54
+ adjustZoom(direction) {
55
+ const steps = Number.isFinite(Number(direction)) ? Number(direction) : 0;
56
+ this.camera.zoom = clamp(this.camera.zoom + steps * CAMERA_ZOOM_STEP, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
57
+ return this.camera.zoom;
58
+ }
59
+
60
+ invalidateSafeArea() {
61
+ this.safeAreaCache = null;
62
+ }
63
+
64
+ invalidateTrackCaches() {
65
+ this.pitBoundsCache = null;
66
+ this.trackBoundsCache = null;
67
+ }
68
+
69
+ getSafeArea(width) {
70
+ if (this.safeAreaCache?.width === width) {
71
+ return this.safeAreaCache.safeArea;
72
+ }
73
+
74
+ const canvasRect = this.canvasHost?.getBoundingClientRect?.();
75
+ const towerRect = this.readouts?.timingTower?.getBoundingClientRect?.();
76
+ if (!canvasRect || !towerRect) {
77
+ const safeArea = { left: 0, width };
78
+ this.safeAreaCache = { width, safeArea };
79
+ return safeArea;
80
+ }
81
+ const overlapsCanvasHorizontally = towerRect.right > canvasRect.left && towerRect.left < canvasRect.right;
82
+ const hasVerticalBounds = Number.isFinite(canvasRect.top) &&
83
+ Number.isFinite(canvasRect.bottom) &&
84
+ Number.isFinite(towerRect.top) &&
85
+ Number.isFinite(towerRect.bottom);
86
+ const overlapsCanvasVertically = !hasVerticalBounds ||
87
+ (towerRect.bottom > canvasRect.top && towerRect.top < canvasRect.bottom);
88
+ const canvasWidth = Math.max(1, canvasRect.right - canvasRect.left || width);
89
+ const towerWidth = Math.max(0, towerRect.right - towerRect.left);
90
+ const isSideGutter = towerWidth < canvasWidth * 0.6;
91
+ if (!overlapsCanvasHorizontally || !overlapsCanvasVertically || !isSideGutter) {
92
+ const safeArea = { left: 0, width };
93
+ this.safeAreaCache = { width, safeArea };
94
+ return safeArea;
95
+ }
96
+
97
+ const overlayGap = 16;
98
+ const reservedLeft = clamp(towerRect.right - canvasRect.left + overlayGap, 0, width * 0.48);
99
+ const safeArea = {
100
+ left: reservedLeft,
101
+ width: Math.max(1, width - reservedLeft),
102
+ };
103
+ this.safeAreaCache = { width, safeArea };
104
+ return safeArea;
105
+ }
106
+
107
+ getTrackBounds(track) {
108
+ if (this.trackBoundsCache && this.trackBoundsCache.track === track) {
109
+ return this.trackBoundsCache.bounds;
110
+ }
111
+ const bounds = getTrackCameraBounds(track);
112
+ this.trackBoundsCache = { track, bounds };
113
+ return bounds;
114
+ }
115
+
116
+ getTrackTarget(track) {
117
+ return getTrackCameraTarget(this.getTrackBounds(track));
118
+ }
119
+
120
+ getBoundsFitScale(bounds, height, safeArea) {
121
+ return getCameraBoundsFitScale(bounds, height, safeArea);
122
+ }
123
+
124
+ getPitBounds(pitLane) {
125
+ if (this.pitBoundsCache && this.pitBoundsCache.pitLane === pitLane) return this.pitBoundsCache.bounds;
126
+ const bounds = getPitCameraBounds(pitLane);
127
+ this.pitBoundsCache = { pitLane, bounds };
128
+ return bounds;
129
+ }
130
+
131
+ getPitTarget(pitLane) {
132
+ return getPitCameraTarget(this.getPitBounds(pitLane));
133
+ }
134
+
135
+ getPitFrame(pitLane, height, baseScale, safeArea, screenCenterX, minimumScale = null) {
136
+ return getPitCameraFrame({
137
+ pitBounds: this.getPitBounds(pitLane),
138
+ cameraZoom: this.camera.zoom,
139
+ height,
140
+ baseScale,
141
+ safeArea,
142
+ screenCenterX,
143
+ minimumScale,
144
+ });
145
+ }
146
+
147
+ hasPitCamera(snapshot) {
148
+ return Boolean(snapshot?.track?.pitLane?.enabled);
149
+ }
150
+
151
+ isModeAvailable(mode, snapshot) {
152
+ return mode !== 'pit' || this.hasPitCamera(snapshot);
153
+ }
154
+
155
+ getFrame(snapshot, width, height, baseScale, safeArea = { left: 0, width }, selectedId = null) {
156
+ const screenCenterX = safeArea.left + safeArea.width / 2;
157
+ const trackBounds = this.getTrackBounds(snapshot.track);
158
+ const trackFitScale = this.getBoundsFitScale(trackBounds, height, safeArea);
159
+
160
+ if (this.camera.mode === 'overview') {
161
+ const target = trackBounds
162
+ ? {
163
+ x: (trackBounds.minX + trackBounds.maxX) / 2,
164
+ y: (trackBounds.minY + trackBounds.maxY) / 2,
165
+ }
166
+ : { x: WORLD.width / 2, y: WORLD.height / 2 };
167
+
168
+ const scale = trackFitScale
169
+ ? clampCameraScale(trackFitScale * this.camera.zoom, trackFitScale, trackFitScale)
170
+ : clampCameraScale(baseScale * this.camera.zoom, baseScale);
171
+
172
+ return this.applyFreeFrame({
173
+ target,
174
+ scale,
175
+ screenX: screenCenterX,
176
+ screenY: height / 2,
177
+ });
178
+ }
179
+
180
+ if (this.camera.mode === 'show-all') {
181
+ return this.applyFreeFrame(getShowAllCameraFrame({
182
+ cars: snapshot.cars,
183
+ cameraZoom: this.camera.zoom,
184
+ height,
185
+ safeArea,
186
+ screenCenterX,
187
+ trackFitScale,
188
+ }));
189
+ }
190
+
191
+ if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
192
+ return this.applyFreeFrame(
193
+ this.getPitFrame(snapshot.track.pitLane, height, baseScale, safeArea, screenCenterX, trackFitScale),
194
+ );
195
+ }
196
+
197
+ return this.applyFreeFrame({
198
+ target: this.getTarget(snapshot, selectedId),
199
+ scale: clampCameraScale(baseScale * this.camera.zoom, baseScale, trackFitScale),
200
+ screenX: screenCenterX,
201
+ screenY: height / 2,
202
+ });
203
+ }
204
+
205
+ applyFreeFrame(frame) {
206
+ if (!this.camera.free || !this.camera.freeTarget) return frame;
207
+ return {
208
+ ...frame,
209
+ target: this.camera.freeTarget,
210
+ };
211
+ }
212
+
213
+ getTarget(snapshot, selectedId = null) {
214
+ if (this.camera.free && this.camera.freeTarget) return this.camera.freeTarget;
215
+ if (this.camera.mode === 'overview') return this.getTrackTarget(snapshot.track);
216
+
217
+ if (this.camera.mode === 'pit' && this.hasPitCamera(snapshot)) {
218
+ return this.getPitTarget(snapshot.track.pitLane);
219
+ }
220
+
221
+ if (this.camera.mode === 'selected') {
222
+ const selected = snapshot.cars.find((car) => car.id === selectedId);
223
+ if (selected) return selected;
224
+ }
225
+
226
+ const leader = snapshot.cars[0];
227
+ return leader ? { x: leader.x, y: leader.y } : { x: WORLD.width / 2, y: WORLD.height / 2 };
228
+ }
229
+
230
+ getBaseScale() {
231
+ const width = this.canvasHost.clientWidth || 900;
232
+ const height = this.canvasHost.clientHeight || 640;
233
+ const safeArea = this.getSafeArea(width);
234
+ return Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
235
+ }
236
+
237
+ applyToWorldLayer(worldLayer, snapshot, { immediate = false, selectedId = null } = {}) {
238
+ if (!worldLayer) return;
239
+
240
+ const width = this.canvasHost.clientWidth || 900;
241
+ const height = this.canvasHost.clientHeight || 640;
242
+ const safeArea = this.getSafeArea(width);
243
+ const baseScale = Math.min(safeArea.width / (WORLD.width + 260), height / (WORLD.height + 220));
244
+ const frame = this.getFrame(snapshot, width, height, baseScale, safeArea, selectedId);
245
+ const scale = frame.scale;
246
+ const target = frame.target;
247
+ const snapCamera = immediate || !this.camera.initialized;
248
+ this.camera.x = snapCamera
249
+ ? target.x
250
+ : this.camera.x + (target.x - this.camera.x) * CAMERA_TARGET_LERP;
251
+ this.camera.y = snapCamera
252
+ ? target.y
253
+ : this.camera.y + (target.y - this.camera.y) * CAMERA_TARGET_LERP;
254
+ const activeScale = snapCamera || this.camera.scale === null
255
+ ? scale
256
+ : this.camera.scale + (scale - this.camera.scale) * CAMERA_SCALE_LERP;
257
+ this.camera.initialized = true;
258
+ this.camera.scale = activeScale;
259
+ worldLayer.scale.set(activeScale);
260
+ worldLayer.position.set(
261
+ frame.screenX - this.camera.x * activeScale,
262
+ frame.screenY - this.camera.y * activeScale,
263
+ );
264
+ }
265
+ }