@inventure71/paddockjs 2.0.1 → 4.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 (136) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/INSTALL_AND_UPDATE.md +24 -4
  3. package/README.md +84 -512
  4. package/docs/architecture.md +38 -24
  5. package/docs/component_inventory.md +72 -0
  6. package/docs/composable-layouts.md +77 -0
  7. package/docs/concepts.md +2 -2
  8. package/docs/custom_model_controller.md +6 -6
  9. package/docs/data-helpers.md +50 -0
  10. package/docs/data_contract.md +104 -27
  11. package/docs/getting-started.md +75 -0
  12. package/docs/headless-environment.md +68 -0
  13. package/docs/index.md +18 -4
  14. package/docs/learnings.md +6 -6
  15. package/docs/rules.md +12 -8
  16. package/docs/sense_contract.md +1 -1
  17. package/docs/system_specs.md +71 -21
  18. package/docs/theming.md +64 -0
  19. package/docs/training.md +26 -19
  20. package/docs/troubleshooting.md +76 -0
  21. package/docs/upgrading-hosts-to-3.md +42 -0
  22. package/examples/python/README.md +6 -0
  23. package/examples/python/base_policy_server.py +19 -0
  24. package/package.json +10 -3
  25. package/src/api/PaddockSimulatorController.js +150 -24
  26. package/src/api/runtimeTheme.js +59 -0
  27. package/src/app/F1SimulatorApp.js +213 -29
  28. package/src/app/banners/raceDataBannerController.js +71 -36
  29. package/src/app/banners/stewardMessageController.js +5 -4
  30. package/src/app/camera/cameraConstants.js +2 -0
  31. package/src/app/camera/cameraController.js +86 -29
  32. package/src/app/camera/cameraGeometry.js +12 -1
  33. package/src/app/domBindings.js +89 -9
  34. package/src/app/layoutScheduler.js +27 -0
  35. package/src/app/layoutSupport.js +165 -0
  36. package/src/app/raceOverlayClearanceSupport.js +158 -0
  37. package/src/app/readouts/carOverviewRenderer.js +16 -8
  38. package/src/app/readouts/controlStateRenderer.js +1 -1
  39. package/src/app/readouts/raceStatusRenderer.js +30 -21
  40. package/src/app/readouts/readoutFormatters.js +2 -0
  41. package/src/app/readouts/telemetryRenderer.js +17 -8
  42. package/src/app/readouts/timingTowerRenderer.js +262 -49
  43. package/src/app/runtime/frameLoop.js +5 -1
  44. package/src/app/runtime/runtimeVisibility.js +66 -5
  45. package/src/config/cssValues.js +16 -0
  46. package/src/config/defaultOptions.js +58 -38
  47. package/src/config/themeOptions.js +830 -0
  48. package/src/config/timingGapMode.js +13 -0
  49. package/src/config/urlOptions.js +25 -0
  50. package/src/data/championship.js +14 -9
  51. package/src/data/index.d.ts +212 -0
  52. package/src/data/index.js +14 -0
  53. package/src/environment/controllerLoop.js +17 -3
  54. package/src/environment/episode.js +11 -1
  55. package/src/environment/index.d.ts +1 -2
  56. package/src/environment/metrics.js +59 -16
  57. package/src/environment/observationVector.js +50 -33
  58. package/src/environment/observations.js +89 -20
  59. package/src/environment/options.js +1 -1
  60. package/src/environment/runtime.js +44 -11
  61. package/src/environment/scenarios.js +1 -1
  62. package/src/environment/sensors/carRays.js +48 -37
  63. package/src/environment/sensors/index.js +501 -97
  64. package/src/environment/sensors/indexedRayBands.js +134 -46
  65. package/src/environment/sensors/rayBandTrace.js +1171 -0
  66. package/src/environment/sensors/rayChannels.js +108 -0
  67. package/src/environment/sensors/rayConfig.js +71 -25
  68. package/src/environment/sensors/rayGeometry.js +23 -17
  69. package/src/environment/sensors/sensorTargets.js +67 -1
  70. package/src/environment/sensors/surfaceRays.js +167 -118
  71. package/src/environment/sensors/trackRays.js +157 -48
  72. package/src/index.d.ts +153 -9
  73. package/src/index.js +52 -3
  74. package/src/simulation/collisionGeometry.js +260 -96
  75. package/src/simulation/driver/driverController.js +1 -1
  76. package/src/simulation/driver/edgeRecovery.js +1 -1
  77. package/src/simulation/driver/racingControls.js +2 -2
  78. package/src/simulation/driver/rejoinControls.js +2 -2
  79. package/src/simulation/pit/pitFlow.js +21 -9
  80. package/src/simulation/pit/pitKinematics.js +2 -2
  81. package/src/simulation/pit/pitQueue.js +5 -3
  82. package/src/simulation/pit/pitRouteMovement.js +9 -4
  83. package/src/simulation/pit/pitRouting.js +79 -46
  84. package/src/simulation/pit/pitState.js +1 -0
  85. package/src/simulation/pit/pitTireService.js +7 -3
  86. package/src/simulation/race/raceFinish.js +25 -8
  87. package/src/simulation/race/raceOrder.js +189 -32
  88. package/src/simulation/race/raceProgress.js +129 -37
  89. package/src/simulation/race/raceSetup.js +4 -6
  90. package/src/simulation/race/raceStep.js +26 -14
  91. package/src/simulation/race/safetyCar.js +1 -1
  92. package/src/simulation/race/stalledDnf.js +1 -1
  93. package/src/simulation/raceSimulation.js +20 -17
  94. package/src/simulation/rules/collisionSteward.js +40 -23
  95. package/src/simulation/rules/rulesReview.js +77 -56
  96. package/src/simulation/snapshots/raceSnapshots.js +37 -0
  97. package/src/simulation/timing/drsTiming.js +143 -30
  98. package/src/simulation/timing/gapEstimation.js +25 -3
  99. package/src/simulation/timing/lapTelemetry.js +138 -40
  100. package/src/simulation/timing/raceTiming.js +5 -1
  101. package/src/simulation/timing/sectorPerformance.js +104 -26
  102. package/src/simulation/timing/timingHistory.js +229 -14
  103. package/src/simulation/timing/timingLines.js +65 -7
  104. package/src/simulation/track/pitLaneLayout.js +30 -3
  105. package/src/simulation/track/pitLaneState.js +218 -19
  106. package/src/simulation/track/spatialQueries.js +66 -33
  107. package/src/simulation/track/trackMath.js +91 -24
  108. package/src/simulation/track/trackModel.js +2 -0
  109. package/src/simulation/track/trackQueryIndex.js +1468 -189
  110. package/src/simulation/track/trackQueryPitIndex.js +138 -14
  111. package/src/simulation/track/trackQueryScratch.js +7 -0
  112. package/src/simulation/track/trackQueryStats.js +25 -0
  113. package/src/simulation/track/trackSnapshotJson.js +139 -0
  114. package/src/simulation/track/trackStatePolicy.js +249 -2
  115. package/src/simulation/vehicle/contactResolution.js +99 -62
  116. package/src/simulation/vehicle/mainTrackWheelSurface.js +73 -34
  117. package/src/simulation/vehicle/pitWheelSurface.js +81 -28
  118. package/src/simulation/vehicle/runoffResponse.js +54 -29
  119. package/src/simulation/vehicle/vehicleGeometry.js +245 -51
  120. package/src/simulation/vehicle/vehicleKinematics.js +2 -2
  121. package/src/simulation/vehicle/vehiclePhysics.js +45 -30
  122. package/src/simulation/vehicle/vehicleSnapshots.js +35 -1
  123. package/src/simulation/vehicle/vehicleState.js +7 -1
  124. package/src/simulation/vehicle/wheelSurface.js +807 -92
  125. package/src/simulation/warmup/runtimeWarmup.js +0 -1
  126. package/src/styles.css +1568 -519
  127. package/src/ui/bannerTemplates.js +38 -28
  128. package/src/ui/cameraControlsTemplate.js +16 -5
  129. package/src/ui/carOverviewTemplate.js +12 -5
  130. package/src/ui/raceCanvasTemplate.js +31 -8
  131. package/src/ui/raceControlsTemplate.js +27 -10
  132. package/src/ui/shellTemplate.js +3 -1
  133. package/src/ui/telemetryDrawerTemplate.js +23 -10
  134. package/src/ui/telemetryTemplates.js +58 -32
  135. package/src/ui/templateUtils.js +48 -0
  136. package/src/ui/timingTowerTemplate.js +29 -12
@@ -1,5 +1,12 @@
1
- import { setText } from '../domBindings.js';
2
- import { escapeHtml, formatLapGap, formatRaceGap, getTireClass } from './readoutFormatters.js';
1
+ import { getNextTimingGapMode, getTimingGapModeLabel, normalizeTimingGapMode } from '../../config/timingGapMode.js';
2
+ import { setText, setTextAll } from '../domBindings.js';
3
+ import {
4
+ escapeHtml,
5
+ formatCssColor,
6
+ formatLapGap,
7
+ formatRaceGap,
8
+ getTireClass,
9
+ } from './readoutFormatters.js';
3
10
 
4
11
  function formatPenaltyHeadline(penalty) {
5
12
  const serviceType = penalty?.serviceType;
@@ -104,12 +111,206 @@ export function getTimingOrderKey(cars = [], { timingGapMode = 'interval' } = {}
104
111
  }
105
112
 
106
113
  export function syncTimingGapModeControls({ readouts, buttons, timingGapMode }) {
107
- const label = timingGapMode === 'leader' ? 'Gap' : 'Int';
108
- setText(readouts.timingGapLabel, label);
114
+ const normalizedMode = normalizeTimingGapMode(timingGapMode);
115
+ const label = getTimingGapModeLabel(normalizedMode);
116
+ const nextLabel = getTimingGapModeLabel(getNextTimingGapMode(normalizedMode));
117
+ setTextAll(readouts.timingGapLabels ?? readouts.timingGapLabel, label);
109
118
  buttons.forEach((button) => {
110
- const active = button.dataset.timingGapMode === timingGapMode;
111
- button.setAttribute('aria-pressed', String(active));
112
- button.classList?.toggle?.('is-active', active);
119
+ const leaderMode = normalizedMode === 'leader';
120
+ setText(button, label);
121
+ button.setAttribute('aria-pressed', String(leaderMode));
122
+ button.setAttribute('aria-label', `Timing gap mode ${label}; switch to ${nextLabel}`);
123
+ button.setAttribute('title', `Switch to ${nextLabel}`);
124
+ button.classList?.toggle?.('is-active', leaderMode);
125
+ });
126
+ }
127
+
128
+ function timingGapForCar(car, raceMode, timingGapMode) {
129
+ if (isDnfCar(car)) return 'DNF';
130
+ if (isWavedFlagCar(car) && raceMode !== 'finished') return 'WAVED';
131
+ if (raceMode === 'finished') return car.rank === 1 ? 'Winner' : 'FIN';
132
+ if (raceMode === 'safety-car' && car.rank > 1) return 'SC';
133
+ if (raceMode === 'pre-start') return car.rank === 1 ? 'Pole' : 'Grid';
134
+ if (car.rank > 1) return formatRaceGap(car, timingGapMode);
135
+ return 'Leader';
136
+ }
137
+
138
+ function timingRowModel({ car, driver, raceMode, selectedId, timingGapMode, penalty }) {
139
+ const dnf = isDnfCar(car);
140
+ const tire = car.tire ?? driver?.tire ?? 'M';
141
+ const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
142
+ const team = car.team ?? driver?.team ?? null;
143
+ const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
144
+ const driverColor = formatCssColor(car.color);
145
+ const iconColor = formatCssColor(team?.color ?? car.color, driverColor);
146
+ const penaltyLabel = penalty ? formatPenaltyBadgeLabel(penalty) : '';
147
+
148
+ return {
149
+ id: car.id,
150
+ rank: car.rank,
151
+ name: car.name,
152
+ selected: car.id === selectedId,
153
+ dnf,
154
+ gap: timingGapForCar(car, raceMode, timingGapMode),
155
+ tire,
156
+ tireClass: getTireClass(tire),
157
+ timingCode,
158
+ icon,
159
+ driverColor,
160
+ iconColor,
161
+ penaltyLabel,
162
+ };
163
+ }
164
+
165
+ function timingRowKey(row) {
166
+ return [
167
+ row.id,
168
+ row.rank,
169
+ row.name,
170
+ row.selected ? 1 : 0,
171
+ row.dnf ? 1 : 0,
172
+ row.gap,
173
+ row.tire,
174
+ row.tireClass,
175
+ row.timingCode,
176
+ row.icon,
177
+ row.driverColor,
178
+ row.iconColor,
179
+ row.penaltyLabel,
180
+ ].join('\u001f');
181
+ }
182
+
183
+ function timingRowsKey(rows) {
184
+ return rows.map(timingRowKey).join('\u001e');
185
+ }
186
+
187
+ function renderTimingRowMarkup(row) {
188
+ const penaltyBadge = row.penaltyLabel
189
+ ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(row.penaltyLabel)}" title="${escapeHtml(row.penaltyLabel)}">!</span>`
190
+ : '';
191
+
192
+ return `
193
+ <li>
194
+ <button class="timing-row ${row.selected ? 'is-selected' : ''} ${row.dnf ? 'is-dnf' : ''}" type="button"
195
+ data-driver-id="${escapeHtml(row.id)}" aria-label="Select ${escapeHtml(row.name)}"
196
+ style="--driver-color: ${escapeHtml(row.driverColor)}">
197
+ <span class="timing-position">${row.rank}</span>
198
+ <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(row.iconColor)}">${escapeHtml(row.icon)}</span>
199
+ <span class="timing-name" title="${escapeHtml(row.name)}"><span>${escapeHtml(row.timingCode)}</span>${penaltyBadge}</span>
200
+ <span class="timing-gap">${escapeHtml(row.gap)}</span>
201
+ <span class="timing-tire timing-tire--${row.tireClass}">${escapeHtml(row.tire)}</span>
202
+ </button>
203
+ </li>
204
+ `;
205
+ }
206
+
207
+ function canPatchTimingRows(timingList) {
208
+ return Boolean(
209
+ timingList?.ownerDocument?.createElement &&
210
+ typeof timingList.appendChild === 'function' &&
211
+ timingList.children,
212
+ );
213
+ }
214
+
215
+ function createTimingRowView(document) {
216
+ const item = document.createElement('li');
217
+ const button = document.createElement('button');
218
+ const position = document.createElement('span');
219
+ const icon = document.createElement('span');
220
+ const name = document.createElement('span');
221
+ const code = document.createElement('span');
222
+ const gap = document.createElement('span');
223
+ const tire = document.createElement('span');
224
+
225
+ item.appendChild(button);
226
+ button.appendChild(position);
227
+ button.appendChild(icon);
228
+ button.appendChild(name);
229
+ name.appendChild(code);
230
+ button.appendChild(gap);
231
+ button.appendChild(tire);
232
+
233
+ return {
234
+ item,
235
+ button,
236
+ position,
237
+ icon,
238
+ name,
239
+ code,
240
+ gap,
241
+ tire,
242
+ penaltyBadge: null,
243
+ };
244
+ }
245
+
246
+ function setStyleProperty(element, name, value) {
247
+ if (typeof element.style?.setProperty === 'function') {
248
+ element.style.setProperty(name, value);
249
+ } else {
250
+ element.setAttribute?.('style', `${name}: ${value}`);
251
+ }
252
+ }
253
+
254
+ function updateTimingRowView(view, row) {
255
+ view.button.className = `timing-row ${row.selected ? 'is-selected' : ''} ${row.dnf ? 'is-dnf' : ''}`;
256
+ view.button.setAttribute('type', 'button');
257
+ view.button.setAttribute('data-driver-id', row.id);
258
+ view.button.setAttribute('aria-label', `Select ${row.name}`);
259
+ setStyleProperty(view.button, '--driver-color', row.driverColor);
260
+
261
+ view.position.className = 'timing-position';
262
+ view.position.textContent = String(row.rank);
263
+
264
+ view.icon.className = 'timing-icon timing-team-icon';
265
+ view.icon.setAttribute('aria-hidden', 'true');
266
+ setStyleProperty(view.icon, '--team-color', row.iconColor);
267
+ view.icon.textContent = String(row.icon ?? '');
268
+
269
+ view.name.className = 'timing-name';
270
+ view.name.setAttribute('title', row.name);
271
+ view.code.textContent = String(row.timingCode ?? '');
272
+
273
+ if (row.penaltyLabel) {
274
+ if (!view.penaltyBadge) {
275
+ view.penaltyBadge = view.name.ownerDocument.createElement('span');
276
+ view.name.appendChild(view.penaltyBadge);
277
+ }
278
+ view.penaltyBadge.className = 'timing-penalty-badge';
279
+ view.penaltyBadge.setAttribute('aria-label', row.penaltyLabel);
280
+ view.penaltyBadge.setAttribute('title', row.penaltyLabel);
281
+ view.penaltyBadge.textContent = '!';
282
+ } else if (view.penaltyBadge) {
283
+ view.penaltyBadge.remove();
284
+ view.penaltyBadge = null;
285
+ }
286
+
287
+ view.gap.className = 'timing-gap';
288
+ view.gap.textContent = String(row.gap);
289
+
290
+ view.tire.className = `timing-tire timing-tire--${row.tireClass}`;
291
+ view.tire.textContent = String(row.tire ?? '');
292
+ }
293
+
294
+ function patchTimingRows(timingList, rows) {
295
+ const rowViews = timingList.__paddockTimingRowViews ?? new Map();
296
+ timingList.__paddockTimingRowViews = rowViews;
297
+ const seen = new Set();
298
+
299
+ rows.forEach((row) => {
300
+ let view = rowViews.get(row.id);
301
+ if (!view) {
302
+ view = createTimingRowView(timingList.ownerDocument);
303
+ rowViews.set(row.id, view);
304
+ }
305
+ seen.add(row.id);
306
+ updateTimingRowView(view, row);
307
+ timingList.appendChild(view.item);
308
+ });
309
+
310
+ rowViews.forEach((view, id) => {
311
+ if (seen.has(id)) return;
312
+ view.item.remove();
313
+ rowViews.delete(id);
113
314
  });
114
315
  }
115
316
 
@@ -126,52 +327,64 @@ export function renderTimingTower({
126
327
  }) {
127
328
  if (!timingList) return lastTimingMarkup;
128
329
  const penaltyByDriver = timingPenaltyBadgesEnabled ? getPenaltyByDriver(penalties) : new Map();
129
- const timingMarkup = cars.map((car) => {
130
- const driver = driverById.get(car.id);
131
- const dnf = isDnfCar(car);
132
- let gap = 'Leader';
133
- if (dnf) {
134
- gap = 'DNF';
135
- } else if (isWavedFlagCar(car) && raceMode !== 'finished') {
136
- gap = 'WAVED';
137
- } else if (raceMode === 'finished') {
138
- gap = car.rank === 1 ? 'Winner' : 'FIN';
139
- } else if (raceMode === 'safety-car' && car.rank > 1) {
140
- gap = 'SC';
141
- } else if (raceMode === 'pre-start') {
142
- gap = car.rank === 1 ? 'Pole' : 'Grid';
143
- } else if (car.rank > 1) {
144
- gap = formatRaceGap(car, timingGapMode);
145
- }
146
- const tire = car.tire ?? driver?.tire ?? 'M';
147
- const timingCode = car.timingCode ?? driver?.timingCode ?? car.code;
148
- const team = car.team ?? driver?.team ?? null;
149
- const icon = team?.icon ?? car.icon ?? driver?.icon ?? timingCode;
150
- const iconColor = team?.color ?? car.color;
151
- const penalty = penaltyByDriver.get(car.id);
152
- const penaltyBadge = penalty
153
- ? `<span class="timing-penalty-badge" aria-label="${escapeHtml(formatPenaltyBadgeLabel(penalty))}" title="${escapeHtml(formatPenaltyBadgeLabel(penalty))}">!</span>`
154
- : '';
155
-
156
- return `
157
- <li>
158
- <button class="timing-row ${car.id === selectedId ? 'is-selected' : ''} ${dnf ? 'is-dnf' : ''}" type="button"
159
- data-driver-id="${escapeHtml(car.id)}" aria-label="Select ${escapeHtml(car.name)}"
160
- style="--driver-color: ${escapeHtml(car.color)}">
161
- <span class="timing-position">${car.rank}</span>
162
- <span class="timing-icon timing-team-icon" aria-hidden="true" style="--team-color: ${escapeHtml(iconColor)}">${escapeHtml(icon)}</span>
163
- <span class="timing-name" title="${escapeHtml(car.name)}"><span>${escapeHtml(timingCode)}</span>${penaltyBadge}</span>
164
- <span class="timing-gap">${escapeHtml(gap)}</span>
165
- <span class="timing-tire timing-tire--${getTireClass(tire)}">${escapeHtml(tire)}</span>
166
- </button>
167
- </li>
168
- `;
169
- }).join('');
170
- if (timingMarkup !== lastTimingMarkup) {
330
+ const rows = cars.map((car) => timingRowModel({
331
+ car,
332
+ driver: driverById.get(car.id),
333
+ raceMode,
334
+ selectedId,
335
+ timingGapMode,
336
+ penalty: penaltyByDriver.get(car.id),
337
+ }));
338
+ if (canPatchTimingRows(timingList)) {
339
+ patchTimingRows(timingList, rows);
340
+ return timingRowsKey(rows);
341
+ }
342
+
343
+ const timingMarkup = rows.map(renderTimingRowMarkup).join('');
344
+ if (timingMarkup !== lastTimingMarkup || timingList.innerHTML !== timingMarkup) {
171
345
  timingList.innerHTML = timingMarkup;
172
346
  return timingMarkup;
173
347
  }
174
348
  return lastTimingMarkup;
175
349
  }
176
350
 
351
+ export function renderTimingTowers({
352
+ timingLists,
353
+ timingList,
354
+ cars,
355
+ raceMode,
356
+ penalties = [],
357
+ driverById,
358
+ selectedId,
359
+ timingGapMode,
360
+ timingPenaltyBadgesEnabled,
361
+ lastTimingMarkup,
362
+ }) {
363
+ const candidateLists = typeof timingLists?.forEach === 'function'
364
+ ? [...timingLists]
365
+ : [];
366
+ const lists = candidateLists.length > 0
367
+ ? candidateLists
368
+ : timingList
369
+ ? [timingList]
370
+ : [];
371
+ if (lists.length === 0) return lastTimingMarkup;
372
+
373
+ let nextTimingMarkup = lastTimingMarkup;
374
+ lists.forEach((list) => {
375
+ nextTimingMarkup = renderTimingTower({
376
+ timingList: list,
377
+ cars,
378
+ raceMode,
379
+ penalties,
380
+ driverById,
381
+ selectedId,
382
+ timingGapMode,
383
+ timingPenaltyBadgesEnabled,
384
+ lastTimingMarkup: nextTimingMarkup,
385
+ });
386
+ });
387
+ return nextTimingMarkup;
388
+ }
389
+
177
390
  export { formatLapGap, formatRaceGap };
@@ -48,7 +48,11 @@ export function runFrameLoopTick(app) {
48
48
  const shouldUpdateDom = now - app.lastDomUpdateTime >= domUpdateIntervalForSpeed(app.simulationSpeed);
49
49
  const fullSnapshot = stepEvents.length > 0 || shouldUpdateDom ? app.sim.snapshot() : null;
50
50
  if (stepEvents.length > 0) app.emitRaceEvents(stepEvents, fullSnapshot);
51
- const renderSource = fullSnapshot ?? app.sim.snapshotRender?.() ?? app.sim.snapshot();
51
+ const renderSource = fullSnapshot ??
52
+ app.sim.snapshotRenderInto?.(app.renderSourceSnapshotBuffer ?? {}) ??
53
+ app.sim.snapshotRender?.() ??
54
+ app.sim.snapshot();
55
+ if (!fullSnapshot && app.sim.snapshotRenderInto) app.renderSourceSnapshotBuffer = renderSource;
52
56
  const renderSnapshot = interpolateRenderSnapshotInto(
53
57
  app.renderSnapshotBuffer ?? {},
54
58
  renderSource,
@@ -1,11 +1,31 @@
1
1
  export function observeRuntimeVisibility(app) {
2
2
  if (!app.canvasHost) return;
3
3
 
4
+ const syncViewportVisibility = () => {
5
+ app.runtimeViewportVisible = isRuntimeViewportVisible(app.canvasHost);
6
+ app.syncRuntimeTicker();
7
+ };
8
+ const scheduleViewportVisibilitySync = () => {
9
+ if (app.runtimeVisibilitySyncFrame) return;
10
+ if (typeof globalThis.requestAnimationFrame !== 'function') {
11
+ syncViewportVisibility();
12
+ return;
13
+ }
14
+ app.runtimeVisibilitySyncFrame = globalThis.requestAnimationFrame(() => {
15
+ app.runtimeVisibilitySyncFrame = null;
16
+ syncViewportVisibility();
17
+ });
18
+ };
19
+
4
20
  if (typeof IntersectionObserver === 'function') {
5
21
  app.visibilityObserver?.disconnect?.();
6
22
  app.visibilityObserver = new IntersectionObserver((entries) => {
7
23
  const entry = entries.find((item) => item.target === app.canvasHost) ?? entries[0];
8
- app.runtimeViewportVisible = Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0);
24
+ app.runtimeViewportVisible = Boolean(
25
+ entry?.isIntersecting ||
26
+ entry?.intersectionRatio > 0 ||
27
+ isRuntimeViewportVisible(app.canvasHost),
28
+ );
9
29
  app.syncRuntimeTicker();
10
30
  }, {
11
31
  root: null,
@@ -25,21 +45,62 @@ export function observeRuntimeVisibility(app) {
25
45
  });
26
46
  }
27
47
 
48
+ syncViewportVisibility();
49
+ globalThis.addEventListener?.('scroll', scheduleViewportVisibilitySync, {
50
+ passive: true,
51
+ signal: app.abortController.signal,
52
+ });
53
+ globalThis.addEventListener?.('resize', scheduleViewportVisibilitySync, {
54
+ passive: true,
55
+ signal: app.abortController.signal,
56
+ });
57
+ globalThis.addEventListener?.('focus', syncViewportVisibility, {
58
+ signal: app.abortController.signal,
59
+ });
60
+ globalThis.addEventListener?.('pageshow', syncViewportVisibility, {
61
+ signal: app.abortController.signal,
62
+ });
28
63
  app.syncRuntimeTicker();
29
64
  }
30
65
 
31
66
  export function syncRuntimeTicker(app) {
32
67
  if (app.expertMode) return;
33
- if (!app.app?.ticker) return;
68
+ const ticker = app.app?.ticker;
69
+ if (!ticker) return;
34
70
 
35
71
  const shouldRun = app.runtimeViewportVisible && app.runtimeDocumentVisible;
36
- if (shouldRun === app.runtimeTickerRunning) return;
72
+ const tickerRunning = typeof ticker.started === 'boolean'
73
+ ? ticker.started
74
+ : app.runtimeTickerRunning;
75
+ if (shouldRun === app.runtimeTickerRunning && shouldRun === tickerRunning) return;
37
76
 
38
77
  app.runtimeTickerRunning = shouldRun;
39
78
  if (shouldRun) {
40
79
  app.resetFrameClock();
41
- app.app.ticker.start?.();
80
+ ticker.start?.();
42
81
  } else {
43
- app.app.ticker.stop?.();
82
+ ticker.stop?.();
44
83
  }
45
84
  }
85
+
86
+ function isRuntimeViewportVisible(element) {
87
+ const rect = element?.getBoundingClientRect?.();
88
+ if (!rect) return true;
89
+
90
+ const top = Number(rect.top);
91
+ const bottom = Number(rect.bottom);
92
+ const left = Number(rect.left);
93
+ const right = Number(rect.right);
94
+ const width = Number(rect.width ?? right - left);
95
+ const height = Number(rect.height ?? bottom - top);
96
+ if (![top, bottom, left, right, width, height].every(Number.isFinite)) return true;
97
+ if (width <= 0 || height <= 0) return false;
98
+
99
+ const viewportHeight = globalThis.innerHeight || globalThis.document?.documentElement?.clientHeight || 0;
100
+ const viewportWidth = globalThis.innerWidth || globalThis.document?.documentElement?.clientWidth || 0;
101
+ const margin = 240;
102
+ return bottom >= -margin &&
103
+ top <= viewportHeight + margin &&
104
+ right >= -margin &&
105
+ left <= viewportWidth + margin;
106
+ }
@@ -0,0 +1,16 @@
1
+ import { normalizeCssColorToken } from './themeOptions.js';
2
+
3
+ export const DEFAULT_SAFE_DRIVER_COLOR = '#e10600';
4
+
5
+ export function formatCssColor(value, fallback = DEFAULT_SAFE_DRIVER_COLOR) {
6
+ return normalizeCssColorToken(value, fallback);
7
+ }
8
+
9
+ export function formatCssUrl(value) {
10
+ const raw = String(value ?? '');
11
+ const escaped = raw
12
+ .replaceAll('\\', '\\\\')
13
+ .replaceAll('"', '\\"')
14
+ .replace(/[\u0000-\u001F\u007F]/g, '');
15
+ return `url("${escaped}")`;
16
+ }
@@ -3,11 +3,18 @@ import { CHAMPIONSHIP_ENTRY_BLUEPRINTS } from '../data/championship.js';
3
3
  import { normalizeSimulatorDrivers } from '../data/normalizeDrivers.js';
4
4
  import { normalizePhysicsMode } from '../simulation/vehicle/vehiclePhysics.js';
5
5
  import { normalizeWarmupOptions } from '../simulation/warmup/runtimeWarmup.js';
6
+ import {
7
+ DEFAULT_PADDOCK_THEME_INPUT,
8
+ applyPaddockThemeCssVariables,
9
+ mergeThemeInputs,
10
+ normalizePaddockTheme,
11
+ } from './themeOptions.js';
12
+ import { normalizeTimingGapMode } from './timingGapMode.js';
13
+ import { normalizePublicUrlOption } from './urlOptions.js';
6
14
 
7
15
  export const DEFAULT_F1_SIMULATOR_OPTIONS = {
8
16
  seed: 1971,
9
17
  physicsMode: 'arcade',
10
- trackQueryIndex: true,
11
18
  warmup: {
12
19
  enabled: true,
13
20
  policy: 'config-change',
@@ -34,23 +41,22 @@ export const DEFAULT_F1_SIMULATOR_OPTIONS = {
34
41
  },
35
42
  showRaceDataPanel: true,
36
43
  raceDataTelemetryDetail: false,
44
+ driverCamera: false,
37
45
  raceDataBanners: {
38
46
  initial: 'project',
39
47
  enabled: ['project', 'radio'],
40
48
  },
41
49
  raceDataBannerSize: 'custom',
42
50
  timingTowerVerticalFit: 'expand-race-view',
51
+ timingGapMode: 'interval',
52
+ timingGapModeToggle: true,
53
+ timingEntryVerticalPadding: 5,
54
+ responsiveNarrowLayout: true,
43
55
  },
44
56
  debug: {
45
57
  physicsModeIndicator: false,
46
58
  },
47
- theme: {
48
- accentColor: '#e10600',
49
- greenColor: '#14c784',
50
- yellowColor: '#ffd166',
51
- timingTowerMaxWidth: '390px',
52
- raceViewMinHeight: '620px',
53
- },
59
+ theme: DEFAULT_PADDOCK_THEME_INPUT,
54
60
  };
55
61
 
56
62
  export const PADDOCK_SIMULATOR_PRESETS = {
@@ -113,15 +119,21 @@ export const PADDOCK_SIMULATOR_PRESETS = {
113
119
  },
114
120
  };
115
121
 
116
- export const PADDOCK_THEME_CSS_VARIABLES = {
117
- accentColor: '--paddock-accent-color',
118
- greenColor: '--paddock-green-color',
119
- yellowColor: '--paddock-yellow-color',
120
- timingTowerMaxWidth: '--paddock-timing-tower-max-width',
121
- raceViewMinHeight: '--paddock-race-view-min-height',
122
- };
122
+ export {
123
+ DEFAULT_PADDOCK_THEME,
124
+ PADDOCK_THEME_CSS_VARIABLES,
125
+ PADDOCK_THEME_TOKEN_KEYS,
126
+ applyPaddockTheme,
127
+ applyPaddockThemeCssVariables,
128
+ resolvePaddockTheme,
129
+ } from './themeOptions.js';
123
130
 
124
- const SUPPORTED_CAMERA_MODES = new Set(['overview', 'leader', 'selected', 'show-all', 'pit']);
131
+ const SUPPORTED_CAMERA_MODES = new Set(['overview', 'leader', 'selected', 'driver', 'show-all', 'pit']);
132
+
133
+ function normalizeNonNegativeFiniteNumber(value, fallback) {
134
+ const numericValue = Number(value);
135
+ return Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : fallback;
136
+ }
125
137
 
126
138
  export function resolveF1SimulatorOptions(options = {}) {
127
139
  const presetName = Object.hasOwn(PADDOCK_SIMULATOR_PRESETS, options.preset)
@@ -130,6 +142,9 @@ export function resolveF1SimulatorOptions(options = {}) {
130
142
  const preset = presetName ? PADDOCK_SIMULATOR_PRESETS[presetName] : {};
131
143
  const requestedUi = options.ui ?? {};
132
144
  const presetUi = preset.ui ?? {};
145
+ const initialCameraMode = SUPPORTED_CAMERA_MODES.has(options.initialCameraMode)
146
+ ? options.initialCameraMode
147
+ : DEFAULT_F1_SIMULATOR_OPTIONS.initialCameraMode;
133
148
  const ui = {
134
149
  ...DEFAULT_F1_SIMULATOR_OPTIONS.ui,
135
150
  ...presetUi,
@@ -140,6 +155,7 @@ export function resolveF1SimulatorOptions(options = {}) {
140
155
  ...(requestedUi.raceDataBanners ?? {}),
141
156
  },
142
157
  };
158
+ ui.driverCamera = Boolean(ui.driverCamera || initialCameraMode === 'driver');
143
159
  ui.raceDataBanners.enabled = normalizeEnabledBanners(ui.raceDataBanners.enabled);
144
160
  ui.telemetryModules = normalizeTelemetryModules(ui.telemetryModules);
145
161
  if (!['project', 'radio', 'hidden'].includes(ui.raceDataBanners.initial)) {
@@ -151,6 +167,13 @@ export function resolveF1SimulatorOptions(options = {}) {
151
167
  if (!['expand-race-view', 'scroll'].includes(ui.timingTowerVerticalFit)) {
152
168
  ui.timingTowerVerticalFit = DEFAULT_F1_SIMULATOR_OPTIONS.ui.timingTowerVerticalFit;
153
169
  }
170
+ ui.timingGapMode = normalizeTimingGapMode(ui.timingGapMode);
171
+ ui.timingGapModeToggle = ui.timingGapModeToggle !== false;
172
+ ui.timingEntryVerticalPadding = normalizeNonNegativeFiniteNumber(
173
+ ui.timingEntryVerticalPadding,
174
+ DEFAULT_F1_SIMULATOR_OPTIONS.ui.timingEntryVerticalPadding,
175
+ );
176
+ ui.responsiveNarrowLayout = ui.responsiveNarrowLayout !== false;
154
177
  if (!['auto', 'custom'].includes(ui.raceDataBannerSize)) {
155
178
  ui.raceDataBannerSize = DEFAULT_F1_SIMULATOR_OPTIONS.ui.raceDataBannerSize;
156
179
  }
@@ -161,28 +184,30 @@ export function resolveF1SimulatorOptions(options = {}) {
161
184
  ...(options.debug ?? {}),
162
185
  };
163
186
  debug.physicsModeIndicator = Boolean(debug.physicsModeIndicator);
164
- const initialCameraMode = SUPPORTED_CAMERA_MODES.has(options.initialCameraMode)
165
- ? options.initialCameraMode
166
- : DEFAULT_F1_SIMULATOR_OPTIONS.initialCameraMode;
167
187
  const mergedOptions = {
168
188
  ...DEFAULT_F1_SIMULATOR_OPTIONS,
169
189
  ...preset,
170
190
  ...options,
171
191
  };
172
- const theme = normalizeTheme({
173
- ...DEFAULT_F1_SIMULATOR_OPTIONS.theme,
174
- ...(preset.theme ?? {}),
175
- ...(options.theme ?? {}),
176
- });
177
192
  const drivers = normalizeSimulatorDrivers(mergedOptions.drivers, {
178
193
  entries: mergedOptions.entries ?? CHAMPIONSHIP_ENTRY_BLUEPRINTS,
179
194
  });
195
+ const theme = normalizePaddockTheme(mergeThemeInputs(
196
+ DEFAULT_F1_SIMULATOR_OPTIONS.theme,
197
+ preset.theme,
198
+ collectDriverTeamThemes(drivers),
199
+ options.theme,
200
+ ));
180
201
 
181
202
  return {
182
203
  ...DEFAULT_F1_SIMULATOR_OPTIONS,
183
204
  ...preset,
184
205
  ...options,
185
206
  preset: presetName ?? options.preset,
207
+ backLinkHref: normalizePublicUrlOption(
208
+ mergedOptions.backLinkHref,
209
+ DEFAULT_F1_SIMULATOR_OPTIONS.backLinkHref,
210
+ ),
186
211
  physicsMode: normalizePhysicsMode(options.physicsMode ?? preset.physicsMode),
187
212
  warmup: normalizeWarmupOptions(mergedOptions.warmup, 'browser'),
188
213
  initialCameraMode,
@@ -194,12 +219,16 @@ export function resolveF1SimulatorOptions(options = {}) {
194
219
  };
195
220
  }
196
221
 
197
- export function applyPaddockThemeCssVariables(root, theme = DEFAULT_F1_SIMULATOR_OPTIONS.theme) {
198
- Object.entries(PADDOCK_THEME_CSS_VARIABLES).forEach(([key, variable]) => {
199
- const value = theme?.[key];
200
- if (value == null || value === '') return;
201
- root?.style?.setProperty?.(variable, String(value));
222
+ function collectDriverTeamThemes(drivers = []) {
223
+ const teamThemes = {};
224
+ drivers.forEach((driver) => {
225
+ const teamId = driver?.team?.id;
226
+ const theme = driver?.team?.theme;
227
+ if (typeof teamId === 'string' && teamId !== '' && typeof theme === 'string' && theme !== '') {
228
+ teamThemes[teamId] = theme;
229
+ }
202
230
  });
231
+ return Object.keys(teamThemes).length > 0 ? { teamThemes } : {};
203
232
  }
204
233
 
205
234
  function normalizeEnabledBanners(value) {
@@ -226,12 +255,3 @@ function normalizeTelemetryModules(value) {
226
255
  value[name] == null ? defaults[name] : Boolean(value[name]),
227
256
  ]));
228
257
  }
229
-
230
- function normalizeTheme(theme) {
231
- return Object.fromEntries(
232
- Object.entries(DEFAULT_F1_SIMULATOR_OPTIONS.theme).map(([key, fallback]) => [
233
- key,
234
- theme?.[key] == null || theme?.[key] === '' ? fallback : theme[key],
235
- ]),
236
- );
237
- }